03물 흐름 — 아래가 막히면 좌우로 퍼지는 액체 규칙
물 칸은 아래→대각선→왼쪽·오른쪽 순서로 시도하고 좌우 이동은 매 프레임 방향을 바꿔 주면, 모래는 쌓이고 물은 낮은 곳을 찾아 흐르는 두 성질이 같은 격자 안에서 공존합니다.
쓰임물감 스밈 효과, 인터랙티브 게임의 지형 침수 연출
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">1</b>칸 물 퍼짐 1칸</span>
<span class="cd__meta cd__meta--dim">canvas · ImageData putImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 물 흐름(03). @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 {
// 렌더러가 읽는 색 — 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 { color: $sand-mist; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); margin: 0; }
.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 {
color: #d8d2c6;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
margin: 0;
}
.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;
}
}// 물 흐름(03). 모래는 02 규칙(낙하+대각), 물은 액체 규칙 —
// 아래 → 대각 → 좌우(매 프레임 방향 교대, 한 번에 SPREAD칸).
// 바닥에 울퉁불퉁한 고정 돌 지형 2~3단을 깔아 모래는 쌓이고 물은 낮은 곳으로 흘러 고인다.
// 렌더는 ImageData 버퍼 → putImageData.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var SPREAD = 1; // 물 좌우 퍼짐 칸수/프레임 — 1 기본, 2~3 은 빠른 유체
var SAND_P = 0.42; // 모래 스포너 칸별 매 틱 생성 확률
var WATER_P = 0.40; // 물 스포너 칸별 매 틱 생성 확률
var ZONE_W = 22; // 스포너 폭(칸)
// ────────────────────────────────────────────────────
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');
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(0x318A03);
// 색은 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_GRAIN = tokRGB('--sand-grain');
var C_GOLD = tokRGB('--sand-gold');
var C_WATER = tokRGB('--sand-water');
var C_ICE = tokRGB('--sand-ice');
var C_STONE = tokRGB('--sand-stone');
var EMPTY = 0, SAND = 1, WATER = 2, STONE = 3;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var CELL = 4;
var W = 0, H = 0, cols = 0, rows = 0;
var grid = null, shade = null;
var sandX = 0, waterX = 0;
var flowDir = 1; // 물 좌우 방향 — 매 틱 교대
var raf = 0, t0 = -1, acc = 0;
var STEP = 1 / 60;
var img = null, buf = null;
function idx(x, y) { return y * cols + x; }
// 울퉁불퉁한 고정 돌 지형 2~3단 — 시드로 결정(매번 같은 지형)
function terrain() {
var i, x, y;
// 1단 — 왼쪽 낮은 언덕
var h1 = Math.round(rows * 0.22);
for (x = 0; x < Math.round(cols * 0.30); x++) {
var rise = Math.round((1 - x / (cols * 0.30)) * h1 * 0.6);
for (y = 0; y <= rise; y++) grid[idx(x, rows - 1 - y)] = STONE;
}
// 2단 — 중앙 능선(가장 높음)
var h2 = Math.round(rows * 0.34);
var cx0 = Math.round(cols * 0.44), cx1 = Math.round(cols * 0.62);
for (x = cx0; x < cx1; x++) {
var k = 1 - Math.abs((x - (cx0 + cx1) / 2) / ((cx1 - cx0) / 2));
var rise2 = Math.round(k * h2);
for (y = 0; y <= rise2; y++) grid[idx(x, rows - 1 - y)] = STONE;
}
// 3단 — 오른쪽 낮은 언덕
var h3 = Math.round(rows * 0.26);
for (x = Math.round(cols * 0.76); x < cols; x++) {
var k3 = (x - cols * 0.76) / (cols * 0.24);
var rise3 = Math.round(k3 * h3 * 0.7);
for (y = 0; y <= rise3; y++) grid[idx(x, rows - 1 - y)] = STONE;
}
// 바닥 한 줄
for (x = 0; x < cols; x++) grid[idx(x, rows - 1)] = STONE;
i = 0;
}
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(1, Math.floor(W / CELL));
rows = Math.max(1, Math.floor(H / CELL));
grid = new Uint8Array(cols * rows);
shade = new Uint8Array(cols * rows);
sandX = Math.max(0, Math.round(cols * 0.16));
waterX = Math.max(0, Math.round(cols * 0.84 - ZONE_W));
terrain();
img = ctx.createImageData(Math.round(W), Math.round(H));
buf = img.data;
}
function spawnStep() {
var x, i;
for (var k = 0; k < ZONE_W; k++) {
x = sandX + k;
if (x >= 0 && x < cols && rand() < SAND_P) {
i = idx(x, 0);
if (grid[i] === EMPTY) { grid[i] = SAND; shade[i] = rand() < 0.5 ? 0 : 1; }
}
x = waterX + k;
if (x >= 0 && x < cols && rand() < WATER_P) {
i = idx(x, 0);
if (grid[i] === EMPTY) grid[i] = WATER;
}
}
}
function swap(a, b) {
var g = grid[a]; grid[a] = grid[b]; grid[b] = g;
var s = shade[a]; shade[a] = shade[b]; shade[b] = s;
}
function update() {
flowDir = -flowDir; // 물 좌우 방향 매 틱 교대
var x, y, i, j, nx;
for (y = rows - 2; y >= 0; y--) {
for (x = 0; x < cols; x++) {
i = idx(x, y);
var c = grid[i];
if (c !== SAND && c !== WATER) continue;
// 아래로 — 물은 모래·돌 위로 못 감. 모래가 물 위로 가라앉음(밀도)
var bi = idx(x, y + 1);
if (grid[bi] === EMPTY || (c === SAND && grid[bi] === WATER)) {
swap(i, bi);
continue;
}
if (c === SAND) {
// 대각선 — 좌하 → 우하 순서
var dir = rand() < 0.5 ? -1 : 1;
var ok1 = false, ok2 = false;
nx = x + dir;
if (nx >= 0 && nx < cols) {
var d1 = idx(nx, y + 1), d1s = idx(nx, y);
var t1 = grid[d1] === EMPTY || grid[d1] === WATER;
if (t1 && (grid[d1s] === EMPTY || grid[d1s] === WATER)) ok1 = true;
}
if (!ok1) {
nx = x - dir;
if (nx >= 0 && nx < cols) {
var d2 = idx(nx, y + 1), d2s = idx(nx, y);
var t2 = grid[d2] === EMPTY || grid[d2] === WATER;
if (t2 && (grid[d2s] === EMPTY || grid[d2s] === WATER)) ok2 = true;
}
}
if (ok1 || ok2) {
var dx = ok1 ? dir : -dir;
swap(i, idx(x + dx, y + 1));
}
} else {
// 물 — 대각선
var wdir = flowDir;
var wok1 = false, wok2 = false;
nx = x + wdir;
if (nx >= 0 && nx < cols && grid[idx(nx, y + 1)] === EMPTY && grid[idx(nx, y)] === EMPTY) wok1 = true;
if (!wok1) {
nx = x - wdir;
if (nx >= 0 && nx < cols && grid[idx(nx, y + 1)] === EMPTY && grid[idx(nx, y)] === EMPTY) wok2 = true;
}
if (wok1 || wok2) {
swap(i, idx(x + (wok1 ? wdir : -wdir), y + 1));
continue;
}
// 물 — 좌우 퍼짐(한 번에 SPREAD칸)
var moved = false;
for (var s = 1; s <= SPREAD; s++) {
nx = x + flowDir * s;
if (nx < 0 || nx >= cols) break;
var ti = idx(nx, y);
if (grid[ti] === EMPTY) {
// 통로 검사
var clear = true;
for (var q = 1; q <= s; q++) {
if (grid[idx(x + flowDir * q, y)] !== EMPTY) { clear = false; break; }
}
if (clear) { swap(i, ti); moved = true; break; }
} else break;
}
if (!moved) {
for (var s2 = 1; s2 <= SPREAD; s2++) {
nx = x - flowDir * s2;
if (nx < 0 || nx >= cols) break;
var ti2 = idx(nx, y);
if (grid[ti2] === EMPTY) {
var clear2 = true;
for (var q2 = 1; q2 <= s2; q2++) {
if (grid[idx(x - flowDir * q2, y)] !== EMPTY) { clear2 = false; break; }
}
if (clear2) { swap(i, ti2); moved = true; break; }
} else break;
}
}
}
}
}
}
function draw(t) {
// 느린 잔광 중심 — ImageData 위에 겹칠 수 없어 먼저 격자로 굽는다
var gx = 0.5 + 0.36 * Math.sin(t * 0.17);
var gy = 0.5 + 0.30 * Math.cos(t * 0.13);
var gr = Math.max(W, H) * 0.7;
var r2 = gr * gr;
var px, py, x, y, i, o;
var Wp = Math.round(W), Hp = Math.round(H);
for (py = 0; py < Hp; py++) {
for (px = 0; px < Wp; px++) {
o = (py * Wp + px) * 4;
// 밤 배경
buf[o] = C_NIGHT[0]; buf[o + 1] = C_NIGHT[1]; buf[o + 2] = C_NIGHT[2]; buf[o + 3] = 255;
// 잔광(안개) — radial gradient 근사, 알파 0.06 내외
var dxg = px - gx * Wp, dyg = py - gy * Hp;
var d2 = (dxg * dxg + dyg * dyg) / r2;
if (d2 < 1) {
var a = 0.07 * (1 - d2);
buf[o] += (C_GLOW[0] - C_NIGHT[0]) * a;
buf[o + 1] += (C_GLOW[1] - C_NIGHT[1]) * a;
buf[o + 2] += (C_GLOW[2] - C_NIGHT[2]) * a;
}
}
}
// 격자 렌더 — CELL 4px 블록
var c0, c1, c2;
for (y = 0; y < rows; y++) {
for (x = 0; x < cols; x++) {
i = idx(x, y);
var cell = grid[i];
if (cell === EMPTY) continue;
if (cell === SAND) { c0 = shade[i] ? C_GOLD : C_GRAIN; }
else if (cell === WATER) { c0 = C_WATER; }
else { c0 = C_STONE; }
var bx = x * CELL, by = y * CELL;
var bw = Math.min(CELL, Wp - bx), bh = Math.min(CELL, Hp - by);
for (var yy = 0; yy < bh; yy++) {
var rowo = ((by + yy) * Wp + bx) * 4;
for (var xx = 0; xx < bw; xx++) {
var oo = rowo + xx * 4;
buf[oo] = c0[0]; buf[oo + 1] = c0[1]; buf[oo + 2] = c0[2];
}
}
}
}
// 물 표면 하이라이트 — 물 셀 바로 위가 빈 칸이면 ice 톤 한 줄
for (y = 1; y < rows; y++) {
for (x = 0; x < cols; x++) {
i = idx(x, y);
if (grid[i] === WATER && grid[idx(x, y - 1)] === EMPTY) {
var hx = x * CELL, hy = y * CELL;
var hw = Math.min(1, Wp - hx);
if (hw > 0 && hy < Hp) {
var ho = (hy * Wp + hx) * 4;
buf[ho] = C_ICE[0]; buf[ho + 1] = C_ICE[1]; buf[ho + 2] = C_ICE[2];
}
}
}
}
ctx.putImageData(img, 0, 0);
}
function stillFrame() {
for (var k = 0; k < 40; k++) { spawnStep(); update(); }
draw(0.6);
}
function frame(now) {
if (t0 < 0) t0 = now;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
acc += dt;
while (acc >= STEP) { spawnStep(); update(); acc -= STEP; }
draw((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
nOut.textContent = String(SPREAD);
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// WaterFlowLiquid — vanilla 판(run/318/demo/03-sand-water-flow-liquid/index.html) 의 React 포트.
// 물 흐름 — 모래는 낙하+대각 규칙, 물은 아래→대각→좌우(매 프레임 방향 교대, 한 번에 SPREAD칸).
// 울퉁불퉁한 고정 돌 지형 위에 모래는 쌓이고 물은 낮은 곳으로 흘러 고인다. ImageData 렌더.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./WaterFlowLiquid.module.scss";
export type WaterFlowLiquidProps = {
duration?: string;
easing?: string;
color?: string;
spread?: number; // 물 좌우 퍼짐 칸수/프레임 — 변형 "물 퍼짐" 축(1 기본 · 3 빠른 유체)
sandP?: number; // 모래 스포너 칸별 매 틱 생성 확률
waterP?: number; // 물 스포너 칸별 매 틱 생성 확률
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "모래와 물 흐름 셀 오토마타 데모";
const CELL = 4;
const STEP = 1 / 60;
const ZONE_W = 10;
const EMPTY = 0, SAND = 1, WATER = 2, STONE = 3;
// 시드 고정 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 WaterFlowLiquid({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
spread = 1,
sandP = 0.34,
waterP = 0.3,
}: WaterFlowLiquidProps) {
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, cols: 0, rows: 0 }) as Box<{ w: number; h: number; cols: number; rows: number }>;
const gridRef = useRef<Uint8Array | null>(null) as Box<Uint8Array | null>;
const shadeRef = useRef<Uint8Array | null>(null) as Box<Uint8Array | null>;
const sandXRef = useRef(0) as Box<number>;
const waterXRef = useRef(0) as Box<number>;
const flowRef = useRef(1) as Box<number>;
const colRef = useRef<{ night: RGB; glow: RGB; grain: RGB; gold: RGB; water: RGB; ice: RGB; stone: RGB } | null>(null);
const spreadRef = useRef(spread) as Box<number>;
const sandPRef = useRef(sandP) as Box<number>;
const waterPRef = useRef(waterP) as Box<number>;
const randRef = useRef(makePRNG(0x318a03)) as Box<() => number>;
const rafRef = useRef(0) as Box<number>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const [n, setN] = useState(String(spread));
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 idx(x: number, y: number): number { return y * sizeRef.current.cols + x; }
// 울퉁불퉁한 고정 돌 지형 2~3단 — 좌 언덕 · 중앙 능선 · 우 언덕 + 바닥 줄
function terrain(): void {
const { cols, rows } = sizeRef.current;
const grid = gridRef.current;
if (!grid) return;
let x: number, y: number;
const h1 = Math.round(rows * 0.22);
for (x = 0; x < Math.round(cols * 0.3); x++) {
const rise = Math.round((1 - x / (cols * 0.3)) * h1 * 0.6);
for (y = 0; y <= rise; y++) grid[idx(x, rows - 1 - y)] = STONE;
}
const h2 = Math.round(rows * 0.34);
const cx0 = Math.round(cols * 0.44), cx1 = Math.round(cols * 0.62);
for (x = cx0; x < cx1; x++) {
const k = 1 - Math.abs((x - (cx0 + cx1) / 2) / ((cx1 - cx0) / 2));
const rise2 = Math.round(k * h2);
for (y = 0; y <= rise2; y++) grid[idx(x, rows - 1 - y)] = STONE;
}
const h3 = Math.round(rows * 0.26);
for (x = Math.round(cols * 0.76); x < cols; x++) {
const k3 = (x - cols * 0.76) / (cols * 0.24);
const rise3 = Math.round(k3 * h3 * 0.7);
for (y = 0; y <= rise3; y++) grid[idx(x, rows - 1 - y)] = STONE;
}
for (x = 0; x < cols; x++) grid[idx(x, rows - 1)] = STONE;
}
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();
const cols = Math.max(1, Math.floor(r.width / CELL));
const rows = Math.max(1, Math.floor(r.height / CELL));
sizeRef.current = { w: r.width, h: r.height, cols, rows };
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
gridRef.current = new Uint8Array(cols * rows);
shadeRef.current = new Uint8Array(cols * rows);
sandXRef.current = Math.max(0, Math.round(cols * 0.16));
waterXRef.current = Math.max(0, Math.round(cols * 0.84 - ZONE_W));
terrain();
ctxRef.current = ctx;
}
function spawnStep(): void {
const { cols } = sizeRef.current;
const grid = gridRef.current;
const shade = shadeRef.current;
if (!grid || !shade) return;
const rand = randRef.current;
for (let k = 0; k < ZONE_W; k++) {
let x = sandXRef.current + k;
if (x >= 0 && x < cols && rand() < sandPRef.current) {
const i = idx(x, 0);
if (grid[i] === EMPTY) { grid[i] = SAND; shade[i] = rand() < 0.5 ? 0 : 1; }
}
x = waterXRef.current + k;
if (x >= 0 && x < cols && rand() < waterPRef.current) {
const i = idx(x, 0);
if (grid[i] === EMPTY) grid[i] = WATER;
}
}
}
function swap(a: number, b: number): void {
const grid = gridRef.current!;
const shade = shadeRef.current!;
const g = grid[a]; grid[a] = grid[b]; grid[b] = g;
const s = shade[a]; shade[a] = shade[b]; shade[b] = s;
}
function update(): void {
const { cols, rows } = sizeRef.current;
const grid = gridRef.current;
if (!grid) return;
const rand = randRef.current;
flowRef.current = -flowRef.current; // 물 좌우 방향 매 틱 교대
const flowDir = flowRef.current;
for (let y = rows - 2; y >= 0; y--) {
for (let x = 0; x < cols; x++) {
const i = idx(x, y);
const c = grid[i];
if (c !== SAND && c !== WATER) continue;
// 아래로 — 모래는 물 위로 가라앉음(밀도)
const bi = idx(x, y + 1);
if (grid[bi] === EMPTY || (c === SAND && grid[bi] === WATER)) {
swap(i, bi);
continue;
}
if (c === SAND) {
// 대각선 — 좌하 → 우하 순서(물 칸으로도 미끄러짐)
const dir = rand() < 0.5 ? -1 : 1;
let ok1 = false, ok2 = false;
let nx = x + dir;
if (nx >= 0 && nx < cols) {
const t1 = grid[idx(nx, y + 1)];
const t1s = grid[idx(nx, y)];
if ((t1 === EMPTY || t1 === WATER) && (t1s === EMPTY || t1s === WATER)) ok1 = true;
}
if (!ok1) {
nx = x - dir;
if (nx >= 0 && nx < cols) {
const t2 = grid[idx(nx, y + 1)];
const t2s = grid[idx(nx, y)];
if ((t2 === EMPTY || t2 === WATER) && (t2s === EMPTY || t2s === WATER)) ok2 = true;
}
}
if (ok1 || ok2) swap(i, idx(x + (ok1 ? dir : -dir), y + 1));
} else {
// 물 — 대각선
const wdir = flowDir;
let wok1 = false, wok2 = false;
let nx = x + wdir;
if (nx >= 0 && nx < cols && grid[idx(nx, y + 1)] === EMPTY && grid[idx(nx, y)] === EMPTY) wok1 = true;
if (!wok1) {
nx = x - wdir;
if (nx >= 0 && nx < cols && grid[idx(nx, y + 1)] === EMPTY && grid[idx(nx, y)] === EMPTY) wok2 = true;
}
if (wok1 || wok2) {
swap(i, idx(x + (wok1 ? wdir : -wdir), y + 1));
continue;
}
// 물 — 좌우 퍼짐(한 번에 SPREAD칸, 통로 검사)
let moved = false;
for (let s = 1; s <= spreadRef.current; s++) {
nx = x + flowDir * s;
if (nx < 0 || nx >= cols) break;
const ti = idx(nx, y);
if (grid[ti] === EMPTY) {
let clear = true;
for (let q = 1; q <= s; q++) {
if (grid[idx(x + flowDir * q, y)] !== EMPTY) { clear = false; break; }
}
if (clear) { swap(i, ti); moved = true; break; }
} else break;
}
if (!moved) {
for (let s2 = 1; s2 <= spreadRef.current; s2++) {
nx = x - flowDir * s2;
if (nx < 0 || nx >= cols) break;
const ti2 = idx(nx, y);
if (grid[ti2] === EMPTY) {
let clear2 = true;
for (let q2 = 1; q2 <= s2; q2++) {
if (grid[idx(x - flowDir * q2, y)] !== EMPTY) { clear2 = false; break; }
}
if (clear2) { swap(i, ti2); moved = true; break; }
} else break;
}
}
}
}
}
}
function draw(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
if (!ctx || !col) return;
const { w: W, h: H, cols, rows } = sizeRef.current;
const grid = gridRef.current;
const shade = shadeRef.current;
if (!grid || !shade) return;
const Wp = Math.round(W), Hp = Math.round(H);
if (!imgRef.current || imgRef.current.width !== Wp || imgRef.current.height !== Hp) {
imgRef.current = ctx.createImageData(Wp, Hp);
}
const img = imgRef.current!;
const buf = img.data;
// 느린 잔광 중심 — sin/cos 로 천천히 도는 radial 근사
const gx = 0.5 + 0.36 * Math.sin(t * 0.17);
const gy = 0.5 + 0.3 * Math.cos(t * 0.13);
const gr = Math.max(W, H) * 0.7;
const r2 = gr * gr;
let o: number;
for (let py = 0; py < Hp; py++) {
for (let px = 0; px < Wp; px++) {
o = (py * Wp + px) * 4;
buf[o] = col.night[0]; buf[o + 1] = col.night[1]; buf[o + 2] = col.night[2]; buf[o + 3] = 255;
const dxg = px - gx * Wp, dyg = py - gy * Hp;
const d2 = (dxg * dxg + dyg * dyg) / r2;
if (d2 < 1) {
const a = 0.07 * (1 - d2);
buf[o] += (col.glow[0] - col.night[0]) * a;
buf[o + 1] += (col.glow[1] - col.night[1]) * a;
buf[o + 2] += (col.glow[2] - col.night[2]) * a;
}
}
}
// 격자 렌더 — CELL 4px 블록
let c0: RGB;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const cell = grid[idx(x, y)];
if (cell === EMPTY) continue;
if (cell === SAND) c0 = shade[idx(x, y)] ? col.gold : col.grain;
else if (cell === WATER) c0 = col.water;
else c0 = col.stone;
const bx = x * CELL, by = y * CELL;
const bw = Math.min(CELL, Wp - bx), bh = Math.min(CELL, Hp - by);
for (let yy = 0; yy < bh; yy++) {
const rowo = ((by + yy) * Wp + bx) * 4;
for (let xx = 0; xx < bw; xx++) {
const oo = rowo + xx * 4;
buf[oo] = c0[0]; buf[oo + 1] = c0[1]; buf[oo + 2] = c0[2];
}
}
}
}
// 물 표면 하이라이트 — 물 셀 바로 위가 빈 칸이면 ice 톤 한 줄
for (let y = 1; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (grid[idx(x, y)] === WATER && grid[idx(x, y - 1)] === EMPTY) {
const hx = x * CELL, hy = y * CELL;
if (hx < Wp && hy < Hp) {
const ho = (hy * Wp + hx) * 4;
buf[ho] = col.ice[0]; buf[ho + 1] = col.ice[1]; buf[ho + 2] = col.ice[2];
}
}
}
}
ctx.putImageData(img, 0, 0);
}
function stillFrame(): void {
for (let k = 0; k < 40; k++) { spawnStep(); update(); }
draw(0.6);
}
let t0 = -1;
let prev = 0;
let acc = 0;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const dt = Math.min(0.05, (now - (prev || now)) / 1000);
prev = now;
acc += dt;
while (acc >= STEP) { spawnStep(); update(); acc -= STEP; }
draw((now - t0) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
spreadRef.current = spread;
sandPRef.current = sandP;
waterPRef.current = waterP;
colRef.current = {
night: tokRGB(root, "--sand-night"),
glow: tokRGB(root, "--sand-glow"),
grain: tokRGB(root, "--sand-grain"),
gold: tokRGB(root, "--sand-gold"),
water: tokRGB(root, "--sand-water"),
ice: tokRGB(root, "--sand-ice"),
stone: tokRGB(root, "--sand-stone"),
};
randRef.current = makePRNG(0x318a03);
flowRef.current = 1;
imgRef.current = null;
fit();
setN(String(spread));
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
}, [spread, sandP, waterP]);
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>칸 물 퍼짐 1칸</span>
<span className={styles.metaDim}>canvas · ImageData putImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}03 물 흐름 — 아래가 막히면 좌우로 퍼지는 액체 규칙 — 측정 누적면적 33.589% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 폴링 샌드(falling sand simulator) 9종 — 모래·물·불 셀 오토마타
이 데모가 속한 글
폴링 샌드(falling sand simulator) 9종 — 모래·물·불 셀 오토마타라이브러리·WebGL 없이 칸 하나의 규칙 네 개(아래·좌아래·우아래·옆)로 모래가 쌓이고 물이 흐르고 불이 번집니다 — 이 폴링 샌드(falling sand simulator) 9종은 그 계산을 캔버스와 바닐라 자 이 글의 데모 전부 보기


