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:
Misode
2023-01-26 01:21:02 +01:00
committed by GitHub
parent 23b3046dee
commit 00029a2010
32 changed files with 996 additions and 1085 deletions

View File

@@ -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,

View File

@@ -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 <></>

View File

@@ -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],
}

View 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)!
}

View File

@@ -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,

View 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] : []
},
}

View File

@@ -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>
</>
}

View 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
}

View File

@@ -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>
</>
}

View 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>
}

View 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>
}

View 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
}
}

View File

@@ -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()

View File

@@ -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>
</>
}

View File

@@ -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],
}

File diff suppressed because one or more lines are too long