Update loot table preview, item display and tooltips to 1.21

This commit is contained in:
Misode
2024-09-11 02:31:17 +02:00
parent 337b7d9b0a
commit fd6de2ac85
15 changed files with 2073 additions and 408 deletions

56
package-lock.json generated
View File

@@ -30,9 +30,10 @@
"brace": "^0.11.1",
"buffer": "^6.0.3",
"comment-json": "^4.1.1",
"deepslate": "^0.18.0",
"deepslate-1.18": "npm:deepslate@^0.9.0-beta.9",
"deepslate-1.18.2": "npm:deepslate@^0.9.0-beta.13",
"deepslate": "^0.21.0",
"deepslate-1.18": "npm:deepslate@0.9.0-beta.9",
"deepslate-1.18.2": "npm:deepslate@0.9.0",
"deepslate-1.20.4": "npm:deepslate@0.20.1",
"highlight.js": "^11.5.1",
"howler": "^2.2.3",
"js-yaml": "^3.14.1",
@@ -1628,9 +1629,9 @@
"dev": true
},
"node_modules/deepslate": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.18.0.tgz",
"integrity": "sha512-mip9lv9ka0ksdaQ6OrfwQHFyr171vtTrmy1FAIWWy+owWbGnYiXioIS6uX4jEeQ9MMaWrBXwKHuPu5Hv+H7E3w==",
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.21.0.tgz",
"integrity": "sha512-Km1JotTlko37L81N3fKfxEerHwVhF3+c+sCUMF3X6gxNHsiG0RSjRpNY63pxuRahxsmyuOyFjgQhQKH2hd2alg==",
"dependencies": {
"gl-matrix": "^3.3.0",
"md5": "^2.3.0",
@@ -1660,6 +1661,17 @@
"pako": "^2.0.3"
}
},
"node_modules/deepslate-1.20.4": {
"name": "deepslate",
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.20.1.tgz",
"integrity": "sha512-jY1PsO2uRfrUz3WsgSsv/tpetZuLQpTCs1TZB2bOJTREqyBIJOHiD9s2Q26Q1VcFTaA55YXHbHcTujAsKWI8Vw==",
"dependencies": {
"gl-matrix": "^3.3.0",
"md5": "^2.3.0",
"pako": "^2.0.3"
}
},
"node_modules/define-lazy-prop": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
@@ -3055,12 +3067,12 @@
}
},
"node_modules/micromatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"dependencies": {
"braces": "^3.0.2",
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
@@ -5453,9 +5465,9 @@
"dev": true
},
"deepslate": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.18.0.tgz",
"integrity": "sha512-mip9lv9ka0ksdaQ6OrfwQHFyr171vtTrmy1FAIWWy+owWbGnYiXioIS6uX4jEeQ9MMaWrBXwKHuPu5Hv+H7E3w==",
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.21.0.tgz",
"integrity": "sha512-Km1JotTlko37L81N3fKfxEerHwVhF3+c+sCUMF3X6gxNHsiG0RSjRpNY63pxuRahxsmyuOyFjgQhQKH2hd2alg==",
"requires": {
"gl-matrix": "^3.3.0",
"md5": "^2.3.0",
@@ -5482,6 +5494,16 @@
"pako": "^2.0.3"
}
},
"deepslate-1.20.4": {
"version": "npm:deepslate@0.20.1",
"resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.20.1.tgz",
"integrity": "sha512-jY1PsO2uRfrUz3WsgSsv/tpetZuLQpTCs1TZB2bOJTREqyBIJOHiD9s2Q26Q1VcFTaA55YXHbHcTujAsKWI8Vw==",
"requires": {
"gl-matrix": "^3.3.0",
"md5": "^2.3.0",
"pako": "^2.0.3"
}
},
"define-lazy-prop": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
@@ -6426,12 +6448,12 @@
"dev": true
},
"micromatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"requires": {
"braces": "^3.0.2",
"braces": "^3.0.3",
"picomatch": "^2.3.1"
}
},

View File

@@ -36,9 +36,10 @@
"brace": "^0.11.1",
"buffer": "^6.0.3",
"comment-json": "^4.1.1",
"deepslate": "^0.18.0",
"deepslate-1.18": "npm:deepslate@^0.9.0-beta.9",
"deepslate-1.18.2": "npm:deepslate@^0.9.0-beta.13",
"deepslate-1.18": "npm:deepslate@0.9.0-beta.9",
"deepslate-1.18.2": "npm:deepslate@0.9.0",
"deepslate-1.20.4": "npm:deepslate@0.20.1",
"deepslate": "^0.21.0",
"highlight.js": "^11.5.1",
"howler": "^2.2.3",
"js-yaml": "^3.14.1",

View File

@@ -1,8 +1,8 @@
import type { DataModel } from '@mcschema/core'
import { Path } from '@mcschema/core'
import * as zip from '@zip.js/zip.js'
import type { Random } from 'deepslate'
import { Matrix3, Matrix4, Vector } from 'deepslate'
import type { Identifier, NbtTag, Random } from 'deepslate'
import { Matrix3, Matrix4, NbtByte, NbtCompound, NbtDouble, NbtInt, NbtList, NbtString, Vector } from 'deepslate'
import type { mat3 } from 'gl-matrix'
import { quat, vec2 } from 'gl-matrix'
import yaml from 'js-yaml'
@@ -593,3 +593,45 @@ export function genPath(gen: ConfigGenerator, version: VersionId) {
}
return path
}
export function jsonToNbt(value: unknown): NbtTag {
if (typeof value === 'string') {
return new NbtString(value)
}
if (typeof value === 'number') {
return Number.isInteger(value) ? new NbtInt(value) : new NbtDouble(value)
}
if (typeof value === 'boolean') {
return new NbtByte(value)
}
if (Array.isArray(value)) {
return new NbtList(value.map(jsonToNbt))
}
if (typeof value === 'object' && value !== null) {
return new NbtCompound(
new Map(Object.entries(value ?? {})
.map(([k, v]) => [k, jsonToNbt(v)]))
)
}
return new NbtByte(0)
}
export function mergeTextComponentStyles(text: unknown, style: Record<string, unknown>) {
if (typeof text === 'string') {
return { ...style, text }
}
if (Array.isArray(text)) {
return { ...style, ...text[0], extra: text.slice(1) }
}
if (typeof text === 'object' && text !== null) {
return { ...style, ...text }
}
return { ...style, text: '' }
}
export function makeDescriptionId(prefix: string, id: Identifier | undefined) {
if (id === undefined) {
return `${prefix}.unregistered_sadface`
}
return `${prefix}.${id.namespace}.${id.path.replaceAll('/', '.')}`
}

View File

@@ -1,13 +1,15 @@
import type { ItemStack } from 'deepslate/core'
import { Identifier } from 'deepslate/core'
import { useEffect, useRef, useState } from 'preact/hooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
import { useVersion } from '../contexts/Version.jsx'
import { useAsync } from '../hooks/useAsync.js'
import { fetchItemComponents } from '../services/index.js'
import { ResolvedItem } from '../services/ResolvedItem.js'
import { renderItem } from '../services/Resources.js'
import { getCollections } from '../services/Schemas.js'
import { jsonToNbt } from '../Utils.js'
import { ItemTooltip } from './ItemTooltip.jsx'
import { Octicon } from './Octicon.jsx'
import { itemHasGlint } from './previews/LootTable.js'
interface Props {
item: ItemStack,
@@ -16,6 +18,7 @@ interface Props {
advancedTooltip?: boolean,
}
export function ItemDisplay({ item, slotDecoration, tooltip, advancedTooltip }: Props) {
const { version } = useVersion()
const el = useRef<HTMLDivElement>(null)
const [tooltipOffset, setTooltipOffset] = useState<[number, number]>([0, 0])
const [tooltipSwap, setTooltipSwap] = useState(false)
@@ -33,10 +36,20 @@ export function ItemDisplay({ item, slotDecoration, tooltip, advancedTooltip }:
return () => el.current?.removeEventListener('mousemove', onMove)
}, [])
const maxDamage = item.getItem().durability
const { value: baseComponents } = useAsync(() => fetchItemComponents(version), [version])
const itemResolver = useCallback((item: ItemStack) => {
const base = baseComponents?.get(item.id.toString()) ?? new Map()
return new ResolvedItem(item, new Map([...base.entries()].map(([k, v]) => [k, jsonToNbt(v)])))
}, [baseComponents])
const resolvedItem = useMemo(() => {
return itemResolver(item)
}, [item, baseComponents])
const maxDamage = resolvedItem.getMaxDamage()
const damage = resolvedItem.getDamage()
return <div class="item-display" ref={el}>
<ItemItself item={item} />
<ItemItself item={resolvedItem} />
{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>
@@ -44,9 +57,9 @@ export function ItemDisplay({ item, slotDecoration, tooltip, advancedTooltip }:
</svg>
</>}
{slotDecoration && <>
{(maxDamage && item.tag.getNumber('Damage') > 0) && <svg class="item-durability" width="100%" height="100%" viewBox="0 0 18 18">
{(maxDamage > 0 && damage > 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.getNumber('Damage')) / maxDamage * 13}`} height="1" fill={`hsl(${(maxDamage - item.tag.getNumber('Damage')) / maxDamage * 120}deg, 100%, 50%)`} />
<rect x="3" y="14" width={`${(maxDamage - damage) / maxDamage * 13}`} height="1" fill={`hsl(${(maxDamage - damage) / maxDamage * 120}deg, 100%, 50%)`} />
</svg>}
<div class="item-slot-overlay"></div>
</>}
@@ -55,16 +68,17 @@ export function ItemDisplay({ item, slotDecoration, tooltip, advancedTooltip }:
right: (tooltipSwap ? `${tooltipOffset[0]}px` : undefined),
top: `${tooltipOffset[1]}px`,
}}>
<ItemTooltip item={item} advanced={advancedTooltip} />
<ItemTooltip item={resolvedItem} advanced={advancedTooltip} resolver={itemResolver} />
</div>}
</div>
}
function ItemItself({ item }: Props) {
interface ResolvedProps extends Props {
item: ResolvedItem
}
function ItemItself({ item }: ResolvedProps) {
const { version } = useVersion()
const hasGlint = itemHasGlint(item)
if (item.id.namespace !== Identifier.DEFAULT_NAMESPACE) {
return Octicon.package
}
@@ -77,20 +91,20 @@ function ItemItself({ item }: Props) {
const modelPath = `item/${item.id.path}`
if (collections.get('model').includes('minecraft:' + modelPath)) {
return <RenderedItem item={item} hasGlint={hasGlint} />
return <RenderedItem item={item} />
}
return Octicon.package
}
function RenderedItem({ item, hasGlint }: Props & { hasGlint: boolean }) {
function RenderedItem({ item }: ResolvedProps) {
const { version } = useVersion()
const { value: src } = useAsync(() => renderItem(version, item), [version, item])
const { value: src } = useAsync(() => renderItem(version, item.flatten()), [version, item])
if (src) {
return <>
<img src={src} alt={item.id.toString()} class="model" draggable={false} />
{hasGlint && <div class="item-glint" style={{'--mask-image': `url("${src}")`}}></div>}
{item.hasFoil() && <div class="item-glint" style={{'--mask-image': `url("${src}")`}}></div>}
</>
}

View File

@@ -0,0 +1,100 @@
import type { ItemStack } from 'deepslate-1.20.4/core'
import { Identifier } from 'deepslate-1.20.4/core'
import { useEffect, useRef, useState } from 'preact/hooks'
import { useVersion } from '../contexts/Version.jsx'
import { useAsync } from '../hooks/useAsync.js'
import { renderItem } from '../services/Resources1204.js'
import { getCollections } from '../services/Schemas.js'
import { ItemTooltip1204 } from './ItemTooltip1204.jsx'
import { Octicon } from './Octicon.jsx'
import { itemHasGlint } from './previews/LootTable1204.js'
interface Props {
item: ItemStack,
slotDecoration?: boolean,
tooltip?: boolean,
advancedTooltip?: boolean,
}
export function ItemDisplay1204({ item, slotDecoration, tooltip, 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 = item.getItem().durability
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.getNumber('Damage') > 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.getNumber('Damage')) / maxDamage * 13}`} height="1" fill={`hsl(${(maxDamage - item.tag.getNumber('Damage')) / maxDamage * 120}deg, 100%, 50%)`} />
</svg>}
<div class="item-slot-overlay"></div>
</>}
{tooltip !== false && <div class="item-tooltip" style={tooltipOffset && {
left: (tooltipSwap ? undefined : `${tooltipOffset[0]}px`),
right: (tooltipSwap ? `${tooltipOffset[0]}px` : undefined),
top: `${tooltipOffset[1]}px`,
}}>
<ItemTooltip1204 item={item} advanced={advancedTooltip} />
</div>}
</div>
}
function ItemItself({ item }: Props) {
const { version } = useVersion()
const hasGlint = itemHasGlint(item)
if (item.id.namespace !== Identifier.DEFAULT_NAMESPACE) {
return Octicon.package
}
const { value: collections } = useAsync(() => getCollections(version), [])
if (collections === undefined) {
return null
}
const modelPath = `item/${item.id.path}`
if (collections.get('model').includes('minecraft:' + modelPath)) {
return <RenderedItem item={item} hasGlint={hasGlint} />
}
return Octicon.package
}
function RenderedItem({ item, hasGlint }: Props & { hasGlint: boolean }) {
const { version } = useVersion()
const { value: src } = useAsync(() => renderItem(version, item), [version, item])
if (src) {
return <>
<img src={src} alt={item.id.toString()} class="model" draggable={false} />
{hasGlint && <div class="item-glint" style={{'--mask-image': `url("${src}")`}}></div>}
</>
}
return <div class="item-display">
{Octicon.package}
</div>
}

