Customized biomes (#396)

* Start with customized biome UI

* Refactor customized page, split logic

* Implement biome replacements

* Remove debug messages

* Disable create button when nothing is modified
This commit is contained in:
Misode
2023-12-06 20:40:30 +01:00
committed by GitHub
parent 1e28b82907
commit 886991af8e
10 changed files with 329 additions and 110 deletions

View File

@@ -53,8 +53,5 @@ export function BasicSettings({ model, initialModel, changeModel }: Props) {
initial={initialModel.ravines} />
</div>}
</div>
<CustomizedSlider label="Biome size" help="The scale of the biome layout, 6 corresponds to the large biomes preset"
value={model.biomeSize} onChange={v => changeModel({ biomeSize: v })}
min={1} max={8} initial={initialModel.biomeSize} />
</>
}

View File

@@ -0,0 +1,128 @@
import { useMemo } from 'preact/hooks'
import { deepClone } from '../../Utils.js'
import { useAsync } from '../../hooks/useAsync.js'
import { fetchRegistries } from '../../services/DataFetcher.js'
import { Octicon, TextInput } from '../index.js'
import { CustomizedInput } from './CustomizedInput.jsx'
import type { CustomizedModel } from './CustomizedModel.js'
import { CustomizedSlider } from './CustomizedSlider.jsx'
interface Props {
model: CustomizedModel,
initialModel: CustomizedModel,
changeModel: (model: Partial<CustomizedModel>) => void,
}
export function BiomesSettings({ model, initialModel, changeModel }: Props) {
const { value: registries } = useAsync(() => fetchRegistries('1.20'))
const biomes = useMemo(() => {
const hiddenBiomes = new Set(['end_barrens', 'end_highlands', 'end_midlands', 'small_end_islands', 'the_end', 'basalt_deltas', 'crimson_forest', 'nether_wastes', 'soul_sand_valley', 'warped_forest', 'the_void'])
return registries?.get('worldgen/biome')?.filter(b => !hiddenBiomes.has(b.replace(/^minecraft:/, '')))
}, [registries])
return <>
<CustomizedSlider label="Biome size" help="The scale of the biome layout, 6 corresponds to the large biomes preset"
value={model.biomeSize} onChange={v => changeModel({ biomeSize: v })}
min={1} max={8} initial={initialModel.biomeSize} />
<p class="customized-info">
{Octicon.info}
Changing the following settings will not affect the terrain, only which biomes are painted on the terrain.
</p>
{biomes?.map(key => {
const biome = key.replace(/^minecraft:/, '')
const state: string | undefined = model.biomeReplacements[biome]
const onChange = (v: string | undefined) => {
const biomeReplacements = deepClone(model.biomeReplacements)
if (v === undefined || v === biome) {
delete biomeReplacements[biome]
} else {
biomeReplacements[biome] = v
}
changeModel({ biomeReplacements })
}
return <CustomizedInput label={biome.replace(/^minecraft:/, '')}
value={state ?? biome} onChange={onChange}
initial={biome}>
<button class={`customized-toggle${state === undefined ? ' customized-true' : ''}`} onClick={() => onChange(undefined)}>Keep</button>
<span>/</span>
<button class={`customized-toggle${state !== undefined ? ' customized-false' : ''}`} onClick={() => onChange(findReplacement(biome, model.biomeReplacements))}>Replace</button>
{state !== undefined && <>
<TextInput value={state} onChange={v => onChange(v)} />
</>}
</CustomizedInput>
})}
</>
}
function findReplacement(biome: string, replacements: Record<string, string>): string {
let replacement = DefaultReplacements[biome]
if (replacement === undefined) {
return 'plains'
}
for (let i = 0; i < 10; i += 1) {
if (biome === replacement) {
return DefaultReplacements[biome]
}
if (!replacements[replacement]) {
return replacement
}
replacement = replacements[replacement]
}
return DefaultReplacements[biome]
}
const DefaultReplacements: Record<string, string> = {
badlands: 'desert',
bamboo_jungle: 'jungle',
beach: 'plains',
birch_forest: 'forest',
cherry_grove: 'meadow',
cold_ocean: 'ocean',
dark_forest: 'forest',
deep_cold_ocean: 'deep_ocean',
deep_dark: 'plains',
deep_frozen_ocean: 'deep_ocean',
deep_lukewarm_ocean: 'deep_ocean',
deep_ocean: 'ocean',
desert: 'savanna',
dripstone_caves: 'plains',
eroded_badlands: 'badlands',
flower_forest: 'forest',
forest: 'plains',
frozen_ocean: 'cold_ocean',
frozen_peaks: 'stony_peaks',
frozen_river: 'river',
grove: 'forest',
ice_spikes: 'snowy_plains',
jagged_peaks: 'stony_peaks',
jungle: 'savanna',
lukewarm_ocean: 'ocean',
lush_caves: 'plains',
mangrove_swamp: 'swamp',
meadow: 'forest',
mushroom_fields: 'plains',
ocean: 'plains',
old_growth_birch_forest: 'birch_forest',
old_growth_pine_taiga: 'taiga',
old_growth_spruce_taiga: 'taiga',
plains: 'the_void',
river: 'plains',
savanna: 'plains',
savanna_plateau: 'savanna',
snowy_beach: 'beach',
snowy_plains: 'plains',
snowy_slopes: 'grove',
snowy_taiga: 'taiga',
sparse_jungle: 'jungle',
stony_peaks: 'plains',
stony_shore: 'beach',
sunflower_plains: 'plains',
swamp: 'river',
taiga: 'forest',
warm_ocean: 'ocean',
windswept_forest: 'forest',
windswept_gravelly_hills: 'windswept_hills',
windswept_hills: 'plains',
windswept_savanna: 'savanna',
wooded_badlands: 'badlands',
}

