11
Patch Succession: Bare to Forest
tap to drop a disturbance
idle
204 lines ยท vanilla
view source
// Patch succession on a 120x80 grid.
// States: 0 bare, 1 grass, 2 shrub, 3 forest.
// Toroidal Moore neighborhood. Disturbance is contagious (cascading fires).
const GW = 120, GH = 80;
const BARE = 0, GRASS = 1, SHRUB = 2, FOREST = 3;
// Per-step base transition probabilities.
const P_BARE_TO_GRASS = 0.012; // boosted per grass neighbor
const P_GRASS_TO_SHRUB = 0.004; // requires >=2 grass neighbors
const P_SHRUB_TO_FOREST = 0.0015; // boosted heavily by forest neighbors
const P_DIST_BASE = 0.0006; // background disturbance
const P_DIST_CONTAGION = 0.06; // per recently-disturbed neighbor
const stepMs = 1000 / 24;
let A, B; // current / next state
let recent; // bytes: 1 if disturbed in last step (used for cascading)
let recentNext;
let off, octx, img, data;
let acc = 0;
let frac = [0, 0, 0, 0];
let fracDisplay = [0.25, 0.25, 0.25, 0.25];
let stepCount = 0;
// Palette: sand, light grass, olive shrub, dark forest. RGB tuples.
const COLOR = [
[212, 196, 156], // bare / sand
[148, 192, 102], // grass
[102, 132, 70], // shrub / olive
[42, 78, 48], // forest / dark green
];
function idx(x, y) { return y * GW + x; }
function init({ width, height }) {
A = new Uint8Array(GW * GH);
B = new Uint8Array(GW * GH);
recent = new Uint8Array(GW * GH);
recentNext = new Uint8Array(GW * GH);
// Seed with a noisy mix weighted toward earlier successional states.
for (let i = 0; i < A.length; i++) {
const r = Math.random();
if (r < 0.45) A[i] = BARE;
else if (r < 0.80) A[i] = GRASS;
else if (r < 0.95) A[i] = SHRUB;
else A[i] = FOREST;
}
off = new OffscreenCanvas(GW, GH);
octx = off.getContext("2d");
img = octx.createImageData(GW, GH);
data = img.data;
computeFractions();
fracDisplay = frac.slice();
}
function computeFractions() {
let c0 = 0, c1 = 0, c2 = 0, c3 = 0;
for (let i = 0; i < A.length; i++) {
const s = A[i];
if (s === BARE) c0++;
else if (s === GRASS) c1++;
else if (s === SHRUB) c2++;
else c3++;
}
const n = A.length;
frac[0] = c0 / n;
frac[1] = c1 / n;
frac[2] = c2 / n;
frac[3] = c3 / n;
}
function neighborsOf(x, y, arr) {
const yN = (y - 1 + GH) % GH;
const yS = (y + 1) % GH;
const xW = (x - 1 + GW) % GW;
const xE = (x + 1) % GW;
return [
arr[yN * GW + xW], arr[yN * GW + x], arr[yN * GW + xE],
arr[y * GW + xW], arr[y * GW + xE],
arr[yS * GW + xW], arr[yS * GW + x], arr[yS * GW + xE],
];
}
function stepCA() {
for (let y = 0; y < GH; y++) {
const yN = (y - 1 + GH) % GH;
const yS = (y + 1) % GH;
for (let x = 0; x < GW; x++) {
const xW = (x - 1 + GW) % GW;
const xE = (x + 1) % GW;
const i = y * GW + x;
const s = A[i];
// Count neighbor states.
const n0 = A[yN * GW + xW], n1 = A[yN * GW + x], n2 = A[yN * GW + xE];
const n3 = A[y * GW + xW], n4 = A[y * GW + xE];
const n5 = A[yS * GW + xW], n6 = A[yS * GW + x], n7 = A[yS * GW + xE];
let nGrass = 0, nShrub = 0, nForest = 0;
const ns = [n0, n1, n2, n3, n4, n5, n6, n7];
for (let k = 0; k < 8; k++) {
const v = ns[k];
if (v === GRASS) nGrass++;
else if (v === SHRUB) nShrub++;
else if (v === FOREST) nForest++;
}
// Count recently-disturbed neighbors for cascading fires.
let nRecent = 0;
nRecent += recent[yN * GW + xW] + recent[yN * GW + x] + recent[yN * GW + xE];
nRecent += recent[y * GW + xW] + recent[y * GW + xE];
nRecent += recent[yS * GW + xW] + recent[yS * GW + x] + recent[yS * GW + xE];
// Disturbance check first: fire/blowdown returns to bare.
// Forest is the most flammable (more fuel), bare can't burn.
let pDist = 0;
if (s !== BARE) {
const fuelMul = (s === GRASS) ? 0.6 : (s === SHRUB) ? 1.0 : 1.6;
pDist = (P_DIST_BASE + P_DIST_CONTAGION * nRecent) * fuelMul;
}
if (Math.random() < pDist) {
B[i] = BARE;
recentNext[i] = 1;
continue;
}
recentNext[i] = 0;
// Succession transitions.
if (s === BARE) {
// Colonization by grass; faster with neighboring grass.
const p = P_BARE_TO_GRASS * (1 + 0.6 * nGrass);
B[i] = (Math.random() < p) ? GRASS : BARE;
} else if (s === GRASS) {
// Needs >=2 grass neighbors to produce a shrub seedling
// (competition / litter buildup).
if (nGrass >= 2 && Math.random() < P_GRASS_TO_SHRUB * (1 + 0.3 * nShrub)) {
B[i] = SHRUB;
} else {
B[i] = GRASS;
}
} else if (s === SHRUB) {
// Very slow without forest seed source nearby; fast at the
// forest edge.
const p = P_SHRUB_TO_FOREST * (1 + 4.0 * nForest);
B[i] = (Math.random() < p) ? FOREST : SHRUB;
} else {
B[i] = FOREST;
}
}
}
const t = A; A = B; B = t;
const r = recent; recent = recentNext; recentNext = r;
stepCount++;
}
function applyDisturbance(gx, gy, radius) {
const r2 = radius * radius;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (dx * dx + dy * dy > r2) continue;
const x = ((gx + dx) % GW + GW) % GW;
const y = ((gy + dy) % GH + GH) % GH;
const i = y * GW + x;
A[i] = BARE;
recent[i] = 1;
}
}
}
function drawHUD(ctx, width, height) {
// Smooth the displayed fractions a touch so the bar doesn't jitter.
for (let k = 0; k < 4; k++) {
fracDisplay[k] += (frac[k] - fracDisplay[k]) * 0.08;
}
const padX = Math.max(8, Math.round(width * 0.014));
const padY = Math.max(8, Math.round(height * 0.02));
const barW = Math.min(width - 2 * padX, Math.max(220, Math.round(width * 0.55)));
const barH = Math.max(10, Math.round(height * 0.022));
const labelH = Math.max(11, Math.round(height * 0.022));
// Title strip background.
ctx.fillStyle = 'rgba(0, 0, 0, 0.42)';
ctx.fillRect(padX - 4, padY - 4, barW + 8, barH + labelH + 14);
// Stacked bar.
let x = padX;
const total = fracDisplay[0] + fracDisplay[1] + fracDisplay[2] + fracDisplay[3] || 1;
for (let k = 0; k < 4; k++) {
const w = (fracDisplay[k] / total) * barW;
const c = COLOR[k];
ctx.fillStyle = `rgb(${c[0]}, ${c[1]}, ${c[2]})`;
ctx.fillRect(x, padY, w, barH);
x += w;
}
ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)';
ctx.lineWidth = 1;
ctx.strokeRect(padX + 0.5, padY + 0.5, barW, barH);
// Labels with percentages.
ctx.font = `${labelH - 2}px ui-monospace, Menlo, monospace`;
ctx.textBaseline = 'top';
const names = ['bare', 'grass', 'shrub', 'forest'];
const colW = barW / 4;
for (let k = 0; k < 4; k++) {
const c = COLOR[k];
const lx = padX + k * colW;
ctx.fillStyle = `rgb(${c[0]}, ${c[1]}, ${c[2]})`;
ctx.fillRect(lx, padY + barH + 6, 8, 8);
ctx.fillStyle = 'rgba(240, 240, 230, 0.92)';
const pct = (fracDisplay[k] * 100).toFixed(0) + '%';
ctx.fillText(`${names[k]} ${pct}`, lx + 12, padY + barH + 4);
}
}
function tick({ ctx, dt, width, height, input }) {
const clicks = (input && input.consumeClicks) ? input.consumeClicks() : [];
for (let c = 0; c < clicks.length; c++) {
const cx = clicks[c].x, cy = clicks[c].y;
const gx = Math.floor((cx / width) * GW);
const gy = Math.floor((cy / height) * GH);
if (gx >= 0 && gx < GW && gy >= 0 && gy < GH) {
// Disturbance pulse: small fire scar that seeds a cascade.
applyDisturbance(gx, gy, 5);
}
}
acc += Math.min(0.05, dt) * 1000;
let n = 0;
while (acc >= stepMs && n < 3) {
stepCA();
acc -= stepMs;
n++;
}
if (n > 0) computeFractions();
// Render the grid into the offscreen image buffer.
for (let i = 0, j = 0; i < A.length; i++, j += 4) {
const c = COLOR[A[i]];
data[j] = c[0];
data[j + 1] = c[1];
data[j + 2] = c[2];
data[j + 3] = 255;
}
octx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(off, 0, 0, width, height);
drawHUD(ctx, width, height);
}
Comments (0)
Log in to comment.