mirror of
https://github.com/misode/misode.github.io.git
synced 2026-04-24 23:56:51 +00:00
Voxel rendering + refactor interactive canvas (#322)
* Add voxel rendering to density function preview * InteractiveCanvas component * Use interactive canvas for noise preview * Use interactive canvas for noise settings preview * Extract common iterateWorld2D logic * Use InteractiveCanvas2D for biome source preview * Display final density in noise settings preview hover * Move remaining preview code * Hide noise router info for checkerboard and fixed * Add higher resolution biome map * User interactive canvas for decorator preview
This commit is contained in:
@@ -3,11 +3,11 @@ import { Identifier } from 'deepslate/core'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { useVersion } from '../contexts/Version.jsx'
|
||||
import { useAsync } from '../hooks/useAsync.js'
|
||||
import { itemHasGlint } from '../previews/LootTable.js'
|
||||
import { renderItem } from '../services/Resources.js'
|
||||
import { getCollections } from '../services/Schemas.js'
|
||||
import { ItemTooltip } from './ItemTooltip.jsx'
|
||||
import { Octicon } from './Octicon.jsx'
|
||||
import { itemHasGlint } from './previews/LootTable.js'
|
||||
|
||||
interface Props {
|
||||
item: ItemStack,
|
||||
|
||||
@@ -24,35 +24,31 @@ export function PreviewPanel({ model, version, id, shown }: PreviewPanelProps) {
|
||||
})
|
||||
|
||||
if (!model) return <></>
|
||||
const data = model.get(new Path([]))
|
||||
if (!data) return <></>
|
||||
|
||||
if (id === 'loot_table') {
|
||||
const data = model.get(new Path([]))
|
||||
if (data) return <LootTablePreview {...{ model, version, shown, 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 }} />
|
||||
return <BiomeSourcePreview {...{ model, version, shown, data }} />
|
||||
}
|
||||
|
||||
if (id === 'worldgen/density_function') {
|
||||
const data = model.get(new Path([]))
|
||||
if (data) return <DensityFunctionPreview {...{ model, version, shown, data }} />
|
||||
return <DensityFunctionPreview {...{ model, version, shown, data }} />
|
||||
}
|
||||
|
||||
if (id === 'worldgen/noise') {
|
||||
const data = model.get(new Path([]))
|
||||
if (data) return <NoisePreview {...{ model, version, shown, data }} />
|
||||
return <NoisePreview {...{ model, version, shown, data }} />
|
||||
}
|
||||
|
||||
if (id === 'worldgen/noise_settings') {
|
||||
const data = model.get(new Path([]))
|
||||
if (data) return <NoiseSettingsPreview {...{ model, version, shown, data }} />
|
||||
if (id === 'worldgen/noise_settings' && checkVersion(version, '1.18')) {
|
||||
return <NoiseSettingsPreview {...{ model, version, shown, data }} />
|
||||
}
|
||||
|
||||
if ((id === 'worldgen/placed_feature' || (id === 'worldgen/configured_feature' && checkVersion(version, '1.16', '1.17')))) {
|
||||
const data = model.get(new Path([]))
|
||||
if (data) return <DecoratorPreview {...{ model, version, shown, data }} />
|
||||
return <DecoratorPreview {...{ model, version, shown, data }} />
|
||||
}
|
||||
|
||||
return <></>
|
||||
|
||||
@@ -1,99 +1,273 @@
|
||||
import { DataModel, Path } from '@mcschema/core'
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { useLocale, useProject, useStore } from '../../contexts/index.js'
|
||||
import { useCanvas } from '../../hooks/index.js'
|
||||
import { biomeMap, getBiome } from '../../previews/index.js'
|
||||
import { randomSeed } from '../../Utils.js'
|
||||
import { DataModel } from '@mcschema/core'
|
||||
import { clampedMap } from 'deepslate'
|
||||
import { mat3 } from 'gl-matrix'
|
||||
import { useCallback, useRef, useState } from 'preact/hooks'
|
||||
import { getProjectData, useLocale, useProject, useStore } from '../../contexts/index.js'
|
||||
import { useAsync } from '../../hooks/index.js'
|
||||
import { checkVersion } from '../../services/Schemas.js'
|
||||
import { Store } from '../../Store.js'
|
||||
import { iterateWorld2D, randomSeed, stringToColor } from '../../Utils.js'
|
||||
import { Btn, BtnMenu, NumberInput } from '../index.js'
|
||||
import type { ColormapType } from './Colormap.js'
|
||||
import { getColormap } from './Colormap.js'
|
||||
import { ColormapSelector } from './ColormapSelector.jsx'
|
||||
import { DEEPSLATE } from './Deepslate.js'
|
||||
import type { PreviewProps } from './index.js'
|
||||
import { InteractiveCanvas2D } from './InteractiveCanvas2D.jsx'
|
||||
|
||||
export const BiomeSourcePreview = ({ model, data, shown, version }: PreviewProps) => {
|
||||
const LAYERS = ['biomes', 'temperature', 'vegetation', 'continents', 'erosion', 'ridges', 'depth'] as const
|
||||
type Layer = typeof LAYERS[number]
|
||||
|
||||
const DETAIL_DELAY = 300
|
||||
const DETAIL_SCALE = 2
|
||||
|
||||
export const BiomeSourcePreview = ({ data, shown, version }: PreviewProps) => {
|
||||
const { locale } = useLocale()
|
||||
const { project } = useProject()
|
||||
const [seed, setSeed] = useState(randomSeed())
|
||||
const [scale, setScale] = useState(2)
|
||||
const [yOffset, setYOffset] = useState(64)
|
||||
const [focused, setFocused] = useState<{[k: string]: number | string} | undefined>(undefined)
|
||||
const { biomeColors } = useStore()
|
||||
const offset = useRef<[number, number]>([0, 0])
|
||||
const res = useRef(1)
|
||||
const refineTimeout = useRef<number>()
|
||||
const [seed, setSeed] = useState(randomSeed())
|
||||
const [layer, setLayer] = useState<Layer>('biomes')
|
||||
const [yOffset, setYOffset] = useState(64)
|
||||
const [focused, setFocused] = useState<string[]>([])
|
||||
const [focused2, setFocused2] = useState<string[]>([])
|
||||
|
||||
const settings = DataModel.unwrapLists(model.get(new Path(['generator', 'settings'])))
|
||||
const state = JSON.stringify([data, settings])
|
||||
const type: string = data.type?.replace(/^minecraft:/, '')
|
||||
const state = JSON.stringify(data)
|
||||
const type: string = data?.generator?.biome_source?.type?.replace(/^minecraft:/, '') ?? ''
|
||||
const hasRandomness = type === 'multi_noise' || type === 'the_end'
|
||||
|
||||
const { canvas, redraw } = useCanvas({
|
||||
size() {
|
||||
return [200 / res.current, 200 / res.current]
|
||||
},
|
||||
async draw(img) {
|
||||
const options = { settings, biomeColors, offset: offset.current, scale, seed, res: res.current, version, project, y: yOffset }
|
||||
await biomeMap(data, img, options)
|
||||
if (res.current === 4) {
|
||||
clearTimeout(refineTimeout.current)
|
||||
refineTimeout.current = setTimeout(() => {
|
||||
res.current = 1
|
||||
redraw()
|
||||
}, 150) as any
|
||||
}
|
||||
},
|
||||
async onDrag(dx, dy) {
|
||||
offset.current[0] = offset.current[0] + dx * 200
|
||||
offset.current[1] = offset.current[1] + dy * 200
|
||||
clearTimeout(refineTimeout.current)
|
||||
res.current = hasRandomness ? 4 : 1
|
||||
redraw()
|
||||
},
|
||||
async onHover(x, y) {
|
||||
const options = { settings, biomeColors, offset: offset.current, scale, seed: seed, res: 1, version, project, y: yOffset }
|
||||
const biome = await getBiome(data, Math.floor(x * 200), Math.floor(y * 200), options)
|
||||
setFocused(biome)
|
||||
},
|
||||
onLeave() {
|
||||
setFocused(undefined)
|
||||
},
|
||||
}, [version, state, scale, seed, yOffset, biomeColors, project])
|
||||
|
||||
useEffect(() => {
|
||||
if (shown) {
|
||||
res.current = hasRandomness ? 4 : 1
|
||||
redraw()
|
||||
const { value } = useAsync(async function loadBiomeSource() {
|
||||
await DEEPSLATE.loadVersion(version, getProjectData(project))
|
||||
await DEEPSLATE.loadChunkGenerator(DataModel.unwrapLists(data?.generator?.settings), DataModel.unwrapLists(data?.generator?.biome_source), seed)
|
||||
return {
|
||||
biomeSource: { loaded: true },
|
||||
noiseRouter: checkVersion(version, '1.19') ? DEEPSLATE.getNoiseRouter() : undefined,
|
||||
}
|
||||
}, [version, state, scale, seed, yOffset, shown, biomeColors, project])
|
||||
}, [state, seed, project, version])
|
||||
const { biomeSource, noiseRouter } = value ?? {}
|
||||
|
||||
const changeScale = (newScale: number) => {
|
||||
newScale = Math.max(1, Math.round(newScale))
|
||||
offset.current[0] = offset.current[0] * scale / newScale
|
||||
offset.current[1] = offset.current[1] * scale / newScale
|
||||
setScale(newScale)
|
||||
}
|
||||
const actualLayer = noiseRouter ? layer : 'biomes'
|
||||
|
||||
const ctx = useRef<CanvasRenderingContext2D>()
|
||||
const imageData = useRef<ImageData>()
|
||||
const [colormap, setColormap] = useState<ColormapType>(Store.getColormap() ?? 'viridis')
|
||||
|
||||
const detailCanvas = useRef<HTMLCanvasElement>(null)
|
||||
const detailCtx = useRef<CanvasRenderingContext2D>()
|
||||
const detailImageData = useRef<ImageData>()
|
||||
const detailTimeout = useRef<number>()
|
||||
|
||||
const onSetup = useCallback(function onSetup(canvas: HTMLCanvasElement) {
|
||||
ctx.current = canvas.getContext('2d') ?? undefined
|
||||
detailCtx.current = detailCanvas.current?.getContext('2d') ?? undefined
|
||||
}, [])
|
||||
const onResize = useCallback(function onResize(width: number, height: number) {
|
||||
if (ctx.current) {
|
||||
imageData.current = ctx.current.getImageData(0, 0, width, height)
|
||||
}
|
||||
if (detailCtx.current && detailCanvas.current) {
|
||||
detailCanvas.current.width = width * DETAIL_SCALE
|
||||
detailCanvas.current.height = height * DETAIL_SCALE
|
||||
detailImageData.current = detailCtx.current.getImageData(0, 0, width * DETAIL_SCALE, height * DETAIL_SCALE)
|
||||
}
|
||||
}, [])
|
||||
const onDraw = useCallback(function onDraw(transform: mat3) {
|
||||
if (!ctx.current || !imageData.current || !shown) return
|
||||
|
||||
function actualDraw(ctx: CanvasRenderingContext2D, img: ImageData, transform: mat3) {
|
||||
if (actualLayer === 'biomes' && biomeSource) {
|
||||
iterateWorld2D(img, transform, (x, y) => {
|
||||
return DEEPSLATE.getBiome(x, yOffset, y)
|
||||
}, (biome) => {
|
||||
return getBiomeColor(biome, biomeColors)
|
||||
})
|
||||
} else if (actualLayer !== 'biomes' && noiseRouter) {
|
||||
const df = noiseRouter[actualLayer]
|
||||
const colorPicker = getColormap(colormap)
|
||||
iterateWorld2D(img, transform, (x, y) => {
|
||||
return df.compute({ x: x*4, y: yOffset, z: y*4 }) ?? 0
|
||||
}, (density) => {
|
||||
const color = colorPicker(clampedMap(density, -1, 1, 0, 1))
|
||||
return [color[0] * 256, color[1] * 256, color[2] * 256]
|
||||
})
|
||||
}
|
||||
ctx.putImageData(img, 0, 0)
|
||||
}
|
||||
|
||||
actualDraw(ctx.current, imageData.current, transform)
|
||||
detailCanvas.current?.classList.remove('visible')
|
||||
|
||||
clearTimeout(detailTimeout.current)
|
||||
if (hasRandomness) {
|
||||
detailTimeout.current = setTimeout(function detailTimout() {
|
||||
if (!detailCtx.current || !detailImageData.current || !detailCanvas.current) return
|
||||
const detailTransform = mat3.create()
|
||||
mat3.scale(detailTransform, transform, [1/DETAIL_SCALE, 1/DETAIL_SCALE])
|
||||
actualDraw(detailCtx.current, detailImageData.current, detailTransform)
|
||||
detailCanvas.current.classList.add('visible')
|
||||
}, DETAIL_DELAY) as unknown as number
|
||||
}
|
||||
}, [biomeSource, noiseRouter, actualLayer, colormap, shown, biomeColors, yOffset])
|
||||
const onHover = useCallback(function onHover(pos: [number, number] | undefined) {
|
||||
const [x, y] = pos ?? [0, 0]
|
||||
if (!pos || !biomeSource) {
|
||||
setFocused([])
|
||||
} else {
|
||||
const biome = DEEPSLATE.getBiome(x, yOffset, -y)
|
||||
setFocused([biome.replace(/^minecraft:/, ''), `X=${x*4} Z=${-y*4}`])
|
||||
}
|
||||
if (!pos || !noiseRouter) {
|
||||
setFocused2([])
|
||||
} else {
|
||||
setFocused2([LAYERS.flatMap(l => {
|
||||
if (l === 'biomes') return []
|
||||
const value = noiseRouter[l].compute({ x: x*4, y: yOffset, z: -y*4 })
|
||||
return [`${locale(`layer.${l}`).charAt(0)}=${value.toPrecision(2)}`]
|
||||
}).join(' ')])
|
||||
}
|
||||
}, [biomeSource, noiseRouter, yOffset])
|
||||
|
||||
return <>
|
||||
{(hasRandomness && focused2) && <div class="controls secondary-controls">
|
||||
{focused2.map(s => <Btn label={s} class="no-pointer" /> )}
|
||||
</div>}
|
||||
<div class="controls preview-controls">
|
||||
{focused && <Btn label={focused.biome as string} class="no-pointer" />}
|
||||
<Btn icon="dash" tooltip={locale('zoom_out')}
|
||||
onClick={() => changeScale(scale * 2)} />
|
||||
<Btn icon="plus" tooltip={locale(Math.round(scale) <= 1 ? 'zoom_in_limit' : 'zoom_in')}
|
||||
disabled={Math.round(scale) <= 1}
|
||||
onClick={() => changeScale(scale / 2)} />
|
||||
{focused.map(s => <Btn label={s} class="no-pointer" /> )}
|
||||
{actualLayer !== 'biomes' && <ColormapSelector value={colormap} onChange={setColormap} />}
|
||||
{hasRandomness && <>
|
||||
<BtnMenu icon="stack">
|
||||
<BtnMenu icon="stack" tooltip={locale('layer')}>
|
||||
<div class="btn btn-input" onClick={e => e.stopPropagation()}>
|
||||
<span>{locale('y')}</span>
|
||||
<NumberInput value={yOffset} onChange={setYOffset} />
|
||||
</div>
|
||||
{checkVersion(version, '1.19') && LAYERS.map(l => <Btn label={locale(`layer.${l}`)} active={l === actualLayer} onClick={() => setLayer(l)} />)}
|
||||
</BtnMenu>
|
||||
<Btn icon="sync" tooltip={locale('generate_new_seed')}
|
||||
onClick={() => setSeed(randomSeed())} />
|
||||
</>}
|
||||
</div>
|
||||
{focused?.temperature !== undefined && <div class="controls secondary-controls">
|
||||
<Btn class="no-pointer" label={Object.entries(focused)
|
||||
.filter(([k]) => k !== 'biome')
|
||||
.map(([k, v]) => `${k[0].toUpperCase()}: ${(v as number).toFixed(2)}`).join(' ')}/>
|
||||
</div>}
|
||||
<canvas ref={canvas} width="200" height="200"></canvas>
|
||||
<div class="full-preview">
|
||||
<InteractiveCanvas2D onSetup={onSetup} onResize={onResize} onDraw={onDraw} onHover={onHover} pixelSize={hasRandomness ? 8 : 2} />
|
||||
{hasRandomness && <canvas class={'preview-details'} ref={detailCanvas} />}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
type Triple = [number, number, number]
|
||||
type BiomeColors = Record<string, Triple>
|
||||
function getBiomeColor(biome: string, biomeColors: BiomeColors): Triple {
|
||||
if (!biome) {
|
||||
return [128, 128, 128]
|
||||
}
|
||||
const color = biomeColors[biome] ?? VanillaColors[biome]
|
||||
if (color === undefined) {
|
||||
return stringToColor(biome)
|
||||
}
|
||||
return color
|
||||
}
|
||||
|
||||
export const VanillaColors: Record<string, Triple> = {
|
||||
'minecraft:badlands': [217,69,21],
|
||||
'minecraft:badlands_plateau': [202,140,101],
|
||||
'minecraft:bamboo_jungle': [118,142,20],
|
||||
'minecraft:bamboo_jungle_hills': [59,71,10],
|
||||
'minecraft:basalt_deltas': [64,54,54],
|
||||
'minecraft:beach': [250,222,85],
|
||||
'minecraft:birch_forest': [48,116,68],
|
||||
'minecraft:birch_forest_hills': [31,95,50],
|
||||
'minecraft:cold_ocean': [32,32,112],
|
||||
'minecraft:crimson_forest': [221,8,8],
|
||||
'minecraft:dark_forest': [64,81,26],
|
||||
'minecraft:dark_forest_hills': [104,121,66],
|
||||
'minecraft:deep_cold_ocean': [32,32,56],
|
||||
'minecraft:deep_frozen_ocean': [64,64,144],
|
||||
'minecraft:deep_lukewarm_ocean': [0,0,64],
|
||||
'minecraft:deep_ocean': [0,0,48],
|
||||
'minecraft:deep_warm_ocean': [0,0,80],
|
||||
'minecraft:desert': [250,148,24],
|
||||
'minecraft:desert_hills': [210,95,18],
|
||||
'minecraft:desert_lakes': [255,188,64],
|
||||
'minecraft:end_barrens': [39,30,61],
|
||||
'minecraft:end_highlands': [232,244,178],
|
||||
'minecraft:end_midlands': [194,187,136],
|
||||
'minecraft:eroded_badlands': [255,109,61],
|
||||
'minecraft:flower_forest': [45,142,73],
|
||||
'minecraft:forest': [5,102,33],
|
||||
'minecraft:frozen_ocean': [112,112,214],
|
||||
'minecraft:frozen_river': [160,160,255],
|
||||
'minecraft:giant_spruce_taiga': [129,142,121],
|
||||
'minecraft:old_growth_spruce_taiga': [129,142,121],
|
||||
'minecraft:giant_spruce_taiga_hills': [109,119,102],
|
||||
'minecraft:giant_tree_taiga': [89,102,81],
|
||||
'minecraft:old_growth_pine_taiga': [89,102,81],
|
||||
'minecraft:giant_tree_taiga_hills': [69,79,62],
|
||||
'minecraft:gravelly_hills': [136,136,136],
|
||||
'minecraft:gravelly_mountains': [136,136,136],
|
||||
'minecraft:windswept_gravelly_hills': [136,136,136],
|
||||
'minecraft:ice_spikes': [180,220,220],
|
||||
'minecraft:jungle': [83,123,9],
|
||||
'minecraft:jungle_edge': [98,139,23],
|
||||
'minecraft:sparse_jungle': [98,139,23],
|
||||
'minecraft:jungle_hills': [44,66,5],
|
||||
'minecraft:lukewarm_ocean': [0,0,144],
|
||||
'minecraft:modified_badlands_plateau': [242,180,141],
|
||||
'minecraft:modified_gravelly_mountains': [120,152,120],
|
||||
'minecraft:modified_jungle': [123,163,49],
|
||||
'minecraft:modified_jungle_edge': [138,179,63],
|
||||
'minecraft:modified_wooded_badlands_plateau': [216,191,141],
|
||||
'minecraft:mountain_edge': [114,120,154],
|
||||
'minecraft:extreme_hills': [96,96,96],
|
||||
'minecraft:mountains': [96,96,96],
|
||||
'minecraft:windswept_hills': [96,96,96],
|
||||
'minecraft:mushroom_field_shore': [160,0,255],
|
||||
'minecraft:mushroom_fields': [255,0,255],
|
||||
'minecraft:nether_wastes': [191,59,59],
|
||||
'minecraft:ocean': [0,0,112],
|
||||
'minecraft:plains': [141,179,96],
|
||||
'minecraft:river': [0,0,255],
|
||||
'minecraft:savanna': [189,178,95],
|
||||
'minecraft:savanna_plateau': [167,157,100],
|
||||
'minecraft:shattered_savanna': [229,218,135],
|
||||
'minecraft:windswept_savanna': [229,218,135],
|
||||
'minecraft:shattered_savanna_plateau': [207,197,140],
|
||||
'minecraft:small_end_islands': [16,12,28],
|
||||
'minecraft:snowy_beach': [250,240,192],
|
||||
'minecraft:snowy_mountains': [160,160,160],
|
||||
'minecraft:snowy_taiga': [49,85,74],
|
||||
'minecraft:snowy_taiga_hills': [36,63,54],
|
||||
'minecraft:snowy_taiga_mountains': [89,125,114],
|
||||
'minecraft:snowy_tundra': [255,255,255],
|
||||
'minecraft:snowy_plains': [255,255,255],
|
||||
'minecraft:soul_sand_valley': [94,56,48],
|
||||
'minecraft:stone_shore': [162,162,132],
|
||||
'minecraft:stony_shore': [162,162,132],
|
||||
'minecraft:sunflower_plains': [181,219,136],
|
||||
'minecraft:swamp': [7,249,178],
|
||||
'minecraft:swamp_hills': [47,255,218],
|
||||
'minecraft:taiga': [11,102,89],
|
||||
'minecraft:taiga_hills': [22,57,51],
|
||||
'minecraft:taiga_mountains': [51,142,129],
|
||||
'minecraft:tall_birch_forest': [88,156,108],
|
||||
'minecraft:old_growth_birch_forest': [88,156,108],
|
||||
'minecraft:tall_birch_hills': [71,135,90],
|
||||
'minecraft:the_end': [59,39,84],
|
||||
'minecraft:the_void': [0,0,0],
|
||||
'minecraft:warm_ocean': [0,0,172],
|
||||
'minecraft:warped_forest': [73,144,123],
|
||||
'minecraft:wooded_badlands_plateau': [176,151,101],
|
||||
'minecraft:wooded_badlands': [176,151,101],
|
||||
'minecraft:wooded_hills': [34,85,28],
|
||||
'minecraft:wooded_mountains': [80,112,80],
|
||||
'minecraft:windswept_forest': [80,112,80],
|
||||
'minecraft:snowy_slopes': [140, 195, 222],
|
||||
'minecraft:lofty_peaks': [196, 168, 193],
|
||||
'minecraft:jagged_peaks': [196, 168, 193],
|
||||
'minecraft:snowcapped_peaks': [200, 198, 200],
|
||||
'minecraft:frozen_peaks': [200, 198, 200],
|
||||
'minecraft:stony_peaks': [82, 92, 103],
|
||||
'minecraft:grove': [150, 150, 189],
|
||||
'minecraft:meadow': [169, 197, 80],
|
||||
'minecraft:lush_caves': [112, 255, 79],
|
||||
'minecraft:dripstone_caves': [140, 124, 0],
|
||||
'minecraft:deep_dark': [10, 14, 19],
|
||||
'minecraft:mangrove_swamp': [36,196,142],
|
||||
}
|
||||
|
||||
50
src/app/components/previews/Colormap.ts
Normal file
50
src/app/components/previews/Colormap.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { clamp } from '../../Utils.js'
|
||||
import colormaps from './colormaps.json'
|
||||
|
||||
// Implementation based on https://github.com/politiken-journalism/scale-color-perceptual/blob/master/utils/interpolate.js
|
||||
// Licenced as ISC
|
||||
// Copyright (c) 2015, Politiken Journalism <emil.bay@pol.dk>
|
||||
|
||||
export const ColormapTypes = ['viridis', 'inferno', 'magma', 'plasma', 'cividis', 'rocket', 'mako', 'turbo', 'gray'] as const
|
||||
export type ColormapType = typeof ColormapTypes[number]
|
||||
|
||||
type Color = [number, number, number]
|
||||
|
||||
function createColormap(type: ColormapType) {
|
||||
if (type === 'gray') {
|
||||
return (t: number) => [t, t, t]
|
||||
}
|
||||
const colors = colormaps[type]
|
||||
const n = colors.length - 2
|
||||
const w = 1 / n
|
||||
const intervals: Array<(t: number) => Color> = []
|
||||
for (var i = 0; i <= n; i++) {
|
||||
const a = colors[i]
|
||||
const b = colors[i+1]
|
||||
const ar = a[0]
|
||||
const ag = a[1]
|
||||
const ab = a[2]
|
||||
const br = b[0] - ar
|
||||
const bg = b[1] - ag
|
||||
const bb = b[2] - ab
|
||||
intervals[i] = (t: number) => [
|
||||
ar + br * t,
|
||||
ag + bg * t,
|
||||
ab + bb * t,
|
||||
]
|
||||
}
|
||||
return (t: number) => {
|
||||
t = clamp(t, 0, 1)
|
||||
const i = Math.floor(t * n)
|
||||
var offs = i * w
|
||||
return intervals[i](t / w - offs / w)
|
||||
}
|
||||
}
|
||||
|
||||
const Colormaps = new Map(ColormapTypes.map(type => {
|
||||
return [type, createColormap(type)]
|
||||
}))
|
||||
|
||||
export function getColormap(type: ColormapType) {
|
||||
return Colormaps.get(type)!
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback } from 'preact/hooks'
|
||||
import { Analytics } from '../../Analytics.js'
|
||||
import type { ColormapType } from '../../previews/Colormap.js'
|
||||
import { ColormapTypes } from '../../previews/Colormap.js'
|
||||
import { Store } from '../../Store.js'
|
||||
import { Btn } from '../Btn.jsx'
|
||||
import { BtnMenu } from '../BtnMenu.jsx'
|
||||
import type { ColormapType } from './Colormap.js'
|
||||
import { ColormapTypes } from './Colormap.js'
|
||||
|
||||
interface Props {
|
||||
value: ColormapType,
|
||||
|
||||
423
src/app/components/previews/Decorator.ts
Normal file
423
src/app/components/previews/Decorator.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import { DataModel } from '@mcschema/core'
|
||||
import type { BlockPos, ChunkPos, PerlinNoise, Random } from 'deepslate/worldgen'
|
||||
import type { VersionId } from '../../services/index.js'
|
||||
import { checkVersion } from '../../services/index.js'
|
||||
import type { Color } from '../../Utils.js'
|
||||
import { clamp, isObject, stringToColor } from '../../Utils.js'
|
||||
|
||||
export type Placement = [BlockPos, number]
|
||||
|
||||
export type PlacementContext = {
|
||||
placements: Placement[],
|
||||
features: string[],
|
||||
random: Random,
|
||||
biomeInfoNoise: PerlinNoise,
|
||||
seaLevel: number,
|
||||
version: VersionId,
|
||||
nextFloat(): number,
|
||||
nextInt(max: number): number,
|
||||
nextGaussian(): number,
|
||||
}
|
||||
|
||||
export type PlacedFeature = {
|
||||
pos: BlockPos,
|
||||
feature: string,
|
||||
color: Color,
|
||||
}
|
||||
|
||||
const terrain = [50, 50, 51, 51, 52, 52, 53, 54, 56, 57, 57, 58, 58, 59, 60, 60, 60, 59, 59, 59, 60, 61, 61, 62, 63, 63, 64, 64, 64, 65, 65, 66, 66, 65, 65, 66, 66, 67, 67, 67, 68, 69, 71, 73, 74, 76, 79, 80, 81, 81, 82, 83, 83, 82, 82, 81, 81, 80, 80, 80, 81, 81, 82, 82]
|
||||
|
||||
const featureColors: Color[] = [
|
||||
[255, 77, 54], // red
|
||||
[59, 118, 255], // blue
|
||||
[91, 207, 25], // green
|
||||
[217, 32, 245], // magenta
|
||||
[255, 209, 41], // yellow
|
||||
[52, 204, 209], // cyan
|
||||
]
|
||||
|
||||
export function decorateChunk(pos: ChunkPos, state: any, ctx: PlacementContext): PlacedFeature[] {
|
||||
if (checkVersion(ctx.version, undefined, '1.17')) {
|
||||
getPlacements([pos[0] * 16, 0, pos[1] * 16], DataModel.unwrapLists(state), ctx)
|
||||
} else {
|
||||
modifyPlacement([pos[0] * 16, 0, pos[1] * 16], DataModel.unwrapLists(state.placement), ctx)
|
||||
}
|
||||
|
||||
return ctx.placements.map(([pos, i]) => {
|
||||
const feature = ctx.features[i]
|
||||
let color = i < featureColors.length ? featureColors[i] : stringToColor(feature)
|
||||
color = [clamp(color[0], 50, 205), clamp(color[1], 50, 205), clamp(color[2], 50, 205)]
|
||||
if ((Math.floor(pos[0] / 16) + Math.floor(pos[2] / 16)) % 2 === 0) {
|
||||
color = [0.85 * color[0], 0.85 * color[1], 0.85 * color[2]]
|
||||
}
|
||||
return { pos, feature, color }
|
||||
})
|
||||
}
|
||||
|
||||
function normalize(id: string) {
|
||||
return id.startsWith('minecraft:') ? id.slice(10) : id
|
||||
}
|
||||
|
||||
function decorateY(pos: BlockPos, y: number): BlockPos[] {
|
||||
return [[ pos[0], y, pos[2] ]]
|
||||
}
|
||||
|
||||
export function sampleInt(value: any, ctx: PlacementContext): number {
|
||||
if (typeof value === 'number') {
|
||||
return value
|
||||
} else if (value.base) {
|
||||
return value.base ?? 1 + ctx.nextInt(1 + (value.spread ?? 0))
|
||||
} else {
|
||||
switch (normalize(value.type)) {
|
||||
case 'constant': return value.value
|
||||
case 'uniform': return value.value.min_inclusive + ctx.nextInt(value.value.max_inclusive - value.value.min_inclusive + 1)
|
||||
case 'biased_to_bottom': return value.value.min_inclusive + ctx.nextInt(ctx.nextInt(value.value.max_inclusive - value.value.min_inclusive + 1) + 1)
|
||||
case 'clamped': return clamp(sampleInt(value.value.source, ctx), value.value.min_inclusive, value.value.max_inclusive)
|
||||
case 'clamped_normal':
|
||||
const normal = value.value.mean + ctx.nextGaussian() * value.value.deviation
|
||||
return Math.floor(clamp(value.value.min_inclusive, value.value.max_inclusive, normal))
|
||||
case 'weighted_list':
|
||||
const totalWeight = (value.distribution as any[]).reduce<number>((sum, e) => sum + e.weight, 0)
|
||||
let i = ctx.nextInt(totalWeight)
|
||||
for (const e of value.distribution) {
|
||||
i -= e.weight
|
||||
if (i < 0) return sampleInt(e.data, ctx)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAnchor(anchor: any, _ctx: PlacementContext): number {
|
||||
if (!isObject(anchor)) return 0
|
||||
if (anchor.absolute !== undefined) return anchor.absolute
|
||||
if (anchor.above_bottom !== undefined) return anchor.above_bottom
|
||||
if (anchor.below_top !== undefined) return 256 - anchor.below_top
|
||||
return 0
|
||||
}
|
||||
|
||||
function sampleHeight(height: any, ctx: PlacementContext): number {
|
||||
if (!isObject(height)) throw new Error('Invalid height provider')
|
||||
if (typeof height.type !== 'string') {
|
||||
return resolveAnchor(height, ctx)
|
||||
}
|
||||
switch (normalize(height.type)) {
|
||||
case 'constant': return resolveAnchor(height.value, ctx)
|
||||
case 'uniform': {
|
||||
const min = resolveAnchor(height.min_inclusive, ctx)
|
||||
const max = resolveAnchor(height.max_inclusive, ctx)
|
||||
return min + ctx.nextInt(max - min + 1)
|
||||
}
|
||||
case 'biased_to_bottom': {
|
||||
const min = resolveAnchor(height.min_inclusive, ctx)
|
||||
const max = resolveAnchor(height.max_inclusive, ctx)
|
||||
const n = ctx.nextInt(max - min - (height.inner ?? 1) + 1)
|
||||
return min + ctx.nextInt(n + (height.inner ?? 1))
|
||||
}
|
||||
case 'very_biased_to_bottom': {
|
||||
const min = resolveAnchor(height.min_inclusive, ctx)
|
||||
const max = resolveAnchor(height.max_inclusive, ctx)
|
||||
const inner = height.inner ?? 1
|
||||
const n1 = min + inner + ctx.nextInt(max - min - inner + 1)
|
||||
const n2 = min + ctx.nextInt(n1 - min)
|
||||
return min + ctx.nextInt(n2 - min + inner)
|
||||
}
|
||||
case 'trapezoid': {
|
||||
const min = resolveAnchor(height.min_inclusive, ctx)
|
||||
const max = resolveAnchor(height.max_inclusive, ctx)
|
||||
const plateau = height.plateau ?? 0
|
||||
if (plateau >= max - min) {
|
||||
return min + ctx.nextInt(max - min + 1)
|
||||
}
|
||||
const n1 = (max - min - plateau) / 2
|
||||
const n2 = (max - min) - n1
|
||||
return min + ctx.nextInt(n2 + 1) + ctx.nextInt(n1 + 1)
|
||||
}
|
||||
default: throw new Error(`Invalid height provider ${height.type}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 1.17 and before
|
||||
function useFeature(s: string, ctx: PlacementContext) {
|
||||
const i = ctx.features.indexOf(s)
|
||||
if (i != -1) return i
|
||||
ctx.features.push(s)
|
||||
return ctx.features.length - 1
|
||||
}
|
||||
|
||||
function getPlacements(pos: BlockPos, feature: any, ctx: PlacementContext): void {
|
||||
if (typeof feature === 'string') {
|
||||
ctx.placements.push([pos, useFeature(feature, ctx)])
|
||||
return
|
||||
}
|
||||
const type = normalize(feature?.type ?? 'no_op')
|
||||
const featureFn = Features[type]
|
||||
if (featureFn) {
|
||||
featureFn(feature.config, pos, ctx)
|
||||
} else {
|
||||
ctx.placements.push([pos, useFeature(JSON.stringify(feature), ctx)])
|
||||
}
|
||||
}
|
||||
|
||||
function getPositions(pos: BlockPos, decorator: any, ctx: PlacementContext): BlockPos[] {
|
||||
const type = normalize(decorator?.type ?? 'nope')
|
||||
const decoratorFn = Decorators[type]
|
||||
if (!decoratorFn) {
|
||||
return [pos]
|
||||
}
|
||||
return decoratorFn(decorator?.config, pos, ctx)
|
||||
}
|
||||
|
||||
const Features: {
|
||||
[key: string]: (config: any, pos: BlockPos, ctx: PlacementContext) => void,
|
||||
} = {
|
||||
decorated: (config, pos, ctx) => {
|
||||
const positions = getPositions(pos, config?.decorator, ctx)
|
||||
positions.forEach(p => getPlacements(p, config?.feature, ctx))
|
||||
},
|
||||
random_boolean_selector: (config, pos, ctx) => {
|
||||
const feature = ctx.nextFloat() < 0.5 ? config?.feature_true : config?.feature_false
|
||||
getPlacements(pos, feature, ctx)
|
||||
},
|
||||
random_selector: (config, pos, ctx) => {
|
||||
for (const f of config?.features ?? []) {
|
||||
if (ctx.nextFloat() < (f?.chance ?? 0)) {
|
||||
getPlacements(pos, f.feature, ctx)
|
||||
return
|
||||
}
|
||||
}
|
||||
getPlacements(pos, config?.default, ctx)
|
||||
},
|
||||
simple_random_selector: (config, pos, ctx) => {
|
||||
const feature = config?.features?.[ctx.nextInt(config?.features?.length ?? 0)]
|
||||
getPlacements(pos, feature, ctx)
|
||||
},
|
||||
}
|
||||
|
||||
const Decorators: {
|
||||
[key: string]: (config: any, pos: BlockPos, ctx: PlacementContext) => BlockPos[],
|
||||
} = {
|
||||
chance: (config, pos, ctx) => {
|
||||
return ctx.nextFloat() < 1 / (config?.chance ?? 1) ? [pos] : []
|
||||
},
|
||||
count: (config, pos, ctx) => {
|
||||
return new Array(sampleInt(config?.count ?? 1, ctx)).fill(pos)
|
||||
},
|
||||
count_extra: (config, pos, ctx) => {
|
||||
let count = config?.count ?? 1
|
||||
if (ctx.nextFloat() < config.extra_chance ?? 0){
|
||||
count += config.extra_count ?? 0
|
||||
}
|
||||
return new Array(count).fill(pos)
|
||||
},
|
||||
count_multilayer: (config, pos, ctx) => {
|
||||
return new Array(sampleInt(config?.count ?? 1, ctx)).fill(pos)
|
||||
.map(p => [
|
||||
p[0] + ctx.nextInt(16),
|
||||
p[1],
|
||||
p[2] + ctx.nextInt(16),
|
||||
])
|
||||
},
|
||||
count_noise: (config, pos, ctx) => {
|
||||
const noise = ctx.biomeInfoNoise.sample(pos[0] / 200, 0, pos[2] / 200)
|
||||
const count = noise < config.noise_level ? config.below_noise : config.above_noise
|
||||
return new Array(count).fill(pos)
|
||||
},
|
||||
count_noise_biased: (config, pos, ctx) => {
|
||||
const factor = Math.max(1, config.noise_factor)
|
||||
const noise = ctx.biomeInfoNoise.sample(pos[0] / factor, 0, pos[2] / factor)
|
||||
const count = Math.max(0, Math.ceil((noise + (config.noise_offset ?? 0)) * config.noise_to_count_ratio))
|
||||
return new Array(count).fill(pos)
|
||||
},
|
||||
dark_oak_tree: (_config, pos, ctx) => {
|
||||
return [...new Array(16)].map((_, i) => {
|
||||
const x = Math.floor(i / 4) * 4 + 1 + ctx.nextInt(3) + pos[0]
|
||||
const y = Math.max(ctx.seaLevel, terrain[clamp(0, 63, x)])
|
||||
const z = Math.floor(i % 4) * 4 + 1 + ctx.nextInt(3) + pos[2]
|
||||
return [x, y, z]
|
||||
})
|
||||
},
|
||||
decorated: (config, pos, ctx) => {
|
||||
return getPositions(pos, config?.outer, ctx).flatMap(p => {
|
||||
return getPositions(p, config?.inner, ctx)
|
||||
})
|
||||
},
|
||||
depth_average: (config, pos, ctx) => {
|
||||
const y = ctx.nextInt(config?.spread ?? 0) + ctx.nextInt(config?.spread ?? 0) - (config.spread ?? 0) + (config?.baseline ?? 0)
|
||||
return decorateY(pos, y)
|
||||
},
|
||||
emerald_ore: (_config, pos, ctx) => {
|
||||
const count = 3 + ctx.nextInt(6)
|
||||
return [...new Array(count)].map(() => [
|
||||
pos[0] + ctx.nextInt(16),
|
||||
4 + ctx.nextInt(28),
|
||||
pos[2] + ctx.nextInt(16),
|
||||
])
|
||||
},
|
||||
fire: (config, pos, ctx) => {
|
||||
const count = 1 + ctx.nextInt(ctx.nextInt(sampleInt(config?.count, ctx)))
|
||||
return [...new Array(count)].map(() => [
|
||||
pos[0] + ctx.nextInt(16),
|
||||
ctx.nextInt(128),
|
||||
pos[2] + ctx.nextInt(16),
|
||||
])
|
||||
},
|
||||
glowstone: (config, pos, ctx) => {
|
||||
const count = ctx.nextInt(1 + ctx.nextInt(sampleInt(config?.count, ctx)))
|
||||
return [...new Array(count)].map(() => [
|
||||
pos[0] + ctx.nextInt(16),
|
||||
ctx.nextInt(128),
|
||||
pos[2] + ctx.nextInt(16),
|
||||
])
|
||||
},
|
||||
heightmap: (_config, pos, ctx) => {
|
||||
const y = Math.max(ctx.seaLevel, terrain[clamp(0, 63, pos[0])])
|
||||
return decorateY(pos, y)
|
||||
},
|
||||
heightmap_spread_double: (_config, pos, ctx) => {
|
||||
const y = Math.max(ctx.seaLevel, terrain[clamp(0, 63, pos[0])])
|
||||
return decorateY(pos, ctx.nextInt(y * 2))
|
||||
},
|
||||
heightmap_world_surface: (_config, pos, ctx) => {
|
||||
const y = Math.max(ctx.seaLevel, terrain[clamp(0, 63, pos[0])])
|
||||
return decorateY(pos, y)
|
||||
},
|
||||
iceberg: (_config, pos, ctx) => {
|
||||
return [[
|
||||
pos[0] + 4 + ctx.nextInt(8),
|
||||
pos[1],
|
||||
pos[2] + 4 + ctx.nextInt(8),
|
||||
]]
|
||||
},
|
||||
lava_lake: (config, pos, ctx) => {
|
||||
if (ctx.nextInt((config.chance ?? 1) / 10) === 0) {
|
||||
const y = ctx.nextInt(ctx.nextInt(256 - 8) + 8)
|
||||
if (y < ctx.seaLevel || ctx.nextInt((config?.chance ?? 1) / 8) == 0) {
|
||||
const x = ctx.nextInt(16) + pos[0]
|
||||
const z = ctx.nextInt(16) + pos[2]
|
||||
return [[x, y, z]]
|
||||
}
|
||||
}
|
||||
return []
|
||||
},
|
||||
nope: (_config, pos) => {
|
||||
return [pos]
|
||||
},
|
||||
range: (config, pos, ctx) => {
|
||||
const y = ctx.nextInt((config?.maximum ?? 1) - (config?.top_offset ?? 0)) + (config?.bottom_offset ?? 0)
|
||||
return decorateY(pos, y)
|
||||
},
|
||||
range_biased: (config, pos, ctx) => {
|
||||
const y = ctx.nextInt(ctx.nextInt((config?.maximum ?? 1) - (config?.top_offset ?? 0)) + (config?.bottom_offset ?? 0))
|
||||
return decorateY(pos, y)
|
||||
},
|
||||
range_very_biased: (config, pos, ctx) => {
|
||||
const y = ctx.nextInt(ctx.nextInt(ctx.nextInt((config?.maximum ?? 1) - (config?.top_offset ?? 0)) + (config?.bottom_offset ?? 0)) + (config?.bottom_offset ?? 0))
|
||||
return decorateY(pos, y)
|
||||
},
|
||||
spread_32_above: (_config, pos, ctx) => {
|
||||
const y = ctx.nextInt(pos[1] + 32)
|
||||
return decorateY(pos, y)
|
||||
},
|
||||
top_solid_heightmap: (_config, pos) => {
|
||||
const y = terrain[clamp(0, 63, pos[0])]
|
||||
return decorateY(pos, y)
|
||||
},
|
||||
magma: (_config, pos, ctx) => {
|
||||
const y = ctx.nextInt(pos[1] + 32)
|
||||
return decorateY(pos, y)
|
||||
},
|
||||
square: (_config, pos, ctx) => {
|
||||
return [[
|
||||
pos[0] + ctx.nextInt(16),
|
||||
pos[1],
|
||||
pos[2] + ctx.nextInt(16),
|
||||
]]
|
||||
},
|
||||
surface_relative_threshold: (config, pos) => {
|
||||
const height = terrain[clamp(0, 63, pos[0])]
|
||||
const min = height + (config?.min_inclusive ?? -Infinity)
|
||||
const max = height + (config?.max_inclusive ?? Infinity)
|
||||
return (pos[1] < min || pos[1] > max) ? [pos] : []
|
||||
},
|
||||
water_lake: (config, pos, ctx) => {
|
||||
if (ctx.nextInt(config.chance ?? 1) === 0) {
|
||||
return [[
|
||||
pos[0] + ctx.nextInt(16),
|
||||
ctx.nextInt(256),
|
||||
pos[2] + ctx.nextInt(16),
|
||||
]]
|
||||
}
|
||||
return []
|
||||
},
|
||||
}
|
||||
|
||||
// 1.18 and after
|
||||
function modifyPlacement(pos: BlockPos, placement: any[], ctx: PlacementContext) {
|
||||
let positions = [pos]
|
||||
for (const modifier of placement) {
|
||||
const modifierFn = PlacementModifiers[normalize(modifier?.type ?? 'nope')]
|
||||
if (!modifierFn) continue
|
||||
positions = positions.flatMap(pos =>
|
||||
PlacementModifiers[normalize(modifier.type)](modifier, pos, ctx)
|
||||
)
|
||||
}
|
||||
for (const pos of positions) {
|
||||
ctx.placements.push([pos, 0])
|
||||
}
|
||||
}
|
||||
|
||||
const PlacementModifiers: {
|
||||
[key: string]: (config: any, pos: BlockPos, ctx: PlacementContext) => BlockPos[],
|
||||
} = {
|
||||
count: ({ count }, pos, ctx) => {
|
||||
return new Array(sampleInt(count ?? 1, ctx)).fill(pos)
|
||||
},
|
||||
count_on_every_layer: ({ count }, pos, ctx) => {
|
||||
return new Array(sampleInt(count ?? 1, ctx)).fill(pos)
|
||||
.map(p => [
|
||||
p[0] + ctx.nextInt(16),
|
||||
p[1],
|
||||
p[2] + ctx.nextInt(16),
|
||||
])
|
||||
},
|
||||
environment_scan: ({}, pos) => {
|
||||
return [pos]
|
||||
},
|
||||
height_range: ({ height }, pos, ctx) => {
|
||||
return decorateY(pos, sampleHeight(height, ctx))
|
||||
},
|
||||
heightmap: ({}, pos, ctx) => {
|
||||
const y = Math.max(ctx.seaLevel, terrain[clamp(0, 63, pos[0])])
|
||||
return decorateY(pos, y)
|
||||
},
|
||||
in_square: ({}, pos, ctx) => {
|
||||
return [[
|
||||
pos[0] + ctx.nextInt(16),
|
||||
pos[1],
|
||||
pos[2] + ctx.nextInt(16),
|
||||
]]
|
||||
},
|
||||
noise_based_count: ({ noise_to_count_ratio, noise_factor, noise_offset }, pos, ctx) => {
|
||||
const factor = Math.max(1, noise_factor)
|
||||
const noise = ctx.biomeInfoNoise.sample(pos[0] / factor, 0, pos[2] / factor)
|
||||
const count = Math.max(0, Math.ceil((noise + (noise_offset ?? 0)) * noise_to_count_ratio))
|
||||
return new Array(count).fill(pos)
|
||||
},
|
||||
noise_threshold_count: ({ noise_level, below_noise, above_noise }, pos, ctx) => {
|
||||
const noise = ctx.biomeInfoNoise.sample(pos[0] / 200, 0, pos[2] / 200)
|
||||
const count = noise < noise_level ? below_noise : above_noise
|
||||
return new Array(count).fill(pos)
|
||||
},
|
||||
random_offset: ({ xz_spread, y_spread }, pos, ctx) => {
|
||||
return [[
|
||||
pos[0] + sampleInt(xz_spread, ctx),
|
||||
pos[1] + sampleInt(y_spread, ctx),
|
||||
pos[2] + sampleInt(xz_spread, ctx),
|
||||
]]
|
||||
},
|
||||
rarity_filter: ({ chance }, pos, ctx) => {
|
||||
return ctx.nextFloat() < 1 / (chance ?? 1) ? [pos] : []
|
||||
},
|
||||
}
|
||||
@@ -1,42 +1,86 @@
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
import { BlockPos, ChunkPos, LegacyRandom, PerlinNoise } from 'deepslate'
|
||||
import type { mat3 } from 'gl-matrix'
|
||||
import { useCallback, useMemo, useRef, useState } from 'preact/hooks'
|
||||
import { useLocale } from '../../contexts/index.js'
|
||||
import { useCanvas } from '../../hooks/index.js'
|
||||
import { decorator } from '../../previews/index.js'
|
||||
import { randomSeed } from '../../Utils.js'
|
||||
import { computeIfAbsent, iterateWorld2D, randomSeed } from '../../Utils.js'
|
||||
import { Btn } from '../index.js'
|
||||
import type { PlacedFeature, PlacementContext } from './Decorator.js'
|
||||
import { decorateChunk } from './Decorator.js'
|
||||
import type { PreviewProps } from './index.js'
|
||||
import { InteractiveCanvas2D } from './InteractiveCanvas2D.jsx'
|
||||
|
||||
export const DecoratorPreview = ({ data, version, shown }: PreviewProps) => {
|
||||
const { locale } = useLocale()
|
||||
const [scale, setScale] = useState(4)
|
||||
const [seed, setSeed] = useState(randomSeed())
|
||||
|
||||
const state = JSON.stringify(data)
|
||||
|
||||
const { canvas, redraw } = useCanvas({
|
||||
size() {
|
||||
return [scale * 16, scale * 16]
|
||||
},
|
||||
async draw(img) {
|
||||
decorator(data, img, { seed, version, size: [scale * 16, 128, scale * 16] })
|
||||
},
|
||||
}, [version, state, seed])
|
||||
|
||||
useEffect(() => {
|
||||
if (shown) {
|
||||
redraw()
|
||||
const { context, chunkFeatures } = useMemo(() => {
|
||||
const random = new LegacyRandom(seed)
|
||||
const context: PlacementContext = {
|
||||
placements: [],
|
||||
features: [],
|
||||
random,
|
||||
biomeInfoNoise: new PerlinNoise(random.fork(), 0, [1]),
|
||||
seaLevel: 63,
|
||||
version: version,
|
||||
nextFloat: () => random.nextFloat(),
|
||||
nextInt: (max: number) => random.nextInt(max),
|
||||
nextGaussian: () => Math.sqrt(-2 * Math.log(1 - random.nextFloat())) * Math.cos(2 * Math.PI * random.nextFloat()),
|
||||
}
|
||||
}, [version, state, scale, seed, shown])
|
||||
return {
|
||||
context,
|
||||
chunkFeatures: new Map<string, PlacedFeature[]>(),
|
||||
}
|
||||
}, [state, version, seed])
|
||||
|
||||
const ctx = useRef<CanvasRenderingContext2D>()
|
||||
const imageData = useRef<ImageData>()
|
||||
const [focused, setFocused] = useState<string[]>([])
|
||||
|
||||
const onSetup = useCallback(function onSetup(canvas: HTMLCanvasElement) {
|
||||
const ctx2D = canvas.getContext('2d')
|
||||
if (!ctx2D) return
|
||||
ctx.current = ctx2D
|
||||
}, [])
|
||||
const onResize = useCallback(function onResize(width: number, height: number) {
|
||||
if (!ctx.current) return
|
||||
imageData.current = ctx.current.getImageData(0, 0, width, height)
|
||||
}, [])
|
||||
const onDraw = useCallback(function onDraw(transform: mat3) {
|
||||
if (!ctx.current || !imageData.current || !shown) return
|
||||
|
||||
iterateWorld2D(imageData.current, transform, (x, y) => {
|
||||
const pos = ChunkPos.create(Math.floor(x / 16), Math.floor(-y / 16))
|
||||
const features = computeIfAbsent(chunkFeatures, `${pos[0]} ${pos[1]}`, () => decorateChunk(pos, data, context))
|
||||
return features.find(f => f.pos[0] === x && f.pos[2] == -y) ?? { pos: BlockPos.create(x, 0, -y) }
|
||||
}, (feature) => {
|
||||
if ('color' in feature) {
|
||||
return feature.color
|
||||
}
|
||||
if ((Math.floor(feature.pos[0] / 16) + Math.floor(feature.pos[2] / 16)) % 2 === 0) {
|
||||
return [0.85 * 256, 0.85 * 256, 0.85 * 256]
|
||||
}
|
||||
return [256, 256, 256]
|
||||
})
|
||||
ctx.current.putImageData(imageData.current, 0, 0)
|
||||
}, [context, chunkFeatures, shown])
|
||||
const onHover = useCallback(function onHover(pos: [number, number] | undefined) {
|
||||
if (!pos) {
|
||||
setFocused([])
|
||||
} else {
|
||||
const [x, y] = pos
|
||||
setFocused([`X=${x} Z=${-y}`])
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <>
|
||||
<div class="controls preview-controls">
|
||||
<Btn icon="dash" tooltip={locale('zoom_out')}
|
||||
onClick={() => setScale(Math.min(16, scale + 1))} />
|
||||
<Btn icon="plus" tooltip={locale('zoom_in')}
|
||||
onClick={() => setScale(Math.max(1, scale - 1))} />
|
||||
{focused.map(s => <Btn label={s} class="no-pointer" /> )}
|
||||
<Btn icon="sync" tooltip={locale('generate_new_seed')}
|
||||
onClick={() => setSeed(randomSeed())} />
|
||||
</div>
|
||||
<canvas ref={canvas} width="64" height="64"></canvas>
|
||||
<div class="full-preview">
|
||||
<InteractiveCanvas2D onSetup={onSetup} onResize={onResize} onDraw={onDraw} onHover={onHover} pixelSize={4} startScale={1/8} minScale={1/32} maxScale={1} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
357
src/app/components/previews/Deepslate.ts
Normal file
357
src/app/components/previews/Deepslate.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import * as deepslate19 from 'deepslate/worldgen'
|
||||
import type { VersionId } from '../../services/index.js'
|
||||
import { checkVersion, fetchAllPresets, fetchPreset } from '../../services/index.js'
|
||||
import { clamp, computeIfAbsent, computeIfAbsentAsync, deepClone, deepEqual, isObject, square } from '../../Utils.js'
|
||||
|
||||
export type ProjectData = Record<string, Record<string, unknown>>
|
||||
|
||||
const DYNAMIC_REGISTRIES = new Set([
|
||||
'minecraft:worldgen/noise',
|
||||
'minecraft:worldgen/density_function',
|
||||
'minecraft:worldgen/noise_settings',
|
||||
])
|
||||
|
||||
export class Deepslate {
|
||||
private d = deepslate19
|
||||
private loadedVersion: VersionId | undefined
|
||||
private loadingVersion: VersionId | undefined
|
||||
private loadingPromise: Promise<void> | undefined
|
||||
private readonly deepslateCache = new Map<VersionId, typeof deepslate19>()
|
||||
private readonly Z = 0
|
||||
|
||||
private cacheState: unknown
|
||||
private settingsCache: NoiseSettings | undefined
|
||||
private routerCache: NoiseRouter | undefined
|
||||
private generatorCache: ChunkGenerator | undefined
|
||||
private biomeSourceCache: BiomeSource | undefined
|
||||
private randomStateCache: deepslate19.RandomState | undefined
|
||||
private chunksCache: Chunk[] = []
|
||||
private biomeCache: Map<string, string> = new Map()
|
||||
private readonly presetCache: Map<string, unknown> = new Map()
|
||||
|
||||
public async loadVersion(version: VersionId, project?: ProjectData) {
|
||||
if (this.loadedVersion === version) {
|
||||
this.applyProjectData(version, project)
|
||||
return
|
||||
}
|
||||
if (this.loadingVersion !== version || !this.loadingPromise) {
|
||||
this.loadingVersion = version
|
||||
this.loadingPromise = this.doLoadVersion(version, project)
|
||||
}
|
||||
return this.loadingPromise
|
||||
}
|
||||
|
||||
private async doLoadVersion(version: VersionId, project?: ProjectData) {
|
||||
const cachedDeepslate = this.deepslateCache.get(version)
|
||||
if (cachedDeepslate) {
|
||||
this.d = cachedDeepslate
|
||||
} else {
|
||||
if (checkVersion(version, '1.19')) {
|
||||
this.d = deepslate19
|
||||
} else if (checkVersion(version, '1.18.2')) {
|
||||
this.d = await import('deepslate-1.18.2') as any
|
||||
} else {
|
||||
this.d = await import('deepslate-1.18') as any
|
||||
}
|
||||
if (checkVersion(version, '1.19')) {
|
||||
await Promise.all(this.d.Registry.REGISTRY.map(async (id, registry) => {
|
||||
if (DYNAMIC_REGISTRIES.has(id.toString())) {
|
||||
const entries = await fetchAllPresets(version, id.path)
|
||||
for (const [key, value] of entries.entries()) {
|
||||
registry.register(this.d.Identifier.parse(key), registry.parse(value), true)
|
||||
}
|
||||
}
|
||||
}))
|
||||
} else if (checkVersion(version, '1.18.2')) {
|
||||
await Promise.all([...DYNAMIC_REGISTRIES].map(async (id) => {
|
||||
const entries = await fetchAllPresets(version, id.replace(/^minecraft:/, ''))
|
||||
for (const [key, value] of entries.entries()) {
|
||||
if (id === 'minecraft:worldgen/noise') {
|
||||
this.d.WorldgenRegistries.NOISE.register(this.d.Identifier.parse(key), this.d.NoiseParameters.fromJson(value), true)
|
||||
} else if (id === 'minecraft:worldgen/density_function') {
|
||||
this.d.WorldgenRegistries.DENSITY_FUNCTION.register(this.d.Identifier.parse(key), this.d.DensityFunction.fromJson(value), true)
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
this.deepslateCache.set(version, this.d)
|
||||
}
|
||||
this.applyProjectData(version, project)
|
||||
this.loadedVersion = version
|
||||
this.loadingVersion = undefined
|
||||
}
|
||||
|
||||
private applyProjectData(version: VersionId, project?: ProjectData) {
|
||||
if (checkVersion(version, '1.19')) {
|
||||
this.d.Registry.REGISTRY.forEach((id, registry) => {
|
||||
if (DYNAMIC_REGISTRIES.has(id.toString())) {
|
||||
registry.clear()
|
||||
for (const [key, value] of Object.entries(project?.[id.path] ?? {})) {
|
||||
registry.register(this.d.Identifier.parse(key), registry.parse(value))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public async loadChunkGenerator(settings: unknown, biomeState: unknown, seed: bigint) {
|
||||
const newCacheState = [settings, `${seed}`, biomeState]
|
||||
if (!deepEqual(this.cacheState, newCacheState)) {
|
||||
const noiseSettings = this.createNoiseSettings(settings)
|
||||
const biomeSource = await this.createBiomeSource(noiseSettings, biomeState, seed)
|
||||
const chunkGenerator = this.isVersion('1.19')
|
||||
? new this.d.NoiseChunkGenerator(biomeSource, noiseSettings)
|
||||
: new (this.d.NoiseChunkGenerator as any)(seed, biomeSource, noiseSettings)
|
||||
this.settingsCache = noiseSettings.noise
|
||||
this.generatorCache = chunkGenerator
|
||||
if (this.isVersion('1.19')) {
|
||||
this.randomStateCache = new this.d.RandomState(noiseSettings, seed)
|
||||
this.routerCache = this.randomStateCache.router
|
||||
} else {
|
||||
this.randomStateCache = undefined
|
||||
|
||||
this.routerCache = undefined
|
||||
}
|
||||
this.biomeSourceCache = {
|
||||
getBiome: (x, y, z) => biomeSource.getBiome(x, y, z, undefined!),
|
||||
}
|
||||
this.chunksCache = []
|
||||
this.biomeCache = new Map()
|
||||
this.cacheState = deepClone(newCacheState)
|
||||
}
|
||||
}
|
||||
|
||||
private async createBiomeSource(noiseSettings: deepslate19.NoiseGeneratorSettings, biomeState: unknown, seed: bigint): Promise<deepslate19.BiomeSource> {
|
||||
if (this.loadedVersion && isObject(biomeState) && typeof biomeState.preset === 'string') {
|
||||
const version = this.loadedVersion
|
||||
const preset = biomeState.preset.replace(/^minecraft:/, '')
|
||||
const biomes = await computeIfAbsentAsync(this.presetCache, `${version}-${preset}`, async () => {
|
||||
const dimension = await fetchPreset(version, 'dimension', preset === 'overworld' ? 'overworld' : 'the_nether')
|
||||
return dimension.generator.biome_source.biomes
|
||||
})
|
||||
biomeState = { type: biomeState.type, biomes }
|
||||
}
|
||||
if (this.isVersion('1.19')) {
|
||||
const bs = this.d.BiomeSource.fromJson(biomeState)
|
||||
return bs
|
||||
} else {
|
||||
const root = isObject(biomeState) ? biomeState : {}
|
||||
const type = typeof root.type === 'string' ? root.type.replace(/^minecraft:/, '') : undefined
|
||||
switch (type) {
|
||||
case 'fixed':
|
||||
return new (this.d as any).FixedBiome(this.isVersion('1.18.2') ? this.d.Identifier.parse(root.biome as string) : root.biome as any)
|
||||
case 'checkerboard':
|
||||
const shift = (root.scale ?? 2) + 2
|
||||
const numBiomes = root.biomes?.length ?? 0
|
||||
return { getBiome: (x: number, _y: number, z: number) => {
|
||||
const i = (((x >> shift) + (z >> shift)) % numBiomes + numBiomes) % numBiomes
|
||||
const biome = root.biomes?.[i]
|
||||
return this.isVersion('1.18.2') ? this.d.Identifier.parse(biome) : biome
|
||||
} }
|
||||
case 'multi_noise':
|
||||
if (this.isVersion('1.18')) {
|
||||
const parameters = new this.d.Climate.Parameters(root.biomes.map((b: any) => {
|
||||
const biome = this.isVersion('1.18.2') ? this.d.Identifier.parse(b.biome) : b.biome
|
||||
return [this.d.Climate.ParamPoint.fromJson(b.parameters), () => biome]
|
||||
}))
|
||||
const multiNoise = new (this.d as any).MultiNoise(parameters)
|
||||
let sampler: any
|
||||
if (this.isVersion('1.18.2')) {
|
||||
const router = this.d.NoiseRouter.create({
|
||||
temperature: new this.d.DensityFunction.Noise(0.25, 0, (this.d as any).Noises.TEMPERATURE),
|
||||
vegetation: new this.d.DensityFunction.Noise(0.25, 0, (this.d as any).Noises.VEGETATION),
|
||||
continents: new this.d.DensityFunction.Noise(0.25, 0, (this.d as any).Noises.CONTINENTALNESS),
|
||||
erosion: new this.d.DensityFunction.Noise(0.25, 0, (this.d as any).Noises.EROSION),
|
||||
ridges: new this.d.DensityFunction.Noise(0.25, 0, (this.d as any).Noises.RIDGE),
|
||||
})
|
||||
sampler = this.d.Climate.Sampler.fromRouter((this.d.NoiseRouter as any).withSettings(router, noiseSettings, seed))
|
||||
} else {
|
||||
const noiseSampler = new (this.d as any).NoiseSampler(this.d.NoiseSettings.fromJson(null), true, seed, true)
|
||||
sampler = (x: number, y: number, z: number) => noiseSampler.sample(x, y, z)
|
||||
}
|
||||
return { getBiome: (x: number, y: number, z: number) => {
|
||||
return multiNoise.getBiome(x, y, z, sampler)
|
||||
} }
|
||||
} else {
|
||||
const noise = ['altitude', 'temperature', 'humidity', 'weirdness']
|
||||
.map((id, i) => {
|
||||
const config = root[`${id}_noise`]
|
||||
config.firstOctave = clamp(config.firstOctave ?? -7, -100, -1)
|
||||
return new this.d.NormalNoise(new this.d.LegacyRandom(seed + BigInt(i)), config)
|
||||
})
|
||||
if (!Array.isArray(root.biomes) || root.biomes.length === 0) {
|
||||
return { getBiome: () => this.d.Identifier.create('unknown') }
|
||||
}
|
||||
return { getBiome: (x: number, _y: number, z: number) => {
|
||||
const n = noise.map(n => n.sample(x, z, 0))
|
||||
let minDist = Infinity
|
||||
let minBiome = 'unknown'
|
||||
for (const { biome, parameters: p } of root.biomes) {
|
||||
const dist = square(p.altitude - n[0]) + square(p.temperature - n[1]) + square(p.humidity - n[2]) + square(p.weirdness - n[3]) + square(p.offset)
|
||||
if (dist < minDist) {
|
||||
minDist = dist
|
||||
minBiome = biome
|
||||
}
|
||||
}
|
||||
return minBiome as unknown as deepslate19.Identifier
|
||||
} }
|
||||
}
|
||||
default: throw new Error(`Unsupported biome source ${type}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createNoiseSettings(settings: unknown): deepslate19.NoiseGeneratorSettings {
|
||||
if (typeof settings === 'string') {
|
||||
if (this.isVersion('1.19')) {
|
||||
return this.d.WorldgenRegistries.NOISE_SETTINGS.getOrThrow(this.d.Identifier.parse(settings))
|
||||
} else {
|
||||
return this.d.NoiseGeneratorSettings.fromJson(undefined)
|
||||
}
|
||||
} else {
|
||||
return this.d.NoiseGeneratorSettings.fromJson(settings)
|
||||
}
|
||||
}
|
||||
|
||||
public generateChunks(minX: number, width: number, biome = 'unknown') {
|
||||
minX = Math.floor(minX)
|
||||
if (!this.settingsCache) {
|
||||
throw new Error('Tried to generate chunks before settings are loaded')
|
||||
}
|
||||
const minY = this.settingsCache.minY
|
||||
const height = this.settingsCache.height
|
||||
|
||||
return [...Array(Math.ceil(width / 16) + 1)].map((_, i) => {
|
||||
const x: number = (minX >> 4) + i
|
||||
const cached = this.chunksCache.find(c => c.pos[0] === x)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const chunk = new this.d.Chunk(minY, height, this.d.ChunkPos.create(x, this.Z >> 4))
|
||||
if (!this.generatorCache) {
|
||||
throw new Error('Tried to generate chunks before generator is loaded')
|
||||
}
|
||||
if (checkVersion(this.loadedVersion!, '1.19')) {
|
||||
if (!this.randomStateCache) {
|
||||
throw new Error('Tried to generate chunks before random state is loaded')
|
||||
}
|
||||
this.generatorCache.fill(this.randomStateCache, chunk, true)
|
||||
this.generatorCache.buildSurface(this.randomStateCache, chunk, biome)
|
||||
} else {
|
||||
(this.generatorCache as any).fill(chunk, true);
|
||||
(this.generatorCache as any).buildSurface(chunk, biome)
|
||||
}
|
||||
this.chunksCache.push(chunk)
|
||||
return chunk
|
||||
})
|
||||
}
|
||||
|
||||
public getBiome(x: number, y: number, z: number) {
|
||||
return computeIfAbsent(this.biomeCache, `${x}:${y}:${z}`, () => {
|
||||
if (this.isVersion('1.19')) {
|
||||
if (!this.randomStateCache || !this.generatorCache) {
|
||||
throw new Error('Tried to compute biomes before random state is loaded')
|
||||
}
|
||||
return this.generatorCache.computeBiome(this.randomStateCache, x, y, z).toString()
|
||||
} else {
|
||||
if(!this.biomeSourceCache) {
|
||||
throw new Error('Tried to compute biomes before biome source is loaded')
|
||||
}
|
||||
return this.biomeSourceCache.getBiome(x, y, z).toString()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public loadDensityFunction(state: unknown, minY: number, height: number, seed: bigint) {
|
||||
if (this.isVersion('1.19')) {
|
||||
const settings = this.d.NoiseGeneratorSettings.create({
|
||||
noise: {
|
||||
minY: minY,
|
||||
height: height,
|
||||
xzSize: 1,
|
||||
ySize: 2,
|
||||
},
|
||||
noiseRouter: this.d.NoiseRouter.create({
|
||||
finalDensity: this.d.DensityFunction.fromJson(state),
|
||||
}),
|
||||
})
|
||||
this.settingsCache = settings.noise
|
||||
const randomState = new this.d.RandomState(settings, seed)
|
||||
return randomState.router.finalDensity
|
||||
} else {
|
||||
const random = this.d.XoroshiroRandom.create(seed).forkPositional()
|
||||
const settings = this.d.NoiseSettings.fromJson({
|
||||
min_y: minY,
|
||||
height: height,
|
||||
size_horizontal: 1,
|
||||
size_vertical: 2,
|
||||
sampling: { xz_scale: 1, y_scale: 1, xz_factor: 80, y_factor: 160 },
|
||||
bottom_slide: { target: 0.1171875, size: 3, offset: 0 },
|
||||
top_slide: { target: -0.078125, size: 2, offset: 8 },
|
||||
terrain_shaper: { offset: 0.044, factor: 4, jaggedness: 0 },
|
||||
})
|
||||
this.settingsCache = settings
|
||||
const originalFn = this.d.DensityFunction.fromJson(state)
|
||||
return originalFn.mapAll(new (this.d.NoiseRouter as any).Visitor(random, settings))
|
||||
}
|
||||
}
|
||||
|
||||
public getNoiseSettings(): NoiseSettings {
|
||||
if (!this.settingsCache) {
|
||||
throw new Error('Tried to access noise settings when they are not loaded')
|
||||
}
|
||||
return this.settingsCache
|
||||
}
|
||||
|
||||
public getNoiseRouter(): NoiseRouter {
|
||||
if (!this.routerCache) {
|
||||
throw new Error('Tried to access noise router when they are not loaded')
|
||||
}
|
||||
return this.routerCache
|
||||
}
|
||||
|
||||
public getBlockState(x: number, y: number) {
|
||||
x = Math.floor(x)
|
||||
y = Math.floor(y)
|
||||
const chunk = this.chunksCache.find(c => this.d.ChunkPos.minBlockX(c.pos) <= x && this.d.ChunkPos.maxBlockX(c.pos) >= x)
|
||||
return chunk?.getBlockState(this.d.BlockPos.create(x, y, this.Z))
|
||||
}
|
||||
|
||||
private isVersion(min?: VersionId, max?: VersionId) {
|
||||
if (!this.loadedVersion) {
|
||||
throw new Error('No deepslate version loaded')
|
||||
}
|
||||
return checkVersion(this.loadedVersion, min, max)
|
||||
}
|
||||
}
|
||||
|
||||
export const DEEPSLATE = new Deepslate()
|
||||
|
||||
interface NoiseSettings {
|
||||
minY: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface NoiseRouter {
|
||||
temperature: deepslate19.DensityFunction
|
||||
vegetation: deepslate19.DensityFunction
|
||||
continents: deepslate19.DensityFunction
|
||||
erosion: deepslate19.DensityFunction
|
||||
ridges: deepslate19.DensityFunction
|
||||
depth: deepslate19.DensityFunction
|
||||
}
|
||||
|
||||
interface ChunkGenerator {
|
||||
fill(randomState: deepslate19.RandomState, chunk: Chunk, onlyFirstZ?: boolean): void
|
||||
buildSurface(randomState: deepslate19.RandomState, chunk: Chunk, biome: string): void
|
||||
computeBiome(randomState: deepslate19.RandomState, quartX: number, quartY: number, quartZ: number): deepslate19.Identifier
|
||||
}
|
||||
|
||||
interface Chunk {
|
||||
readonly pos: deepslate19.ChunkPos
|
||||
getBlockState(pos: deepslate19.BlockPos): deepslate19.BlockState
|
||||
}
|
||||
|
||||
interface BiomeSource {
|
||||
getBiome(x: number, y: number, z: number): deepslate19.Identifier
|
||||
}
|
||||
@@ -1,77 +1,132 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { useLocale, useProject } from '../../contexts/index.js'
|
||||
import { useCanvas } from '../../hooks/index.js'
|
||||
import type { ColormapType } from '../../previews/Colormap.js'
|
||||
import { densityFunction, densityPoint } from '../../previews/index.js'
|
||||
import { DataModel } from '@mcschema/core'
|
||||
import type { Voxel } from 'deepslate/render'
|
||||
import { clampedMap, VoxelRenderer } from 'deepslate/render'
|
||||
import type { mat3, mat4 } from 'gl-matrix'
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { getProjectData, useLocale, useProject, useVersion } from '../../contexts/index.js'
|
||||
import { useAsync } from '../../hooks/useAsync.js'
|
||||
import { Store } from '../../Store.js'
|
||||
import { randomSeed } from '../../Utils.js'
|
||||
import { Btn, BtnMenu } from '../index.js'
|
||||
import { iterateWorld2D, randomSeed } from '../../Utils.js'
|
||||
import { Btn, BtnMenu, NumberInput } from '../index.js'
|
||||
import type { ColormapType } from './Colormap.js'
|
||||
import { getColormap } from './Colormap.js'
|
||||
import { ColormapSelector } from './ColormapSelector.jsx'
|
||||
import { DEEPSLATE } from './Deepslate.js'
|
||||
import type { PreviewProps } from './index.js'
|
||||
import { InteractiveCanvas2D } from './InteractiveCanvas2D.jsx'
|
||||
import { InteractiveCanvas3D } from './InteractiveCanvas3D.jsx'
|
||||
|
||||
export const DensityFunctionPreview = ({ data, shown, version }: PreviewProps) => {
|
||||
export const DensityFunctionPreview = ({ data, shown }: PreviewProps) => {
|
||||
const { locale } = useLocale()
|
||||
const { project } = useProject()
|
||||
const { version } = useVersion()
|
||||
const [voxelMode, setVoxelMode] = useState(false)
|
||||
const [seed, setSeed] = useState(randomSeed())
|
||||
const [minY] = useState(0)
|
||||
const [height] = useState(256)
|
||||
const [autoScroll, setAutoScroll] = useState(false)
|
||||
const serializedData = JSON.stringify(data)
|
||||
|
||||
const { value: df } = useAsync(async () => {
|
||||
await DEEPSLATE.loadVersion(version, getProjectData(project))
|
||||
const df = DEEPSLATE.loadDensityFunction(DataModel.unwrapLists(data), minY, height, seed)
|
||||
return df
|
||||
}, [version, project, minY, height, seed, serializedData])
|
||||
|
||||
// === 2D ===
|
||||
const imageData = useRef<ImageData>()
|
||||
const ctx = useRef<CanvasRenderingContext2D>()
|
||||
const [focused, setFocused] = useState<string[]>([])
|
||||
const [colormap, setColormap] = useState<ColormapType>(Store.getColormap() ?? 'viridis')
|
||||
const offset = useRef(0)
|
||||
const scrollInterval = useRef<number | undefined>(undefined)
|
||||
const state = JSON.stringify([data])
|
||||
|
||||
const size = 256
|
||||
const { canvas, redraw } = useCanvas({
|
||||
size() {
|
||||
return [size, size]
|
||||
},
|
||||
async draw(img) {
|
||||
const options = { offset: offset.current, width: img.width, seed, version, project, minY, height, colormap }
|
||||
await densityFunction(data, img, options)
|
||||
},
|
||||
async onDrag(dx) {
|
||||
offset.current += dx * size
|
||||
redraw()
|
||||
},
|
||||
async onHover(x, y) {
|
||||
const worldX = Math.floor(x * size)
|
||||
const worldY = Math.floor(y * (height - minY))
|
||||
const options = { offset: offset.current, width: size, seed, version, project, minY, height, colormap }
|
||||
const density = await densityPoint(data, worldX, worldY, options)
|
||||
setFocused([density.toPrecision(3), `X=${Math.floor(worldX - offset.current)} Y=${(height - minY) - worldY}`])
|
||||
},
|
||||
onLeave() {
|
||||
const onSetup2D = useCallback((canvas: HTMLCanvasElement) => {
|
||||
const ctx2D = canvas.getContext('2d')
|
||||
if (!ctx2D) return
|
||||
ctx.current = ctx2D
|
||||
}, [voxelMode])
|
||||
const onResize2D = useCallback((width: number, height: number) => {
|
||||
if (!ctx.current) return
|
||||
imageData.current = ctx.current.getImageData(0, 0, width, height)
|
||||
}, [voxelMode])
|
||||
const onDraw2D = useCallback((transform: mat3) => {
|
||||
if (!ctx.current || !imageData.current || !df) return
|
||||
|
||||
const colormapFn = getColormap(colormap)
|
||||
const colorPicker = (t: number) => colormapFn(t <= 0.5 ? t - 0.08 : t + 0.08)
|
||||
let limit = 0.01
|
||||
iterateWorld2D(imageData.current, transform, (x, y) => {
|
||||
const density = df.compute({ x, y, z: 0 })
|
||||
limit = Math.max(limit, Math.min(1, Math.abs(density)))
|
||||
return density
|
||||
}, (density) => {
|
||||
const color = colorPicker(clampedMap(density, -limit, limit, 1, 0))
|
||||
return [color[0] * 256, color[1] * 256, color[2] * 256]
|
||||
})
|
||||
ctx.current.putImageData(imageData.current, 0, 0)
|
||||
}, [voxelMode, df, colormap])
|
||||
const onHover2D = useCallback((pos: [number, number] | undefined) => {
|
||||
if (!pos || !df) {
|
||||
setFocused([])
|
||||
},
|
||||
}, [version, state, seed, minY, height, colormap, project])
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollInterval.current) {
|
||||
clearInterval(scrollInterval.current)
|
||||
} else {
|
||||
const [x, y] = pos
|
||||
const output = df.compute({ x: x, y: -y, z: 0 })
|
||||
setFocused([output.toPrecision(3), `X=${x} Y=${-y}`])
|
||||
}
|
||||
if (shown) {
|
||||
redraw()
|
||||
if (autoScroll) {
|
||||
scrollInterval.current = setInterval(() => {
|
||||
offset.current -= 8
|
||||
redraw()
|
||||
}, 100) as any
|
||||
}, [voxelMode, df])
|
||||
|
||||
// === 3D ===
|
||||
const renderer = useRef<VoxelRenderer | undefined>(undefined)
|
||||
const [state, setState] = useState(0)
|
||||
const [cutoff, setCutoff] = useState(0)
|
||||
|
||||
const onSetup3D = useCallback((canvas: HTMLCanvasElement) => {
|
||||
const gl = canvas.getContext('webgl')
|
||||
if (!gl) return
|
||||
renderer.current = new VoxelRenderer(gl)
|
||||
}, [voxelMode])
|
||||
const onResize3D = useCallback((width: number, height: number) => {
|
||||
renderer.current?.setViewport(0, 0, width, height)
|
||||
}, [voxelMode])
|
||||
const onDraw3D = useCallback((transform: mat4) => {
|
||||
renderer.current?.draw(transform)
|
||||
}, [voxelMode])
|
||||
useEffect(() => {
|
||||
if (!renderer.current || !shown || !df || !voxelMode) return
|
||||
const voxels: Voxel[] = []
|
||||
const maxY = minY + height
|
||||
for (let x = 0; x < 16; x += 1) {
|
||||
for (let y = minY; y < maxY; y += 1) {
|
||||
for (let z = 0; z < 16; z += 1) {
|
||||
const density = df.compute({ x, y, z })
|
||||
if (density > cutoff) {
|
||||
voxels.push({ x, y, z, color: [200, 200, 200] })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [version, state, seed, minY, height, colormap, project, shown, autoScroll])
|
||||
renderer.current.setVoxels(voxels)
|
||||
setState(state => state + 1)
|
||||
}, [voxelMode, df, cutoff])
|
||||
|
||||
return <>
|
||||
<div class="controls preview-controls">
|
||||
{focused.map(s => <Btn label={s} class="no-pointer" /> )}
|
||||
<ColormapSelector value={colormap} onChange={setColormap} />
|
||||
<BtnMenu icon="gear" tooltip={locale('terrain_settings')}>
|
||||
<Btn icon={autoScroll ? 'square_fill' : 'square'} label={locale('preview.auto_scroll')} onClick={() => setAutoScroll(!autoScroll)} />
|
||||
</BtnMenu>
|
||||
{voxelMode ? <>
|
||||
<BtnMenu icon="gear">
|
||||
<div class="btn btn-input" onClick={e => e.stopPropagation()}>
|
||||
<span>{locale('cutoff')}</span>
|
||||
<NumberInput value={cutoff} onChange={setCutoff} />
|
||||
</div>
|
||||
</BtnMenu>
|
||||
</> : <>
|
||||
<ColormapSelector value={colormap} onChange={setColormap} />
|
||||
</>}
|
||||
<Btn label={voxelMode ? locale('3d') : locale('2d')} onClick={() => setVoxelMode(!voxelMode)} />
|
||||
<Btn icon="sync" tooltip={locale('generate_new_seed')}
|
||||
onClick={() => setSeed(randomSeed())} />
|
||||
</div>
|
||||
<canvas ref={canvas} width={size} height={size}></canvas>
|
||||
<div class="full-preview">{voxelMode
|
||||
? <InteractiveCanvas3D onSetup={onSetup3D} onDraw={onDraw3D} onResize={onResize3D} state={state} startDistance={100} startPosition={[8, 120, 8]} />
|
||||
: <InteractiveCanvas2D onSetup={onSetup2D} onDraw={onDraw2D} onHover={onHover2D} onResize={onResize2D} state={state} pixelSize={4} />
|
||||
}</div>
|
||||
</>
|
||||
}
|
||||
|
||||
127
src/app/components/previews/InteractiveCanvas2D.tsx
Normal file
127
src/app/components/previews/InteractiveCanvas2D.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { mat3, vec2 } from 'gl-matrix'
|
||||
import { useCallback, useEffect, useRef } from 'preact/hooks'
|
||||
|
||||
interface Props {
|
||||
onSetup: (canvas: HTMLCanvasElement) => void,
|
||||
onDraw: (transform: mat3) => void,
|
||||
onHover?: (offset: [number, number] | undefined) => void,
|
||||
onResize: (width: number, height: number) => void,
|
||||
state?: unknown,
|
||||
pixelSize?: number,
|
||||
startPosition?: [number, number],
|
||||
startScale?: number,
|
||||
minScale?: number,
|
||||
maxScale?: number,
|
||||
}
|
||||
export function InteractiveCanvas2D({ onSetup, onDraw, onHover, onResize, state, pixelSize = 1, startPosition, startScale, minScale = 1/16, maxScale = 16 }: Props) {
|
||||
const canvas = useRef<HTMLCanvasElement>(null)
|
||||
const dragStart = useRef<[number, number] | undefined>()
|
||||
const dragButton = useRef<number | undefined>()
|
||||
const centerPos = useRef<[number, number]>(startPosition ? [-startPosition[0], -startPosition[1]] : [0, 0])
|
||||
const viewScale = useRef(startScale ?? 1)
|
||||
const frameRequest = useRef<number>()
|
||||
|
||||
// Transforms screen coordinates to world coordinates
|
||||
const transform = useCallback(() => {
|
||||
const mat = mat3.create()
|
||||
if (!canvas.current) return mat
|
||||
const halfWidth = Math.floor(canvas.current.clientWidth / 2) / pixelSize
|
||||
const halfHeight = Math.floor(canvas.current.clientHeight / 2) / pixelSize
|
||||
const scale = Math.pow(2, Math.floor(Math.log(viewScale.current * pixelSize) /Math.log(2)))
|
||||
const offsetX = Math.floor(centerPos.current[0])
|
||||
const offsetY = Math.floor(centerPos.current[1])
|
||||
mat3.translate(mat, mat, [offsetX, offsetY])
|
||||
mat3.scale(mat, mat, [scale, scale])
|
||||
mat3.translate(mat, mat, [-halfWidth, -halfHeight])
|
||||
return mat
|
||||
}, [pixelSize])
|
||||
|
||||
const redraw = useRef<Function>(() => {})
|
||||
useEffect(() => {
|
||||
redraw.current = function requestRedraw() {
|
||||
if (frameRequest.current !== undefined) {
|
||||
cancelAnimationFrame(frameRequest.current)
|
||||
}
|
||||
frameRequest.current = requestAnimationFrame(function redraw() {
|
||||
onDraw(transform())
|
||||
})
|
||||
}
|
||||
}, [onDraw, transform])
|
||||
|
||||
useEffect(function changeDetected() {
|
||||
redraw.current()
|
||||
}, [state, onDraw, transform])
|
||||
|
||||
useEffect(function setupListeners() {
|
||||
if (!canvas.current) return
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (!canvas.current) return
|
||||
dragStart.current = [e.offsetX, e.offsetY]
|
||||
dragButton.current = e.button
|
||||
}
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!canvas.current) return
|
||||
if (dragStart.current === undefined) {
|
||||
const pos = vec2.fromValues(e.offsetX / pixelSize, e.offsetY / pixelSize)
|
||||
vec2.transformMat3(pos, pos, transform())
|
||||
onHover?.([Math.floor(pos[0]), Math.floor(pos[1])])
|
||||
} else {
|
||||
const dragEnd: [number, number] = [e.offsetX, e.offsetY]
|
||||
const dx = (dragEnd[0] - dragStart.current[0]) * (viewScale.current)
|
||||
const dy = (dragEnd[1] - dragStart.current[1]) * (viewScale.current)
|
||||
centerPos.current = [centerPos.current[0] - dx, centerPos.current[1] - dy]
|
||||
dragStart.current = dragEnd
|
||||
redraw.current()
|
||||
}
|
||||
}
|
||||
function onMouseUp () {
|
||||
dragStart.current = undefined
|
||||
}
|
||||
function onMouseLeave () {
|
||||
onHover?.(undefined)
|
||||
}
|
||||
function onWheel (e: WheelEvent) {
|
||||
const newScale = Math.pow(2, Math.log(viewScale.current) / Math.log(2) + e.deltaY / 200)
|
||||
if (newScale > minScale && newScale < maxScale) {
|
||||
viewScale.current = newScale
|
||||
redraw.current()
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
function onContextMenu(evt: MouseEvent) {
|
||||
evt.preventDefault()
|
||||
}
|
||||
function resizeHandler() {
|
||||
if (!canvas.current) return
|
||||
const width = Math.floor(canvas.current.clientWidth / pixelSize)
|
||||
const height = Math.floor(canvas.current.clientHeight / pixelSize)
|
||||
canvas.current.width = width
|
||||
canvas.current.height = height
|
||||
onResize?.(width, height)
|
||||
redraw.current()
|
||||
}
|
||||
|
||||
onSetup(canvas.current)
|
||||
resizeHandler()
|
||||
|
||||
canvas.current.addEventListener('mousedown', onMouseDown)
|
||||
canvas.current.addEventListener('mousemove', onMouseMove)
|
||||
canvas.current.addEventListener('mouseleave', onMouseLeave)
|
||||
document.body.addEventListener('mouseup', onMouseUp)
|
||||
canvas.current.addEventListener('wheel', onWheel)
|
||||
canvas.current.addEventListener('contextmenu', onContextMenu)
|
||||
window.addEventListener('resize', resizeHandler)
|
||||
|
||||
return () => {
|
||||
canvas.current?.removeEventListener('mousedown', onMouseDown)
|
||||
canvas.current?.removeEventListener('mousemove', onMouseMove)
|
||||
canvas.current?.removeEventListener('mouseleave', onMouseLeave)
|
||||
document.body.removeEventListener('mouseup', onMouseUp)
|
||||
canvas.current?.removeEventListener('wheel', onWheel)
|
||||
canvas.current?.removeEventListener('contextmenu', onContextMenu)
|
||||
window.removeEventListener('resize', resizeHandler)
|
||||
}
|
||||
}, [onSetup, onResize, onHover, transform, pixelSize, minScale, maxScale])
|
||||
|
||||
return <canvas ref={canvas}></canvas>
|
||||
}
|
||||
125
src/app/components/previews/InteractiveCanvas3D.tsx
Normal file
125
src/app/components/previews/InteractiveCanvas3D.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { mat4, vec3 } from 'gl-matrix'
|
||||
import { useEffect, useRef } from 'preact/hooks'
|
||||
|
||||
interface Props {
|
||||
onSetup: (canvas: HTMLCanvasElement) => void,
|
||||
onDraw: (transform: mat4) => void,
|
||||
onResize: (width: number, height: number) => void,
|
||||
state?: unknown,
|
||||
startPosition?: [number, number, number],
|
||||
startDistance?: number,
|
||||
startYRotation?: number,
|
||||
startXRotation?: number,
|
||||
}
|
||||
export function InteractiveCanvas3D({ onSetup, onDraw, onResize, state, startPosition, startDistance, startYRotation, startXRotation }: Props) {
|
||||
const canvas = useRef<HTMLCanvasElement>(null)
|
||||
const dragStart = useRef<[number, number] | undefined>()
|
||||
const dragButton = useRef<number | undefined>()
|
||||
const centerPos = useRef<[number, number, number]>(startPosition ? [-startPosition[0], -startPosition[1], -startPosition[2]] : [0, 0, 0])
|
||||
const viewDist = useRef(startDistance ?? 4)
|
||||
const yRotation = useRef(startYRotation ?? 0.8)
|
||||
const xRotation = useRef(startXRotation ?? 0.5)
|
||||
const frameRequest = useRef<number>()
|
||||
|
||||
const redraw = useRef<Function>(() => {})
|
||||
useEffect(() => {
|
||||
redraw.current = function requestRedraw() {
|
||||
if (frameRequest.current !== undefined) {
|
||||
cancelAnimationFrame(frameRequest.current)
|
||||
}
|
||||
frameRequest.current = requestAnimationFrame(function redraw() {
|
||||
yRotation.current = yRotation.current % (Math.PI * 2)
|
||||
xRotation.current = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, xRotation.current))
|
||||
viewDist.current = Math.max(1, viewDist.current)
|
||||
|
||||
const transform = mat4.create()
|
||||
mat4.translate(transform, transform, [0, 0, -viewDist.current])
|
||||
mat4.rotateX(transform, transform, xRotation.current)
|
||||
mat4.rotateY(transform, transform, yRotation.current)
|
||||
mat4.translate(transform, transform, centerPos.current)
|
||||
onDraw(transform)
|
||||
})
|
||||
}
|
||||
}, [onDraw])
|
||||
|
||||
useEffect(function changeDetected() {
|
||||
redraw.current()
|
||||
}, [state, onDraw])
|
||||
|
||||
useEffect(function setupListeners() {
|
||||
if (!canvas.current) return
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
dragStart.current = [e.offsetX, e.offsetY]
|
||||
dragButton.current = e.button
|
||||
}
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (dragStart.current === undefined) {
|
||||
return
|
||||
}
|
||||
const dx = e.offsetX - dragStart.current[0]
|
||||
const dy = e.offsetY - dragStart.current[1]
|
||||
if (dx === 0 && dy === 0) {
|
||||
return
|
||||
}
|
||||
if (dragButton.current === 0) {
|
||||
yRotation.current += dx / 100
|
||||
xRotation.current += dy / 100
|
||||
redraw.current()
|
||||
} else if (dragButton.current === 1 || dragButton.current === 2) {
|
||||
const pos = vec3.fromValues(centerPos.current[0], centerPos.current[1], centerPos.current[2])
|
||||
vec3.rotateY(pos, pos, [0, 0, 0], yRotation.current)
|
||||
vec3.rotateX(pos, pos, [0, 0, 0], xRotation.current)
|
||||
const d = vec3.fromValues(dx / 100, -dy / 100, 0)
|
||||
vec3.scale(d, d, 0.25 * viewDist.current)
|
||||
vec3.add(pos, pos, d)
|
||||
vec3.rotateX(pos, pos, [0, 0, 0], -xRotation.current)
|
||||
vec3.rotateY(pos, pos, [0, 0, 0], -yRotation.current)
|
||||
centerPos.current = [pos[0], pos[1], pos[2]]
|
||||
redraw.current()
|
||||
}
|
||||
dragStart.current = [e.offsetX, e.offsetY]
|
||||
}
|
||||
function onMouseUp() {
|
||||
dragStart.current = undefined
|
||||
}
|
||||
function onWheel(evt: WheelEvent) {
|
||||
viewDist.current = Math.max(1, viewDist.current + evt.deltaY / 100)
|
||||
redraw.current()
|
||||
evt.preventDefault()
|
||||
}
|
||||
function onContextMenu(evt: MouseEvent) {
|
||||
evt.preventDefault()
|
||||
}
|
||||
function resizeHandler() {
|
||||
if (!canvas.current) return
|
||||
const width = canvas.current.clientWidth
|
||||
const height = canvas.current.clientHeight
|
||||
canvas.current.width = width
|
||||
canvas.current.height = height
|
||||
onResize?.(width, height)
|
||||
redraw.current()
|
||||
}
|
||||
|
||||
console.warn('Uhhh...', canvas.current)
|
||||
onSetup(canvas.current)
|
||||
resizeHandler()
|
||||
|
||||
canvas.current.addEventListener('mousedown', onMouseDown)
|
||||
canvas.current.addEventListener('mousemove', onMouseMove)
|
||||
document.body.addEventListener('mouseup', onMouseUp)
|
||||
canvas.current.addEventListener('wheel', onWheel)
|
||||
canvas.current.addEventListener('contextmenu', onContextMenu)
|
||||
window.addEventListener('resize', resizeHandler)
|
||||
|
||||
return () => {
|
||||
canvas.current?.removeEventListener('mousedown', onMouseDown)
|
||||
canvas.current?.removeEventListener('mousemove', onMouseMove)
|
||||
document.body.removeEventListener('mouseup', onMouseUp)
|
||||
canvas.current?.removeEventListener('wheel', onWheel)
|
||||
canvas.current?.removeEventListener('contextmenu', onContextMenu)
|
||||
window.removeEventListener('resize', resizeHandler)
|
||||
}
|
||||
}, [onSetup, onResize])
|
||||
|
||||
return <canvas ref={canvas}></canvas>
|
||||
}
|
||||
654
src/app/components/previews/LootTable.ts
Normal file
654
src/app/components/previews/LootTable.ts
Normal file
@@ -0,0 +1,654 @@
|
||||
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 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':
|
||||
entryConsumer(new ItemStack(Identifier.parse(entry.name), 1))
|
||||
break
|
||||
case 'tag':
|
||||
ctx.getItemTag(entry.name).forEach(tagEntry => {
|
||||
entryConsumer(new ItemStack(Identifier.parse(tagEntry), 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: 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 (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)]
|
||||
const ench = Enchantment.REGISTRY.get(Identifier.parse(id))
|
||||
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 )
|
||||
},
|
||||
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)
|
||||
enchantItem(item, { id: Identifier.parse(id), lvl }, add)
|
||||
})
|
||||
},
|
||||
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') {
|
||||
item.tag.set('Potion', new NbtString(Identifier.parse(id).toString()))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
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: 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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
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'
|
||||
import type { SlottedItem } from './LootTable.js'
|
||||
import { generateLootTable } from './LootTable.js'
|
||||
|
||||
export const LootTablePreview = ({ data }: PreviewProps) => {
|
||||
const { locale } = useLocale()
|
||||
|
||||
@@ -1,76 +1,73 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks'
|
||||
import { DataModel } from '@mcschema/core'
|
||||
import { clampedMap, NoiseParameters, NormalNoise, XoroshiroRandom } from 'deepslate'
|
||||
import type { mat3 } from 'gl-matrix'
|
||||
import { useCallback, useMemo, useRef, useState } from 'preact/hooks'
|
||||
import { useLocale } from '../../contexts/index.js'
|
||||
import { useCanvas } from '../../hooks/index.js'
|
||||
import type { ColormapType } from '../../previews/Colormap.js'
|
||||
import { normalNoise, normalNoisePoint } from '../../previews/index.js'
|
||||
import { Store } from '../../Store.js'
|
||||
import { randomSeed } from '../../Utils.js'
|
||||
import { iterateWorld2D, randomSeed } from '../../Utils.js'
|
||||
import { Btn } from '../index.js'
|
||||
import type { ColormapType } from './Colormap.js'
|
||||
import { getColormap } from './Colormap.js'
|
||||
import { ColormapSelector } from './ColormapSelector.jsx'
|
||||
import type { PreviewProps } from './index.js'
|
||||
import { InteractiveCanvas2D } from './InteractiveCanvas2D.jsx'
|
||||
|
||||
export const NoisePreview = ({ data, shown, version }: PreviewProps) => {
|
||||
export const NoisePreview = ({ data, shown }: PreviewProps) => {
|
||||
const { locale } = useLocale()
|
||||
const [seed, setSeed] = useState(randomSeed())
|
||||
const [scale, setScale] = useState(2)
|
||||
const state = JSON.stringify(data)
|
||||
|
||||
const noise = useMemo(() => {
|
||||
const random = XoroshiroRandom.create(seed)
|
||||
const params = NoiseParameters.fromJson(DataModel.unwrapLists(data))
|
||||
return new NormalNoise(random, params)
|
||||
}, [state, seed])
|
||||
|
||||
const imageData = useRef<ImageData>()
|
||||
const ctx = useRef<CanvasRenderingContext2D>()
|
||||
const [focused, setFocused] = useState<string[]>([])
|
||||
const [colormap, setColormap] = useState<ColormapType>(Store.getColormap() ?? 'viridis')
|
||||
const offset = useRef<[number, number]>([0, 0])
|
||||
const state = JSON.stringify([data])
|
||||
|
||||
const { canvas, redraw } = useCanvas({
|
||||
size() {
|
||||
return [256, 256]
|
||||
},
|
||||
async draw(img) {
|
||||
const options = { offset: offset.current, scale, seed, version, colormap }
|
||||
normalNoise(data, img, options)
|
||||
},
|
||||
async onDrag(dx, dy) {
|
||||
offset.current[0] = offset.current[0] + dx * 256
|
||||
offset.current[1] = offset.current[1] + dy * 256
|
||||
redraw()
|
||||
},
|
||||
onHover(x, y) {
|
||||
const x2 = Math.floor(x * 256)
|
||||
const y2 = Math.floor(y * 256)
|
||||
const options = { offset: offset.current, scale, seed, version, colormap }
|
||||
const value = normalNoisePoint(data, x2, y2, options)
|
||||
|
||||
const ox = -options.offset[0] - 100
|
||||
const oy = -options.offset[1] - 100
|
||||
const xx = (x2 + ox) * options.scale
|
||||
const yy = (y2 + oy) * options.scale
|
||||
setFocused([value.toPrecision(3), `X=${Math.floor(xx)} Y=${Math.floor(yy)}`])
|
||||
},
|
||||
onLeave() {
|
||||
const onSetup = useCallback((canvas: HTMLCanvasElement) => {
|
||||
const ctx2D = canvas.getContext('2d')
|
||||
if (!ctx2D) return
|
||||
ctx.current = ctx2D
|
||||
}, [])
|
||||
const onResize = useCallback((width: number, height: number) => {
|
||||
if (!ctx.current) return
|
||||
imageData.current = ctx.current.getImageData(0, 0, width, height)
|
||||
}, [])
|
||||
const onDraw = useCallback((transform: mat3) => {
|
||||
if (!ctx.current || !imageData.current || !shown) return
|
||||
|
||||
const colorPicker = getColormap(colormap)
|
||||
iterateWorld2D(imageData.current, transform, (x, y) => {
|
||||
return noise.sample(x, y, 0)
|
||||
}, (value) => {
|
||||
const color = colorPicker(clampedMap(value, -1, 1, 1, 0))
|
||||
return [color[0] * 256, color[1] * 256, color[2] * 256]
|
||||
})
|
||||
ctx.current.putImageData(imageData.current, 0, 0)
|
||||
}, [noise, colormap, shown])
|
||||
const onHover = useCallback((pos: [number, number] | undefined) => {
|
||||
if (!pos) {
|
||||
setFocused([])
|
||||
},
|
||||
}, [version, state, scale, seed, colormap])
|
||||
|
||||
useEffect(() => {
|
||||
if (shown) {
|
||||
redraw()
|
||||
} else {
|
||||
const [x, y] = pos
|
||||
const output = noise.sample(x, -y, 0)
|
||||
setFocused([output.toPrecision(3), `X=${x} Y=${-y}`])
|
||||
}
|
||||
}, [version, state, scale, seed, colormap, shown])
|
||||
|
||||
const changeScale = (newScale: number) => {
|
||||
offset.current[0] = offset.current[0] * scale / newScale
|
||||
offset.current[1] = offset.current[1] * scale / newScale
|
||||
setScale(newScale)
|
||||
}
|
||||
}, [noise])
|
||||
|
||||
return <>
|
||||
<div class="controls preview-controls">
|
||||
{focused.map(s => <Btn label={s} class="no-pointer" /> )}
|
||||
<ColormapSelector value={colormap} onChange={setColormap} />
|
||||
<Btn icon="dash" tooltip={locale('zoom_out')}
|
||||
onClick={() => changeScale(scale * 1.5)} />
|
||||
<Btn icon="plus" tooltip={locale('zoom_in')}
|
||||
onClick={() => changeScale(scale / 1.5)} />
|
||||
<Btn icon="sync" tooltip={locale('generate_new_seed')}
|
||||
onClick={() => setSeed(randomSeed())} />
|
||||
</div>
|
||||
<canvas ref={canvas} width="256" height="256"></canvas>
|
||||
<div class="full-preview">
|
||||
<InteractiveCanvas2D onSetup={onSetup} onResize={onResize} onDraw={onDraw} onHover={onHover} pixelSize={4} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,79 +1,90 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
||||
import { useLocale, useProject } from '../../contexts/index.js'
|
||||
import { useCanvas } from '../../hooks/index.js'
|
||||
import type { ColormapType } from '../../previews/Colormap.js'
|
||||
import { densityFunction, getNoiseBlock, noiseSettings } from '../../previews/index.js'
|
||||
import { CachedCollections, checkVersion } from '../../services/index.js'
|
||||
import { DataModel } from '@mcschema/core'
|
||||
import { clampedMap } from 'deepslate'
|
||||
import type { mat3 } from 'gl-matrix'
|
||||
import { vec2 } from 'gl-matrix'
|
||||
import { useCallback, useMemo, useRef, useState } from 'preact/hooks'
|
||||
import { getProjectData, useLocale, useProject } from '../../contexts/index.js'
|
||||
import { useAsync } from '../../hooks/index.js'
|
||||
import { CachedCollections } from '../../services/index.js'
|
||||
import { Store } from '../../Store.js'
|
||||
import { randomSeed } from '../../Utils.js'
|
||||
import { iterateWorld2D, randomSeed } from '../../Utils.js'
|
||||
import { Btn, BtnInput, BtnMenu } from '../index.js'
|
||||
import type { ColormapType } from './Colormap.js'
|
||||
import { getColormap } from './Colormap.js'
|
||||
import { ColormapSelector } from './ColormapSelector.jsx'
|
||||
import { DEEPSLATE } from './Deepslate.js'
|
||||
import type { PreviewProps } from './index.js'
|
||||
import { InteractiveCanvas2D } from './InteractiveCanvas2D.jsx'
|
||||
|
||||
export const NoiseSettingsPreview = ({ data, shown, version }: PreviewProps) => {
|
||||
const { locale } = useLocale()
|
||||
const { project } = useProject()
|
||||
const [seed, setSeed] = useState(randomSeed())
|
||||
const [biome, setBiome] = useState('minecraft:plains')
|
||||
const [biomeScale, setBiomeScale] = useState(0.2)
|
||||
const [biomeDepth, setBiomeDepth] = useState(0.1)
|
||||
const [autoScroll, setAutoScroll] = useState(false)
|
||||
const [focused, setFocused] = useState<string[]>([])
|
||||
const [layer, setLayer] = useState('terrain')
|
||||
const state = JSON.stringify(data)
|
||||
|
||||
const { value } = useAsync(async () => {
|
||||
const unwrapped = DataModel.unwrapLists(data)
|
||||
await DEEPSLATE.loadVersion(version, getProjectData(project))
|
||||
const biomeSource = { type: 'fixed', biome }
|
||||
await DEEPSLATE.loadChunkGenerator(unwrapped, biomeSource, seed)
|
||||
const noiseSettings = DEEPSLATE.getNoiseSettings()
|
||||
const finalDensity = DEEPSLATE.loadDensityFunction(unwrapped?.noise_router?.final_density, noiseSettings.minY, noiseSettings.height, seed)
|
||||
return { noiseSettings, finalDensity }
|
||||
}, [state, seed, version, project, biome])
|
||||
const { noiseSettings, finalDensity } = value ?? {}
|
||||
|
||||
const imageData = useRef<ImageData>()
|
||||
const ctx = useRef<CanvasRenderingContext2D>()
|
||||
const [focused, setFocused] = useState<string[]>([])
|
||||
const [colormap, setColormap] = useState<ColormapType>(Store.getColormap() ?? 'viridis')
|
||||
const offset = useRef(0)
|
||||
const scrollInterval = useRef<number | undefined>(undefined)
|
||||
const state = JSON.stringify([data, biomeScale, biomeDepth])
|
||||
|
||||
const size = data?.noise?.height ?? 256
|
||||
const { canvas, redraw } = useCanvas({
|
||||
size() {
|
||||
return [size, size]
|
||||
},
|
||||
async draw(img) {
|
||||
const options = { biome, biomeDepth, biomeScale, offset: offset.current, width: img.width, seed, version, project, colormap, minY: data?.noise?.min_y ?? 0, height: data?.noise?.height ?? 256, hardZero: true }
|
||||
if (layer === 'final_density') {
|
||||
const df = data?.noise_router?.final_density ?? 0
|
||||
await densityFunction(df, img, options)
|
||||
} else {
|
||||
await noiseSettings(data, img, options)
|
||||
}
|
||||
},
|
||||
async onDrag(dx) {
|
||||
offset.current += dx * size
|
||||
redraw()
|
||||
},
|
||||
async onHover(x, y) {
|
||||
const worldX = Math.floor(x * size - offset.current)
|
||||
const worldY = size - Math.max(1, Math.ceil(y * size)) + (data?.noise?.min_y ?? 0)
|
||||
const block = getNoiseBlock(worldX, worldY)
|
||||
setFocused([block ? `Y=${worldY} (${block.getName().path})` : `Y=${worldY}`])
|
||||
},
|
||||
onLeave() {
|
||||
const onSetup = useCallback((canvas: HTMLCanvasElement) => {
|
||||
const ctx2D = canvas.getContext('2d')
|
||||
if (!ctx2D) return
|
||||
ctx.current = ctx2D
|
||||
}, [])
|
||||
const onResize = useCallback((width: number, height: number) => {
|
||||
if (!ctx.current) return
|
||||
imageData.current = ctx.current.getImageData(0, 0, width, height)
|
||||
}, [])
|
||||
const onDraw = useCallback((transform: mat3) => {
|
||||
if (!ctx.current || !imageData.current || !shown) return
|
||||
|
||||
if (layer === 'terrain') {
|
||||
const pos = vec2.create()
|
||||
const minX = vec2.transformMat3(pos, vec2.fromValues(0, 0), transform)[0]
|
||||
const maxX = vec2.transformMat3(pos, vec2.fromValues(imageData.current.width-1, 0), transform)[0]
|
||||
DEEPSLATE.generateChunks(minX, maxX - minX + 1)
|
||||
iterateWorld2D(imageData.current, transform, (x, y) => {
|
||||
return DEEPSLATE.getBlockState(x, y)?.getName().toString()
|
||||
}, (block) => {
|
||||
return BlockColors[block ?? 'minecraft:air']
|
||||
})
|
||||
} else if (layer === 'final_density') {
|
||||
const colormapFn = getColormap(colormap)
|
||||
const colorPicker = (t: number) => colormapFn(t <= 0.5 ? t - 0.08 : t + 0.08)
|
||||
iterateWorld2D(imageData.current, transform, (x, y) => {
|
||||
return finalDensity?.compute({ x, y, z: 0 }) ?? 0
|
||||
}, (density) => {
|
||||
const color = colorPicker(clampedMap(density, -1, 1, 1, 0))
|
||||
return [color[0] * 256, color[1] * 256, color[2] * 256]
|
||||
})
|
||||
}
|
||||
ctx.current.putImageData(imageData.current, 0, 0)
|
||||
}, [noiseSettings, finalDensity, layer, colormap, shown])
|
||||
const onHover = useCallback((pos: [number, number] | undefined) => {
|
||||
if (!pos || !noiseSettings || !finalDensity) {
|
||||
setFocused([])
|
||||
},
|
||||
}, [version, state, seed, project, shown, biome, biomeScale, biomeDepth, layer, colormap])
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollInterval.current) {
|
||||
clearInterval(scrollInterval.current)
|
||||
} else {
|
||||
const [x, y] = pos
|
||||
const inVoid = -y < noiseSettings.minY || -y >= noiseSettings.minY + noiseSettings.height
|
||||
const density = finalDensity.compute({ x, y: -y, z: 0})
|
||||
const block = inVoid ? 'void' : DEEPSLATE.getBlockState(x, -y)?.getName().path ?? 'unknown'
|
||||
setFocused([`${block} D=${density.toPrecision(3)}`, `X=${x} Y=${-y}`])
|
||||
}
|
||||
if (shown) {
|
||||
(async () => {
|
||||
try {
|
||||
await redraw()
|
||||
if (autoScroll) {
|
||||
scrollInterval.current = setInterval(() => {
|
||||
offset.current -= 8
|
||||
redraw()
|
||||
}, 100) as any
|
||||
}
|
||||
} catch (e) {
|
||||
throw e
|
||||
}
|
||||
})()
|
||||
}
|
||||
}, [version, state, seed, project, shown, biome, biomeScale, biomeDepth, autoScroll, layer, colormap])
|
||||
}, [noiseSettings, finalDensity])
|
||||
|
||||
const allBiomes = useMemo(() => CachedCollections?.get('worldgen/biome') ?? [], [version])
|
||||
|
||||
@@ -82,18 +93,33 @@ export const NoiseSettingsPreview = ({ data, shown, version }: PreviewProps) =>
|
||||
{focused.map(s => <Btn label={s} class="no-pointer" /> )}
|
||||
{layer === 'final_density' && <ColormapSelector value={colormap} onChange={setColormap} />}
|
||||
<BtnMenu icon="gear" tooltip={locale('terrain_settings')}>
|
||||
{checkVersion(version, undefined, '1.17') ? <>
|
||||
<BtnInput label={locale('preview.scale')} value={`${biomeScale}`} onChange={v => setBiomeScale(Number(v))} />
|
||||
<BtnInput label={locale('preview.depth')} value={`${biomeDepth}`} onChange={v => setBiomeDepth(Number(v))} />
|
||||
</> :
|
||||
<BtnInput label={locale('preview.biome')} value={biome} onChange={setBiome} dataList={allBiomes} larger />
|
||||
}
|
||||
<Btn icon={autoScroll ? 'square_fill' : 'square'} label={locale('preview.auto_scroll')} onClick={() => setAutoScroll(!autoScroll)} />
|
||||
<BtnInput label={locale('preview.biome')} value={biome} onChange={setBiome} dataList={allBiomes} larger />
|
||||
<Btn icon={layer === 'final_density' ? 'square_fill' : 'square'} label={locale('preview.final_density')} onClick={() => setLayer(layer === 'final_density' ? 'terrain' : 'final_density')} />
|
||||
</BtnMenu>
|
||||
<Btn icon="sync" tooltip={locale('generate_new_seed')}
|
||||
onClick={() => setSeed(randomSeed())} />
|
||||
</div>
|
||||
<canvas ref={canvas} width={size} height={size}></canvas>
|
||||
<div class="full-preview">
|
||||
<InteractiveCanvas2D onSetup={onSetup} onResize={onResize} onDraw={onDraw} onHover={onHover} pixelSize={4} startScale={0.5} startPosition={[0, 64]} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
const BlockColors: Record<string, [number, number, number]> = {
|
||||
'minecraft:air': [150, 160, 170],
|
||||
'minecraft:water': [20, 80, 170],
|
||||
'minecraft:lava': [200, 100, 0],
|
||||
'minecraft:stone': [55, 55, 55],
|
||||
'minecraft:deepslate': [34, 34, 36],
|
||||
'minecraft:bedrock': [10, 10, 10],
|
||||
'minecraft:grass_block': [47, 120, 23],
|
||||
'minecraft:dirt': [64, 40, 8],
|
||||
'minecraft:gravel': [70, 70, 70],
|
||||
'minecraft:sand': [196, 180, 77],
|
||||
'minecraft:sandstone': [148, 135, 52],
|
||||
'minecraft:netherrack': [100, 40, 40],
|
||||
'minecraft:crimson_nylium': [144, 22, 22],
|
||||
'minecraft:warped_nylium': [28, 115, 113],
|
||||
'minecraft:basalt': [73, 74, 85],
|
||||
'minecraft:end_stone': [200, 200, 140],
|
||||
}
|
||||
|
||||
1
src/app/components/previews/colormaps.json
Normal file
1
src/app/components/previews/colormaps.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user