From 0a9d17b76e6a0047703774add6c355fa67a4bf05 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sun, 14 Jun 2026 23:39:42 +0100 Subject: [PATCH 1/4] World generation overhaul: biomes, terrain, coasts, blending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modular world-gen pipeline (src/game/gen/) replacing the single-file generator with declarative, deterministic stages: Climate (heat/humidity + altitude chill), Biomes (Voronoi climate selection, 12 biomes), TerrainNoise (continents + ridged mountains + rivers + detail), Caves (worm tunnels + caverns), Ores (depth-gated veins), Surface (coastal + snow + blend painting), Trees (6 species w/ variants), Decorations (clustered ground cover), WorldgenStats. Generation is deterministic per seed (byte-identical, seamless chunk borders) and budgeted (~3.6ms/chunk). Biome system: 12 biomes selected by nearest climate point with altitude chill; fixed a climate-map DC-bias bug that had frozen half the map to freezing. All biomes appear across seeds. Coastlines (multi-pass tuning): - Beaches are a proximity effect, not an elevation biome; sand requires real water adjacency so low inland plains stay grassy. - Beach PRESENCE is a low-frequency patch mask gated by per-biome tendency (desert always sandy; forest/snow/mountain mostly earthen banks w/ sand pockets) — breaks the uniform sand ring around lakes. - Beach width noise-modulated + slope-aware; steep shores never sandy. - Elevation-aware rocky threshold near sea kills grey stone rings. - 3D solidity wobble damped near sea so dressed floor tracks the heightmap (fixes invisible-water/beach gaps). - Underwater shelves grade sand -> gravel -> dirt by depth. Biome blending: continuous temperature-driven snow mask (tapers Snow -> Snowy Grass -> Grass, biome-independent) + biome-edge mottling, removing hard biome/snow boundary lines. Blocks: +8 (dead bush, fern, papyrus, cornflower, birch wood/leaves, spruce leaves, snowy leaves) + 9 atlas tiles. Debug: WorldgenOverlay (G toggle, H cycle) w/ live minimap (biome/heat/humidity/height/slope/shore/snow modes) + target-column readout (biome, surface, snow, beach strength/presence, water extent, shore distance, slope). Gen timing in the F3 perf overlay. __voxl.worldgen()/worldgenMode()/worldgenInfo() console API. Spawn search now finds dry land near origin. Typecheck clean; build passes; 3/3 test suites pass. --- src/engine/Textures.ts | 113 +++++ src/game/Blocks.ts | 97 +++++ src/game/Game.ts | 98 ++++- src/game/TerrainGenerator.ts | 762 +++++++++++++++++----------------- src/game/gen/Biomes.ts | 423 +++++++++++++++++++ src/game/gen/BlockIds.ts | 44 ++ src/game/gen/Caves.ts | 60 +++ src/game/gen/Climate.ts | 57 +++ src/game/gen/Decorations.ts | 128 ++++++ src/game/gen/Ores.ts | 39 ++ src/game/gen/Surface.ts | 248 +++++++++++ src/game/gen/TerrainNoise.ts | 121 ++++++ src/game/gen/Trees.ts | 149 +++++++ src/game/gen/WorldgenStats.ts | 75 ++++ src/main.ts | 7 + src/ui/PerfOverlay.ts | 12 + src/ui/WorldgenOverlay.ts | 284 +++++++++++++ src/ui/ui.css | 53 +++ 18 files changed, 2387 insertions(+), 383 deletions(-) create mode 100644 src/game/gen/Biomes.ts create mode 100644 src/game/gen/BlockIds.ts create mode 100644 src/game/gen/Caves.ts create mode 100644 src/game/gen/Climate.ts create mode 100644 src/game/gen/Decorations.ts create mode 100644 src/game/gen/Ores.ts create mode 100644 src/game/gen/Surface.ts create mode 100644 src/game/gen/TerrainNoise.ts create mode 100644 src/game/gen/Trees.ts create mode 100644 src/game/gen/WorldgenStats.ts create mode 100644 src/ui/WorldgenOverlay.ts diff --git a/src/engine/Textures.ts b/src/engine/Textures.ts index 76decf0..33c774c 100644 --- a/src/engine/Textures.ts +++ b/src/engine/Textures.ts @@ -409,6 +409,119 @@ export function createTextureAtlas(scene: Scene): AtlasResult { } } + // --- Richer-world tiles (world-gen upgrade): 34..42 --- + + // 34: dead bush (desert — dry brown twigs, plantlike transparent bg) + { + const [ox, oy] = off(34); + ctx.clearRect(ox, oy, TILE_PX, TILE_PX); + ctx.fillStyle = "rgb(120,92,52)"; + // central stalk + ctx.fillRect(ox + 8, oy + 4, 1, 10); + // branching twigs + ctx.fillRect(ox + 8, oy + 6, 3, 1); + ctx.fillRect(ox + 11, oy + 6, 1, 3); + ctx.fillRect(ox + 5, oy + 8, 3, 1); + ctx.fillRect(ox + 4, oy + 6, 1, 3); + ctx.fillStyle = "rgb(150,116,70)"; + ctx.fillRect(ox + 8, oy + 11, 4, 1); + ctx.fillRect(ox + 12, oy + 11, 1, 3); + ctx.fillRect(ox + 6, oy + 12, 2, 1); + } + // 35: fern (forest ground cover, plantlike) + { + const [ox, oy] = off(35); + ctx.clearRect(ox, oy, TILE_PX, TILE_PX); + const shades = ["#3e7e36", "#4e8e40", "#5ea048"]; + for (let i = 0; i < 5; i++) { + const bx = ox + 3 + i * 2; + const h = 6 + (i % 3) * 2; + ctx.fillStyle = shades[i % shades.length]; + for (let y = 0; y < h; y++) { + const w = y < h - 2 ? 1 : 2; + ctx.fillRect(bx - (y > h - 3 ? 1 : 0), oy + (TILE_PX - h) + y, w, 1); + ctx.fillRect(bx + (y > h - 3 ? 0 : 0), oy + (TILE_PX - h) + y, 1, 1); + } + } + } + // 36: papyrus (tall reed near water, plantlike) + { + const [ox, oy] = off(36); + ctx.clearRect(ox, oy, TILE_PX, TILE_PX); + for (const cx of [6, 8, 10]) { + const h = 12 + (cx % 3); + for (let y = 0; y < h; y++) { + const d = (rand() - 0.5) * 16; + const g = clamp255(150 + d) | 0; + ctx.fillStyle = `rgb(${clamp255(130 + d) | 0},${g},${clamp255(80 + d) | 0})`; + ctx.fillRect(ox + cx, oy + (TILE_PX - h) + y, 1, 1); + } + // leafy top + ctx.fillStyle = "rgb(110,150,64)"; + ctx.fillRect(ox + cx - 1, oy + (TILE_PX - h), 1, 1); + ctx.fillRect(ox + cx + 1, oy + (TILE_PX - h) + 1, 1, 1); + } + } + // 37: cornflower (blue flower, plantlike) + { + const [ox, oy] = off(37); + drawFlower(ctx, ox, oy, rand, [74, 108, 214]); + } + // 38: birch wood top (pale with faint rings) + { + const [ox, oy] = off(38); + paintSpeckled(ctx, ox, oy, [216, 208, 188], 8, 50, rand); + ctx.strokeStyle = "rgba(150,140,120,0.6)"; + ctx.beginPath(); + ctx.arc(ox + 8, oy + 8, 4, 0, Math.PI * 2); + ctx.stroke(); + } + // 39: birch wood side (white bark with black dashes) + { + const [ox, oy] = off(39); + paintSpeckled(ctx, ox, oy, [222, 214, 196], 8, 70, rand); + ctx.fillStyle = "rgb(54,50,46)"; + for (let i = 0; i < 7; i++) { + const x = ox + Math.floor(rand() * (TILE_PX - 3)); + const y = oy + 1 + Math.floor(rand() * (TILE_PX - 2)); + const w = 2 + Math.floor(rand() * 2); + ctx.fillRect(x, y, w, 1); + } + } + // 40: birch leaves (bright yellow-green, lighter than oak) + { + const [ox, oy] = off(40); + paintSpeckled(ctx, ox, oy, [120, 170, 70], 36, 150, rand); + for (let i = 0; i < 8; i++) { + ctx.fillStyle = "rgb(90,140,46)"; + ctx.fillRect(ox + Math.floor(rand() * TILE_PX), oy + Math.floor(rand() * TILE_PX), 1, 1); + } + } + // 41: spruce leaves (dark, cold green for taiga/snowy pines) + { + const [ox, oy] = off(41); + paintSpeckled(ctx, ox, oy, [38, 74, 42], 28, 150, rand); + for (let i = 0; i < 14; i++) { + ctx.fillStyle = "rgb(22,48,26)"; + ctx.fillRect(ox + Math.floor(rand() * TILE_PX), oy + Math.floor(rand() * TILE_PX), 1, 1); + } + } + // 42: snowy leaves (spruce green dusted with white snow) + { + const [ox, oy] = off(42); + paintSpeckled(ctx, ox, oy, [40, 78, 46], 26, 120, rand); + // snow caps along the top third + ctx.fillStyle = "rgb(238,242,250)"; + for (let x = 0; x < TILE_PX; x++) { + const h = 3 + Math.floor(rand() * 4); + for (let y = 0; y < h; y++) { + const d = (rand() - 0.5) * 12; + ctx.fillStyle = `rgb(${clamp255(238 + d) | 0},${clamp255(242 + d) | 0},${clamp255(250 + d) | 0})`; + ctx.fillRect(ox + x, oy + y, 1, 1); + } + } + } + // Upload the painted canvas to the GPU. texture.update(false); diff --git a/src/game/Blocks.ts b/src/game/Blocks.ts index ddd5609..5ceba57 100644 --- a/src/game/Blocks.ts +++ b/src/game/Blocks.ts @@ -51,6 +51,16 @@ const T = { JUNGLE_LEAVES: 31, MOSSY_STONE: 32, GLOWSTONE: 33, + // --- Richer-world tiles (world-gen upgrade) --- + DEAD_BUSH: 34, + FERN: 35, + PAPYRUS: 36, + CORNFLOWER: 37, + BIRCH_TOP: 38, + BIRCH_SIDE: 39, + BIRCH_LEAVES: 40, + SPRUCE_LEAVES: 41, + SNOWY_LEAVES: 42, } as const; /** @@ -492,6 +502,93 @@ export const BLOCKS: readonly BlockDef[] = [ // does not pass unattenuated (deep water darkens). light: { lightPassesThrough: true, sunlightPassesThrough: false }, }, + { + id: 30, + name: "Dead Bush", + tiles: uniform(T.DEAD_BUSH), + color: "#8a6a3a", + solid: false, + opaque: false, + transparent: true, + liquid: false, + shape: "plantlike", + }, + { + id: 31, + name: "Fern", + tiles: uniform(T.FERN), + color: "#4e7e36", + solid: false, + opaque: false, + transparent: true, + liquid: false, + shape: "plantlike", + }, + { + id: 32, + name: "Papyrus", + tiles: uniform(T.PAPYRUS), + color: "#9aac5a", + solid: false, + opaque: false, + transparent: true, + liquid: false, + shape: "plantlike", + }, + { + id: 33, + name: "Cornflower", + tiles: uniform(T.CORNFLOWER), + color: "#4a6cd6", + solid: false, + opaque: false, + transparent: true, + liquid: false, + shape: "plantlike", + }, + { + id: 34, + name: "Birch Wood", + tiles: [T.BIRCH_SIDE, T.BIRCH_SIDE, T.BIRCH_TOP, T.BIRCH_TOP, T.BIRCH_SIDE, T.BIRCH_SIDE], + color: "#d8d0bc", + solid: true, + opaque: true, + transparent: false, + liquid: false, + }, + { + id: 35, + name: "Birch Leaves", + tiles: uniform(T.BIRCH_LEAVES), + color: "#7fae4e", + solid: true, + opaque: true, + transparent: false, + liquid: false, + light: { lightPassesThrough: true }, + }, + { + id: 36, + name: "Spruce Leaves", + tiles: uniform(T.SPRUCE_LEAVES), + color: "#2a4a2a", + solid: true, + opaque: true, + transparent: false, + liquid: false, + light: { lightPassesThrough: true }, + }, + { + id: 37, + name: "Snowy Leaves", + tiles: uniform(T.SNOWY_LEAVES), + color: "#cdd8e6", + solid: true, + opaque: true, + transparent: false, + liquid: false, + light: { lightPassesThrough: true }, + }, ]; export const AIR_BLOCK = 0; diff --git a/src/game/Game.ts b/src/game/Game.ts index 8d94c2b..a43b1cf 100644 --- a/src/game/Game.ts +++ b/src/game/Game.ts @@ -13,6 +13,7 @@ import { import { PLAYER_HALF_WIDTH, PLAYER_HEIGHT, + SEA_LEVEL, } from "../constants"; import type { GameState, Settings } from "../types"; import { Renderer } from "../engine/Renderer"; @@ -46,6 +47,7 @@ import { GraphicsController, MAX_RENDER_DISTANCE, MIN_RENDER_DISTANCE, presetRen import { graphicsFromPreset, type GraphicsPreset, type GraphicsSettings } from "./graphics/GraphicsSettings"; import { PerfOverlay, type PerfSnapshot } from "../ui/PerfOverlay"; import { ChunkBorderOverlay } from "../ui/ChunkBorderOverlay"; +import { WorldgenOverlay, type WorldgenSnapshot, type WorldgenMapMode } from "../ui/WorldgenOverlay"; import { UnderwaterRenderer } from "./UnderwaterRenderer"; import { isLiquid, liquidDefOf, WATER_BLOCK, WATER_FLOWING_BLOCK } from "./Blocks"; import { dbg, dbgWarn } from "../state/Debug"; @@ -87,6 +89,7 @@ export class Game { private readonly graphics: GraphicsController; private readonly perf: PerfOverlay; private readonly chunkBorders: ChunkBorderOverlay; + private readonly worldgenOverlay: WorldgenOverlay; private readonly underwater: UnderwaterRenderer; private selectedIndex = 0; @@ -152,6 +155,7 @@ export class Game { this.graphics = new GraphicsController(this.renderer.engine, scene, this.sky, this.player.camera); this.perf = new PerfOverlay(); this.chunkBorders = new ChunkBorderOverlay(scene); + this.worldgenOverlay = new WorldgenOverlay(); this.underwater = new UnderwaterRenderer(scene); this.graphics.apply(this.settings.graphics); @@ -209,6 +213,13 @@ export class Game { const on = this.chunkBorders.toggle(); this.hud.showToast(on ? "Chunk borders: on" : "Chunk borders: off"); } + if (code === "KeyG") { + const on = this.worldgenOverlay.toggle(); + this.hud.showToast(on ? "Worldgen overlay: on" : "Worldgen overlay: off"); + } + if (code === "KeyH" && this.worldgenOverlay.isOpen) { + this.worldgenOverlay.cycleMode(); + } if (code === "F4") { // Toggle liquid targeting (Luanti `liquids` pointability). Default // (off) = mine/build through water; on = point at the water surface to @@ -300,7 +311,8 @@ export class Game { private startGame(): void { this.setState("loading"); this.createWorld(this.settings.seed); - this.player.spawn(this.world!, 0, 0); + const spawn = this.findSpawnColumn(); + this.player.spawn(this.world!, spawn.x, spawn.z); this.spawnPoint = this.player.position.clone(); const px = this.player.position.x; const pz = this.player.position.z; @@ -326,7 +338,8 @@ export class Game { if (this.state === "playing" || this.state === "paused") { this.setState("loading"); this.createWorld(seed); - this.player.spawn(this.world!, 0, 0); + const spawn = this.findSpawnColumn(); + this.player.spawn(this.world!, spawn.x, spawn.z); this.spawnPoint = this.player.position.clone(); for (let i = 0; i < 10; i++) this.world!.update(this.player.position.x, this.player.position.z, this.settings.viewDistance); this.updateFog(); @@ -355,6 +368,31 @@ export class Game { this.graphics.attachWorld(this.world, this.lighting); } + /** + * Find a dry-land column near the origin to spawn the player on, searching an + * expanding square spiral. Avoids spawning on the ocean floor when the seed + * places deep water at (0,0). Falls back to the origin if none is found. + */ + private findSpawnColumn(): { x: number; z: number } { + const gen = this.world!.generator; + const check = (x: number, z: number): boolean => { + const h = gen.columnHeight(x, z); + return h > SEA_LEVEL && gen.biomeAt(x, z, h) !== "ocean"; + }; + if (check(0, 0)) return { x: 0, z: 0 }; + for (let r = 6; r <= 384; r += 6) { + for (let x = -r; x <= r; x += 6) { + if (check(x, -r)) return { x, z: -r }; + if (check(x, r)) return { x, z: r }; + } + for (let z = -r + 6; z <= r - 6; z += 6) { + if (check(-r, z)) return { x: -r, z }; + if (check(r, z)) return { x: r, z }; + } + } + return { x: 0, z: 0 }; + } + /** Load inventory+vitals for this seed, or seed a fresh starter kit. */ private loadOrCreateProgress(): void { const save = loadSave(this.settings.seed); @@ -612,6 +650,11 @@ export class Game { ); this.lighting.overlay.update(info); } + // World-gen debug overlay (throttled). The minimap re-renders on player + // movement, so this is cheap between moves. + if (this.worldgenOverlay.isOpen) { + this.worldgenOverlay.update(this.buildWorldgenSnapshot(target)); + } } // --- Periodic save --- @@ -912,6 +955,24 @@ export class Game { this.perf.update(this.buildPerfSnapshot()); } + /** Snapshot for the world-gen overlay: targeted column + rolling gen stats. */ + private buildWorldgenSnapshot( + target: ReturnType, + ): WorldgenSnapshot { + const p = this.player.position; + const tx = target ? Math.floor(target.x) : Math.floor(p.x); + const tz = target ? Math.floor(target.z) : Math.floor(p.z); + return { + generator: this.world?.generator ?? null, + stats: this.world?.generator.statsSnapshot() ?? null, + playerX: p.x, + playerZ: p.z, + targetWX: tx, + targetWZ: tz, + seaLevel: SEA_LEVEL, + }; + } + private buildPerfSnapshot(): PerfSnapshot { const scene = this.scene; const active = scene.getActiveMeshes(); @@ -942,6 +1003,11 @@ export class Game { } } } + const genStats = this.world?.generator.statsSnapshot() ?? null; + const ppos = this.player.position; + const biomeAtPlayer = this.world?.generator + ? this.world.generator.biomeAt(Math.floor(ppos.x), Math.floor(ppos.z), Math.floor(ppos.y)) + : "—"; return { fps: this.fpsEma, frameMs: this.frameMsEma, @@ -993,6 +1059,10 @@ export class Game { : null, waterSidesOn: this.world?.waterSidesOn ?? true, waterAnimOn: this.world?.waterShader.animationOn ?? true, + genAvgMs: genStats?.avgMs ?? 0, + genLastMs: genStats?.lastMs ?? 0, + genChunks: genStats?.chunks ?? 0, + biome: biomeAtPlayer, }; } @@ -1179,6 +1249,30 @@ export class Game { this.chunkBorders.setVisible(on ?? !this.chunkBorders.isOpen); } + /** Toggle the world-gen debug overlay from the console (`__voxl.worldgen()`). */ + _toggleWorldgen(on?: boolean): void { + this.worldgenOverlay.setVisible(on ?? !this.worldgenOverlay.isOpen); + } + + /** Set the world-gen minimap mode from the console (`__voxl.worldgenMode()`). */ + _worldgenMode(mode: WorldgenMapMode): void { + this.worldgenOverlay.setVisible(true); + this.worldgenOverlay.setMode(mode); + } + + /** Dump world-gen stats + the biome at the player to the console. */ + _worldgenInfo(): void { + const gen = this.world?.generator; + if (!gen) { + console.log("[voxl] no world"); + return; + } + const p = this.player.position; + const d = gen.debugAt(Math.floor(p.x), Math.floor(p.z)); + console.log("[voxl] worldgen @", Math.floor(p.x), Math.floor(p.z), d); + console.log("[voxl] stats", gen.statsSnapshot()); + } + // ---- Per-layer isolation toggles (for diagnosing patches/artifacts) ---- /** Debug: show/hide the entire water layer. */ diff --git a/src/game/TerrainGenerator.ts b/src/game/TerrainGenerator.ts index 33fe572..e6a6f62 100644 --- a/src/game/TerrainGenerator.ts +++ b/src/game/TerrainGenerator.ts @@ -1,499 +1,496 @@ import { CHUNK_SIZE, CHUNK_HEIGHT, SEA_LEVEL } from "../constants"; -import type { BlockId } from "../types"; import { Noise } from "../engine/Noise"; import { getBlock } from "./Blocks"; import type { Chunk } from "./Chunk"; - -// Deterministic procedural terrain, modelled on Minetest/Luanti's modern mapgen. +import * as B from "./gen/BlockIds"; +import { ClimateMaps } from "./gen/Climate"; +import { BIOME_DEFS, landBiome, landBlend, selectBiome, type BiomeId, type BiomeSelection, type TreeType } from "./gen/Biomes"; +import { HeightMap } from "./gen/TerrainNoise"; +import { CaveGenerator } from "./gen/Caves"; +import { OreGenerator } from "./gen/Ores"; +import { SurfacePainter, decideSurface, shoreBeach, snowFactor, applySnowAndBlend, type SurfaceCtx } from "./gen/Surface"; +import { TreeGenerator, type SetBlock } from "./gen/Trees"; +import { DecorationGenerator } from "./gen/Decorations"; +import { WorldgenStats, type WorldgenStatsSnapshot } from "./gen/WorldgenStats"; + +// Deterministic modular world generator. Orchestrates the gen/ pipeline: // -// Two-phase generation gives the world real depth instead of a flat heightmap: -// Phase 1 — a 3D density field carves the solid world. In mountain regions a -// 3D noise wobbles the surface, producing overhangs, cliffs, spires and -// floating chunks (a pure 2D heightmap can't do this). Caves (winding 3D -// tunnels + deep low-frequency caverns) are carved, and stone carries -// sedimentary strata + blob ores. -// Phase 2 — per column, the topmost solid block is found and dressed with a -// biome/altitude/slope-appropriate surface (grass, sand, snow, rock on -// steep cliffs…), a subsoil layer, and water/ice up to sea level. -// Phase 3 — decorations cluster by noise (forest groves, clearings), with -// biome-specific trees (oak, pine, jungle, acacia), cacti, grass and flora. +// Phase 1 — 3D solid field (continents + ridged mountains with overhangs), +// cave carving, sedimentary strata, ore veins. +// Phase 2 — per-column surface dressing (slope-graded soil/rock/snow/sand), +// sub-surface filler, water + ice fill to sea level. +// Phase 3 — clustered trees (oak/birch/spruce/pine/jungle/acacia) and ground +// cover (grass/flowers/ferns/mushrooms/dead bush/papyrus). // -// All randomness derives from the seed string, so a seed reproduces the world. - -// Block ids (see Blocks.ts). -const AIR = 0; -const GRASS = 1; -const DIRT = 2; -const STONE = 3; -const SAND = 4; -const WOOD = 5; -const LEAVES = 6; -const WATER = 7; -const BEDROCK = 8; -const SNOW = 9; -const SNOWY_GRASS = 10; -const ICE = 11; -const DESERT_SAND = 12; -const DESERT_STONE = 13; -const SANDSTONE = 14; -const GRAVEL = 15; -const COAL_ORE = 16; -const IRON_ORE = 17; -const COPPER_ORE = 18; -const CACTUS = 19; -const TALL_GRASS = 20; -const FLOWER_RED = 21; -const FLOWER_YELLOW = 22; -const MUSHROOM = 23; -const DRY_GRASS = 24; -const JUNGLE_GRASS = 25; -const JUNGLE_LEAVES = 26; -const MOSSY_STONE = 27; - -export type Biome = - | "grassland" - | "forest" - | "savanna" - | "rainforest" - | "taiga" - | "tundra" - | "desert"; +// Every random value derives from the seed + world coordinates, so a seed +// reproduces the world exactly and generation is seamless across chunk borders. const SEA = SEA_LEVEL; const SNOW_LINE = SEA + 30; +const ROCK_LINE = SEA + 22; const MAX_HEIGHT = CHUNK_HEIGHT - 12; -/** Slope (in blocks to a neighbour) above which a surface becomes rock. */ -const CLIFF_SLOPE = 2.3; - -function hash2(x: number, z: number, seed: string): number { - let h = 374761393; - const s = `${x},${z},${seed}`; - for (let i = 0; i < s.length; i++) { - h = Math.imul(h ^ s.charCodeAt(i), 668265263); - } - h = (h ^ (h >>> 13)) >>> 0; - return h / 0x100000000; -} - -function clamp01(v: number): number { - return v < 0 ? 0 : v > 1 ? 1 : v; -} - -/** Stone-family ids that subsoil/surface dressing may overwrite. */ -function isStoneFamily(id: BlockId): boolean { - return ( - id === STONE || - id === DESERT_STONE || - id === SANDSTONE || - id === GRAVEL || - id === MOSSY_STONE - ); -} +const CLIFF_SLOPE = 2.6; export class TerrainGenerator { readonly seed: string; private readonly noise: Noise; + private readonly climate: ClimateMaps; + private readonly heightMap: HeightMap; + private readonly caves: CaveGenerator; + private readonly ores: OreGenerator; + private readonly surface: SurfacePainter; + private readonly trees: TreeGenerator; + private readonly decorations: DecorationGenerator; + readonly stats = new WorldgenStats(); constructor(seed: string) { this.seed = seed || "voxl"; this.noise = new Noise(this.seed); + this.climate = new ClimateMaps(this.noise); + this.heightMap = new HeightMap(this.noise, { seaLevel: SEA, maxHeight: MAX_HEIGHT }); + this.caves = new CaveGenerator(this.noise); + this.ores = new OreGenerator(this.noise); + this.surface = new SurfacePainter(this.noise, SEA); + this.trees = new TreeGenerator(this.seed); + this.decorations = new DecorationGenerator(this.noise, this.seed); } + /** 2D surface height (floored) — the world's base terrain shape. */ columnHeight(wx: number, wz: number): number { - return Math.floor(this.rawHeight2D(wx, wz)); - } - - biomeAt(wx: number, wz: number, height: number): Biome { - const n = this.noise; - // Large-scale heat/moist: a single smooth low-frequency octave (Minetest - // uses a biome-noise spread of several hundred nodes). One octave + no - // stretching keeps biomes big and coherent instead of fragmented. - const heat = clamp01(0.5 + n.fbm2(wx * 0.0008 + 500, wz * 0.0008 + 500, 1)); - const moist = clamp01(0.5 + n.fbm2(wx * 0.0008, wz * 0.0008 + 900, 1)); - const cold = height > SNOW_LINE ? (height - SNOW_LINE) / 16 : 0; - const effHeat = heat - cold; - if (effHeat < 0.38) return moist > 0.5 ? "taiga" : "tundra"; - if (heat > 0.62) { - if (moist < 0.4) return "desert"; - if (moist > 0.62) return "rainforest"; - return "savanna"; - } - if (moist > 0.55) return "forest"; - return "grassland"; - } - - /** 2D base surface height (continents + ridged mountains). */ - private rawHeight2D(wx: number, wz: number): number { - const n = this.noise; - const continental = n.fbm2(wx * 0.004, wz * 0.004, 4); - const hills = n.fbm2(wx * 0.016 + 200, wz * 0.016 + 200, 3); - let h = SEA + 2 + continental * 18 + hills * 6; // lows dip under sea → lakes/oceans - const mregion = this.mountainRegion(wx, wz); - const ridge = 1 - Math.abs(n.noise2(wx * 0.012 + 50, wz * 0.012 + 50)); - h += mregion * mregion * ridge * 62; - return Math.max(3, Math.min(MAX_HEIGHT, h)); + return Math.floor(this.heightMap.height(wx, wz)); } - /** 0..1 mask of where 3D mountain terrain (overhangs) is allowed. */ - private mountainRegion(wx: number, wz: number): number { - const v = this.noise.fbm2(wx * 0.005 + 1000, wz * 0.005 + 1000, 3) + 0.1; - return Math.min(1, Math.max(0, v * 1.8)); + /** Elevation + climate-aware biome at a column (for the debug overlay). */ + biomeAt(wx: number, wz: number, height: number): BiomeId { + const c = this.climate.base(wx, wz); + const effHeat = ClimateMaps.effectiveHeat(c.heat, height, SEA, SNOW_LINE); + return selectBiome(effHeat, c.humidity, height, SEA, ROCK_LINE, SNOW_LINE).id; } - /** Winding 3D tunnels + deep low-frequency caverns. */ - private isCave(wx: number, y: number, wz: number, h2: number): boolean { - if (y < 2 || y > h2 + 2) return false; - // Keep a solid surface shell. The previous thresholds could carve caves up - // into the dressed terrain layer, exposing huge open cross-sections and - // leaving surface details visually floating above underground voids. - if (y > h2 - 10) return false; - if (h2 <= SEA + 1) return false; // no caves beneath water (no flow sim) - const n = this.noise; - const a = n.noise3(wx * 0.045, y * 0.08, wz * 0.045); - const b = n.noise3(wx * 0.045 + 100, y * 0.05 + 100, wz * 0.045 + 100); - if (Math.abs(a) < 0.055 && Math.abs(b) < 0.28) return true; - if (y < SEA - 10) { - const c = n.fbm3(wx * 0.02 + 50, y * 0.03 + 50, wz * 0.02 + 50, 2); - if (c > 0.58) return true; + /** Rich climate/debug info at a column (for the world-gen overlay). */ + debugAt(wx: number, wz: number): { + biome: BiomeId; + landBiome: BiomeId; + heat: number; + humidity: number; + effHeat: number; + height: number; + slope: number; + treeDensity: number; + coastal: boolean; + rocky: boolean; + nearWater: boolean; + shoreDist: number; + beachStrength: number; + hasBeach: boolean; + beachWidth: number; + waterExtent: number; + snow: number; + blendEdge: number; + blendBiome: BiomeId; + shelf: string; + surfaceBlock: string; + } { + const c = this.climate.base(wx, wz); + const height = Math.floor(this.heightMap.height(wx, wz)); + const effHeat = ClimateMaps.effectiveHeat(c.heat, height, SEA, SNOW_LINE); + const sel = selectBiome(effHeat, c.humidity, height, SEA, ROCK_LINE, SNOW_LINE); + // Waterline cells use the adjacent land biome (matches the surface painter). + const biome = sel.id === "ocean" ? BIOME_DEFS[sel.landId] : BIOME_DEFS[sel.id]; + const slope = this.heightMap.slope(wx, wz); + const shoreDist = this.shoreDistance(wx, wz, 6); + const nearWater = shoreDist <= 2; + // Match the painter's noises + overlays so the overlay explains the block. + const bs = 0.5 + 0.5 * this.noise.fbm2(wx * 0.03, wz * 0.03, 2); + const beachStrength = bs < 0 ? 0 : bs > 1 ? 1 : bs; + const snowNoise = this.noise.fbm2(wx * 0.045 + 1200, wz * 0.045 + 1200, 2); + const blendNoise = this.noise.fbm2(wx * 0.07 + 900, wz * 0.07 + 900, 2); + const beach = shoreBeach(biome, slope, beachStrength); + const d = decideSurface(SEA, height, slope, biome, height, nearWater, beach.hasBeach, beach.width); + const snow = snowFactor(effHeat, snowNoise); + const blend = landBlend(c.heat, c.humidity); + const blendSurface = BIOME_DEFS[blend.second].surface; + const final = applySnowAndBlend(d, snow, blend.edge, blendSurface, blendNoise); + // Cheap local water-extent estimate (fraction of radius-8 neighbourhood + // below sea) — distinguishes open ocean (~1) from small ponds/rivers (~0). + let waterCells = 0; + let totalCells = 0; + for (let dz = -8; dz <= 8; dz += 2) { + for (let dx = -8; dx <= 8; dx += 2) { + totalCells++; + if (Math.floor(this.heightMap.height(wx + dx, wz + dz)) < SEA) waterCells++; + } } - return false; - } - - /** Stone with sedimentary strata (sandstone / gravel bands) and biome tint. */ - private stratumBlock(wx: number, y: number, wz: number, biome: Biome): BlockId { - const n = this.noise; - const s = n.fbm3(wx * 0.03 + 500, y * 0.06 + 500, wz * 0.03 + 500, 2); - if (s > 0.34) return SANDSTONE; - if (s < -0.4) return GRAVEL; - if (biome === "rainforest" && n.fbm3(wx * 0.09, y * 0.09, wz * 0.09, 2) > 0.35) return MOSSY_STONE; - return biome === "desert" ? DESERT_STONE : STONE; + const waterExtent = waterCells / totalCells; + return { + biome: sel.id, + landBiome: sel.landId, + heat: c.heat, + humidity: c.humidity, + effHeat, + height, + slope, + treeDensity: biome.treeDensity, + coastal: d.coastal, + rocky: d.rocky, + nearWater, + shoreDist, + beachStrength, + hasBeach: beach.hasBeach, + beachWidth: beach.width, + waterExtent, + snow, + blendEdge: blend.edge, + blendBiome: blend.second, + shelf: d.shelf, + surfaceBlock: getBlock(final).name, + }; } - /** Blob-style ore by depth + 3D noise (coal shallow → copper deep). */ - private oreAt(wx: number, y: number, wz: number): BlockId { - const n = this.noise; - if (y > 8 && y < MAX_HEIGHT - 3 && n.fbm3(wx * 0.07, y * 0.07, wz * 0.07, 2) > 0.42) return COAL_ORE; - if (y > 4 && y < SEA + 4 && n.fbm3(wx * 0.085 + 30, y * 0.085 + 30, wz * 0.085 + 30, 2) > 0.5) return IRON_ORE; - if (y > 2 && y < SEA - 6 && n.fbm3(wx * 0.1 + 60, y * 0.1 + 60, wz * 0.1 + 60, 2) > 0.56) return COPPER_ORE; - return 0; + statsSnapshot(): WorldgenStatsSnapshot { + return this.stats.snapshot(); } /** Fill a chunk's block data. Does not touch meshes. */ generate(chunk: Chunk): void { if (chunk.generated) return; + const t0 = performance.now(); const blocks = chunk.blocks; const ox = chunk.originX; const oz = chunk.originZ; const n = this.noise; const size = CHUNK_SIZE; - // Per-column scratch (shared across phases). - const h2col = new Float32Array(size * size); + const heightCol = new Float32Array(size * size); const surfY = new Int16Array(size * size); - const biomeCol: Biome[] = new Array(size * size); + const biomeCol: BiomeSelection[] = new Array(size * size); + const effHeatCol = new Float32Array(size * size); + + let caveCells = 0; + let oreCells = 0; // ---------- Phase 1: 3D solid field + caves + strata + ores ---------- for (let lz = 0; lz < size; lz++) { for (let lx = 0; lx < size; lx++) { const wx = ox + lx; const wz = oz + lz; - const h2 = this.rawHeight2D(wx, wz); - h2col[lz * size + lx] = h2; - const biome = this.biomeAt(wx, wz, Math.floor(h2)); - biomeCol[lz * size + lx] = biome; - const mregion = this.mountainRegion(wx, wz); + const h2 = this.heightMap.height(wx, wz); + const ci = lz * size + lx; + heightCol[ci] = h2; + const c = this.climate.base(wx, wz); + const effHeat = ClimateMaps.effectiveHeat(c.heat, Math.floor(h2), SEA, SNOW_LINE); + effHeatCol[ci] = effHeat; + const sel = selectBiome(effHeat, c.humidity, Math.floor(h2), SEA, ROCK_LINE, SNOW_LINE); + biomeCol[ci] = sel; + const biome = BIOME_DEFS[sel.id]; + const mregion = this.heightMap.mountainMask(wx, wz); const yMax = Math.min(CHUNK_HEIGHT - 1, Math.floor(h2) + (mregion > 0.15 ? 20 : 3)); for (let y = 0; y <= yMax; y++) { if (y === 0) { - blocks[(0 * size + lz) * size + lx] = BEDROCK; + blocks[(0 * size + lz) * size + lx] = B.BEDROCK; continue; } - // Decide solidity: deep cells are solid; the surface band uses 3D - // detail so mountains get overhangs/cliffs. + // Solidity: deep cells solid; the surface band uses 3D detail so + // mountains get overhangs, cliffs and spires in their regions. The + // wobble is damped near sea level so the dressed surface tracks the + // (already-smoothed) 2D heightmap around coastlines — otherwise the + // dressed floor could dip below sea where the heightmap says "land", + // creating invisible water the shore detector can't see (and beaches + // wouldn't form). let solid: boolean; if (y <= h2 - 4) { solid = true; } else { const detail = n.fbm3(wx * 0.018, y * 0.045, wz * 0.018, 3); const rough = n.fbm3(wx * 0.06, y * 0.06, wz * 0.06, 2); - const top = h2 + detail * 22 * mregion + rough * 2; + const distFromSea = Math.abs(h2 - SEA); + const wobbleScale = distFromSea < 10 ? 0.3 + 0.7 * (distFromSea / 10) : 1; + const top = h2 + (detail * 22 * mregion + rough * 2) * wobbleScale; solid = y <= top; } if (!solid) continue; - if (this.isCave(wx, y, wz, h2)) continue; // carve + if (this.caves.isCarved(wx, y, wz, h2, SEA)) { + caveCells++; + continue; + } - let block = this.stratumBlock(wx, y, wz, biome); - const ore = this.oreAt(wx, y, wz); - if (ore) block = ore; + // Base stone: biome-tinted + sedimentary strata. + let block = biome.stone; + const strata = this.ores.stratumBlock(wx, y, wz); + if (strata) block = strata; + if (sel.id === "rainforest" && n.fbm3(wx * 0.09, y * 0.09, wz * 0.09, 2) > 0.35) { + block = B.MOSSY_STONE; + } + const ore = this.ores.oreAt(wx, y, wz); + if (ore) { + block = ore; + oreCells++; + } blocks[(y * size + lz) * size + lx] = block; } } } - // ---------- Phase 2: surfaces, cliffs, water, ice, snow ---------- + // ---------- Phase 2: surface dressing + water/ice ---------- for (let lz = 0; lz < size; lz++) { for (let lx = 0; lx < size; lx++) { - const wx = ox + lx; - const wz = oz + lz; - const ci = lz * size + lx; - const h2 = h2col[ci]; - const biome = biomeCol[ci]; - // Topmost solid (non-air) in the column. let topY = -1; for (let y = CHUNK_HEIGHT - 1; y >= 0; y--) { - if (blocks[(y * size + lz) * size + lx] !== AIR) { + if (blocks[(y * size + lz) * size + lx] !== B.AIR) { topY = y; break; } } + const ci = lz * size + lx; surfY[ci] = topY; - if (topY < 1) continue; - - // Slope from neighbour base heights (continuous across chunks). - const hL = lx > 0 ? h2col[(lz) * size + (lx - 1)] : this.rawHeight2D(wx - 1, wz); - const hR = lx < size - 1 ? h2col[(lz) * size + (lx + 1)] : this.rawHeight2D(wx + 1, wz); - const hD = lz > 0 ? h2col[(lz - 1) * size + (lx)] : this.rawHeight2D(wx, wz - 1); - const hU = lz < size - 1 ? h2col[(lz + 1) * size + (lx)] : this.rawHeight2D(wx, wz + 1); - const slope = Math.max(Math.max(Math.abs(hL - h2), Math.abs(hR - h2)), Math.max(Math.abs(hD - h2), Math.abs(hU - h2))); - const steep = slope > CLIFF_SLOPE; - const cold = biome === "tundra" || biome === "taiga"; - const beach = topY <= SEA + 1; - - // Surface block. - let surf: BlockId; - if (beach) { - surf = topY >= SEA - 3 ? (biome === "desert" ? DESERT_SAND : SAND) : DIRT; - } else if (topY >= SNOW_LINE) { - surf = SNOW; - } else if (biome === "tundra" || biome === "taiga") { - surf = SNOWY_GRASS; - } else if (biome === "desert") { - surf = DESERT_SAND; - } else if (biome === "savanna") { - surf = DRY_GRASS; - } else if (biome === "rainforest") { - surf = JUNGLE_GRASS; - } else { - surf = GRASS; - } - if (steep && !beach) surf = biome === "desert" ? DESERT_STONE : STONE; // rocky cliff top - blocks[(topY * size + lz) * size + lx] = surf; - - // Subsoil (only overwrites stone-family, never ores/caves). - let sub: BlockId; - if (steep && !beach) sub = biome === "desert" ? DESERT_STONE : STONE; - else if (beach) sub = biome === "desert" ? DESERT_SAND : SAND; - else sub = DIRT; - for (let y = topY - 1; y >= topY - 3 && y >= 1; y--) { - const idx = (y * size + lz) * size + lx; - if (isStoneFamily(blocks[idx])) blocks[idx] = sub; - } - - // Water / ice fill above the surface up to sea level. - if (topY < SEA) { - for (let y = topY + 1; y <= SEA; y++) { - const idx = (y * size + lz) * size + lx; - if (blocks[idx] === AIR) blocks[idx] = cold && y === SEA ? ICE : WATER; - } - } } } - - // ---------- Phase 3: clustered decorations & trees ---------- for (let lz = 0; lz < size; lz++) { for (let lx = 0; lx < size; lx++) { + const ci = lz * size + lx; + const topY = surfY[ci]; + if (topY < 1) continue; const wx = ox + lx; const wz = oz + lz; + const sel = biomeCol[ci]; + // An "ocean"-biome column (2D height ≤ sea) whose dressed surface is at + // or above sea is a waterline cell — use the adjacent LAND biome so it + // gets a proper sandy shore instead of the ocean-floor material. For + // truly underwater columns decideSurface ignores .surface anyway. + const biome = sel.id === "ocean" ? BIOME_DEFS[sel.landId] : BIOME_DEFS[sel.id]; + const h2 = heightCol[ci]; + const slope = this.columnSlope(heightCol, lx, lz, wx, wz, size); + const nearWater = this.nearWaterRadius(surfY, lx, lz, wx, wz, size, 2); + // Biome-edge blend: near a climate boundary, mottle the surface toward + // the neighbour biome so the border is a band, not a line. + const blend = landBlend(sel.heat, sel.humidity); + const blendSurface = BIOME_DEFS[blend.second].surface; + const sctx: SurfaceCtx = { + blocks, + size, + lx, + lz, + wx, + wz, + topY, + slope, + biome, + effHeat: effHeatCol[ci], + height: h2, + nearWater, + blendEdge: blend.edge, + blendSurface, + }; + this.surface.paint(sctx); + this.surface.fillWater(sctx); + } + } + + // ---------- Phase 3: trees + ground decoration ---------- + let treesPlaced = 0; + let decoPlaced = 0; + const setIfAir: SetBlock = (lx, ly, lz, id) => { + if (lx < 0 || lx >= size || lz < 0 || lz >= size || ly < 0 || ly >= CHUNK_HEIGHT) return; + const idx = (ly * size + lz) * size + lx; + if (blocks[idx] === B.AIR) blocks[idx] = id; + }; + for (let lz = 0; lz < size; lz++) { + for (let lx = 0; lx < size; lx++) { const ci = lz * size + lx; const topY = surfY[ci]; - const biome = biomeCol[ci]; if (topY < 1) continue; + const wx = ox + lx; + const wz = oz + lz; + const sel = biomeCol[ci]; + const biome = BIOME_DEFS[sel.id]; + const above = topY + 1; + if (above >= CHUNK_HEIGHT) continue; + if (blocks[(above * size + lz) * size + lx] !== B.AIR) continue; const surface = blocks[(topY * size + lz) * size + lx]; - this.decorate(blocks, lx, lz, topY, surface, biome, wx, wz); + const slope = this.columnSlope(heightCol, lx, lz, wx, wz, size); + const nearWater = this.nearWaterRadius(surfY, lx, lz, wx, wz, size, 1); + + // Trees: clustered via grove noise, gated by soil, sea level & slope. + if ( + biome.treeDensity > 0 && + topY > SEA && + topY < CHUNK_HEIGHT - 14 && + slope < CLIFF_SLOPE && + this.isTreeSoil(surface, biome.id) && + lx >= 2 && + lx <= size - 3 && + lz >= 2 && + lz <= size - 3 + ) { + const grove = n.fbm2(wx * 0.03 + 700, wz * 0.03 + 700, 2) * 0.5 + 0.5; + const r01 = this.hash01(wx + 7, wz + 4); + if (r01 < biome.treeDensity * (0.3 + grove * 1.4)) { + const type = this.pickTree(biome.treeTypes, wx, wz); + this.placeTree(type, setIfAir, lx, above, lz, wx, wz, sel); + treesPlaced++; + continue; // a tree precludes ground cover in this column + } + } + + // Ground cover. + const placed = this.decorations.place( + { + lx, + lz, + topY, + surface, + biome, + landId: sel.landId, + wx, + wz, + nearWater, + }, + setIfAir, + ); + if (placed) decoPlaced++; } } chunk.generated = true; chunk.dirty = true; + + const ms = performance.now() - t0; + this.stats.record({ ms, decorations: decoPlaced, trees: treesPlaced, caves: caveCells, ores: oreCells }); } - /** Clustered decorations: groves/clearings via noise, biome-specific trees. */ - private decorate( - blocks: Uint8Array, + /** Slope of a column from its neighbours (cached height array + border fallback). */ + private columnSlope( + heightCol: Float32Array, lx: number, lz: number, - topY: number, - surface: BlockId, - biome: Biome, wx: number, wz: number, - ): void { - const above = topY + 1; - if (above >= CHUNK_HEIGHT - 1) return; - if (blocks[(above * CHUNK_SIZE + lz) * CHUNK_SIZE + lx] !== AIR) return; - const seed = this.seed; - const r01 = (a: number, b: number): number => hash2(wx + a, wz + b, seed); - // Cluster noises: high → grove/dense, low → clearing. - const grove = this.noise.fbm2(wx * 0.03 + 700, wz * 0.03 + 700, 2) * 0.5 + 0.5; - const flora = this.noise.fbm2(wx * 0.05 + 300, wz * 0.05 + 300, 2) * 0.5 + 0.5; - - const setIfAir = (x: number, y: number, z: number, id: BlockId): void => { - if (x < 0 || x >= CHUNK_SIZE || z < 0 || z >= CHUNK_SIZE || y < 0 || y >= CHUNK_HEIGHT) return; - const idx = (y * CHUNK_SIZE + z) * CHUNK_SIZE + x; - if (blocks[idx] === AIR) blocks[idx] = id; - }; - - const canTree = lx >= 2 && lx <= CHUNK_SIZE - 3 && lz >= 2 && lz <= CHUNK_SIZE - 3 && topY < CHUNK_HEIGHT - 12; - - if (biome === "desert") { - if (surface === DESERT_SAND && r01(3, 5) < 0.02 * (0.3 + grove)) { - const h = 2 + Math.floor(r01(9, 2) * 3); - for (let i = 0; i < h; i++) setIfAir(lx, above + i, lz, CACTUS); - } - return; - } - - // Cold biomes: sparse pines (denser in taiga). - if (biome === "taiga" || biome === "tundra") { - if ((surface === SNOWY_GRASS || surface === SNOW) && topY > SEA) { - const chance = biome === "taiga" ? 0.09 : 0.02; - if (canTree && r01(4, 8) < chance * (0.25 + grove * 1.3)) { - this.placePine(blocks, lx, above, lz, r01(11, 7)); - } - } - return; - } + size: number, + ): number { + const h2 = heightCol[lz * size + lx]; + const hL = lx > 0 ? heightCol[lz * size + (lx - 1)] : this.heightMap.height(wx - 1, wz); + const hR = lx < size - 1 ? heightCol[lz * size + (lx + 1)] : this.heightMap.height(wx + 1, wz); + const hD = lz > 0 ? heightCol[(lz - 1) * size + lx] : this.heightMap.height(wx, wz - 1); + const hU = lz < size - 1 ? heightCol[(lz + 1) * size + lx] : this.heightMap.height(wx, wz + 1); + return Math.max( + Math.abs(hL - h2), + Math.abs(hR - h2), + Math.abs(hD - h2), + Math.abs(hU - h2), + ); + } - // Rainforest: dense jungle canopy + undergrowth. - if (biome === "rainforest") { - if (surface === JUNGLE_GRASS && topY > SEA) { - if (canTree && r01(2, 4) < 0.12 * (0.3 + grove * 1.5)) { - this.placeJungle(blocks, lx, above, lz, r01(13, 9)); - return; - } - if (flora > 0.55 && r01(1, 1) < 0.3) setIfAir(lx, above, lz, TALL_GRASS); - else if (r01(8, 3) < 0.04) setIfAir(lx, above, lz, MUSHROOM); + /** True if a water column (dressed surface below sea) exists within Chebyshev + * `radius`. Uses the dressed `surfY` for in-chunk neighbours (accurate — it + * is what actually determines where water fills) and the 2D heightmap for + * out-of-chunk neighbours. Radius 2 gates beach width; radius 1 gates reeds. */ + private nearWaterRadius( + surfY: Int16Array, + lx: number, + lz: number, + wx: number, + wz: number, + size: number, + radius: number, + ): boolean { + for (let dz = -radius; dz <= radius; dz++) { + for (let dx = -radius; dx <= radius; dx++) { + if (dx === 0 && dz === 0) continue; + const nlx = lx + dx; + const nlz = lz + dz; + const isWater = + nlx >= 0 && nlx < size && nlz >= 0 && nlz < size + ? surfY[nlz * size + nlx] >= 0 && surfY[nlz * size + nlx] < SEA + : Math.floor(this.heightMap.height(wx + dx, wz + dz)) < SEA; + if (isWater) return true; } - return; } + return false; + } - // Savanna: flat-topped acacia + dry grass patches. - if (biome === "savanna") { - if (surface === DRY_GRASS && topY > SEA) { - if (canTree && r01(6, 2) < 0.018 * (0.3 + grove)) { - this.placeAcacia(blocks, lx, above, lz, r01(13, 9)); - return; + /** Approximate Chebyshev distance to the nearest water column (for the debug + * overlay). Uses the 2D heightmap; caps at `maxR`, returns maxR+1 if none. */ + shoreDistance(wx: number, wz: number, maxR = 6): number { + for (let r = 1; r <= maxR; r++) { + for (let dz = -r; dz <= r; dz++) { + for (let dx = -r; dx <= r; dx++) { + if (Math.abs(dx) !== r && Math.abs(dz) !== r) continue; // ring only + if (Math.floor(this.heightMap.height(wx + dx, wz + dz)) < SEA) return r; } - if (flora > 0.5 && r01(1, 1) < 0.18) setIfAir(lx, above, lz, TALL_GRASS); } - return; - } - - // Grassland & forest: oaks, grass tufts, flowers, mushrooms (forest). - if (surface !== GRASS || topY <= SEA) return; - const treeChance = (biome === "forest" ? 0.07 : 0.014) * (0.25 + grove * 1.4); - if (canTree && r01(7, 4) < treeChance) { - this.placeOak(blocks, lx, above, lz, r01(13, 9)); - return; - } - const roll = r01(1, 1); - if (roll < 0.16 * flora) { - setIfAir(lx, above, lz, TALL_GRASS); - } else if (roll < 0.19 * flora) { - setIfAir(lx, above, lz, r01(5, 5) < 0.5 ? FLOWER_RED : FLOWER_YELLOW); - } else if (biome === "forest" && roll < 0.21 * flora) { - setIfAir(lx, above, lz, MUSHROOM); } + return maxR + 1; } - /** A rounded oak-style tree (trunk + leafy canopy). */ - private placeOak(blocks: Uint8Array, lx: number, baseY: number, lz: number, r: number): void { - const trunk = 4 + Math.floor(r * 3); - const topY = baseY + trunk; - const set = (x: number, y: number, z: number, id: BlockId): void => { - if (x < 0 || x >= CHUNK_SIZE || z < 0 || z >= CHUNK_SIZE || y < 0 || y >= CHUNK_HEIGHT) return; - const idx = (y * CHUNK_SIZE + z) * CHUNK_SIZE + x; - if (blocks[idx] === AIR) blocks[idx] = id; - }; - for (let y = topY - 2; y <= topY + 1; y++) { - const radius = y <= topY - 1 ? 2 : 1; - for (let dz = -radius; dz <= radius; dz++) { - for (let dx = -radius; dx <= radius; dx++) { - if (dx === 0 && dz === 0 && y < topY) continue; - if (Math.abs(dx) === radius && Math.abs(dz) === radius && radius === 2) continue; - set(lx + dx, y, lz + dz, LEAVES); - } - } + private isTreeSoil(surface: number, biomeId: BiomeId): boolean { + switch (biomeId) { + case "grassland": + case "forest": + case "denseForest": + return surface === B.GRASS; + case "savanna": + return surface === B.DRY_GRASS; + case "rainforest": + return surface === B.JUNGLE_GRASS; + case "taiga": + return surface === B.SNOWY_GRASS || surface === B.SNOW; + case "tundra": + return surface === B.SNOWY_GRASS; + case "mountain": + return surface === B.SNOWY_GRASS || surface === B.GRASS; + default: + return false; } - for (let y = baseY; y < topY; y++) set(lx, y, lz, WOOD); } - /** A conical pine tree (tall trunk + tapering leaf spire). */ - private placePine(blocks: Uint8Array, lx: number, baseY: number, lz: number, r: number): void { - const trunk = 6 + Math.floor(r * 4); - const topY = baseY + trunk; - const set = (x: number, y: number, z: number, id: BlockId): void => { - if (x < 0 || x >= CHUNK_SIZE || z < 0 || z >= CHUNK_SIZE || y < 0 || y >= CHUNK_HEIGHT) return; - const idx = (y * CHUNK_SIZE + z) * CHUNK_SIZE + x; - if (blocks[idx] === AIR) blocks[idx] = id; - }; - for (let y = baseY + 3; y <= topY; y++) { - const t = (y - baseY) / trunk; - const radius = t > 0.7 ? 0 : t > 0.45 ? 1 : 2; - for (let dz = -radius; dz <= radius; dz++) { - for (let dx = -radius; dx <= radius; dx++) { - if (radius === 2 && Math.abs(dx) === 2 && Math.abs(dz) === 2) continue; - set(lx + dx, y, lz + dz, LEAVES); - } - } - } - set(lx, topY + 1, lz, LEAVES); - for (let y = baseY; y < topY; y++) set(lx, y, lz, WOOD); + private pickTree(types: TreeType[], wx: number, wz: number): TreeType { + if (types.length === 1) return types[0]; + return types[Math.floor(this.hash01(wx + 1, wz + 1) * types.length) % types.length]; } - /** A tall jungle tree with a wide, blobby dark canopy. */ - private placeJungle(blocks: Uint8Array, lx: number, baseY: number, lz: number, r: number): void { - const trunk = 7 + Math.floor(r * 4); - const topY = baseY + trunk; - const set = (x: number, y: number, z: number, id: BlockId): void => { - if (x < 0 || x >= CHUNK_SIZE || z < 0 || z >= CHUNK_SIZE || y < 0 || y >= CHUNK_HEIGHT) return; - const idx = (y * CHUNK_SIZE + z) * CHUNK_SIZE + x; - if (blocks[idx] === AIR) blocks[idx] = id; - }; - for (let y = topY - 3; y <= topY + 1; y++) { - const radius = y <= topY - 1 ? 3 : 2; - for (let dz = -radius; dz <= radius; dz++) { - for (let dx = -radius; dx <= radius; dx++) { - if (dx === 0 && dz === 0 && y < topY) continue; - if (radius === 3 && (Math.abs(dx) + Math.abs(dz)) > 4) continue; // round - set(lx + dx, y, lz + dz, JUNGLE_LEAVES); - } - } + private placeTree( + type: TreeType, + set: SetBlock, + lx: number, + above: number, + lz: number, + wx: number, + wz: number, + sel: BiomeSelection, + ): void { + switch (type) { + case "oak": + this.trees.placeOak(set, lx, above, lz, wx, wz); + break; + case "birch": + this.trees.placeBirch(set, lx, above, lz, wx, wz); + break; + case "pine": + this.trees.placePine(set, lx, above, lz, wx, wz); + break; + case "spruce": + this.trees.placeSpruce(set, lx, above, lz, wx, wz); + break; + case "jungle": + this.trees.placeJungle(set, lx, above, lz, wx, wz); + break; + case "acacia": + this.trees.placeAcacia(set, lx, above, lz, wx, wz); + break; + default: + void sel; + break; } - for (let y = baseY; y < topY; y++) set(lx, y, lz, WOOD); } - /** A short acacia with a flat umbrella canopy. */ - private placeAcacia(blocks: Uint8Array, lx: number, baseY: number, lz: number, r: number): void { - const trunk = 3 + Math.floor(r * 3); - const topY = baseY + trunk; - const set = (x: number, y: number, z: number, id: BlockId): void => { - if (x < 0 || x >= CHUNK_SIZE || z < 0 || z >= CHUNK_SIZE || y < 0 || y >= CHUNK_HEIGHT) return; - const idx = (y * CHUNK_SIZE + z) * CHUNK_SIZE + x; - if (blocks[idx] === AIR) blocks[idx] = id; - }; - const radius = 2 + Math.floor(r * 2); - for (let y = topY; y <= topY + 1; y++) { - for (let dz = -radius; dz <= radius; dz++) { - for (let dx = -radius; dx <= radius; dx++) { - if (Math.abs(dx) === radius && Math.abs(dz) === radius) continue; // round - set(lx + dx, y, lz + dz, LEAVES); - } - } - } - for (let y = baseY; y < topY; y++) set(lx, y, lz, WOOD); + private hash01(x: number, z: number): number { + let h = 374761393; + const s = `${x},${z},${this.seed}`; + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 668265263); + h = (h ^ (h >>> 13)) >>> 0; + return h / 0x100000000; } } @@ -508,3 +505,6 @@ export function findGroundY(chunk: Chunk, lx: number, lz: number): number { } return 0; } + +// Re-exports for the debug overlay / console API. +export { landBiome, landBlend, type BiomeId }; diff --git a/src/game/gen/Biomes.ts b/src/game/gen/Biomes.ts new file mode 100644 index 0000000..b5eaa2a --- /dev/null +++ b/src/game/gen/Biomes.ts @@ -0,0 +1,423 @@ +import * as B from "./BlockIds"; + +// Biome system, modelled on Minetest/Luanti mapgen v7: biomes are selected from +// heat/humidity climate coordinates (nearest-point / Voronoi in climate space) +// with altitude chill and elevation overrides (ocean → beach → land → mountain). +// +// Each biome is a data record carrying its full surface palette and decoration +// hints, so the terrain, surface-painter, tree and decoration passes all read +// from one declarative source (easy to extend with new biomes). + +export type BiomeId = + | "ocean" + | "beach" + | "grassland" + | "forest" + | "denseForest" + | "savanna" + | "rainforest" + | "taiga" + | "tundra" + | "desert" + | "mountain" + | "snowyMountain"; + +/** Tree archetypes the tree generator knows how to build. */ +export type TreeType = "oak" | "birch" | "pine" | "spruce" | "jungle" | "acacia"; + +export interface BiomeDef { + id: BiomeId; + /** Climate coordinate in [0,1] (only meaningful for land biomes). */ + heatPoint: number; + humidityPoint: number; + /** Top surface block on gentle ground. */ + surface: number; + /** Subsurface (a few blocks below the surface). */ + filler: number; + /** Deep stone. */ + stone: number; + /** Block beaches/shores use in this climate. */ + beachBlock: number; + /** Blocks above sea level still treated as shore. */ + beachWidth: number; + /** 0..1 — how likely this biome's shore is sandy at all (beach "presence" + * tendency). Desert ~1 (always sand), forest/snow ~0.2 (grassy/snowy banks + * with only small sand pockets). Breaks the uniform sand ring. Optional; + * resolved via {@link tendencyFor} when omitted. */ + beachTendency?: number; + /** Base tree probability multiplier (0 = none). */ + treeDensity: number; + treeTypes: TreeType[]; + /** Ground-cover densities (0..1+). */ + grassDensity: number; + flowerDensity: number; + shrubDensity: number; + /** Whether the surface receives a snow cover. */ + snowy: boolean; + /** Top water layer in cold climates (ice) — 0 = none. */ + waterTop: number; + /** Debug/minimap colour (hex). */ + color: string; +} + +const OCEAN: BiomeDef = { + id: "ocean", + heatPoint: 0.5, + humidityPoint: 0.5, + surface: B.GRAVEL, + filler: B.DIRT, + stone: B.STONE, + beachBlock: B.SAND, + beachWidth: 0, + treeDensity: 0, + treeTypes: [], + grassDensity: 0, + flowerDensity: 0, + shrubDensity: 0, + snowy: false, + waterTop: 0, + color: "#2a4d7a", +}; + +export const BIOME_DEFS: Record = { + ocean: OCEAN, + beach: { + id: "beach", + heatPoint: 0.5, + humidityPoint: 0.4, + surface: B.SAND, + filler: B.SAND, + stone: B.STONE, + beachBlock: B.SAND, + beachWidth: 3, + treeDensity: 0, + treeTypes: [], + grassDensity: 0.04, + flowerDensity: 0, + shrubDensity: 0, + snowy: false, + waterTop: 0, + color: "#e0d096", + }, + grassland: { + id: "grassland", + heatPoint: 0.45, + humidityPoint: 0.35, + surface: B.GRASS, + filler: B.DIRT, + stone: B.STONE, + beachBlock: B.SAND, + beachWidth: 2, + treeDensity: 0.012, + treeTypes: ["oak", "birch"], + grassDensity: 0.6, + flowerDensity: 0.18, + shrubDensity: 0.06, + snowy: false, + waterTop: 0, + color: "#5fa84a", + }, + forest: { + id: "forest", + heatPoint: 0.5, + humidityPoint: 0.6, + surface: B.GRASS, + filler: B.DIRT, + stone: B.STONE, + beachBlock: B.SAND, + beachWidth: 1, + treeDensity: 0.06, + treeTypes: ["oak", "birch"], + grassDensity: 0.5, + flowerDensity: 0.12, + shrubDensity: 0.1, + snowy: false, + waterTop: 0, + color: "#2f7a34", + }, + denseForest: { + id: "denseForest", + heatPoint: 0.58, + humidityPoint: 0.82, + surface: B.GRASS, + filler: B.DIRT, + stone: B.MOSSY_STONE, + beachBlock: B.SAND, + beachWidth: 1, + treeDensity: 0.11, + treeTypes: ["oak", "birch", "oak"], + grassDensity: 0.45, + flowerDensity: 0.1, + shrubDensity: 0.16, + snowy: false, + waterTop: 0, + color: "#235e2a", + }, + savanna: { + id: "savanna", + heatPoint: 0.72, + humidityPoint: 0.28, + surface: B.DRY_GRASS, + filler: B.DIRT, + stone: B.STONE, + beachBlock: B.SAND, + beachWidth: 2, + treeDensity: 0.02, + treeTypes: ["acacia"], + grassDensity: 0.35, + flowerDensity: 0.03, + shrubDensity: 0.04, + snowy: false, + waterTop: 0, + color: "#b6a04e", + }, + rainforest: { + id: "rainforest", + heatPoint: 0.82, + humidityPoint: 0.85, + surface: B.JUNGLE_GRASS, + filler: B.DIRT, + stone: B.MOSSY_STONE, + beachBlock: B.SAND, + beachWidth: 1, + treeDensity: 0.13, + treeTypes: ["jungle"], + grassDensity: 0.6, + flowerDensity: 0.06, + shrubDensity: 0.12, + snowy: false, + waterTop: 0, + color: "#1c4a20", + }, + taiga: { + id: "taiga", + heatPoint: 0.22, + humidityPoint: 0.55, + surface: B.SNOWY_GRASS, + filler: B.DIRT, + stone: B.STONE, + beachBlock: B.SAND, + beachWidth: 1, + treeDensity: 0.08, + treeTypes: ["spruce", "pine"], + grassDensity: 0.2, + flowerDensity: 0.02, + shrubDensity: 0.04, + snowy: true, + waterTop: B.ICE, + color: "#5b7a8a", + }, + tundra: { + id: "tundra", + heatPoint: 0.15, + humidityPoint: 0.3, + surface: B.SNOW, + filler: B.DIRT, + stone: B.STONE, + beachBlock: B.GRAVEL, + beachWidth: 1, + treeDensity: 0.015, + treeTypes: ["spruce"], + grassDensity: 0.08, + flowerDensity: 0.01, + shrubDensity: 0.02, + snowy: true, + waterTop: B.ICE, + color: "#dfe6f2", + }, + desert: { + id: "desert", + heatPoint: 0.85, + humidityPoint: 0.12, + surface: B.DESERT_SAND, + filler: B.DESERT_SAND, + stone: B.DESERT_STONE, + beachBlock: B.DESERT_SAND, + beachWidth: 4, + treeDensity: 0, + treeTypes: [], + grassDensity: 0, + flowerDensity: 0, + shrubDensity: 0.04, + snowy: false, + waterTop: 0, + color: "#e2c67a", + }, + mountain: { + id: "mountain", + heatPoint: 0.4, + humidityPoint: 0.4, + surface: B.STONE, + filler: B.STONE, + stone: B.STONE, + beachBlock: B.GRAVEL, + beachWidth: 1, + treeDensity: 0.01, + treeTypes: ["spruce"], + grassDensity: 0.05, + flowerDensity: 0.01, + shrubDensity: 0.02, + snowy: false, + waterTop: 0, + color: "#8a8a8e", + }, + snowyMountain: { + id: "snowyMountain", + heatPoint: 0.1, + humidityPoint: 0.4, + surface: B.SNOW, + filler: B.STONE, + stone: B.STONE, + beachBlock: B.GRAVEL, + beachWidth: 1, + treeDensity: 0, + treeTypes: [], + grassDensity: 0, + flowerDensity: 0, + shrubDensity: 0, + snowy: true, + waterTop: B.ICE, + color: "#c0cfdc", + }, +}; + +/** Land biomes that compete in climate-space selection. */ +const LAND_BIOMES: readonly BiomeId[] = [ + "tundra", + "taiga", + "grassland", + "forest", + "denseForest", + "savanna", + "rainforest", + "desert", +]; + +/** Squared distance in climate space (humidity weighted a touch less than heat). */ +function climateDistance(heat: number, humidity: number, def: BiomeDef): number { + const dh = heat - def.heatPoint; + const dm = humidity - def.humidityPoint; + return dh * dh + dm * dm * 0.85; +} + +/** Nearest climate-selectable land biome (ignoring elevation). */ +export function landBiome(heat: number, humidity: number): BiomeId { + let best = LAND_BIOMES[0]; + let bestD = Infinity; + for (let i = 0; i < LAND_BIOMES.length; i++) { + const id = LAND_BIOMES[i]; + const d = climateDistance(heat, humidity, BIOME_DEFS[id]); + if (d < bestD) { + bestD = d; + best = id; + } + } + return best; +} + +/** Second-nearest land biome + how close it was (0 = far, 1 = tied). Used for + * edge-blend jitter so biome borders aren't hard straight lines. */ +export function landBlend(heat: number, humidity: number): { second: BiomeId; edge: number } { + let best = LAND_BIOMES[0]; + let second = LAND_BIOMES[1 % LAND_BIOMES.length]; + let bestD = Infinity; + let secondD = Infinity; + for (let i = 0; i < LAND_BIOMES.length; i++) { + const id = LAND_BIOMES[i]; + const d = climateDistance(heat, humidity, BIOME_DEFS[id]); + if (d < bestD) { + secondD = bestD; + second = best; + bestD = d; + best = id; + } else if (d < secondD) { + secondD = d; + second = id; + } + } + // edge ≈ 1 when the two nearest biomes are nearly equidistant. + const edge = bestD < 1e-6 ? 1 : Math.max(0, 1 - Math.sqrt(secondD) / (Math.sqrt(bestD) + 0.001)); + return { second, edge: edge < 0 ? 0 : edge > 1 ? 1 : edge }; +} + +export function def(id: BiomeId): BiomeDef { + return BIOME_DEFS[id]; +} + +/** + * Resolve a biome's beach "presence" tendency in [0,1] — the probability that a + * shore segment in this biome gets sand at all (vs. grassy/dirt/snowy banks). + * Desert shores are almost always sandy; forest/snow/mountain shores are mostly + * earthen with only small sand pockets. This is what breaks the uniform sand + * ring around lakes/coasts: low-frequency noise gates beach *presence*, not just + * width. + */ +export function tendencyFor(id: BiomeId): number { + const explicit = BIOME_DEFS[id].beachTendency; + if (explicit !== undefined) return explicit; + switch (id) { + case "desert": + return 1.0; + case "savanna": + return 0.7; + case "grassland": + return 0.6; + case "rainforest": + return 0.3; + case "forest": + case "denseForest": + return 0.24; + case "taiga": + return 0.2; + case "tundra": + case "mountain": + return 0.14; + case "snowyMountain": + return 0.1; + default: + return 0.3; + } +} + +export interface BiomeSelection { + id: BiomeId; + /** Underlying land biome chosen by climate (before elevation overrides). */ + landId: BiomeId; + heat: number; + humidity: number; +} + +/** + * Full elevation-aware biome selection, mirroring Luanti's layering: + * ocean → (climate land biome) → mountain → snowyMountain, with the land biome + * chosen by nearest climate point and altitude chill sliding it toward cold. + * + * There is intentionally NO broad "beach" biome band: sand around water is a + * shoreline *effect* decided by the surface painter from real water proximity + * (see Surface.decideSurface), so low inland plains stay grassy/snowy instead + * of turning into giant sand flats. + */ +export function selectBiome( + heat: number, + humidity: number, + height: number, + seaLevel: number, + rockLine: number, + snowLine: number, +): BiomeSelection { + const land = landBiome(heat, humidity); + if (height <= seaLevel) { + return { id: "ocean", landId: land, heat, humidity }; + } + if (height >= snowLine) { + return { id: "snowyMountain", landId: land, heat, humidity }; + } + // High rocky zone: above the rock line the climate biome gives way to bare + // mountain, unless it is already cold/snowy (taiga/tundra) in which case the + // snowy identity reads more naturally. + if (height >= rockLine) { + const cold = heat < 0.3; + return { id: cold ? "snowyMountain" : "mountain", landId: land, heat, humidity }; + } + return { id: land, landId: land, heat, humidity }; +} diff --git a/src/game/gen/BlockIds.ts b/src/game/gen/BlockIds.ts new file mode 100644 index 0000000..2e43a98 --- /dev/null +++ b/src/game/gen/BlockIds.ts @@ -0,0 +1,44 @@ +// Numeric block-id constants used by world generation. These mirror the ids +// assigned in Blocks.ts (BLOCKS[] is positional). Centralising them here keeps +// the gen modules free of magic numbers and makes the terrain palette easy to +// audit at a glance. IDs MUST stay stable (chunk data stores raw ids). + +export const AIR = 0; +export const GRASS = 1; +export const DIRT = 2; +export const STONE = 3; +export const SAND = 4; +export const WOOD = 5; +export const LEAVES = 6; +export const WATER = 7; +export const BEDROCK = 8; +export const SNOW = 9; +export const SNOWY_GRASS = 10; +export const ICE = 11; +export const DESERT_SAND = 12; +export const DESERT_STONE = 13; +export const SANDSTONE = 14; +export const GRAVEL = 15; +export const COAL_ORE = 16; +export const IRON_ORE = 17; +export const COPPER_ORE = 18; +export const CACTUS = 19; +export const TALL_GRASS = 20; +export const FLOWER_RED = 21; +export const FLOWER_YELLOW = 22; +export const MUSHROOM = 23; +export const DRY_GRASS = 24; +export const JUNGLE_GRASS = 25; +export const JUNGLE_LEAVES = 26; +export const MOSSY_STONE = 27; +// (28 Glowstone, 29 Flowing Water — not placed by terrain gen.) +export const DEAD_BUSH = 30; +export const FERN = 31; +export const PAPYRUS = 32; +export const CORNFLOWER = 33; +export const BIRCH_WOOD = 34; +export const BIRCH_LEAVES = 35; +export const SPRUCE_LEAVES = 36; +export const SNOWY_LEAVES = 37; + +export type BlockId = number; diff --git a/src/game/gen/Caves.ts b/src/game/gen/Caves.ts new file mode 100644 index 0000000..eda94ce --- /dev/null +++ b/src/game/gen/Caves.ts @@ -0,0 +1,60 @@ +import type { Noise } from "../../engine/Noise"; + +// Cave generation, modelled on Luanti's mapgen v7 cave system: +// +// • Worm tunnels — two 3D noises intersected: a column is air where both lie +// within narrow bands, producing winding tubes (the classic "two noises" +// technique). A slow noise varies the tube width so tunnels breathe. +// • Caverns — a low-frequency 3D fbm above a high threshold, only deep down, +// yielding rare large chambers. +// +// Carving respects a surface crust so caves don't gut the dressed terrain, and +// avoids caves beneath shallow/ocean columns (the liquid sim has no pressure +// model — see AGENTS.md). Cave *entrances* are allowed on hills via a noise +// gate so the underground occasionally breaches the surface. + +export class CaveGenerator { + constructor(private readonly noise: Noise) {} + + /** + * @param height dressed 2D surface height for the column + * @param sea sea level + * @returns true if the voxel should be carved to air + */ + isCarved(wx: number, y: number, wz: number, height: number, sea: number): boolean { + if (y < 2) return false; + if (y > height + 3) return false; + // Keep caves out of shallow / ocean columns (no pressure-based flow sim). + if (height <= sea + 1 && y < sea) return false; + + const n = this.noise; + + // --- Worm tunnels (intersecting bands) --- + const a = n.noise3(wx * 0.04, y * 0.075, wz * 0.04); + const b = n.noise3(wx * 0.04 + 100, y * 0.05 + 100, wz * 0.04 + 100); + // Slow width modulation: deeper → slightly wider tunnels. + const depthFrac = y < sea ? 1 : Math.max(0, 1 - (height - y) / 40); + const half = 0.062 + depthFrac * 0.02; + const tube = 0.3; + if (Math.abs(a) < half && Math.abs(b) < tube) { + // Surface crust: don't carve the top few blocks unless this is a hill with + // an "entrance" gate (so caves sometimes open onto the surface). + if (y > height - 5) { + const entrance = n.noise2(wx * 0.13 + 5, wz * 0.13 + 5); + if (height < sea + 14 || entrance < 0.5) return false; + } + return true; + } + + // --- Caverns (rare, deep) --- + if (y < sea - 14 && y > 2) { + const c = n.fbm3(wx * 0.018 + 50, y * 0.025 + 50, wz * 0.018 + 50, 2); + if (c > 0.6) { + // Preserve the crust even for caverns near the surface. + if (y > height - 6) return false; + return true; + } + } + return false; + } +} diff --git a/src/game/gen/Climate.ts b/src/game/gen/Climate.ts new file mode 100644 index 0000000..8eb08ee --- /dev/null +++ b/src/game/gen/Climate.ts @@ -0,0 +1,57 @@ +import type { Noise } from "../../engine/Noise"; + +// Climate maps: heat (temperature) and humidity. These are the two signals +// Minetest/Luanti mapgen uses to select biomes — each registered biome declares +// a (heat_point, humidity_point) and the column is assigned to the biome whose +// point is nearest in climate space (see Biomes.ts). +// +// Deliberately single-octave, very-low-frequency (~0.0008) so biomes stay large +// and coherent. Raising octaves/frequency re-fragments them into tiny patches +// (a well-known Luanti pitfall). See AGENTS.md. + +export interface Climate { + /** Temperature in [0,1]. 0 = freezing, 1 = scorching. */ + heat: number; + /** Moisture in [0,1]. 0 = arid, 1 = drenched. */ + humidity: number; +} + +// The Noise impl's fbm2(1 octave) has standard deviation ≈ 0.27 (measured over +// many seeds; it is a property of the gradient set, not the seed). A naive +// 0.5 + 0.5*v mapping only covers ~[0.15, 0.76], so hot biomes (desert) could +// never be selected. Dividing by ~2·sd spreads the empirical range across the +// full [0,1] so every climate point is reachable. +const FBM_SD = 0.27; +function climate01(v: number): number { + const x = 0.5 + v / (2 * FBM_SD); + return x < 0 ? 0 : x > 1 ? 1 : x; +} + +export class ClimateMaps { + constructor(private readonly noise: Noise) {} + + /** Raw heat/humidity for a world column (before altitude chill). */ + base(wx: number, wz: number): Climate { + const n = this.noise; + // Distinct offsets keep heat and humidity independent. The very low + // frequency yields biomes hundreds of blocks across. + const heat = climate01(n.fbm2(wx * 0.00075 + 500, wz * 0.00075 + 500, 1)); + const humidity = climate01(n.fbm2(wx * 0.00075, wz * 0.00075 + 900, 1)); + return { heat, humidity }; + } + + /** + * Altitude chill (Minetest "valleys" mapgen concept): temperature falls with + * elevation so cold biomes climb the mountains rather than appearing as random + * flat patches. Chill is gentle and only kicks in well above sea level, so + * rolling lowlands keep their climate biome and only genuine highlands trend + * cold. Returns the effective heat in [0,1]. + */ + static effectiveHeat(heat: number, height: number, seaLevel: number, snowLine: number): number { + const chillStart = seaLevel + 8; + if (height <= chillStart) return heat; + const span = Math.max(8, snowLine - chillStart); + const t = Math.min(1, (height - chillStart) / span); + return heat - t * 0.45; + } +} diff --git a/src/game/gen/Decorations.ts b/src/game/gen/Decorations.ts new file mode 100644 index 0000000..ad99e94 --- /dev/null +++ b/src/game/gen/Decorations.ts @@ -0,0 +1,128 @@ +import type { Noise } from "../../engine/Noise"; +import type { BiomeDef, BiomeId } from "./Biomes"; +import * as B from "./BlockIds"; + +// Ground-cover decoration pass: tall grass, flowers, ferns, mushrooms, dead +// bushes, papyrus. Coverage is clustered via two fbm "grove"/"flora" noises so +// the surface forms natural patches and clearings instead of an even speckle +// (Minetest decorations use noise `fill_ratio`/`noise_params` the same way). +// +// Everything is plantlike (cutout pass) and bounds-checked via `set`, so it adds +// no draw calls beyond the chunk's existing cutout mesh — distance culling +// (World foliage setting) keeps far decoration cheap. + +export type SetBlock = (lx: number, ly: number, lz: number, id: number) => void; + +export interface DecoColumn { + lx: number; + lz: number; + topY: number; + surface: number; + biome: BiomeDef; + landId: BiomeId; + wx: number; + wz: number; + /** True if an adjacent column is water (for reeds/papyrus). */ + nearWater: boolean; +} + +function hash01(x: number, z: number, seed: string, salt: number): number { + let h = 374761393 ^ salt; + const s = `${x},${z},${seed}`; + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 668265263); + h = (h ^ (h >>> 13)) >>> 0; + return h / 0x100000000; +} + +export class DecorationGenerator { + constructor( + private readonly noise: Noise, + private readonly seed: string, + ) {} + + private r(x: number, z: number, salt: number): number { + return hash01(x, z, this.seed, salt); + } + + /** Try to place ground cover at a dressed column. Returns true if it placed something. */ + place(col: DecoColumn, set: SetBlock): boolean { + const { lx, lz, topY, surface, biome, wx, wz } = col; + const above = topY + 1; + if (above <= 0) return false; + const n = this.noise; + + // Cluster signals: grove = tree-friendly, flora = herb-friendly. + const grove = n.fbm2(wx * 0.03 + 700, wz * 0.03 + 700, 2) * 0.5 + 0.5; + const flora = n.fbm2(wx * 0.05 + 300, wz * 0.05 + 300, 2) * 0.5 + 0.5; + const f = flora * (0.5 + grove * 0.7); + + // Desert: dead bushes + (rare) cactus only. + if (biome.id === "desert" || surface === B.DESERT_SAND) { + if (this.r(wx, wz, 41) < 0.03 * (0.3 + grove)) { + set(lx, above, lz, B.DEAD_BUSH); + return true; + } + if (this.r(wx, wz, 42) < 0.012 * (0.3 + grove)) { + const h = 2 + Math.floor(this.r(wx, wz, 43) * 3); + for (let i = 0; i < h; i++) set(lx, above + i, lz, B.CACTUS); + return true; + } + return false; + } + + // Papyrus / reeds at the water's edge. + if (col.nearWater && this.r(wx, wz, 44) < 0.18) { + const h = 2 + Math.floor(this.r(wx, wz, 45) * 2); + for (let i = 0; i < h; i++) set(lx, above + i, lz, B.PAPYRUS); + return true; + } + + // Snowy/cold surfaces: very sparse cover (snow already coats the ground). + if (biome.id === "tundra" || biome.id === "snowyMountain") { + if (surface === B.SNOWY_GRASS && this.r(wx, wz, 46) < 0.04 * f) { + set(lx, above, lz, B.DEAD_BUSH); + return true; + } + return false; + } + + // Grass-eligible surfaces (grass / dry grass / jungle grass / snowy grass). + const grassy = + surface === B.GRASS || + surface === B.DRY_GRASS || + surface === B.JUNGLE_GRASS || + surface === B.SNOWY_GRASS; + if (!grassy) return false; + + const roll = this.r(wx, wz, 47); + const grassP = biome.grassDensity * f * 0.8; + if (roll < grassP) { + // Forest/denseForest get ferns mixed into the grass. + if ((biome.id === "forest" || biome.id === "denseForest") && this.r(wx, wz, 48) < 0.25) { + set(lx, above, lz, B.FERN); + } else { + set(lx, above, lz, B.TALL_GRASS); + } + return true; + } + const flowerP = biome.flowerDensity * f; + if (roll < grassP + flowerP) { + const fr = this.r(wx, wz, 49); + set(lx, above, lz, fr < 0.4 ? B.FLOWER_RED : fr < 0.7 ? B.FLOWER_YELLOW : B.CORNFLOWER); + return true; + } + const shrubP = biome.shrubDensity * f * 0.6; + if (roll < grassP + flowerP + shrubP) { + // Forests favour ferns; savanna/rainforest favour mushrooms. + if (biome.id === "rainforest" && this.r(wx, wz, 50) < 0.4) { + set(lx, above, lz, B.MUSHROOM); + } else if (biome.id === "forest" || biome.id === "denseForest") { + set(lx, above, lz, B.FERN); + } else { + set(lx, above, lz, B.TALL_GRASS); + } + return true; + } + return false; + } +} diff --git a/src/game/gen/Ores.ts b/src/game/gen/Ores.ts new file mode 100644 index 0000000..637ca25 --- /dev/null +++ b/src/game/gen/Ores.ts @@ -0,0 +1,39 @@ +import type { Noise } from "../../engine/Noise"; +import * as B from "./BlockIds"; + +// Ore + stone-variation generation. Ores form blob/vein clusters (Luanti +// "scatter" ore type) selected by depth: coal near the surface, iron mid, copper +// deep. Each is a 3D fbm threshold so veins are connected blobs, not speckle. +// Gravel pockets and sandstone strata add underground texture. Returns 0 (no +// override) when nothing fires, leaving the host stone in place. + +export class OreGenerator { + constructor(private readonly noise: Noise) {} + + /** Sedimentary strata: occasional sandstone/gravel bands in stone. */ + stratumBlock(wx: number, y: number, wz: number): number { + const n = this.noise; + const s = n.fbm3(wx * 0.03 + 500, y * 0.06 + 500, wz * 0.03 + 500, 2); + if (s > 0.34) return B.SANDSTONE; + if (s < -0.4) return B.GRAVEL; + return 0; + } + + /** + * Ore vein id for a voxel, or 0. Depth gating (y) keeps the progression + * coal → iron → copper with depth; thresholds sit in the fbm upper tail so + * veins are modest clusters. + */ + oreAt(wx: number, y: number, wz: number): number { + const n = this.noise; + // Coal: shallow-to-mid, the most common. + if (y > 6 && n.fbm3(wx * 0.07, y * 0.07, wz * 0.07, 2) > 0.42) return B.COAL_ORE; + // Iron: mid-depth. + if (y > 4 && y < 64 && n.fbm3(wx * 0.085 + 30, y * 0.085 + 30, wz * 0.085 + 30, 2) > 0.5) return B.IRON_ORE; + // Copper: deeper, rarer. + if (y > 2 && y < 40 && n.fbm3(wx * 0.1 + 60, y * 0.1 + 60, wz * 0.1 + 60, 2) > 0.56) return B.COPPER_ORE; + // Scattered gravel pockets (small) for underground texture. + if (y > 2 && n.fbm3(wx * 0.12 + 90, y * 0.12 + 90, wz * 0.12 + 90, 2) > 0.62) return B.GRAVEL; + return 0; + } +} diff --git a/src/game/gen/Surface.ts b/src/game/gen/Surface.ts new file mode 100644 index 0000000..3c0df44 --- /dev/null +++ b/src/game/gen/Surface.ts @@ -0,0 +1,248 @@ +import type { Noise } from "../../engine/Noise"; +import { tendencyFor, type BiomeDef } from "./Biomes"; +import * as B from "./BlockIds"; + +// Coastal + surface dressing, modelled on Luanti mapgen shoreline behaviour and +// biome blending: +// +// • Beaches are a *proximity* effect (water within the shore radius) whose +// WIDTH is noise-modulated and slope-reduced, so the shoreline is an +// irregular, varying band — never a uniform painted ring around every +// lake/river/ocean. +// • Stone exposure is elevation-aware (near sea a slope must be a genuine +// cliff to bare rock), and filler depth shrinks on steep slopes so cliff +// faces expose stone naturally (Luanti `depth_filler`). +// • Underwater shelves grade sand → gravel → dirt with depth. +// • Snow is a CONTINUOUS temperature mask (altitude chill + low-frequency +// meander), independent of the discrete biome id, so it tapers across +// climate gradients instead of snapping at a biome boundary line. +// • Near climate boundaries the surface is mottled toward the neighbour +// biome (Luanti "biomeblend") so biome borders read as blend bands. + +/** Underwater sand depth (blocks below sea that stay sandy). */ +const SHALLOW_SAND = 4; +/** Extra gravel band below the sand shelf before the deep seabed. */ +const SHELF_GRAVEL = 3; +/** Rocky-cliff threshold at sea level (very steep — real cliffs only). */ +const ROCKY_AT_SEA = 5.5; +/** Rocky-cliff threshold high up (normal mountain cliff sensitivity). */ +const ROCKY_AT_HIGH = 2.6; +/** Elevation (blocks above sea) at which the high rocky threshold fully applies. */ +const ROCKY_HIGH_BAND = 36; + +export interface SurfaceCtx { + blocks: Uint8Array; + size: number; + lx: number; + lz: number; + /** World coords — required so noise is continuous across chunk borders. */ + wx: number; + wz: number; + topY: number; + /** Max |neighbour topY - topY| — true dressed-surface slope. */ + slope: number; + biome: BiomeDef; + effHeat: number; + height: number; + /** A water column exists within the shore radius (bank/shore awareness). */ + nearWater: boolean; + /** 0..1 — how close the column is to a climate-biome boundary. */ + blendEdge: number; + /** Surface block of the neighbour biome (for edge mottling). */ + blendSurface: number; +} + +/** Classification shared with the debug overlay (pure — no chunk mutation). */ +export interface SurfaceDecision { + surface: number; + filler: number; + /** Filler depth in blocks (shrinks on steep slopes so cliffs expose stone). */ + fillDepth: number; + coastal: boolean; + rocky: boolean; + underwater: boolean; + /** Shelf label for the debug minimap. */ + shelf: "beach" | "shallow" | "deep" | "rock" | "land"; +} + +/** Elevation-aware slope above which bare stone shows. */ +function rockyThreshold(above: number): number { + const t = above <= 0 ? 0 : above >= ROCKY_HIGH_BAND ? 1 : above / ROCKY_HIGH_BAND; + return ROCKY_AT_SEA + (ROCKY_AT_HIGH - ROCKY_AT_SEA) * t; +} + +/** Stone-family ids that soil dressing may overwrite (never ores/air/water). */ +function isStoneFamily(id: number): boolean { + return ( + id === B.STONE || + id === B.DESERT_STONE || + id === B.SANDSTONE || + id === B.GRAVEL || + id === B.MOSSY_STONE + ); +} + +function clamp01(v: number): number { + return v < 0 ? 0 : v > 1 ? 1 : v; +} + +/** + * Patch-based beach decision. `beachStrength` is a low-frequency noise in + * [0,1] (large patches). A shore segment gets sand ONLY where the strength + * exceeds `1 − tendency`: desert (tendency 1) is always sandy, while + * forest/snow/mountain shores (tendency ≈0.2) get small sand pockets separated + * by earthen/snowy banks. This breaks the uniform sand ring — beach *presence* + * is patchy, not just the width. Steep shores are never sandy. + * + * Returns `{ hasBeach, width }`; width also scales with strength so present + * beaches vary from narrow (low strength) to wider (high strength). + */ +export function shoreBeach( + biome: BiomeDef, + slope: number, + beachStrength: number, +): { hasBeach: boolean; width: number } { + if (slope > 2.5) return { hasBeach: false, width: 0 }; + const tendency = tendencyFor(biome.id); + const hasBeach = beachStrength > 1 - tendency; + if (!hasBeach) return { hasBeach: false, width: 0 }; + const width = Math.max(1, biome.beachWidth * (0.4 + beachStrength * 1.1)); + return { hasBeach, width }; +} + +/** + * Continuous snow mask in [0,1], derived from temperature (altitude-chilled + * heat) + a low-frequency meander. Independent of the discrete biome id, so + * snow tapers smoothly across climate boundaries instead of snapping at a line. + */ +export function snowFactor(effHeat: number, snowNoise: number): number { + const base = (0.4 - effHeat) / 0.28; // 0 at effHeat 0.40 → 1 at effHeat 0.12 + return clamp01(base + snowNoise * 0.22); +} + +/** + * Pure coastal + surface decision. Shared by the live painter (dressed `topY`) + * and the debug overlay (2D heightmap height), so the map and world agree on + * *why* a block was chosen. + * + * `effBeachWidth` is the already-noise/slope-adjusted beach width (see + * {@link effectiveBeachWidth}); the caller computes it so this function stays + * pure. `nearWater` must mean "water within the shore radius". + */ +export function decideSurface( + sea: number, + topY: number, + slope: number, + biome: BiomeDef, + height: number, + nearWater: boolean, + hasBeach: boolean, + beachWidth: number, +): SurfaceDecision { + void height; + const above = topY - sea; + const depth = sea - topY; + const underwater = topY < sea; + const rockyT = rockyThreshold(Math.max(0, above)); + const rocky = slope > rockyT; + // Beach only where the patch mask allows (hasBeach) AND water is within the + // shore radius AND the column is within the (variable) beach width. Low- + // tendency biomes produce pockets of sand separated by earthen/snowy banks. + const coastal = !underwater && nearWater && hasBeach && above <= beachWidth; + const desert = biome.id === "desert"; + const rock = desert ? B.DESERT_STONE : B.STONE; + + if (underwater) { + let surface: number; + let shelf: SurfaceDecision["shelf"]; + if (depth <= SHALLOW_SAND) { + surface = desert ? B.DESERT_SAND : B.SAND; + shelf = "shallow"; + } else if (depth <= SHALLOW_SAND + SHELF_GRAVEL) { + surface = B.GRAVEL; + shelf = "shallow"; + } else { + surface = B.DIRT; + shelf = "deep"; + } + return { surface, filler: surface, fillDepth: 4, coastal: false, rocky, underwater: true, shelf }; + } + + // Land. + if (coastal && !rocky) { + const shore = biome.beachBlock; + return { surface: shore, filler: shore, fillDepth: 3, coastal: true, rocky: false, underwater: false, shelf: "beach" }; + } + if (rocky) { + return { surface: rock, filler: rock, fillDepth: 2, coastal, rocky: true, underwater: false, shelf: coastal ? "rock" : "land" }; + } + return { surface: biome.surface, filler: biome.filler, fillDepth: 4, coastal, rocky: false, underwater: false, shelf: "land" }; +} + +/** + * Apply the continuous snow overlay and biome-edge mottling to a base surface + * decision. Pure — shared by the painter and the debug overlay so they agree. + */ +export function applySnowAndBlend( + d: SurfaceDecision, + snow: number, + blendEdge: number, + blendSurface: number, + blendNoise: number, +): number { + if (d.underwater) return d.surface; + if (d.rocky) return snow > 0.55 ? B.SNOW : d.surface; + if (d.coastal) return d.surface; // beach keeps its shore material + if (snow > 0.62) return B.SNOW; + if (snow > 0.34) return B.SNOWY_GRASS; + if (blendEdge > 0.18 && blendNoise > 1 - blendEdge * 0.75) return blendSurface; + return d.surface; +} + +export class SurfacePainter { + constructor( + private readonly noise: Noise, + private readonly sea: number, + ) {} + + paint(ctx: SurfaceCtx): void { + const { blocks, size, lx, lz, topY, slope, biome, effHeat, height, nearWater, wx, wz, blendEdge, blendSurface } = ctx; + if (topY < 1) return; + const idx = (y: number): number => (y * size + lz) * size + lx; + + // Low-frequency spatial noises (world-space → seamless across chunks). + // beachStrength uses a very low frequency so beach *presence* forms broad + // patches (long sandy stretches vs. long earthen banks), not per-block + // speckle. + const beachStrength = clamp01(0.5 + 0.5 * this.noise.fbm2(wx * 0.03, wz * 0.03, 2)); + const snowNoise = this.noise.fbm2(wx * 0.045 + 1200, wz * 0.045 + 1200, 2); + const blendNoise = this.noise.fbm2(wx * 0.07 + 900, wz * 0.07 + 900, 2); + const beach = shoreBeach(biome, slope, beachStrength); + + const d = decideSurface(this.sea, topY, slope, biome, height, nearWater, beach.hasBeach, beach.width); + const snow = snowFactor(effHeat, snowNoise); + const surf = applySnowAndBlend(d, snow, blendEdge, blendSurface, blendNoise); + blocks[idx(topY)] = surf; + + // Sub-surface filler (shrinks on steep slopes so cliff faces bare stone). + for (let y = topY - 1, n = 0; y >= 1 && n < d.fillDepth; y--, n++) { + const i = idx(y); + if (isStoneFamily(blocks[i])) blocks[i] = d.filler; + } + } + + /** + * Fill air above the surface up to sea level with water (ice cap in frozen + * climates). Called after painting so the floor block is already set. + */ + fillWater(ctx: SurfaceCtx): void { + const { blocks, size, lx, lz, topY, biome } = ctx; + if (topY >= this.sea) return; + const cap = biome.waterTop; // ice id, or 0 + for (let y = topY + 1; y <= this.sea; y++) { + const i = (y * size + lz) * size + lx; + if (blocks[i] !== B.AIR) continue; + blocks[i] = cap && y === this.sea ? cap : B.WATER; + } + } +} diff --git a/src/game/gen/TerrainNoise.ts b/src/game/gen/TerrainNoise.ts new file mode 100644 index 0000000..e92ff9e --- /dev/null +++ b/src/game/gen/TerrainNoise.ts @@ -0,0 +1,121 @@ +import type { Noise } from "../../engine/Noise"; + +// Layered terrain heightmap, inspired by Luanti mapgen v7 + carpathian: +// +// height = continent(base) + hills + ridged-mountains + rivers + detail +// +// Each layer is an independent noise sampled at world coordinates, so the result +// is fully deterministic per seed and seamless across chunk borders (the +// generator only ever queries height(wx, wz) at absolute coordinates). +// +// Layers: +// • continent — very-low-freq fbm; broad landmasses vs ocean basins. +// • hills — medium-freq fbm; rolling countryside relief. +// • mountain mask + ridged noise — sharp peaks & ridges, but only inside +// mountain regions (the mask) so most of the world stays mild. +// • rivers — thin ridged bands carved toward sea level (Luanti "ridges"), +// producing valleys/canyons and varied coastlines. +// • detail — high-freq micro-relief so close-range ground isn't sterile. +// +// The whole field is biased a little above sea level so land dominates ocean +// (keeps spawn sane without special-casing the seed). + +export interface HeightNoiseConfig { + seaLevel: number; + maxHeight: number; +} + +export class HeightMap { + private readonly sea: number; + private readonly max: number; + + constructor( + private readonly noise: Noise, + cfg: HeightNoiseConfig, + ) { + this.sea = cfg.seaLevel; + this.max = cfg.maxHeight; + } + + /** Final 2D base surface height (float). */ + height(wx: number, wz: number): number { + const n = this.noise; + // Continent: broad ocean/landmass. Slight positive bias → more land. + const continent = n.fbm2(wx * 0.0033, wz * 0.0033, 4); + let h = this.sea + 6 + continent * 20; + + // Rolling hills. + const hills = n.fbm2(wx * 0.013 + 200, wz * 0.013 + 200, 3); + h += hills * 9; + + // Mountains: only where the regional mask allows, then a ridged noise + // (1 - |n|) raised to a power gives sharp peaks rather than smooth bumps. + const mask = this.mountainMask(wx, wz); + if (mask > 0.02) { + const ridge = 1 - Math.abs(n.noise2(wx * 0.011 + 50, wz * 0.011 + 50)); + const ridge2 = 1 - Math.abs(n.noise2(wx * 0.023 + 320, wz * 0.023 + 320)); + const peak = Math.pow(ridge, 1.4) * 0.7 + Math.pow(ridge2, 2.2) * 0.3; + h += mask * mask * peak * 56; + // Carpathian-style subtle terracing inside the strongest cores. + if (mask > 0.55) { + const terrace = n.fbm2(wx * 0.008 + 700, wz * 0.008 + 700, 2); + const steps = Math.round((terrace + 1) * 3) / 3; + h += (steps - 0.5) * 6 * (mask - 0.55); + } + } + + // Rivers: thin ridged bands carved down toward (and slightly below) sea. + h += this.riverOffset(wx, wz); + + // High-freq detail — damped near sea level so coastlines read as broad, + // smooth bands instead of noisy 1-block wiggles. The damping uses the + // pre-detail height so there's no feedback loop. + const detail = n.fbm2(wx * 0.05 + 400, wz * 0.05 + 400, 2); + const distFromSea = Math.abs(h - this.sea); + const detailScale = distFromSea < 12 ? 0.25 + 0.75 * (distFromSea / 12) : 1; + h += detail * 2.5 * detailScale; + + return h < 3 ? 3 : h > this.max ? this.max : h; + } + + /** 0..1 mask of where 3D mountain terrain (overhangs/cliffs) is allowed. */ + mountainMask(wx: number, wz: number): number { + const v = this.noise.fbm2(wx * 0.0042 + 1000, wz * 0.0042 + 1000, 3) + 0.1; + const m = Math.min(1, Math.max(0, v * 1.7)); + return m; + } + + /** + * River carving offset (negative or ~0). A ridged noise produces thin bands + * where the value is high; we scoop terrain there toward sea level, leaving + * most columns untouched. Returns the delta to add to height. + */ + private riverOffset(wx: number, wz: number): number { + const n = this.noise; + const river = 1 - Math.abs(n.noise2(wx * 0.0029 + 800, wz * 0.0029 + 800)); + if (river < 0.82) return 0; + // Strength of the carve (0..1) inside the band. + const s = (river - 0.82) / 0.18; + // Carve proportionally to how far above sea we are, floor at ~sea-4. + // Pre-compute the rough current height by reusing the cheaper terms; the + // river contribution is excluded so there's no feedback loop. + const approx = + this.sea + 6 + n.fbm2(wx * 0.0033, wz * 0.0033, 4) * 20 + n.fbm2(wx * 0.013 + 200, wz * 0.013 + 200, 3) * 9; + const target = this.sea - 3; + const delta = (target - approx) * s * 0.9; + return delta < -52 ? -52 : delta; + } + + /** + * Approximate surface slope at a column via finite differences of the height + * field. Useful as a hint; the surface painter recomputes the *true* slope + * from dressed neighbour column tops when it has them (more accurate around + * 3D-carved cliffs). + */ + slope(wx: number, wz: number): number { + const h = this.height(wx, wz); + const hx = this.height(wx + 1, wz); + const hz = this.height(wx, wz + 1); + return Math.max(Math.abs(hx - h), Math.abs(hz - h)); + } +} diff --git a/src/game/gen/Trees.ts b/src/game/gen/Trees.ts new file mode 100644 index 0000000..e4f1db0 --- /dev/null +++ b/src/game/gen/Trees.ts @@ -0,0 +1,149 @@ +import * as B from "./BlockIds"; + +// Tree structures, drawn directly into a chunk's block array via a `set` +// callback supplied by the orchestrator (bounds-checked, air-only placement so +// trees never overwrite terrain). Each species comes in a few randomized +// variants (trunk height, canopy radius, occasional asymmetry) so forests read +// as varied groves rather than a grid of identical trees. +// +// All variation derives from a deterministic hash of (worldX, worldZ, seed), so +// a tree at a given column is reproducible regardless of generation order. + +/** Bounds-checked air-only setter (local chunk coords). */ +export type SetBlock = (lx: number, ly: number, lz: number, id: number) => void; + +function hash01(x: number, z: number, seed: string, salt: number): number { + let h = 374761393 ^ salt; + const s = `${x},${z},${seed}`; + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 668265263); + h = (h ^ (h >>> 13)) >>> 0; + return h / 0x100000000; +} + +export class TreeGenerator { + constructor(private readonly seed: string) {} + + private r(wx: number, wz: number, salt: number): number { + return hash01(wx, wz, this.seed, salt); + } + + /** Rounded oak: occasional larger specimen. */ + placeOak(set: SetBlock, lx: number, baseY: number, lz: number, wx: number, wz: number): void { + const big = this.r(wx, wz, 21) < 0.18; + const trunk = (big ? 5 : 4) + Math.floor(this.r(wx, wz, 22) * 3); + const topY = baseY + trunk; + const radius = big ? 3 : 2; + for (let y = topY - 2; y <= topY + 1; y++) { + const layerR = y <= topY - 1 ? radius : Math.max(1, radius - 1); + for (let dz = -layerR; dz <= layerR; dz++) { + for (let dx = -layerR; dx <= layerR; dx++) { + if (dx === 0 && dz === 0 && y < topY) continue; + // Trim corners on the widest layers for a rounder canopy, with a + // little noise-driven irregularity. + const corner = Math.abs(dx) === layerR && Math.abs(dz) === layerR; + if (corner && (layerR === 2 || this.r(wx + dx, wz + dz, 23) < 0.4)) continue; + set(lx + dx, y, lz + dz, B.LEAVES); + } + } + } + for (let y = baseY; y < topY; y++) set(lx, y, lz, B.WOOD); + } + + /** Birch: slim, tall, pale bark + bright leaves. */ + placeBirch(set: SetBlock, lx: number, baseY: number, lz: number, wx: number, wz: number): void { + const trunk = 5 + Math.floor(this.r(wx, wz, 24) * 4); + const topY = baseY + trunk; + for (let y = topY - 3; y <= topY + 1; y++) { + const t = (y - (topY - 3)) / 4; + const layerR = t > 0.75 ? 0 : t > 0.4 ? 2 : 2; + for (let dz = -layerR; dz <= layerR; dz++) { + for (let dx = -layerR; dx <= layerR; dx++) { + if (dx === 0 && dz === 0 && y < topY) continue; + if (Math.abs(dx) === 2 && Math.abs(dz) === 2 && this.r(wx + dx, wz + dz, 25) < 0.6) continue; + set(lx + dx, y, lz + dz, B.BIRCH_LEAVES); + } + } + } + for (let y = baseY; y < topY; y++) set(lx, y, lz, B.BIRCH_WOOD); + } + + /** Conical spruce/pine. `snowy` dusts the canopy with snowy leaves. */ + placeConifer( + set: SetBlock, + lx: number, + baseY: number, + lz: number, + wx: number, + wz: number, + leaf: number, + ): void { + const trunk = 6 + Math.floor(this.r(wx, wz, 26) * 5); + const topY = baseY + trunk; + // Layered cones: wide at the base, tapering to a point, with a small skirt + // drooping below the first layer for a classic spruce silhouette. + for (let y = baseY + 3; y <= topY; y++) { + const t = (y - baseY) / trunk; + const radius = t > 0.78 ? 0 : t > 0.5 ? 1 : 2; + for (let dz = -radius; dz <= radius; dz++) { + for (let dx = -radius; dx <= radius; dx++) { + if (radius === 2 && Math.abs(dx) === 2 && Math.abs(dz) === 2) continue; + // Top layers and upward-facing cells get the snowy variant. + const useSnow = leaf === B.SPRUCE_LEAVES && t > 0.45 && this.r(wx + dx, wz + dz, 27) < 0.5; + set(lx + dx, y, lz + dz, useSnow ? B.SNOWY_LEAVES : leaf); + } + } + } + set(lx, topY + 1, lz, leaf); + for (let y = baseY; y < topY; y++) set(lx, y, lz, B.WOOD); + } + + placeSpruce(set: SetBlock, lx: number, baseY: number, lz: number, wx: number, wz: number): void { + this.placeConifer(set, lx, baseY, lz, wx, wz, B.SPRUCE_LEAVES); + } + + placePine(set: SetBlock, lx: number, baseY: number, lz: number, wx: number, wz: number): void { + this.placeConifer(set, lx, baseY, lz, wx, wz, B.LEAVES); + } + + /** Tall jungle tree with a wide, blobby dark canopy. */ + placeJungle(set: SetBlock, lx: number, baseY: number, lz: number, wx: number, wz: number): void { + const trunk = 7 + Math.floor(this.r(wx, wz, 28) * 5); + const topY = baseY + trunk; + for (let y = topY - 4; y <= topY + 1; y++) { + const radius = y <= topY - 1 ? 3 : 2; + for (let dz = -radius; dz <= radius; dz++) { + for (let dx = -radius; dx <= radius; dx++) { + if (dx === 0 && dz === 0 && y < topY) continue; + // Round (diamond-ish) canopy: drop far corners. + if (radius === 3 && Math.abs(dx) + Math.abs(dz) > 4) continue; + set(lx + dx, y, lz + dz, B.JUNGLE_LEAVES); + } + } + } + for (let y = baseY; y < topY; y++) set(lx, y, lz, B.WOOD); + } + + /** Short acacia with a flat umbrella canopy. */ + placeAcacia(set: SetBlock, lx: number, baseY: number, lz: number, wx: number, wz: number): void { + const trunk = 3 + Math.floor(this.r(wx, wz, 29) * 3); + const topY = baseY + trunk; + const radius = 2 + Math.floor(this.r(wx, wz, 30) * 2); + for (let y = topY; y <= topY + 1; y++) { + for (let dz = -radius; dz <= radius; dz++) { + for (let dx = -radius; dx <= radius; dx++) { + // Round the umbrella and poke a few holes for an organic edge. + if (Math.abs(dx) === radius && Math.abs(dz) === radius) continue; + if (this.r(wx + dx, wz + dz, 31) < 0.12) continue; + set(lx + dx, y, lz + dz, B.LEAVES); + } + } + } + for (let y = baseY; y < topY; y++) set(lx, y, lz, B.WOOD); + } + + /** Cactus column (desert). */ + placeCactus(set: SetBlock, lx: number, baseY: number, lz: number, wx: number, wz: number): void { + const h = 2 + Math.floor(this.r(wx, wz, 32) * 3); + for (let i = 0; i < h; i++) set(lx, baseY + i, lz, B.CACTUS); + } +} diff --git a/src/game/gen/WorldgenStats.ts b/src/game/gen/WorldgenStats.ts new file mode 100644 index 0000000..60e1d80 --- /dev/null +++ b/src/game/gen/WorldgenStats.ts @@ -0,0 +1,75 @@ +// Rolling per-chunk generation statistics for the world-gen debug overlay. +// The orchestrator records one sample per generated chunk; this keeps an +// exponential moving average so a single slow chunk doesn't dominate and the +// overlay reads smoothly while streaming. + +export interface ChunkGenSample { + /** Wall-clock time to generate the chunk, in milliseconds. */ + ms: number; + decorations: number; + trees: number; + caves: number; + ores: number; +} + +export interface WorldgenStatsSnapshot { + /** EMA of per-chunk generation time. */ + avgMs: number; + /** Last chunk's generation time. */ + lastMs: number; + avgDecorations: number; + avgTrees: number; + avgCaves: number; + avgOres: number; + /** Total chunks generated this session. */ + chunks: number; +} + +export class WorldgenStats { + private avgMs = 0; + private lastMs = 0; + private avgDeco = 0; + private avgTrees = 0; + private avgCaves = 0; + private avgOres = 0; + private count = 0; + private initialised = false; + + record(s: ChunkGenSample): void { + this.lastMs = s.ms; + this.count++; + if (!this.initialised) { + this.avgMs = s.ms; + this.avgDeco = s.decorations; + this.avgTrees = s.trees; + this.avgCaves = s.caves; + this.avgOres = s.ores; + this.initialised = true; + return; + } + const a = 0.92; + this.avgMs = this.avgMs * a + s.ms * (1 - a); + this.avgDeco = this.avgDeco * a + s.decorations * (1 - a); + this.avgTrees = this.avgTrees * a + s.trees * (1 - a); + this.avgCaves = this.avgCaves * a + s.caves * (1 - a); + this.avgOres = this.avgOres * a + s.ores * (1 - a); + } + + reset(): void { + this.avgMs = this.lastMs = this.avgDeco = this.avgTrees = this.avgCaves = this.avgOres = 0; + this.count = 0; + this.initialised = false; + } + + snapshot(): WorldgenStatsSnapshot { + return { + avgMs: this.avgMs, + lastMs: this.lastMs, + avgDecorations: this.avgDeco, + avgTrees: this.avgTrees, + avgCaves: this.avgCaves, + avgOres: this.avgOres, + chunks: this.count, + }; + } +} diff --git a/src/main.ts b/src/main.ts index 22ea8e0..27fc3e5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -49,6 +49,10 @@ function boot(): void { waterDepth?: (on?: boolean) => void; waterSimple?: (on?: boolean) => void; targetLiquids?: (on?: boolean) => boolean; + // World-gen debug surface (see WorldgenOverlay / TerrainGenerator). + worldgen?: (on?: boolean) => void; + worldgenMode?: (mode: "biome" | "heat" | "humidity" | "height" | "slope" | "shore" | "snow") => void; + worldgenInfo?: () => void; } (window as unknown as { __voxl?: VoxlAutomation }).__voxl = { beginPlay: () => game.beginPlay(), @@ -82,6 +86,9 @@ function boot(): void { waterDepth: (on) => game._setWaterDepth(on ?? true), waterSimple: (on) => game._setWaterSimple(on ?? true), targetLiquids: (on) => game._setTargetLiquids(on), + worldgen: (on) => game._toggleWorldgen(on), + worldgenMode: (mode) => game._worldgenMode(mode), + worldgenInfo: () => game._worldgenInfo(), }; } diff --git a/src/ui/PerfOverlay.ts b/src/ui/PerfOverlay.ts index 710c6a5..9a73a9b 100644 --- a/src/ui/PerfOverlay.ts +++ b/src/ui/PerfOverlay.ts @@ -55,6 +55,11 @@ export interface PerfSnapshot { firstLiquid: { x: number; y: number; z: number } | null; waterSidesOn: boolean; waterAnimOn: boolean; + // World-generation diagnostics (TerrainGenerator stats + player biome). + genAvgMs: number; + genLastMs: number; + genChunks: number; + biome: string; } function $(id: string): HTMLElement { @@ -83,6 +88,7 @@ export class PerfOverlay { private readonly liquidEl: HTMLElement; private readonly targetEl: HTMLElement; private readonly memEl: HTMLElement; + private readonly genEl: HTMLElement; private visible = false; constructor() { @@ -119,6 +125,7 @@ export class PerfOverlay { this.liquidEl = mkline(grid, "liquid"); this.targetEl = mkline(grid, "target"); this.memEl = mkline(grid, "mem"); + this.genEl = mkline(grid, "worldgen"); root.appendChild(grid); this.root = root; @@ -181,6 +188,11 @@ export class PerfOverlay { } else { setLine(this.memEl, `${tod}${s.gpuRenderer ? " · " + s.gpuRenderer : ""}`); } + setLine( + this.genEl, + `${s.genAvgMs.toFixed(1)}ms avg · ${s.genLastMs.toFixed(1)} last · ${s.genChunks} chunks · ${s.biome}`, + s.genAvgMs > 8 ? "var(--danger)" : s.genAvgMs > 5 ? "var(--warm)" : "", + ); } } diff --git a/src/ui/WorldgenOverlay.ts b/src/ui/WorldgenOverlay.ts new file mode 100644 index 0000000..f572c06 --- /dev/null +++ b/src/ui/WorldgenOverlay.ts @@ -0,0 +1,284 @@ +import type { TerrainGenerator } from "../game/TerrainGenerator"; +import { BIOME_DEFS, type BiomeId } from "../game/gen/Biomes"; +import type { WorldgenStatsSnapshot } from "../game/gen/WorldgenStats"; + +// World-generation debug overlay: a compact read-out of the climate/biome at the +// targeted column plus a live top-down minimap. The minimap re-renders by +// sampling the generator's deterministic height/climate functions over a grid +// around the player, so it works anywhere — even where chunks haven't streamed. +// +// Toggle with the worldgen-debug key (wired in Game). Map mode cycles with the +// same key while open. Off by default (it does real work each refresh). + +export type WorldgenMapMode = "biome" | "heat" | "humidity" | "height" | "slope" | "shore" | "snow"; + +const MODES: WorldgenMapMode[] = ["biome", "heat", "humidity", "height", "slope", "shore", "snow"]; +const MAP_PX = 112; // canvas size +const STEP = 2; // world blocks per pixel +const RERENDER_MOVE = 8; // re-render only after moving this many blocks + +export interface WorldgenSnapshot { + generator: TerrainGenerator | null; + stats: WorldgenStatsSnapshot | null; + playerX: number; + playerZ: number; + /** Targeted column (falls back to the player column). */ + targetWX: number; + targetWZ: number; + seaLevel: number; +} + +function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace("#", ""); + const n = parseInt(h.length === 3 ? h.replace(/(.)/g, "$1$1") : h, 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +function heatColor(v: number): [number, number, number] { + // 0 cold-blue → 0.5 green → 1 hot-red + const t = v < 0 ? 0 : v > 1 ? 1 : v; + if (t < 0.5) { + const k = t / 0.5; + return [Math.round(40 + k * 80), Math.round(120 + k * 110), Math.round(220 - k * 140)]; + } + const k = (t - 0.5) / 0.5; + return [Math.round(220 - k * 40), Math.round(150 - k * 90), Math.round(60 + k * 20)]; +} + +function slopeColor(v: number): [number, number, number] { + const t = v < 0 ? 0 : v > 6 ? 1 : v / 6; + return [Math.round(70 + t * 180), Math.round(180 - t * 140), Math.round(90 - t * 60)]; +} + +export class WorldgenOverlay { + private readonly root: HTMLElement; + private readonly canvas: HTMLCanvasElement; + private readonly ctx: CanvasRenderingContext2D; + private readonly modeEl: HTMLElement; + private readonly targetEl: HTMLElement; + private readonly statsEl: HTMLElement; + private readonly hintEl: HTMLElement; + private visible = false; + private mode: WorldgenMapMode = "biome"; + private lastRenderX = Number.NaN; + private lastRenderZ = Number.NaN; + private lastRenderMode: WorldgenMapMode | null = null; + + constructor() { + this.root = document.createElement("div"); + this.root.className = "worldgen-overlay"; + this.root.hidden = true; + + const header = document.createElement("div"); + header.className = "worldgen-header"; + const title = document.createElement("span"); + title.textContent = "worldgen"; + this.modeEl = document.createElement("span"); + this.modeEl.className = "worldgen-mode"; + this.hintEl = document.createElement("span"); + this.hintEl.className = "perf-hint"; + this.hintEl.textContent = "G: mode"; + header.appendChild(title); + header.appendChild(this.modeEl); + header.appendChild(this.hintEl); + this.root.appendChild(header); + + this.canvas = document.createElement("canvas"); + this.canvas.width = MAP_PX; + this.canvas.height = MAP_PX; + this.canvas.className = "worldgen-map"; + this.root.appendChild(this.canvas); + const ctx = this.canvas.getContext("2d"); + if (!ctx) throw new Error("2D context unavailable for worldgen overlay"); + this.ctx = ctx; + + this.targetEl = document.createElement("div"); + this.targetEl.className = "worldgen-line"; + this.statsEl = document.createElement("div"); + this.statsEl.className = "worldgen-line"; + this.root.appendChild(this.targetEl); + this.root.appendChild(this.statsEl); + + document.getElementById("hud")?.appendChild(this.root); + } + + setVisible(v: boolean): void { + this.visible = v; + this.root.hidden = !v; + } + + toggle(): boolean { + this.setVisible(!this.visible); + return this.visible; + } + + /** Cycle the minimap colour mode (called on the toggle key while open). */ + cycleMode(): void { + const i = MODES.indexOf(this.mode); + this.mode = MODES[(i + 1) % MODES.length]; + this.lastRenderMode = null; // force a re-render + } + + /** Set a specific map mode (console API). */ + setMode(mode: WorldgenMapMode): void { + if (MODES.indexOf(mode) >= 0) { + this.mode = mode; + this.lastRenderMode = null; + } + } + + get isOpen(): boolean { + return this.visible; + } + + update(s: WorldgenSnapshot): void { + if (!this.visible) return; + const gen = s.generator; + this.modeEl.textContent = this.mode; + + if (gen) { + const d = gen.debugAt(s.targetWX, s.targetWZ); + this.targetEl.textContent = + `${d.biome} · ${d.surfaceBlock} · snow ${d.snow.toFixed(2)}` + + (d.blendEdge > 0.3 ? ` · blend→${d.blendBiome}` : "") + + (d.coastal ? " · BEACH" : "") + (d.rocky ? " · ROCK" : "") + + ` · beachStr ${d.beachStrength.toFixed(2)}${d.hasBeach ? "" : "(no)"}` + + ` · water ${d.waterExtent.toFixed(2)}` + + ` · shore ${d.shoreDist > 6 ? "—" : d.shoreDist}` + + ` · h ${d.height - s.seaLevel >= 0 ? "+" : ""}${d.height - s.seaLevel} · slope ${d.slope.toFixed(1)}`; + + // Re-render the minimap only when the player has moved enough or mode + // changed (keeps it cheap). + const moved = + Math.abs(s.playerX - this.lastRenderX) > RERENDER_MOVE || + Math.abs(s.playerZ - this.lastRenderZ) > RERENDER_MOVE; + if (moved || this.lastRenderMode !== this.mode) { + this.renderMap(gen, s.playerX, s.playerZ, s.seaLevel); + this.lastRenderX = s.playerX; + this.lastRenderZ = s.playerZ; + this.lastRenderMode = this.mode; + } + } else { + this.targetEl.textContent = "no world"; + } + + if (s.stats) { + const st = s.stats; + this.statsEl.textContent = + `gen ${st.avgMs.toFixed(1)}ms avg · ${st.lastMs.toFixed(1)} last · ${st.chunks} chunks` + + ` · ${st.avgTrees.toFixed(1)} trees · ${st.avgDecorations.toFixed(0)} deco · ${st.avgCaves.toFixed(0)} caves · ${st.avgOres.toFixed(0)} ores`; + } + } + + private renderMap(gen: TerrainGenerator, cx: number, cz: number, sea: number): void { + const ctx = this.ctx; + const half = (MAP_PX * STEP) / 2; + const img = ctx.createImageData(MAP_PX, MAP_PX); + const data = img.data; + for (let py = 0; py < MAP_PX; py++) { + for (let px = 0; px < MAP_PX; px++) { + const wx = Math.floor(cx - half + px * STEP); + const wz = Math.floor(cz - half + py * STEP); + const d = gen.debugAt(wx, wz); + let rgb: [number, number, number]; + switch (this.mode) { + case "biome": + rgb = hexToRgb(BIOME_DEFS[d.biome as BiomeId].color); + break; + case "heat": + rgb = heatColor(d.effHeat); + break; + case "humidity": + // dry(brown) → wet(blue) + rgb = heatColor(1 - d.humidity); + break; + case "height": { + const h = d.height; + if (h < sea) { + const k = h / sea; + rgb = [Math.round(30 + k * 30), Math.round(60 + k * 80), Math.round(120 + k * 90)]; + } else { + const k = Math.min(1, (h - sea) / 50); + rgb = [ + Math.round(90 + k * 140), + Math.round(150 - k * 30), + Math.round(80 - k * 40), + ]; + if (k > 0.7) { + const w = (k - 0.7) / 0.3; + rgb = [ + Math.round(rgb[0] + (240 - rgb[0]) * w), + Math.round(rgb[1] + (244 - rgb[1]) * w), + Math.round(rgb[2] + (250 - rgb[2]) * w), + ]; + } + } + break; + } + case "slope": + rgb = slopeColor(d.slope); + break; + case "snow": { + // Snow mask: green (none) → pale (patchy snowy-grass) → white (full). + const t = d.snow; + rgb = [ + Math.round(60 + (240 - 60) * t), + Math.round(120 + (244 - 120) * t), + Math.round(70 + (250 - 70) * t), + ]; + break; + } + case "shore": { + // Shoreline classification: deep=navy, shallow=cyan, beach=sand, + // rock=grey, near-shore transition=pale, inland=muted biome. + switch (d.shelf) { + case "deep": + rgb = [24, 44, 78]; + break; + case "shallow": + rgb = [86, 168, 196]; + break; + case "beach": + rgb = [224, 208, 150]; + break; + case "rock": + rgb = [120, 120, 124]; + break; + default: { + const bc = hexToRgb(BIOME_DEFS[d.biome as BiomeId].color); + if (d.shoreDist <= 4) { + // near-shore transition band — lighten to reveal the shoreline + rgb = [ + Math.round(bc[0] * 0.7 + 60), + Math.round(bc[1] * 0.7 + 66), + Math.round(bc[2] * 0.7 + 50), + ]; + } else { + rgb = [ + Math.round(bc[0] * 0.6 + 24), + Math.round(bc[1] * 0.6 + 30), + Math.round(bc[2] * 0.6 + 24), + ]; + } + } + } + break; + } + default: + rgb = [0, 0, 0]; + } + const o = (py * MAP_PX + px) * 4; + data[o] = rgb[0]; + data[o + 1] = rgb[1]; + data[o + 2] = rgb[2]; + data[o + 3] = 255; + } + } + ctx.putImageData(img, 0, 0); + // Player marker at the centre. + ctx.fillStyle = "rgba(255,255,255,0.9)"; + ctx.fillRect(MAP_PX / 2 - 1, MAP_PX / 2 - 1, 3, 3); + ctx.strokeStyle = "rgba(0,0,0,0.6)"; + ctx.strokeRect(0.5, 0.5, MAP_PX - 1, MAP_PX - 1); + } +} diff --git a/src/ui/ui.css b/src/ui/ui.css index 76a51f6..5357b2f 100644 --- a/src/ui/ui.css +++ b/src/ui/ui.css @@ -876,3 +876,56 @@ input[type="checkbox"] { color: var(--ink); font-variant-numeric: tabular-nums; } + +/* ===== World-gen debug overlay (G) ===== */ +.worldgen-overlay { + position: absolute; + top: 14px; + left: 14px; + z-index: 16; + width: 150px; + padding: 9px 11px; + background: rgba(8, 12, 28, 0.72); + border: 1px solid var(--panel-border); + border-radius: 9px; + backdrop-filter: blur(4px); + font-family: var(--mono); + font-size: 11.5px; + line-height: 1.5; + color: var(--ink); + pointer-events: none; + user-select: none; +} +.worldgen-overlay[hidden] { display: none; } +.worldgen-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + padding-bottom: 5px; + border-bottom: 1px solid rgba(120, 150, 220, 0.18); + color: var(--accent-2); + letter-spacing: 0.06em; + text-transform: uppercase; + font-size: 10.5px; +} +.worldgen-mode { + color: var(--accent); + text-transform: lowercase; +} +.worldgen-map { + display: block; + width: 112px; + height: 112px; + margin: 0 auto 6px; + border-radius: 4px; + image-rendering: pixelated; +} +.worldgen-line { + white-space: normal; + color: var(--ink); + font-variant-numeric: tabular-nums; + font-size: 10.5px; + line-height: 1.45; +} From 14c392ee2d5779be058ae9cb3398df00653573ad Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sun, 14 Jun 2026 23:59:48 +0100 Subject: [PATCH 2/4] Address PR review: sub-sea cave fix, river carve, overlay perf, cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [HIGH] Caves: never carve below sea level. Previously, worm tunnels carved at y < sea under land columns (height > sea+1), leaving dry air pockets underwater (fillWater only floods columns whose surface is below sea). Now all sub-sea crust stays solid; caves live only above sea under land. Verified: 0 sub-sea air cells, 55k+ above-sea cave cells remain. Removed the now-unreachable deep-cavern branch. [MED] River carve clamped to never raise terrain (was ridging the deep ocean floor where bands crossed water). Also refactored height() to pass the continent+hills base to riverOffset() instead of recomputing two fbm octaves per column (~1.7k fewer noise evals/chunk; gen dropped 3.6->3.4ms). [MED] WorldgenOverlay hint text corrected ('G toggle · H cycle'). [MED] Minimap perf: debugAt() gains a fast flag that skips the radius-8 water-extent scan and radius-6 shore distance for the per-pixel minimap render (~600 fewer noise evals/pixel); full readout still used for the single target column. [MED] Trees: collapsed redundant birch canopy layerR branch. [LOW] Biomes: documented 'beach' as a reserved palette entry (selectBiome never returns it by design). [LOW] Removed dead re-exports (landBiome/landBlend/BiomeId) and the now- unused landBiome import from TerrainGenerator. [LOW] findSpawnColumn spiral step 6->8 to halve worst-case ocean-start cost. Typecheck clean; build passes; 3/3 tests pass. --- src/game/Game.ts | 8 +++--- src/game/TerrainGenerator.ts | 51 ++++++++++++++++++++++-------------- src/game/gen/Biomes.ts | 4 +++ src/game/gen/Caves.ts | 37 +++++++++++--------------- src/game/gen/TerrainNoise.ts | 24 ++++++++--------- src/game/gen/Trees.ts | 2 +- src/ui/WorldgenOverlay.ts | 4 +-- 7 files changed, 71 insertions(+), 59 deletions(-) diff --git a/src/game/Game.ts b/src/game/Game.ts index a43b1cf..27d8030 100644 --- a/src/game/Game.ts +++ b/src/game/Game.ts @@ -380,12 +380,14 @@ export class Game { return h > SEA_LEVEL && gen.biomeAt(x, z, h) !== "ocean"; }; if (check(0, 0)) return { x: 0, z: 0 }; - for (let r = 6; r <= 384; r += 6) { - for (let x = -r; x <= r; x += 6) { + // Step 8 keeps the worst-case (ocean-start) spiral cheap while still landing + // on land within a few blocks of the ideal spot. + for (let r = 8; r <= 384; r += 8) { + for (let x = -r; x <= r; x += 8) { if (check(x, -r)) return { x, z: -r }; if (check(x, r)) return { x, z: r }; } - for (let z = -r + 6; z <= r - 6; z += 6) { + for (let z = -r + 8; z <= r - 8; z += 8) { if (check(-r, z)) return { x: -r, z }; if (check(r, z)) return { x: r, z }; } diff --git a/src/game/TerrainGenerator.ts b/src/game/TerrainGenerator.ts index e6a6f62..6c25208 100644 --- a/src/game/TerrainGenerator.ts +++ b/src/game/TerrainGenerator.ts @@ -4,7 +4,7 @@ import { getBlock } from "./Blocks"; import type { Chunk } from "./Chunk"; import * as B from "./gen/BlockIds"; import { ClimateMaps } from "./gen/Climate"; -import { BIOME_DEFS, landBiome, landBlend, selectBiome, type BiomeId, type BiomeSelection, type TreeType } from "./gen/Biomes"; +import { BIOME_DEFS, landBlend, selectBiome, type BiomeId, type BiomeSelection, type TreeType } from "./gen/Biomes"; import { HeightMap } from "./gen/TerrainNoise"; import { CaveGenerator } from "./gen/Caves"; import { OreGenerator } from "./gen/Ores"; @@ -67,8 +67,14 @@ export class TerrainGenerator { return selectBiome(effHeat, c.humidity, height, SEA, ROCK_LINE, SNOW_LINE).id; } - /** Rich climate/debug info at a column (for the world-gen overlay). */ - debugAt(wx: number, wz: number): { + /** + * Rich climate/debug info at a column (for the world-gen overlay). Pass + * `fast=true` for the per-pixel minimap render: it skips the expensive + * radius-8 water-extent scan and the radius-6 shore distance (using a cheap + * height-based near-water approximation instead), cutting ~600 noise evals + * per call. The single target-column readout uses the full (slow) path. + */ + debugAt(wx: number, wz: number, fast = false): { biome: BiomeId; landBiome: BiomeId; heat: number; @@ -98,8 +104,29 @@ export class TerrainGenerator { // Waterline cells use the adjacent land biome (matches the surface painter). const biome = sel.id === "ocean" ? BIOME_DEFS[sel.landId] : BIOME_DEFS[sel.id]; const slope = this.heightMap.slope(wx, wz); - const shoreDist = this.shoreDistance(wx, wz, 6); - const nearWater = shoreDist <= 2; + let shoreDist: number; + let nearWater: boolean; + let waterExtent: number; + if (fast) { + // Cheap approximation for the minimap: treat low columns as "near water". + nearWater = height <= SEA + 1; + shoreDist = nearWater ? 1 : 7; + waterExtent = -1; + } else { + shoreDist = this.shoreDistance(wx, wz, 6); + nearWater = shoreDist <= 2; + // Local water-extent estimate (fraction of radius-8 neighbourhood below + // sea) — distinguishes open ocean (~1) from small ponds/rivers (~0). + let waterCells = 0; + let totalCells = 0; + for (let dz = -8; dz <= 8; dz += 2) { + for (let dx = -8; dx <= 8; dx += 2) { + totalCells++; + if (Math.floor(this.heightMap.height(wx + dx, wz + dz)) < SEA) waterCells++; + } + } + waterExtent = waterCells / totalCells; + } // Match the painter's noises + overlays so the overlay explains the block. const bs = 0.5 + 0.5 * this.noise.fbm2(wx * 0.03, wz * 0.03, 2); const beachStrength = bs < 0 ? 0 : bs > 1 ? 1 : bs; @@ -111,17 +138,6 @@ export class TerrainGenerator { const blend = landBlend(c.heat, c.humidity); const blendSurface = BIOME_DEFS[blend.second].surface; const final = applySnowAndBlend(d, snow, blend.edge, blendSurface, blendNoise); - // Cheap local water-extent estimate (fraction of radius-8 neighbourhood - // below sea) — distinguishes open ocean (~1) from small ponds/rivers (~0). - let waterCells = 0; - let totalCells = 0; - for (let dz = -8; dz <= 8; dz += 2) { - for (let dx = -8; dx <= 8; dx += 2) { - totalCells++; - if (Math.floor(this.heightMap.height(wx + dx, wz + dz)) < SEA) waterCells++; - } - } - const waterExtent = waterCells / totalCells; return { biome: sel.id, landBiome: sel.landId, @@ -505,6 +521,3 @@ export function findGroundY(chunk: Chunk, lx: number, lz: number): number { } return 0; } - -// Re-exports for the debug overlay / console API. -export { landBiome, landBlend, type BiomeId }; diff --git a/src/game/gen/Biomes.ts b/src/game/gen/Biomes.ts index b5eaa2a..e80f9ad 100644 --- a/src/game/gen/Biomes.ts +++ b/src/game/gen/Biomes.ts @@ -81,6 +81,10 @@ const OCEAN: BiomeDef = { export const BIOME_DEFS: Record = { ocean: OCEAN, + // NOTE: "beach" is a RESERVED palette entry — selectBiome() never returns it + // (sand around water is a proximity effect decided by the surface painter, not + // an elevation biome). It exists so BiomeId/"beach" stays a valid lookup target + // and the minimap can colour-code it if ever selected. beach: { id: "beach", heatPoint: 0.5, diff --git a/src/game/gen/Caves.ts b/src/game/gen/Caves.ts index eda94ce..5d4d093 100644 --- a/src/game/gen/Caves.ts +++ b/src/game/gen/Caves.ts @@ -1,17 +1,18 @@ import type { Noise } from "../../engine/Noise"; -// Cave generation, modelled on Luanti's mapgen v7 cave system: +// Cave generation, modelled on Luanti's mapgen v7 worm-tunnel technique: // // • Worm tunnels — two 3D noises intersected: a column is air where both lie -// within narrow bands, producing winding tubes (the classic "two noises" -// technique). A slow noise varies the tube width so tunnels breathe. -// • Caverns — a low-frequency 3D fbm above a high threshold, only deep down, -// yielding rare large chambers. +// within narrow bands, producing winding tubes. A slow noise varies the tube +// width so tunnels breathe. // // Carving respects a surface crust so caves don't gut the dressed terrain, and -// avoids caves beneath shallow/ocean columns (the liquid sim has no pressure -// model — see AGENTS.md). Cave *entrances* are allowed on hills via a noise -// gate so the underground occasionally breaches the surface. +// never carves below sea level — a carved cell below sea under a land column +// can't be flooded by fillWater (the column surface is above sea), which would +// leave dry air pockets under coasts/hills (the liquid sim has no pressure +// model — see AGENTS.md). Caves therefore exist only above sea, under land. +// Cave *entrances* are allowed on hills via a noise gate so the underground +// occasionally breaches the surface. export class CaveGenerator { constructor(private readonly noise: Noise) {} @@ -24,8 +25,12 @@ export class CaveGenerator { isCarved(wx: number, y: number, wz: number, height: number, sea: number): boolean { if (y < 2) return false; if (y > height + 3) return false; - // Keep caves out of shallow / ocean columns (no pressure-based flow sim). - if (height <= sea + 1 && y < sea) return false; + // Never carve below sea level. A carved cell below sea under a LAND column + // can't be flooded — fillWater only runs for columns whose surface is below + // sea — so it would leave dry air pockets under coasts/hills, contradicting + // the "no sub-sea caves" intent (the liquid sim has no pressure model). + // Caves therefore live only above sea (under land). + if (y < sea) return false; const n = this.noise; @@ -33,7 +38,7 @@ export class CaveGenerator { const a = n.noise3(wx * 0.04, y * 0.075, wz * 0.04); const b = n.noise3(wx * 0.04 + 100, y * 0.05 + 100, wz * 0.04 + 100); // Slow width modulation: deeper → slightly wider tunnels. - const depthFrac = y < sea ? 1 : Math.max(0, 1 - (height - y) / 40); + const depthFrac = Math.max(0, 1 - (height - y) / 40); const half = 0.062 + depthFrac * 0.02; const tube = 0.3; if (Math.abs(a) < half && Math.abs(b) < tube) { @@ -45,16 +50,6 @@ export class CaveGenerator { } return true; } - - // --- Caverns (rare, deep) --- - if (y < sea - 14 && y > 2) { - const c = n.fbm3(wx * 0.018 + 50, y * 0.025 + 50, wz * 0.018 + 50, 2); - if (c > 0.6) { - // Preserve the crust even for caverns near the surface. - if (y > height - 6) return false; - return true; - } - } return false; } } diff --git a/src/game/gen/TerrainNoise.ts b/src/game/gen/TerrainNoise.ts index e92ff9e..4cc4a00 100644 --- a/src/game/gen/TerrainNoise.ts +++ b/src/game/gen/TerrainNoise.ts @@ -42,11 +42,11 @@ export class HeightMap { const n = this.noise; // Continent: broad ocean/landmass. Slight positive bias → more land. const continent = n.fbm2(wx * 0.0033, wz * 0.0033, 4); - let h = this.sea + 6 + continent * 20; - // Rolling hills. const hills = n.fbm2(wx * 0.013 + 200, wz * 0.013 + 200, 3); - h += hills * 9; + // Continent + hills base (shared with riverOffset to avoid recomputing it). + const baseCH = this.sea + 6 + continent * 20 + hills * 9; + let h = baseCH; // Mountains: only where the regional mask allows, then a ridged noise // (1 - |n|) raised to a power gives sharp peaks rather than smooth bumps. @@ -65,7 +65,7 @@ export class HeightMap { } // Rivers: thin ridged bands carved down toward (and slightly below) sea. - h += this.riverOffset(wx, wz); + h += this.riverOffset(wx, wz, baseCH); // High-freq detail — damped near sea level so coastlines read as broad, // smooth bands instead of noisy 1-block wiggles. The damping uses the @@ -86,23 +86,21 @@ export class HeightMap { } /** - * River carving offset (negative or ~0). A ridged noise produces thin bands + * River carving offset (negative or 0). A ridged noise produces thin bands * where the value is high; we scoop terrain there toward sea level, leaving - * most columns untouched. Returns the delta to add to height. + * most columns untouched. `baseCH` is the continent+hills height (passed in + * to avoid recomputing the octave noise). The carve is clamped to never RAISE + * terrain, so river bands crossing deep ocean leave the seabed alone. */ - private riverOffset(wx: number, wz: number): number { + private riverOffset(wx: number, wz: number, baseCH: number): number { const n = this.noise; const river = 1 - Math.abs(n.noise2(wx * 0.0029 + 800, wz * 0.0029 + 800)); if (river < 0.82) return 0; // Strength of the carve (0..1) inside the band. const s = (river - 0.82) / 0.18; - // Carve proportionally to how far above sea we are, floor at ~sea-4. - // Pre-compute the rough current height by reusing the cheaper terms; the - // river contribution is excluded so there's no feedback loop. - const approx = - this.sea + 6 + n.fbm2(wx * 0.0033, wz * 0.0033, 4) * 20 + n.fbm2(wx * 0.013 + 200, wz * 0.013 + 200, 3) * 9; const target = this.sea - 3; - const delta = (target - approx) * s * 0.9; + // Only carve downward — never raise the floor (would ridge the deep seabed). + const delta = Math.min(0, (target - baseCH) * s * 0.9); return delta < -52 ? -52 : delta; } diff --git a/src/game/gen/Trees.ts b/src/game/gen/Trees.ts index e4f1db0..4db8122 100644 --- a/src/game/gen/Trees.ts +++ b/src/game/gen/Trees.ts @@ -55,7 +55,7 @@ export class TreeGenerator { const topY = baseY + trunk; for (let y = topY - 3; y <= topY + 1; y++) { const t = (y - (topY - 3)) / 4; - const layerR = t > 0.75 ? 0 : t > 0.4 ? 2 : 2; + const layerR = t > 0.75 ? 0 : 2; for (let dz = -layerR; dz <= layerR; dz++) { for (let dx = -layerR; dx <= layerR; dx++) { if (dx === 0 && dz === 0 && y < topY) continue; diff --git a/src/ui/WorldgenOverlay.ts b/src/ui/WorldgenOverlay.ts index f572c06..b241d4a 100644 --- a/src/ui/WorldgenOverlay.ts +++ b/src/ui/WorldgenOverlay.ts @@ -77,7 +77,7 @@ export class WorldgenOverlay { this.modeEl.className = "worldgen-mode"; this.hintEl = document.createElement("span"); this.hintEl.className = "perf-hint"; - this.hintEl.textContent = "G: mode"; + this.hintEl.textContent = "G toggle · H cycle"; header.appendChild(title); header.appendChild(this.modeEl); header.appendChild(this.hintEl); @@ -179,7 +179,7 @@ export class WorldgenOverlay { for (let px = 0; px < MAP_PX; px++) { const wx = Math.floor(cx - half + px * STEP); const wz = Math.floor(cz - half + py * STEP); - const d = gen.debugAt(wx, wz); + const d = gen.debugAt(wx, wz, true); let rgb: [number, number, number]; switch (this.mode) { case "biome": From ca13ccad0b5f03bf3fd365b973d5152adbbb9fed Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Mon, 15 Jun 2026 00:13:19 +0100 Subject: [PATCH 3/4] Address PR review round 2: landBlend edge, KeyH conflict, spawn check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [MED] Biomes.landBlend(): the edge metric was broken — the formula 1 - sqrt(secondD)/(sqrt(bestD)+eps) evaluated to ~0 everywhere (center AND boundary), and the bestD<1e-6 ? 1 special case returned max edge at biome CENTERS (backwards). Replaced with a correct fixed-width band: edge = 1 - (dSecond - dBest)/0.05, so it's 0 deep inside a biome and 1 on a boundary. Biome-edge mottling now actually fires near climate boundaries (was effectively dead). Verified: max edge 1.0, ~22% of columns in the mottle band. [MED] Game.ts: KeyH fell through from the worldgen mode-cycle into handleLightingDebugKey (which toggles shadows), so one keypress changed two unrelated toggles. Added an early return after cycling. [LOW] findSpawnColumn: the biomeAt(... !== 'ocean') check was redundant with columnHeight > SEA (selectBiome returns ocean iff height <= sea), so it duplicated the heightmap + climate work. Dropped it — check is now a single columnHeight() call, roughly halving worst-case ocean-start cost. Typecheck clean; build passes; 3/3 tests pass; determinism holds. --- src/game/Game.ts | 8 ++++---- src/game/gen/Biomes.ts | 10 ++++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/game/Game.ts b/src/game/Game.ts index 27d8030..107dac3 100644 --- a/src/game/Game.ts +++ b/src/game/Game.ts @@ -219,6 +219,7 @@ export class Game { } if (code === "KeyH" && this.worldgenOverlay.isOpen) { this.worldgenOverlay.cycleMode(); + return; // don't fall through to the lighting-debug key handler } if (code === "F4") { // Toggle liquid targeting (Luanti `liquids` pointability). Default @@ -375,10 +376,9 @@ export class Game { */ private findSpawnColumn(): { x: number; z: number } { const gen = this.world!.generator; - const check = (x: number, z: number): boolean => { - const h = gen.columnHeight(x, z); - return h > SEA_LEVEL && gen.biomeAt(x, z, h) !== "ocean"; - }; + // Dry land is purely height-based (selectBiome returns "ocean" iff height ≤ + // sea), so a single columnHeight() check suffices — no climate/biome eval. + const check = (x: number, z: number): boolean => gen.columnHeight(x, z) > SEA_LEVEL; if (check(0, 0)) return { x: 0, z: 0 }; // Step 8 keeps the worst-case (ocean-start) spiral cheap while still landing // on land within a few blocks of the ideal spot. diff --git a/src/game/gen/Biomes.ts b/src/game/gen/Biomes.ts index e80f9ad..f0c2031 100644 --- a/src/game/gen/Biomes.ts +++ b/src/game/gen/Biomes.ts @@ -339,8 +339,14 @@ export function landBlend(heat: number, humidity: number): { second: BiomeId; ed second = id; } } - // edge ≈ 1 when the two nearest biomes are nearly equidistant. - const edge = bestD < 1e-6 ? 1 : Math.max(0, 1 - Math.sqrt(secondD) / (Math.sqrt(bestD) + 0.001)); + // edge ∈ [0,1]: 0 deep inside a biome (nearest much closer than second), + // 1 on a boundary (the two nearest equidistant). Uses a fixed-width band so + // the blend zone is well-defined regardless of biome spacing. + const dBest = Math.sqrt(bestD); + const dSecond = Math.sqrt(secondD); + const BLEND_BAND = 0.05; + const gap = dSecond - dBest; + const edge = gap < BLEND_BAND ? 1 - gap / BLEND_BAND : 0; return { second, edge: edge < 0 ? 0 : edge > 1 ? 1 : edge }; } From f5428da56b92ec416dce4d3ef4c1b0a07f35f152 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Mon, 15 Jun 2026 00:51:09 +0100 Subject: [PATCH 4/4] Address PR review round 3: survival consistency + cleanup [MED] Items: new plantlike blocks (dead bush 30, fern 31, papyrus 32, cornflower 33) now break instantly in survival like the existing plantlike blocks, instead of defaulting to 0.9s. [MED] Items: new leaf blocks (birch 35, spruce 36, snowy 37) now drop nothing, matching oak (6) and jungle (26) leaves. [LOW] Items: added the new blocks (b30-b37) to the creative palette so they're pickable without console/survival gathering. [LOW] WorldgenOverlay: fixed misleading humidity-mode colour comment (dry=red, not brown); invalidate the minimap render cache on hide so reopening forces a fresh render instead of a stale canvas. [LOW] Trees: removed dead placeCactus() (cacti are placed by the decoration generator). [LOW] Surface: removed the unused height parameter from decideSurface() (and the void height; placeholder) plus its callers + SurfaceCtx field. Typecheck clean; build passes; 3/3 tests pass; determinism holds. --- src/game/Items.ts | 8 ++++++-- src/game/TerrainGenerator.ts | 4 +--- src/game/gen/Surface.ts | 7 ++----- src/game/gen/Trees.ts | 6 ------ src/ui/WorldgenOverlay.ts | 9 ++++++++- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/game/Items.ts b/src/game/Items.ts index 2b17f4a..58ebe63 100644 --- a/src/game/Items.ts +++ b/src/game/Items.ts @@ -82,10 +82,10 @@ export function isFood(id: ItemId): boolean { /** Order of items shown in the creative palette. */ export const CREATIVE_PALETTE: readonly ItemId[] = [ - "b1", "b2", "b3", "b4", "b5", "b6", "b9", "b19", + "b1", "b2", "b3", "b4", "b5", "b34", "b6", "b35", "b36", "b37", "b9", "b19", "b10", "b11", "b12", "b13", "b14", "b15", "b27", "b16", "b17", "b18", - "b20", "b21", "b22", "b23", + "b20", "b21", "b22", "b23", "b30", "b31", "b32", "b33", "b24", "b25", "b26", "b7", "apple", "bread", "cooked_beef", "cookie", "golden_apple", @@ -117,6 +117,9 @@ const DROP_TABLE: Record = { 10: "b2", // snowy grass -> dirt 11: null, // ice -> melts to nothing 26: null, // jungle leaves -> nothing + 35: null, // birch leaves -> nothing + 36: null, // spruce leaves -> nothing + 37: null, // snowy leaves -> nothing }; export function dropForBlock(blockId: BlockId): ItemId | null { @@ -131,6 +134,7 @@ const HARDNESS: Record = { [WATER_BLOCK]: "unbreakable", // fluids can't be punched away [WATER_FLOWING_BLOCK]: "unbreakable", // flowing water likewise 20: "instant", 21: "instant", 22: "instant", [MUSHROOM_BLOCK]: "instant", // plantlike + 30: "instant", 31: "instant", 32: "instant", 33: "instant", // dead bush, fern, papyrus, cornflower }; function hardnessOf(blockId: BlockId): Hardness { diff --git a/src/game/TerrainGenerator.ts b/src/game/TerrainGenerator.ts index 6c25208..6b1256e 100644 --- a/src/game/TerrainGenerator.ts +++ b/src/game/TerrainGenerator.ts @@ -133,7 +133,7 @@ export class TerrainGenerator { const snowNoise = this.noise.fbm2(wx * 0.045 + 1200, wz * 0.045 + 1200, 2); const blendNoise = this.noise.fbm2(wx * 0.07 + 900, wz * 0.07 + 900, 2); const beach = shoreBeach(biome, slope, beachStrength); - const d = decideSurface(SEA, height, slope, biome, height, nearWater, beach.hasBeach, beach.width); + const d = decideSurface(SEA, height, slope, biome, nearWater, beach.hasBeach, beach.width); const snow = snowFactor(effHeat, snowNoise); const blend = landBlend(c.heat, c.humidity); const blendSurface = BIOME_DEFS[blend.second].surface; @@ -276,7 +276,6 @@ export class TerrainGenerator { // gets a proper sandy shore instead of the ocean-floor material. For // truly underwater columns decideSurface ignores .surface anyway. const biome = sel.id === "ocean" ? BIOME_DEFS[sel.landId] : BIOME_DEFS[sel.id]; - const h2 = heightCol[ci]; const slope = this.columnSlope(heightCol, lx, lz, wx, wz, size); const nearWater = this.nearWaterRadius(surfY, lx, lz, wx, wz, size, 2); // Biome-edge blend: near a climate boundary, mottle the surface toward @@ -294,7 +293,6 @@ export class TerrainGenerator { slope, biome, effHeat: effHeatCol[ci], - height: h2, nearWater, blendEdge: blend.edge, blendSurface, diff --git a/src/game/gen/Surface.ts b/src/game/gen/Surface.ts index 3c0df44..9b1db5d 100644 --- a/src/game/gen/Surface.ts +++ b/src/game/gen/Surface.ts @@ -43,7 +43,6 @@ export interface SurfaceCtx { slope: number; biome: BiomeDef; effHeat: number; - height: number; /** A water column exists within the shore radius (bank/shore awareness). */ nearWater: boolean; /** 0..1 — how close the column is to a climate-biome boundary. */ @@ -134,12 +133,10 @@ export function decideSurface( topY: number, slope: number, biome: BiomeDef, - height: number, nearWater: boolean, hasBeach: boolean, beachWidth: number, ): SurfaceDecision { - void height; const above = topY - sea; const depth = sea - topY; const underwater = topY < sea; @@ -206,7 +203,7 @@ export class SurfacePainter { ) {} paint(ctx: SurfaceCtx): void { - const { blocks, size, lx, lz, topY, slope, biome, effHeat, height, nearWater, wx, wz, blendEdge, blendSurface } = ctx; + const { blocks, size, lx, lz, topY, slope, biome, effHeat, nearWater, wx, wz, blendEdge, blendSurface } = ctx; if (topY < 1) return; const idx = (y: number): number => (y * size + lz) * size + lx; @@ -219,7 +216,7 @@ export class SurfacePainter { const blendNoise = this.noise.fbm2(wx * 0.07 + 900, wz * 0.07 + 900, 2); const beach = shoreBeach(biome, slope, beachStrength); - const d = decideSurface(this.sea, topY, slope, biome, height, nearWater, beach.hasBeach, beach.width); + const d = decideSurface(this.sea, topY, slope, biome, nearWater, beach.hasBeach, beach.width); const snow = snowFactor(effHeat, snowNoise); const surf = applySnowAndBlend(d, snow, blendEdge, blendSurface, blendNoise); blocks[idx(topY)] = surf; diff --git a/src/game/gen/Trees.ts b/src/game/gen/Trees.ts index 4db8122..43386b2 100644 --- a/src/game/gen/Trees.ts +++ b/src/game/gen/Trees.ts @@ -140,10 +140,4 @@ export class TreeGenerator { } for (let y = baseY; y < topY; y++) set(lx, y, lz, B.WOOD); } - - /** Cactus column (desert). */ - placeCactus(set: SetBlock, lx: number, baseY: number, lz: number, wx: number, wz: number): void { - const h = 2 + Math.floor(this.r(wx, wz, 32) * 3); - for (let i = 0; i < h; i++) set(lx, baseY + i, lz, B.CACTUS); - } } diff --git a/src/ui/WorldgenOverlay.ts b/src/ui/WorldgenOverlay.ts index b241d4a..aab6960 100644 --- a/src/ui/WorldgenOverlay.ts +++ b/src/ui/WorldgenOverlay.ts @@ -105,6 +105,13 @@ export class WorldgenOverlay { setVisible(v: boolean): void { this.visible = v; this.root.hidden = !v; + if (!v) { + // Invalidate the minimap cache so reopening forces a fresh render instead + // of showing a stale canvas from the previous session. + this.lastRenderX = Number.NaN; + this.lastRenderZ = Number.NaN; + this.lastRenderMode = null; + } } toggle(): boolean { @@ -189,7 +196,7 @@ export class WorldgenOverlay { rgb = heatColor(d.effHeat); break; case "humidity": - // dry(brown) → wet(blue) + // dry(red) → wet(blue); heatColor is inverted so 0=red, 1=blue-ish rgb = heatColor(1 - d.humidity); break; case "height": {