Add loot table preview (#293)

* Initial loot table preview + item display counts

* Add loot functions without NBT

* Add loot conditions

* Render item tooltips with name and lore components

* Remove debug text component

* Disable advanced tooltips in the tree

* Minor style fixes

* Add item slot overlay and tweak tooltip offset

* Fix some items not rendering

* Translate item names and text components

* Translate params + more functions and tooltips

* Add durability bar

* Configurable stack mixing

* Correct tooltip background and border

* Add enchanting

* Enchantment glint

* Configurable luck, daytime and weather

* Improve tooltip spacing

* More tooltip spacing improvements

* Remove debug logging
This commit is contained in:
Misode
2022-10-13 02:05:33 +02:00
committed by GitHub
parent 86687ea6b9
commit ac259bb83e
24 changed files with 1630 additions and 56 deletions

View File

@@ -1,55 +1,100 @@
import { useState } from 'preact/hooks'
import { useEffect, useRef, useState } from 'preact/hooks'
import { useVersion } from '../contexts/Version.jsx'
import { useAsync } from '../hooks/useAsync.js'
import type { Item } from '../previews/LootTable.js'
import { MaxDamageItems } from '../previews/LootTable.js'
import { getAssetUrl } from '../services/DataFetcher.js'
import { renderItem } from '../services/Resources.js'
import { getCollections } from '../services/Schemas.js'
import { ItemTooltip } from './ItemTooltip.jsx'
import { Octicon } from './Octicon.jsx'
interface Props {
item: string,
item: Item,
slotDecoration?: boolean,
advancedTooltip?: boolean,
}
export function ItemDisplay({ item }: Props) {
export function ItemDisplay({ item, slotDecoration, advancedTooltip }: Props) {
const el = useRef<HTMLDivElement>(null)
const [tooltipOffset, setTooltipOffset] = useState<[number, number]>([0, 0])
const [tooltipSwap, setTooltipSwap] = useState(false)
useEffect(() => {
const onMove = (e: MouseEvent) => {
requestAnimationFrame(() => {
const { right, width } = el.current!.getBoundingClientRect()
const swap = right + 200 > document.body.clientWidth
setTooltipSwap(swap)
setTooltipOffset([(swap ? width - e.offsetX : e.offsetX) + 20, e.offsetY - 40])
})
}
el.current?.addEventListener('mousemove', onMove)
return () => el.current?.removeEventListener('mousemove', onMove)
}, [])
const maxDamage = MaxDamageItems.get(item.id)
return <div class="item-display" ref={el}>
<ItemItself item={item} />
{item.count !== 1 && <>
<svg class="item-count" width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="xMinYMid meet">
<text x="95" y="93" font-size="50" textAnchor="end" fontFamily="MinecraftSeven" fill="#373737">{item.count}</text>
<text x="90" y="88" font-size="50" textAnchor="end" fontFamily="MinecraftSeven" fill="#ffffff">{item.count}</text>
</svg>
</>}
{slotDecoration && <>
{(maxDamage && (item.tag?.Damage ?? 0) > 0) && <svg class="item-durability" width="100%" height="100%" viewBox="0 0 18 18">
<rect x="3" y="14" width="13" height="2" fill="#000" />
<rect x="3" y="14" width={`${(maxDamage - item.tag.Damage) / maxDamage * 13}`} height="1" fill={`hsl(${(maxDamage - item.tag.Damage) / maxDamage * 120}deg, 100%, 50%)`} />
</svg>}
<div class="item-slot-overlay"></div>
</>}
<ItemTooltip {...item} advanced={advancedTooltip} offset={tooltipOffset} swap={tooltipSwap} />
</div>
}
function ItemItself({ item }: Props) {
const { version } = useVersion()
const [errored, setErrored] = useState(false)
if (errored || (item.includes(':') && !item.startsWith('minecraft:'))) {
return <div class="item-display">
{Octicon.package}
</div>
const isEnchanted = (item.tag?.Enchantments?.length ?? 0) > 0 || (item.tag?.StoredEnchantments?.length ?? 0) > 0
if (errored || (item.id.includes(':') && !item.id.startsWith('minecraft:'))) {
return Octicon.package
}
const { value: collections } = useAsync(() => getCollections(version), [])
if (collections === undefined) {
return <div class="item-display"></div>
return null
}
const texturePath = `item/${item.replace(/^minecraft:/, '')}`
const texturePath = `item/${item.id.replace(/^minecraft:/, '')}`
if (collections.get('texture').includes('minecraft:' + texturePath)) {
return <div class="item-display">
<img src={getAssetUrl(version, 'textures', texturePath)} alt="" onError={() => setErrored(true)} />
</div>
const src = getAssetUrl(version, 'textures', texturePath)
return <>
<img src={src} alt="" onError={() => setErrored(true)} draggable={false} />
{isEnchanted && <div class="item-glint" style={{'--mask-image': `url("${src}")`}}></div>}
</>
}
const modelPath = `block/${item.replace(/^minecraft:/, '')}`
const modelPath = `item/${item.id.replace(/^minecraft:/, '')}`
if (collections.get('model').includes('minecraft:' + modelPath)) {
return <div class="item-display">
<RenderedItem item={item} />
</div>
return <RenderedItem item={item} isEnchanted={isEnchanted} />
}
return <div class="item-display">
{Octicon.package}
</div>
return Octicon.package
}
function RenderedItem({ item }: Props) {
function RenderedItem({ item, isEnchanted }: Props & { isEnchanted: boolean }) {
const { version } = useVersion()
const { value: src } = useAsync(() => renderItem(version, item), [version, item])
const { value: src } = useAsync(() => renderItem(version, item.id), [version, item])
if (src) {
return <img src={src} alt={item} />
return <>
<img src={src} alt={item.id} class="model" draggable={false} />
{isEnchanted && <div class="item-glint" style={{'--mask-image': `url("${src}")`}}></div>}
</>
}
return <div class="item-display">

View File

@@ -0,0 +1,57 @@
import { useVersion } from '../contexts/Version.jsx'
import { useAsync } from '../hooks/useAsync.js'
import { getEnchantmentData, MaxDamageItems } from '../previews/LootTable.js'
import { getTranslation } from '../services/Resources.js'
import { TextComponent } from './TextComponent.jsx'
interface Props {
id: string,
tag?: any,
advanced?: boolean,
offset?: [number, number],
swap?: boolean,
}
export function ItemTooltip({ id, tag, advanced, offset = [0, 0], swap }: Props) {
const { version } = useVersion()
const { value: translatedName } = useAsync(() => {
const key = id.split(':').join('.')
return getTranslation(version, `item.${key}`) ?? getTranslation(version, `block.${key}`)
}, [version, id])
const displayName = tag?.display?.Name
const name = displayName ? JSON.parse(displayName) : (translatedName ?? fakeTranslation(id))
const maxDamage = MaxDamageItems.get(id)
return <div class="item-tooltip" style={offset && {
left: (swap ? undefined : `${offset[0]}px`),
right: (swap ? `${offset[0]}px` : undefined),
top: `${offset[1]}px`,
}}>
<TextComponent component={name} base={{ color: 'white' }} />
{tag?.Enchantments?.map(({ id, lvl }: { id: string, lvl: number }) => {
const ench = getEnchantmentData(id)
const component: any[] = [{ translate: `enchantment.${id.replace(':', '.')}`, color: ench?.curse ? 'red' : 'gray' }]
if (lvl !== 1 || ench?.maxLevel !== 1) {
component.push(' ', { translate: `enchantment.level.${lvl}`})
}
return <TextComponent component={component} />
})}
{tag?.display && <>
{tag?.display?.color && (advanced
? <TextComponent component={{ translate: 'item.color', with: [`#${tag.display.color.toString(16).padStart(6, '0')}`], color: 'gray' }} />
: <TextComponent component={{ translate: 'item.dyed', color: 'gray' }} />)}
{(tag?.display?.Lore ?? []).map((line: any) => <TextComponent component={JSON.parse(line)} base={{ color: 'dark_purple', italic: true }} />)}
</>}
{tag?.Unbreakable === true && <TextComponent component={{ translate: 'item.unbreakable', color: 'blue' }} />}
{(advanced && (tag?.Damage ?? 0) > 0 && maxDamage) && <TextComponent component={{ translate: 'item.durability', with: [`${maxDamage - tag.Damage}`, `${maxDamage}`] }} />}
{advanced && <>
<TextComponent component={{ text: id, color: 'dark_gray'}} />
{tag && <TextComponent component={{ translate: 'item.nbt_tags', with: [Object.keys(tag).length], color: 'dark_gray' }} />}
</>}
</div>
}
function fakeTranslation(str: string) {
const raw = str.replace(/minecraft:/, '').replaceAll('_', ' ')
return raw[0].toUpperCase() + raw.slice(1)
}

View File

@@ -0,0 +1,129 @@
import { useMemo } from 'preact/hooks'
import { useVersion } from '../contexts/Version.jsx'
import { useAsync } from '../hooks/useAsync.js'
import { getTranslation } from '../services/Resources.js'
interface StyleData {
color?: string,
bold?: boolean,
italic?: boolean,
underlined?: boolean,
strikethrough?: boolean,
}
interface PartData extends StyleData {
text?: string,
translate?: string,
with?: string[],
}
interface Props {
component: unknown,
base?: StyleData,
shadow?: boolean,
}
export function TextComponent({ component, base = { color: 'white' }, shadow = true }: Props) {
const state = JSON.stringify(component)
const parts = useMemo(() => {
const parts: PartData[] = []
visitComponent(component, el => parts.push(el))
return parts
}, [state])
return <div class="text-component">
{shadow && <div style={createStyle(base, true)}>
{parts.map(p => <TextPart part={p} shadow={true} />)}
</div>}
<div class="text-foreground" style={createStyle(base, false)}>
{parts.map(p => <TextPart part={p} />)}
</div>
</div>
}
function visitComponent(component: unknown, consumer: (c: PartData) => void) {
if (typeof component === 'string' || typeof component === 'number') {
consumer({ text: component.toString() })
} else if (Array.isArray(component)) {
const base = component[0]
visitComponent(base, consumer)
for (const c of component.slice(1)) {
visitComponent(c, d => consumer(inherit(d, base)))
}
} else if (typeof component === 'object' && component !== null) {
if ('text' in component) {
consumer(component)
} else if ('translate' in component) {
consumer(component)
} else if ('score' in component) {
consumer({ ...component, text: '123' })
} else if ('selector' in component) {
consumer({ ...component, text: 'Steve' })
} else if ('keybind' in component) {
consumer({ ...component, text: (component as any).keybind })
} else if ('nbt' in component) {
consumer({ ...component, text: (component as any).nbt })
}
if ('extra' in component) {
for (const e of (component as any).extra) {
visitComponent(e, c => consumer(inherit(c, component)))
}
}
}
}
function inherit(component: object, base: PartData) {
return {
color: base.color,
bold: base.bold,
italic: base.italic,
underlined: base.underlined,
strikethrough: base.strikethrough,
...component,
}
}
const TextColors = {
black: ['#000', '#000'],
dark_blue: ['#00A', '#00002A'],
dark_green: ['#0A0', '#002A00'],
dark_aqua: ['#0AA', '#002A2A'],
dark_red: ['#A00', '#2A0000'],
dark_purple: ['#A0A', '#2A002A'],
gold: ['#FA0', '#2A2A00'],
gray: ['#AAA', '#2A2A2A'],
dark_gray: ['#555', '#151515'],
blue: ['#55F', '#15153F'],
green: ['#5F5', '#153F15'],
aqua: ['#5FF', '#153F3F'],
red: ['#F55', '#3F1515'],
light_purple: ['#F5F', '#3F153F'],
yellow: ['#FF5', '#3F3F15'],
white: ['#FFF', '#3F3F3F'],
}
type TextColorKey = keyof typeof TextColors
const TextColorKeys = Object.keys(TextColors)
function TextPart({ part, shadow }: { part: PartData, shadow?: boolean }) {
if (part.translate) {
const { version } = useVersion()
const { value: translated } = useAsync(() => {
return getTranslation(version, part.translate!, part.with)
}, [version, part.translate, ...part.with ?? []])
return <span style={createStyle(part, shadow)}>{translated ?? part.translate}</span>
}
return <span style={createStyle(part, shadow)}>{part.text}</span>
}
function createStyle(style: StyleData, shadow?: boolean) {
return {
color: style.color && (TextColorKeys.includes(style.color)
? TextColors[style.color as TextColorKey][shadow ? 1 : 0]
: shadow ? 'transparent' : style.color),
fontWeight: (style.bold === true) ? 'bold' : undefined,
fontStyle: (style.italic === true) ? 'italic' : undefined,
textDecoration: (style.underlined === true)
? (style.strikethrough === true) ? 'underline line-through' : 'underline'
: (style.strikethrough === true) ? 'line-through' : undefined,
}
}

View File

@@ -5,8 +5,9 @@ import { useModel } from '../../hooks/index.js'
import type { VersionId } from '../../services/index.js'
import { checkVersion } from '../../services/index.js'
import { BiomeSourcePreview, DecoratorPreview, DensityFunctionPreview, NoisePreview, NoiseSettingsPreview } from '../previews/index.js'
import { LootTablePreview } from '../previews/LootTablePreview.jsx'
export const HasPreview = ['dimension', 'worldgen/density_function', 'worldgen/noise', 'worldgen/noise_settings', 'worldgen/configured_feature', 'worldgen/placed_feature']
export const HasPreview = ['loot_table', 'dimension', 'worldgen/density_function', 'worldgen/noise', 'worldgen/noise_settings', 'worldgen/configured_feature', 'worldgen/placed_feature']
type PreviewPanelProps = {
model: DataModel | undefined,
@@ -24,6 +25,11 @@ export function PreviewPanel({ model, version, id, shown }: PreviewPanelProps) {
if (!model) return <></>
if (id === 'loot_table') {
const data = model.get(new Path([]))
if (data) return <LootTablePreview {...{ model, version, shown, data }} />
}
if (id === 'dimension' && model.get(new Path(['generator', 'type']))?.endsWith('noise')) {
const data = model.get(new Path(['generator', 'biome_source']))
if (data) return <BiomeSourcePreview {...{ model, version, shown, data }} />

View File

@@ -63,8 +63,6 @@ export const BiomeSourcePreview = ({ model, data, shown, version }: PreviewProps
}
}, [version, state, scale, seed, yOffset, shown, biomeColors, project])
console.log(yOffset)
const changeScale = (newScale: number) => {
newScale = Math.max(1, Math.round(newScale))
offset.current[0] = offset.current[0] * scale / newScale

View File

@@ -0,0 +1,79 @@
import { DataModel } from '@mcschema/core'
import { useEffect, useRef, useState } from 'preact/hooks'
import { useLocale, useVersion } from '../../contexts/index.js'
import type { SlottedItem } from '../../previews/LootTable.js'
import { generateLootTable } from '../../previews/LootTable.js'
import { clamp, randomSeed } from '../../Utils.js'
import { Btn, BtnMenu, NumberInput } from '../index.js'
import { ItemDisplay } from '../ItemDisplay.jsx'
import type { PreviewProps } from './index.js'
export const LootTablePreview = ({ data }: PreviewProps) => {
const { locale } = useLocale()
const { version } = useVersion()
const [seed, setSeed] = useState(randomSeed())
const [luck, setLuck] = useState(0)
const [daytime, setDaytime] = useState(0)
const [weather, setWeather] = useState('clear')
const [mixItems, setMixItems] = useState(true)
const [advancedTooltips, setAdvancedTooltips] = useState(true)
const overlay = useRef<HTMLDivElement>(null)
const [items, setItems] = useState<SlottedItem[]>([])
const table = DataModel.unwrapLists(data)
const state = JSON.stringify(table)
useEffect(() => {
const items = generateLootTable(table, { version, seed, luck, daytime, weather, stackMixer: mixItems ? 'container' : 'default' })
setItems(items)
}, [version, seed, luck, daytime, weather, mixItems, state])
return <>
<div ref={overlay} class="preview-overlay">
<img src="/images/container.png" alt="Container background" class="pixelated" draggable={false} />
{items.map(({ slot, item }) =>
<div key={slot} style={slotStyle(slot)}>
<ItemDisplay item={item} slotDecoration={true} advancedTooltip={advancedTooltips} />
</div>
)}
</div>
<div class="controls preview-controls">
<BtnMenu icon="gear" tooltip={locale('settings')} >
<div class="btn btn-input" onClick={e => e.stopPropagation()}>
<span>{locale('preview.luck')}</span>
<NumberInput value={luck} onChange={setLuck} />
</div>
<div class="btn btn-input" onClick={e => e.stopPropagation()}>
<span>{locale('preview.daytime')}</span>
<NumberInput value={daytime} onChange={setDaytime} />
</div>
<div class="btn btn-input" onClick={e => e.stopPropagation()}>
<span>{locale('preview.weather')}</span>
<select value={weather} onChange={e => setWeather((e.target as HTMLSelectElement).value)} >
{['clear', 'rain', 'thunder'].map(v =>
<option value={v}>{locale(`preview.weather.${v}`)}</option>)}
</select>
</div>
<Btn icon={mixItems ? 'square_fill' : 'square'} label="Fill container randomly" onClick={e => {setMixItems(!mixItems); e.stopPropagation()}} />
<Btn icon={advancedTooltips ? 'square_fill' : 'square'} label="Advanced tooltips" onClick={e => {setAdvancedTooltips(!advancedTooltips); e.stopPropagation()}} />
</BtnMenu>
<Btn icon="sync" tooltip={locale('generate_new_seed')} onClick={() => setSeed(randomSeed())} />
</div>
</>
}
const GUI_WIDTH = 176
const GUI_HEIGHT = 81
const SLOT_SIZE = 18
function slotStyle(slot: number) {
slot = clamp(slot, 0, 26)
const x = (slot % 9) * SLOT_SIZE + 7
const y = (Math.floor(slot / 9)) * SLOT_SIZE + 20
return {
left: `${x*100/GUI_WIDTH}%`,
top: `${y*100/GUI_HEIGHT}%`,
width: `${SLOT_SIZE*100/GUI_WIDTH}%`,
height: `${SLOT_SIZE*100/GUI_HEIGHT}%`,
}
}