import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D, NoiseFunction3D } from "@paulaboks/rng"; import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts"; import type { FeatureChunk } from "$/common/mod_api/worldgen.ts"; import type { OreJson } from "$/common/mod_data.ts"; // generation runs in a worker now, so it only needs somewhere to put blocks export interface BlockSink { add_block(block: { x: number; y: number; z: number; id: string }): void; // the surface height and biome of each column, for later passes set_column?(x: number, z: number, height: number, biome: string): void; } type Biome = | "desert" | "plains" | "forest" | "jungle" | "tundra" | "taiga" | "snow" | "savanna" | "swamp"; type OreDef = { id: string; min_y: number; max_y: number; scale: number; threshold: number; }; const ORES: OreDef[] = [ { id: "bworld:coal_ore", min_y: 20, max_y: 120, scale: 0.05, threshold: 0.55 }, { id: "bworld:copper_ore", min_y: 10, max_y: 80, scale: 0.06, threshold: 0.6 }, { id: "bworld:tin_ore", min_y: 5, max_y: 60, scale: 0.06, threshold: 0.62 }, { id: "bworld:iron_ore", min_y: 5, max_y: 50, scale: 0.05, threshold: 0.65 }, { id: "bworld:gold_ore", min_y: 0, max_y: 30, scale: 0.04, threshold: 0.7 }, ]; function get_biome(temp: number, moisture: number): Biome { if (temp > 0.6) { if (moisture < -0.2) { return "desert"; } if (moisture > 0.4) { return "jungle"; } return "savanna"; } if (temp > 0) { if (moisture > 0.5) { return "swamp"; } if (moisture > 0) { return "forest"; } return "plains"; } if (temp > -0.5) { return "taiga"; } return "tundra"; } function get_surface_block(biome: Biome) { if (biome === "desert") { return "bworld:sand"; } else if (biome === "tundra") { return "bworld:snow"; } return "bworld:grass"; } function biome_height_modifier(biome: Biome) { if (biome === "desert") { return 0.2; } if (biome === "plains") { return 0.4; } if (biome === "forest") { return 0.5; } if (biome === "jungle") { return 0.45; } if (biome === "taiga") { return 0.55; } if (biome === "tundra") { return 0.35; } if (biome === "savanna") { return 0.4; } if (biome === "swamp") { return 0.35; } return 0.4; } function fractal_noise(noise: NoiseFunction2D, x: number, y: number, octaves = 2) { let value = 0; let amp = 1; let freq = 1; let max = 0; for (let i = 0; i < octaves; i++) { value += noise(x * freq, y * freq) * amp; max += amp; amp *= 0.5; freq *= 2; } return value / max; } function get_terrain_height(base: number, biome: Biome, x: number, z: number, noise: NoiseFunction2D) { const biomeMod = biome_height_modifier(biome); const main = fractal_noise(noise, x * 0.003, z * 0.003) * 15; const detail = fractal_noise(noise, x * 0.01, z * 0.01) * 3; return Math.floor(base + biomeMod * 20 + main + detail); } function can_place_tree(tree_map: boolean[][], local_x: number, local_z: number) { const TREE_SPACING = 4; for (let dx = -TREE_SPACING; dx <= TREE_SPACING; dx++) { for (let dz = -TREE_SPACING; dz <= TREE_SPACING; dz++) { const nx = local_x + dx; const nz = local_z + dz; if (nx >= 0 && nx < CHUNK_SIZE && nz >= 0 && nz < CHUNK_SIZE && tree_map[nx][nz]) { return false; } } } return true; } function place_tree(dimension: BlockSink, rng: Alea, x: number, y: number, z: number, biome: Biome) { const height = Math.floor(rng.next() * 3) + (biome === "jungle" ? 8 : 4); const trunk_block = "bworld:log"; const leaves_block = "bworld:leaves"; for (let i = 0; i < height; i++) { dimension.add_block({ x, y: y + i, z, id: trunk_block }); } for (let dx = -2; dx <= 2; dx++) { for (let dz = -2; dz <= 2; dz++) { for (let dy = -1; dy <= 1; dy++) { if (Math.abs(dx) + Math.abs(dz) + Math.abs(dy) <= 3) { dimension.add_block({ x: x + dx, y: y + height + dy, z: z + dz, id: leaves_block, }); } } } } } const TREE_THRESHOLD: Record = { forest: 0.5, jungle: 0.3, taiga: 0.6, plains: 0.95, desert: 1, tundra: 1, savanna: 0.65, swamp: 0.5, snow: 0.8, }; function should_place_tree(feature_noise: NoiseFunction2D, biome: Biome, x: number, z: number) { const n = feature_noise(x * 0.1, z * 0.1); return n > (TREE_THRESHOLD[biome] ?? 0.8); } interface SeedNoises { height_noise: NoiseFunction2D; temp_noise: NoiseFunction2D; moisture_noise: NoiseFunction2D; feature_noise: NoiseFunction2D; ore_noises: NoiseFunction3D[]; } // building the permutation tables is expensive, only do it once per seed const noise_cache = new Map(); function get_noises(seed: string): SeedNoises { let noises = noise_cache.get(seed); if (!noises) { noises = { height_noise: create_noise_2d(new Alea(seed + "_height")), temp_noise: create_noise_2d(new Alea(seed + "_temp")), moisture_noise: create_noise_2d(new Alea(seed + "_moisture")), feature_noise: create_noise_2d(new Alea(seed + "_feature")), ore_noises: ORES.map((ore) => create_noise_3d(new Alea(seed + "_" + ore.id))), }; noise_cache.set(seed, noises); } return noises; } export function generate_chunk(dimension: BlockSink, cx: number, cz: number, seed = "seed") { const { height_noise, temp_noise, moisture_noise, feature_noise, ore_noises } = get_noises(seed); // seeded per chunk so every client generates the exact same terrain const rng = new Alea(`${seed}_chunk_${cx}_${cz}`); const biome_scale = 0.003; const terrain_scale = 0.01; const tree_map: boolean[][] = Array.from({ length: CHUNK_SIZE }, () => Array(CHUNK_SIZE).fill(false)); for (let x = 0; x < CHUNK_SIZE; x++) { for (let z = 0; z < CHUNK_SIZE; z++) { const wx = cx * CHUNK_SIZE + x; const wz = cz * CHUNK_SIZE + z; const temp = temp_noise(wx * biome_scale, wz * biome_scale); const moisture = moisture_noise(wx * biome_scale, wz * biome_scale); const biome = get_biome(temp, moisture); const height_noise_value = fractal_noise(height_noise, wx * terrain_scale, wz * terrain_scale); const base_height = (height_noise_value + 1) * 15 + 50; const height = get_terrain_height(base_height, biome, wx, wz, height_noise); const surface_block = get_surface_block(biome); dimension.set_column?.(wx, wz, height, `bworld:${biome}`); for (let y = 0; y <= height; y++) { let block = "bworld:stone"; if (y < height - 3) { for (let i = 0; i < ORES.length; i++) { const ore = ORES[i]; if (y >= ore.min_y && y <= ore.max_y) { const noise = ore_noises[i]( wx * ore.scale, y * ore.scale, wz * ore.scale, ); if (noise > ore.threshold) { block = ore.id; break; } } } } if (y === height) { block = surface_block; } else if (y > height - 4) { block = "bworld:dirt"; } if (biome === "swamp" && y === height && rng.next() < 0.2) { block = "bworld:water"; } dimension.add_block({ x: wx, y, z: wz, id: block }); } if (should_place_tree(feature_noise, biome, wx, wz) && can_place_tree(tree_map, x, z)) { place_tree(dimension, rng, wx, height + 1, wz, biome); tree_map[x][z] = true; } } } } // noise the way mods get it: create_noise_2d(new Alea(seed + "_" + name)), the same as the base terrain's const mod_noise_2d = new Map(); const mod_noise_3d = new Map(); export function named_noise_2d(seed: string, name: string): NoiseFunction2D { const key = `${seed}_${name}`; let noise = mod_noise_2d.get(key); if (!noise) { noise = create_noise_2d(new Alea(key)); mod_noise_2d.set(key, noise); } return noise; } export function named_noise_3d(seed: string, name: string): NoiseFunction3D { const key = `${seed}_${name}`; let noise = mod_noise_3d.get(key); if (!noise) { noise = create_noise_3d(new Alea(key)); mod_noise_3d.set(key, noise); } return noise; } // what mods add to generation, see "World generation" in MODS.md export interface WorldgenSetup { ores: OreJson[]; features: { id: string; generate: (chunk: FeatureChunk) => void }[]; } export interface RawChunk { // numeric block ids, only what this chunk generated itself blocks: Uint32Array; // blocks it generated in other chunks (tree leaves), flattened as x, y, z, numeric id spills: Int32Array; } // features that threw, so each is only reported once const failed_features = new Set(); // generates one chunk on its own. neighbors' spills get merged in by whoever assembles the world: // a chunk's own blocks always win and spills only fill air, so the result doesn't depend on load order export function generate_raw_chunk( chunk_x: number, chunk_z: number, seed: string, block_ids: Record, worldgen?: WorldgenSetup, // the value to store for each numeric id, with its default states. just the id when missing default_values?: number[], ): RawChunk { const blocks = new Uint32Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT); const spills: number[] = []; const heights = new Int32Array(CHUNK_AREA); const biomes: string[] = new Array(CHUNK_AREA); const set = (x: number, y: number, z: number, nid: number) => { if (y < 0 || y >= CHUNK_HEIGHT) { return; } nid = default_values?.[nid] ?? nid; const block_chunk_x = Math.floor(x / CHUNK_SIZE); const block_chunk_z = Math.floor(z / CHUNK_SIZE); if (block_chunk_x !== chunk_x || block_chunk_z !== chunk_z) { spills.push(x, y, z, nid); return; } const lx = x - chunk_x * CHUNK_SIZE; const lz = z - chunk_z * CHUNK_SIZE; blocks[y * CHUNK_AREA + lz * CHUNK_SIZE + lx] = nid; }; generate_chunk( { add_block(block) { const nid = block_ids[block.id]; if (nid !== undefined) { set(block.x, block.y, block.z, nid); } }, set_column(x, z, height, biome) { const index = (z - chunk_z * CHUNK_SIZE) * CHUNK_SIZE + (x - chunk_x * CHUNK_SIZE); heights[index] = height; biomes[index] = biome; }, }, chunk_x, chunk_z, seed, ); if (worldgen) { generate_ores(blocks, chunk_x, chunk_z, seed, block_ids, worldgen.ores, default_values); generate_features(blocks, heights, biomes, set, chunk_x, chunk_z, seed, block_ids, worldgen.features); } return { blocks, spills: new Int32Array(spills) }; } // each block an ore replaces becomes the first ore whose noise is above its threshold function generate_ores( blocks: Uint32Array, chunk_x: number, chunk_z: number, seed: string, block_ids: Record, ores: OreJson[], default_values: number[] | undefined, ) { const usable = ores .map((ore) => ({ ...ore, nid: block_ids[ore.id], replaces_nid: block_ids[ore.replaces] })) .filter((ore) => ore.nid !== undefined && ore.replaces_nid !== undefined); if (usable.length === 0) { return; } const noises = usable.map((ore) => named_noise_3d(seed, ore.id)); for (let y = 0; y < CHUNK_HEIGHT; y++) { for (let lz = 0; lz < CHUNK_SIZE; lz++) { for (let lx = 0; lx < CHUNK_SIZE; lx++) { const index = y * CHUNK_AREA + lz * CHUNK_SIZE + lx; const current = blocks[index] & ID_MASK; if (current === AIR) { continue; } for (let i = 0; i < usable.length; i++) { const ore = usable[i]; if (current !== ore.replaces_nid || y < ore.min_y || y > ore.max_y) { continue; } const wx = chunk_x * CHUNK_SIZE + lx; const wz = chunk_z * CHUNK_SIZE + lz; if (noises[i](wx * ore.scale, y * ore.scale, wz * ore.scale) > ore.threshold) { blocks[index] = default_values?.[ore.nid] ?? ore.nid; break; } } } } } } function generate_features( blocks: Uint32Array, heights: Int32Array, biomes: string[], set: (x: number, y: number, z: number, nid: number) => void, chunk_x: number, chunk_z: number, seed: string, block_ids: Record, features: WorldgenSetup["features"], ) { const ids_by_nid: string[] = []; for (const [id, nid] of Object.entries(block_ids)) { ids_by_nid[nid] = id; } const local = (x: number, z: number, what: string) => { const lx = x - chunk_x * CHUNK_SIZE; const lz = z - chunk_z * CHUNK_SIZE; if (lx < 0 || lx >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE) { throw new Error(`${what}(${x}, ${z}) is outside chunk ${chunk_x}, ${chunk_z}`); } return lz * CHUNK_SIZE + lx; }; for (const feature of features) { const chunk: FeatureChunk = { x: chunk_x, z: chunk_z, seed, rng: new Alea(`${seed}_feature_${feature.id}_${chunk_x}_${chunk_z}`), noise_2d: (name) => named_noise_2d(seed, name), noise_3d: (name) => named_noise_3d(seed, name), height_at: (x, z) => heights[local(x, z, "height_at")], biome_at: (x, z) => biomes[local(x, z, "biome_at")], get_block(x, y, z) { if (y < 0 || y >= CHUNK_HEIGHT) { return undefined; } const nid = blocks[y * CHUNK_AREA + local(x, z, "get_block")] & ID_MASK; return nid === AIR ? "bworld:air" : ids_by_nid[nid]; }, set_block(x, y, z, id) { const nid = id === "bworld:air" ? AIR : block_ids[id]; if (nid === undefined) { throw new Error(`unknown block ${id}`); } const dx = Math.floor(x / CHUNK_SIZE) - chunk_x; const dz = Math.floor(z / CHUNK_SIZE) - chunk_z; if (Math.abs(dx) > 1 || Math.abs(dz) > 1) { throw new Error(`set_block(${x}, ${y}, ${z}) is more than one chunk away`); } set(x, y, z, nid); }, }; try { feature.generate(chunk); } catch (e) { // every client and the server hit the same error, so skipping it keeps them in agreement if (!failed_features.has(feature.id)) { failed_features.add(feature.id); console.error(`Worldgen feature ${feature.id} failed, skipping it where it throws:`, e); } } } }