09Ecosystem scene — sand, water, fire and plants running together in one grid
Lay terrain (dirt, stone) with a lake, add a rain spawner and a lightning igniter, and run every rule in one grid — fire eats the grass, rain kills the fire, growth follows the water: a self-contained ecosystem fills the screen.
Best forA living background for an interactive hero, a game-world demo
ImageDataputImageDatarequestAnimationFrame

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint"><span>Rain, fire, water and grass cycle through a single grid</span></p>
<div class="cd__field" id="cdField" role="application" aria-label="Ecosystem cellular automaton demo" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">6</b>materials · ecosystem cycle</span>
<span class="cd__meta cd__meta--dim">canvas · ImageData putImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 생태계 장면(09) — 편 대표. @keyframes 을 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(빗물·번개·화재·소화·발아·연기)가 만든다.
// 무대는 어스름 잉크. 파스텔 고정 팔레트를 --sand-* 로 편 공통 규격(01~09 동일).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(BD26 vivid 0 실측 통과값 — 다른 색 금지) ──
$sand-night: #201c17;
$sand-mist: #d8d2c6;
$sand-glow: #f2e2c4;
$sand-grain: #d9b98a;
$sand-gold: #ecd9b0;
$sand-water: #a9c8dd;
$sand-ice: #cfe3ee;
$sand-fire: #ffc9ad;
$sand-flame: #ffe0c4;
$sand-ash: #b9b2a6;
$sand-smoke: #8e8879;
$sand-plant: #9fd6ad;
$sand-leaf: #c4e6c9;
$sand-acid: #c7dd8f;
$sand-stone: #8a8578;
$sand-wood: #b98a63;
.cd {
--sand-night: #{$sand-night};
--sand-mist: #{$sand-mist};
--sand-glow: #{$sand-glow};
--sand-grain: #{$sand-grain};
--sand-gold: #{$sand-gold};
--sand-water: #{$sand-water};
--sand-ice: #{$sand-ice};
--sand-fire: #{$sand-fire};
--sand-flame: #{$sand-flame};
--sand-ash: #{$sand-ash};
--sand-smoke: #{$sand-smoke};
--sand-plant: #{$sand-plant};
--sand-leaf: #{$sand-leaf};
--sand-acid: #{$sand-acid};
--sand-stone: #{$sand-stone};
--sand-wood: #{$sand-wood};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $sand-mist;
}
.cd__field {
position: relative; height: 200px;
border-radius: $r-card; overflow: hidden;
background: $sand-night;
box-shadow: inset 0 0 0 1px rgba($sand-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $subject-lilac; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($sand-mist, .72); }
.cd__val { color: $subject-lilac; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 110px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 순환이 한창인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--sand-night: #201c17;
--sand-mist: #d8d2c6;
--sand-glow: #f2e2c4;
--sand-grain: #d9b98a;
--sand-gold: #ecd9b0;
--sand-water: #a9c8dd;
--sand-ice: #cfe3ee;
--sand-fire: #ffc9ad;
--sand-flame: #ffe0c4;
--sand-ash: #b9b2a6;
--sand-smoke: #8e8879;
--sand-plant: #9fd6ad;
--sand-leaf: #c4e6c9;
--sand-acid: #c7dd8f;
--sand-stone: #8a8578;
--sand-wood: #b98a63;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8d2c6;
}
.cd__field {
position: relative;
height: 200px;
border-radius: 16px;
overflow: hidden;
background: #201c17;
box-shadow: inset 0 0 0 1px rgba(216, 210, 198, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 210, 198, 0.72);
}
.cd__val {
color: #b9a0ff;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 110px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 생태계 장면(09) — 편의 대표. 재료 코드: 0 빈칸 1 모래 2 물 4 나무 5 불 6 재 7 연기 8 씨앗 9 식물 10 흙 11 돌.
// 규칙 통합:
// 물(03) — 아래→대각→좌우 유동. 불을 만나면 불을 재로 끄고 확률로 자신 소모.
// 불(04) — 풀(식물·씨앗·나무)로 번지고 태운 자리에 재를 남긴다. 수명이 다하면 연기로.
// 성장(05) — 씨앗은 아래가 흙/돌/재/식물이면 확률로 식물로 자라고, 식물은 위로 자라 나무가 된다.
// 빗물 스포너 — 상단 성긴 드리즐(RAIN_P). 번개 점화기 — STRIKE_T 초마다 무작위 풀 칸에 불 1점.
// 재·씨앗은 물길 닿으면 확률(REGROW_P)로 다시 발아한다. 재 → 씨앗 → 식물 → 불 → 재 로 순환.
(function () {
// ── 변형 상수 블록 ──
var RAIN_P = 0.015; // 상단 빗물 드리즐 확률(칸당 틱) — 늘리면 불이 금방 꺼진다
var STRIKE_T = 1.2; // 번개 점화 주기(초) — 짧으면 산불이 잦는다
var REGROW_P = 0.05; // 물길 닿은 재·씨앗 발아 확률 — 생태 회복 속도
// ────────────────────────────
var EMPTY = 0, SAND = 1, WATER = 2, WOOD = 4, FIRE = 5, ASH = 6, SMOKE = 7, SEED = 8, PLANT = 9, DIRT = 10, STONE = 11;
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x318A09);
function hexToRgb(hex) {
hex = hex.trim();
if (hex.length === 4) hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
var n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var CELL = 4;
var cols = 0, rows = 0, grid = null, life = null; // life = 불·연기 수명
var COL = {};
// 지형 연출 — 프레임0 은 빈 무대에서 시작해 TERRAIN_TICKS 틱에 걸쳐 분지 지형·호수·풀이 차오른다.
var TERRAIN_TICKS = 95;
function readColors() {
var css = getComputedStyle(root);
COL.night = hexToRgb(css.getPropertyValue('--sand-night'));
COL.stone = hexToRgb(css.getPropertyValue('--sand-stone'));
COL.dirt = hexToRgb(css.getPropertyValue('--sand-wood'));
COL.sandA = hexToRgb(css.getPropertyValue('--sand-grain'));
COL.sandB = hexToRgb(css.getPropertyValue('--sand-gold'));
COL.water = hexToRgb(css.getPropertyValue('--sand-water'));
COL.ice = hexToRgb(css.getPropertyValue('--sand-ice'));
COL.fire = hexToRgb(css.getPropertyValue('--sand-fire'));
COL.flame = hexToRgb(css.getPropertyValue('--sand-flame'));
COL.ash = hexToRgb(css.getPropertyValue('--sand-ash'));
COL.smoke = hexToRgb(css.getPropertyValue('--sand-smoke'));
COL.plant = hexToRgb(css.getPropertyValue('--sand-plant'));
COL.leaf = hexToRgb(css.getPropertyValue('--sand-leaf'));
COL.glow = hexToRgb(css.getPropertyValue('--sand-glow'));
}
function idx(x, y) { return y * cols + x; }
function get(x, y) {
if (x < 0 || x >= cols || y < 0 || y >= rows) return STONE;
return grid[idx(x, y)];
}
function set(x, y, v) {
if (x < 0 || x >= cols || y < 0 || y >= rows) return;
grid[idx(x, y)] = v;
if (v !== FIRE && v !== SMOKE) life[idx(x, y)] = 0;
}
// ── 초기 지형: 좌측 분지(오목한 그릇)에 호수, 우측은 완만한 언덕. 지형 위 풀 초기 배치 ──
function terrainHeight(x) {
var u = x / cols;
// 좌측 분지 — 완만한 U자. 우측으로 갈수록 낮은 언덕.
var basin = Math.pow(Math.max(0, (0.55 - u) / 0.55), 1.4);
var hill = 0.18 * Math.sin(u * 9.0) * u;
return Math.floor(rows * (0.68 - 0.26 * basin + hill));
}
// 굳는 시각표 — 각 칸에 '이 틱에 채워진다' 예약. 가장자리·얕은 쪽부터, 중앙 심부가 마지막.
var solidAt = null;
function buildSceneStagger() {
solidAt = new Float32Array(cols * rows);
for (var x = 0; x < cols; x++) {
var top = terrainHeight(x);
var edgeDist = Math.min(x, cols - 1 - x) / (cols / 2);
for (var y = top; y < rows; y++) {
var depthFrac = (y - top) / Math.max(1, rows - top);
solidAt[idx(x, y)] = TERRAIN_TICKS * (0.1 + 0.5 * edgeDist + 0.4 * depthFrac) * (0.85 + 0.3 * rand());
}
// 호수 물 — 지형이 굳은 뒤에 차오른다
var lakeLevel = Math.floor(rows * 0.58);
for (var wy = terrainHeight(x) - 1; wy >= lakeLevel; wy--) {
if (x / cols < 0.42) solidAt[idx(x, wy)] = TERRAIN_TICKS * (0.85 + 0.25 * rand()) * (0.5 + 0.5 * (wy - lakeLevel + 1) / Math.max(1, terrainHeight(x) - lakeLevel));
}
// 풀 — 제일 마지막에 돋는다
if (rand() < 0.42) {
var ty = terrainHeight(x) - 1;
if (ty >= 0 && x / cols >= 0.42) solidAt[idx(x, ty)] = TERRAIN_TICKS * (1.05 + 0.35 * rand());
}
}
}
function buildSceneFull() {
for (var x = 0; x < cols; x++) {
var top = terrainHeight(x);
for (var y = top; y < rows; y++) {
var depth = y - top;
var m = depth < 3 ? DIRT : STONE;
if (depth >= 3 && ((x * 13 + y * 7) % 23 === 0)) m = SAND;
grid[idx(x, y)] = m;
}
}
var lakeLevel = Math.floor(rows * 0.58);
for (var x2 = 0; x2 < cols; x2++) {
for (var y2 = terrainHeight(x2) - 1; y2 >= lakeLevel; y2--) {
if (x2 / cols < 0.42) grid[idx(x2, y2)] = WATER;
}
}
for (var x3 = 0; x3 < cols; x3++) {
if (rand() < 0.42) {
var ty2 = terrainHeight(x3) - 1;
if (ty2 >= 0 && grid[idx(x3, ty2)] === EMPTY) grid[idx(x3, ty2)] = rand() < 0.7 ? PLANT : SEED;
}
}
}
function resolveSolid(tick) {
if (!solidAt) return;
var lakeLevel = Math.floor(rows * 0.58);
for (var y = 0; y < rows; y++) {
for (var x = 0; x < cols; x++) {
var i = idx(x, y);
if (solidAt[i] > 0 && solidAt[i] <= tick && grid[i] === EMPTY) {
if (y >= lakeLevel && x / cols < 0.42 && y < terrainHeight(x)) {
grid[i] = WATER; // 호수
} else if (y >= terrainHeight(x)) {
var depth = y - terrainHeight(x);
grid[i] = depth < 3 ? DIRT : (((x * 13 + y * 7) % 23 === 0) ? SAND : STONE);
} else {
grid[i] = rand() < 0.7 ? PLANT : SEED; // 풀
}
solidAt[i] = 0;
}
}
}
}
function resetGrid() {
grid = new Uint8Array(cols * rows);
life = new Uint8Array(cols * rows);
buildSceneStagger();
}
function isFlammable(m) { return m === PLANT || m === SEED || m === WOOD; }
function moveInto(x, y, nx, ny, m) {
if (get(nx, ny) !== EMPTY) return false;
var l = life[idx(x, y)];
set(x, y, EMPTY);
grid[idx(nx, ny)] = m;
life[idx(nx, ny)] = l;
return true;
}
function updateWater(x, y, dir) {
if (get(x, y) !== WATER) return;
// 불 끄기 — 이웃에 불이 있으면 재로, 자신은 확률 소모
var douses = [[0, 1], [-1, 0], [1, 0], [0, -1]];
for (var i = 0; i < douses.length; i++) {
if (get(x + douses[i][0], y + douses[i][1]) === FIRE) {
set(x + douses[i][0], y + douses[i][1], ASH);
if (rand() < 0.6) { set(x, y, EMPTY); return; }
}
}
// 발아 돕기 — 이웃 재·씨앗에 물길이 닿으면 확률로 발아
for (var j = 0; j < douses.length; j++) {
var nx0 = x + douses[j][0], ny0 = y + douses[j][1];
var m0 = get(nx0, ny0);
if ((m0 === ASH || m0 === SEED) && rand() < REGROW_P) set(nx0, ny0, PLANT);
}
if (moveInto(x, y, x, y + 1, WATER)) return;
var first = dir > 0 ? -1 : 1;
if (moveInto(x, y, x + first, y + 1, WATER)) return;
if (moveInto(x, y, x - first, y + 1, WATER)) return;
if (moveInto(x, y, x + first, y, WATER)) return;
moveInto(x, y, x - first, y, WATER);
}
function updateFire(x, y) {
if (get(x, y) !== FIRE) return;
life[idx(x, y)]++;
if (life[idx(x, y)] > 46) { set(x, y, rand() < 0.5 ? SMOKE : ASH); return; }
var around = [[0, 1], [0, -1], [-1, 0], [1, 0], [-1, -1], [1, 1], [-1, 1], [1, -1]];
var burned = false;
for (var i = 0; i < around.length; i++) {
var nx = x + around[i][0], ny = y + around[i][1];
var m = get(nx, ny);
if (isFlammable(m) && rand() < 0.34) { set(nx, ny, FIRE); burned = true; }
else if (m === WATER) { set(x, y, ASH); return; } // 물 만난 불은 재로 소화
}
if (burned) set(x, y, ASH); // 태우고 나면 자리에 재
}
function updateSmoke(x, y) {
if (get(x, y) !== SMOKE) return;
life[idx(x, y)]++;
if (life[idx(x, y)] > 30) { set(x, y, EMPTY); return; }
var bias = rand() < 0.6 ? -1 : 1;
if (moveInto(x, y, x + (rand() < 0.3 ? bias : 0), y - 1, SMOKE)) return;
moveInto(x, y, x + bias, y, SMOKE);
}
function updateSand(x, y, dir) {
if (get(x, y) !== SAND) return;
// 아래가 물이면 스왑(가라앉기)
if (get(x, y + 1) === WATER) {
grid[idx(x, y)] = WATER;
grid[idx(x, y + 1)] = SAND;
return;
}
if (moveInto(x, y, x, y + 1, SAND)) return;
var first = dir > 0 ? -1 : 1;
if (moveInto(x, y, x + first, y + 1, SAND)) return;
moveInto(x, y, x - first, y + 1, SAND);
}
function updatePlant(x, y) {
var m = get(x, y);
if (m !== PLANT && m !== SEED) return;
if (m === SEED) {
// 씨앗 — 아래가 흙·돌·재·식물이면 확률로 발아
var below = get(x, y + 1);
if ((below === DIRT || below === STONE || below === ASH || below === PLANT || below === WOOD) && rand() < 0.08) {
set(x, y, PLANT);
}
return;
}
// 식물 — 위로 자란다(3칸 넘으면 나무로 간주·색만 구분). 자랄 자리가 없으면 그대로.
if (get(x, y - 1) === EMPTY && rand() < 0.02) {
set(x, y - 1, PLANT);
life[idx(x, y - 1)] = life[idx(x, y)] + 1;
}
}
var tick = 0;
var nextStrike = 72; // 첫 번개(틱) — 1.2초
function update() {
tick++;
resolveSolid(tick);
// 빗물 드리즐 — 상단 몇 칸에서 성기게
for (var rx = 0; rx < cols; rx++) {
if (rand() < RAIN_P && get(rx, 0) === EMPTY) set(rx, 0, WATER);
}
// 번개 점화기 — 주기마다 무작위 풀 칸에 불 1점
if (tick >= nextStrike) {
nextStrike = tick + Math.round(STRIKE_T * 60);
for (var tries = 0; tries < 60; tries++) {
var sx = Math.floor(rand() * cols);
var sy = Math.floor(rand() * rows);
var m = get(sx, sy);
if (isFlammable(m)) { set(sx, sy, FIRE); break; }
}
}
var dir = (tick % 2 === 0) ? 1 : -1;
for (var y = rows - 1; y >= 0; y--) {
if (dir > 0) {
for (var x = 0; x < cols; x++) {
var c = get(x, y);
if (c === SAND) updateSand(x, y, dir);
else if (c === WATER) updateWater(x, y, dir);
else if (c === FIRE) updateFire(x, y);
else if (c === SMOKE) updateSmoke(x, y);
else if (c === PLANT || c === SEED) updatePlant(x, y);
}
} else {
for (var x2 = cols - 1; x2 >= 0; x2--) {
var c2 = get(x2, y);
if (c2 === SAND) updateSand(x2, y, dir);
else if (c2 === WATER) updateWater(x2, y, dir);
else if (c2 === FIRE) updateFire(x2, y);
else if (c2 === SMOKE) updateSmoke(x2, y);
else if (c2 === PLANT || c2 === SEED) updatePlant(x2, y);
}
}
}
}
var buf = null, imgData = null;
function ensureBuf() {
imgData = ctx.createImageData(cv.width, cv.height);
buf = imgData.data;
}
function shade(c, amt) {
return [
Math.max(0, Math.min(255, c[0] + amt)),
Math.max(0, Math.min(255, c[1] + amt)),
Math.max(0, Math.min(255, c[2] + amt))
];
}
function draw(t) {
if (!buf) ensureBuf();
var W = cv.width, H = cv.height;
var wave = Math.sin(t * 0.00055) * 0.5 + 0.5;
var cx = W * (0.5 + 0.34 * Math.sin(t * 0.00042));
var cy = H * (0.5 + 0.34 * Math.cos(t * 0.00036));
var maxD = Math.hypot(W, H) * 0.6;
for (var y = 0; y < rows; y++) {
for (var x = 0; x < cols; x++) {
var m = grid[idx(x, y)];
var px0 = x * CELL, py0 = y * CELL;
var d = Math.hypot(px0 - cx, py0 - cy) / maxD;
var glow = Math.max(0, 1 - d) * (0.08 + 0.05 * wave);
var col;
if (m === EMPTY) col = shade(COL.night, Math.round(glow * 40));
else if (m === DIRT) col = shade(COL.dirt, ((x * 7 + y * 13) % 5) - 2 + Math.round(glow * 14));
else if (m === STONE) col = shade(COL.stone, ((x * 11 + y * 5) % 5) - 2 + Math.round(glow * 10));
else if (m === SAND) col = ((x + y) % 3 === 0) ? shade(COL.sandA, Math.round(glow * 18)) : shade(COL.sandB, Math.round(glow * 18));
else if (m === WATER) {
var shimmer = Math.sin(t * 0.004 + x * 0.6 + y * 0.3) * 0.5 + 0.5;
col = shade(shimmer > 0.5 ? COL.ice : COL.water, Math.round(glow * 12));
} else if (m === FIRE) {
var flick = Math.sin(t * 0.03 + x * 0.9 + y * 0.7) * 0.5 + 0.5;
col = shade(flick > 0.5 ? COL.flame : COL.fire, Math.round(glow * 20));
} else if (m === SMOKE) col = shade(COL.smoke, ((x + y * 3) % 7) - 3 + Math.round(glow * 12));
else if (m === ASH) col = shade(COL.ash, ((x * 5 + y * 3) % 5) - 2 + Math.round(glow * 12));
else if (m === SEED) col = shade(COL.plant, -20 + Math.round(glow * 12));
else if (m === PLANT || m === WOOD) col = ((x + y) % 4 === 0) ? shade(COL.leaf, Math.round(glow * 14)) : shade(COL.plant, Math.round(glow * 14));
else col = COL.night;
for (var cyi = 0; cyi < CELL; cyi++) {
var rowOff = ((py0 + cyi) * W + px0) * 4;
for (var cxi = 0; cxi < CELL; cxi++) {
var o = rowOff + cxi * 4;
buf[o] = col[0]; buf[o + 1] = col[1]; buf[o + 2] = col[2]; buf[o + 3] = 255;
}
}
}
}
ctx.putImageData(imgData, 0, 0);
}
function fit() {
readColors();
var r = field.getBoundingClientRect();
cv.width = Math.max(1, Math.round(r.width));
cv.height = Math.max(1, Math.round(r.height));
cols = Math.max(1, Math.floor(cv.width / CELL));
rows = Math.max(1, Math.floor(cv.height / CELL));
buf = null;
resetGrid();
}
function stillFrame() {
buildSceneFull();
solidAt = null;
for (var i = 0; i < 110; i++) update();
draw(1800);
root.classList.add('is-still');
}
var last = 0, acc = 0;
var STEP = 1000 / 60;
function frame(now) {
if (!last) last = now;
acc += now - last;
last = now;
var guard = 0;
while (acc >= STEP && guard < 6) { update(); acc -= STEP; guard++; }
draw(now);
requestAnimationFrame(frame);
}
window.addEventListener('resize', fit);
fit();
if (reduce) { stillFrame(); } else { requestAnimationFrame(frame); }
})();// FullEcosystemScene — vanilla 판(run/318/demo/09-sand-full-ecosystem-scene/index.html) 의 React 포트.
// 흙·돌 분지 지형 + 호수 + 풀, 상단 빗물 드리즐, 주기적 번개 점화 — 물·불·성장 규칙이 한 격자에서 순환한다.
// 재료: 모래·물·나무·불·재·연기·씨앗·식물·흙·돌.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FullEcosystemScene.module.scss";
export type FullEcosystemSceneProps = {
duration?: string;
easing?: string;
color?: string;
rainP?: number; // 상단 빗물 드리즐 확률(칸당 틱)
strikeSec?: number; // 번개 점화 주기(초)
regrowP?: number; // 물길 닿은 재·씨앗 발아 확률
cellPx?: number; // 셀 크기(px)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Colors = {
night: RGB; stone: RGB; dirt: RGB; sandA: RGB; sandB: RGB; water: RGB; ice: RGB;
fire: RGB; flame: RGB; ash: RGB; smoke: RGB; plant: RGB; leaf: RGB; glow: RGB;
};
const FIELD_LABEL = "생태계 통합 셀 오토마타 데모";
const EMPTY = 0, SAND = 1, WATER = 2, WOOD = 4, FIRE = 5, ASH = 6, SMOKE = 7, SEED = 8, PLANT = 9, DIRT = 10, STONE = 11;
const TERRAIN_TICKS = 95;
function makePRNG(seed: number): () => number {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default function FullEcosystemScene({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
rainP = 0.015,
strikeSec = 1.2,
regrowP = 0.05,
cellPx = 4,
}: FullEcosystemSceneProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const gridRef = useRef(new Uint8Array(0)) as Box<Uint8Array>;
const lifeRef = useRef(new Uint8Array(0)) as Box<Uint8Array>;
const solidRef = useRef(new Float32Array(0)) as Box<Float32Array>;
const dimRef = useRef({ cols: 0, rows: 0 }) as Box<{ cols: number; rows: number }>;
const cellRef = useRef(cellPx) as Box<number>;
const rainRef = useRef(rainP) as Box<number>;
const strikeRef = useRef(strikeSec) as Box<number>;
const regrowRef = useRef(regrowP) as Box<number>;
const randRef = useRef(makePRNG(0x318a09)) as Box<() => number>;
const tickRef = useRef(0) as Box<number>;
const nextStrikeRef = useRef(72) as Box<number>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function get(x: number, y: number): number {
const { cols, rows } = dimRef.current;
if (x < 0 || x >= cols || y < 0 || y >= rows) return STONE;
return gridRef.current[y * cols + x];
}
function set(x: number, y: number, v: number): void {
const { cols, rows } = dimRef.current;
if (x < 0 || x >= cols || y < 0 || y >= rows) return;
const i = y * cols + x;
gridRef.current[i] = v;
if (v !== FIRE && v !== SMOKE) lifeRef.current[i] = 0;
}
function terrainHeight(x: number): number {
const { cols, rows } = dimRef.current;
const u = x / cols;
const basin = Math.pow(Math.max(0, (0.55 - u) / 0.55), 1.4);
const hill = 0.18 * Math.sin(u * 9.0) * u;
return Math.floor(rows * (0.68 - 0.26 * basin + hill));
}
function buildSceneStagger(): void {
const rand = randRef.current;
const { cols, rows } = dimRef.current;
const solid = new Float32Array(cols * rows);
const lakeLevel = Math.floor(rows * 0.58);
for (let x = 0; x < cols; x++) {
const top = terrainHeight(x);
const edgeDist = Math.min(x, cols - 1 - x) / (cols / 2);
for (let y = top; y < rows; y++) {
const depthFrac = (y - top) / Math.max(1, rows - top);
solid[y * cols + x] = TERRAIN_TICKS * (0.1 + 0.5 * edgeDist + 0.4 * depthFrac) * (0.85 + 0.3 * rand());
}
for (let wy = terrainHeight(x) - 1; wy >= lakeLevel; wy--) {
if (x / cols < 0.42) {
solid[wy * cols + x] = TERRAIN_TICKS * (0.85 + 0.25 * rand()) * (0.5 + 0.5 * (wy - lakeLevel + 1) / Math.max(1, terrainHeight(x) - lakeLevel));
}
}
if (rand() < 0.42) {
const ty = terrainHeight(x) - 1;
if (ty >= 0 && x / cols >= 0.42) solid[ty * cols + x] = TERRAIN_TICKS * (1.05 + 0.35 * rand());
}
}
solidRef.current = solid;
}
function resolveSolid(tick: number): void {
const rand = randRef.current;
const { cols, rows } = dimRef.current;
const solid = solidRef.current;
const lakeLevel = Math.floor(rows * 0.58);
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const i = y * cols + x;
if (solid[i] > 0 && solid[i] <= tick && gridRef.current[i] === EMPTY) {
if (y >= lakeLevel && x / cols < 0.42 && y < terrainHeight(x)) {
gridRef.current[i] = WATER;
} else if (y >= terrainHeight(x)) {
const depth = y - terrainHeight(x);
gridRef.current[i] = depth < 3 ? DIRT : (((x * 13 + y * 7) % 23 === 0) ? SAND : STONE);
} else {
gridRef.current[i] = rand() < 0.7 ? PLANT : SEED;
}
solid[i] = 0;
}
}
}
}
function isFlammable(m: number): boolean { return m === PLANT || m === SEED || m === WOOD; }
function moveInto(x: number, y: number, nx: number, ny: number, m: number): boolean {
if (get(nx, ny) !== EMPTY) return false;
const { cols } = dimRef.current;
const l = lifeRef.current[y * cols + x];
set(x, y, EMPTY);
gridRef.current[ny * cols + nx] = m;
lifeRef.current[ny * cols + nx] = l;
return true;
}
function updateWater(x: number, y: number, dir: number): void {
const rand = randRef.current;
if (get(x, y) !== WATER) return;
const douses: [number, number][] = [[0, 1], [-1, 0], [1, 0], [0, -1]];
for (let i = 0; i < douses.length; i++) {
if (get(x + douses[i][0], y + douses[i][1]) === FIRE) {
set(x + douses[i][0], y + douses[i][1], ASH);
if (rand() < 0.6) { set(x, y, EMPTY); return; }
}
}
for (let j = 0; j < douses.length; j++) {
const nx0 = x + douses[j][0], ny0 = y + douses[j][1];
const m0 = get(nx0, ny0);
if ((m0 === ASH || m0 === SEED) && rand() < regrowRef.current) set(nx0, ny0, PLANT);
}
if (moveInto(x, y, x, y + 1, WATER)) return;
const first = dir > 0 ? -1 : 1;
if (moveInto(x, y, x + first, y + 1, WATER)) return;
if (moveInto(x, y, x - first, y + 1, WATER)) return;
if (moveInto(x, y, x + first, y, WATER)) return;
moveInto(x, y, x - first, y, WATER);
}
function updateFire(x: number, y: number): void {
const rand = randRef.current;
const { cols } = dimRef.current;
if (get(x, y) !== FIRE) return;
const i = y * cols + x;
lifeRef.current[i]++;
if (lifeRef.current[i] > 46) { set(x, y, rand() < 0.5 ? SMOKE : ASH); return; }
const around: [number, number][] = [[0, 1], [0, -1], [-1, 0], [1, 0], [-1, -1], [1, 1], [-1, 1], [1, -1]];
let burned = false;
for (let k = 0; k < around.length; k++) {
const nx = x + around[k][0], ny = y + around[k][1];
const m = get(nx, ny);
if (isFlammable(m) && rand() < 0.34) { set(nx, ny, FIRE); burned = true; }
else if (m === WATER) { set(x, y, ASH); return; }
}
if (burned) set(x, y, ASH);
}
function updateSmoke(x: number, y: number): void {
const rand = randRef.current;
const { cols } = dimRef.current;
if (get(x, y) !== SMOKE) return;
const i = y * cols + x;
lifeRef.current[i]++;
if (lifeRef.current[i] > 30) { set(x, y, EMPTY); return; }
const bias = rand() < 0.6 ? -1 : 1;
if (moveInto(x, y, x + (rand() < 0.3 ? bias : 0), y - 1, SMOKE)) return;
moveInto(x, y, x + bias, y, SMOKE);
}
function updateSand(x: number, y: number, dir: number): void {
const { cols } = dimRef.current;
if (get(x, y) !== SAND) return;
if (get(x, y + 1) === WATER) {
gridRef.current[y * cols + x] = WATER;
gridRef.current[(y + 1) * cols + x] = SAND;
return;
}
if (moveInto(x, y, x, y + 1, SAND)) return;
const first = dir > 0 ? -1 : 1;
if (moveInto(x, y, x + first, y + 1, SAND)) return;
moveInto(x, y, x - first, y + 1, SAND);
}
function updatePlant(x: number, y: number): void {
const rand = randRef.current;
const { cols } = dimRef.current;
const m = get(x, y);
if (m !== PLANT && m !== SEED) return;
if (m === SEED) {
const below = get(x, y + 1);
if ((below === DIRT || below === STONE || below === ASH || below === PLANT || below === WOOD) && rand() < 0.08) {
set(x, y, PLANT);
}
return;
}
if (get(x, y - 1) === EMPTY && rand() < 0.02) {
set(x, y - 1, PLANT);
lifeRef.current[(y - 1) * cols + x] = lifeRef.current[y * cols + x] + 1;
}
}
function update(): void {
const rand = randRef.current;
const { cols, rows } = dimRef.current;
tickRef.current++;
const tick = tickRef.current;
resolveSolid(tick);
for (let rx = 0; rx < cols; rx++) {
if (rand() < rainRef.current && get(rx, 0) === EMPTY) set(rx, 0, WATER);
}
if (tick >= nextStrikeRef.current) {
nextStrikeRef.current = tick + Math.round(strikeRef.current * 60);
for (let tries = 0; tries < 60; tries++) {
const sx = Math.floor(rand() * cols);
const sy = Math.floor(rand() * rows);
const m = get(sx, sy);
if (isFlammable(m)) { set(sx, sy, FIRE); break; }
}
}
const dir = tick % 2 === 0 ? 1 : -1;
for (let y = rows - 1; y >= 0; y--) {
if (dir > 0) {
for (let x = 0; x < cols; x++) {
const c = get(x, y);
if (c === SAND) updateSand(x, y, dir);
else if (c === WATER) updateWater(x, y, dir);
else if (c === FIRE) updateFire(x, y);
else if (c === SMOKE) updateSmoke(x, y);
else if (c === PLANT || c === SEED) updatePlant(x, y);
}
} else {
for (let x = cols - 1; x >= 0; x--) {
const c = get(x, y);
if (c === SAND) updateSand(x, y, dir);
else if (c === WATER) updateWater(x, y, dir);
else if (c === FIRE) updateFire(x, y);
else if (c === SMOKE) updateSmoke(x, y);
else if (c === PLANT || c === SEED) updatePlant(x, y);
}
}
}
}
function shade(c: RGB, amt: number): RGB {
return [
Math.max(0, Math.min(255, c[0] + amt)),
Math.max(0, Math.min(255, c[1] + amt)),
Math.max(0, Math.min(255, c[2] + amt)),
];
}
function draw(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
if (!ctx || !col) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const CELL = cellRef.current;
const { cols, rows } = dimRef.current;
let img = imgRef.current;
if (!img || img.width !== W || img.height !== H) {
img = ctx.createImageData(W, H);
imgRef.current = img;
}
const buf = img.data;
const grid = gridRef.current;
const wave = Math.sin(t * 0.00055) * 0.5 + 0.5;
const cx = W * (0.5 + 0.34 * Math.sin(t * 0.00042));
const cy = H * (0.5 + 0.34 * Math.cos(t * 0.00036));
const maxD = Math.hypot(W, H) * 0.6;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const m = grid[y * cols + x];
const px0 = x * CELL, py0 = y * CELL;
const d = Math.hypot(px0 - cx, py0 - cy) / maxD;
const glow = Math.max(0, 1 - d) * (0.08 + 0.05 * wave);
let c: RGB;
if (m === EMPTY) c = shade(col.night, Math.round(glow * 40));
else if (m === DIRT) c = shade(col.dirt, ((x * 7 + y * 13) % 5) - 2 + Math.round(glow * 14));
else if (m === STONE) c = shade(col.stone, ((x * 11 + y * 5) % 5) - 2 + Math.round(glow * 10));
else if (m === SAND) c = (x + y) % 3 === 0 ? shade(col.sandA, Math.round(glow * 18)) : shade(col.sandB, Math.round(glow * 18));
else if (m === WATER) {
const shimmer = Math.sin(t * 0.004 + x * 0.6 + y * 0.3) * 0.5 + 0.5;
c = shade(shimmer > 0.5 ? col.ice : col.water, Math.round(glow * 12));
} else if (m === FIRE) {
const flick = Math.sin(t * 0.03 + x * 0.9 + y * 0.7) * 0.5 + 0.5;
c = shade(flick > 0.5 ? col.flame : col.fire, Math.round(glow * 20));
} else if (m === SMOKE) c = shade(col.smoke, ((x + y * 3) % 7) - 3 + Math.round(glow * 12));
else if (m === ASH) c = shade(col.ash, ((x * 5 + y * 3) % 5) - 2 + Math.round(glow * 12));
else if (m === SEED) c = shade(col.plant, -20 + Math.round(glow * 12));
else c = (x + y) % 4 === 0 ? shade(col.leaf, Math.round(glow * 14)) : shade(col.plant, Math.round(glow * 14));
for (let cyi = 0; cyi < CELL; cyi++) {
const rowOff = ((py0 + cyi) * W + px0) * 4;
for (let cxi = 0; cxi < CELL; cxi++) {
const o = rowOff + cxi * 4;
buf[o] = c[0]; buf[o + 1] = c[1]; buf[o + 2] = c[2]; buf[o + 3] = 255;
}
}
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const r = field.getBoundingClientRect();
sizeRef.current.w = Math.max(1, Math.round(r.width));
sizeRef.current.h = Math.max(1, Math.round(r.height));
cv.width = sizeRef.current.w;
cv.height = sizeRef.current.h;
ctxRef.current = ctx;
const CELL = cellRef.current;
dimRef.current.cols = Math.max(1, Math.floor(sizeRef.current.w / CELL));
dimRef.current.rows = Math.max(1, Math.floor(sizeRef.current.h / CELL));
gridRef.current = new Uint8Array(dimRef.current.cols * dimRef.current.rows);
lifeRef.current = new Uint8Array(dimRef.current.cols * dimRef.current.rows);
imgRef.current = null;
buildSceneStagger();
}
let last = 0, acc = 0;
const STEP = 1000 / 60;
function frame(now: number): void {
if (!last) last = now;
acc += now - last;
last = now;
let guard = 0;
while (acc >= STEP && guard < 6) { update(); acc -= STEP; guard++; }
draw(now);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
cellRef.current = cellPx;
rainRef.current = rainP;
strikeRef.current = strikeSec;
regrowRef.current = regrowP;
randRef.current = makePRNG(0x318a09);
tickRef.current = 0;
nextStrikeRef.current = 72;
colRef.current = {
night: tokRGB(root, "--sand-night"),
stone: tokRGB(root, "--sand-stone"),
dirt: tokRGB(root, "--sand-wood"),
sandA: tokRGB(root, "--sand-grain"),
sandB: tokRGB(root, "--sand-gold"),
water: tokRGB(root, "--sand-water"),
ice: tokRGB(root, "--sand-ice"),
fire: tokRGB(root, "--sand-fire"),
flame: tokRGB(root, "--sand-flame"),
ash: tokRGB(root, "--sand-ash"),
smoke: tokRGB(root, "--sand-smoke"),
plant: tokRGB(root, "--sand-plant"),
leaf: tokRGB(root, "--sand-leaf"),
glow: tokRGB(root, "--sand-glow"),
};
fit();
function onResize(): void { last = 0; acc = 0; fit(); }
window.addEventListener("resize", onResize);
if (reduced()) {
for (let i = 0; i < 110; i++) update();
draw(1800);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rainP, strikeSec, regrowP, cellPx]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>비·불·물·풀이 한 격자에서 순환합니다</span>
</p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>6</b>종 재료 · 생태계 순환</span>
<span className={styles.metaDim}>canvas · ImageData putImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}09 Ecosystem scene — sand, water, fire and plants running together in one grid — Measured area 32.884% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Falling Sand Simulator, 9 Ways (No Library)
The post this demo lives in
Falling Sand Simulator, 9 Ways (No Library)No library, no WebGL — four rules per cell (down, down-left, down-right, sideways) make sand pile up, water flow and fir See all demos in this post
If you like this effect - similar demos
Creative UI09 Checker floor — shadow, fog and reflection in one scene9 Ray Marching Scenes in Canvas (No WebGL)
Creative UI05 Lava flow — heat piling up in grid cells9 Canvas Fire Animation Scenes, No WebGL
Creative UI01 Basic three-sensor agent — one slime cell sniffing front, left, right and turningSlime Mold Simulation, 9 Ways (No Library)