diff --git a/src/app/components/customized/BasicSettings.tsx b/src/app/components/customized/BasicSettings.tsx index 6452885b..10abc77a 100644 --- a/src/app/components/customized/BasicSettings.tsx +++ b/src/app/components/customized/BasicSettings.tsx @@ -53,8 +53,5 @@ export function BasicSettings({ model, initialModel, changeModel }: Props) { initial={initialModel.ravines} /> } - changeModel({ biomeSize: v })} - min={1} max={8} initial={initialModel.biomeSize} /> } diff --git a/src/app/components/customized/BiomesSettings.tsx b/src/app/components/customized/BiomesSettings.tsx new file mode 100644 index 00000000..85948e95 --- /dev/null +++ b/src/app/components/customized/BiomesSettings.tsx @@ -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) => 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 <> + changeModel({ biomeSize: v })} + min={1} max={8} initial={initialModel.biomeSize} /> +

+ {Octicon.info} + Changing the following settings will not affect the terrain, only which biomes are painted on the terrain. +

+ {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 + + / + + {state !== undefined && <> + onChange(v)} /> + } + + })} + +} + +function findReplacement(biome: string, replacements: Record): 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 = { + 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', +} diff --git a/src/app/components/customized/CustomizedGenerator.ts b/src/app/components/customized/CustomizedGenerator.ts index c6ebc804..55f2d03a 100644 --- a/src/app/components/customized/CustomizedGenerator.ts +++ b/src/app/components/customized/CustomizedGenerator.ts @@ -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> @@ -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> = { ancientCities: 'ancient_cities', buriedTreasures: 'buried_treasures', diff --git a/src/app/components/customized/CustomizedModel.ts b/src/app/components/customized/CustomizedModel.ts index 2771acf3..68f2ded0 100644 --- a/src/app/components/customized/CustomizedModel.ts +++ b/src/app/components/customized/CustomizedModel.ts @@ -22,7 +22,9 @@ export interface CustomizedModel { noiseCaves: boolean, carverCaves: boolean, ravines: boolean, + // Biomes biomeSize: number, + biomeReplacements: Record, // 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, diff --git a/src/app/components/customized/CustomizedPanel.tsx b/src/app/components/customized/CustomizedPanel.tsx new file mode 100644 index 00000000..83a3ca02 --- /dev/null +++ b/src/app/components/customized/CustomizedPanel.tsx @@ -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) => { + 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(null) + const [error, setError] = useState(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 <> +
+ {tab === 'basic' && } + {tab === 'biomes' && } + {tab === 'structures' && } + {tab === 'ores' && } +
+
+ + + {isModified && } +
+ {error && setError(null)} body={`\n### Customized settings\n
\n
\n${JSON.stringify(getDiffModel(model, initialModel), null, 2)}\n
\n
\n`} />} + {hasDownloaded &&
+

+ {Octicon.mortar_board} + What now? +

+
    +
  1. After launching Minecraft, create a new singleplayer world.
  2. +
  3. Select the "More" tab at the top.
  4. +
  5. Click on "Data Packs" and drag the downloaded zip file onto the game window.
  6. +
  7. Move the imported data pack to the right panel and click on "Done".
  8. +
  9. A message will warn about the use of experimental world settings. Click on "Proceed".
  10. +
+
} + +} + +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 +} diff --git a/src/app/components/generator/GeneratorCard.tsx b/src/app/components/generator/GeneratorCard.tsx index 2b1649c5..5bcf17c6 100644 --- a/src/app/components/generator/GeneratorCard.tsx +++ b/src/app/components/generator/GeneratorCard.tsx @@ -39,8 +39,6 @@ export function GeneratorCard({ id, minimal }: Props) { .map(v => v.id as VersionId) }, [gen]) - console.log(config.versions) - const versionText = useMemo(() => { if (versions.length <= 5) { return versions.join(VERSION_SEP) diff --git a/src/app/pages/Customized.tsx b/src/app/pages/Customized.tsx index 642a0cb5..55acc870 100644 --- a/src/app/pages/Customized.tsx +++ b/src/app/pages/Customized.tsx @@ -1,26 +1,21 @@ -import { useCallback, useEffect, useErrorBoundary, useMemo, useRef, useState } from 'preact/hooks' +import { useEffect, useErrorBoundary, useMemo } from 'preact/hooks' import config from '../Config.js' -import { deepClone, deepEqual, writeZip } from '../Utils.js' -import { BasicSettings } from '../components/customized/BasicSettings.jsx' -import { generateCustomized } from '../components/customized/CustomizedGenerator.js' -import { CustomizedModel } from '../components/customized/CustomizedModel.js' -import { OresSettings } from '../components/customized/OresSettings.jsx' -import { StructuresSettings } from '../components/customized/StructuresSettings.jsx' -import { Btn, ErrorPanel, Footer, Octicon, VersionSwitcher } from '../components/index.js' -import { useLocale, useTitle } from '../contexts/index.js' +import { CustomizedPanel } from '../components/customized/CustomizedPanel.jsx' +import { ErrorPanel, Footer, Octicon, VersionSwitcher } from '../components/index.js' +import { useLocale, useTitle, useVersion } from '../contexts/index.js' import { useSearchParam } from '../hooks/index.js' -import { stringifySource } from '../services/Source.js' +import type { VersionId } from '../services/Schemas.js' +import { checkVersion } from '../services/Schemas.js' -const Tabs = ['basic', 'structures', 'ores'] +const MIN_VERSION = '1.20' +const Tabs = ['basic', 'biomes', 'structures', 'ores'] interface Props { path?: string, } export function Customized({}: Props) { const { locale } = useLocale() - // const { version, changeVersion } = useVersion() - const version = '1.20.3' // TODO: support multiple versions - const changeVersion = () => {} + const { version, changeVersion } = useVersion() useTitle(locale('title.customized')) const [errorBoundary, errorRetry] = useErrorBoundary() @@ -29,6 +24,13 @@ export function Customized({}: Props) { return
} + const allowedVersions = useMemo(() => { + return config.versions + .filter(v => checkVersion(v.id, MIN_VERSION)) + .map(v => v.id as VersionId) + .reverse() + }, []) + const [tab, setTab] = useSearchParam('tab') useEffect(() => { if (tab === undefined || !Tabs.includes(tab)) { @@ -36,97 +38,26 @@ export function Customized({}: Props) { } }, [tab]) - const [model, setModel] = useState(CustomizedModel.getDefault(version)) - const changeModel = useCallback((change: Partial) => { - 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(null) - const [error, setError] = useState(null) - const [hasDownloaded, setHasDownloaded] = useState(false) - const generate = useCallback(async () => { - if (!download.current) return - try { - const pack = await generateCustomized(model, version) - console.log('Generated customized', pack) - 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
- setTab('basic')}>{locale('customized.basic')} - setTab('structures')}>{locale('customized.structures')} - setTab('ores')}>{locale('customized.ores')} - + {Tabs.map(t => + setTab(t)}>{locale(`customized.${t}`)} + )} +
-
- {tab === 'basic' && } - {tab === 'structures' && } - {tab === 'ores' && } -
-
- - - {isModified && } -
- {error && setError(null)} body={`\n### Customized settings\n
\n
\n${JSON.stringify(getDiffModel(model, initialModel), null, 2)}\n
\n
\n`} />} - {hasDownloaded &&
-

- {Octicon.mortar_board} - What now? -

-
    -
  1. After launching Minecraft, create a new singleplayer world.
  2. -
  3. Select the "More" tab at the top.
  4. -
  5. Click on "Data Packs" and drag the downloaded zip file onto the game window.
  6. -
  7. Move the imported data pack to the right panel and click on "Done".
  8. -
  9. A message will warn about the use of experimental world settings. Click on "Proceed".
  10. -
-
} + {checkVersion(version, '1.20') ? <> + + : <> + +
+
changeVersion(MIN_VERSION)}> + {locale('generator.switch_version', MIN_VERSION)} {Octicon.arrow_right} +
+
+
+ }
} - -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 -} diff --git a/src/app/services/DataFetcher.ts b/src/app/services/DataFetcher.ts index 742fb7d7..da03301d 100644 --- a/src/app/services/DataFetcher.ts +++ b/src/app/services/DataFetcher.ts @@ -55,12 +55,12 @@ export async function fetchData(versionId: string, collectionTarget: CollectionR await validateCache(version) await Promise.all([ - fetchRegistries(version, collectionTarget), - fetchBlockStateMap(version, blockStateTarget), + _fetchRegistries(version, collectionTarget), + _fetchBlockStateMap(version, blockStateTarget), ]) } -async function fetchRegistries(version: Version, target: CollectionRegistry) { +async function _fetchRegistries(version: Version, target: CollectionRegistry) { console.debug(`[fetchRegistries] ${version.id}`) try { const data = await cachedFetch(`${mcmeta(version, 'summary')}/registries/data.min.json`) @@ -72,7 +72,7 @@ async function fetchRegistries(version: Version, target: CollectionRegistry) { } } -async function fetchBlockStateMap(version: Version, target: BlockStateRegistry) { +async function _fetchBlockStateMap(version: Version, target: BlockStateRegistry) { console.debug(`[fetchBlockStateMap] ${version.id}`) try { const data = await cachedFetch(`${mcmeta(version, 'summary')}/blocks/data.min.json`) @@ -87,6 +87,21 @@ async function fetchBlockStateMap(version: Version, target: BlockStateRegistry) } } +export async function fetchRegistries(versionId: VersionId) { + console.debug(`[fetchRegistries] ${versionId}`) + const version = config.versions.find(v => v.id === versionId)! + try { + const data = await cachedFetch(`${mcmeta(version, 'summary')}/registries/data.min.json`) + const result = new Map() + for (const id in data) { + result.set(id, data[id].map((e: string) => 'minecraft:' + e)) + } + return result + } catch (e) { + throw new Error(`Error occurred while fetching registries (2): ${message(e)}`) + } +} + export async function fetchBlockStates(versionId: VersionId) { console.debug(`[fetchBlockStates] ${versionId}`) const version = config.versions.find(v => v.id === versionId)! diff --git a/src/locales/en.json b/src/locales/en.json index f8789ba7..539275d7 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -31,7 +31,9 @@ "copy_share": "Copy share link", "copied": "Copied!", "copy_context": "Copy context", + "customized.error_min_version": "Customized worlds are not available in versions before %0%", "customized.basic": "Basic", + "customized.biomes": "Biomes", "customized.structures": "Structures", "customized.ores": "Ores", "cutoff": "Cutoff", diff --git a/src/styles/global.css b/src/styles/global.css index aa35874d..a11632b7 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -650,6 +650,10 @@ main.has-project { padding: 20px 18px; } +.btn.customized-create.btn.disabled { + background-color: var(--accent-site-1); +} + .btn.customized-create:not(.disabled):hover { background-color: var(--accent-site-2) !important; } @@ -699,6 +703,18 @@ main.has-project { margin-top: 10px; } +.customized-info { + display: flex; + align-items: center; + padding-top: 20px; + color: var(--text-3); + fill: var(--text-3); +} + +.customized-info > svg { + margin-right: 10px; +} + .customized-childs { margin-top: 10px; margin-bottom: 20px;