biome.js
112 lines · in natura-src-1.0.0.zip as natura/biome.js · a module · its entry in the module reference · its test: biome-test.js
// biome.js -- Natura BIOME DYNAMICS (SYSTEMS.md #8, ALGORITHMS.md): the map of biomes CHANGES with the
// climate over time -- forests advance, deserts creep, tundra retreats up the mountains as the world warms.
// Each cell holds a CLIMATE (a long-run running average of the weather's temperature and rainfall, not the
// day's weather), and the climate-appropriate biome for that cell is found by a Whittaker-style classifier
// (the Terrain Engine's biome taxonomy -- sea/beach/ice/tundra/taiga/forest/grass/desert/marsh -- adapted
// to Natura's units). A cell does NOT flip the instant the weather wobbles: it accumulates SUCCESSION
// pressure toward the climate-favoured biome and only converts once that pressure has held long enough, so
// a biome front advances or retreats slowly and with hysteresis -- a real ecological succession, not noise.
//
// Reads weather (temp, rain) + an elevation sampler; an optional warmingPerYear forces a climate trend so
// the biomes visibly shift. Deterministic + node-testable. UMD (node test / window.Biome).
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
else root.Biome = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
// the Terrain Engine's biome set (kept identical so a biome index means the same thing across the kit)
const BIOMES = ['sea', 'beach', 'ice', 'tundra', 'taiga', 'forest', 'grass', 'desert', 'marsh'];
const COLD = new Set([2, 3, 4]); // ice, tundra, taiga -- the biomes a warming world loses
// classify a cell by elevation (world units), climate temperature (deg C), and climate moisture (0..1).
// The Terrain Engine's biomeOf logic, re-expressed in Natura's units (its version took normalized t,p).
function classify(elev, seaLevel, tC, moist) {
if (elev < seaLevel) return 0; // sea
if (elev < seaLevel + 0.02) return 1; // beach
if (tC < -6) return 2; // ice cap
if (tC < 1) return 3; // tundra
if (tC > 45) return 7; // scorched barren (a red-giant heat, or a hot desert) -> nothing lives
if (moist < 0.22) return 7; // desert (dry, whatever the warmth)
if (moist > 0.80 && elev < seaLevel + 0.10) return 8; // marsh (wet lowland)
if (tC < 8) return 4; // taiga (cool + moist)
if (moist > 0.45) return 5; // forest
return 6; // grassland
}
// make a Biome system. config: { N, weather, elevation, seaLevel, climateTau(days), successionDays,
// precipRef, warmingPerYear }.
function makeBiome(config) {
config = config || {};
const N = config.N || 64;
const weatherName = config.weather || 'weather';
const seaLevel = config.seaLevel != null ? config.seaLevel : 0;
const climateTau = config.climateTau != null ? config.climateTau : 730; // climate = ~2-year running mean
const maxDtDays = config.maxDtDays != null ? config.maxDtDays : 3; // per-step cap on sim-days; raise it hugely for DEEP TIME so biome equilibrates quasi-statically (over a Myr biomes fully settle)
const successionDays = config.successionDays != null ? config.successionDays : 900; // how long a new climate must hold to convert
const precipRef = config.precipRef != null ? config.precipRef : 0.014; // rain EMA -> moisture 0..1 scale (matched to weather rain magnitudes)
const warmingPerYear = config.warmingPerYear != null ? config.warmingPerYear : 0; // climate forcing (deg C / year)
const stellarName = config.stellar || 'stellar';
const insolSensitivity = config.insolSensitivity != null ? config.insolSensitivity : 40; // deg C per unit change in stellar insolation
const elevationFrom = config.elevationFrom || null; // read a LIVE elevation field (e.g. 'tectonics') instead of the static init copy
const elevFn = config.elevation || ((x, y) => 0.2);
const gx = (i) => (i / (N - 1)) * 2 - 1;
return {
name: config.name || 'biome',
domain: 'life',
scale: 'regional',
params: config,
init() {
const elev = new Float32Array(N * N);
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) elev[j * N + i] = elevFn(gx(i), gx(j));
return { N, elev, biome: new Int8Array(N * N).fill(-1),
climT: new Float32Array(N * N), climP: new Float32Array(N * N),
progress: new Float32Array(N * N), toward: new Int8Array(N * N).fill(-1),
seeded: false, shifts: 0 };
},
step(st, ctx, dt) {
const dd = clamp(dt / 86400, 0, maxDtDays); if (dd <= 0) return st;
const w = ctx.fields[weatherName];
if (!w || !w.temp) return st;
const temp = w.temp, rain = w.rain, { elev, biome, climT, climP, progress, toward } = st;
const aC = 1 - Math.exp(-dd / climateTau); // climate EMA weight
const stel = ctx.fields[stellarName]; // a star's insolation drives the deep-time climate
const warm = warmingPerYear * (ctx.clock.t / (365 * 86400)) + (stel ? insolSensitivity * (stel.insolation - 1) : 0);
const ev = (elevationFrom && ctx.fields[elevationFrom] && ctx.fields[elevationFrom].elevation) || elev; // live (evolving) elevation if wired, else the static init copy
st.shifts = 0;
// seed the climate + biomes from the first real weather field
if (!st.seeded) {
for (let k = 0; k < N * N; k++) { climT[k] = temp[k]; climP[k] = (rain ? rain[k] : 0); }
for (let k = 0; k < N * N; k++) {
const moist = clamp(climP[k] / precipRef, 0, 1);
biome[k] = classify(ev[k], seaLevel, climT[k] + warm, moist);
}
st.seeded = true; return st;
}
for (let k = 0; k < N * N; k++) {
climT[k] += (temp[k] - climT[k]) * aC; // update the running CLIMATE
climP[k] += ((rain ? rain[k] : 0) - climP[k]) * aC;
if (ev[k] < seaLevel) { biome[k] = 0; continue; } // sea stays sea
const moist = clamp(climP[k] / precipRef, 0, 1);
const target = classify(ev[k], seaLevel, climT[k] + warm, moist);
if (target === biome[k]) { progress[k] = Math.max(0, progress[k] - dd); toward[k] = -1; continue; }
// SUCCESSION: pressure toward the climate-favoured biome; convert once it has held long enough
if (toward[k] !== target) { toward[k] = target; progress[k] = 0; }
progress[k] += dd;
if (progress[k] >= successionDays) { biome[k] = target; progress[k] = 0; toward[k] = -1; st.shifts++; }
}
return st;
},
// OUT: biome (index per cell, matching BIOMES), climT/climP (the climate) for anything that wants it
fields(st) { return { N: st.N, biome: st.biome, climateTemp: st.climT, climateMoist: st.climP, BIOMES }; },
events(st) { return st.shifts > 0 ? [{ kind: 'biomeShift', cells: st.shifts }] : []; }
};
}
return { makeBiome, classify, BIOMES, COLD };
});