04불 번짐 — 이웃 연료를 태우고 줄어드는 소모 규칙
불 칸은 매 프레임 확률로 이웃 나무·기름 칸을 불로 바꾸고 자신은 수명을 한 칸 줄여 재가 되게 하면, 타고 넘어가며 결국 꺼지는 불줄기가 재료 사이를 이동합니다.
쓰임게임 파괴 이펙트, 다크 히어로의 불티 배경
ImageDataputImageDatarequestAnimationFrame

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint"><span>불은 이웃한 나무를 확률로 태우고, 스스로는 재가 됩니다</span></p>
<div class="cd__field" id="cdField" role="application" aria-label="불 번짐 셀 오토마타 데모" 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>점화 확률 %</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 불 번짐 — 이웃 연료를 태우고 줄어드는 소모 규칙 — 측정 누적면적 49.402% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 폴링 샌드(falling sand simulator) 9종 — 모래·물·불 셀 오토마타
이 데모가 속한 글
폴링 샌드(falling sand simulator) 9종 — 모래·물·불 셀 오토마타라이브러리·WebGL 없이 칸 하나의 규칙 네 개(아래·좌아래·우아래·옆)로 모래가 쌓이고 물이 흐르고 불이 번집니다 — 이 폴링 샌드(falling sand simulator) 9종은 그 계산을 캔버스와 바닐라 자 이 글의 데모 전부 보기