View File

@@ -1,137 +1,296 @@
import type { ItemStack } from 'deepslate/core'
import { AttributeModifierOperation, Enchantment, Identifier, MobEffectInstance, Potion } from 'deepslate/core'
import { NbtList, NbtType } from 'deepslate/nbt'
import { useLocale } from '../contexts/Locale.jsx'
import { useVersion } from '../contexts/Version.jsx'
import { useAsync } from '../hooks/useAsync.js'
import { getLanguage, getTranslation } from '../services/Resources.js'
import { message } from '../Utils.js'
import type { MobEffectInstance, NbtTag } from 'deepslate'
import { ItemStack, NbtCompound, NbtList, PotionContents } from 'deepslate'
import { Identifier } from 'deepslate/core'
import type { ResolvedItem } from '../services/ResolvedItem.js'
import { makeDescriptionId, mergeTextComponentStyles } from '../Utils.js'
import { TextComponent } from './TextComponent.jsx'
interface Props {
item: ItemStack,
item: ResolvedItem,
advanced?: boolean,
resolver: (item: ItemStack) => ResolvedItem,
}
export function ItemTooltip({ item, advanced }: Props) {
const { version } = useVersion()
const { lang } = useLocale()
const { value: language } = useAsync(() => getLanguage(version, lang), [version, lang])
const isPotion = item.is('potion') || item.is('splash_potion') || item.is('lingering_potion')
let displayName = item.tag.getCompound('display').getString('Name')
let name: string | undefined
if (displayName) {
try {
name = JSON.parse(displayName)
} catch (e) {
console.warn(`Error parsing display name '${displayName}': ${message(e)}`)
displayName = ''
}
export function ItemTooltip({ item, advanced, resolver }: Props) {
if (item.has('hide_tooltip')) {
return <></>
}
if (name === undefined) {
if (language) {
let descriptionId = `${item.id.namespace}.${item.id.path}`
if (isPotion) {
descriptionId = `${descriptionId}.effect.${Potion.fromNbt(item).name}`
}
name = getTranslation(language, `item.${descriptionId}`)
name ??= getTranslation(language, `block.${descriptionId}`)
}
name ??= item.id.path
.replace(/[_\/]/g, ' ')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
const lore: any[] = []
item.tag.getCompound('display').getList('Lore', NbtType.String).forEach((line) => {
try {
lore.push(JSON.parse(line['value']))
} catch (e) {
console.warn(`Error parsing lore line '${line}': ${message(e)}`)
}
})
const durability = item.getItem().durability
const enchantments = (item.is('enchanted_book') ? item.tag.getList('StoredEnchantments', NbtType.Compound) : item.tag.getList('Enchantments', NbtType.Compound)) ?? NbtList.create()
const effects = isPotion ? Potion.getAllEffects(item) : []
const attributeModifiers = isPotion ? Potion.getAllAttributeModifiers(item) : []
return <>
<TextComponent component={name} base={{ color: 'white', italic: displayName.length > 0 }} />
{shouldShow(item, 'additional') && <>
{(!advanced && displayName.length === 0 && item.is('filled_map') && item.tag.hasNumber('map')) && <>
<TextComponent component={{ text: `#${item.tag.getNumber('map')}`, color: 'gray' }} />
<TextComponent component={item.getStyledHoverName()} />
{!advanced && !item.has('custom_name') && item.is('filled_map') && item.has('map_id') && (
<TextComponent component={{ translate: 'filled_map.id', with: [item.get('map_id', tag => tag.getAsNumber())], color: 'gray' }} />
)}
{!item.has('hide_additional_tooltip') && <>
{item.is('filled_map') && advanced && (item.get('map_id', tag => tag.isNumber())
? <TextComponent component={{ translate: 'filled_map.id', with: [item.get('map_id', tag => tag.getAsNumber())], color: 'gray' }} />
: <TextComponent component={{ translate: 'filled_map.unknown', color: 'gray' }} />
)}
{(item.id.path.endsWith('_banner') || item.is('shield')) && item.get('banner_patterns', tag => tag.isList() ? tag : [])?.map(layer =>
<TextComponent component={{ translate: `${layer.isCompound() ? (layer.hasCompound('pattern') ? layer.getString('translation_key') : `block.minecraft.banner.${layer.getString('pattern').replace(/^minecraft:/, '')}`) : ''}.${layer.isCompound() ? layer.getString('color') : ''}`, color: 'gray' }} />
)}
{item.is('crossbow') && item.getChargedProjectile() && (
<TextComponent component={{ translate: 'item.minecraft.crossbow.projectile', extra: [' ', resolver(item.getChargedProjectile()!).getDisplayName()] }}/>
)}
{item.is('disc_fragment_5') && (
<TextComponent component={{ translate: `${makeDescriptionId('item', item.id)}.desc`, color: 'gray' }} />
)}
{item.is('firework_rocket') && item.has('fireworks') && <>
{((item.get('fireworks', tag => tag.isCompound() ? tag.getNumber('flight_duration') : 0) ?? 0) > 0) && (
<TextComponent component={{ translate: 'item.minecraft.firework_rocket.flight', extra: [' ', item.get('fireworks', tag => tag.isCompound() ? tag.getNumber('flight_duration') : 0)], color: 'gray'}} />
)}
{/* TODO: firework explosions */}
</>}
{(item.is('filled_map') && advanced) && <>
<TextComponent component={{ translate: 'filled_map.unknown', color: 'gray' }} />
{item.is('firework_star') && item.has('firework_explosion') && (
<TextComponent component={{ translate: `item.minecraft.firework_star.shape.${item.get('firework_explosion', tag => tag.isCompound() ? tag.getString('shape') : '')}`, color: 'gray' }} />
// TODO: additional stuff
)}
{/* TODO: painting variants */}
{item.is('goat_horn') && item.has('instrument') && (
<TextComponent component={mergeTextComponentStyles(item.get('instrument', tag => tag.isCompound()
? tag.get('description')?.toSimplifiedJson()
: { translate: makeDescriptionId('instrument', Identifier.parse(tag.getAsString()))}
), { color: 'gray' })} />
)}
{(item.is('lingering_potion') || item.is('potion') || item.is('splash_potion') || item.is('tipped_arrow')) && (
<PotionContentsTooltip contents={PotionContents.fromNbt(item.get('potion_contents', tag => tag) ?? NbtCompound.create())} factor={item.is('lingering_potion') ? 0.25 : item.is('tipped_arrow') ? 0.125 : 1} />
)}
{/* TODO: mob buckets */}
{/* TODO: smithing templates */}
{item.is('written_book') && item.has('written_book_content') && <>
<TextComponent component={{ translate: 'book.byAuthor', with: [item.get('written_book_content', tag => tag.isCompound() ? tag.getString('author') : undefined) ?? ''], color: 'gray' }} />
<TextComponent component={{ translate: `book.generation.${item.get('written_book_content', tag => tag.isCompound() ? tag.getNumber('generation') : undefined) ?? 0}`, color: 'gray' }} />
</>}
{isPotion && effects.length === 0
? <TextComponent component={{ translate: 'effect.none', color: 'gray' }} />
: effects.map(e => {
const color = e.effect.category === 'harmful' ? 'red' : 'blue'
let component: any = { translate: `effect.${e.effect.id.namespace}.${e.effect.id.path}` }
if (e.amplifier > 0) {
component = { translate: 'potion.withAmplifier', with: [component, { translate: `potion.potency.${e.amplifier}` }] }
}
if (e.duration > 20) {
component = { translate: 'potion.withDuration', with: [component, MobEffectInstance.formatDuration(e)] }
}
return <TextComponent component={{ ...component, color }} />
})}
{attributeModifiers.length > 0 && <>
<TextComponent component='' />
<TextComponent component={{ translate: 'potion.whenDrank', color: 'dark_purple' }} />
{attributeModifiers.map(([attr, { amount, operation }]) => {
const a = operation === AttributeModifierOperation.addition ? amount * 100 : amount
if (amount > 0) {
return <TextComponent component={{ translate: `attribute.modifier.plus.${operation}`, with: [Math.floor(a * 100) / 100, { translate: `attribute.name.${attr.id.path}` }], color: 'blue' }} />
} else if (amount < 0) {
return <TextComponent component={{ translate: `attribute.modifier.take.${operation}`, with: [Math.floor(a * -100) / 100, { translate: `attribute.name.${attr.id.path}` }], color: 'red' }} />
}
return null
{(item.is('beehive') || item.is('bee_nest')) && <>
<TextComponent component={{ translate: 'container.beehive.bees', with: [item.get('bees', tag => tag.isList() ? tag.length : 0) ?? 0, 3], color: 'gray' }} />
<TextComponent component={{ translate: 'container.beehive.honey', with: [item.get('block_state', tag => tag.isCompound() ? tag.getString('honey_level') : 0) ?? 0, 5], color: 'gray' }} />
</>}
{item.is('decorated_pot') && item.has('pot_decorations') && <>
<TextComponent component={''} />
{item.get('pot_decorations', tag => tag.isList() ? tag.map(e =>
<TextComponent component={mergeTextComponentStyles(resolver(new ItemStack(Identifier.parse(e.getAsString()), 1)).getHoverName(), { color: 'gray' })} />
) : undefined)}
</>}
{item.id.path.endsWith('_shulker_box') && <>
{item.has('container_loot') && (
<TextComponent component={{ translate: 'container.shulkerBox.unknownContents' }} />
)}
{(item.get('container', tag => tag.isList() ? tag.getItems() : []) ?? []).slice(0, 5).map(e => {
const subItem = resolver(ItemStack.fromNbt(e.isCompound() ? e.getCompound('item') : new NbtCompound()))
return <TextComponent component={{ translate: 'container.shulkerBox.itemCount', with: [subItem.getHoverName(), subItem.count] }} />
})}
{(item.get('container', tag => tag.isList() ? tag.length : 0) ?? 0) > 5 && (
<TextComponent component={{ translate: 'container.shulkerBox.more', with: [(item.get('container', tag => tag.isList() ? tag.length : 0) ?? 0) - 5], italic: true }} />
)}
</>}
{/* TODO: spawner and trial spawner */}
</>}
{shouldShow(item, 'enchantments') && enchantments.map(enchantment => {
const id = enchantment.getString('id')
const lvl = enchantment.getNumber('lvl')
const ench = Enchantment.REGISTRY.get(Identifier.parse(id))
const component: any[] = [{ translate: `enchantment.${id.replace(':', '.')}`, color: ench?.isCurse ? 'red' : 'gray' }]
if (lvl !== 1 || ench?.maxLevel !== 1) {
component.push(' ', { translate: `enchantment.level.${lvl}`})
}
return <TextComponent component={component} />
})}
{item.tag.hasCompound('display') && <>
{shouldShow(item, 'dye') && item.tag.getCompound('display').hasNumber('color') && (advanced
? <TextComponent component={{ translate: 'item.color', with: [`#${item.tag.getCompound('display').getNumber('color').toString(16).padStart(6, '0')}`], color: 'gray' }} />
: <TextComponent component={{ translate: 'item.dyed', color: 'gray' }} />)}
{lore.map((component) => <TextComponent component={component} base={{ color: 'dark_purple', italic: true }} />)}
{item.showInTooltip('jukebox_playable') && <>
<TextComponent component={mergeTextComponentStyles(item.get('jukebox_playable', tag => tag.isCompound() ? (
tag.hasCompound('song')
? tag.getCompound('song').get('description')?.toSimplifiedJson()
: { translate: makeDescriptionId('jukebox_song', Identifier.parse(tag.getString('song')))}
) : {}) ?? {}, { color: 'gray'})} />
</>}
{shouldShow(item, 'unbreakable') && item.tag.getBoolean('Unbreakable') && <TextComponent component={{ translate: 'item.unbreakable', color: 'blue' }} />}
{(advanced && item.tag.getNumber('Damage') > 0 && durability) && <TextComponent component={{ translate: 'item.durability', with: [`${durability - item.tag.getNumber('Damage')}`, `${durability}`] }} />}
{item.showInTooltip('trim') && <>
<TextComponent component={{ translate: makeDescriptionId('item', Identifier.create('smithing_template.upgrade' )), color: 'gray' }} />
<TextComponent component={{ text: ' ', extra: [item.get('trim', tag => tag.isCompound() ? (
tag.hasCompound('pattern')
? tag.getCompound('pattern').get('description')?.toSimplifiedJson()
: { translate: makeDescriptionId('trim_pattern', Identifier.parse(tag.getString('pattern'))), color: BUILTIN_TRIM_MATERIALS[tag.getString('material').replace(/^minecraft:/, '')] ?? 'gray' }
) : '')] }} />
<TextComponent component={{ text: ' ', extra: [item.get('trim', tag => tag.isCompound() ? (
tag.hasCompound('material')
? tag.getCompound('material').get('description')?.toSimplifiedJson()
: { translate: makeDescriptionId('trim_material', Identifier.parse(tag.getString('material'))), color: BUILTIN_TRIM_MATERIALS[tag.getString('material').replace(/^minecraft:/, '')] ?? 'gray' }
) : '')] }}/>
</>}
{item.showInTooltip('stored_enchantments') && (
<EnchantmentsTooltip data={item.get('stored_enchantments', tag => tag)} />
)}
{item.showInTooltip('enchantments') && (
<EnchantmentsTooltip data={item.get('enchantments', tag => tag)} />
)}
{item.showInTooltip('dyed_color') && (advanced
? <TextComponent component={{ translate: 'item.color', with: [`#${item.get('dyed_color', tag => tag.isCompound() ? tag.getNumber('rgb') : tag.getAsNumber())?.toString(16).padStart(6, '0')}`], color: 'gray' }} />
: <TextComponent component={{ translate: 'item.dyed', color: 'gray' }} />
)}
{item.getLore().map((component) =>
<TextComponent component={JSON.parse(component)} base={{ color: 'dark_purple', italic: true }} />
)}
{item.showInTooltip('attribute_modifiers') && (
<AttributeModifiersTooltip data={item.get('attribute_modifiers', tag => tag)} />
)}
{item.showInTooltip('unbreakable') && (
<TextComponent component={{ translate: 'item.unbreakable', color: 'blue' }} />
)}
{item.has('ominous_bottle_amplifier') && (
<PotionContentsTooltip contents={{ customEffects: [{ effect: Identifier.create('bad_omen'), amplifier: item.get('ominous_bottle_amplifier', tag => tag.getAsNumber()) ?? 0, duration: 120000 }]}} />
)}
{/* TODO: creative-only suspicious stew effects */}
{/* TODO: can break and can place on */}
{advanced && item.isDamageable() && (
<TextComponent component={{ translate: 'item.durability', with: [`${item.getMaxDamage() - item.getDamage()}`, `${item.getMaxDamage()}`] }} />
)}
{advanced && <>
<TextComponent component={{ text: item.id.toString(), color: 'dark_gray'}} />
{item.tag.size > 0 && <TextComponent component={{ translate: 'item.nbt_tags', with: [item.tag.size], color: 'dark_gray' }} />}
{item.getSize() > 0 && <TextComponent component={{ translate: 'item.components', with: [item.getSize()], color: 'dark_gray' }} />}
</>}
</>
}
const TooltipMasks = {
enchantments: 1,
modifiers: 2,
unbreakable: 4,
can_destroy: 8,
can_place: 16,
additional: 32,
dye: 64,
upgrades: 128,
const BUILTIN_TRIM_MATERIALS: Record<string, string | undefined> = {
amethyst: '#9A5CC6',
copper: '#B4684D',
diamond: '#6EECD2',
emerald: '#11A036',
gold: '#DEB12D',
iron: '#ECECEC',
lapis: '#416E97',
netherite: '#625859',
quartz: '#E3D4C4',
redstone: '#971607',
}
function shouldShow(item: ItemStack, mask: keyof typeof TooltipMasks) {
const flags = item.tag.getNumber('HideFlags')
return (flags & TooltipMasks[mask]) === 0
const HARMFUL_EFFECTS = new Set([
'minecraft:slowness',
'minecraft:mining_fatigue',
'minecraft:instant_damage',
'minecraft:nausea',
'minecraft:blindness',
'minecraft:hunger',
'minecraft:weakness',
'minecraft:poison',
'minecraft:wither',
'minecraft:levitation',
'minecraft:unluck',
'minecraft:darkness',
'minecraft:wind_charged',
'minecraft:weaving',
'minecraft:oozing',
'minecraft:infested',
])
function PotionContentsTooltip({ contents, factor }: { contents: PotionContents, factor?: number }) {
const effects = PotionContents.getAllEffects(contents)
return <>
{effects.map(e => {
const color = HARMFUL_EFFECTS.has(e.effect.toString()) ? 'red' : 'blue'
let component: any = { translate: makeDescriptionId('effect', e.effect) }
if (e.amplifier > 0) {
component = { translate: 'potion.withAmplifier', with: [component, { translate: `potion.potency.${e.amplifier}` }] }
}
if (e.duration === -1 || e.duration > 20) {
component = { translate: 'potion.withDuration', with: [component, formatDuration(e, factor ?? 1)] }
}
return <TextComponent component={{ ...component, color }} />
})}
{effects.length === 0 && <TextComponent component={{ translate: 'effect.none', color: 'gray' }} />}
</>
}
function formatDuration(effect: MobEffectInstance, factor: number) {
if (effect.duration === -1) {
return { translate: 'effect.duration.infinite' }
}
const ticks = Math.floor(effect.duration * factor)
let seconds = Math.floor(ticks / 20)
let minutes = Math.floor(seconds / 60)
seconds %= 60
const hours = Math.floor(minutes / 60)
minutes %= 60
return `${hours > 0 ? `${hours}:` : ''}${minutes.toFixed().padStart(2, '0')}:${seconds.toFixed().padStart(2, '0')}`
}
function EnchantmentsTooltip({ data }: { data: NbtTag | undefined }) {
if (!data || !data.isCompound()) {
return <></>
}
const levels = data.hasCompound('levels') ? data.getCompound('levels') : data
return <>
{[...levels.keys()].map((key) => {
const level = levels.getNumber(key)
if (level <= 0) return <></>
const id = Identifier.parse(key)
return <TextComponent component={{ translate: makeDescriptionId('enchantment', id), color: id.path.endsWith('_curse') ? 'red' : 'gray', extra: level === 1 ? [] : [' ', { translate: `enchantment.level.${level}` }] }} />
})}
</>
}
const EQUIPMENT_GROUPS = [
'any',
'mainhand',
'offhand',
'hand',
'feet',
'legs',
'chest',
'head',
'armor',
'body',
]
const MODIFIER_OPERATIONS = [
'add_value',
'add_multiplied_base',
'add_multiplied_total',
]
const NEGATIVE_ATTRIBUTES = new Set([
'minecraft:burning_time',
'minecraft:fall_damage_multiplier',
])
const NEUTRAL_ATTRIBUTES = new Set([
'minecraft:gravity',
'minecraft:scale',
])
function AttributeModifiersTooltip({ data }: { data: NbtTag | undefined }) {
const modifiers = data?.isList() ? data : data?.isCompound() ? data.getList('modifiers') : new NbtList()
return <>
{EQUIPMENT_GROUPS.map(group => {
let first = true
return modifiers.map((e) => {
if (!e.isCompound()) return
const slot = e.has('slot') ? e.getString('slot') : 'any'
if (slot !== group) return
const wasFirst = first
first = false
let amount = e.getNumber('amount')
const type = Identifier.parse(e.getString('type'))
const id = Identifier.parse(e.getString('id'))
const operation = MODIFIER_OPERATIONS.indexOf(e.getString('operation'))
let absolute = false
if (id.equals(Identifier.create('base_attack_damage'))) {
amount += 2
absolute = true
} else if (id.equals(Identifier.create('base_attack_speed'))) {
amount += 4
absolute = true
}
if (operation !== 0) {
amount *= 100
} else if (type.equals(Identifier.create('knockback_resistance'))) {
amount *= 10
}
return <>
{wasFirst && <>
<TextComponent component={''} />
<TextComponent component={{ translate: `item.modifiers.${group}`, color: 'gray' }} />
</>}
{absolute ? (
<TextComponent component={[' ', { translate: `attribute.modifier.equals.${operation}`, with: [+amount.toFixed(2), { translate: `attribute.name.${type.path}`}], color: 'dark_green' }]} />
) : amount > 0 ? (
<TextComponent component={{ translate: `attribute.modifier.plus.${operation}`, with: [+amount.toFixed(2), { translate: `attribute.name.${type.path}`}], color: NEGATIVE_ATTRIBUTES.has(type.toString()) ? 'red' : NEUTRAL_ATTRIBUTES.has(type.toString()) ? 'gray' : 'blue' }} />
) : amount < 0 ? (
<TextComponent component={{ translate: `attribute.modifier.take.${operation}`, with: [+(-amount).toFixed(2), { translate: `attribute.name.${type.path}`}], color: NEGATIVE_ATTRIBUTES.has(type.toString()) ? 'blue' : NEUTRAL_ATTRIBUTES.has(type.toString()) ? 'gray' : 'red'}} />
) : <></>}
</>
})
})}
</>
}

View File

@@ -0,0 +1,137 @@
import type { ItemStack } from 'deepslate-1.20.4/core'
import { AttributeModifierOperation, Enchantment, Identifier, MobEffectInstance, Potion } from 'deepslate-1.20.4/core'
import { NbtList, NbtType } from 'deepslate-1.20.4/nbt'
import { useLocale } from '../contexts/Locale.jsx'
import { useVersion } from '../contexts/Version.jsx'
import { useAsync } from '../hooks/useAsync.js'
import { getLanguage, getTranslation } from '../services/Resources.js'
import { message } from '../Utils.js'
import { TextComponent } from './TextComponent.jsx'
interface Props {
item: ItemStack,
advanced?: boolean,
}
export function ItemTooltip1204({ item, advanced }: Props) {
const { version } = useVersion()
const { lang } = useLocale()
const { value: language } = useAsync(() => getLanguage(version, lang), [version, lang])
const isPotion = item.is('potion') || item.is('splash_potion') || item.is('lingering_potion')
let displayName = item.tag.getCompound('display').getString('Name')
let name: string | undefined
if (displayName) {
try {
name = JSON.parse(displayName)
} catch (e) {
console.warn(`Error parsing display name '${displayName}': ${message(e)}`)
displayName = ''
}
}
if (name === undefined) {
if (language) {
let descriptionId = `${item.id.namespace}.${item.id.path}`
if (isPotion) {
descriptionId = `${descriptionId}.effect.${Potion.fromNbt(item).name}`
}
name = getTranslation(language, `item.${descriptionId}`)
name ??= getTranslation(language, `block.${descriptionId}`)
}
name ??= item.id.path
.replace(/[_\/]/g, ' ')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
const lore: any[] = []
item.tag.getCompound('display').getList('Lore', NbtType.String).forEach((line) => {
try {
lore.push(JSON.parse(line['value']))
} catch (e) {
console.warn(`Error parsing lore line '${line}': ${message(e)}`)
}
})
const durability = item.getItem().durability
const enchantments = (item.is('enchanted_book') ? item.tag.getList('StoredEnchantments', NbtType.Compound) : item.tag.getList('Enchantments', NbtType.Compound)) ?? NbtList.create()
const effects = isPotion ? Potion.getAllEffects(item) : []
const attributeModifiers = isPotion ? Potion.getAllAttributeModifiers(item) : []
return <>
<TextComponent component={name} base={{ color: 'white', italic: displayName.length > 0 }} />
{shouldShow(item, 'additional') && <>
{(!advanced && displayName.length === 0 && item.is('filled_map') && item.tag.hasNumber('map')) && <>
<TextComponent component={{ text: `#${item.tag.getNumber('map')}`, color: 'gray' }} />
</>}
{(item.is('filled_map') && advanced) && <>
<TextComponent component={{ translate: 'filled_map.unknown', color: 'gray' }} />
</>}
{isPotion && effects.length === 0
? <TextComponent component={{ translate: 'effect.none', color: 'gray' }} />
: effects.map(e => {
const color = e.effect.category === 'harmful' ? 'red' : 'blue'
let component: any = { translate: `effect.${e.effect.id.namespace}.${e.effect.id.path}` }
if (e.amplifier > 0) {
component = { translate: 'potion.withAmplifier', with: [component, { translate: `potion.potency.${e.amplifier}` }] }
}
if (e.duration > 20) {
component = { translate: 'potion.withDuration', with: [component, MobEffectInstance.formatDuration(e)] }
}
return <TextComponent component={{ ...component, color }} />
})}
{attributeModifiers.length > 0 && <>
<TextComponent component='' />
<TextComponent component={{ translate: 'potion.whenDrank', color: 'dark_purple' }} />
{attributeModifiers.map(([attr, { amount, operation }]) => {
const a = operation === AttributeModifierOperation.addition ? amount * 100 : amount
if (amount > 0) {
return <TextComponent component={{ translate: `attribute.modifier.plus.${operation}`, with: [Math.floor(a * 100) / 100, { translate: `attribute.name.${attr.id.path}` }], color: 'blue' }} />
} else if (amount < 0) {
return <TextComponent component={{ translate: `attribute.modifier.take.${operation}`, with: [Math.floor(a * -100) / 100, { translate: `attribute.name.${attr.id.path}` }], color: 'red' }} />
}
return null
})}
</>}
</>}
{shouldShow(item, 'enchantments') && enchantments.map(enchantment => {
const id = enchantment.getString('id')
const lvl = enchantment.getNumber('lvl')
const ench = Enchantment.REGISTRY.get(Identifier.parse(id))
const component: any[] = [{ translate: `enchantment.${id.replace(':', '.')}`, color: ench?.isCurse ? 'red' : 'gray' }]
if (lvl !== 1 || ench?.maxLevel !== 1) {
component.push(' ', { translate: `enchantment.level.${lvl}`})
}
return <TextComponent component={component} />
})}
{item.tag.hasCompound('display') && <>
{shouldShow(item, 'dye') && item.tag.getCompound('display').hasNumber('color') && (advanced
? <TextComponent component={{ translate: 'item.color', with: [`#${item.tag.getCompound('display').getNumber('color').toString(16).padStart(6, '0')}`], color: 'gray' }} />
: <TextComponent component={{ translate: 'item.dyed', color: 'gray' }} />)}
{lore.map((component) => <TextComponent component={component} base={{ color: 'dark_purple', italic: true }} />)}
</>}
{shouldShow(item, 'unbreakable') && item.tag.getBoolean('Unbreakable') && <TextComponent component={{ translate: 'item.unbreakable', color: 'blue' }} />}
{(advanced && item.tag.getNumber('Damage') > 0 && durability) && <TextComponent component={{ translate: 'item.durability', with: [`${durability - item.tag.getNumber('Damage')}`, `${durability}`] }} />}
{advanced && <>
<TextComponent component={{ text: item.id.toString(), color: 'dark_gray'}} />
{item.tag.size > 0 && <TextComponent component={{ translate: 'item.nbt_tags', with: [item.tag.size], color: 'dark_gray' }} />}
</>}
</>
}
const TooltipMasks = {
enchantments: 1,
modifiers: 2,
unbreakable: 4,
can_destroy: 8,
can_place: 16,
additional: 32,
dye: 64,
upgrades: 128,
}
function shouldShow(item: ItemStack, mask: keyof typeof TooltipMasks) {
const flags = item.tag.getNumber('HideFlags')
return (flags & TooltipMasks[mask]) === 0
}

View File

@@ -1,15 +1,17 @@
import { NbtByte, NbtDouble, NbtLong } from 'deepslate'
import type { Random } from 'deepslate/core'
import { Enchantment, Identifier, ItemStack, LegacyRandom } from 'deepslate/core'
import { NbtCompound, NbtInt, NbtList, NbtShort, NbtString, NbtTag, NbtType } from 'deepslate/nbt'
import { clamp, getWeightedRandom, isObject } from '../../Utils.js'
import { Identifier, ItemStack, LegacyRandom } from 'deepslate/core'
import { NbtCompound, NbtInt, NbtList, NbtString, NbtTag } from 'deepslate/nbt'
import { ResolvedItem } from '../../services/ResolvedItem.js'
import type { VersionId } from '../../services/Schemas.js'
import { clamp, isObject, jsonToNbt } from '../../Utils.js'
export interface SlottedItem {
slot: number,
item: ItemStack,
item: ResolvedItem,
}
type ItemConsumer = (item: ItemStack) => void
type ItemConsumer = (item: ResolvedItem) => void
const StackMixers = {
container: fillContainer,
@@ -25,6 +27,7 @@ interface LootOptions {
daytime: number,
weather: string,
stackMixer: StackMixer,
getBaseComponents(id: string): Map<string, NbtTag>,
}
interface LootContext extends LootOptions {
@@ -39,7 +42,7 @@ interface LootContext extends LootOptions {
export function generateLootTable(lootTable: any, options: LootOptions) {
const ctx = createLootContext(options)
const result: ItemStack[] = []
const result: ResolvedItem[] = []
generateTable(lootTable, item => result.push(item), ctx)
const mixer = StackMixers[options.stackMixer]
return mixer(result, ctx)
@@ -47,7 +50,7 @@ export function generateLootTable(lootTable: any, options: LootOptions) {
const SLOT_COUNT = 27
function fillContainer(items: ItemStack[], ctx: LootContext): SlottedItem[] {
function fillContainer(items: ResolvedItem[], ctx: LootContext): SlottedItem[] {
const slots = shuffle([...Array(SLOT_COUNT)].map((_, i) => i), ctx)
const queue = items.filter(i => !i.is('air') && i.count > 1)
@@ -83,7 +86,7 @@ function fillContainer(items: ItemStack[], ctx: LootContext): SlottedItem[] {
return results
}
function assignSlots(items: ItemStack[]): SlottedItem[] {
function assignSlots(items: ResolvedItem[]): SlottedItem[] {
const results: SlottedItem[] = []
let slot = 0
for (const item of items) {
@@ -98,7 +101,7 @@ function assignSlots(items: ItemStack[]): SlottedItem[] {
return results
}
function splitItem(item: ItemStack, count: number): ItemStack {
function splitItem(item: ResolvedItem, count: number): ResolvedItem {
const splitCount = Math.min(count, item.count)
const other = item.clone()
other.count = splitCount
@@ -130,6 +133,7 @@ function createLootContext(options: LootOptions): LootContext {
luck: options.luck,
weather: options.weather,
dayTime: options.daytime,
// TODO
getItemTag: () => [],
getLootTable: () => ({ pools: [] }),
getPredicate: () => [],
@@ -229,15 +233,13 @@ function createItem(entry: any, consumer: ItemConsumer, ctx: LootContext) {
}
switch (type) {
case 'item':
try {
entryConsumer(new ItemStack(Identifier.parse(entry.name), 1))
} catch (e) {}
const id = Identifier.parse(entry.name)
entryConsumer(new ResolvedItem(new ItemStack(id, 1), ctx.getBaseComponents(id.toString())))
break
case 'tag':
ctx.getItemTag(entry.name).forEach(tagEntry => {
try {
entryConsumer(new ItemStack(Identifier.parse(tagEntry), 1))
} catch (e) {}
const id = Identifier.parse(tagEntry)
entryConsumer(new ResolvedItem(new ItemStack(id, 1), ctx.getBaseComponents(id.toString())))
})
break
case 'loot_table':
@@ -253,7 +255,7 @@ function computeWeight(entry: any, luck: number) {
return Math.max(Math.floor((entry.weight ?? 1) + (entry.quality ?? 0) * luck), 0)
}
type LootFunction = (item: ItemStack, ctx: LootContext) => void
type LootFunction = (item: ResolvedItem, ctx: LootContext) => void
function decorateFunctions(functions: any[], consumer: ItemConsumer, ctx: LootContext): ItemConsumer {
const compositeFunction = composeFunctions(functions)
@@ -277,44 +279,11 @@ function composeFunctions(functions: any[]): LootFunction {
}
const LootFunctions: Record<string, (params: any) => LootFunction> = {
enchant_randomly: ({ enchantments }) => (item, ctx) => {
const isBook = item.is('book')
if (enchantments === undefined || enchantments.length === 0) {
enchantments = Enchantment.REGISTRY.map((_, ench) => ench)
.filter(ench => ench.isDiscoverable && (isBook || Enchantment.canEnchant(item, ench)))
.map(e => e.id.toString())
}
if (enchantments.length > 0) {
const id = enchantments[ctx.random.nextInt(enchantments.length)]
let ench: Enchantment | undefined
try {
ench = Enchantment.REGISTRY.get(Identifier.parse(id))
} catch (e) {}
if (ench === undefined) return
const lvl = ctx.random.nextInt(ench.maxLevel - ench.minLevel + 1) + ench.minLevel
if (isBook) {
item.tag = new NbtCompound()
item.count = 1
}
enchantItem(item, { id, lvl })
if (isBook) {
item.id = Identifier.create('enchanted_book')
}
}
enchant_randomly: () => () => {
// TODO
},
enchant_with_levels: ({ levels, treasure }) => (item, ctx) => {
const enchants = selectEnchantments(ctx.random, item, computeInt(levels, ctx), treasure)
const isBook = item.is('book')
if (isBook) {
item.count = 1
item.tag = new NbtCompound()
}
for (const enchant of enchants) {
enchantItem(item, enchant)
}
if (isBook) {
item.id = Identifier.create('enchanted_book')
}
enchant_with_levels: () => () => {
// TODO
},
exploration_map: ({ decoration }) => (item) => {
if (!item.is('map')) {
@@ -323,64 +292,167 @@ const LootFunctions: Record<string, (params: any) => LootFunction> = {
item.id = Identifier.create('filled_map')
const color = decoration === 'mansion' ? 5393476 : decoration === 'monument' ? 3830373 : -1
if (color >= 0) {
getOrCreateTag(item, 'display').set('MapColor', new NbtInt(color))
item.set('map_color', new NbtInt(color))
}
},
filtered: ({ item_filter, modifier }) => (item, ctx) => {
if (testItemPredicate(item_filter, item, ctx)) {
composeFunctions([modifier])(item, ctx)
}
},
limit_count: ({ limit }) => (item, ctx) => {
const { min, max } = prepareIntRange(limit, ctx)
item.count = clamp(item.count, min, max )
item.count = clamp(item.count, min, max)
},
sequence: ({ functions }) => (item, ctx) => {
if (!Array.isArray(functions)) return
composeFunctions(functions)(item, ctx)
},
set_attributes: ({ modifiers, replace }) => (item, ctx) => {
if (!Array.isArray(modifiers)) return
const newModifiers = modifiers.map<AttributeModifier>(m => {
if (typeof m !== 'object' || m === null) m = {}
return {
id: Identifier.parse(typeof m.id === 'string' ? m.id : ''),
type: Identifier.parse(typeof m.attribute === 'string' ? m.attribute : ''),
amount: computeFloat(m.amount, ctx),
operation: typeof m.operation === 'string' ? m.operation : 'add_value',
slot: typeof m.slot === 'string' ? m.slot : Array.isArray(m.slot) ? m.slot[ctx.random.nextInt(m.slot.length)] : 'any',
}
})
updateAttributes(item, (modifiers) => {
if (replace === false) {
return [...modifiers, ...newModifiers]
} else {
return newModifiers
}
})
},
set_banner_pattern: ({ patterns, append }) => (item) => {
if (!Array.isArray(patterns)) return
if (append) {
const existing = item.get('banner_patterns', tag => tag.isList() ? tag : undefined) ?? new NbtList()
item.set('banner_patterns', new NbtList([...existing.getItems(), ...patterns.map(jsonToNbt)]))
} else {
item.set('banner_patterns', jsonToNbt(patterns))
}
},
set_book_cover: ({ title, author, generation }) => (item) => {
const content = item.get('written_book_content', tag => tag.isCompound() ? tag : undefined) ?? new NbtCompound()
const newContent = new NbtCompound()
.set('title', title !== undefined ? jsonToNbt(title) : content.get('title') ?? new NbtString(''))
.set('author', author !== undefined ? jsonToNbt(author) : content.get('author') ?? new NbtString(''))
.set('generation', generation !== undefined ? jsonToNbt(generation) : content.get('generation') ?? new NbtInt(0))
.set('pages', content.getList('pages'))
.set('resolved', content.get('resolved') ?? new NbtByte(1))
item.set('written_book_content', newContent)
},
set_components: ({ components }) => (item) => {
for (const [key, value] of Object.entries(components)) {
item.set(key, jsonToNbt(value))
}
},
set_contents: ({ component, entries }) => (item, ctx) => {
const result = generateLootTable({ pools: [{ rolls: 1, entries }] }, ctx)
if (Identifier.parse(component).is('container')) {
item.set(component, new NbtList(result.map(s => new NbtCompound()
.set('slot', new NbtInt(s.slot))
.set('item', s.item.toNbt())
)))
} else {
item.set(component, new NbtList(result.map(s => s.item.toNbt())))
}
},
set_count: ({ count, add }) => (item, ctx) => {
const oldCount = add ? (item.count) : 0
item.count = clamp(oldCount + computeInt(count, ctx), 0, 64)
},
set_damage: ({ damage, add }) => (item, ctx) => {
const maxDamage = item.getItem().durability
if (maxDamage) {
const oldDamage = add ? 1 - item.tag.getNumber('Damage') / maxDamage : 0
const newDamage = 1 - clamp(computeFloat(damage, ctx) + oldDamage, 0, 1)
const finalDamage = Math.floor(newDamage * maxDamage)
item.tag.set('Damage', new NbtInt(finalDamage))
}
},
set_enchantments: ({ enchantments, add }) => (item, ctx) => {
Object.entries(enchantments).forEach(([id, level]) => {
const lvl = computeInt(level, ctx)
try {
enchantItem(item, { id: Identifier.parse(id), lvl }, add)
} catch (e) {}
})
},
set_lore: ({ lore, replace }) => (item) => {
const lines: string[] = lore.flatMap((line: any) => line !== undefined ? [JSON.stringify(line)] : [])
const newLore = replace ? lines : [...item.tag.getCompound('display').getList('Lore', NbtType.String).map(s => s.getAsString()), ...lines]
getOrCreateTag(item, 'display').set('Lore', new NbtList(newLore.map(l => new NbtString(l))))
},
set_name: ({ name }) => (item) => {
if (name !== undefined) {
const newName = JSON.stringify(name)
getOrCreateTag(item, 'display').set('Name', new NbtString(newName))
}
},
set_nbt: ({ tag }) => (item) => {
set_custom_data: ({ tag }) => (item) => {
try {
const newTag = NbtTag.fromString(tag)
if (newTag.isCompound()) {
item.tag = newTag
item.set('custom_data', newTag)
}
} catch (e) {}
},
set_custom_model_data: ({ value }) => (item, ctx) => {
item.set('custom_model_data', new NbtInt(computeInt(value, ctx)))
},
set_damage: ({ damage, add }) => (item, ctx) => {
if (item.isDamageable()) {
const maxDamage = item.getMaxDamage()
const oldDamage = add ? 1 - item.getDamage() / maxDamage : 0
const newDamage = 1 - clamp(computeFloat(damage, ctx) + oldDamage, 0, 1)
const finalDamage = Math.floor(newDamage * maxDamage)
item.set('damage', new NbtInt(clamp(finalDamage, 0, maxDamage)))
}
},
set_enchantments: ({ enchantments, add }) => (item, ctx) => {
if (item.is('book')) {
item.id = Identifier.create('enchanted_book')
item.base = ctx.getBaseComponents(item.id.toString())
}
updateEnchantments(item, levels => {
Object.entries(enchantments).forEach(([id, level]) => {
id = Identifier.parse(id).toString()
if (add) {
levels.set(id, clamp((levels.get(id) ?? 0) + computeInt(level, ctx), 0, 255))
} else {
levels.set(id, clamp(computeInt(level, ctx), 0, 255))
}
})
return levels
})
},
set_firework_explosion: () => () => {
// TODO
},
set_fireworks: () => () => {
// TODO
},
set_instrument: () => () => {
// TODO: depends on item tag
},
set_item: ({ item: newId }) => (item, ctx) => {
if (typeof newId !== 'string') return
item.id = Identifier.parse(newId)
item.base = ctx.getBaseComponents(item.id.toString())
},
set_loot_table: ({ name, seed }) => (item) => {
item.set('container_loot', new NbtCompound()
.set('loot_table', new NbtString(Identifier.parse(typeof name === 'string' ? name : '').toString()))
.set('seed', new NbtLong(typeof seed === 'number' ? BigInt(seed) : BigInt(0))))
},
set_lore: ({ lore }) => (item) => {
const lines: string[] = lore.flatMap((line: any) => line !== undefined ? [JSON.stringify(line)] : [])
// TODO: account for mode
item.set('lore', new NbtList(lines.map(l => new NbtString(l))))
},
set_name: ({ name, target }) => (item) => {
if (name !== undefined) {
const newName = JSON.stringify(name)
item.set(target ?? 'custom_name', new NbtString(newName))
}
},
set_ominous_bottle_amplifier: ({ amplifier }) => (item, ctx) => {
item.set('ominous_bottle_amplifier', new NbtInt(computeInt(amplifier, ctx)))
},
set_potion: ({ id }) => (item) => {
if (typeof id === 'string') {
try {
item.tag.set('Potion', new NbtString(Identifier.parse(id).toString()))
} catch (e) {}
item.set('potion_contents', new NbtString(id))
}
},
toggle_tooltips: ({ toggles }) => (item) => {
if (typeof toggles !== 'object' || toggles === null) return
Object.entries(toggles).forEach(([key, value]) => {
if (typeof value !== 'boolean') return
const tag = item.get(key, tag => tag)
if (tag === undefined) return
if (tag.isCompound()) {
item.set(key, tag.set('show_in_tooltip', new NbtByte(value)))
}
})
},
}
type LootCondition = (ctx: LootContext) => boolean
@@ -427,14 +499,14 @@ const LootConditions: Record<string, (params: any) => LootCondition> = {
block_state_property: () => () => {
return false // TODO
},
damage_source_properties: ({ predicate }) => (ctx) => {
return testDamageSourcePredicate(predicate, ctx)
damage_source_properties: () => () => {
return false // TODO
},
entity_properties: ({ predicate }) => (ctx) => {
return testEntityPredicate(predicate, ctx)
entity_properties: () => () => {
return false // TODO
},
entity_scores: () => () => {
return false // TODO,
return false // TODO
},
inverted: ({ term }) => (ctx) => {
return !testCondition(term, ctx)
@@ -442,11 +514,11 @@ const LootConditions: Record<string, (params: any) => LootCondition> = {
killed_by_player: ({ inverted }) => () => {
return (inverted ?? false) === false // TODO
},
location_check: ({ predicate }) => (ctx) => {
return testLocationPredicate(predicate, ctx)
location_check: () => () => {
return false // TODO
},
match_tool: ({ predicate }) => (ctx) => {
return testItemPredicate(predicate, ctx)
match_tool: () => () => {
return false // TODO
},
random_chance: ({ chance }) => (ctx) => {
return ctx.random.nextFloat() < chance
@@ -551,135 +623,107 @@ function prepareIntRange(range: any, ctx: LootContext) {
return { min, max }
}
function testItemPredicate(_predicate: any, _ctx: LootContext) {
return false // TODO
}
function testLocationPredicate(_predicate: any, _ctx: LootContext) {
return false // TODO
}
function testEntityPredicate(_predicate: any, _ctx: LootContext) {
return false // TODO
}
function testDamageSourcePredicate(_predicate: any, _ctx: LootContext) {
return false // TODO
}
function enchantItem(item: ItemStack, enchant: Enchant, additive?: boolean) {
const listKey = item.is('book') ? 'StoredEnchantments' : 'Enchantments'
if (!item.tag.hasList(listKey, NbtType.Compound)) {
item.tag.set(listKey, new NbtList())
}
const enchantments = item.tag.getList(listKey, NbtType.Compound).getItems()
let index = enchantments.findIndex((e: any) => e.id === enchant.id)
if (index !== -1) {
const oldEnch = enchantments[index]
oldEnch.set('lvl', new NbtShort(Math.max(additive ? oldEnch.getNumber('lvl') + enchant.lvl : enchant.lvl, 0)))
} else {
enchantments.push(new NbtCompound().set('id', new NbtString(enchant.id.toString())).set('lvl', new NbtShort(enchant.lvl)))
index = enchantments.length - 1
}
if (enchantments[index].getNumber('lvl') === 0) {
enchantments.splice(index, 1)
}
item.tag.set(listKey, new NbtList(enchantments))
}
function selectEnchantments(random: Random, item: ItemStack, levels: number, treasure: boolean): Enchant[] {
const enchantmentValue = item.getItem().enchantmentValue
if (enchantmentValue === undefined) {
return []
}
levels += 1 + random.nextInt(Math.floor(enchantmentValue / 4 + 1)) + random.nextInt(Math.floor(enchantmentValue / 4 + 1))
const f = (random.nextFloat() + random.nextFloat() - 1) * 0.15
levels = clamp(Math.round(levels + levels * f), 1, Number.MAX_SAFE_INTEGER)
let available = getAvailableEnchantments(item, levels, treasure)
if (available.length === 0) {
return []
}
const result: Enchant[] = []
const first = getWeightedRandom(random, available, getEnchantWeight)
if (first) result.push(first)
while (random.nextInt(50) <= levels) {
if (result.length > 0) {
const lastAdded = result[result.length - 1]
available = available.filter(a => Enchantment.isCompatible(Enchantment.REGISTRY.getOrThrow(a.id), Enchantment.REGISTRY.getOrThrow(lastAdded.id)))
function testItemPredicate(predicate: any, item: ResolvedItem, ctx: LootContext) {
if (!isObject(predicate)) return false
if (typeof predicate.items === 'string') {
if (predicate.items.startsWith('#')) {
return false // TODO: depends on item tag
} else if (!item.id.is(predicate.items)) {
return false
}
} else if (Array.isArray(predicate.items)) {
if (!predicate.items.some(i => typeof i === 'string' && item.id.is(i))) {
return false
}
if (available.length === 0) break
const ench = getWeightedRandom(random, available, getEnchantWeight)
if (ench) result.push(ench)
levels = Math.floor(levels / 2)
}
return result
}
const EnchantmentsRarityWeights = new Map(Object.entries<number>({
common: 10,
uncommon: 5,
rare: 2,
very_rare: 1,
}))
function getEnchantWeight(ench: Enchant) {
return EnchantmentsRarityWeights.get(Enchantment.REGISTRY.get(ench.id)?.rarity ?? 'common') ?? 10
}
function getAvailableEnchantments(item: ItemStack, levels: number, treasure: boolean): Enchant[] {
const result: Enchant[] = []
const isBook = item.is('book')
Enchantment.REGISTRY.forEach((id, ench) => {
if ((!ench.isTreasure || treasure) && ench.isDiscoverable && (Enchantment.canEnchant(item, ench) || isBook)) {
for (let lvl = ench.maxLevel; lvl > ench.minLevel - 1; lvl -= 1) {
if (levels >= ench.minCost(lvl) && levels <= ench.maxCost(lvl)) {
result.push({ id, lvl })
}
if (predicate.count !== undefined) {
const { min, max } = prepareIntRange(predicate.count, ctx)
console.log(min, max, item.count)
if (min > item.count || item.count > max) {
return false
}
}
if (isObject(predicate.components)) {
for (const [key, value] of Object.entries(predicate.components)) {
const tag = jsonToNbt(value)
const other = item.get(key, tag => tag)
if (!other || !tag.equals(other)) {
return false
}
}
}
// TODO: item sub predicates
return true
}
function updateEnchantments(item: ResolvedItem, fn: (levels: Map<string, number>) => Map<string, number>) {
const type = item.is('book') ? 'stored_enchantments' : 'enchantments'
if (!item.has(type)) {
return
}
const levelsTag = item.get(type, tag => {
return tag.isCompound() ? tag.has('levels') ? tag.getCompound('levels') : tag : undefined
}) ?? new NbtCompound()
const showInTooltip = item.get(type, tag => {
return tag.isCompound() && tag.hasCompound('levels') ? tag.get('show_in_tooltip') : undefined
}) ?? new NbtByte(1)
const levels = new Map<string, number>()
levelsTag.forEach((id, lvl) => {
levels.set(Identifier.parse(id).toString(), lvl.getAsNumber())
})
return result
const newLevels = fn(levels)
const newLevelsTag = new NbtCompound()
for (const [key, lvl] of newLevels) {
if (lvl > 0) {
newLevelsTag.set(key, new NbtInt(lvl))
}
}
const newTag = new NbtCompound()
.set('levels', newLevelsTag)
.set('show_in_tooltip', showInTooltip)
item.set(type, newTag)
}
interface Enchant {
interface AttributeModifier {
id: Identifier,
lvl: number,
type: Identifier,
amount: number,
operation: string,
slot: string,
}
const AlwaysHasGlint = new Set([
'minecraft:debug_stick',
'minecraft:enchanted_golden_apple',
'minecraft:enchanted_book',
'minecraft:end_crystal',
'minecraft:experience_bottle',
'minecraft:written_book',
])
function updateAttributes(item: ResolvedItem, fn: (modifiers: AttributeModifier[]) => AttributeModifier[]) {
const modifiersTag = item.get('attribute_modifiers', tag => {
return tag.isCompound() ? tag.getList('modifiers') : tag.isList() ? tag : undefined
}) ?? new NbtList()
const showInTooltip = item.get('attribute_modifiers', tag => {
return tag.isCompound() ? tag.get('show_in_tooltip') : undefined
}) ?? new NbtByte(1)
const modifiers = modifiersTag.map<AttributeModifier>(m => {
const root = m.isCompound() ? m : new NbtCompound()
return {
id: Identifier.parse(root.getString('id')),
type: Identifier.parse(root.getString('type')),
amount: root.getNumber('amount'),
operation: root.getString('operation'),
slot: root.getString('slot'),
}
})
export function itemHasGlint(item: ItemStack) {
if (AlwaysHasGlint.has(item.id.toString())) {
return true
}
if (item.is('compass') && (item.tag.has('LodestoneDimension') || item.tag.has('LodestonePos'))) {
return true
}
if ((item.is('potion') || item.is('splash_potion') || item.is('lingering_potion')) && (item.tag.has('Potion') || item.tag.has('CustomPotionEffects'))) {
return true
}
if (item.tag.getList('Enchantments').length > 0 || item.tag.getList('StoredEnchantments').length > 0) {
return true
}
return false
}
const newModifiers = fn(modifiers)
function getOrCreateTag(item: ItemStack, key: string) {
if (item.tag.hasCompound(key)) {
return item.tag.getCompound(key)
} else {
const tag = new NbtCompound()
item.tag.set(key, tag)
return tag
}
const newModifiersTag = new NbtList(newModifiers.map(m => {
return new NbtCompound()
.set('id', new NbtString(m.id.toString()))
.set('type', new NbtString(m.type.toString()))
.set('amount', new NbtDouble(m.amount))
.set('operation', new NbtString(m.operation))
.set('slot', new NbtString(m.slot))
}))
const newTag = new NbtCompound()
.set('modifiers', newModifiersTag)
.set('show_in_tooltip', showInTooltip)
item.set('attribute_modifiers', newTag)
}

View File

@@ -0,0 +1,686 @@
import type { Random } from 'deepslate-1.20.4/core'
import { Enchantment, Identifier, ItemStack, LegacyRandom } from 'deepslate-1.20.4/core'
import { NbtCompound, NbtInt, NbtList, NbtShort, NbtString, NbtTag, NbtType } from 'deepslate-1.20.4/nbt'
import type { VersionId } from '../../services/Schemas.js'
import { clamp, getWeightedRandom, isObject } from '../../Utils.js'
export interface SlottedItem {
slot: number,
item: ItemStack,
}
type ItemConsumer = (item: ItemStack) => void
const StackMixers = {
container: fillContainer,
default: assignSlots,
}
type StackMixer = keyof typeof StackMixers
interface LootOptions {
version: VersionId,
seed: bigint,
luck: number,
daytime: number,
weather: string,
stackMixer: StackMixer,
}
interface LootContext extends LootOptions {
random: Random,
luck: number
weather: string,
dayTime: number,
getItemTag(id: string): string[],
getLootTable(id: string): any,
getPredicate(id: string): any,
}
export function generateLootTable(lootTable: any, options: LootOptions) {
const ctx = createLootContext(options)
const result: ItemStack[] = []
generateTable(lootTable, item => result.push(item), ctx)
const mixer = StackMixers[options.stackMixer]
return mixer(result, ctx)
}
const SLOT_COUNT = 27
function fillContainer(items: ItemStack[], ctx: LootContext): SlottedItem[] {
const slots = shuffle([...Array(SLOT_COUNT)].map((_, i) => i), ctx)
const queue = items.filter(i => !i.is('air') && i.count > 1)
items = items.filter(i => !i.is('air') && i.count === 1)
while (SLOT_COUNT - items.length - queue.length > 0 && queue.length > 0) {
const [itemA] = queue.splice(ctx.random.nextInt(queue.length), 1)
const splitCount = ctx.random.nextInt(Math.floor(itemA.count / 2)) + 1
const itemB = splitItem(itemA, splitCount)
for (const item of [itemA, itemB]) {
if (item.count > 1 && ctx.random.nextFloat() < 0.5) {
queue.push(item)
} else {
items.push(item)
}
}
}
items.push(...queue)
shuffle(items, ctx)
const results: SlottedItem[] = []
for (const item of items) {
const slot = slots.pop()
if (slot === undefined) {
break
}
if (!item.is('air') && item.count > 0) {
results.push({ slot, item })
}
}
return results
}
function assignSlots(items: ItemStack[]): SlottedItem[] {
const results: SlottedItem[] = []
let slot = 0
for (const item of items) {
if (slot >= 27) {
break
}
if (!item.is('air') && item.count > 0) {
results.push({ slot, item })
slot += 1
}
}
return results
}
function splitItem(item: ItemStack, count: number): ItemStack {
const splitCount = Math.min(count, item.count)
const other = item.clone()
other.count = splitCount
item.count = item.count - splitCount
return other
}
function shuffle<T>(array: T[], ctx: LootContext) {
let i = array.length
while (i > 0) {
const j = ctx.random.nextInt(i)
i -= 1;
[array[i], array[j]] = [array[j], array[i]]
}
return array
}
function generateTable(table: any, consumer: ItemConsumer, ctx: LootContext) {
const tableConsumer = decorateFunctions(table.functions ?? [], consumer, ctx)
for (const pool of table.pools ?? []) {
generatePool(pool, tableConsumer, ctx)
}
}
function createLootContext(options: LootOptions): LootContext {
return {
...options,
random: new LegacyRandom(options.seed),
luck: options.luck,
weather: options.weather,
dayTime: options.daytime,
getItemTag: () => [],
getLootTable: () => ({ pools: [] }),
getPredicate: () => [],
}
}
function generatePool(pool: any, consumer: ItemConsumer, ctx: LootContext) {
if (composeConditions(pool.conditions ?? [])(ctx)) {
const poolConsumer = decorateFunctions(pool.functions ?? [], consumer, ctx)
const rolls = computeInt(pool.rolls, ctx) + Math.floor(computeFloat(pool.bonus_rolls, ctx) * ctx.luck)
for (let i = 0; i < rolls; i += 1) {
let totalWeight = 0
const entries: any[] = []
// Expand entries
for (const entry of pool.entries ?? []) {
expandEntry(entry, ctx, (e) => {
const weight = computeWeight(e, ctx.luck)
if (weight > 0) {
entries.push(e)
totalWeight += weight
}
})
}
// Select random entry
if (totalWeight === 0 || entries.length === 0) {
continue
}
if (entries.length === 1) {
createItem(entries[0], poolConsumer, ctx)
continue
}
let remainingWeight = ctx.random.nextInt(totalWeight)
for (const entry of entries) {
remainingWeight -= computeWeight(entry, ctx.luck)
if (remainingWeight < 0) {
createItem(entry, poolConsumer, ctx)
break
}
}
}
}
}
function expandEntry(entry: any, ctx: LootContext, consumer: (entry: any) => void): boolean {
if (!canEntryRun(entry, ctx)) {
return false
}
const type = entry.type?.replace(/^minecraft:/, '')
switch (type) {
case 'group':
for (const child of entry.children ?? []) {
expandEntry(child, ctx, consumer)
}
return true
case 'alternatives':
for (const child of entry.children ?? []) {
if (expandEntry(child, ctx, consumer)) {
return true
}
}
return false
case 'sequence':
for (const child of entry.children ?? []) {
if (!expandEntry(child, ctx, consumer)) {
return false
}
}
return true
case 'tag':
if (entry.expand) {
ctx.getItemTag(entry.tag ?? '').forEach(tagEntry => {
consumer({ type: 'item', name: tagEntry })
})
} else {
consumer(entry)
}
return true
default:
consumer(entry)
return true
}
}
function canEntryRun(entry: any, ctx: LootContext): boolean {
return composeConditions(entry.conditions ?? [])(ctx)
}
function createItem(entry: any, consumer: ItemConsumer, ctx: LootContext) {
const entryConsumer = decorateFunctions(entry.functions ?? [], consumer, ctx)
const type = entry.type?.replace(/^minecraft:/, '')
if (typeof entry.name !== 'string') {
return
}
switch (type) {
case 'item':
try {
entryConsumer(new ItemStack(Identifier.parse(entry.name), 1))
} catch (e) {}
break
case 'tag':
ctx.getItemTag(entry.name).forEach(tagEntry => {
try {
entryConsumer(new ItemStack(Identifier.parse(tagEntry), 1))
} catch (e) {}
})
break
case 'loot_table':
generateTable(ctx.getLootTable(entry.name), entryConsumer, ctx)
break
case 'dynamic':
// not relevant for this simulation
break
}
}
function computeWeight(entry: any, luck: number) {
return Math.max(Math.floor((entry.weight ?? 1) + (entry.quality ?? 0) * luck), 0)
}
type LootFunction = (item: ItemStack, ctx: LootContext) => void
function decorateFunctions(functions: any[], consumer: ItemConsumer, ctx: LootContext): ItemConsumer {
const compositeFunction = composeFunctions(functions)
return (item) => {
compositeFunction(item, ctx)
consumer(item)
}
}
function composeFunctions(functions: any[]): LootFunction {
return (item, ctx) => {
for (const fn of functions) {
if (Array.isArray(fn)) {
composeFunctions(fn)
} else if (composeConditions(fn.conditions ?? [])(ctx)) {
const type = fn.function?.replace(/^minecraft:/, '');
(LootFunctions[type]?.(fn) ?? (i => i))(item, ctx)
}
}
}
}
const LootFunctions: Record<string, (params: any) => LootFunction> = {
enchant_randomly: ({ enchantments }) => (item, ctx) => {
const isBook = item.is('book')
if (enchantments === undefined || enchantments.length === 0) {
enchantments = Enchantment.REGISTRY.map((_, ench) => ench)
.filter(ench => ench.isDiscoverable && (isBook || Enchantment.canEnchant(item, ench)))
.map(e => e.id.toString())
}
if (enchantments.length > 0) {
const id = enchantments[ctx.random.nextInt(enchantments.length)]
let ench: Enchantment | undefined
try {
ench = Enchantment.REGISTRY.get(Identifier.parse(id))
} catch (e) {}
if (ench === undefined) return
const lvl = ctx.random.nextInt(ench.maxLevel - ench.minLevel + 1) + ench.minLevel
if (isBook) {
item.tag = new NbtCompound()
item.count = 1
}
enchantItem(item, { id, lvl })
if (isBook) {
item.id = Identifier.create('enchanted_book')
}
}
},
enchant_with_levels: ({ levels, treasure }) => (item, ctx) => {
const enchants = selectEnchantments(ctx.random, item, computeInt(levels, ctx), treasure)
const isBook = item.is('book')
if (isBook) {
item.count = 1
item.tag = new NbtCompound()
}
for (const enchant of enchants) {
enchantItem(item, enchant)
}
if (isBook) {
item.id = Identifier.create('enchanted_book')
}
},
exploration_map: ({ decoration }) => (item) => {
if (!item.is('map')) {
return
}
item.id = Identifier.create('filled_map')
const color = decoration === 'mansion' ? 5393476 : decoration === 'monument' ? 3830373 : -1
if (color >= 0) {
getOrCreateTag(item, 'display').set('MapColor', new NbtInt(color))
}
},
limit_count: ({ limit }) => (item, ctx) => {
const { min, max } = prepareIntRange(limit, ctx)
item.count = clamp(item.count, min, max )
},
sequence: ({ functions }) => (item, ctx) => {
if (!Array.isArray(functions)) return
composeFunctions(functions)(item, ctx)
},
set_count: ({ count, add }) => (item, ctx) => {
const oldCount = add ? (item.count) : 0
item.count = clamp(oldCount + computeInt(count, ctx), 0, 64)
},
set_damage: ({ damage, add }) => (item, ctx) => {
const maxDamage = item.getItem().durability
if (maxDamage) {
const oldDamage = add ? 1 - item.tag.getNumber('Damage') / maxDamage : 0
const newDamage = 1 - clamp(computeFloat(damage, ctx) + oldDamage, 0, 1)
const finalDamage = Math.floor(newDamage * maxDamage)
item.tag.set('Damage', new NbtInt(finalDamage))
}
},
set_enchantments: ({ enchantments, add }) => (item, ctx) => {
Object.entries(enchantments).forEach(([id, level]) => {
const lvl = computeInt(level, ctx)
try {
enchantItem(item, { id: Identifier.parse(id), lvl }, add)
} catch (e) {}
})
},
set_lore: ({ lore, replace }) => (item) => {
const lines: string[] = lore.flatMap((line: any) => line !== undefined ? [JSON.stringify(line)] : [])
const newLore = replace ? lines : [...item.tag.getCompound('display').getList('Lore', NbtType.String).map(s => s.getAsString()), ...lines]
getOrCreateTag(item, 'display').set('Lore', new NbtList(newLore.map(l => new NbtString(l))))
},
set_name: ({ name }) => (item) => {
if (name !== undefined) {
const newName = JSON.stringify(name)
getOrCreateTag(item, 'display').set('Name', new NbtString(newName))
}
},
set_nbt: ({ tag }) => (item) => {
try {
const newTag = NbtTag.fromString(tag)
if (newTag.isCompound()) {
item.tag = newTag
}
} catch (e) {}
},
set_potion: ({ id }) => (item) => {
if (typeof id === 'string') {
try {
item.tag.set('Potion', new NbtString(Identifier.parse(id).toString()))
} catch (e) {}
}
},
}
type LootCondition = (ctx: LootContext) => boolean
function composeConditions(conditions: any[]): LootCondition {
return (ctx) => {
for (const cond of conditions) {
if (!testCondition(cond, ctx)) {
return false
}
}
return true
}
}
function testCondition(condition: any, ctx: LootContext): boolean {
if (Array.isArray(condition)) {
return composeConditions(condition)(ctx)
}
const type = condition.condition?.replace(/^minecraft:/, '')
return (LootConditions[type]?.(condition) ?? (() => true))(ctx)
}
const LootConditions: Record<string, (params: any) => LootCondition> = {
alternative: params => LootConditions['any_of'](params),
all_of: ({ terms }) => (ctx) => {
if (!Array.isArray(terms) || terms.length === 0) return true
for (const term of terms) {
if (!testCondition(term, ctx)) {
return false
}
}
return true
},
any_of: ({ terms }) => (ctx) => {
if (!Array.isArray(terms) || terms.length === 0) return true
for (const term of terms) {
if (testCondition(term, ctx)) {
return true
}
}
return false
},
block_state_property: () => () => {
return false // TODO
},
damage_source_properties: ({ predicate }) => (ctx) => {
return testDamageSourcePredicate(predicate, ctx)
},
entity_properties: ({ predicate }) => (ctx) => {
return testEntityPredicate(predicate, ctx)
},
entity_scores: () => () => {
return false // TODO,
},
inverted: ({ term }) => (ctx) => {
return !testCondition(term, ctx)
},
killed_by_player: ({ inverted }) => () => {
return (inverted ?? false) === false // TODO
},
location_check: ({ predicate }) => (ctx) => {
return testLocationPredicate(predicate, ctx)
},
match_tool: ({ predicate }) => (ctx) => {
return testItemPredicate(predicate, ctx)
},
random_chance: ({ chance }) => (ctx) => {
return ctx.random.nextFloat() < chance
},
random_chance_with_looting: ({ chance, looting_multiplier }) => (ctx) => {
const level = 0 // TODO: get looting level from killer
const probability = chance + level * looting_multiplier
return ctx.random.nextFloat() < probability
},
reference: ({ name }) => (ctx) => {
const predicate = ctx.getPredicate(name) ?? []
if (Array.isArray(predicate)) {
return composeConditions(predicate)(ctx)
}
return testCondition(predicate, ctx)
},
survives_explosion: () => () => true,
table_bonus: ({ chances }) => (ctx) => {
const level = 0 // TODO: get enchantment level from tool
const chance = chances[clamp(level, 0, chances.length - 1)]
return ctx.random.nextFloat() < chance
},
time_check: ({ value, period }) => (ctx) => {
let time = ctx.dayTime
if (period !== undefined) {
time = time % period
}
const { min, max } = prepareIntRange(value, ctx)
return min <= time && time <= max
},
value_check: () => () => {
return false // TODO
},
weather_check: ({ raining, thundering }) => (ctx) => {
const isRaining = ctx.weather === 'rain' || ctx.weather === 'thunder'
const isThundering = ctx.weather === 'thunder'
if (raining !== undefined && raining !== isRaining) return false
if (thundering !== undefined && thundering !== isThundering) return false
return true
},
}
function computeInt(provider: any, ctx: LootContext): number {
if (typeof provider === 'number') return Math.round(provider)
if (!isObject(provider)) return 0
const type = provider.type?.replace(/^minecraft:/, '') ?? 'uniform'
switch (type) {
case 'constant':
return Math.round(provider.value ?? 0)
case 'uniform':
const min = computeInt(provider.min, ctx)
const max = computeInt(provider.max, ctx)
return max < min ? min : ctx.random.nextInt(max - min + 1) + min
case 'binomial':
const n = computeInt(provider.n, ctx)
const p = computeFloat(provider.p, ctx)
let result = 0
for (let i = 0; i < n; i += 1) {
if (ctx.random.nextFloat() < p) {
result += 1
}
}
return result
}
return 0
}
function computeFloat(provider: any, ctx: LootContext): number {
if (typeof provider === 'number') return provider
if (!isObject(provider)) return 0
const type = provider.type?.replace(/^minecraft:/, '') ?? 'uniform'
switch (type) {
case 'constant':
return provider.value ?? 0
case 'uniform':
const min = computeFloat(provider.min, ctx)
const max = computeFloat(provider.max, ctx)
return max < min ? min : ctx.random.nextFloat() * (max-min) + min
case 'binomial':
const n = computeInt(provider.n, ctx)
const p = computeFloat(provider.p, ctx)
let result = 0
for (let i = 0; i < n; i += 1) {
if (ctx.random.nextFloat() < p) {
result += 1
}
}
return result
}
return 0
}
function prepareIntRange(range: any, ctx: LootContext) {
if (typeof range === 'number') {
range = { min: range, max: range }
}
const min = computeInt(range.min, ctx)
const max = computeInt(range.max, ctx)
return { min, max }
}
function testItemPredicate(_predicate: any, _ctx: LootContext) {
return false // TODO
}
function testLocationPredicate(_predicate: any, _ctx: LootContext) {
return false // TODO
}
function testEntityPredicate(_predicate: any, _ctx: LootContext) {
return false // TODO
}
function testDamageSourcePredicate(_predicate: any, _ctx: LootContext) {
return false // TODO
}
function enchantItem(item: ItemStack, enchant: Enchant, additive?: boolean) {
const listKey = item.is('book') ? 'StoredEnchantments' : 'Enchantments'
if (!item.tag.hasList(listKey, NbtType.Compound)) {
item.tag.set(listKey, new NbtList())
}
const enchantments = item.tag.getList(listKey, NbtType.Compound).getItems()
let index = enchantments.findIndex((e: any) => e.id === enchant.id)
if (index !== -1) {
const oldEnch = enchantments[index]
oldEnch.set('lvl', new NbtShort(Math.max(additive ? oldEnch.getNumber('lvl') + enchant.lvl : enchant.lvl, 0)))
} else {
enchantments.push(new NbtCompound().set('id', new NbtString(enchant.id.toString())).set('lvl', new NbtShort(enchant.lvl)))
index = enchantments.length - 1
}
if (enchantments[index].getNumber('lvl') === 0) {
enchantments.splice(index, 1)
}
item.tag.set(listKey, new NbtList(enchantments))
}
function selectEnchantments(random: Random, item: ItemStack, levels: number, treasure: boolean): Enchant[] {
const enchantmentValue = item.getItem().enchantmentValue
if (enchantmentValue === undefined) {
return []
}
levels += 1 + random.nextInt(Math.floor(enchantmentValue / 4 + 1)) + random.nextInt(Math.floor(enchantmentValue / 4 + 1))
const f = (random.nextFloat() + random.nextFloat() - 1) * 0.15
levels = clamp(Math.round(levels + levels * f), 1, Number.MAX_SAFE_INTEGER)
let available = getAvailableEnchantments(item, levels, treasure)
if (available.length === 0) {
return []
}
const result: Enchant[] = []
const first = getWeightedRandom(random, available, getEnchantWeight)
if (first) result.push(first)
while (random.nextInt(50) <= levels) {
if (result.length > 0) {
const lastAdded = result[result.length - 1]
available = available.filter(a => Enchantment.isCompatible(Enchantment.REGISTRY.getOrThrow(a.id), Enchantment.REGISTRY.getOrThrow(lastAdded.id)))
}
if (available.length === 0) break
const ench = getWeightedRandom(random, available, getEnchantWeight)
if (ench) result.push(ench)
levels = Math.floor(levels / 2)
}
return result
}
const EnchantmentsRarityWeights = new Map(Object.entries<number>({
common: 10,
uncommon: 5,
rare: 2,
very_rare: 1,
}))
function getEnchantWeight(ench: Enchant) {
return EnchantmentsRarityWeights.get(Enchantment.REGISTRY.get(ench.id)?.rarity ?? 'common') ?? 10
}
function getAvailableEnchantments(item: ItemStack, levels: number, treasure: boolean): Enchant[] {
const result: Enchant[] = []
const isBook = item.is('book')
Enchantment.REGISTRY.forEach((id, ench) => {
if ((!ench.isTreasure || treasure) && ench.isDiscoverable && (Enchantment.canEnchant(item, ench) || isBook)) {
for (let lvl = ench.maxLevel; lvl > ench.minLevel - 1; lvl -= 1) {
if (levels >= ench.minCost(lvl) && levels <= ench.maxCost(lvl)) {
result.push({ id, lvl })
}
}
}
})
return result
}
interface Enchant {
id: Identifier,
lvl: number,
}
const AlwaysHasGlint = new Set([
'minecraft:debug_stick',
'minecraft:enchanted_golden_apple',
'minecraft:enchanted_book',
'minecraft:end_crystal',
'minecraft:experience_bottle',
'minecraft:written_book',
])
export function itemHasGlint(item: ItemStack) {
console.log(item)
if (AlwaysHasGlint.has(item.id.toString())) {
return true
}
if (item.is('compass') && (item.tag.has('LodestoneDimension') || item.tag.has('LodestonePos'))) {
return true
}
if ((item.is('potion') || item.is('splash_potion') || item.is('lingering_potion')) && (item.tag.has('Potion') || item.tag.has('CustomPotionEffects'))) {
return true
}
if (item.tag.getList('Enchantments').length > 0 || item.tag.getList('StoredEnchantments').length > 0) {
return true
}
return false
}
function getOrCreateTag(item: ItemStack, key: string) {
if (item.tag.hasCompound(key)) {
return item.tag.getCompound(key)
} else {
const tag = new NbtCompound()
item.tag.set(key, tag)
return tag
}
}

View File

@@ -1,15 +1,21 @@
import { DataModel } from '@mcschema/core'
import { useMemo, useRef, useState } from 'preact/hooks'
import { useLocale, useVersion } from '../../contexts/index.js'
import { clamp, randomSeed } from '../../Utils.js'
import { useAsync } from '../../hooks/useAsync.js'
import { checkVersion, fetchItemComponents } from '../../services/index.js'
import { clamp, jsonToNbt, randomSeed } from '../../Utils.js'
import { Btn, BtnMenu, NumberInput } from '../index.js'
import { ItemDisplay } from '../ItemDisplay.jsx'
import { ItemDisplay1204 } from '../ItemDisplay1204.jsx'
import type { PreviewProps } from './index.js'
import { generateLootTable } from './LootTable.js'
import { generateLootTable as generateLootTable1204 } from './LootTable1204.js'
export const LootTablePreview = ({ data }: PreviewProps) => {
const { locale } = useLocale()
const { version } = useVersion()
const use1204 = checkVersion(version, undefined, '1.20.4')
const [seed, setSeed] = useState(randomSeed())
const [luck, setLuck] = useState(0)
const [daytime, setDaytime] = useState(0)
@@ -18,18 +24,31 @@ export const LootTablePreview = ({ data }: PreviewProps) => {
const [advancedTooltips, setAdvancedTooltips] = useState(true)
const overlay = useRef<HTMLDivElement>(null)
const { value: itemComponents } = useAsync(() => {
return use1204 ? Promise.resolve(undefined) : fetchItemComponents(version)
}, [use1204, version])
const table = DataModel.unwrapLists(data)
const state = JSON.stringify(table)
const items = useMemo(() => {
return generateLootTable(table, { version, seed, luck, daytime, weather, stackMixer: mixItems ? 'container' : 'default' })
}, [version, seed, luck, daytime, weather, mixItems, state])
if (use1204) {
return generateLootTable1204(table, { version, seed, luck, daytime, weather, stackMixer: mixItems ? 'container' : 'default' })
} else {
if (itemComponents === undefined) {
return []
}
return generateLootTable(table, { version, seed, luck, daytime, weather, stackMixer: mixItems ? 'container' : 'default', getBaseComponents: (id) => new Map([...(itemComponents.get(id) ?? new Map()).entries()].map(([k, v]) => [k, jsonToNbt(v)])) })
}
}, [version, seed, luck, daytime, weather, mixItems, state, itemComponents])
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} />
{use1204 ?
<ItemDisplay1204 item={item as any} slotDecoration={true} advancedTooltip={advancedTooltips} /> :
<ItemDisplay item={item as any} slotDecoration={true} advancedTooltip={advancedTooltips} />}
</div>
)}
</div>

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
import { useLocale, useVersion } from '../../contexts/index.js'
import { useAsync } from '../../hooks/useAsync.js'
import { fetchAllPresets } from '../../services/index.js'
import { jsonToNbt } from '../../Utils.js'
import { Btn, BtnMenu } from '../index.js'
import { ItemDisplay } from '../ItemDisplay.jsx'
import type { PreviewProps } from './index.js'
@@ -172,18 +173,17 @@ function placeItems(recipe: any, animation: number, itemTags: Map<string, any>)
items.set(resultSlot, base)
}
} else if (typeof result === 'string') {
try {
items.set(resultSlot, new ItemStack(Identifier.parse(result), 1))
} catch (e) {}
items.set(resultSlot, new ItemStack(Identifier.parse(result), 1))
} else if (typeof result === 'object' && result !== null) {
const id = typeof result.id === 'string' ? result.id
: typeof result.item === 'string' ? result.item
: 'minecraft:air'
const count = typeof result.count === 'number' ? result.count : 1
// TODO: add components
try {
items.set(resultSlot, new ItemStack(Identifier.parse(id), count))
} catch (e) {}
if (id !== 'minecraft:air') {
const count = typeof result.count === 'number' ? result.count : 1
const components = new Map(Object.entries(result.components ?? {})
.map(([k, v]) => [k, jsonToNbt(v)]))
items.set(resultSlot, new ItemStack(Identifier.parse(id), count, components))
}
}
return items
@@ -195,19 +195,14 @@ function allIngredientChoices(ingredient: any, itemTags: Map<string, any>): Item
}
if (typeof ingredient === 'object' && ingredient !== null) {
if (typeof ingredient.item === 'string') {
try {
return [new ItemStack(Identifier.parse(ingredient.item), 1)]
} catch (e) {}
return [new ItemStack(Identifier.parse(ingredient.item), 1)]
} else if (typeof ingredient.tag === 'string') {
const tag: any = itemTags.get(ingredient.tag.replace(/^minecraft:/, ''))
if (typeof tag === 'object' && tag !== null && Array.isArray(tag.values)) {
return tag.values.flatMap((value: any) => {
if (typeof value !== 'string') return []
if (value.startsWith('#')) return allIngredientChoices({ tag: value.slice(1) }, itemTags)
try {
return [new ItemStack(Identifier.parse(value), 1)]
} catch (e) {}
return []
return [new ItemStack(Identifier.parse(value), 1)]
})
}
}

View File

@@ -120,6 +120,31 @@ export async function fetchBlockStates(versionId: VersionId) {
return result
}
export async function fetchItemComponents(versionId: VersionId) {
console.debug(`[fetchItemComponents] ${versionId}`)
const version = config.versions.find(v => v.id === versionId)!
const result = new Map<string, Map<string, unknown>>()
try {
const data = await cachedFetch<Record<string, Record<string, unknown>>>(`${mcmeta(version, 'summary')}/item_components/data.min.json`)
for (const [id, components] of Object.entries(data)) {
const base = new Map<string, unknown>()
if (Array.isArray(components)) { // syntax before 1.21
for (const entry of components) {
base.set(entry.type, entry.value)
}
} else {
for (const [key, value] of Object.entries(components)) {
base.set(key, value)
}
}
result.set('minecraft:' + id, base)
}
} catch (e) {
console.warn('Error occurred while fetching item components:', message(e))
}
return result
}
export async function fetchPreset(versionId: VersionId, registry: string, id: string) {
console.debug(`[fetchPreset] ${versionId} ${registry} ${id}`)
const version = config.versions.find(v => v.id === versionId)!

View File

@@ -0,0 +1,194 @@
import type { NbtTag } from 'deepslate'
import { Identifier, ItemStack } from 'deepslate'
export class ResolvedItem extends ItemStack {
constructor(
item: ItemStack,
public base: ReadonlyMap<string, NbtTag>,
) {
super(item.id, item.count, item.components)
}
public static create(id: string | Identifier, count: number, components: Map<string, NbtTag>, baseGetter: (id: string) => ReadonlyMap<string, NbtTag>) {
if (typeof id === 'string') {
id = Identifier.parse(id)
}
const item = new ItemStack(id, count, components)
return new ResolvedItem(item, baseGetter(id.toString()))
}
public clone(): ResolvedItem {
return new ResolvedItem(super.clone(), this.base)
}
public flatten(): ItemStack {
const components = new Map(this.base)
for (const [key, value] of this.components) {
if (key.startsWith('!')) {
components.delete(key.slice(1))
} else {
components.set(key, value)
}
}
return new ItemStack(this.id, this.count, components)
}
private getTag(key: string) {
key = Identifier.parse(key).toString()
if (this.components.has(key)) {
return this.components.get(key)
}
if (this.components.has(`!${key}`)) {
return undefined
}
return this.base.get(key)
}
public get<T>(key: string, reader: (tag: NbtTag) => T) {
const tag = this.getTag(key)
return tag === undefined ? undefined : reader(tag)
}
public has(key: string) {
return this.getTag(key) !== undefined
}
public set(key: string, tag: NbtTag) {
key = Identifier.parse(key).toString()
this.components.set(key, tag)
}
public getSize() {
const keys = new Set(this.base.keys())
for (const key of this.components.keys()) {
if (key.startsWith('!')) {
keys.delete(key.slice(1))
} else {
keys.add(key)
}
}
return keys.size
}
public getMaxDamage() {
return this.get('max_damage', tag => tag.getAsNumber()) ?? 0
}
public getDamage() {
return this.get('damage', tag => tag.getAsNumber()) ?? 0
}
public isDamageable() {
return this.has('max_damage') && this.has('damage') && !this.has('unbreakable')
}
public isDamaged() {
return this.isDamageable() && this.getDamage() > 0
}
public getMaxStackSize() {
return this.get('max_stack_size', tag => tag.getAsNumber()) ?? 1
}
public isStackable() {
return this.getMaxStackSize() > 1 && (!this.isDamageable() || !this.isDamaged())
}
public isEnchanted() {
return this.get('enchantments', tag => {
return tag.isCompound() ? tag.has('levels') ? tag.getCompound('levels').size > 0 : tag.size > 0 : false
}) ?? false
}
public hasFoil() {
return this.has('enchantment_glint_override') || this.isEnchanted()
}
public getLore() {
return this.get('lore', tag => {
return tag.isList() ? tag.map(e => e.getAsString()) : []
}) ?? []
}
public showInTooltip(key: string) {
return this.get(key, tag => {
return tag.isCompound() && tag.has('show_in_tooltip') ? tag.getBoolean('show_in_tooltip') !== false : true
}) ?? false
}
public getRarity() {
const rarity = this.get('rarity', tag => tag.isString() ? tag.getAsString() : undefined) ?? 'common'
if (!this.isEnchanted()) {
return rarity
}
if (rarity === 'common' || rarity === 'uncommon') {
return 'rare'
}
if (rarity === 'rare') {
return 'epic'
}
return rarity
}
public getRarityColor() {
const rarity = this.getRarity()
if (rarity === 'epic') {
return 'light_purple'
} else if (rarity === 'rare') {
return 'aqua'
} else if (rarity === 'uncommon') {
return 'yellow'
} else {
return 'white'
}
}
public getHoverName() {
const customName = this.get('custom_name', tag => tag.isString() ? tag.getAsString() : undefined)
if (customName) {
try {
return JSON.parse(customName)
} catch (e) {
return '(invalid custom name)'
}
}
const bookTitle = this.get('written_book_content', tag => tag.isCompound() ? (tag.hasCompound('title') ? tag.getCompound('title').getString('raw') : tag.getString('title')) : undefined)
if (bookTitle && bookTitle.length > 0) {
return { text: bookTitle }
}
const itemName = this.get('item_name', tag => tag.isString() ? tag.getAsString() : undefined)
try {
if (itemName) {
return JSON.parse(itemName)
}
} catch (e) {}
const guess = this.id.path
.replace(/[_\/]/g, ' ')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
return { text: guess }
}
public getStyledHoverName() {
return { text: '', extra: [this.getHoverName()], color: this.getRarityColor(), italic: this.has('custom_name') }
}
public getDisplayName() {
// Does not use translation key "chat.square_brackets" due to limitations of TextComponent
return { text: '[', extra: [this.getStyledHoverName(), ']'], color: this.getRarityColor() }
}
public getChargedProjectile() {
return this.get('charged_projectiles', tag => {
if (!tag.isList() || tag.length === 0) {
return undefined
}
return ItemStack.fromNbt(tag.getCompound(0))
})
}
}

View File

@@ -0,0 +1,248 @@
import type { BlockDefinitionProvider, BlockFlagsProvider, BlockModelProvider, BlockPropertiesProvider, ItemStack, TextureAtlasProvider, UV } from 'deepslate-1.20.4/render'
import { BlockDefinition, BlockModel, Identifier, ItemRenderer, TextureAtlas, upperPowerOfTwo } from 'deepslate-1.20.4/render'
import config from '../Config.js'
import { message } from '../Utils.js'
import { fetchLanguage, fetchResources } from './DataFetcher.js'
import type { VersionId } from './Schemas.js'
const Resources: Record<string, ResourceManager | Promise<ResourceManager>> = {}
export async function getResources(version: VersionId) {
if (!Resources[version]) {
Resources[version] = (async () => {
try {
const { blockDefinitions, models, uvMapping, atlas} = await fetchResources(version)
Resources[version] = new ResourceManager(blockDefinitions, models, uvMapping, atlas)
return Resources[version]
} catch (e) {
console.error('Error: ', e)
throw new Error(`Cannot get resources for version ${version}: ${message(e)}`)
}
})()
return Resources[version]
}
return Resources[version]
}
const RENDER_SIZE = 128
const ItemRenderCache = new Map<string, Promise<string>>()
export async function renderItem(version: VersionId, item: ItemStack) {
const cache_key = `${version} ${item.toString()}`
const cached = ItemRenderCache.get(cache_key)
if (cached !== undefined) {
return cached
}
const promise = (async () => {
const canvas = document.createElement('canvas')
canvas.width = RENDER_SIZE
canvas.height = RENDER_SIZE
const resources = await getResources(version)
const gl = canvas.getContext('webgl2', { preserveDrawingBuffer: true })
if (!gl) {
throw new Error('Cannot get WebGL2 context')
}
const renderer = new ItemRenderer(gl, item, resources)
console.log('Rendering', item.toString())
renderer.drawItem()
return canvas.toDataURL()
})()
ItemRenderCache.set(cache_key, promise)
return promise
}
interface Resources extends BlockDefinitionProvider, BlockModelProvider, TextureAtlasProvider, BlockFlagsProvider, BlockPropertiesProvider {}
export class ResourceManager implements Resources {
private readonly blockDefinitions: { [id: string]: BlockDefinition }
private readonly blockModels: { [id: string]: BlockModel }
private textureAtlas: TextureAtlas
constructor(blockDefinitions: Map<string, unknown>, models: Map<string, unknown>, uvMapping: any, textureAtlas: HTMLImageElement) {
this.blockDefinitions = {}
this.blockModels = {}
this.textureAtlas = TextureAtlas.empty()
this.loadBlockDefinitions(blockDefinitions)
this.loadBlockModels(models)
this.loadBlockAtlas(textureAtlas, uvMapping)
}
public getBlockDefinition(id: Identifier) {
return this.blockDefinitions[id.toString()]
}
public getBlockModel(id: Identifier) {
return this.blockModels[id.toString()]
}
public getTextureUV(id: Identifier) {
return this.textureAtlas.getTextureUV(id)
}
public getTextureAtlas() {
return this.textureAtlas.getTextureAtlas()
}
public getBlockFlags() {
return { opaque: false }
}
public getBlockProperties() {
return null
}
public getDefaultBlockProperties() {
return null
}
private loadBlockModels(models: Map<string, unknown>) {
[...models.entries()].forEach(([id, model]) => {
this.blockModels[Identifier.create(id).toString()] = BlockModel.fromJson(id, model)
})
Object.values(this.blockModels).forEach(m => m.flatten(this))
}
private loadBlockDefinitions(definitions: Map<string, unknown>) {
[...definitions.entries()].forEach(([id, definition]) => {
this.blockDefinitions[Identifier.create(id).toString()] = BlockDefinition.fromJson(id, definition)
})
}
private loadBlockAtlas(image: HTMLImageElement, textures: any) {
const atlasCanvas = document.createElement('canvas')
const w = upperPowerOfTwo(image.width)
const h = upperPowerOfTwo(image.height)
atlasCanvas.width = w
atlasCanvas.height = h
const ctx = atlasCanvas.getContext('2d')!
ctx.drawImage(image, 0, 0)
const imageData = ctx.getImageData(0, 0, w, h)
const idMap: Record<string, UV> = {}
Object.keys(textures).forEach(id => {
const [u, v, du, dv] = textures[id]
const dv2 = (du !== dv && id.startsWith('block/')) ? du : dv
idMap[Identifier.create(id).toString()] = [u / w, v / h, (u + du) / w, (v + dv2) / h]
})
this.textureAtlas = new TextureAtlas(imageData, idMap)
}
}
export class ResourceWrapper implements Resources {
constructor(
private readonly wrapped: Resources,
private readonly overrides: Partial<Resources>,
) {}
public getBlockDefinition(id: Identifier) {
return this.overrides.getBlockDefinition?.(id) ?? this.wrapped.getBlockDefinition(id)
}
public getBlockModel(id: Identifier) {
return this.overrides.getBlockModel?.(id) ?? this.wrapped.getBlockModel(id)
}
public getTextureUV(texture: Identifier) {
return this.overrides.getTextureUV?.(texture) ?? this.wrapped.getTextureUV(texture)
}
public getTextureAtlas() {
return this.overrides.getTextureAtlas?.() ?? this.wrapped.getTextureAtlas()
}
public getBlockFlags(id: Identifier) {
return this.overrides.getBlockFlags?.(id) ?? this.wrapped.getBlockFlags(id)
}
public getBlockProperties(id: Identifier) {
return this.overrides.getBlockProperties?.(id) ?? this.wrapped.getBlockProperties(id)
}
public getDefaultBlockProperties(id: Identifier) {
return this.overrides.getDefaultBlockProperties?.(id) ?? this.wrapped.getDefaultBlockProperties(id)
}
}
export type Language = Record<string, string>
const Languages: Record<string, Language | Promise<Language>> = {}
export async function getLanguage(version: VersionId, lang: string = 'en') {
const mcLang = config.languages.find(l => l.code === lang)?.mc ?? 'en_us'
const cacheKey = `${version}_${mcLang}`
if (!Languages[cacheKey]) {
Languages[cacheKey] = (async () => {
try {
Languages[cacheKey] = await fetchLanguage(version, mcLang)
return Languages[cacheKey]
} catch (e) {
console.error('Error: ', e)
throw new Error(`Cannot get language '${mcLang}' for version ${version}: ${message(e)}`)
}
})()
return Languages[cacheKey]
}
return Languages[cacheKey]
}
export function getTranslation(lang: Language, key: string, params?: string[]) {
const str = lang[key]
if (!str) return undefined
return replaceTranslation(str, params)
}
export function replaceTranslation(src: string, params?: string[]) {
let out = ''
let i = 0
let p = 0
while (i < src.length) {
const c0 = src[i++]
if (c0 === '%') { // percent character
if (i >= src.length) { // INVALID: %<end>
out += c0
break
}
let c1 = src[i++]
if (c1 === '%') { // escape
out += '%'
} else if (c1 === 's' || c1 === 'd') { // short form %s
out += params?.[p++] ?? ''
} else if (c1 >= '0' && c1 <= '9') {
if (i >= src.length) { // INVALID: %2<end>
out += c0 + c1
break
}
let num = ''
do {
num += c1
c1 = src[i++]
} while (i < src.length && c1 >= '0' && c1 <= '9')
if (c1 === '$') {
if (i >= src.length) { // INVALID: %2$<end>
out += c0 + num + c1
break
}
const c2 = src[i++]
if (c2 === 's' || c2 === 'd') { // long form %2$s
const pos = parseInt(num) - 1
if (!params || isNaN(pos) || pos < 0 || pos >= params.length) {
out += ''
} else {
out += params[pos]
}
} else { // INVALID: %2$...
out += c0 + num + c1
}
} else { // INVALID: %2...
out += c0 + num
}
} else { // INVALID: %...
out += c0
}
} else { // normal character
out += c0
}
}
return out
}

View File

@@ -1,6 +1,7 @@
import { NbtByte, NbtCompound, NbtDouble, NbtInt, NbtList, NbtString, NbtTag } from 'deepslate'
import { NbtTag } from 'deepslate'
import yaml from 'js-yaml'
import { Store } from '../Store.js'
import { jsonToNbt } from '../Utils.js'
const INDENTS: Record<string, number | string | undefined> = {
'2_spaces': 2,
@@ -40,28 +41,6 @@ const FORMATS: Record<string, {
},
}
function jsonToNbt(value: unknown): NbtTag {
if (typeof value === 'string') {
return new NbtString(value)
}
if (typeof value === 'number') {
return Number.isInteger(value) ? new NbtInt(value) : new NbtDouble(value)
}
if (typeof value === 'boolean') {
return new NbtByte(value)
}
if (Array.isArray(value)) {
return new NbtList(value.map(jsonToNbt))
}
if (typeof value === 'object' && value !== null) {
return new NbtCompound(
new Map(Object.entries(value ?? {})
.map(([k, v]) => [k, jsonToNbt(v)]))
)
}
throw new Error(`Could not convert ${value} to NBT`)
}
export function stringifySource(data: unknown, format?: string, indent?: string) {
return FORMATS[format ?? Store.getFormat()].stringify(data, INDENTS[indent ?? Store.getIndent()])
}