mirror of
https://github.com/misode/misode.github.io.git
synced 2026-04-23 07:10:41 +00:00
Add loot table preview (#293)
* Initial loot table preview + item display counts * Add loot functions without NBT * Add loot conditions * Render item tooltips with name and lore components * Remove debug text component * Disable advanced tooltips in the tree * Minor style fixes * Add item slot overlay and tweak tooltip offset * Fix some items not rendering * Translate item names and text components * Translate params + more functions and tooltips * Add durability bar * Configurable stack mixing * Correct tooltip background and border * Add enchanting * Enchantment glint * Configurable luck, daytime and weather * Improve tooltip spacing * More tooltip spacing improvements * Remove debug logging
This commit is contained in:
14
package-lock.json
generated
14
package-lock.json
generated
@@ -22,7 +22,7 @@
|
||||
"brace": "^0.11.1",
|
||||
"buffer": "^6.0.3",
|
||||
"comment-json": "^4.1.1",
|
||||
"deepslate": "^0.13.0",
|
||||
"deepslate": "^0.13.2",
|
||||
"deepslate-1.18": "npm:deepslate@^0.9.0-beta.9",
|
||||
"deepslate-1.18.2": "npm:deepslate@^0.9.0-beta.13",
|
||||
"highlight.js": "^11.5.1",
|
||||
@@ -1933,9 +1933,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/deepslate": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.13.0.tgz",
|
||||
"integrity": "sha512-16Dh/dOc8RLtiL0aQ3/h7bHUcIer+jAfFXehavhvHquEkxncQyZDq9YGktPTm9gTD2VGTDZeuIwZWAiMYkdfqw==",
|
||||
"version": "0.13.2",
|
||||
"resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.13.2.tgz",
|
||||
"integrity": "sha512-6pa9mgPu4A+RqYoN7AH79oKzzSNfvCJsrBKHE+AQjt20Uo33qJIRNG+2+sFHx84PAPJ3Z1CCnWWV+kBniD8E2g==",
|
||||
"dependencies": {
|
||||
"gl-matrix": "^3.3.0",
|
||||
"md5": "^2.3.0",
|
||||
@@ -6621,9 +6621,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"deepslate": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.13.0.tgz",
|
||||
"integrity": "sha512-16Dh/dOc8RLtiL0aQ3/h7bHUcIer+jAfFXehavhvHquEkxncQyZDq9YGktPTm9gTD2VGTDZeuIwZWAiMYkdfqw==",
|
||||
"version": "0.13.2",
|
||||
"resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.13.2.tgz",
|
||||
"integrity": "sha512-6pa9mgPu4A+RqYoN7AH79oKzzSNfvCJsrBKHE+AQjt20Uo33qJIRNG+2+sFHx84PAPJ3Z1CCnWWV+kBniD8E2g==",
|
||||
"requires": {
|
||||
"gl-matrix": "^3.3.0",
|
||||
"md5": "^2.3.0",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"brace": "^0.11.1",
|
||||
"buffer": "^6.0.3",
|
||||
"comment-json": "^4.1.1",
|
||||
"deepslate": "^0.13.0",
|
||||
"deepslate": "^0.13.2",
|
||||
"deepslate-1.18": "npm:deepslate@^0.9.0-beta.9",
|
||||
"deepslate-1.18.2": "npm:deepslate@^0.9.0-beta.13",
|
||||
"highlight.js": "^11.5.1",
|
||||
|
||||
BIN
public/fonts/seven.ttf
Normal file
BIN
public/fonts/seven.ttf
Normal file
Binary file not shown.
BIN
public/images/container.png
Normal file
BIN
public/images/container.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 417 B |
BIN
public/images/glint.png
Normal file
BIN
public/images/glint.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
public/images/tooltip.png
Normal file
BIN
public/images/tooltip.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 B |
@@ -1,6 +1,7 @@
|
||||
import type { DataModel } from '@mcschema/core'
|
||||
import { Path } from '@mcschema/core'
|
||||
import * as zip from '@zip.js/zip.js'
|
||||
import type { Random } from 'deepslate/core'
|
||||
import yaml from 'js-yaml'
|
||||
import { route } from 'preact-router'
|
||||
import rfdc from 'rfdc'
|
||||
@@ -337,3 +338,21 @@ export async function computeIfAbsentAsync<K, V>(map: Map<K, V>, key: K, getter:
|
||||
map.set(key, value)
|
||||
return value
|
||||
}
|
||||
|
||||
export function getWeightedRandom<T>(random: Random, entries: T[], getWeight: (entry: T) => number) {
|
||||
let totalWeight = 0
|
||||
for (const entry of entries) {
|
||||
totalWeight += getWeight(entry)
|
||||
}
|
||||
if (totalWeight <= 0) {
|
||||
return undefined
|
||||
}
|
||||
let n = random.nextInt(totalWeight)
|
||||
for (const entry of entries) {
|
||||
n -= getWeight(entry)
|
||||
if (n < 0) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,55 +1,100 @@
|
||||
import { useState } from 'preact/hooks'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { useVersion } from '../contexts/Version.jsx'
|
||||
import { useAsync } from '../hooks/useAsync.js'
|
||||
import type { Item } from '../previews/LootTable.js'
|
||||
import { MaxDamageItems } from '../previews/LootTable.js'
|
||||
import { getAssetUrl } from '../services/DataFetcher.js'
|
||||
import { renderItem } from '../services/Resources.js'
|
||||
import { getCollections } from '../services/Schemas.js'
|
||||
import { ItemTooltip } from './ItemTooltip.jsx'
|
||||
import { Octicon } from './Octicon.jsx'
|
||||
|
||||
interface Props {
|
||||
item: string,
|
||||
item: Item,
|
||||
slotDecoration?: boolean,
|
||||
advancedTooltip?: boolean,
|
||||
}
|
||||
export function ItemDisplay({ item }: Props) {
|
||||
export function ItemDisplay({ item, slotDecoration, advancedTooltip }: Props) {
|
||||
const el = useRef<HTMLDivElement>(null)
|
||||
const [tooltipOffset, setTooltipOffset] = useState<[number, number]>([0, 0])
|
||||
const [tooltipSwap, setTooltipSwap] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
requestAnimationFrame(() => {
|
||||
const { right, width } = el.current!.getBoundingClientRect()
|
||||
const swap = right + 200 > document.body.clientWidth
|
||||
setTooltipSwap(swap)
|
||||
setTooltipOffset([(swap ? width - e.offsetX : e.offsetX) + 20, e.offsetY - 40])
|
||||
})
|
||||
}
|
||||
el.current?.addEventListener('mousemove', onMove)
|
||||
return () => el.current?.removeEventListener('mousemove', onMove)
|
||||
}, [])
|
||||
|
||||
const maxDamage = MaxDamageItems.get(item.id)
|
||||
|
||||
return <div class="item-display" ref={el}>
|
||||
<ItemItself item={item} />
|
||||
{item.count !== 1 && <>
|
||||
<svg class="item-count" width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="xMinYMid meet">
|
||||
<text x="95" y="93" font-size="50" textAnchor="end" fontFamily="MinecraftSeven" fill="#373737">{item.count}</text>
|
||||
<text x="90" y="88" font-size="50" textAnchor="end" fontFamily="MinecraftSeven" fill="#ffffff">{item.count}</text>
|
||||
</svg>
|
||||
</>}
|
||||
{slotDecoration && <>
|
||||
{(maxDamage && (item.tag?.Damage ?? 0) > 0) && <svg class="item-durability" width="100%" height="100%" viewBox="0 0 18 18">
|
||||
<rect x="3" y="14" width="13" height="2" fill="#000" />
|
||||
<rect x="3" y="14" width={`${(maxDamage - item.tag.Damage) / maxDamage * 13}`} height="1" fill={`hsl(${(maxDamage - item.tag.Damage) / maxDamage * 120}deg, 100%, 50%)`} />
|
||||
</svg>}
|
||||
<div class="item-slot-overlay"></div>
|
||||
</>}
|
||||
<ItemTooltip {...item} advanced={advancedTooltip} offset={tooltipOffset} swap={tooltipSwap} />
|
||||
</div>
|
||||
}
|
||||
|
||||
function ItemItself({ item }: Props) {
|
||||
const { version } = useVersion()
|
||||
const [errored, setErrored] = useState(false)
|
||||
|
||||
if (errored || (item.includes(':') && !item.startsWith('minecraft:'))) {
|
||||
return <div class="item-display">
|
||||
{Octicon.package}
|
||||
</div>
|
||||
const isEnchanted = (item.tag?.Enchantments?.length ?? 0) > 0 || (item.tag?.StoredEnchantments?.length ?? 0) > 0
|
||||
|
||||
if (errored || (item.id.includes(':') && !item.id.startsWith('minecraft:'))) {
|
||||
return Octicon.package
|
||||
}
|
||||
|
||||
const { value: collections } = useAsync(() => getCollections(version), [])
|
||||
|
||||
if (collections === undefined) {
|
||||
return <div class="item-display"></div>
|
||||
return null
|
||||
}
|
||||
|
||||
const texturePath = `item/${item.replace(/^minecraft:/, '')}`
|
||||
const texturePath = `item/${item.id.replace(/^minecraft:/, '')}`
|
||||
if (collections.get('texture').includes('minecraft:' + texturePath)) {
|
||||
return <div class="item-display">
|
||||
<img src={getAssetUrl(version, 'textures', texturePath)} alt="" onError={() => setErrored(true)} />
|
||||
</div>
|
||||
const src = getAssetUrl(version, 'textures', texturePath)
|
||||
return <>
|
||||
<img src={src} alt="" onError={() => setErrored(true)} draggable={false} />
|
||||
{isEnchanted && <div class="item-glint" style={{'--mask-image': `url("${src}")`}}></div>}
|
||||
</>
|
||||
}
|
||||
|
||||
const modelPath = `block/${item.replace(/^minecraft:/, '')}`
|
||||
const modelPath = `item/${item.id.replace(/^minecraft:/, '')}`
|
||||
if (collections.get('model').includes('minecraft:' + modelPath)) {
|
||||
return <div class="item-display">
|
||||
<RenderedItem item={item} />
|
||||
</div>
|
||||
return <RenderedItem item={item} isEnchanted={isEnchanted} />
|
||||
}
|
||||
|
||||
return <div class="item-display">
|
||||
{Octicon.package}
|
||||
</div>
|
||||
return Octicon.package
|
||||
}
|
||||
|
||||
function RenderedItem({ item }: Props) {
|
||||
function RenderedItem({ item, isEnchanted }: Props & { isEnchanted: boolean }) {
|
||||
const { version } = useVersion()
|
||||
const { value: src } = useAsync(() => renderItem(version, item), [version, item])
|
||||
const { value: src } = useAsync(() => renderItem(version, item.id), [version, item])
|
||||
|
||||
if (src) {
|
||||
return <img src={src} alt={item} />
|
||||
return <>
|
||||
<img src={src} alt={item.id} class="model" draggable={false} />
|
||||
{isEnchanted && <div class="item-glint" style={{'--mask-image': `url("${src}")`}}></div>}
|
||||
</>
|
||||
}
|
||||
|
||||
return <div class="item-display">
|
||||
|
||||
57
src/app/components/ItemTooltip.tsx
Normal file
57
src/app/components/ItemTooltip.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useVersion } from '../contexts/Version.jsx'
|
||||
import { useAsync } from '../hooks/useAsync.js'
|
||||
import { getEnchantmentData, MaxDamageItems } from '../previews/LootTable.js'
|
||||
import { getTranslation } from '../services/Resources.js'
|
||||
import { TextComponent } from './TextComponent.jsx'
|
||||
|
||||
interface Props {
|
||||
id: string,
|
||||
tag?: any,
|
||||
advanced?: boolean,
|
||||
offset?: [number, number],
|
||||
swap?: boolean,
|
||||
}
|
||||
export function ItemTooltip({ id, tag, advanced, offset = [0, 0], swap }: Props) {
|
||||
const { version } = useVersion()
|
||||
const { value: translatedName } = useAsync(() => {
|
||||
const key = id.split(':').join('.')
|
||||
return getTranslation(version, `item.${key}`) ?? getTranslation(version, `block.${key}`)
|
||||
}, [version, id])
|
||||
const displayName = tag?.display?.Name
|
||||
const name = displayName ? JSON.parse(displayName) : (translatedName ?? fakeTranslation(id))
|
||||
|
||||
const maxDamage = MaxDamageItems.get(id)
|
||||
|
||||
return <div class="item-tooltip" style={offset && {
|
||||
left: (swap ? undefined : `${offset[0]}px`),
|
||||
right: (swap ? `${offset[0]}px` : undefined),
|
||||
top: `${offset[1]}px`,
|
||||
}}>
|
||||
<TextComponent component={name} base={{ color: 'white' }} />
|
||||
{tag?.Enchantments?.map(({ id, lvl }: { id: string, lvl: number }) => {
|
||||
const ench = getEnchantmentData(id)
|
||||
const component: any[] = [{ translate: `enchantment.${id.replace(':', '.')}`, color: ench?.curse ? 'red' : 'gray' }]
|
||||
if (lvl !== 1 || ench?.maxLevel !== 1) {
|
||||
component.push(' ', { translate: `enchantment.level.${lvl}`})
|
||||
}
|
||||
return <TextComponent component={component} />
|
||||
})}
|
||||
{tag?.display && <>
|
||||
{tag?.display?.color && (advanced
|
||||
? <TextComponent component={{ translate: 'item.color', with: [`#${tag.display.color.toString(16).padStart(6, '0')}`], color: 'gray' }} />
|
||||
: <TextComponent component={{ translate: 'item.dyed', color: 'gray' }} />)}
|
||||
{(tag?.display?.Lore ?? []).map((line: any) => <TextComponent component={JSON.parse(line)} base={{ color: 'dark_purple', italic: true }} />)}
|
||||
</>}
|
||||
{tag?.Unbreakable === true && <TextComponent component={{ translate: 'item.unbreakable', color: 'blue' }} />}
|
||||
{(advanced && (tag?.Damage ?? 0) > 0 && maxDamage) && <TextComponent component={{ translate: 'item.durability', with: [`${maxDamage - tag.Damage}`, `${maxDamage}`] }} />}
|
||||
{advanced && <>
|
||||
<TextComponent component={{ text: id, color: 'dark_gray'}} />
|
||||
{tag && <TextComponent component={{ translate: 'item.nbt_tags', with: [Object.keys(tag).length], color: 'dark_gray' }} />}
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function fakeTranslation(str: string) {
|
||||
const raw = str.replace(/minecraft:/, '').replaceAll('_', ' ')
|
||||
return raw[0].toUpperCase() + raw.slice(1)
|
||||
}
|
||||
129
src/app/components/TextComponent.tsx
Normal file
129
src/app/components/TextComponent.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useMemo } from 'preact/hooks'
|
||||
import { useVersion } from '../contexts/Version.jsx'
|
||||
import { useAsync } from '../hooks/useAsync.js'
|
||||
import { getTranslation } from '../services/Resources.js'
|
||||
|
||||
interface StyleData {
|
||||
color?: string,
|
||||
bold?: boolean,
|
||||
italic?: boolean,
|
||||
underlined?: boolean,
|
||||
strikethrough?: boolean,
|
||||
}
|
||||
|
||||
interface PartData extends StyleData {
|
||||
text?: string,
|
||||
translate?: string,
|
||||
with?: string[],
|
||||
}
|
||||
|
||||
interface Props {
|
||||
component: unknown,
|
||||
base?: StyleData,
|
||||
shadow?: boolean,
|
||||
}
|
||||
export function TextComponent({ component, base = { color: 'white' }, shadow = true }: Props) {
|
||||
const state = JSON.stringify(component)
|
||||
const parts = useMemo(() => {
|
||||
const parts: PartData[] = []
|
||||
visitComponent(component, el => parts.push(el))
|
||||
return parts
|
||||
}, [state])
|
||||
|
||||
return <div class="text-component">
|
||||
{shadow && <div style={createStyle(base, true)}>
|
||||
{parts.map(p => <TextPart part={p} shadow={true} />)}
|
||||
</div>}
|
||||
<div class="text-foreground" style={createStyle(base, false)}>
|
||||
{parts.map(p => <TextPart part={p} />)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function visitComponent(component: unknown, consumer: (c: PartData) => void) {
|
||||
if (typeof component === 'string' || typeof component === 'number') {
|
||||
consumer({ text: component.toString() })
|
||||
} else if (Array.isArray(component)) {
|
||||
const base = component[0]
|
||||
visitComponent(base, consumer)
|
||||
for (const c of component.slice(1)) {
|
||||
visitComponent(c, d => consumer(inherit(d, base)))
|
||||
}
|
||||
} else if (typeof component === 'object' && component !== null) {
|
||||
if ('text' in component) {
|
||||
consumer(component)
|
||||
} else if ('translate' in component) {
|
||||
consumer(component)
|
||||
} else if ('score' in component) {
|
||||
consumer({ ...component, text: '123' })
|
||||
} else if ('selector' in component) {
|
||||
consumer({ ...component, text: 'Steve' })
|
||||
} else if ('keybind' in component) {
|
||||
consumer({ ...component, text: (component as any).keybind })
|
||||
} else if ('nbt' in component) {
|
||||
consumer({ ...component, text: (component as any).nbt })
|
||||
}
|
||||
if ('extra' in component) {
|
||||
for (const e of (component as any).extra) {
|
||||
visitComponent(e, c => consumer(inherit(c, component)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function inherit(component: object, base: PartData) {
|
||||
return {
|
||||
color: base.color,
|
||||
bold: base.bold,
|
||||
italic: base.italic,
|
||||
underlined: base.underlined,
|
||||
strikethrough: base.strikethrough,
|
||||
...component,
|
||||
}
|
||||
}
|
||||
|
||||
const TextColors = {
|
||||
black: ['#000', '#000'],
|
||||
dark_blue: ['#00A', '#00002A'],
|
||||
dark_green: ['#0A0', '#002A00'],
|
||||
dark_aqua: ['#0AA', '#002A2A'],
|
||||
dark_red: ['#A00', '#2A0000'],
|
||||
dark_purple: ['#A0A', '#2A002A'],
|
||||
gold: ['#FA0', '#2A2A00'],
|
||||
gray: ['#AAA', '#2A2A2A'],
|
||||
dark_gray: ['#555', '#151515'],
|
||||
blue: ['#55F', '#15153F'],
|
||||
green: ['#5F5', '#153F15'],
|
||||
aqua: ['#5FF', '#153F3F'],
|
||||
red: ['#F55', '#3F1515'],
|
||||
light_purple: ['#F5F', '#3F153F'],
|
||||
yellow: ['#FF5', '#3F3F15'],
|
||||
white: ['#FFF', '#3F3F3F'],
|
||||
}
|
||||
|
||||
type TextColorKey = keyof typeof TextColors
|
||||
const TextColorKeys = Object.keys(TextColors)
|
||||
|
||||
function TextPart({ part, shadow }: { part: PartData, shadow?: boolean }) {
|
||||
if (part.translate) {
|
||||
const { version } = useVersion()
|
||||
const { value: translated } = useAsync(() => {
|
||||
return getTranslation(version, part.translate!, part.with)
|
||||
}, [version, part.translate, ...part.with ?? []])
|
||||
return <span style={createStyle(part, shadow)}>{translated ?? part.translate}</span>
|
||||
}
|
||||
return <span style={createStyle(part, shadow)}>{part.text}</span>
|
||||
}
|
||||
|
||||
function createStyle(style: StyleData, shadow?: boolean) {
|
||||
return {
|
||||
color: style.color && (TextColorKeys.includes(style.color)
|
||||
? TextColors[style.color as TextColorKey][shadow ? 1 : 0]
|
||||
: shadow ? 'transparent' : style.color),
|
||||
fontWeight: (style.bold === true) ? 'bold' : undefined,
|
||||
fontStyle: (style.italic === true) ? 'italic' : undefined,
|
||||
textDecoration: (style.underlined === true)
|
||||
? (style.strikethrough === true) ? 'underline line-through' : 'underline'
|
||||
: (style.strikethrough === true) ? 'line-through' : undefined,
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import { useModel } from '../../hooks/index.js'
|
||||
import type { VersionId } from '../../services/index.js'
|
||||
import { checkVersion } from '../../services/index.js'
|
||||
import { BiomeSourcePreview, DecoratorPreview, DensityFunctionPreview, NoisePreview, NoiseSettingsPreview } from '../previews/index.js'
|
||||
import { LootTablePreview } from '../previews/LootTablePreview.jsx'
|
||||
|
||||
export const HasPreview = ['dimension', 'worldgen/density_function', 'worldgen/noise', 'worldgen/noise_settings', 'worldgen/configured_feature', 'worldgen/placed_feature']
|
||||
export const HasPreview = ['loot_table', 'dimension', 'worldgen/density_function', 'worldgen/noise', 'worldgen/noise_settings', 'worldgen/configured_feature', 'worldgen/placed_feature']
|
||||
|
||||
type PreviewPanelProps = {
|
||||
model: DataModel | undefined,
|
||||
@@ -24,6 +25,11 @@ export function PreviewPanel({ model, version, id, shown }: PreviewPanelProps) {
|
||||
|
||||
if (!model) return <></>
|
||||
|
||||
if (id === 'loot_table') {
|
||||
const data = model.get(new Path([]))
|
||||
if (data) return <LootTablePreview {...{ model, version, shown, data }} />
|
||||
}
|
||||
|
||||
if (id === 'dimension' && model.get(new Path(['generator', 'type']))?.endsWith('noise')) {
|
||||
const data = model.get(new Path(['generator', 'biome_source']))
|
||||
if (data) return <BiomeSourcePreview {...{ model, version, shown, data }} />
|
||||
|
||||
@@ -63,8 +63,6 @@ export const BiomeSourcePreview = ({ model, data, shown, version }: PreviewProps
|
||||
}
|
||||
}, [version, state, scale, seed, yOffset, shown, biomeColors, project])
|
||||
|
||||
console.log(yOffset)
|
||||
|
||||
const changeScale = (newScale: number) => {
|
||||
newScale = Math.max(1, Math.round(newScale))
|
||||
offset.current[0] = offset.current[0] * scale / newScale
|
||||
|
||||
79
src/app/components/previews/LootTablePreview.tsx
Normal file
79
src/app/components/previews/LootTablePreview.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { DataModel } from '@mcschema/core'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { useLocale, useVersion } from '../../contexts/index.js'
|
||||
import type { SlottedItem } from '../../previews/LootTable.js'
|
||||
import { generateLootTable } from '../../previews/LootTable.js'
|
||||
import { clamp, randomSeed } from '../../Utils.js'
|
||||
import { Btn, BtnMenu, NumberInput } from '../index.js'
|
||||
import { ItemDisplay } from '../ItemDisplay.jsx'
|
||||
import type { PreviewProps } from './index.js'
|
||||
|
||||
export const LootTablePreview = ({ data }: PreviewProps) => {
|
||||
const { locale } = useLocale()
|
||||
const { version } = useVersion()
|
||||
const [seed, setSeed] = useState(randomSeed())
|
||||
const [luck, setLuck] = useState(0)
|
||||
const [daytime, setDaytime] = useState(0)
|
||||
const [weather, setWeather] = useState('clear')
|
||||
const [mixItems, setMixItems] = useState(true)
|
||||
const [advancedTooltips, setAdvancedTooltips] = useState(true)
|
||||
const overlay = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [items, setItems] = useState<SlottedItem[]>([])
|
||||
|
||||
const table = DataModel.unwrapLists(data)
|
||||
const state = JSON.stringify(table)
|
||||
useEffect(() => {
|
||||
const items = generateLootTable(table, { version, seed, luck, daytime, weather, stackMixer: mixItems ? 'container' : 'default' })
|
||||
setItems(items)
|
||||
}, [version, seed, luck, daytime, weather, mixItems, state])
|
||||
|
||||
return <>
|
||||
<div ref={overlay} class="preview-overlay">
|
||||
<img src="/images/container.png" alt="Container background" class="pixelated" draggable={false} />
|
||||
{items.map(({ slot, item }) =>
|
||||
<div key={slot} style={slotStyle(slot)}>
|
||||
<ItemDisplay item={item} slotDecoration={true} advancedTooltip={advancedTooltips} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="controls preview-controls">
|
||||
<BtnMenu icon="gear" tooltip={locale('settings')} >
|
||||
<div class="btn btn-input" onClick={e => e.stopPropagation()}>
|
||||
<span>{locale('preview.luck')}</span>
|
||||
<NumberInput value={luck} onChange={setLuck} />
|
||||
</div>
|
||||
<div class="btn btn-input" onClick={e => e.stopPropagation()}>
|
||||
<span>{locale('preview.daytime')}</span>
|
||||
<NumberInput value={daytime} onChange={setDaytime} />
|
||||
</div>
|
||||
<div class="btn btn-input" onClick={e => e.stopPropagation()}>
|
||||
<span>{locale('preview.weather')}</span>
|
||||
<select value={weather} onChange={e => setWeather((e.target as HTMLSelectElement).value)} >
|
||||
{['clear', 'rain', 'thunder'].map(v =>
|
||||
<option value={v}>{locale(`preview.weather.${v}`)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<Btn icon={mixItems ? 'square_fill' : 'square'} label="Fill container randomly" onClick={e => {setMixItems(!mixItems); e.stopPropagation()}} />
|
||||
<Btn icon={advancedTooltips ? 'square_fill' : 'square'} label="Advanced tooltips" onClick={e => {setAdvancedTooltips(!advancedTooltips); e.stopPropagation()}} />
|
||||
</BtnMenu>
|
||||
<Btn icon="sync" tooltip={locale('generate_new_seed')} onClick={() => setSeed(randomSeed())} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
const GUI_WIDTH = 176
|
||||
const GUI_HEIGHT = 81
|
||||
const SLOT_SIZE = 18
|
||||
|
||||
function slotStyle(slot: number) {
|
||||
slot = clamp(slot, 0, 26)
|
||||
const x = (slot % 9) * SLOT_SIZE + 7
|
||||
const y = (Math.floor(slot / 9)) * SLOT_SIZE + 20
|
||||
return {
|
||||
left: `${x*100/GUI_WIDTH}%`,
|
||||
top: `${y*100/GUI_HEIGHT}%`,
|
||||
width: `${SLOT_SIZE*100/GUI_WIDTH}%`,
|
||||
height: `${SLOT_SIZE*100/GUI_HEIGHT}%`,
|
||||
}
|
||||
}
|
||||
@@ -67,8 +67,6 @@ export async function getBiome(state: any, x: number, z: number, options: BiomeS
|
||||
const xx = Math.floor(centerX + ((x - 100) * quartStep))
|
||||
const zz = Math.floor(centerZ + ((z - 100) * quartStep))
|
||||
|
||||
console.log('get biome', options.y)
|
||||
|
||||
const { palette, data } = DEEPSLATE.fillBiomes(xx * 4, xx * 4 + 4, zz * 4, zz * 4 + 4, 1, options.y)
|
||||
const biome = palette.get(data[0])!
|
||||
|
||||
|
||||
994
src/app/previews/LootTable.ts
Normal file
994
src/app/previews/LootTable.ts
Normal file
@@ -0,0 +1,994 @@
|
||||
import type { Random } from 'deepslate'
|
||||
import { LegacyRandom } from 'deepslate'
|
||||
import type { VersionId } from '../services/Schemas.js'
|
||||
import { clamp, deepClone, getWeightedRandom, isObject } from '../Utils.js'
|
||||
|
||||
export interface Item {
|
||||
id: string,
|
||||
count: number,
|
||||
tag?: any,
|
||||
}
|
||||
|
||||
export interface SlottedItem {
|
||||
slot: number,
|
||||
item: Item,
|
||||
}
|
||||
|
||||
type ItemConsumer = (item: Item) => 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: Item[] = []
|
||||
generateTable(lootTable, item => result.push(item), ctx)
|
||||
const mixer = StackMixers[options.stackMixer]
|
||||
return mixer(result, ctx)
|
||||
}
|
||||
|
||||
const SLOT_COUNT = 27
|
||||
|
||||
function fillContainer(items: Item[], ctx: LootContext): SlottedItem[] {
|
||||
const slots = shuffle([...Array(SLOT_COUNT)].map((_, i) => i), ctx)
|
||||
|
||||
const queue = items.filter(i => i.id !== 'minecraft:air' && i.count > 1)
|
||||
items = items.filter(i => i.id !== 'minecraft: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.id !== 'minecraft:air' && item.count > 0) {
|
||||
results.push({ slot, item })
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
function assignSlots(items: Item[]): SlottedItem[] {
|
||||
return items.map((item, i) => ({ slot: i, item }))
|
||||
}
|
||||
|
||||
function splitItem(item: Item, count: number): Item {
|
||||
const splitCount = Math.min(count, item.count)
|
||||
const other = deepClone(item)
|
||||
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:/, '')
|
||||
switch (type) {
|
||||
case 'item':
|
||||
entryConsumer({ id: entry.name, count: 1 })
|
||||
break
|
||||
case 'tag':
|
||||
ctx.getItemTag(entry.name ?? '').forEach(tagEntry => {
|
||||
entryConsumer({ id: tagEntry, count: 1 })
|
||||
})
|
||||
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: Item, 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 (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.id === 'minecraft:book'
|
||||
if (enchantments === undefined || enchantments.length === 0) {
|
||||
enchantments = [...Enchantments.keys()]
|
||||
.filter(e => {
|
||||
const data = getEnchantmentData(e)
|
||||
return data.discoverable && (isBook || data.canEnchant(item.id))
|
||||
})
|
||||
}
|
||||
const id = enchantments[ctx.random.nextInt(enchantments.length)]
|
||||
const data = getEnchantmentData(id)
|
||||
const lvl = ctx.random.nextInt(data.maxLevel - data.minLevel + 1) + data.minLevel
|
||||
enchantItem(item, { id, lvl })
|
||||
},
|
||||
enchant_with_levels: ({ levels, treasure }) => (item, ctx) => {
|
||||
const enchants = selectEnchantments(ctx.random, item, computeInt(levels, ctx), treasure)
|
||||
const isBook = item.id === 'minecraft:book'
|
||||
if (isBook) {
|
||||
item.id = 'minecraft:enchanted_book'
|
||||
item.count = 1
|
||||
item.tag = {}
|
||||
}
|
||||
for (const enchant of enchants) {
|
||||
enchantItem(item, enchant)
|
||||
}
|
||||
},
|
||||
limit_count: ({ limit }) => (item, ctx) => {
|
||||
const { min, max } = prepareIntRange(limit, ctx)
|
||||
item.count = clamp(item.count, min, max )
|
||||
},
|
||||
set_count: ({ count }) => (item, ctx) => {
|
||||
item.count = computeInt(count, ctx)
|
||||
},
|
||||
set_damage: ({ damage, add }) => (item, ctx) => {
|
||||
const maxDamage = MaxDamageItems.get(item.id)
|
||||
if (maxDamage) {
|
||||
const oldDamage = add ? 1 - (item.tag?.Damage ?? 0) / maxDamage : 0
|
||||
const newDamage = 1 - clamp(computeFloat(damage, ctx) + oldDamage, 0, 1)
|
||||
const finalDamage = Math.floor(newDamage * maxDamage)
|
||||
item.tag = { ...item.tag, Damage: finalDamage }
|
||||
}
|
||||
},
|
||||
set_enchantments: ({ enchantments, add }) => (item, ctx) => {
|
||||
Object.entries(enchantments).forEach(([id, level]) => {
|
||||
const lvl = computeInt(level, ctx)
|
||||
enchantItem(item, { id, lvl }, add)
|
||||
})
|
||||
},
|
||||
set_lore: ({ lore, replace }) => (item) => {
|
||||
const lines = lore.map((line: any) => JSON.stringify(line))
|
||||
const newLore = replace ? lines : [...(item.tag?.display?.Lore ?? []), ...lines]
|
||||
item.tag = { ...item.tag, display: { ...item.tag?.display, Lore: newLore } }
|
||||
},
|
||||
set_name: ({ name }) => (item) => {
|
||||
const newName = JSON.stringify(name)
|
||||
item.tag = { ...item.tag, display: { ...item.tag?.display, Name: newName } }
|
||||
},
|
||||
}
|
||||
|
||||
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 {
|
||||
const type = condition.condition?.replace(/^minecraft:/, '')
|
||||
return (LootConditions[type]?.(condition) ?? (() => true))(ctx)
|
||||
}
|
||||
|
||||
const LootConditions: Record<string, (params: any) => LootCondition> = {
|
||||
alternative: ({ terms }) => (ctx) => {
|
||||
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 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: Item, enchant: Enchant, additive?: boolean) {
|
||||
if (!item.tag) {
|
||||
item.tag = {}
|
||||
}
|
||||
const listKey = (item.id === 'minecraft:book') ? 'StoredEnchantments' : 'Enchantments'
|
||||
if (!item.tag[listKey] || !Array.isArray(item.tag[listKey])) {
|
||||
item.tag[listKey] = []
|
||||
}
|
||||
const enchantments = item.tag[listKey] as any[]
|
||||
let index = enchantments.findIndex((e: any) => e.id === enchant.id)
|
||||
if (index !== -1) {
|
||||
const oldEnch = enchantments[index]
|
||||
oldEnch.lvl = Math.max(additive ? oldEnch.lvl + enchant.lvl : enchant.lvl, 0)
|
||||
} else {
|
||||
enchantments.push(enchant)
|
||||
index = enchantments.length - 1
|
||||
}
|
||||
if (enchantments[index].lvl === 0) {
|
||||
enchantments.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function selectEnchantments(random: Random, item: Item, levels: number, treasure: boolean): Enchant[] {
|
||||
const enchantmentValue = EnchantmentItems.get(item.id) ?? 0
|
||||
if (enchantmentValue <= 0) {
|
||||
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 = []
|
||||
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 => isEnchantCompatible(a.id, 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
|
||||
}
|
||||
|
||||
function getEnchantWeight(ench: Enchant) {
|
||||
return EnchantmentsRarityWeights.get(getEnchantmentData(ench.id)?.rarity ?? 'common') ?? 0
|
||||
}
|
||||
|
||||
function getAvailableEnchantments(item: Item, levels: number, treasure: boolean): Enchant[] {
|
||||
const result = []
|
||||
const isBook = item.id === 'minecraft:book'
|
||||
|
||||
for (const id of Enchantments.keys()) {
|
||||
const ench = getEnchantmentData(id)!
|
||||
if ((!ench.treasure || treasure) && ench.discoverable && (ench.canEnchant(item.id) || 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: string,
|
||||
lvl: number,
|
||||
}
|
||||
|
||||
function isEnchantCompatible(a: string, b: string) {
|
||||
return a !== b && isEnchantCompatibleRaw(a, b) && isEnchantCompatibleRaw(b, a)
|
||||
}
|
||||
|
||||
function isEnchantCompatibleRaw(a: string, b: string) {
|
||||
const ench = getEnchantmentData(a)
|
||||
return ench?.isCompatible(b)
|
||||
}
|
||||
|
||||
export const MaxDamageItems = new Map(Object.entries<number>({
|
||||
'minecraft:carrot_on_a_stick': 25,
|
||||
'minecraft:warped_fungus_on_a_stick': 100,
|
||||
'minecraft:flint_and_steel': 64,
|
||||
'minecraft:elytra': 432,
|
||||
'minecraft:bow': 384,
|
||||
'minecraft:fishing_rod': 64,
|
||||
'minecraft:shears': 238,
|
||||
'minecraft:shield': 336,
|
||||
'minecraft:trident': 250,
|
||||
'minecraft:crossbow': 465,
|
||||
|
||||
'minecraft:leather_helmet': 11 * 5,
|
||||
'minecraft:leather_chestplate': 16 * 5,
|
||||
'minecraft:leather_leggings': 15 * 5,
|
||||
'minecraft:leather_boots': 13 * 5,
|
||||
'minecraft:chainmail_helmet': 11 * 15,
|
||||
'minecraft:chainmail_chestplate': 16 * 15,
|
||||
'minecraft:chainmail_leggings': 15 * 15,
|
||||
'minecraft:chainmail_boots': 13 * 15,
|
||||
'minecraft:iron_helmet': 11 * 15,
|
||||
'minecraft:iron_chestplate': 16 * 15,
|
||||
'minecraft:iron_leggings': 15 * 15,
|
||||
'minecraft:iron_boots': 13 * 15,
|
||||
'minecraft:diamond_helmet': 11 * 33,
|
||||
'minecraft:diamond_chestplate': 16 * 33,
|
||||
'minecraft:diamond_leggings': 15 * 33,
|
||||
'minecraft:diamond_boots': 13 * 33,
|
||||
'minecraft:golden_helmet': 11 * 7,
|
||||
'minecraft:golden_chestplate': 16 * 7,
|
||||
'minecraft:golden_leggings': 15 * 7,
|
||||
'minecraft:golden_boots': 13 * 7,
|
||||
'minecraft:netherite_helmet': 11 * 37,
|
||||
'minecraft:netherite_chestplate': 16 * 37,
|
||||
'minecraft:netherite_leggings': 15 * 37,
|
||||
'minecraft:netherite_boots': 13 * 37,
|
||||
'minecraft:turtle_helmet': 11 * 25,
|
||||
|
||||
'minecraft:wooden_sword': 59,
|
||||
'minecraft:wooden_shovel': 59,
|
||||
'minecraft:wooden_pickaxe': 59,
|
||||
'minecraft:wooden_axe': 59,
|
||||
'minecraft:wooden_hoe': 59,
|
||||
'minecraft:stone_sword': 131,
|
||||
'minecraft:stone_shovel': 131,
|
||||
'minecraft:stone_pickaxe': 131,
|
||||
'minecraft:stone_axe': 131,
|
||||
'minecraft:stone_hoe': 131,
|
||||
'minecraft:iron_sword': 250,
|
||||
'minecraft:iron_shovel': 250,
|
||||
'minecraft:iron_pickaxe': 250,
|
||||
'minecraft:iron_axe': 250,
|
||||
'minecraft:iron_hoe': 250,
|
||||
'minecraft:diamond_sword': 1561,
|
||||
'minecraft:diamond_shovel': 1561,
|
||||
'minecraft:diamond_pickaxe': 1561,
|
||||
'minecraft:diamond_axe': 1561,
|
||||
'minecraft:diamond_hoe': 1561,
|
||||
'minecraft:gold_sword': 32,
|
||||
'minecraft:gold_shovel': 32,
|
||||
'minecraft:gold_pickaxe': 32,
|
||||
'minecraft:gold_axe': 32,
|
||||
'minecraft:gold_hoe': 32,
|
||||
'minecraft:netherite_sword': 2031,
|
||||
'minecraft:netherite_shovel': 2031,
|
||||
'minecraft:netherite_pickaxe': 2031,
|
||||
'minecraft:netherite_axe': 2031,
|
||||
'minecraft:netherite_hoe': 2031,
|
||||
}))
|
||||
|
||||
const EnchantmentItems = new Map(Object.entries<number>({
|
||||
'minecraft:book': 1,
|
||||
'minecraft:fishing_rod': 1,
|
||||
'minecraft:trident': 1,
|
||||
'minecraft:bow': 1,
|
||||
'minecraft:crossbow': 1,
|
||||
|
||||
'minecraft:leather_helmet': 15,
|
||||
'minecraft:leather_chestplate': 15,
|
||||
'minecraft:leather_leggings': 15,
|
||||
'minecraft:leather_boots': 15,
|
||||
'minecraft:chainmail_helmet': 12,
|
||||
'minecraft:chainmail_chestplate': 12,
|
||||
'minecraft:chainmail_leggings': 12,
|
||||
'minecraft:chainmail_boots': 12,
|
||||
'minecraft:iron_helmet': 9,
|
||||
'minecraft:iron_chestplate': 9,
|
||||
'minecraft:iron_leggings': 9,
|
||||
'minecraft:iron_boots': 9,
|
||||
'minecraft:diamond_helmet': 10,
|
||||
'minecraft:diamond_chestplate': 10,
|
||||
'minecraft:diamond_leggings': 10,
|
||||
'minecraft:diamond_boots': 10,
|
||||
'minecraft:golden_helmet': 25,
|
||||
'minecraft:golden_chestplate': 25,
|
||||
'minecraft:golden_leggings': 25,
|
||||
'minecraft:golden_boots': 25,
|
||||
'minecraft:netherite_helmet': 15,
|
||||
'minecraft:netherite_chestplate': 15,
|
||||
'minecraft:netherite_leggings': 15,
|
||||
'minecraft:netherite_boots': 15,
|
||||
'minecraft:turtle_helmet': 15,
|
||||
|
||||
'minecraft:wooden_sword': 15,
|
||||
'minecraft:wooden_shovel': 15,
|
||||
'minecraft:wooden_pickaxe': 15,
|
||||
'minecraft:wooden_axe': 15,
|
||||
'minecraft:wooden_hoe': 15,
|
||||
'minecraft:stone_sword': 5,
|
||||
'minecraft:stone_shovel': 5,
|
||||
'minecraft:stone_pickaxe': 5,
|
||||
'minecraft:stone_axe': 5,
|
||||
'minecraft:stone_hoe': 5,
|
||||
'minecraft:iron_sword': 14,
|
||||
'minecraft:iron_shovel': 14,
|
||||
'minecraft:iron_pickaxe': 14,
|
||||
'minecraft:iron_axe': 14,
|
||||
'minecraft:iron_hoe': 14,
|
||||
'minecraft:diamond_sword': 10,
|
||||
'minecraft:diamond_shovel': 10,
|
||||
'minecraft:diamond_pickaxe': 10,
|
||||
'minecraft:diamond_axe': 10,
|
||||
'minecraft:diamond_hoe': 10,
|
||||
'minecraft:gold_sword': 22,
|
||||
'minecraft:gold_shovel': 22,
|
||||
'minecraft:gold_pickaxe': 22,
|
||||
'minecraft:gold_axe': 22,
|
||||
'minecraft:gold_hoe': 22,
|
||||
'minecraft:netherite_sword': 15,
|
||||
'minecraft:netherite_shovel': 15,
|
||||
'minecraft:netherite_pickaxe': 15,
|
||||
'minecraft:netherite_axe': 15,
|
||||
'minecraft:netherite_hoe': 15,
|
||||
}))
|
||||
|
||||
interface EnchantmentData {
|
||||
id: string
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'very_rare'
|
||||
category: 'armor' | 'armor_feet' | 'armor_legs' | 'armor_chest' | 'armor_head' | 'weapon' | 'digger' | 'fishing_rod' | 'trident' | 'breakable' | 'bow' | 'wearable' | 'crossbow' | 'vanishable'
|
||||
minLevel: number
|
||||
maxLevel: number
|
||||
minCost: (lvl: number) => number
|
||||
maxCost: (lvl: number) => number
|
||||
discoverable: boolean
|
||||
treasure: boolean
|
||||
curse: boolean
|
||||
canEnchant: (id: string) => boolean
|
||||
isCompatible: (other: string) => boolean
|
||||
}
|
||||
|
||||
export function getEnchantmentData(id: string): EnchantmentData {
|
||||
const data = Enchantments.get(id)
|
||||
const category = data?.category ?? 'armor'
|
||||
return {
|
||||
id,
|
||||
rarity: data?.rarity ?? 'common',
|
||||
category,
|
||||
minLevel: data?.minLevel ?? 1,
|
||||
maxLevel: data?.maxLevel ?? 1,
|
||||
minCost: data?.minCost ?? ((lvl) => 1 + lvl * 10),
|
||||
maxCost: data?.maxCost ?? ((lvl) => 6 + lvl * 10),
|
||||
discoverable: data?.discoverable ?? true,
|
||||
treasure: data?.treasure ?? false,
|
||||
curse: data?.curse ?? false,
|
||||
canEnchant: id => EnchantmentsCategories.get(category)!.includes(id),
|
||||
isCompatible: data?.isCompatible ?? (() => true),
|
||||
}
|
||||
}
|
||||
|
||||
const PROTECTION_ENCHANTS = ['minecraft:protection', 'minecraft:fire_protection', 'minecraft:blast_protection', 'minecraft:projectile_protection']
|
||||
const DAMAGE_ENCHANTS = ['minecraft:sharpness', 'minecraft:smite', 'minecraft:bane_of_arthropods']
|
||||
|
||||
const Enchantments = new Map(Object.entries<Partial<EnchantmentData>>({
|
||||
'minecraft:protection': { rarity: 'common', category: 'armor', maxLevel: 4,
|
||||
minCost: lvl => 1 + (lvl - 1) * 11,
|
||||
maxCost: lvl => 1 + (lvl - 1) * 11 + 11,
|
||||
isCompatible: other => !PROTECTION_ENCHANTS.includes(other) },
|
||||
'minecraft:fire_protection': { rarity: 'uncommon', category: 'armor', maxLevel: 4,
|
||||
minCost: lvl => 10 + (lvl - 1) * 8,
|
||||
maxCost: lvl => 10 + (lvl - 1) * 8 + 8,
|
||||
isCompatible: other => !PROTECTION_ENCHANTS.includes(other) },
|
||||
'minecraft:feather_falling': { rarity: 'uncommon', category: 'armor_feet', maxLevel: 4,
|
||||
minCost: lvl => 5 + (lvl - 1) * 6,
|
||||
maxCost: lvl => 5 + (lvl - 1) * 6 + 6 },
|
||||
'minecraft:blast_protection': { rarity: 'rare', category: 'armor', maxLevel: 4,
|
||||
minCost: lvl => 5 + (lvl - 1) * 8,
|
||||
maxCost: lvl => 5 + (lvl - 1) * 8 + 8,
|
||||
isCompatible: other => !PROTECTION_ENCHANTS.includes(other) },
|
||||
'minecraft:projectile_protection': { rarity: 'uncommon', category: 'armor', maxLevel: 4,
|
||||
minCost: lvl => 3 + (lvl - 1) * 6,
|
||||
maxCost: lvl => 3 + (lvl - 1) * 6 + 6,
|
||||
isCompatible: other => !PROTECTION_ENCHANTS.includes(other) },
|
||||
'minecraft:respiration': { rarity: 'rare', category: 'armor_head', maxLevel: 3,
|
||||
minCost: lvl => 10 * lvl,
|
||||
maxCost: lvl => 10 * lvl + 30 },
|
||||
'minecraft:aqua_affinity': { rarity: 'rare', category: 'armor_head',
|
||||
minCost: () => 1,
|
||||
maxCost: () => 40 },
|
||||
'minecraft:thorns': { rarity: 'very_rare', category: 'armor_chest', maxLevel: 3,
|
||||
minCost: lvl => 10 + 20 * (lvl - 1),
|
||||
maxCost: lvl => 10 + 20 * (lvl - 1) + 50 },
|
||||
'minecraft:depth_strider': { rarity: 'rare', category: 'armor_feet', maxLevel: 3,
|
||||
minCost: lvl => 10 * lvl,
|
||||
maxCost: lvl => 10 * lvl + 15,
|
||||
isCompatible: other => other !== 'minecraft:frost_walker' },
|
||||
'minecraft:frost_walker': { rarity: 'rare', category: 'armor_feet', maxLevel: 2, treasure: true,
|
||||
minCost: lvl => 10 * lvl,
|
||||
maxCost: lvl => 10 * lvl + 15,
|
||||
isCompatible: other => other !== 'minecraft:depth_strider' },
|
||||
'minecraft:binding_curse': { rarity: 'very_rare', category: 'wearable', treasure: true, curse: true,
|
||||
minCost: () => 25,
|
||||
maxCost: () => 50 },
|
||||
'minecraft:soul_speed': { rarity: 'very_rare', category: 'armor_feet', maxLevel: 3,
|
||||
discoverable: false, treasure: true,
|
||||
minCost: lvl => 10 * lvl,
|
||||
maxCost: lvl => 10 * lvl + 15 },
|
||||
'minecraft:swift_sneak': { rarity: 'very_rare', category: 'armor_legs', maxLevel: 3,
|
||||
discoverable: false, treasure: true,
|
||||
minCost: lvl => 25 * lvl,
|
||||
maxCost: lvl => 25 * lvl + 50 },
|
||||
'minecraft:sharpness': { rarity: 'common', category: 'weapon', maxLevel: 5,
|
||||
minCost: lvl => 1 + (lvl - 1) * 11,
|
||||
maxCost: lvl => 1 + (lvl - 1) * 11 + 20,
|
||||
isCompatible: other => !DAMAGE_ENCHANTS.includes(other) },
|
||||
'minecraft:smite': { rarity: 'common', category: 'weapon', maxLevel: 5,
|
||||
minCost: lvl => 5 + (lvl - 1) * 8,
|
||||
maxCost: lvl => 5 + (lvl - 1) * 8 + 20,
|
||||
isCompatible: other => !DAMAGE_ENCHANTS.includes(other) },
|
||||
'minecraft:bane_of_arthropods': { rarity: 'common', category: 'weapon', maxLevel: 5,
|
||||
minCost: lvl => 5 + (lvl - 1) * 8,
|
||||
maxCost: lvl => 5 + (lvl - 1) * 8 + 20,
|
||||
isCompatible: other => !DAMAGE_ENCHANTS.includes(other) },
|
||||
'minecraft:knockback': { rarity: 'uncommon', category: 'weapon', maxLevel: 2,
|
||||
minCost: lvl => 5 + 20 * (lvl - 1),
|
||||
maxCost: lvl => 1 + lvl * 10 + 50 },
|
||||
'minecraft:fire_aspect': { rarity: 'rare', category: 'weapon', maxLevel: 2,
|
||||
minCost: lvl => 5 + 20 * (lvl - 1),
|
||||
maxCost: lvl => 1 + lvl * 10 + 50 },
|
||||
'minecraft:looting': { rarity: 'rare', category: 'weapon', maxLevel: 3,
|
||||
minCost: lvl => 15 + (lvl - 1) * 9,
|
||||
maxCost: lvl => 1 + lvl * 10 + 50,
|
||||
isCompatible: other => other !== 'minecraft:silk_touch' },
|
||||
'minecraft:sweeping': { rarity: 'rare', category: 'weapon', maxLevel: 3,
|
||||
minCost: lvl => 5 + (lvl - 1) * 9,
|
||||
maxCost: lvl => 5 + (lvl - 1) * 9 + 15 },
|
||||
'minecraft:efficiency': { rarity: 'common', category: 'digger', maxLevel: 5,
|
||||
minCost: lvl => 1 + 10 * (lvl - 1),
|
||||
maxCost: lvl => 1 + lvl * 10 + 50,
|
||||
canEnchant: id => id === 'minecraft:shears' || EnchantmentsCategories.get('digger')!.includes(id) },
|
||||
'minecraft:silk_touch': { rarity: 'very_rare', category: 'digger',
|
||||
minCost: () => 15,
|
||||
maxCost: lvl => 1 + lvl * 10 + 50,
|
||||
isCompatible: other => other !== 'minecraft:fortune' },
|
||||
'minecraft:unbreaking': { rarity: 'uncommon', category: 'breakable', maxLevel: 3,
|
||||
minCost: lvl => 5 + (lvl - 1) * 8,
|
||||
maxCost: lvl => 1 + lvl * 10 + 50 },
|
||||
'minecraft:fortune': { rarity: 'rare', category: 'digger', maxLevel: 3,
|
||||
minCost: lvl => 15 + (lvl - 1) * 9,
|
||||
maxCost: lvl => 1 + lvl * 10 + 50,
|
||||
isCompatible: other => other !== 'minecraft:silk_touch' },
|
||||
'minecraft:power': { rarity: 'common', category: 'bow', maxLevel: 5,
|
||||
minCost: lvl => 1 + (lvl - 1) * 10,
|
||||
maxCost: lvl => 1 + (lvl - 1) * 10 + 15 },
|
||||
'minecraft:punch': { rarity: 'rare', category: 'bow', maxLevel: 2,
|
||||
minCost: lvl => 12 + (lvl - 1) * 20,
|
||||
maxCost: lvl => 12 + (lvl - 1) * 20 + 25 },
|
||||
'minecraft:flame': { rarity: 'rare', category: 'bow',
|
||||
minCost: () => 20,
|
||||
maxCost: () => 50 },
|
||||
'minecraft:infinity': { rarity: 'very_rare', category: 'bow',
|
||||
minCost: () => 20,
|
||||
maxCost: () => 50,
|
||||
isCompatible: other => other !== 'minecraft:mending' },
|
||||
'minecraft:luck_of_the_sea': { rarity: 'rare', category: 'fishing_rod', maxLevel: 3,
|
||||
minCost: lvl => 15 + (lvl - 1) * 9,
|
||||
maxCost: lvl => 1 + lvl * 10 + 50,
|
||||
isCompatible: other => other !== 'minecraft:silk_touch' },
|
||||
'minecraft:lure': { rarity: 'rare', category: 'fishing_rod', maxLevel: 3,
|
||||
minCost: lvl => 15 + (lvl - 1) * 9,
|
||||
maxCost: lvl => 1 + lvl * 10 + 50 },
|
||||
'minecraft:loyalty': { rarity: 'uncommon', category: 'trident', maxLevel: 3,
|
||||
minCost: lvl => 5 + lvl * 7,
|
||||
maxCost: () => 50 },
|
||||
'minecraft:impaling': { rarity: 'rare', category: 'trident', maxLevel: 5,
|
||||
minCost: lvl => 1 + (lvl - 1) * 8,
|
||||
maxCost: lvl => 1 + (lvl - 1) * 8 + 20 },
|
||||
'minecraft:riptide': { rarity: 'rare', category: 'trident', maxLevel: 3,
|
||||
minCost: lvl => 5 + lvl * 7,
|
||||
maxCost: () => 50,
|
||||
isCompatible: other => !['minecraft:riptide', 'minecraft:channeling'].includes(other) },
|
||||
'minecraft:channeling': { rarity: 'very_rare', category: 'trident',
|
||||
minCost: () => 25,
|
||||
maxCost: () => 50 },
|
||||
'minecraft:multishot': { rarity: 'rare', category: 'crossbow',
|
||||
minCost: () => 20,
|
||||
maxCost: () => 50,
|
||||
isCompatible: other => other !== 'minecraft:piercing' },
|
||||
'minecraft:quick_charge': { rarity: 'uncommon', category: 'crossbow', maxLevel: 3,
|
||||
minCost: lvl => 12 + (lvl - 1) * 20,
|
||||
maxCost: () => 50 },
|
||||
'minecraft:piercing': { rarity: 'common', category: 'crossbow', maxLevel: 4,
|
||||
minCost: lvl => 1 + (lvl - 1) * 10,
|
||||
maxCost: () => 50,
|
||||
isCompatible: other => other !== 'minecraft:multishot' },
|
||||
'minecraft:mending': { rarity: 'rare', category: 'breakable', treasure: true,
|
||||
minCost: lvl => lvl * 25,
|
||||
maxCost: lvl => lvl * 25 + 50 },
|
||||
'minecraft:vanishing_curse': { rarity: 'very_rare', category: 'vanishable', treasure: true, curse: true,
|
||||
minCost: () => 25,
|
||||
maxCost: () => 50 },
|
||||
}))
|
||||
|
||||
const EnchantmentsRarityWeights = new Map(Object.entries<number>({
|
||||
common: 10,
|
||||
uncommon: 5,
|
||||
rare: 2,
|
||||
very_rare: 1,
|
||||
}))
|
||||
|
||||
const ARMOR_FEET = [
|
||||
'minecraft:leather_boots',
|
||||
'minecraft:chainmail_boots',
|
||||
'minecraft:iron_boots',
|
||||
'minecraft:diamond_boots',
|
||||
'minecraft:golden_boots',
|
||||
'minecraft:netherite_boots',
|
||||
]
|
||||
const ARMOR_LEGS = [
|
||||
'minecraft:leather_leggings',
|
||||
'minecraft:chainmail_leggings',
|
||||
'minecraft:iron_leggings',
|
||||
'minecraft:diamond_leggings',
|
||||
'minecraft:golden_leggings',
|
||||
'minecraft:netherite_leggings',
|
||||
]
|
||||
const ARMOR_CHEST = [
|
||||
'minecraft:leather_chestplate',
|
||||
'minecraft:chainmail_chestplate',
|
||||
'minecraft:iron_chestplate',
|
||||
'minecraft:diamond_chestplate',
|
||||
'minecraft:golden_chestplate',
|
||||
'minecraft:netherite_chestplate',
|
||||
]
|
||||
const ARMOR_HEAD = [
|
||||
'minecraft:leather_helmet',
|
||||
'minecraft:chainmail_helmet',
|
||||
'minecraft:iron_helmet',
|
||||
'minecraft:diamond_helmet',
|
||||
'minecraft:golden_helmet',
|
||||
'minecraft:netherite_helmet',
|
||||
'minecraft:turtle_helmet',
|
||||
]
|
||||
const ARMOR = [...ARMOR_FEET, ...ARMOR_LEGS, ...ARMOR_CHEST, ...ARMOR_HEAD]
|
||||
const SWORD = [
|
||||
'minecraft:wooden_sword',
|
||||
'minecraft:stone_sword',
|
||||
'minecraft:iron_sword',
|
||||
'minecraft:diamond_sword',
|
||||
'minecraft:gold_sword',
|
||||
'minecraft:netherite_sword',
|
||||
]
|
||||
const DIGGER = [
|
||||
'minecraft:wooden_shovel',
|
||||
'minecraft:wooden_pickaxe',
|
||||
'minecraft:wooden_axe',
|
||||
'minecraft:wooden_hoe',
|
||||
'minecraft:stone_shovel',
|
||||
'minecraft:stone_pickaxe',
|
||||
'minecraft:stone_axe',
|
||||
'minecraft:stone_hoe',
|
||||
'minecraft:iron_shovel',
|
||||
'minecraft:iron_pickaxe',
|
||||
'minecraft:iron_axe',
|
||||
'minecraft:iron_hoe',
|
||||
'minecraft:diamond_shovel',
|
||||
'minecraft:diamond_pickaxe',
|
||||
'minecraft:diamond_axe',
|
||||
'minecraft:diamond_hoe',
|
||||
'minecraft:gold_shovel',
|
||||
'minecraft:gold_pickaxe',
|
||||
'minecraft:gold_axe',
|
||||
'minecraft:gold_hoe',
|
||||
'minecraft:netherite_shovel',
|
||||
'minecraft:netherite_pickaxe',
|
||||
'minecraft:netherite_axe',
|
||||
'minecraft:netherite_hoe',
|
||||
]
|
||||
const BREAKABLE = [...MaxDamageItems.keys()]
|
||||
const WEARABLE = [
|
||||
...ARMOR,
|
||||
'minecraft:elytra',
|
||||
'minecraft:carved_pumpkin',
|
||||
'minecraft:creeper_head',
|
||||
'minecraft:dragon_head',
|
||||
'minecraft:player_head',
|
||||
'minecraft:zombie_head',
|
||||
]
|
||||
|
||||
const EnchantmentsCategories = new Map(Object.entries<string[]>({
|
||||
armor: ARMOR,
|
||||
armor_feet: ARMOR_FEET,
|
||||
armor_legs: ARMOR_LEGS,
|
||||
armor_chest: ARMOR_CHEST,
|
||||
armor_head: ARMOR_HEAD,
|
||||
weapon: SWORD,
|
||||
digger: DIGGER,
|
||||
fishing_rod: ['minecraft:fishing_rod'],
|
||||
trident: ['minecraft:trident'],
|
||||
breakable: BREAKABLE,
|
||||
bow: ['minecraft:bow'],
|
||||
wearable: WEARABLE,
|
||||
crossbow: ['minecraft:crossbow'],
|
||||
vanishable: [...BREAKABLE, 'minecraft:compass'],
|
||||
}))
|
||||
@@ -139,7 +139,7 @@ const renderHtml: RenderHook = {
|
||||
let label: undefined | string | JSX.Element
|
||||
if (['loot_pool.entries.entry', 'loot_entry.alternatives.children.entry', 'loot_entry.group.children.entry', 'loot_entry.sequence.children.entry', 'function.set_contents.entries.entry'].includes(cPath.getContext().join('.'))) {
|
||||
if (isObject(cValue) && typeof cValue.type === 'string' && cValue.type.replace(/^minecraft:/, '') === 'item' && typeof cValue.name === 'string') {
|
||||
label = <ItemDisplay item={cValue.name} />
|
||||
label = <ItemDisplay item={{ id: cValue.name, count: 1 }} />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -204,6 +204,16 @@ async function loadImage(src: string) {
|
||||
}
|
||||
*/
|
||||
|
||||
export async function fetchLanguage(versionId: VersionId, lang: string = 'en_us') {
|
||||
const version = config.versions.find(v => v.id === versionId)!
|
||||
await validateCache(version)
|
||||
try {
|
||||
return await cachedFetch<Record<string, string>>(`${mcmeta(version, 'assets')}/assets/minecraft/lang/${lang}.json`)
|
||||
} catch (e) {
|
||||
throw new Error(`Error occured while fetching language: ${message(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
export interface Change {
|
||||
group: string,
|
||||
version: string,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BlockModelProvider, TextureAtlasProvider, UV } from 'deepslate/render'
|
||||
import { BlockModel, Identifier, ItemRenderer, TextureAtlas, upperPowerOfTwo } from 'deepslate/render'
|
||||
import { message } from '../Utils.js'
|
||||
import { fetchResources } from './DataFetcher.js'
|
||||
import { fetchLanguage, fetchResources } from './DataFetcher.js'
|
||||
import type { VersionId } from './Schemas.js'
|
||||
|
||||
const Resources: Record<string, ResourceManager | Promise<ResourceManager>> = {}
|
||||
@@ -99,3 +99,83 @@ export class ResourceManager implements BlockModelProvider, TextureAtlasProvider
|
||||
this.textureAtlas = new TextureAtlas(imageData, idMap)
|
||||
}
|
||||
}
|
||||
|
||||
const Languages: Record<string, Record<string, string> | Promise<Record<string, string>>> = {}
|
||||
|
||||
export async function getLanguage(version: VersionId) {
|
||||
if (!Languages[version]) {
|
||||
Languages[version] = (async () => {
|
||||
try {
|
||||
Languages[version] = await fetchLanguage(version)
|
||||
return Languages[version]
|
||||
} catch (e) {
|
||||
console.error('Error: ', e)
|
||||
throw new Error(`Cannot get language for version ${version}: ${message(e)}`)
|
||||
}
|
||||
})()
|
||||
return Languages[version]
|
||||
}
|
||||
return Languages[version]
|
||||
}
|
||||
|
||||
export async function getTranslation(version: VersionId, key: string, params?: string[]) {
|
||||
const lang = await getLanguage(version)
|
||||
const str = lang[key]
|
||||
if (!str) return null
|
||||
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
|
||||
}
|
||||
|
||||
@@ -127,11 +127,17 @@
|
||||
"preview": "Visualize",
|
||||
"preview.auto_scroll": "Auto scroll",
|
||||
"preview.biome": "Biome",
|
||||
"preview.daytime": "Daytime",
|
||||
"preview.luck": "Luck",
|
||||
"preview.scale": "Scale",
|
||||
"preview.depth": "Depth",
|
||||
"preview.factor": "Factor",
|
||||
"preview.offset": "Offset",
|
||||
"preview.peaks": "Peaks",
|
||||
"preview.weather": "Weather",
|
||||
"preview.weather.clear": "Clear",
|
||||
"preview.weather.rain": "Rain",
|
||||
"preview.weather.thunder": "Thunder",
|
||||
"preview.width": "Width",
|
||||
"project.new": "New project",
|
||||
"project.cancel": "Cancel",
|
||||
|
||||
@@ -132,6 +132,7 @@ body {
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--background-1);
|
||||
--full-width: calc(100vw - (100vw - 100%));
|
||||
}
|
||||
|
||||
header {
|
||||
@@ -454,6 +455,10 @@ main.has-preview {
|
||||
background-color: var(--nav-faded);
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.popup-preview canvas,
|
||||
.popup-preview .pixelated {
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: -webkit-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
@@ -524,6 +529,20 @@ main.has-project {
|
||||
padding-left: max(200px, 20vw);
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
height: min-content;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-overlay > img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-overlay > div {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -669,7 +688,8 @@ main.has-project {
|
||||
padding-right: 7px;
|
||||
}
|
||||
|
||||
.btn-input input {
|
||||
.btn-input input,
|
||||
.btn-input select {
|
||||
background: var(--background-1);
|
||||
color: var(--text-1);
|
||||
font-size: 17px;
|
||||
@@ -679,7 +699,8 @@ main.has-project {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.btn-input.larger-input input {
|
||||
.btn-input.larger-input input,
|
||||
.btn-input.larger-input select {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
@@ -688,7 +709,8 @@ main.has-project {
|
||||
padding-left: 11px;
|
||||
}
|
||||
|
||||
.btn-input.large-input input {
|
||||
.btn-input.large-input input,
|
||||
.btn-input.large-input select {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -1164,29 +1186,149 @@ hr {
|
||||
}
|
||||
|
||||
.item-display {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.item-display > img {
|
||||
width: 26px;
|
||||
position: relative;
|
||||
image-rendering: pixelated;
|
||||
width: 88.888%;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: -webkit-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.item-display > svg {
|
||||
width: 26px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
.item-display > img.model {
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
.item-display > svg:not(.item-count):not(.item-durability) {
|
||||
width: 81.25%;
|
||||
height: 62.5%;
|
||||
fill: var(--node-text-dimmed);
|
||||
}
|
||||
|
||||
.item-display > canvas {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
.item-display > svg.item-count,
|
||||
.item-display > svg.item-durability,
|
||||
.item-display > .item-glint,
|
||||
.item-display > .item-slot-overlay {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
.item-display > .item-glint,
|
||||
.item-display > .item-slot-overlay {
|
||||
left: 5.555%;
|
||||
top: 5.555%;
|
||||
width: 88.888%;
|
||||
height: 88.888%;
|
||||
}
|
||||
|
||||
.item-display > .item-glint,
|
||||
.item-display > .item-glint::after {
|
||||
background: url(/images/glint.png) repeat;
|
||||
filter: brightness(1.4) blur(1px) opacity(0.8);
|
||||
animation: glint 20s linear 0s infinite;
|
||||
background-size: 400%;
|
||||
background-blend-mode: overlay;
|
||||
-webkit-mask-image: var(--mask-image);
|
||||
mask-image: var(--mask-image);
|
||||
-webkit-mask-size: contain;
|
||||
mask-size: contain;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: -webkit-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.item-display > .item-glint::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
animation: glint2 30s linear 0s infinite;
|
||||
}
|
||||
|
||||
@keyframes glint {
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: -400% 400%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glint2 {
|
||||
from {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
to {
|
||||
background-position: 500% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.item-display:hover > .item-slot-overlay {
|
||||
background-color: #fff4;
|
||||
}
|
||||
|
||||
.item-tooltip {
|
||||
padding: 3px 1px 1px 3px;
|
||||
border: solid 4px #220044;
|
||||
border-image-source: url(/images/tooltip.png);
|
||||
border-image-slice: 2 fill;
|
||||
border-image-outset: 2px;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: -webkit-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.item-display > .item-tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
margin: 4px;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.item-display:hover > .item-tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.item-display > .item-tooltip > :nth-child(1) {
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.item-display > .item-tooltip > :nth-child(2) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.text-component {
|
||||
font-family: MinecraftSeven, sans-serif;
|
||||
font-size: 20px;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
line-height: 1.1 ;
|
||||
}
|
||||
|
||||
.text-component > .text-foreground {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: -2px;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
.file-view {
|
||||
@@ -1442,12 +1584,14 @@ hr {
|
||||
|
||||
.sound-config input[type=range] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sound-config input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.sound-config input[type=range]:focus {
|
||||
@@ -1456,6 +1600,7 @@ hr {
|
||||
|
||||
.sound-config input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border: none;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
@@ -2191,7 +2336,7 @@ hr {
|
||||
}
|
||||
|
||||
.popup-source {
|
||||
width: 100vw;
|
||||
width: var(--full-width);
|
||||
}
|
||||
|
||||
.source {
|
||||
@@ -2199,7 +2344,7 @@ hr {
|
||||
}
|
||||
|
||||
.popup-preview {
|
||||
width: 100vw;
|
||||
width: var(--full-width);
|
||||
height: unset;
|
||||
bottom: 0;
|
||||
background-color: transparent;
|
||||
@@ -2223,3 +2368,8 @@ hr {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "MinecraftSeven";
|
||||
src: url("/fonts/seven.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@@ -108,6 +108,11 @@
|
||||
background-color: var(--node-background-label);
|
||||
}
|
||||
|
||||
.node-header > label > .item-display {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.node-header > input {
|
||||
font-size: 18px;
|
||||
padding-left: 9px;
|
||||
|
||||
@@ -83,8 +83,6 @@ export default defineConfig({
|
||||
preact(),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{ src: 'src/.nojekyll', dest: '' },
|
||||
{ src: 'src/sitemap.txt', dest: '' },
|
||||
{ src: 'src/styles/giscus.css', dest: 'assets' },
|
||||
{ src: 'src/styles/giscus-burn.css', dest: 'assets' },
|
||||
{ src: 'src/guides/*', dest: 'guides' },
|
||||
|
||||
Reference in New Issue
Block a user