04Fire spread — a consumption rule that burns neighboring fuel and shrinks
A fire cell stochastically ignites adjacent wood or oil cells while its own life ticks down to ash, so flames crawl through the materials, burn out and leave residue behind.
Best forA game destruction effect, an ember backdrop on a dark hero
ImageDataputImageDatarequestAnimationFrame

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint"><span>Fire ignites neighboring trees with a probability, then itself turns to ash</span></p>
<div class="cd__field" id="cdField" role="application" aria-label="Fire Spread 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>% ignition chance</span>
<span class="cd__meta cd__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 불 번짐 소모 규칙(04). @keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(셀 오토마타 점화·수명·연기)가 만든다.
// 무대는 모래의 밤. 파스텔 고정 팔레트 16색을 --sand-* 로 편 공통 규격(318 전 항목 동일).
$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 {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --sand-* 를 받아 쓴다
--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;
}
}// 불 번짐(04). 재료 코드 0 빈칸 1 모래 2 물 3 벽 4 나무 5 불 6 재 7 연기 8 씨앗 9 식물 10 흙 11 돌.
// 불 칸은 매 틱 이웃(4방향) 나무·기름(밝은 fire 색 조명 나무) 칸을 IGNITE_P 확률로 불로 바꾸고,
// 자신은 LIFE 틱의 수명이 다하면 재가 된 뒤 위로 연기를 몇 칸 보낸다 — 타고 넘어가며 꺼지는 불줄기.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var IGNITE_P = 0.06; // 이웃 점화 확률 — 0.02 는 느린 은은한 불씨, 0.14 는 폭발적 화염
var LIFE = 14; // 불 수명(틱) — 짧으면 잔불 없이 스치듯, 길면 오래 타오른다
// ────────────────────────────────────────────────────
var EMPTY=0, WOOD=4, FIRE=5, ASH=6, SMOKE=7, OIL=8;
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 연소 순서로 흐른다
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(0x318A44);
// 색은 CSS 커스텀 프로퍼티(--sand-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var 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)];
}
var C_NIGHT = tokRGB('--sand-night');
var C_GLOW = tokRGB('--sand-glow');
var C_FIRE = tokRGB('--sand-fire');
var C_FLAME = tokRGB('--sand-flame');
var C_GOLD = tokRGB('--sand-gold');
var C_ASH = tokRGB('--sand-ash');
var C_SMOKE = tokRGB('--sand-smoke');
var C_WOOD = tokRGB('--sand-wood');
var C_MIST = tokRGB('--sand-mist');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, cols = 0, rows = 0, CELL = 4;
var grid = null, life = null, img = null, buf = null;
var raf = 0, t0 = -1;
var tick = 0;
// ── 장면 — 계단식 나무 발판 3단 + 바닥 기름 웅덩이 ──
// 오일은 '밝은 fire 색조로 조명된 나무' — 가연물이되 셀 색만 fire 톤으로 둔다(기름 표면의 광택)
function idx(x, y) { return y * cols + x; }
function build() {
grid = new Uint8Array(cols * rows);
life = new Uint8Array(cols * rows);
var x, y, i;
// 계단식 나무 발판 3단 — 좌하단에서 우상단으로 올라가는 층
var rowsPer = Math.max(2, Math.round(rows * 0.05));
var y0 = rows - Math.max(2, Math.round(rows * 0.16));
for (var step = 0; step < 3; step++) {
var x0 = Math.round(cols * (0.06 + step * 0.24));
var x1 = Math.round(cols * (0.30 + step * 0.24));
var yy = y0 - step * Math.round(rows * 0.16);
for (x = x0; x < x1; x++)
for (y = yy; y < yy + rowsPer; y++)
if (x >= 0 && x < cols && y >= 0 && y < rows) grid[idx(x, y)] = WOOD;
}
// 바닥 기름 웅덩이 — 맨 아래 2줄 전체 폭
for (x = 0; x < cols; x++)
for (y = rows - 2; y < rows; y++)
grid[idx(x, y)] = OIL;
img = ctx.createImageData(cols, rows);
buf = img.data;
}
// ── 점화 스케줄 — t=0 첫 나무, 이후 ~1.2초마다 다른 위치(우측 발판 → 기름) 재점화 ──
var ignitions = [];
function scheduleIgnitions() {
ignitions = [
{ at: 0.05, kind: 'wood1' },
{ at: 1.2, kind: 'wood3' },
{ at: 2.4, kind: 'oil' },
{ at: 3.6, kind: 'wood2' },
{ at: 4.8, kind: 'oil' },
{ at: 6.0, kind: 'wood1' },
{ at: 7.2, kind: 'wood3' },
{ at: 8.4, kind: 'oil' }
];
}
var ignIdx = 0;
function doIgnite(kind) {
var y0 = rows - Math.max(2, Math.round(rows * 0.16));
var rowsPer = Math.max(2, Math.round(rows * 0.05));
function spark(cx, top, w, h, mat) {
// 해당 재료 영역에서 무작위 한 칸을 찾아 불로
for (var k = 0; k < 60; k++) {
var x = cx + Math.floor(rand() * w);
var y = top + Math.floor(rand() * h);
if (x < 0 || x >= cols || y < 0 || y >= rows) continue;
var i = idx(x, y);
if (grid[i] === mat) { grid[i] = FIRE; life[i] = LIFE; return true; }
}
return false;
}
if (kind === 'wood1') spark(Math.round(cols * 0.10), y0, Math.round(cols * 0.22), rowsPer, WOOD);
else if (kind === 'wood2') spark(Math.round(cols * 0.34), y0 - Math.round(rows * 0.16), Math.round(cols * 0.22), rowsPer, WOOD);
else if (kind === 'wood3') spark(Math.round(cols * 0.58), y0 - Math.round(rows * 0.32), Math.round(cols * 0.22), rowsPer, WOOD);
else spark(Math.round(cols * 0.1), rows - 2, Math.round(cols * 0.8), 2, OIL);
}
function update() {
tick++;
// 아래 행 → 위 행. 이미 이번 틱에 갱신된 칸은 건너뛴다(불의 연쇄가 한 틱에 끝까지 달리는 것 방지)
var moved = new Uint8Array(cols * rows);
var x, y, i, d;
for (y = rows - 1; y >= 0; y--) {
var ltr = (tick & 1) === 0; // 홀짝 스캔 방향 교차 — 번짐 비대칭 방지
for (var s = 0; s < cols; s++) {
x = ltr ? s : cols - 1 - s;
i = idx(x, y);
if (moved[i]) continue;
var m = grid[i];
if (m === FIRE) {
// 이웃 점화 — 4방향의 나무·기름을 확률로 불로
var nb = [[1,0],[-1,0],[0,1],[0,-1]];
for (d = 0; d < 4; d++) {
var nx = x + nb[d][0], ny = y + nb[d][1];
if (nx < 0 || nx >= cols || ny < 0 || ny >= rows) continue;
var j = idx(nx, ny);
if ((grid[j] === WOOD || grid[j] === OIL) && rand() < IGNITE_P) {
grid[j] = FIRE; life[j] = LIFE; moved[j] = 1;
}
}
// 수명 감소 — 다하면 재, 위 빈칸에 연기 시도
if (life[i] <= 1) {
grid[i] = ASH;
if (y > 0 && grid[idx(x, y - 1)] === EMPTY && rand() < 0.5) {
grid[idx(x, y - 1)] = SMOKE;
if (y > 1 && grid[idx(x, y - 2)] === EMPTY && rand() < 0.5) grid[idx(x, y - 2)] = SMOKE;
}
} else {
life[i]--;
}
} else if (m === SMOKE) {
// 연기 — 위로 한 칸, 막히면 옆. 오래 남지 않게 확률 소멸
if (rand() < 0.22) { grid[i] = EMPTY; continue; }
var up = y > 0 ? idx(x, y - 1) : -1;
if (up >= 0 && grid[up] === EMPTY) {
grid[up] = SMOKE; grid[i] = EMPTY; moved[up] = 1;
} else if (x > 0 && grid[idx(x - 1, y)] === EMPTY && rand() < 0.5) {
grid[idx(x - 1, y)] = SMOKE; grid[i] = EMPTY;
} else if (x < cols - 1 && grid[idx(x + 1, y)] === EMPTY && rand() < 0.5) {
grid[idx(x + 1, y)] = SMOKE; grid[i] = EMPTY;
}
} else if (m === ASH) {
// 재 — 낙하(아래 빈칸) + 확률 소멸. 소멸한 자리는 연기 없이 비운다
if (y < rows - 1 && grid[idx(x, y + 1)] === EMPTY) {
grid[idx(x, y + 1)] = ASH; grid[i] = EMPTY; moved[idx(x, y + 1)] = 1;
} else if (rand() < 0.012) { grid[i] = EMPTY; }
}
}
}
}
// ── 렌더 — ImageData 버퍼 → putImageData. 1 픽셀 = 1 칸 ──
function px(o, c, k) {
buf[o] = c[0] * k;
buf[o + 1] = c[1] * k;
buf[o + 2] = c[2] * k;
buf[o + 3] = 255;
}
function draw(t) {
var x, y, i, o;
// 배경 — 밤. 칸 단위로 밤색을 깔아 ImageData 전체를 채운다
for (i = 0; i < cols * rows; i++) {
o = i * 4;
buf[o] = C_NIGHT[0]; buf[o + 1] = C_NIGHT[1]; buf[o + 2] = C_NIGHT[2]; buf[o + 3] = 255;
}
// 격자 재료 렌더
var flicker = 0.5 + 0.5 * Math.sin(t * 9.3) * Math.cos(t * 6.1);
for (y = 0; y < rows; y++) {
for (x = 0; x < cols; x++) {
i = idx(x, y);
o = i * 4;
var m = grid[i];
if (m === WOOD) px(o, C_WOOD, 1);
else if (m === OIL) px(o, C_FIRE, 0.9);
else if (m === ASH) px(o, C_ASH, 0.95);
else if (m === SMOKE) px(o, C_SMOKE, 0.8);
else if (m === FIRE) {
// 매 틱 fire/flame/gold 랜덤 스왑 — 타오르는 질감
var r3 = rand();
var fc = r3 < 0.4 ? C_FIRE : (r3 < 0.8 ? C_FLAME : C_GOLD);
px(o, fc, 0.85 + 0.3 * flicker);
}
}
}
ctx.putImageData(img, 0, 0);
// 잔광(안개) — 중심이 sin/cos 로 천천히 도는 radial gradient, 알파 낮게 — 화면이 늘 살아 있다
var gx = W * (0.5 + 0.30 * Math.sin(t * 0.9));
var gy = H * (0.55 + 0.26 * Math.cos(t * 0.7));
var gr = Math.max(W, H) * 0.52;
var g = ctx.createRadialGradient(gx, gy, 0, gx, gy, gr);
g.addColorStop(0, 'rgba(' + C_GLOW.join(',') + ',0.07)');
g.addColorStop(1, 'rgba(' + C_GLOW.join(',') + ',0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
cols = Math.max(8, Math.floor(W / CELL));
rows = Math.max(8, Math.floor(H / CELL));
build();
scheduleIgnitions();
ignIdx = 0;
tick = 0;
}
function stillFrame() {
// reduce — 점화 직후 몇 틱만 돌리고 한 장
doIgnite('wood1');
for (var k = 0; k < 12; k++) update();
draw(0.2);
}
var STEP = 1000 / 60, acc = 0;
function frame(now) {
if (t0 < 0) t0 = now;
var dt = now - t0;
acc = acc + (now - (frame.prev || now));
frame.prev = now;
while (acc >= STEP) {
update();
var t = tick / 60;
while (ignIdx < ignitions.length && ignitions[ignIdx].at <= t) {
doIgnite(ignitions[ignIdx].kind);
ignIdx++;
}
acc -= STEP;
}
draw(dt / 1000);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
nOut.textContent = String(Math.round(IGNITE_P * 100));
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// FireBurnSpread — vanilla 판(run/318/demo/04-sand-fire-burn-spread/index.html) 의 React 포트.
// 불 번짐 소모 규칙 — 불 칸은 이웃 나무·기름을 확률로 점화하고 수명이 다하면 재가 되며 연기를 남긴다.
// 계단식 나무 발판 3단 + 바닥 기름 웅덩이를 ~1.2초마다 다른 위치에서 재점화해 화면이 늘 활동한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FireBurnSpread.module.scss";
export type FireBurnSpreadProps = {
duration?: string;
easing?: string;
color?: string;
igniteP?: number; // 이웃 점화 확률 — 변형 "점화 확률" 축(0.02 은은 · 0.14 폭발)
life?: number; // 불 수명(틱)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Ignition = { at: number; kind: string };
type Colors = {
night: RGB; glow: RGB; fire: RGB; flame: RGB; gold: RGB;
ash: RGB; smoke: RGB; wood: RGB;
};
const TAU = Math.PI * 2;
const FIELD_LABEL = "불 번짐 셀 오토마타 데모";
const EMPTY = 0, WOOD = 4, FIRE = 5, ASH = 6, SMOKE = 7, OIL = 8;
const CELL = 4;
const STEP = 1000 / 60;
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 연소 순서로 흐른다
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 FireBurnSpread({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
igniteP = 0.06,
life = 14,
}: FireBurnSpreadProps) {
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 ignitePRef = useRef(igniteP) as Box<number>;
const lifeRef = useRef(life) as Box<number>;
const gridRef = useRef<Uint8Array | null>(null) as Box<Uint8Array | null>;
const lifeGridRef = useRef<Uint8Array | null>(null) as Box<Uint8Array | null>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const dimsRef = useRef({ cols: 0, rows: 0 }) as Box<{ cols: number; rows: number }>;
const tickRef = useRef(0) as Box<number>;
const ignRef = useRef<Ignition[]>([]) as Box<Ignition[]>;
const ignIdxRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const randRef = useRef(makePRNG(0x318a44)) as Box<() => number>;
const [n, setN] = useState(String(Math.round(igniteP * 100)));
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;
}
// 색은 CSS 커스텀 프로퍼티(--sand-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
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 idx(x: number, y: number): number {
const { cols } = dimsRef.current;
return y * cols + x;
}
// ── 장면 — 계단식 나무 발판 3단 + 바닥 기름 웅덩이 ──
// 오일은 '밝은 fire 색조로 조명된 나무' — 가연물이되 셀 색만 fire 톤으로 둔다(기름 표면의 광택)
function build(): void {
const { cols, rows } = dimsRef.current;
const grid = new Uint8Array(cols * rows);
const lifeGrid = new Uint8Array(cols * rows);
const rowsPer = Math.max(2, Math.round(rows * 0.05));
const y0 = rows - Math.max(2, Math.round(rows * 0.16));
for (let step = 0; step < 3; step++) {
const x0 = Math.round(cols * (0.06 + step * 0.24));
const x1 = Math.round(cols * (0.3 + step * 0.24));
const yy = y0 - step * Math.round(rows * 0.16);
for (let x = x0; x < x1; x++)
for (let y = yy; y < yy + rowsPer; y++)
if (x >= 0 && x < cols && y >= 0 && y < rows) grid[idx(x, y)] = WOOD;
}
for (let x = 0; x < cols; x++)
for (let y = rows - 2; y < rows; y++)
grid[idx(x, y)] = OIL;
gridRef.current = grid;
lifeGridRef.current = lifeGrid;
const ctx = ctxRef.current;
if (ctx) imgRef.current = ctx.createImageData(cols, rows);
}
// ── 점화 스케줄 — t=0 첫 나무, 이후 ~1.2초마다 다른 위치(우측 발판 → 기름) 재점화 ──
function scheduleIgnitions(): void {
ignRef.current = [
{ at: 0.05, kind: "wood1" },
{ at: 1.2, kind: "wood3" },
{ at: 2.4, kind: "oil" },
{ at: 3.6, kind: "wood2" },
{ at: 4.8, kind: "oil" },
{ at: 6.0, kind: "wood1" },
{ at: 7.2, kind: "wood3" },
{ at: 8.4, kind: "oil" },
];
ignIdxRef.current = 0;
}
function doIgnite(kind: string): void {
if (!gridRef.current || !lifeGridRef.current) return;
const grid: Uint8Array = gridRef.current;
const lifeGrid: Uint8Array = lifeGridRef.current;
const { cols, rows } = dimsRef.current;
const rand = randRef.current;
const y0 = rows - Math.max(2, Math.round(rows * 0.16));
const rowsPer = Math.max(2, Math.round(rows * 0.05));
function spark(cx: number, top: number, w: number, h: number, mat: number): void {
for (let k = 0; k < 60; k++) {
const x = cx + Math.floor(rand() * w);
const y = top + Math.floor(rand() * h);
if (x < 0 || x >= cols || y < 0 || y >= rows) continue;
const i = idx(x, y);
if (grid[i] === mat) { grid[i] = FIRE; lifeGrid[i] = lifeRef.current; return; }
}
}
if (kind === "wood1") spark(Math.round(cols * 0.1), y0, Math.round(cols * 0.22), rowsPer, WOOD);
else if (kind === "wood2") spark(Math.round(cols * 0.34), y0 - Math.round(rows * 0.16), Math.round(cols * 0.22), rowsPer, WOOD);
else if (kind === "wood3") spark(Math.round(cols * 0.58), y0 - Math.round(rows * 0.32), Math.round(cols * 0.22), rowsPer, WOOD);
else spark(Math.round(cols * 0.1), rows - 2, Math.round(cols * 0.8), 2, OIL);
}
function update(): void {
const grid = gridRef.current;
const lifeGrid = lifeGridRef.current;
const { cols, rows } = dimsRef.current;
const rand = randRef.current;
if (!grid || !lifeGrid) return;
tickRef.current++;
const tick = tickRef.current;
const moved = new Uint8Array(cols * rows);
for (let y = rows - 1; y >= 0; y--) {
const ltr = (tick & 1) === 0; // 홀짝 스캔 방향 교차 — 번짐 비대칭 방지
for (let s = 0; s < cols; s++) {
const x = ltr ? s : cols - 1 - s;
const i = idx(x, y);
if (moved[i]) continue;
const m = grid[i];
if (m === FIRE) {
// 이웃 점화 — 4방향의 나무·기름을 확률로 불로
const nb = [[1, 0], [-1, 0], [0, 1], [0, -1]];
for (let d = 0; d < 4; d++) {
const nx = x + nb[d][0], ny = y + nb[d][1];
if (nx < 0 || nx >= cols || ny < 0 || ny >= rows) continue;
const j = idx(nx, ny);
if ((grid[j] === WOOD || grid[j] === OIL) && rand() < ignitePRef.current) {
grid[j] = FIRE; lifeGrid[j] = lifeRef.current; moved[j] = 1;
}
}
// 수명 감소 — 다하면 재, 위 빈칸에 연기 시도
if (lifeGrid[i] <= 1) {
grid[i] = ASH;
if (y > 0 && grid[idx(x, y - 1)] === EMPTY && rand() < 0.5) {
grid[idx(x, y - 1)] = SMOKE;
if (y > 1 && grid[idx(x, y - 2)] === EMPTY && rand() < 0.5) grid[idx(x, y - 2)] = SMOKE;
}
} else {
lifeGrid[i]--;
}
} else if (m === SMOKE) {
// 연기 — 위로 한 칸, 막히면 옆. 오래 남지 않게 확률 소멸
if (rand() < 0.22) { grid[i] = EMPTY; continue; }
const up = y > 0 ? idx(x, y - 1) : -1;
if (up >= 0 && grid[up] === EMPTY) {
grid[up] = SMOKE; grid[i] = EMPTY; moved[up] = 1;
} else if (x > 0 && grid[idx(x - 1, y)] === EMPTY && rand() < 0.5) {
grid[idx(x - 1, y)] = SMOKE; grid[i] = EMPTY;
} else if (x < cols - 1 && grid[idx(x + 1, y)] === EMPTY && rand() < 0.5) {
grid[idx(x + 1, y)] = SMOKE; grid[i] = EMPTY;
}
} else if (m === ASH) {
// 재 — 낙하(아래 빈칸) + 확률 소멸. 소멸한 자리는 연기 없이 비운다
if (y < rows - 1 && grid[idx(x, y + 1)] === EMPTY) {
grid[idx(x, y + 1)] = ASH; grid[i] = EMPTY; moved[idx(x, y + 1)] = 1;
} else if (rand() < 0.012) { grid[i] = EMPTY; }
}
}
}
}
// ── 렌더 — ImageData 버퍼 → putImageData. 1 픽셀 = 1 칸 ──
function px(buf: Uint8ClampedArray, o: number, c: RGB, k: number): void {
buf[o] = c[0] * k;
buf[o + 1] = c[1] * k;
buf[o + 2] = c[2] * k;
buf[o + 3] = 255;
}
function draw(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
const grid = gridRef.current;
const { w: W, h: H } = sizeRef.current;
const { cols, rows } = dimsRef.current;
const rand = randRef.current;
if (!ctx || !col || !img || !grid) return;
const buf = img.data;
let i: number, o: number;
for (i = 0; i < cols * rows; i++) {
o = i * 4;
buf[o] = col.night[0]; buf[o + 1] = col.night[1]; buf[o + 2] = col.night[2]; buf[o + 3] = 255;
}
const flicker = 0.5 + 0.5 * Math.sin(t * 9.3) * Math.cos(t * 6.1);
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
i = idx(x, y);
o = i * 4;
const m = grid[i];
if (m === WOOD) px(buf, o, col.wood, 1);
else if (m === OIL) px(buf, o, col.fire, 0.9);
else if (m === ASH) px(buf, o, col.ash, 0.95);
else if (m === SMOKE) px(buf, o, col.smoke, 0.8);
else if (m === FIRE) {
// 매 틱 fire/flame/gold 랜덤 스왑 — 타오르는 질감
const r3 = rand();
const fc = r3 < 0.4 ? col.fire : r3 < 0.8 ? col.flame : col.gold;
px(buf, o, fc, 0.85 + 0.3 * flicker);
}
}
}
ctx.putImageData(img, 0, 0);
// 잔광(안개) — 중심이 sin/cos 로 천천히 도는 radial gradient, 알파 낮게 — 화면이 늘 살아 있다
const gx = W * (0.5 + 0.3 * Math.sin(t * 0.9));
const gy = H * (0.55 + 0.26 * Math.cos(t * 0.7));
const gr = Math.max(W, H) * 0.52;
const g = ctx.createRadialGradient(gx, gy, 0, gx, gy, gr);
g.addColorStop(0, `rgba(${col.glow.join(",")},0.07)`);
g.addColorStop(1, `rgba(${col.glow.join(",")},0)`);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
dimsRef.current.cols = Math.max(8, Math.floor(r.width / CELL));
dimsRef.current.rows = Math.max(8, Math.floor(r.height / CELL));
build();
scheduleIgnitions();
tickRef.current = 0;
}
function stillFrame(): void {
// reduce — 점화 직후 몇 틱만 돌리고 한 장
doIgnite("wood1");
for (let k = 0; k < 12; k++) update();
draw(0.2);
}
let t0 = -1;
let prev = 0;
let acc = 0;
function frame(now: number): void {
if (t0 < 0) t0 = now;
acc += now - (prev || now);
prev = now;
while (acc >= STEP) {
update();
const t = tickRef.current / 60;
const igns = ignRef.current;
while (ignIdxRef.current < igns.length && igns[ignIdxRef.current].at <= t) {
doIgnite(igns[ignIdxRef.current].kind);
ignIdxRef.current++;
}
acc -= STEP;
}
draw((now - t0) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
ignitePRef.current = igniteP;
lifeRef.current = life;
colRef.current = {
night: tokRGB(root, "--sand-night"),
glow: tokRGB(root, "--sand-glow"),
fire: tokRGB(root, "--sand-fire"),
flame: tokRGB(root, "--sand-flame"),
gold: tokRGB(root, "--sand-gold"),
ash: tokRGB(root, "--sand-ash"),
smoke: tokRGB(root, "--sand-smoke"),
wood: tokRGB(root, "--sand-wood"),
};
randRef.current = makePRNG(0x318a44);
fit();
setN(String(Math.round(igniteP * 100)));
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [igniteP, life]);
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}>{n}</b>점화 확률 %</span>
<span className={styles.metaDim}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}04 Fire spread — a consumption rule that burns neighboring fuel and shrinks — Measured area 49.402% · 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 Ember spread — a grid of fuel cells burning outward9 Canvas Fire Animation Scenes, No WebGL
Creative UI05 Sheep fleece — several seeds collide and blend9 Reaction Diffusion Simulations, No Library
Creative UI03 Torus ring — the donut distance and rim light9 Ray Marching Scenes in Canvas (No WebGL)