View File

@@ -8,7 +8,7 @@ import { CustomizedModel } from './CustomizedModel.js'
// Random prefix to avoid collisions with other packs that add no-op placed features
const FeatureCollisionPrefix = 468794
const PackTypes = ['dimension_type', 'worldgen/noise_settings', 'worldgen/noise', 'worldgen/structure_set', 'worldgen/placed_feature', 'worldgen/configured_feature', 'worldgen/configured_carver'] as const
const PackTypes = ['dimension', 'dimension_type', 'worldgen/noise_settings', 'worldgen/noise', 'worldgen/structure_set', 'worldgen/placed_feature', 'worldgen/configured_feature', 'worldgen/configured_carver'] as const
export type CustomizedPackType = typeof PackTypes[number]
export type CustomizedPack = Record<CustomizedPackType, Map<string, any>>
@@ -41,6 +41,7 @@ export async function generateCustomized(model: CustomizedModel, version: Versio
}, Object.create(null)) as CustomizedPack,
featureCollisionIndex: FeatureCollisionPrefix,
}
generateBiomeReplacements(ctx)
generateDimensionType(ctx)
generateNoiseSettings(ctx)
generateCarvers(ctx)
@@ -133,6 +134,27 @@ function generateClimateNoises(ctx: Context) {
}
}
function generateBiomeReplacements(ctx: Context) {
if (isUnchanged(ctx, 'biomeReplacements')) return
const vanilla = ctx.vanilla['dimension'].get('overworld')
const biomes = vanilla.generator.biome_source.biomes.slice()
const replacements = new Map(Object.entries(ctx.model.biomeReplacements))
ctx.out['dimension'].set('overworld', {
type: 'minecraft:overworld',
generator: {
type: 'minecraft:noise',
settings: 'minecraft:overworld',
biome_source: {
type: 'minecraft:multi_noise',
biomes: biomes.map((b: any) => ({
biome: replacements.get(b.biome.slice(10)) ?? b.biome,
parameters: b.parameters,
})),
},
},
})
}
const Structures: Partial<Record<keyof CustomizedModel, string>> = {
ancientCities: 'ancient_cities',
buriedTreasures: 'buried_treasures',

View File

@@ -22,7 +22,9 @@ export interface CustomizedModel {
noiseCaves: boolean,
carverCaves: boolean,
ravines: boolean,
// Biomes
biomeSize: number,
biomeReplacements: Record<string, string>,
// Structures
ancientCities: boolean,
buriedTreasures: boolean,
@@ -85,7 +87,9 @@ export namespace CustomizedModel {
noiseCaves: true,
carverCaves: true,
ravines: true,
biomeSize: 4,
biomeReplacements: {},
ancientCities: true,
buriedTreasures: true,

View File

@@ -0,0 +1,106 @@
import { useCallback, useMemo, useRef, useState } from 'preact/hooks'
import config from '../../Config.js'
import { deepClone, deepEqual, writeZip } from '../../Utils.js'
import { useVersion } from '../../contexts/Version.jsx'
import { stringifySource } from '../../services/Source.js'
import { Btn } from '../Btn.jsx'
import { ErrorPanel } from '../ErrorPanel.jsx'
import { Octicon } from '../Octicon.jsx'
import { BasicSettings } from './BasicSettings.jsx'
import { BiomesSettings } from './BiomesSettings.jsx'
import { generateCustomized } from './CustomizedGenerator.js'
import { CustomizedModel } from './CustomizedModel.js'
import { OresSettings } from './OresSettings.jsx'
import { StructuresSettings } from './StructuresSettings.jsx'
interface Props {
tab: string | undefined
}
export function CustomizedPanel({ tab }: Props) {
const { version } = useVersion()
const [model, setModel] = useState(CustomizedModel.getDefault(version))
const changeModel = useCallback((change: Partial<CustomizedModel>) => {
setModel(m => ({ ...m, ...change }))
}, [])
const initialModel = useMemo(() => {
return CustomizedModel.getDefault(version)
}, [version])
const isModified = useMemo(() => {
return !deepEqual(model, initialModel)
}, [model, initialModel])
const reset = useCallback(() => {
setModel(deepClone(initialModel))
}, [initialModel])
const download = useRef<HTMLAnchorElement>(null)
const [error, setError] = useState<Error | string | null>(null)
const [hasDownloaded, setHasDownloaded] = useState(false)
const generate = useCallback(async () => {
if (!download.current) return
try {
const pack = await generateCustomized(model, version)
const entries = Object.entries(pack).flatMap(([type, files]) => {
const prefix = `data/minecraft/${type}/`
return [...files.entries()].map(([name, data]) => {
return [prefix + name + '.json', stringifySource(data, 'json')] as [string, string]
})
})
const pack_format = config.versions.find(v => v.id === version)!.pack_format
entries.push(['pack.mcmeta', stringifySource({ pack: { pack_format, description: 'Customized world from misode.github.io' } }, 'json')])
const url = await writeZip(entries)
download.current.setAttribute('href', url)
download.current.setAttribute('download', 'customized.zip')
download.current.click()
setHasDownloaded(true)
setError(null)
} catch (e) {
if (e instanceof Error) {
e.message = `Something went wrong creating the customized pack: ${e.message}`
setError(e)
}
}
}, [model, version])
return <>
<div class="customized-tab">
{tab === 'basic' && <BasicSettings {...{model, initialModel, changeModel}} />}
{tab === 'biomes' && <BiomesSettings {...{model, initialModel, changeModel}} />}
{tab === 'structures' && <StructuresSettings {...{model, initialModel, changeModel}} />}
{tab === 'ores' && <OresSettings {...{model, initialModel, changeModel}} />}
</div>
<div class="customized-actions">
<Btn icon="download" label="Create" class="customized-create" tooltip="Create and download data pack" tooltipLoc="se" onClick={isModified ? generate : undefined} disabled={!isModified} />
<a ref={download} style="display: none;"></a>
{isModified && <Btn icon="undo" label="Reset" tooltip="Reset to default" tooltipLoc="se" onClick={reset} />}
</div>
{error && <ErrorPanel error={error} onDismiss={() => setError(null)} body={`\n### Customized settings\n<details>\n<pre>\n${JSON.stringify(getDiffModel(model, initialModel), null, 2)}\n</pre>\n</details>\n`} />}
{hasDownloaded && <div class="customized-instructions">
<h4>
{Octicon.mortar_board}
What now?
</h4>
<ol>
<li>After launching Minecraft, create a new singleplayer world.</li>
<li>Select the "More" tab at the top.</li>
<li>Click on "Data Packs" and drag the downloaded zip file onto the game window. </li>
<li>Move the imported data pack to the right panel and click on "Done".</li>
<li>A message will warn about the use of experimental world settings. Click on "Proceed".</li>
</ol>
</div>}
</>
}
function getDiffModel(model: any, initial: any) {
const result = deepClone(model)
if (typeof result !== 'object' || result === null) return
Object.keys(result).forEach(key => {
if (deepEqual(result[key], initial[key])) {
delete result[key]
} else if (typeof result[key] === 'object' && result[key] !== null) {
result[key] = getDiffModel(result[key], initial[key])
}
})
return result
}