08砂時計タイマー — 壁が落下を誘導する時間表示
首の細い二つの円錐の壁を固定マスで描き、上に砂を満たすと、落下規則だけで上側が空き下側が満ちる本物の砂時計のリズムが得られます。
使いどころカウントダウンタイマー、イベントページの待機演出
fillRectrequestAnimationFrame

<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">3</b>マス 首の幅・砂時計の落下</span>
<span class="cd__meta cd__meta--dim">canvas · fillRect · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 모래시계 타이머(08). @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;
}
}// 모래시계 타이머(08). 재료 코드: 0 빈칸 1 모래 3 벽(고정).
// 위 원뿔 + 아래 역원뿔 모양으로 벽을 1 칸 두께 계단선으로 그려 목 폭 NECK 칸을 만들고,
// 위칸을 모래로 가득 채운다. 규칙은 기본 낙하(아래 비었으면 스왑) + 대각 미끄짐(SLIDE_P) 뿐 —
// 벽이 유도하는 것만으로 위가 비고 아래가 차는 모래시계 리듬이 나온다.
(function () {
// ── 변형 상수 블록 ──
var NECK = 3; // 목 폭(칸) — 늘리면 낙하가 빨라져 시계가 급하게 다 찬다
var SLIDE_P = 1.0; // 사면 대각 미끄짐 확률 — 낮추면 모래가 봉긋하게 쌓인다
// ────────────────────────────
var EMPTY = 0, SAND = 1, WALL = 3;
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(0x318A08);
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];
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var CELL = 4;
var cols = 0, rows = 0, grid = null;
var COL = {};
var leftWall = null, rightWall = null; // 행별 벽 칼럼(좌/우)
function readColors() {
var css = getComputedStyle(root);
COL.night = hexToRgb(css.getPropertyValue('--sand-night'));
COL.mist = hexToRgb(css.getPropertyValue('--sand-mist'));
COL.ice = hexToRgb(css.getPropertyValue('--sand-ice'));
COL.sandA = hexToRgb(css.getPropertyValue('--sand-grain'));
COL.sandB = hexToRgb(css.getPropertyValue('--sand-gold'));
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 WALL;
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;
}
function buildHourglass() {
var cx = cols / 2;
var neckHalf = NECK / 2;
var edgeMargin = Math.max(1, Math.round(cols * 0.04)); // 화면 폭 대부분을 쓰도록 가장자리 여백 최소화
var wideHalf = cols / 2 - edgeMargin;
var neckBand = Math.max(2, Math.round(rows * 0.05));
var mid = rows / 2;
leftWall = new Int16Array(rows);
rightWall = new Int16Array(rows);
for (var y = 0; y < rows; y++) {
var half;
if (Math.abs(y - mid) <= neckBand) {
half = neckHalf;
} else if (y < mid) {
var f = 1 - (mid - neckBand - y) / Math.max(1, mid - neckBand);
half = neckHalf + (wideHalf - neckHalf) * Math.max(0, Math.min(1, 1 - f));
} else {
var f2 = (y - mid - neckBand) / Math.max(1, rows - 1 - mid - neckBand);
half = neckHalf + (wideHalf - neckHalf) * Math.max(0, Math.min(1, f2));
}
leftWall[y] = Math.round(cx - half);
rightWall[y] = Math.round(cx + half);
}
for (var yy = 0; yy < rows; yy++) {
set(leftWall[yy], yy, WALL);
set(rightWall[yy], yy, WALL);
}
// 위칸 가득 채우기 — mid - neckBand 위쪽 내부 전부 모래
var fillBottom = Math.floor(mid - neckBand);
for (var y2 = 0; y2 < fillBottom; y2++) {
for (var x2 = leftWall[y2] + 1; x2 < rightWall[y2]; x2++) {
set(x2, y2, SAND);
}
}
}
function resetGrid() {
grid = new Uint8Array(cols * rows);
buildHourglass();
}
function moveInto(x, y, nx, ny) {
if (get(nx, ny) !== EMPTY) return false;
set(x, y, EMPTY);
set(nx, ny, SAND);
return true;
}
function updateSand(x, y, dir) {
if (get(x, y) !== SAND) return false;
if (moveInto(x, y, x, y + 1)) return true;
if (rand() > SLIDE_P) return false;
var first = dir > 0 ? -1 : 1;
if (moveInto(x, y, x + first, y + 1)) return true;
return moveInto(x, y, x - first, y + 1);
}
var tick = 0;
var cycleTick = 0; // 뒤집기 후 경과 틱
var CYCLE_TICKS = 150; // 한 뒤집기 주기(틱) — 위칸이 다시 채워지는 시간 포함
var lastMove = 0; // 마지막 모래 이동 틱(정지 감지)
function update() {
tick++;
cycleTick++;
var dir = (tick % 2 === 0) ? 1 : -1;
var moved = false;
for (var y = rows - 2; y >= 0; y--) {
if (dir > 0) {
for (var x = 0; x < cols; x++) { if (updateSand(x, y, dir)) moved = true; }
} else {
for (var x2 = cols - 1; x2 >= 0; x2--) { if (updateSand(x2, y, dir)) moved = true; }
}
}
if (moved) lastMove = tick;
// 뒤집기 — 낙하가 멎은 뒤 잠깐 멈춤 보여주고 위칸을 다시 채운다(무한 타이머)
if (!moved && tick - lastMove > 12 && cycleTick > 40) {
grid = new Uint8Array(cols * rows);
buildHourglass();
cycleTick = 0;
lastMove = tick;
}
}
function draw(t) {
var W = cv.width, H = cv.height;
var wave = Math.sin(t * 0.00055) * 0.5 + 0.5;
var cx = W * (0.5 + 0.3 * Math.sin(t * 0.00042));
var cy = H * (0.5 + 0.3 * Math.cos(t * 0.00036));
var maxD = Math.hypot(W, H) * 0.6;
var grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, maxD);
grad.addColorStop(0, rgba(COL.glow, 0.10 + 0.05 * wave));
grad.addColorStop(1, rgba(COL.night, 0));
ctx.fillStyle = rgba(COL.night, 1);
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
for (var y = 0; y < rows; y++) {
for (var x = 0; x < cols; x++) {
var m = grid[idx(x, y)];
if (m === EMPTY) continue;
var px = x * CELL, py = y * CELL;
if (m === WALL) {
ctx.fillStyle = rgba(COL.mist, 0.42);
} else {
ctx.fillStyle = rgba(((x + y) % 3 === 0) ? COL.sandA : COL.sandB, 1);
}
ctx.fillRect(px, py, CELL, CELL);
}
}
}
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));
resetGrid();
}
function stillFrame() {
for (var i = 0; i < 220; i++) update();
draw(3600);
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); }
})();// HourglassTimer — vanilla 판(run/318/demo/08-sand-hourglass-timer/index.html) 의 React 포트.
// 위 원뿔 + 아래 역원뿔 벽(목 폭 neck 칸)을 계단선으로 그려 두고 위칸을 모래로 가득 채우면,
// 기본 낙하 + 대각 미끄짐 규칙만으로 위가 비고 아래가 차는 모래시계 리듬이 나온다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HourglassTimer.module.scss";
export type HourglassTimerProps = {
duration?: string;
easing?: string;
color?: string;
neck?: number; // 목 폭(칸)
slideP?: number; // 사면 대각 미끄짐 확률(0~1)
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; mist: RGB; ice: RGB; sandA: RGB; sandB: RGB; glow: RGB };
const FIELD_LABEL = "모래시계 셀 오토마타 데모";
const EMPTY = 0, SAND = 1, WALL = 3;
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 HourglassTimer({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
neck = 3,
slideP = 1.0,
cellPx = 4,
}: HourglassTimerProps) {
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 dimRef = useRef({ cols: 0, rows: 0 }) as Box<{ cols: number; rows: number }>;
const cellRef = useRef(cellPx) as Box<number>;
const neckRef = useRef(neck) as Box<number>;
const slideRef = useRef(slideP) as Box<number>;
const randRef = useRef(makePRNG(0x318a08)) as Box<() => number>;
const tickRef = useRef(0) as Box<number>;
const cycleRef = useRef(0) as Box<number>;
const lastMoveRef = useRef(0) as Box<number>;
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 rgba(c: RGB, a: number): string { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
function get(x: number, y: number): number {
const { cols, rows } = dimRef.current;
if (x < 0 || x >= cols || y < 0 || y >= rows) return WALL;
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;
gridRef.current[y * cols + x] = v;
}
function buildHourglass(): void {
const { cols, rows } = dimRef.current;
const cx = cols / 2;
const neckHalf = neckRef.current / 2;
const edgeMargin = Math.max(1, Math.round(cols * 0.04));
const wideHalf = cols / 2 - edgeMargin;
const neckBand = Math.max(2, Math.round(rows * 0.05));
const mid = rows / 2;
const leftWall = new Int16Array(rows);
const rightWall = new Int16Array(rows);
for (let y = 0; y < rows; y++) {
let half: number;
if (Math.abs(y - mid) <= neckBand) {
half = neckHalf;
} else if (y < mid) {
const f = 1 - (mid - neckBand - y) / Math.max(1, mid - neckBand);
half = neckHalf + (wideHalf - neckHalf) * Math.max(0, Math.min(1, 1 - f));
} else {
const f2 = (y - mid - neckBand) / Math.max(1, rows - 1 - mid - neckBand);
half = neckHalf + (wideHalf - neckHalf) * Math.max(0, Math.min(1, f2));
}
leftWall[y] = Math.round(cx - half);
rightWall[y] = Math.round(cx + half);
}
for (let y = 0; y < rows; y++) {
set(leftWall[y], y, WALL);
set(rightWall[y], y, WALL);
}
const fillBottom = Math.floor(mid - neckBand);
for (let y = 0; y < fillBottom; y++) {
for (let x = leftWall[y] + 1; x < rightWall[y]; x++) set(x, y, SAND);
}
}
function moveInto(x: number, y: number, nx: number, ny: number): boolean {
if (get(nx, ny) !== EMPTY) return false;
set(x, y, EMPTY);
set(nx, ny, SAND);
return true;
}
function updateSand(x: number, y: number, dir: number): boolean {
const rand = randRef.current;
if (get(x, y) !== SAND) return false;
if (moveInto(x, y, x, y + 1)) return true;
if (rand() > slideRef.current) return false;
const first = dir > 0 ? -1 : 1;
if (moveInto(x, y, x + first, y + 1)) return true;
return moveInto(x, y, x - first, y + 1);
}
function update(): void {
const { cols, rows } = dimRef.current;
tickRef.current++;
cycleRef.current++;
const dir = tickRef.current % 2 === 0 ? 1 : -1;
let moved = false;
for (let y = rows - 2; y >= 0; y--) {
if (dir > 0) {
for (let x = 0; x < cols; x++) { if (updateSand(x, y, dir)) moved = true; }
} else {
for (let x = cols - 1; x >= 0; x--) { if (updateSand(x, y, dir)) moved = true; }
}
}
if (moved) lastMoveRef.current = tickRef.current;
// 뒤집기 — 낙하가 멎은 뒤 잠깐 멈춤 보여주고 위칸을 다시 채운다(무한 타이머)
if (!moved && tickRef.current - lastMoveRef.current > 12 && cycleRef.current > 40) {
gridRef.current = new Uint8Array(cols * rows);
buildHourglass();
cycleRef.current = 0;
lastMoveRef.current = tickRef.current;
}
}
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;
const wave = Math.sin(t * 0.00055) * 0.5 + 0.5;
const cx = W * (0.5 + 0.3 * Math.sin(t * 0.00042));
const cy = H * (0.5 + 0.3 * Math.cos(t * 0.00036));
const maxD = Math.hypot(W, H) * 0.6;
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, maxD);
grad.addColorStop(0, rgba(col.glow, 0.10 + 0.05 * wave));
grad.addColorStop(1, rgba(col.night, 0));
ctx.fillStyle = rgba(col.night, 1);
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
const grid = gridRef.current;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const m = grid[y * cols + x];
if (m === EMPTY) continue;
const px = x * CELL, py = y * CELL;
if (m === WALL) ctx.fillStyle = rgba(col.mist, 0.42);
else ctx.fillStyle = rgba((x + y) % 3 === 0 ? col.sandA : col.sandB, 1);
ctx.fillRect(px, py, CELL, CELL);
}
}
}
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);
buildHourglass();
}
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;
neckRef.current = neck;
slideRef.current = slideP;
randRef.current = makePRNG(0x318a08);
tickRef.current = 0;
cycleRef.current = 0;
lastMoveRef.current = 0;
colRef.current = {
night: tokRGB(root, "--sand-night"),
mist: tokRGB(root, "--sand-mist"),
ice: tokRGB(root, "--sand-ice"),
sandA: tokRGB(root, "--sand-grain"),
sandB: tokRGB(root, "--sand-gold"),
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 < 220; i++) update();
draw(3600);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [neck, slideP, 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}>{neck}</b>칸 목 폭 · 모래시계 낙하</span>
<span className={styles.metaDim}>canvas · fillRect · requestAnimationFrame</span>
</div>
</div>
</main>
);
}08 砂時計タイマー — 壁が落下を誘導する時間表示 — 計測 累積面積 50.213% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · フォーリングサンド(falling sand simulator) 9種の作り方
このデモが属する記事
フォーリングサンド(falling sand simulator) 9種の作り方フォーリングサンド(falling sand simulator)とは、画面上部から落ちる粒を演出ではなく計算で積もらせる仕組みです。ライブラリ·WebGL なしでセル1つの規則4つ(下・左下・右下・横)で砂が積み、水が流れ、火が広がります この記事のデモをすべて見る


