basin / commit
Add terrain aging (erosion) config axis
index.html | 4 ++ readme.md | 24 ++++++--- src/main.ts | 14 +++++ src/model/heightmap.test.ts | 46 ++++++++++++++++- src/model/heightmap.ts | 123 ++++++++++++++++++++++++++++++++++++++++++++ src/model/puzzle.test.ts | 45 ++++++++++++++++ src/model/puzzle.ts | 34 +++++++++--- 7 files changed, 274 insertions(+), 16 deletions(-) diff --git a/index.html b/index.html index 63d3ba2..2a21fd6 100644 --- a/index.html +++ b/index.html @@ -29,6 +29,10 @@ <label><input type="checkbox" id="cfg-wall-s" /> S</label> <label><input type="checkbox" id="cfg-wall-w" /> W</label> </fieldset> + <label class="ctl" title="Simulated erosion: how weathered the terrain is before you arrive. 0 is a pristine dome; higher values carve grooves and change the edge pattern."> + Aging <span id="cfg-erosion-val" class="val"></span> + <input type="range" id="cfg-erosion" min="0" max="15" step="1" /> + </label> <label class="ctl"> Terrain <span class="seedrow"> diff --git a/readme.md b/readme.md index 817a862..dd6eb3f 100644 --- a/readme.md +++ b/readme.md @@ -22,6 +22,12 @@ a **mountain wall** (high ground that blocks water). Walls make canyons: e.g. north + south walls with east + west oceans is a valley that drains sideways. Water can never leave through a wall. +The terrain can also be **aged**: an *erosion* amount simulates how much +weathering happened before you arrived. At 0 the board is a pristine dome; turn +it up and simulated hydraulic erosion carves grooves and channels, ragging the +coastline and moving where water finally reaches the sea — so a heavily-aged +board reads a visibly different edge signature from its pristine self. + ## How to play **Goal:** find the positions of the hidden springs that reproduce that signature. @@ -38,12 +44,13 @@ found, so a board is always produced. ## Configuring & sharing boards Every axis of a board is a control in the panel: **size**, **spring count**, which -sides are **walls**, and the **terrain seed**. All of it is captured in a single -shareable **code** — an 8-character token (e.g. `B0B0bgTL`) that packs the four -axes into 44 bits and base64url-encodes them, so the settings aren't legible at a -glance (reversible obfuscation, not encryption). The code and the controls are -two-way views of the same config — changing any control rewrites the code, and -pasting a code sets every control. +sides are **walls**, the **aging** (erosion) amount, and the **terrain seed**. All +of it is captured in a single shareable **code** — an 8-character token (e.g. +`B0B0bgTL`) that packs the five axes into 48 bits and base64url-encodes them, so +the settings aren't legible at a glance (reversible obfuscation, not encryption). +The code and the controls are two-way views of the same config — changing any +control rewrites the code, and pasting a code sets every control. Older codes +predate the aging axis and simply decode to a pristine (un-eroded) board. The code also lives in the URL as `?code=<code>`, so a link reproduces the exact board. **🎲 Random** rolls a whole new config; the 🎲 next to Terrain rerolls just @@ -71,8 +78,9 @@ The model (typed arrays) is decoupled from the renderer so a tiled/WebGL rendere can replace Canvas2D for large boards later. - `src/model/heightmap.ts` — terrain + per-side edge types (ocean/wall) + - priority-flood depression filling (guarantees every cell drains to an *ocean* - edge, seeded only from ocean sides). + droplet-based hydraulic **erosion** (the aging axis; skipped, and rng-free, at + 0) + priority-flood depression filling (guarantees every cell drains to an + *ocean* edge, seeded only from ocean sides). - `src/model/flow.ts` — the branching watershed flood (single- and multi-spring). - `src/model/puzzle.ts` — the board `Config`, its shareable code (encode/decode), config-driven puzzle generation (with an unambiguous-solution check), and the diff --git a/src/main.ts b/src/main.ts index 5d8d330..40f9aad 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,6 +26,8 @@ const game = new Game(canvas, status); const sizeInput = $<HTMLInputElement>('#cfg-size'); const sizeVal = $<HTMLElement>('#cfg-size-val'); const springsInput = $<HTMLInputElement>('#cfg-springs'); +const erosionInput = $<HTMLInputElement>('#cfg-erosion'); +const erosionVal = $<HTMLElement>('#cfg-erosion-val'); const wall = { n: $<HTMLInputElement>('#cfg-wall-n'), e: $<HTMLInputElement>('#cfg-wall-e'), @@ -44,6 +46,8 @@ function syncControls(): void { sizeInput.value = String(config.size); sizeVal.textContent = `${config.size}×${config.size}`; springsInput.value = String(config.springCount); + erosionInput.value = String(config.erosion); + erosionVal.textContent = agingLabel(config.erosion); wall.n.checked = config.edges.n === 'wall'; wall.e.checked = config.edges.e === 'wall'; wall.s.checked = config.edges.s === 'wall'; @@ -65,12 +69,22 @@ function apply(next: Config): void { const edgeOf = (checked: boolean) => (checked ? 'wall' : 'ocean'); +/** Human tag for an erosion level, so the slider reads as more than a number. */ +function agingLabel(n: number): string { + const word = n === 0 ? 'pristine' : n <= 4 ? 'weathered' : n <= 9 ? 'eroded' : 'dissected'; + return `${n} · ${word}`; +} + // Live-update the size label while dragging, but only rebuild on release. sizeInput.addEventListener('input', () => (sizeVal.textContent = `${sizeInput.value}×${sizeInput.value}`)); sizeInput.addEventListener('change', () => apply({ ...config, size: Number(sizeInput.value) })); springsInput.addEventListener('change', () => apply({ ...config, springCount: Number(springsInput.value) }), ); +// Live-update the aging label while dragging; erosion is costly, so only rebuild +// on release. +erosionInput.addEventListener('input', () => (erosionVal.textContent = agingLabel(Number(erosionInput.value)))); +erosionInput.addEventListener('change', () => apply({ ...config, erosion: Number(erosionInput.value) })); for (const side of ['n', 'e', 's', 'w'] as const) { wall[side].addEventListener('change', () => apply({ ...config, edges: { ...config.edges, [side]: edgeOf(wall[side].checked) } }), diff --git a/src/model/heightmap.test.ts b/src/model/heightmap.test.ts index 2b1cf65..8d7956a 100644 --- a/src/model/heightmap.test.ts +++ b/src/model/heightmap.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { generateDome, resolveDrainage, heightAt, isOceanEdge, ALL_OCEAN } from './heightmap'; +import { generateDome, resolveDrainage, erode, heightAt, isOceanEdge, ALL_OCEAN } from './heightmap'; import type { Edges, Heightmap } from './heightmap'; import { simulateFlow } from './flow'; import { mulberry32 } from '../rng'; @@ -84,6 +84,50 @@ describe('generateDome with walls', () => { }); }); +describe('erode', () => { + it('strength 0 is a no-op that touches neither the terrain nor the rng', () => { + const hm = generateDome(10, 10, mulberry32(3)); + const snapshot = Float32Array.from(hm.data); + + const rng = mulberry32(99); + erode(hm, rng, 0); + expect(Array.from(hm.data)).toEqual(Array.from(snapshot)); // terrain untouched + // The rng stream is untouched, so the very next draw is a fresh first draw. + expect(rng()).toBe(mulberry32(99)()); + }); + + it('reshapes the terrain and cuts channels below the pristine surface', () => { + const pristine = generateDome(16, 16, mulberry32(7)); + const aged = generateDome(16, 16, mulberry32(7), ALL_OCEAN, 12); + + // Something changed, and at least some interior cell was cut lower than it + // stood on the pristine dome (a groove), not merely silted higher everywhere. + let changed = 0; + let cutDeeper = 0; + for (let y = 1; y < 15; y++) { + for (let x = 1; x < 15; x++) { + const i = y * 16 + x; + if (aged.data[i] !== pristine.data[i]) changed++; + if (aged.data[i] < pristine.data[i] - 1e-3) cutDeeper++; + } + } + expect(changed).toBeGreaterThan(0); + expect(cutDeeper).toBeGreaterThan(0); + }); + + it('never lowers an ocean edge below 0', () => { + const hm = generateDome(12, 12, mulberry32(21), ALL_OCEAN, 15); + for (let x = 0; x < 12; x++) { + expect(heightAt(hm, x, 0)).toBe(0); + expect(heightAt(hm, x, 11)).toBe(0); + } + for (let y = 0; y < 12; y++) { + expect(heightAt(hm, 0, y)).toBe(0); + expect(heightAt(hm, 11, y)).toBe(0); + } + }); +}); + describe('resolveDrainage', () => { it('raises a sealed interior pit until it drains', () => { // A single deep pit walled off by higher ground; before filling it cannot diff --git a/src/model/heightmap.ts b/src/model/heightmap.ts index 1130134..5ddb2db 100644 --- a/src/model/heightmap.ts +++ b/src/model/heightmap.ts @@ -62,12 +62,18 @@ function distToOcean(x: number, y: number, width: number, height: number, edges: * ocean sides fall to 0 while mountain-wall sides stand up as high ground — * water runs off the walls toward the oceans (e.g. n+s walls make a canyon that * drains east and west). + * + * `erosion` (0 = pristine) then ages the freshly-raised dome with simulated + * hydraulic erosion before drainage is resolved, carving grooves that also shift + * the eventual outflow signature (see {@link erode}). `erosion === 0` consumes no + * `rng`, so a pristine board is bit-for-bit what it always was. */ export function generateDome( width: number, height: number, rng: Rng, edges: Edges = ALL_OCEAN, + erosion = 0, ): Heightmap { const data = new Float32Array(width * height); @@ -111,10 +117,127 @@ export function generateDome( if (peak > 0) for (let i = 0; i < data.length; i++) data[i] /= peak; const hm: Heightmap = { width, height, data, edges }; + erode(hm, rng, erosion); // age the terrain before resolving drainage resolveDrainage(hm); return hm; } +// 8-connected neighbours for erosion droplets — diagonals included so carved +// channels aren't locked to the cardinal grid (the flow model still uses the 4 +// cardinals; erosion is purely terrain shaping). +const EROSION_NEIGHBORS: ReadonlyArray<readonly [number, number]> = [ + [0, -1], + [1, -1], + [1, 0], + [1, 1], + [0, 1], + [-1, 1], + [-1, 0], + [-1, -1], +]; + +/** + * Simulated aging by hydraulic ("droplet") erosion — the geomorphic process that + * turns a smooth dome into a grooved, dissected landscape. `strength` is roughly + * "how much time has passed": each unit rains more droplets over the board. + * + * Every droplet starts on a random interior cell and runs downhill by steepest + * descent, carrying sediment. On a steep, fast reach it picks terrain up (cutting + * a channel); where it slows or overfills it drops sediment back (silting flats + * and pit floors). Repeated over many droplets this concentrates flow into + * dendritic grooves and reshapes where water finally reaches the sea — so a + * heavily-aged board reads a visibly different edge signature from its pristine + * self. + * + * Invariants preserved for the caller: + * - Ocean-edge cells are never written (they stay exactly 0 — that's the sea a + * droplet washes out into and stops). + * - Erosion is capped at the local descent, so it never digs a cell below the + * neighbour it flows to; any residual flats/pits are still cleaned up by the + * {@link resolveDrainage} pass the generator runs afterwards. + * - `strength <= 0` returns immediately without touching `rng`, so a pristine + * board is identical to one generated before erosion existed. + */ +export function erode(hm: Heightmap, rng: Rng, strength: number): void { + if (strength <= 0) return; + const { width, height, data, edges } = hm; + if (width < 3 || height < 3) return; // no interior to seed droplets on + + // Tuning constants for grid-droplet hydraulic erosion. Chosen so strength 1 + // lightly weathers the dome and the max clearly dissects it into grooves. + const dropsPerCell = 0.6; + const maxSteps = width + height; + const capacity = 6; // sediment a droplet holds per unit of slope·speed + const minSlope = 0.005; // keeps near-flat reaches still mildly erosive + const erodeRate = 0.3; + const depositRate = 0.25; + const gravity = 8; + const evaporate = 0.01; + + const drops = Math.round(strength * width * height * dropsPerCell); + + for (let d = 0; d < drops; d++) { + // Two rng draws per droplet — the only rng this function consumes, so the + // stream stays a deterministic function of (seed, strength, board size). + let x = 1 + Math.floor(rng() * (width - 2)); + let y = 1 + Math.floor(rng() * (height - 2)); + let sediment = 0; + let speed = 0; + let water = 1; + + for (let step = 0; step < maxSteps; step++) { + // Reaching the sea ends the droplet; its sediment washes out and is lost. + if (isOceanEdge(edges, x, y, width, height)) break; + + const i = y * width + x; + const h = data[i]; + + // Steepest-descent 8-neighbour. + let bx = -1; + let by = -1; + let bh = h; + for (const [dx, dy] of EROSION_NEIGHBORS) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue; + const nh = data[ny * width + nx]; + if (nh < bh) { + bh = nh; + bx = nx; + by = ny; + } + } + + if (bx < 0) { + // Local pit: nowhere lower to go. Silt it up with the carried load. + data[i] = Math.min(1, h + sediment); + break; + } + + const drop = h - bh; // > 0 + const cap = Math.max(drop, minSlope) * (speed + 1) * water * capacity; + + if (sediment > cap) { + // Over capacity → deposit, but never above the cell we came to rest on. + const dep = Math.min((sediment - cap) * depositRate, sediment); + data[i] = Math.min(1, h + dep); + sediment -= dep; + } else { + // Under capacity → cut, but never below the neighbour we descend to, so + // the path stays monotone and we don't spike a fresh pit. + const ero = Math.min((cap - sediment) * erodeRate, drop); + data[i] = h - ero; + sediment += ero; + } + + speed = Math.sqrt(Math.max(0, speed * speed + drop * gravity)); + water *= 1 - evaporate; + x = bx; + y = by; + } + } +} + const DRAIN_NEIGHBORS: ReadonlyArray<readonly [number, number]> = [ [0, -1], [1, 0], diff --git a/src/model/puzzle.test.ts b/src/model/puzzle.test.ts index fbffa72..bd7383b 100644 --- a/src/model/puzzle.test.ts +++ b/src/model/puzzle.test.ts @@ -10,6 +10,7 @@ import { SIZE_MAX, SPRINGS_MIN, SPRINGS_MAX, + EROSION_MAX, } from './puzzle'; import type { Config } from './puzzle'; import { simulateFlows } from './flow'; @@ -24,6 +25,7 @@ const cfg = (over: Partial<Config> = {}): Config => ({ size: SIZE, springCount: COUNT, edges: ALL_OCEAN, + erosion: 0, terrainSeed: 4242, ...over, }); @@ -136,6 +138,8 @@ describe('config code', () => { cfg({ size: 5, springCount: 1, edges: { n: 'wall', e: 'wall', s: 'wall', w: 'ocean' } }), cfg({ size: 20, springCount: 6, edges: CANYON, terrainSeed: 0xffffffff }), cfg({ terrainSeed: 0 }), + cfg({ erosion: 1 }), + cfg({ erosion: EROSION_MAX, size: 16, springCount: 4 }), ]; for (const c of configs) { const code = encodeConfig(c); @@ -164,6 +168,47 @@ describe('config code', () => { expect(tiny.size).toBe(SIZE_MIN); expect(tiny.springCount).toBe(SPRINGS_MIN); }); + + it('clamps erosion into range and defaults a missing value to 0', () => { + expect(normalizeConfig(cfg({ erosion: 999 })).erosion).toBe(EROSION_MAX); + expect(normalizeConfig(cfg({ erosion: -5 })).erosion).toBe(0); + // Older codes predate erosion, so decoding must land on a pristine board. + const legacy = cfg(); + delete (legacy as Partial<Config>).erosion; + expect(normalizeConfig(legacy).erosion).toBe(0); + }); +}); + +describe('erosion (aging)', () => { + it('leaves a pristine (erosion 0) board bit-for-bit unchanged', () => { + // Backward compatibility: adding the erosion axis must not consume rng when + // it is 0, so every previously-shared code reproduces its exact board. + const pristine = generatePuzzle(cfg({ erosion: 0, terrainSeed: 777 })); + const aged = generatePuzzle(cfg({ erosion: 5, terrainSeed: 777 })); + expect(Array.from(aged.hm.data)).not.toEqual(Array.from(pristine.hm.data)); + }); + + it('keeps the drainage invariant and ocean edges at 0 after aging', () => { + for (const erosion of [1, 5, 10, EROSION_MAX]) { + for (const seed of [1, 42, 12345]) { + const p = generatePuzzle(cfg({ size: 14, erosion, edges: CANYON, terrainSeed: seed })); + // Ocean edges stay exactly 0 (the sea erosion washes out into). + for (let y = 0; y < 14; y++) { + for (let x = 0; x < 14; x++) { + if (isOceanEdge(p.edges, x, y, 14, 14)) expect(p.hm.data[y * 14 + x]).toBe(0); + } + } + // Every interior cell still drains out to an ocean edge. + for (let y = 1; y < 13; y++) { + for (let x = 1; x < 13; x++) { + const { outflow } = simulateFlows(p.hm, [{ x, y }]); + expect(outflow.length).toBeGreaterThan(0); + for (const o of outflow) expect(isOceanEdge(p.edges, o.x, o.y, 14, 14)).toBe(true); + } + } + } + } + }); }); describe('randomConfig', () => { diff --git a/src/model/puzzle.ts b/src/model/puzzle.ts index c928e4e..50f42f3 100644 --- a/src/model/puzzle.ts +++ b/src/model/puzzle.ts @@ -17,6 +17,12 @@ export interface Config { springCount: number; /** Per-side edge types (ocean vs wall). */ edges: Edges; + /** + * How much simulated erosion (aging) the terrain underwent before the board + * was shown. 0 = a pristine dome; higher values carve deeper grooves and shift + * the outflow signature. See {@link erode}. + */ + erosion: number; /** Seed for the terrain noise + spring placement (given the other axes). */ terrainSeed: number; } @@ -25,6 +31,8 @@ export const SIZE_MIN = 5; export const SIZE_MAX = 20; export const SPRINGS_MIN = 1; export const SPRINGS_MAX = 6; +export const EROSION_MIN = 0; +export const EROSION_MAX = 15; // fits the 4 spare bits of the config code /** * A solvable board (Reading A): terrain + a target edge signature are given, and @@ -59,9 +67,9 @@ export interface Puzzle { */ export function generatePuzzle(config: Config): Puzzle { const norm = normalizeConfig(config); - const { size, springCount, edges, terrainSeed } = norm; + const { size, springCount, edges, erosion, terrainSeed } = norm; const rng = mulberry32(terrainSeed); - const hm = generateDome(size, size, rng, edges); + const hm = generateDome(size, size, rng, edges, erosion); // Candidates: interior (non-edge) land cells. After drainage resolution every // interior cell has height > 0 and a strictly-decreasing path to an ocean edge. @@ -147,7 +155,14 @@ export function normalizeConfig(c: Config): Config { const size = clampInt(c.size, SIZE_MIN, SIZE_MAX); const interior = (size - 2) * (size - 2); const springCount = clampInt(c.springCount, SPRINGS_MIN, Math.min(SPRINGS_MAX, interior)); - return { size, springCount, edges: ensureOcean(c.edges), terrainSeed: c.terrainSeed >>> 0 }; + const erosion = clampInt(c.erosion ?? 0, EROSION_MIN, EROSION_MAX); + return { + size, + springCount, + edges: ensureOcean(c.edges), + erosion, + terrainSeed: c.terrainSeed >>> 0, + }; } /** A wholly random board within sensible bounds. */ @@ -156,13 +171,16 @@ export function randomConfig(): Config { size: 8 + Math.floor(Math.random() * 5) * 2, // 8,10,12,14,16 springCount: 2 + Math.floor(Math.random() * 3), // 2,3,4 edges: rollEdges(Math.random), + erosion: Math.floor(Math.random() * 9), // 0..8: pristine to well-weathered terrainSeed: (Math.random() * 2 ** 32) >>> 0, }); } -// The config packs into 44 bits — size(5) · springs(3) · edges(4) · seed(32) — -// which we emit as an 8-char base64url string so the axes aren't legible at a -// glance. It's obfuscation, not encryption: fully reversible, no secrets. +// The config packs into 48 bits — seed(32) · edges(4) · springs(3) · size(5) · +// erosion(4) — which we emit as an 8-char base64url string so the axes aren't +// legible at a glance. It's obfuscation, not encryption: fully reversible, no +// secrets. Erosion occupies bits 44-47, which older (erosion-free) codes left 0, +// so those codes still decode to a pristine board. const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; const CODE_LEN = 8; @@ -173,7 +191,8 @@ export function encodeConfig(c: Config): string { BigInt(n.terrainSeed >>> 0) | (BigInt(edgesToMask(n.edges)) << 32n) | (BigInt(n.springCount) << 36n) | - (BigInt(n.size) << 39n); + (BigInt(n.size) << 39n) | + (BigInt(n.erosion) << 44n); let out = ''; for (let i = 0; i < CODE_LEN; i++) { out = B64[Number(v & 63n)] + out; @@ -196,6 +215,7 @@ export function decodeConfig(code: string): Config | null { size: Number((v >> 39n) & 0x1fn), springCount: Number((v >> 36n) & 0x7n), edges: maskToEdges(Number((v >> 32n) & 0xfn)), + erosion: Number((v >> 44n) & 0xfn), terrainSeed: Number(v & 0xffffffffn) >>> 0, }); }