Spaces:
Running
Running
| // The world model on top of the height field: where water goes, and what that | |
| // implies for everything else. | |
| // | |
| // A height field alone is scenery. What makes it read as a world is that the | |
| // terrain decides the water, the water decides the moisture, and moisture plus | |
| // slope and elevation decide what lives where. Each layer here is derived from | |
| // the one above it, so a rerolled world stays internally consistent: rivers run | |
| // downhill into the basins, forests thicken along them, and cliffs stay bare. | |
| import { GRID } from './world.js'; | |
| const idx = (x, y) => y * GRID + x; | |
| const inside = (x, y) => x >= 0 && x < GRID && y >= 0 && y < GRID; | |
| /** | |
| * Droplet erosion. Each drop walks downhill, picking up sediment on steep ground | |
| * and dropping it where the slope eases, which is what cuts valleys instead of | |
| * just adding more noise. The flow it leaves behind is the river network. | |
| */ | |
| export function erode(height, rng, drops = 12000) { | |
| const flow = new Float32Array(GRID * GRID); | |
| const capacity = 3.2, deposition = 0.28, erosion = 0.42, evaporation = 0.02; | |
| for (let d = 0; d < drops; d++) { | |
| let x = rng() * (GRID - 1); | |
| let y = rng() * (GRID - 1); | |
| let vx = 0, vy = 0, water = 1, sediment = 0; | |
| for (let step = 0; step < 64; step++) { | |
| const gx = Math.floor(x), gy = Math.floor(y); | |
| if (!inside(gx + 1, gy + 1) || !inside(gx - 1, gy - 1)) break; | |
| // bilinear gradient | |
| const fx = x - gx, fy = y - gy; | |
| const h00 = height[idx(gx, gy)], h10 = height[idx(gx + 1, gy)]; | |
| const h01 = height[idx(gx, gy + 1)], h11 = height[idx(gx + 1, gy + 1)]; | |
| const gradX = (h10 - h00) * (1 - fy) + (h11 - h01) * fy; | |
| const gradY = (h01 - h00) * (1 - fx) + (h11 - h10) * fx; | |
| vx = vx * 0.82 - gradX; | |
| vy = vy * 0.82 - gradY; | |
| const len = Math.hypot(vx, vy); | |
| if (len < 1e-4) break; | |
| vx /= len; vy /= len; | |
| const hOld = h00 * (1 - fx) * (1 - fy) + h10 * fx * (1 - fy) + | |
| h01 * (1 - fx) * fy + h11 * fx * fy; | |
| x += vx; y += vy; | |
| if (!inside(Math.floor(x), Math.floor(y))) break; | |
| const hNew = height[idx(Math.floor(x), Math.floor(y))]; | |
| const drop = hOld - hNew; | |
| flow[idx(Math.floor(x), Math.floor(y))] += water; | |
| const cap = Math.max(0, drop) * water * capacity; | |
| if (sediment > cap || drop < 0) { | |
| // uphill or over capacity: lay sediment down, filling the hollow | |
| const give = drop < 0 ? Math.min(sediment, -drop) : (sediment - cap) * deposition; | |
| height[idx(gx, gy)] += give; | |
| sediment -= give; | |
| } else { | |
| const take = Math.min((cap - sediment) * erosion, Math.max(0, drop)); | |
| height[idx(gx, gy)] -= take; | |
| sediment += take; | |
| } | |
| water *= (1 - evaporation); | |
| if (water < 0.02) break; | |
| } | |
| } | |
| return flow; | |
| } | |
| /** | |
| * Drainage area per cell (D8): every cell sheds one unit of rain into its | |
| * steepest downhill neighbour, processed from the highest ground down so each | |
| * cell already holds everything upstream of it by the time it drains. | |
| * | |
| * Droplet paths alone do not make a river network — with one drop per few cells | |
| * the traces never converge. Drainage area does, and it is what actually decides | |
| * where a stream becomes a river: the network is dendritic because the terrain is. | |
| */ | |
| /** | |
| * Priority-Flood depression filling. Water is poured in from the borders and | |
| * raised only as much as it must be, so every cell ends up with a downhill path | |
| * to the edge — and wherever the filled surface sits above the ground, that is a | |
| * lake, obtained for free. | |
| * | |
| * Without this, drainage is meaningless here: erosion leaves thousands of small | |
| * pits, each one swallowing its catchment, so accumulation never grows past a | |
| * couple of hundred cells and no river ever forms. | |
| */ | |
| export function fillDepressions(height) { | |
| const n = GRID * GRID; | |
| const filled = Float32Array.from(height); | |
| const closed = new Uint8Array(n); | |
| // binary heap keyed on height | |
| const hp = []; | |
| const push = (i) => { | |
| hp.push(i); | |
| let c = hp.length - 1; | |
| while (c > 0) { | |
| const p = (c - 1) >> 1; | |
| if (filled[hp[p]] <= filled[hp[c]]) break; | |
| [hp[p], hp[c]] = [hp[c], hp[p]]; | |
| c = p; | |
| } | |
| }; | |
| const pop = () => { | |
| const top = hp[0], last = hp.pop(); | |
| if (hp.length) { | |
| hp[0] = last; | |
| let p = 0; | |
| for (;;) { | |
| const l = p * 2 + 1, r = l + 1; | |
| let s = p; | |
| if (l < hp.length && filled[hp[l]] < filled[hp[s]]) s = l; | |
| if (r < hp.length && filled[hp[r]] < filled[hp[s]]) s = r; | |
| if (s === p) break; | |
| [hp[p], hp[s]] = [hp[s], hp[p]]; | |
| p = s; | |
| } | |
| } | |
| return top; | |
| }; | |
| for (let x = 0; x < GRID; x++) { | |
| for (const y of [0, GRID - 1]) { const i = idx(x, y); closed[i] = 1; push(i); } | |
| } | |
| for (let y = 1; y < GRID - 1; y++) { | |
| for (const x of [0, GRID - 1]) { const i = idx(x, y); closed[i] = 1; push(i); } | |
| } | |
| while (hp.length) { | |
| const i = pop(); | |
| const x = i % GRID, y = (i / GRID) | 0; | |
| for (let dy = -1; dy <= 1; dy++) { | |
| for (let dx = -1; dx <= 1; dx++) { | |
| if (!dx && !dy) continue; | |
| const nx = x + dx, ny = y + dy; | |
| if (!inside(nx, ny)) continue; | |
| const j = idx(nx, ny); | |
| if (closed[j]) continue; | |
| closed[j] = 1; | |
| // raise just enough to drain, with a hair of slope so D8 has a direction | |
| filled[j] = Math.max(filled[j], filled[i] + 1e-4); | |
| push(j); | |
| } | |
| } | |
| } | |
| return filled; | |
| } | |
| export function drainage(height) { | |
| const n = GRID * GRID; | |
| const acc = new Float32Array(n).fill(1); | |
| const order = Array.from({ length: n }, (_, i) => i) | |
| .sort((a, b) => height[b] - height[a]); | |
| const sinks = []; | |
| for (const i of order) { | |
| const x = i % GRID, y = (i / GRID) | 0; | |
| let best = -1, bestDrop = 0; | |
| for (let dy = -1; dy <= 1; dy++) { | |
| for (let dx = -1; dx <= 1; dx++) { | |
| if (!dx && !dy) continue; | |
| const nx = x + dx, ny = y + dy; | |
| if (!inside(nx, ny)) continue; | |
| const j = idx(nx, ny); | |
| const drop = (height[i] - height[j]) / Math.hypot(dx, dy); | |
| if (drop > bestDrop) { bestDrop = drop; best = j; } | |
| } | |
| } | |
| if (best >= 0) acc[best] += acc[i]; | |
| else sinks.push(i); | |
| } | |
| return { acc, sinks }; | |
| } | |
| /** | |
| * Standing and running water. Rivers come from drainage area; seas and lakes | |
| * are simply everything below the water line. Returns the water depth per cell | |
| * (0 where dry) and the surface height to render. | |
| */ | |
| export function hydrology(height, flow, seaLevel, filled) { | |
| const surface = new Float32Array(GRID * GRID); | |
| const depth = new Float32Array(GRID * GRID); | |
| // A stream becomes visible once it drains enough ground — the same rule a map | |
| // uses. 0.4% of the grid is roughly a first-order stream at this resolution. | |
| const riverThreshold = GRID * GRID * 0.004; | |
| let maxFlow = riverThreshold; | |
| for (let i = 0; i < flow.length; i++) maxFlow = Math.max(maxFlow, flow[i]); | |
| for (let y = 0; y < GRID; y++) { | |
| for (let x = 0; x < GRID; x++) { | |
| const i = idx(x, y); | |
| const h = height[i]; | |
| if (seaLevel > -900 && h < seaLevel) { | |
| surface[i] = seaLevel; | |
| depth[i] = seaLevel - h; | |
| continue; | |
| } | |
| // A filled depression is a lake — but only a real one. The fill raises | |
| // cells by a hair as it propagates outward, and treating those as water | |
| // hangs sheets of it down every cliff, so a lake has to be deep enough | |
| // to be a lake. | |
| if (filled && filled[i] - h > 0.4) { | |
| surface[i] = filled[i]; | |
| depth[i] = filled[i] - h; | |
| continue; | |
| } | |
| if (flow[i] <= riverThreshold) continue; | |
| // Water only stays where the ground can hold it. Drops run down steep | |
| // faces and leave flow behind them, but painting a surface there gives | |
| // sheets of water clinging to cliffs — so the channel has to be flat | |
| // enough, and the steeper it is the more flow it takes to qualify. | |
| const dx = height[idx(Math.min(GRID - 1, x + 1), y)] - height[idx(Math.max(0, x - 1), y)]; | |
| const dy = height[idx(x, Math.min(GRID - 1, y + 1))] - height[idx(x, Math.max(0, y - 1))]; | |
| const grade = Math.hypot(dx, dy) / 2; // metres per cell | |
| const maxGrade = 0.7; | |
| if (grade > maxGrade) continue; | |
| const strength = Math.min(1, (flow[i] - riverThreshold) / (maxFlow - riverThreshold + 1e-6)); | |
| if (strength < (grade / maxGrade) * 0.35) continue; | |
| const d = 0.12 + strength * 0.45 * (1 - grade / maxGrade * 0.6); | |
| surface[i] = h + d; | |
| depth[i] = d; | |
| } | |
| } | |
| return { surface, depth }; | |
| } | |
| /** | |
| * Distance to the nearest water, in cells, by two-pass chamfer transform — cheap | |
| * and accurate enough to drive vegetation. Everything is thirsty; how thirsty is | |
| * what separates a riverbank from a dune field. | |
| */ | |
| export function moisture(depth) { | |
| const INF = 1e6; | |
| const dist = new Float32Array(GRID * GRID).fill(INF); | |
| for (let i = 0; i < depth.length; i++) if (depth[i] > 0) dist[i] = 0; | |
| for (let y = 0; y < GRID; y++) { | |
| for (let x = 0; x < GRID; x++) { | |
| let d = dist[idx(x, y)]; | |
| if (inside(x - 1, y)) d = Math.min(d, dist[idx(x - 1, y)] + 1); | |
| if (inside(x, y - 1)) d = Math.min(d, dist[idx(x, y - 1)] + 1); | |
| if (inside(x - 1, y - 1)) d = Math.min(d, dist[idx(x - 1, y - 1)] + 1.414); | |
| dist[idx(x, y)] = d; | |
| } | |
| } | |
| for (let y = GRID - 1; y >= 0; y--) { | |
| for (let x = GRID - 1; x >= 0; x--) { | |
| let d = dist[idx(x, y)]; | |
| if (inside(x + 1, y)) d = Math.min(d, dist[idx(x + 1, y)] + 1); | |
| if (inside(x, y + 1)) d = Math.min(d, dist[idx(x, y + 1)] + 1); | |
| if (inside(x + 1, y + 1)) d = Math.min(d, dist[idx(x + 1, y + 1)] + 1.414); | |
| dist[idx(x, y)] = d; | |
| } | |
| } | |
| // 0..1, saturating about 25 cells out | |
| const m = new Float32Array(GRID * GRID); | |
| for (let i = 0; i < m.length; i++) m[i] = Math.max(0, 1 - dist[i] / 25); | |
| return m; | |
| } | |
| /** | |
| * Where a creature can live. Habitats are expressed the way a field guide would: | |
| * water or land, how steep, how high, how wet — never "region 3", so the same | |
| * table works on any world the generator produces. | |
| */ | |
| export const HABITAT = { | |
| water: { water: [0.6, 99], slope: [0, 9], height: [-99, 99], moist: [0, 1] }, | |
| shallows: { water: [0.05, 1.2], slope: [0, 0.5], height: [-99, 99], moist: [0.5, 1] }, | |
| riverbank: { water: [0, 0.02], slope: [0, 0.45], height: [0, 99], moist: [0.55, 1] }, | |
| plain: { water: [0, 0.02], slope: [0, 0.35], height: [1, 99], moist: [0.1, 0.8] }, | |
| forest: { water: [0, 0.02], slope: [0, 0.6], height: [2, 99], moist: [0.35, 1] }, | |
| arid: { water: [0, 0.02], slope: [0, 0.5], height: [1, 99], moist: [0, 0.25] }, | |
| highland: { water: [0, 0.02], slope: [0.2, 1.2], height: [14, 99], moist: [0, 1] }, | |
| cliff: { water: [0, 0.02], slope: [0.7, 9], height: [6, 99], moist: [0, 1] }, | |
| }; | |
| function fits(rule, ctx) { | |
| return ctx.water >= rule.water[0] && ctx.water <= rule.water[1] && | |
| ctx.slope >= rule.slope[0] && ctx.slope <= rule.slope[1] && | |
| ctx.height >= rule.height[0] && ctx.height <= rule.height[1] && | |
| ctx.moist >= rule.moist[0] && ctx.moist <= rule.moist[1]; | |
| } | |
| /** | |
| * Populate the world. `species` is a list of {id, habitat, weight, scale, herd}, | |
| * so the caller supplies its own cast — dinosaurs, livestock, anything — and the | |
| * rules here decide where each one belongs. Herd animals are placed in clusters | |
| * because a lone sauropod on an empty plain does not read as a living world. | |
| */ | |
| export function populate(world, species, rng, opts = {}) { | |
| const { height, seaLevel } = world; | |
| const { depth, } = world.water; | |
| const moist = world.moisture; | |
| const step = (opts.worldSize || 200) / (GRID - 1); | |
| const budget = opts.budget || 120; | |
| const placed = []; | |
| const total = species.reduce((a, s) => a + (s.weight || 1), 0); | |
| for (const sp of species) { | |
| const want = Math.max(1, Math.round(budget * (sp.weight || 1) / total)); | |
| const rule = HABITAT[sp.habitat] || HABITAT.plain; | |
| let made = 0, tries = 0; | |
| while (made < want && tries < want * 220) { | |
| tries++; | |
| const gx = Math.floor(rng() * GRID), gy = Math.floor(rng() * GRID); | |
| const i = idx(gx, gy); | |
| const h = height[i]; | |
| const dxh = (height[idx(Math.min(GRID - 1, gx + 1), gy)] - height[idx(Math.max(0, gx - 1), gy)]); | |
| const dyh = (height[idx(gx, Math.min(GRID - 1, gy + 1))] - height[idx(gx, Math.max(0, gy - 1))]); | |
| const slope = Math.hypot(dxh, dyh) / (step * 2); | |
| if (!fits(rule, { water: depth[i], slope, height: h, moist: moist[i] })) continue; | |
| // herd members share a neighbourhood rather than being sprinkled | |
| const group = sp.herd ? 1 + Math.floor(rng() * sp.herd) : 1; | |
| for (let g = 0; g < group && made < want; g++) { | |
| const jx = gx + (g ? Math.round((rng() - 0.5) * 10) : 0); | |
| const jy = gy + (g ? Math.round((rng() - 0.5) * 10) : 0); | |
| if (!inside(jx, jy)) continue; | |
| const j = idx(jx, jy); | |
| if (!fits(rule, { water: depth[j], slope, height: height[j], moist: moist[j] })) continue; | |
| placed.push({ | |
| id: sp.id, | |
| x: -(opts.worldSize || 200) / 2 + jx * step, | |
| z: -(opts.worldSize || 200) / 2 + jy * step, | |
| y: depth[j] > 0.05 ? Math.max(height[j], seaLevel) : height[j], | |
| scale: (sp.scale || 1) * (0.85 + rng() * 0.3), | |
| rot: rng() * Math.PI * 2, | |
| }); | |
| made++; | |
| } | |
| } | |
| } | |
| return placed; | |
| } | |