mirror of
https://github.com/misode/misode.github.io.git
synced 2026-04-24 07:37:10 +00:00
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:
@@ -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">
|
||||
|
||||
57
src/app/components/ItemTooltip.tsx
Normal file
57
src/app/components/ItemTooltip.tsx
Normal 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)
|
||||
}
|
||||
129
src/app/components/TextComponent.tsx
Normal file
129
src/app/components/TextComponent.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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
|
||||
|
||||
79
src/app/components/previews/LootTablePreview.tsx
Normal file
79
src/app/components/previews/LootTablePreview.tsx
Normal 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}%`,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user