04불붙은 점이 도화선 길이를 따라 걷고 불똥이 튀는 카운트다운
구불구불한 경로를 길이 기준으로 걸어 불붙은 지점을 구하고, 그 점에서 불똥 입자를 법선 방향으로 흩뿌린다. 이미 탄 구간은 재처럼 어둡게, 남은 줄만 밝게 칠한다.
쓰임게임 시작 전 긴장감 연출·퀴즈 쇼 제한 시간
PATH_LENEMBER_NBURN_MSSPARK_SPREAD

<main class="stage">
<div class="cd is-demo" id="cd" data-demo="1">
<div class="cd__view cd__view--scene">
<canvas class="cd__cv" data-r="cv" role="img" aria-label="도화선을 따라 불이 타들어 가는 장면"></canvas>
<p class="cd__read cd__read--corner" role="timer"><b data-r="num">1.5</b><span>초</span></p>
</div>
<div class="cd__bar">
<button class="cd__btn" type="button" data-r="toggle">멈춤</button>
<button class="cd__btn" type="button" data-r="reset">다시</button>
</div>
<p class="cd__sr" aria-live="polite" data-r="status"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 남은 시간 하나(경과는 rAF 타임스탬프 누적)에서 모양을 계산해 그린다. CSS 는 모양과 색만 맡는다.
$bg: $stage-ink;
$fg: $subject-cream;
$btn: rgba($subject-cream, .14);
@include stage($bg);
.cd {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view { position: relative; }
.cd__view--scene { width: 100%; height: var(--view-h); overflow: hidden; border-radius: $r-card; background: mix($stage-ink, $subject-lilac, 88%); }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__read { margin: 0; display: flex; align-items: baseline; gap: $sp-1; font-variant-numeric: tabular-nums; }
.cd__read b { font-size: 28px; line-height: 32px; font-weight: 800; }
.cd__read--center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); }
.cd__read--corner { position: absolute; left: $sp-2; top: $sp-2; }
.cd__bar { display: flex; gap: $sp-2; }
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $btn;
color: $fg;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible { outline: 2px solid $subject-lilac; outline-offset: 2px; }
.cd__sr { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.cd__strip { filter: none !important; }
.cd__leaf { display: none !important; }
}
.cd {
--flame: #{mix($stage-yellow, $subject-cream, 60%)};
--hot: #{$subject-pink};
--rope: #{$subject-cream};
--bomb: #{$subject-lilac};
}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 {
--view-h: min(calc(100vh - 2 * clamp(8px, 4vw, 24px) - 48px), 196px);
position: relative;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #fff7e6;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.cd__view {
position: relative;
}
.cd__view--scene {
width: 100%;
height: var(--view-h);
overflow: hidden;
border-radius: 16px;
background: rgb(16.6431372549%, 14.431372549%, 20.9725490196%);
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__read {
margin: 0;
display: flex;
align-items: baseline;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.cd__read b {
font-size: 28px;
line-height: 32px;
font-weight: 800;
}
.cd__read--center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.cd__read--corner {
position: absolute;
left: 8px;
top: 8px;
}
.cd__bar {
display: flex;
gap: 8px;
}
.cd__btn {
box-sizing: border-box;
height: 36px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cd__btn:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cd__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.cd__strip {
filter: none !important;
}
.cd__leaf {
display: none !important;
}
}
.cd {
--flame: rgb(100%, 88.1568627451%, 50.9019607843%);
--hot: #ff9ebb;
--rope: #fff7e6;
--bomb: #b9a0ff;
}(function () {
const root = document.getElementById("cd");
if (!root) return;
function cx(s) {
return s;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let running = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks = { stop: () => { } };
const offs = [];
const BURN_MS = 1500;
const HOLD_MS = 300;
const EMBER_N = 56;
const SPARK_SPREAD = 1.1;
let totalMs = BURN_MS;
const cv = q('[data-r="cv"]');
const ctx2d = cv.getContext("2d");
if (!ctx2d)
return () => { };
const g = ctx2d;
let cw = 1;
let ch = 1;
let dpr = 1;
// 캔버스 안쪽 해상도를 화면 크기와 devicePixelRatio 에 맞춘다. 리사이즈마다 다시 호출된다
function fitCanvas() {
const r = cv.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1);
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
cv.width = Math.round(cw * dpr);
cv.height = Math.round(ch * dpr);
g.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function cssVar(name) {
return getComputedStyle(root).getPropertyValue(name).trim();
}
function parseColor(h) {
// CSS 변수 값은 #rrggbb 로도, rgb(r, g, b) 로도, rgb(100%, 50%, 0%) 로도 나올 수 있다
const m6 = /^#([0-9a-f]{6})$/i.exec(h);
if (m6) {
const n = parseInt(m6[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const m = /([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)/.exec(h);
if (!m)
return [255, 255, 255];
const ch = (v, pct) => Math.round(pct ? (Number(v) * 255) / 100 : Number(v));
return [ch(m[1], m[2]), ch(m[3], m[4]), ch(m[5], m[6])];
}
function rgba(c, a) {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a.toFixed(3) + ")";
}
function q(sel) {
return root.querySelector(sel);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x, target, per, dt) {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q('[data-r="toggle"]');
const resetBtn = q('[data-r="reset"]');
const status = q('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs() {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons() {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg) {
if (!demo)
status.textContent = msg;
}
function stopDemo() {
if (!demo)
return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart() {
elapsed = 0;
resetState();
}
function update(dt) {
tick(dt);
draw();
}
function frame(ts) {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
}
else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250)
return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs)
restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
// 그림 좌표계는 220 × 100. 실제 화면 크기는 맞는 배율로 곱한다
const LW = 220;
const LH = 100;
// 도화선 경로: 곡선 위 점을 직접 샘플링하고 점마다 누적 길이와 법선을 둔다
function buildPath() {
const N = 180;
const pts = [];
let len = 0;
for (let i = 0; i <= N; i++) {
const t = i / N;
const x = 14 + t * 172;
const y = 50 + 30 * Math.sin(t * Math.PI * 3.4) * (1 - 0.2 * t);
if (i > 0)
len += Math.hypot(x - pts[i - 1].x, y - pts[i - 1].y);
pts.push({ x, y, nx: 0, ny: 0, s: len });
}
for (let i = 0; i <= N; i++) {
const a = pts[Math.max(0, i - 1)];
const b = pts[Math.min(N, i + 1)];
const tx = b.x - a.x;
const ty = b.y - a.y;
const m = Math.hypot(tx, ty) || 1;
pts[i].nx = -ty / m;
pts[i].ny = tx / m;
}
return pts;
}
const PATH = buildPath();
const PATH_LEN = PATH[PATH.length - 1].s;
// 길이 s 에 해당하는 점(경로 위 선형 보간)
function pointAt(s) {
const t = clamp(s, 0, PATH_LEN);
let lo = 0;
let hi = PATH.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (PATH[mid].s <= t)
lo = mid;
else
hi = mid;
}
const a = PATH[lo];
const b = PATH[hi];
const f = b.s > a.s ? (t - a.s) / (b.s - a.s) : 0;
return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, nx: a.nx + (b.nx - a.nx) * f, ny: a.ny + (b.ny - a.ny) * f, s: t };
}
// 불똥 입자 풀: 고정 길이 배열을 돌려 쓴다
const ex = new Float32Array(EMBER_N);
const ey = new Float32Array(EMBER_N);
const evx = new Float32Array(EMBER_N);
const evy = new Float32Array(EMBER_N);
const elife = new Float32Array(EMBER_N);
const EMBER_LIFE = 0.5;
let seed = 7;
let emitAcc = 0;
let slot = 0;
function rand() {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return seed / 4294967296;
}
let cFlame = [255, 233, 159];
let cHot = [255, 158, 187];
let cRope = [255, 247, 230];
let cBomb = [185, 160, 255];
function resize() {
fitCanvas();
cFlame = parseColor(cssVar("--flame"));
cHot = parseColor(cssVar("--hot"));
cRope = parseColor(cssVar("--rope"));
cBomb = parseColor(cssVar("--bomb"));
}
const num = q('[data-r="num"]');
function burnS() {
return PATH_LEN * (1 - remainMs() / totalMs);
}
function resetState() {
elife.fill(0);
emitAcc = 0;
seed = 7;
}
function emit(p) {
const side = rand() < 0.5 ? -1 : 1;
const th = (rand() - 0.5) * 2 * SPARK_SPREAD;
const c = Math.cos(th);
const sn = Math.sin(th);
const dx = p.nx * side;
const dy = p.ny * side;
const sp = 28 + 60 * rand();
ex[slot] = p.x;
ey[slot] = p.y;
evx[slot] = (dx * c - dy * sn) * sp - 14;
evy[slot] = (dx * sn + dy * c) * sp - 10;
elife[slot] = EMBER_LIFE * (0.6 + 0.4 * rand());
slot = (slot + 1) % EMBER_N;
}
function tick(dt) {
if (reduce)
return;
const p = pointAt(burnS());
if (running && remainMs() > 0) {
emitAcc += dt * (EMBER_N / EMBER_LIFE) * 0.8;
while (emitAcc >= 1) {
emit(p);
emitAcc -= 1;
}
}
for (let i = 0; i < EMBER_N; i++) {
if (elife[i] <= 0)
continue;
elife[i] -= dt;
evy[i] += 120 * dt;
evx[i] *= Math.pow(0.9, dt * 60);
ex[i] += evx[i] * dt;
ey[i] += evy[i] * dt;
}
}
function draw() {
const sc = Math.min(cw / LW, ch / LH);
const ox = (cw - LW * sc) / 2;
const oy = (ch - LH * sc) / 2;
g.clearRect(0, 0, cw, ch);
g.save();
g.translate(ox, oy);
g.scale(sc, sc);
const s = burnS();
const p = pointAt(s);
const burning = remainMs() > 0;
// 불빛이 번지는 배경
const gl = g.createRadialGradient(p.x, p.y, 0, p.x, p.y, 64);
gl.addColorStop(0, rgba(cHot, burning ? 0.42 : 0.12));
gl.addColorStop(1, rgba(cHot, 0));
g.fillStyle = gl;
g.fillRect(-ox / sc, -oy / sc, cw / sc, ch / sc);
g.lineCap = "round";
g.lineJoin = "round";
// 탄 부분(재)
g.strokeStyle = rgba(cRope, 0.2);
g.lineWidth = 2.4;
g.beginPath();
g.moveTo(PATH[0].x, PATH[0].y);
for (let i = 1; i < PATH.length && PATH[i].s <= s; i++)
g.lineTo(PATH[i].x, PATH[i].y);
g.lineTo(p.x, p.y);
g.stroke();
// 남은 도화선
g.strokeStyle = rgba(cRope, 0.95);
g.lineWidth = 3.4;
g.setLineDash([4, 2.4]);
g.beginPath();
g.moveTo(p.x, p.y);
for (let i = 0; i < PATH.length; i++)
if (PATH[i].s > s)
g.lineTo(PATH[i].x, PATH[i].y);
g.stroke();
g.setLineDash([]);
// 폭탄
const end = PATH[PATH.length - 1];
g.fillStyle = rgba(cBomb, 1);
g.beginPath();
g.arc(end.x + 14, end.y, 13, 0, Math.PI * 2);
g.fill();
g.fillStyle = rgba(cRope, 0.35);
g.beginPath();
g.arc(end.x + 10, end.y - 4, 4, 0, Math.PI * 2);
g.fill();
// 불똥
if (!reduce) {
for (let i = 0; i < EMBER_N; i++) {
if (elife[i] <= 0)
continue;
const a = clamp(elife[i] / EMBER_LIFE, 0, 1);
g.fillStyle = rgba(a > 0.5 ? cFlame : cHot, a);
g.beginPath();
g.arc(ex[i], ey[i], 0.8 + 1.6 * a, 0, Math.PI * 2);
g.fill();
}
}
// 불꽃 머리
const flick = reduce ? 1 : 1 + 0.18 * Math.sin(elapsed * 0.05) + 0.1 * Math.sin(elapsed * 0.13);
const fl = g.createRadialGradient(p.x, p.y, 0, p.x, p.y, 9 * flick);
fl.addColorStop(0, rgba([255, 255, 255], 1));
fl.addColorStop(0.4, rgba(cFlame, 0.95));
fl.addColorStop(1, rgba(cHot, 0));
g.fillStyle = fl;
g.beginPath();
g.arc(p.x, p.y, 9 * flick, 0, Math.PI * 2);
g.fill();
g.restore();
num.textContent = (remainMs() / 1000).toFixed(1);
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
})();// FuseBurnSparkTrail - vanilla 판(04-cd-fuse-burn-spark-trail) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./FuseBurnSparkTrail.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FuseBurnSparkTrail() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce: boolean = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo: boolean = !reduce;
let running: boolean = demo;
let elapsed = 0;
let lastTs = -1;
let lastDraw = -1e9;
let raf = 0;
const hooks: { stop: () => void } = { stop: () => {} };
const offs: Array<() => void> = [];
const BURN_MS = 1500;
const HOLD_MS = 300;
const EMBER_N = 56;
const SPARK_SPREAD = 1.1;
let totalMs = BURN_MS;
const cv = q<HTMLCanvasElement>('[data-r="cv"]');
const ctx2d = cv.getContext("2d");
if (!ctx2d) return () => {};
const g: CanvasRenderingContext2D = ctx2d;
let cw = 1;
let ch = 1;
let dpr = 1;
// 캔버스 안쪽 해상도를 화면 크기와 devicePixelRatio 에 맞춘다. 리사이즈마다 다시 호출된다
function fitCanvas(): void {
const r = cv.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1);
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
cv.width = Math.round(cw * dpr);
cv.height = Math.round(ch * dpr);
g.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function cssVar(name: string): string {
return getComputedStyle(root).getPropertyValue(name).trim();
}
function parseColor(h: string): number[] {
// CSS 변수 값은 #rrggbb 로도, rgb(r, g, b) 로도, rgb(100%, 50%, 0%) 로도 나올 수 있다
const m6 = /^#([0-9a-f]{6})$/i.exec(h);
if (m6) {
const n = parseInt(m6[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const m = /([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)[ ,]+([0-9.]+)(%?)/.exec(h);
if (!m) return [255, 255, 255];
const ch = (v: string, pct: string): number => Math.round(pct ? (Number(v) * 255) / 100 : Number(v));
return [ch(m[1], m[2]), ch(m[3], m[4]), ch(m[5], m[6])];
}
function rgba(c: number[], a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a.toFixed(3) + ")";
}
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function on(el: EventTarget, type: string, fn: (e: Event) => void): void {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
// 지수 접근: 프레임률이 달라도 같은 속도가 되도록 dt 를 60fps 프레임 수로 환산해 쓴다
function approach(x: number, target: number, per: number, dt: number): number {
return x + (target - x) * (1 - Math.pow(1 - per, dt * 60));
}
const toggle = q<HTMLButtonElement>('[data-r="toggle"]');
const resetBtn = q<HTMLButtonElement>('[data-r="reset"]');
const status = q<HTMLElement>('[data-r="status"]');
// 남은 시간. 경과는 rAF 타임스탬프 차이를 누적한 값이라 시계를 따로 읽지 않는다
function remainMs(): number {
return clamp(totalMs - elapsed, 0, totalMs);
}
function syncButtons(): void {
toggle.textContent = running ? "멈춤" : "시작";
toggle.setAttribute("aria-pressed", running ? "true" : "false");
}
// 스크린리더 안내는 멈출 때와 끝날 때만 갱신한다. 시연 루프 중에는 읽지 않는다
function say(msg: string): void {
if (!demo) status.textContent = msg;
}
function stopDemo(): void {
if (!demo) return;
demo = false;
root.classList.remove("is-demo");
root.removeAttribute("data-demo");
hooks.stop();
}
function restart(): void {
elapsed = 0;
resetState();
}
function update(dt: number): void {
tick(dt);
draw();
}
function frame(ts: number): void {
raf = requestAnimationFrame(frame);
const raw = lastTs < 0 ? 0 : ts - lastTs;
lastTs = ts;
if (running) {
elapsed += raw;
if (demo) {
const cycle = totalMs + HOLD_MS;
if (elapsed >= cycle) {
elapsed %= cycle;
resetState();
}
} else if (elapsed >= totalMs) {
elapsed = totalMs;
running = false;
say("시간이 다 됐습니다");
syncButtons();
}
}
if (reduce && ts - lastDraw < 250) return;
lastDraw = ts;
update(Math.min(raw, 50) / 1000);
}
on(toggle, "click", () => {
stopDemo();
if (!running && elapsed >= totalMs) restart();
running = !running;
say(running ? "" : "멈췄습니다");
syncButtons();
update(0);
});
on(resetBtn, "click", () => {
stopDemo();
running = false;
restart();
say("");
syncButtons();
update(0);
});
// 그림 좌표계는 220 × 100. 실제 화면 크기는 맞는 배율로 곱한다
const LW = 220;
const LH = 100;
type Pt = { x: number; y: number; nx: number; ny: number; s: number };
// 도화선 경로: 곡선 위 점을 직접 샘플링하고 점마다 누적 길이와 법선을 둔다
function buildPath(): Pt[] {
const N = 180;
const pts: Pt[] = [];
let len = 0;
for (let i = 0; i <= N; i++) {
const t = i / N;
const x = 14 + t * 172;
const y = 50 + 30 * Math.sin(t * Math.PI * 3.4) * (1 - 0.2 * t);
if (i > 0) len += Math.hypot(x - pts[i - 1].x, y - pts[i - 1].y);
pts.push({ x, y, nx: 0, ny: 0, s: len });
}
for (let i = 0; i <= N; i++) {
const a = pts[Math.max(0, i - 1)];
const b = pts[Math.min(N, i + 1)];
const tx = b.x - a.x;
const ty = b.y - a.y;
const m = Math.hypot(tx, ty) || 1;
pts[i].nx = -ty / m;
pts[i].ny = tx / m;
}
return pts;
}
const PATH = buildPath();
const PATH_LEN = PATH[PATH.length - 1].s;
// 길이 s 에 해당하는 점(경로 위 선형 보간)
function pointAt(s: number): Pt {
const t = clamp(s, 0, PATH_LEN);
let lo = 0;
let hi = PATH.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (PATH[mid].s <= t) lo = mid;
else hi = mid;
}
const a = PATH[lo];
const b = PATH[hi];
const f = b.s > a.s ? (t - a.s) / (b.s - a.s) : 0;
return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, nx: a.nx + (b.nx - a.nx) * f, ny: a.ny + (b.ny - a.ny) * f, s: t };
}
// 불똥 입자 풀: 고정 길이 배열을 돌려 쓴다
const ex = new Float32Array(EMBER_N);
const ey = new Float32Array(EMBER_N);
const evx = new Float32Array(EMBER_N);
const evy = new Float32Array(EMBER_N);
const elife = new Float32Array(EMBER_N);
const EMBER_LIFE = 0.5;
let seed = 7;
let emitAcc = 0;
let slot = 0;
function rand(): number {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return seed / 4294967296;
}
let cFlame = [255, 233, 159];
let cHot = [255, 158, 187];
let cRope = [255, 247, 230];
let cBomb = [185, 160, 255];
function resize(): void {
fitCanvas();
cFlame = parseColor(cssVar("--flame"));
cHot = parseColor(cssVar("--hot"));
cRope = parseColor(cssVar("--rope"));
cBomb = parseColor(cssVar("--bomb"));
}
const num = q<HTMLElement>('[data-r="num"]');
function burnS(): number {
return PATH_LEN * (1 - remainMs() / totalMs);
}
function resetState(): void {
elife.fill(0);
emitAcc = 0;
seed = 7;
}
function emit(p: Pt): void {
const side = rand() < 0.5 ? -1 : 1;
const th = (rand() - 0.5) * 2 * SPARK_SPREAD;
const c = Math.cos(th);
const sn = Math.sin(th);
const dx = p.nx * side;
const dy = p.ny * side;
const sp = 28 + 60 * rand();
ex[slot] = p.x;
ey[slot] = p.y;
evx[slot] = (dx * c - dy * sn) * sp - 14;
evy[slot] = (dx * sn + dy * c) * sp - 10;
elife[slot] = EMBER_LIFE * (0.6 + 0.4 * rand());
slot = (slot + 1) % EMBER_N;
}
function tick(dt: number): void {
if (reduce) return;
const p = pointAt(burnS());
if (running && remainMs() > 0) {
emitAcc += dt * (EMBER_N / EMBER_LIFE) * 0.8;
while (emitAcc >= 1) {
emit(p);
emitAcc -= 1;
}
}
for (let i = 0; i < EMBER_N; i++) {
if (elife[i] <= 0) continue;
elife[i] -= dt;
evy[i] += 120 * dt;
evx[i] *= Math.pow(0.9, dt * 60);
ex[i] += evx[i] * dt;
ey[i] += evy[i] * dt;
}
}
function draw(): void {
const sc = Math.min(cw / LW, ch / LH);
const ox = (cw - LW * sc) / 2;
const oy = (ch - LH * sc) / 2;
g.clearRect(0, 0, cw, ch);
g.save();
g.translate(ox, oy);
g.scale(sc, sc);
const s = burnS();
const p = pointAt(s);
const burning = remainMs() > 0;
// 불빛이 번지는 배경
const gl = g.createRadialGradient(p.x, p.y, 0, p.x, p.y, 64);
gl.addColorStop(0, rgba(cHot, burning ? 0.42 : 0.12));
gl.addColorStop(1, rgba(cHot, 0));
g.fillStyle = gl;
g.fillRect(-ox / sc, -oy / sc, cw / sc, ch / sc);
g.lineCap = "round";
g.lineJoin = "round";
// 탄 부분(재)
g.strokeStyle = rgba(cRope, 0.2);
g.lineWidth = 2.4;
g.beginPath();
g.moveTo(PATH[0].x, PATH[0].y);
for (let i = 1; i < PATH.length && PATH[i].s <= s; i++) g.lineTo(PATH[i].x, PATH[i].y);
g.lineTo(p.x, p.y);
g.stroke();
// 남은 도화선
g.strokeStyle = rgba(cRope, 0.95);
g.lineWidth = 3.4;
g.setLineDash([4, 2.4]);
g.beginPath();
g.moveTo(p.x, p.y);
for (let i = 0; i < PATH.length; i++) if (PATH[i].s > s) g.lineTo(PATH[i].x, PATH[i].y);
g.stroke();
g.setLineDash([]);
// 폭탄
const end = PATH[PATH.length - 1];
g.fillStyle = rgba(cBomb, 1);
g.beginPath();
g.arc(end.x + 14, end.y, 13, 0, Math.PI * 2);
g.fill();
g.fillStyle = rgba(cRope, 0.35);
g.beginPath();
g.arc(end.x + 10, end.y - 4, 4, 0, Math.PI * 2);
g.fill();
// 불똥
if (!reduce) {
for (let i = 0; i < EMBER_N; i++) {
if (elife[i] <= 0) continue;
const a = clamp(elife[i] / EMBER_LIFE, 0, 1);
g.fillStyle = rgba(a > 0.5 ? cFlame : cHot, a);
g.beginPath();
g.arc(ex[i], ey[i], 0.8 + 1.6 * a, 0, Math.PI * 2);
g.fill();
}
}
// 불꽃 머리
const flick = reduce ? 1 : 1 + 0.18 * Math.sin(elapsed * 0.05) + 0.1 * Math.sin(elapsed * 0.13);
const fl = g.createRadialGradient(p.x, p.y, 0, p.x, p.y, 9 * flick);
fl.addColorStop(0, rgba([255, 255, 255], 1));
fl.addColorStop(0.4, rgba(cFlame, 0.95));
fl.addColorStop(1, rgba(cHot, 0));
g.fillStyle = fl;
g.beginPath();
g.arc(p.x, p.y, 9 * flick, 0, Math.PI * 2);
g.fill();
g.restore();
num.textContent = (remainMs() / 1000).toFixed(1);
}
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(root);
offs.push(() => ro.disconnect());
resize();
resetState();
syncButtons();
update(0);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
}, []);
return (
<div className={cx("cd") + " is-demo"} id="cd" data-demo="1" ref={rootRef}>
<div className={cx("cd__view cd__view--scene")}>
<canvas className={cx("cd__cv")} data-r="cv" role="img" aria-label="도화선을 따라 불이 타들어 가는 장면"></canvas>
<p className={cx("cd__read cd__read--corner")} role="timer"><b data-r="num">1.5</b><span>초</span></p>
</div>
<div className={cx("cd__bar")}>
<button className={cx("cd__btn")} type="button" data-r="toggle">멈춤</button>
<button className={cx("cd__btn")} type="button" data-r="reset">다시</button>
</div>
<p className={cx("cd__sr")} aria-live="polite" data-r="status"></p>
</div>
);
}04 불붙은 점이 도화선 길이를 따라 걷고 불똥이 튀는 카운트다운 — 측정 누적면적 57.0% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · css countdown timer 9가지 — 링·모래·도화선으로 줄어드는 남은 시간
이 데모가 속한 글
css countdown timer 9가지 — 링·모래·도화선으로 줄어드는 남은 시간css countdown timer 의 남은 시간은 숫자로 읽기 전에 모양이 줄어드는 속도로 먼저 느껴진다 — 01번 링과 03번 모래가 그 속도를 계산으로 정한다. 이 글의 데모 전부 보기


