06花火の爆発 — 重力と残り火の算数
一点から飛び出した破片が重力で放物線を描き、色温度の階段を下って消える — この規則だけで夜空の爆発が完成します。
使いどころお祝い・年末LPのハイライト
fillRectglobalCompositeOperationrequestAnimationFrame

<main class="stage">
<div class="fw is-demo" id="fwRoot">
<p class="fw__hint">
<svg class="fw__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 24a16 16 0 0 1 16 16v8a80.06 80.06 0 0 1 63.94 63.94h8a16 16 0 0 1 0 32h-8A80.06 80.06 0 0 1 144 207.94v8a16 16 0 0 1-32 0v-8A80.06 80.06 0 0 1 48.06 144h-8a16 16 0 0 1 0-32h8A80.06 80.06 0 0 1 112 48.06v-8A16 16 0 0 1 128 24m-16 57.35V96a16 16 0 0 0 32 0V81.35A48.09 48.09 0 0 0 81.35 112H96a16 16 0 0 0 0-32h14.65M174.65 144H160a16 16 0 0 0 0 32h14.65A48.09 48.09 0 0 0 144 174.65V160a16 16 0 0 0-32 0v14.65A48.09 48.09 0 0 0 81.35 144" opacity=".2"/><path d="M232 128a104 104 0 1 1-104-104a104 104 0 0 1 104 104m-32 0a8 8 0 0 0-8-8h-9.34A56.1 56.1 0 0 0 136 77.34V68a8 8 0 0 0-16 0v9.34A56.1 56.1 0 0 0 77.34 120H68a8 8 0 0 0 0 16h9.34A56.1 56.1 0 0 0 120 179.66V188a8 8 0 0 0 16 0v-8.34A56.1 56.1 0 0 0 178.66 136H192a8 8 0 0 0 8-8m-40 0a32 32 0 1 1-32-32a32 32 0 0 1 32 32m-16 0a16 16 0 1 0-16 16a16 16 0 0 0 16-16"/></g></svg>
<span>破片が重力で放物線を描き、色温度の階段を下って消えます — クリックで自分で打ち上げます</span>
</p>
<div class="fw__field" id="fwField" role="application" aria-label="花火の爆発実験盤" tabindex="0">
<canvas class="fw__cv" id="fwCv"></canvas>
</div>
<div class="fw__bar">
<span class="fw__meta"><b class="fw__val" id="fwN">0</b> 生きている破片・g 34px/s²</span>
<span class="fw__meta fw__meta--dim">lighter合成・色温度ラダー</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 불꽃놀이 폭발(06). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 중력·항력·색온도 냉각의 파티클 계산이 만든다.
// 무대는 잉크(#241b1d — 밤하늘). 파스텔 고정 팔레트를 --fire-* 로 편 편 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(BD9~11 원칙) ──
$fire-ink: #241b1d;
$fire-amber: #f0a868;
$fire-gold: #f6cf8a;
$fire-rose: #eca0a8;
$fire-ember: #d97a52;
$fire-smoke: #c9c2c6;
.fw {
--fire-ink: #{$fire-ink};
--fire-amber: #{$fire-amber};
--fire-gold: #{$fire-gold};
--fire-rose: #{$fire-rose};
--fire-ember: #{$fire-ember};
--fire-smoke: #{$fire-smoke};
// 항목 반경 토큰(01~09 공통)
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.fw__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: $fire-smoke;
}
.fw__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $fire-smoke; }
.fw__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $fire-ink;
box-shadow: inset 0 0 0 1px rgba($fire-smoke, .18);
cursor: crosshair; touch-action: none;
}
.fw__field:focus-visible { outline: 2px solid $fire-amber; outline-offset: 2px; }
.fw__cv { display: block; width: 100%; height: 100%; }
.fw__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.fw__meta { font-size: 10px; font-weight: 500; color: rgba($fire-smoke, .72); }
.fw__val { color: $fire-gold; font-weight: 700; }
.fw__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.fw { gap: $sp-1; }
.fw__field { height: 100px; }
.fw__meta--dim { display: none; }
}
// reduce — rAF 없이 폭발이 걸린 대표 프레임 한 장만 남긴다.
@media (prefers-reduced-motion: reduce) {
.fw.is-still .fw__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);
}
.fw {
--fire-ink: #241b1d;
--fire-amber: #f0a868;
--fire-gold: #f6cf8a;
--fire-rose: #eca0a8;
--fire-ember: #d97a52;
--fire-smoke: #c9c2c6;
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.fw__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: #c9c2c6;
}
.fw__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #c9c2c6;
}
.fw__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #241b1d;
box-shadow: inset 0 0 0 1px rgba(201, 194, 198, 0.18);
cursor: crosshair;
touch-action: none;
}
.fw__field:focus-visible {
outline: 2px solid #f0a868;
outline-offset: 2px;
}
.fw__cv {
display: block;
width: 100%;
height: 100%;
}
.fw__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.fw__meta {
font-size: 10px;
font-weight: 500;
color: rgba(201, 194, 198, 0.72);
}
.fw__val {
color: #f6cf8a;
font-weight: 700;
}
.fw__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.fw {
gap: 4px;
}
.fw__field {
height: 100px;
}
.fw__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.fw.is-still .fw__field {
cursor: default !important;
}
}// 불꽃놀이 폭발(06). 폭발 하나 = 한 점에서 방사형으로 튀어나간 파편 다발.
// 각 파편은 오직 4개의 규칙을 따른다:
// ① vy += g * dt — 중력(포물선의 전부)
// ② v *= drag — 공기 저항(끝에서 뭉개지는 이유)
// ③ k -= burn * dt — 연료 소모 → 색온도 사다리(흰→gold→amber→빨강→잔불)를 내려간다
// ④ k <= 0 이면 죽는다 — 잔불이 어둠 속으로 녹아 없어진다
// 발사 스케줄은 시드 고정 PRNG 로 정해 미리보기가 항상 같은 순서로 터진다.
// 포인터로 궤적을 기울이고(風), 클릭/스페이스로 그 자리에서 직접 터뜨린다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var PER_BURST = 170; // 폭발당 파편 수 — 변형 "파편 수" 축(45 → 180). 재튜닝(2026-09-15): 90→170
var GRAV = 34; // 중력 가속도(px/s²) — 변형 "중력" 축(18 → 60)
var PERIOD = 0.85; // 자동 발사 주기(s) — 변형 "주기" 축(0.9 → 2.6). 재튜닝: 1.5→0.85 — 발사 빈도↑
// ────────────────────────────────────────────────────
var root = document.getElementById('fwRoot');
var field = document.getElementById('fwField');
var cv = document.getElementById('fwCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('fwN');
// 시드 고정 PRNG — mulberry32
var SEED = 0x5eed06;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// 색은 CSS 커스텀 프로퍼티(--fire-*)에서 getComputedStyle 로 읽는다
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_INK = tokRGB('--fire-ink');
var C_AMBER = tokRGB('--fire-amber');
var C_GOLD = tokRGB('--fire-gold');
var C_ROSE = tokRGB('--fire-rose');
var C_EMBER = tokRGB('--fire-ember');
var C_SMOKE = tokRGB('--fire-smoke');
// 색온도 사다리 최상단 — 흰 코어(터진 직후 0.1초의 섬광)
var C_WHITE = [255, 250, 244];
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var px01 = 0.5, py01 = 0.5;
var manual = false;
var BURN = 0.34; // 연료 소모율 (1/s) — 재튜닝(2026-09-15): 0.62→0.34, 잔불 수명↑(궤적이 프레임 사이에 끊기지 않게)
var DRAG = 0.985; // 매 프레임 속도 유지율(공기 저항)
var nextLaunch = 0.62; // 다음 자동 발사 시각(s). 재튜닝: 0.8→0.62 — 첫 프레임부터 궤적이 살아 있게(오프닝 폭발과 이어짐)
// 파편 풀 — 객체 생성을 프레임마다 하지 않기 위해 미리 만들어 재활용한다
var MAXP = PER_BURST * 10; // 재튜닝: 동시 다발 폭발 대비 풀 여유(6→10)
var pool = [];
for (var i = 0; i < MAXP; i++) {
pool.push({ live: false, x: 0, y: 0, vx: 0, vy: 0, k: 0, tone: 0 });
}
var alive = 0;
// 폭발 — (x, y) 에서 파편 PER_BURST 개를 방사형으로 뿌린다.
// 속도는 균등 각도 + 시드 지터, 크기는 안팎 두 겹(껍질이 먼저 뭉개지는 이중 꽃).
// tone: 0 = gold 계열, 1 = rose 계열(변형 "색" 축) — 편당 두 계열이 섞여 나온다
function burst(x, y, tone) {
var n = PER_BURST;
var placed = 0;
for (var i = 0; i < MAXP && placed < n; i++) {
var p = pool[i];
if (p.live) continue;
var ang = (placed / n) * TAU + (rand() - 0.5) * 0.22;
var shell = placed % 5 === 0 ? 0.62 : 1.0; // 안쪽 겹(느린 파편)
var sp = (46 + rand() * 58) * shell;
p.live = true;
p.x = x; p.y = y;
p.vx = Math.cos(ang) * sp;
p.vy = Math.sin(ang) * sp - 12; // 살짝 위로 — 포물선 정점이 보이게
p.k = 0.85 + rand() * 0.3;
p.tone = tone;
placed++;
}
}
// ── 색온도 사다리 — k(연료)를 색에 대응시킨다 ──
// k > 0.92 : 흰 섬광(터진 직후)
// 0.92..0.64 : gold → amber (활활)
// 0.64..0.34 : amber → ember (식어가는 주황)
// 0.34..0.12 : ember → ink+연기 (잔불 — 어둠에 가까운 불씨)
// 0.12..0 : 잔불 깜빡임(시위상 깜박거림) 뒤 소멸
function fragColor(k, tone, tSec, ph) {
var r, g, b;
if (k > 0.92) {
var m0 = (k - 0.92) / 0.2;
r = C_GOLD[0] + (C_WHITE[0] - C_GOLD[0]) * m0;
g = C_GOLD[1] + (C_WHITE[1] - C_GOLD[1]) * m0;
b = C_GOLD[2] + (C_WHITE[2] - C_GOLD[2]) * m0;
} else if (k > 0.64) {
var m1 = (k - 0.64) / 0.28;
var lo = tone ? C_ROSE : C_GOLD, hi = tone ? C_GOLD : C_AMBER;
r = lo[0] + (hi[0] - lo[0]) * m1;
g = lo[1] + (hi[1] - lo[1]) * m1;
b = lo[2] + (hi[2] - lo[2]) * m1;
} else if (k > 0.34) {
var m2 = (k - 0.34) / 0.30;
var lo2 = tone ? C_ROSE : C_AMBER;
r = lo2[0] + (C_EMBER[0] - lo2[0]) * m2;
g = lo2[1] + (C_EMBER[1] - lo2[1]) * m2;
b = lo2[2] + (C_EMBER[2] - lo2[2]) * m2;
} else {
// 잔불 — 어둠으로 가라앉으며 시위상으로 깜빡인다
var m3 = k / 0.34;
var tw = 0.55 + 0.45 * Math.sin(tSec * 9 + ph);
r = C_INK[0] + (C_EMBER[0] - C_INK[0]) * m3 * tw;
g = C_INK[1] + (C_EMBER[1] - C_INK[1]) * m3 * tw;
b = C_INK[2] + (C_EMBER[2] - C_INK[2]) * m3 * tw;
}
return [r, g, b];
}
function draw(tSec, dt) {
// 1) 밤하늘 — 잉크에 가까운 배경. 위쪽으로 살짝 밝아지는 대기광
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
var sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, 'rgba(' + C_SMOKE[0] + ',' + C_SMOKE[1] + ',' + C_SMOKE[2] + ',0.05)');
sky.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
var wind = (px01 - 0.5) * 14; // 포인터 x → 風(궤적 기울기)
// 2) 파편 — lighter 로 겹쳐 화면이 차오르게 한다
ctx.globalCompositeOperation = 'lighter';
alive = 0;
for (var i = 0; i < MAXP; i++) {
var p = pool[i];
if (!p.live) continue;
// 물리: 중력 → 항력 → 이동
p.vy += GRAV * dt;
p.vx = p.vx * DRAG + wind * dt;
p.vy *= DRAG;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.k -= BURN * dt;
if (p.k <= 0 || p.y > H + 6 || p.x < -6 || p.x > W + 6) { p.live = false; continue; }
alive++;
var col = fragColor(p.k, p.tone, tSec, i * 1.7);
// 잔불 단계에선 반경이 줄고, 활활 단계에선 연료가 크기다
var rad = 0.9 + p.k * 1.5;
var a = p.k > 0.34 ? 0.85 : 0.35 + p.k * 1.5; // 잔불은 어둡게
ctx.fillStyle = 'rgba(' + (col[0] | 0) + ',' + (col[1] | 0) + ',' + (col[2] | 0) + ',' + Math.min(1, a).toFixed(3) + ')';
ctx.beginPath();
ctx.arc(p.x, p.y, rad, 0, TAU);
ctx.fill();
// 꼬리 — 속도 방향으로 짧은 선(속도 벡터 자체가 꼬리의 방향이다)
if (p.k > 0.5) {
ctx.strokeStyle = 'rgba(' + (col[0] | 0) + ',' + (col[1] | 0) + ',' + (col[2] | 0) + ',0.25)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x - p.vx * 0.08, p.y - p.vy * 0.08);
ctx.stroke();
}
}
nOut.textContent = String(alive);
// 3) 지평선 — 받아주는 어두운 땅(면 계산)
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',0.9)';
ctx.fillRect(0, H * 0.94, W, H * 0.06 + 1);
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
// 시드 기반 주기 발사 — 위치도 시드로 뽑아 미리보기가 항상 같은 안무가 되게 한다
// 재튜닝(2026-09-15): 한 번에 2~3발 동시 발사 — 밤하늘에 여러 궤적이 함께 살아 있게
if (t >= nextLaunch) {
var nBursts = rand() < 0.4 ? 2 : 3;
for (var b = 0; b < nBursts; b++) {
var lx = W * (0.12 + rand() * 0.76);
var ly = H * (0.12 + rand() * 0.42);
burst(lx, ly, rand() < 0.4 ? 1 : 0);
}
nextLaunch = t + PERIOD * (0.7 + rand() * 0.6);
}
if (!manual) {
// 가상 風 — Lissajous(시드 고정)
var ph = t * TAU / 3.6;
px01 = 0.5 + 0.4 * Math.sin(ph + 0.3);
py01 = 0.5 + 0.3 * Math.sin(2 * ph + 1.1);
}
draw(t, dt);
raf = requestAnimationFrame(frame);
}
field.addEventListener('pointerdown', function (e) {
var r = field.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
burst(x, y, (x / Math.max(1, r.width)) < 0.5 ? 0 : 1);
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) draw(2.4, 0.016);
});
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
px01 = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
py01 = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
});
field.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
burst(W * (0.3 + rand() * 0.4), H * (0.2 + rand() * 0.3), rand() < 0.5 ? 1 : 0);
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) draw(2.4, 0.016);
return;
}
var d = { ArrowLeft: [-0.08, 0], ArrowRight: [0.08, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!d) return;
e.preventDefault();
px01 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(2.4, 0.016); });
fit();
if (reduce) {
// reduce — rAF 없이 폭발 세 개가 하늘에 걸린 대표 프레임 한 장만 그린다(재튜닝: 2→3발)
var lx1 = W * 0.28, ly1 = H * 0.30, lx2 = W * 0.70, ly2 = H * 0.20, lx3 = W * 0.5, ly3 = H * 0.42;
// 시간을 진행시켜 포물선이 벌어진 순간을 만든 다음 멈춘다
burst(lx1, ly1, 0);
burst(lx2, ly2, 1);
burst(lx3, ly3, 0);
for (var s = 0; s < 46; s++) draw(2.4 + s * 0.016, 0.016);
draw(2.4, 0);
root.classList.add('is-still');
} else {
// 오프닝 폭발 — 첫 프레임부터 하늘에 궤적이 살아 있게(재튜닝: 정적 오프닝 제거)
burst(W * 0.30, H * 0.30, 0);
burst(W * 0.68, H * 0.22, 1);
raf = requestAnimationFrame(frame);
}
})();// FireFireworkBurst — vanilla 판(run/314/demo/06-fire-firework-burst/index.html) 의 React 포트.
// 한 점에서 방사형으로 튀어나간 파편이 중력(vy += g·dt)·항력·연료 소모(색온도 사다리)로
// 포물선을 그리며 식어 사라진다. 발사 스케줄은 시드 고정 PRNG 로 같은 안무가 반복된다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FireFireworkBurst.module.scss";
export type FireFireworkBurstProps = {
duration?: string;
easing?: string;
color?: string;
perBurst?: number; // 폭발당 파편 수 — 변형 "파편 수" 축
gravity?: number; // 중력(px/s²) — 변형 "중력" 축
period?: number; // 자동 발사 주기(s) — 변형 "주기" 축
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type PointerEvt = { clientX: number; clientY: number };
type KeyEvt = { key: string; preventDefault(): void };
type RGB = [number, number, number];
type Frag = { live: boolean; x: number; y: number; vx: number; vy: number; k: number; tone: number };
type Colors = { ink: RGB; amber: RGB; gold: RGB; rose: RGB; ember: RGB; smoke: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "불꽃놀이 폭발 실험판";
const BURN = 0.34; // 연료 소모율 (1/s) — 재튜닝(2026-09-15): 0.62→0.34, 잔불 수명↑
const DRAG = 0.985; // 매 프레임 속도 유지율
const STILL_T = 2.4; // reduce 에서 그리는 시각
// 시드 고정 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;
};
}
// 색온도 사다리 — 흰 섬광 → gold → amber → ember → 잔불 깜빡임
function fragColor(k: number, tone: number, tSec: number, ph: number, col: Colors): RGB {
const white: RGB = [255, 250, 244];
if (k > 0.92) {
const m0 = (k - 0.92) / 0.2;
return [
col.gold[0] + (white[0] - col.gold[0]) * m0,
col.gold[1] + (white[1] - col.gold[1]) * m0,
col.gold[2] + (white[2] - col.gold[2]) * m0,
];
}
if (k > 0.64) {
const m1 = (k - 0.64) / 0.28;
const lo = tone ? col.rose : col.gold;
const hi = tone ? col.gold : col.amber;
return [
lo[0] + (hi[0] - lo[0]) * m1,
lo[1] + (hi[1] - lo[1]) * m1,
lo[2] + (hi[2] - lo[2]) * m1,
];
}
if (k > 0.34) {
const m2 = (k - 0.34) / 0.3;
const lo2 = tone ? col.rose : col.amber;
return [
lo2[0] + (col.ember[0] - lo2[0]) * m2,
lo2[1] + (col.ember[1] - lo2[1]) * m2,
lo2[2] + (col.ember[2] - lo2[2]) * m2,
];
}
const m3 = k / 0.34;
const tw = 0.55 + 0.45 * Math.sin(tSec * 9 + ph);
return [
col.ink[0] + (col.ember[0] - col.ink[0]) * m3 * tw,
col.ink[1] + (col.ember[1] - col.ink[1]) * m3 * tw,
col.ink[2] + (col.ember[2] - col.ink[2]) * m3 * tw,
];
}
export default function FireFireworkBurst({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f6cf8a",
perBurst = 170,
gravity = 34,
period = 0.85,
}: FireFireworkBurstProps) {
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 pbRef = useRef(perBurst) as Box<number>;
const gRef = useRef(gravity) as Box<number>;
const perRef = useRef(period) as Box<number>;
const poolRef = useRef<Frag[]>([]) as Box<Frag[]>;
const aliveRef = useRef(0) as Box<number>;
const nextRef = useRef(0.62) as Box<number>;
const schedRef = useRef(makePRNG(0x5eed06)) as Box<() => number>;
const pxRef = useRef(0.5) as Box<number>;
const manualRef = useRef(false) as Box<boolean>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const prevRef = useRef(0) as Box<number>;
const [alive, setAlive] = useState(0);
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)];
}
// 파편 풀 — prop 변경 시 리빌드. 재튜닝(2026-09-15): 동시 다발 폭발 대비 풀 여유(6→10)
function buildPool(): void {
const MAXP = pbRef.current * 10;
const pool: Frag[] = [];
for (let i = 0; i < MAXP; i++) {
pool.push({ live: false, x: 0, y: 0, vx: 0, vy: 0, k: 0, tone: 0 });
}
poolRef.current = pool;
}
// 폭발 — 균등 각도 + 시드 지터 + 안팎 두 겹
function burst(x: number, y: number, tone: number): void {
const pool = poolRef.current;
const rand = schedRef.current;
const n = pbRef.current;
let placed = 0;
for (let i = 0; i < pool.length && placed < n; i++) {
const p = pool[i];
if (p.live) continue;
const ang = (placed / n) * TAU + (rand() - 0.5) * 0.22;
const shell = placed % 5 === 0 ? 0.62 : 1.0;
const sp = (46 + rand() * 58) * shell;
p.live = true;
p.x = x; p.y = y;
p.vx = Math.cos(ang) * sp;
p.vy = Math.sin(ang) * sp - 12;
p.k = 0.85 + rand() * 0.3;
p.tone = tone;
placed++;
}
}
function draw(tSec: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
if (!ctx || !col) return;
const { w: W, h: H } = sizeRef.current;
const pool = poolRef.current;
// 1) 밤하늘 — 잉크 + 위쪽 대기광
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgb(${col.ink[0]},${col.ink[1]},${col.ink[2]})`;
ctx.fillRect(0, 0, W, H);
const sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, `rgba(${col.smoke[0]},${col.smoke[1]},${col.smoke[2]},0.05)`);
sky.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
const wind = (pxRef.current - 0.5) * 14;
// 2) 파편 — lighter 합성
ctx.globalCompositeOperation = "lighter";
let live = 0;
for (let i = 0; i < pool.length; i++) {
const p = pool[i];
if (!p.live) continue;
p.vy += gRef.current * dt;
p.vx = p.vx * DRAG + wind * dt;
p.vy *= DRAG;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.k -= BURN * dt;
if (p.k <= 0 || p.y > H + 6 || p.x < -6 || p.x > W + 6) { p.live = false; continue; }
live++;
const c = fragColor(p.k, p.tone, tSec, i * 1.7, col);
const rad = 0.9 + p.k * 1.5;
const a = p.k > 0.34 ? 0.85 : 0.35 + p.k * 1.5;
ctx.fillStyle = `rgba(${c[0] | 0},${c[1] | 0},${c[2] | 0},${Math.min(1, a).toFixed(3)})`;
ctx.beginPath();
ctx.arc(p.x, p.y, rad, 0, TAU);
ctx.fill();
if (p.k > 0.5) {
ctx.strokeStyle = `rgba(${c[0] | 0},${c[1] | 0},${c[2] | 0},0.25)`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x - p.vx * 0.08, p.y - p.vy * 0.08);
ctx.stroke();
}
}
aliveRef.current = live;
// 3) 지평선
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgba(${col.ink[0]},${col.ink[1]},${col.ink[2]},0.9)`;
ctx.fillRect(0, H * 0.94, W, H * 0.06 + 1);
}
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;
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
const dt = Math.min(0.05, (now - (prevRef.current || now)) / 1000);
prevRef.current = now;
const rand = schedRef.current;
const { w: W, h: H } = sizeRef.current;
// 재튜닝(2026-09-15): 한 번에 2~3발 동시 발사 — 여러 궤적이 함께 살아 있게
if (t >= nextRef.current) {
const nBursts = rand() < 0.4 ? 2 : 3;
for (let b = 0; b < nBursts; b++) {
const lx = W * (0.12 + rand() * 0.76);
const ly = H * (0.12 + rand() * 0.42);
burst(lx, ly, rand() < 0.4 ? 1 : 0);
}
nextRef.current = t + perRef.current * (0.7 + rand() * 0.6);
}
if (!manualRef.current) {
const ph = (t * TAU) / 3.6;
pxRef.current = 0.5 + 0.4 * Math.sin(ph + 0.3);
}
draw(t, dt);
setAlive(aliveRef.current);
rafRef.current = requestAnimationFrame(frame);
}
function onDown(e: PointerEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const x = e.clientX - r.left;
const y = e.clientY - r.top;
burst(x, y, x / Math.max(1, r.width) < 0.5 ? 0 : 1);
if (!manualRef.current) manualRef.current = true;
if (reduced()) { draw(STILL_T, 0.016); setAlive(aliveRef.current); }
}
function onMove(e: PointerEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
pxRef.current = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
}
function onKeyDown(e: KeyEvt): void {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
const rand = schedRef.current;
const { w: W, h: H } = sizeRef.current;
burst(W * (0.3 + rand() * 0.4), H * (0.2 + rand() * 0.3), rand() < 0.5 ? 1 : 0);
if (!manualRef.current) manualRef.current = true;
if (reduced()) { draw(STILL_T, 0.016); setAlive(aliveRef.current); }
return;
}
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-0.08, 0], ArrowRight: [0.08, 0],
};
const d = deltas[e.key];
if (!d) return;
e.preventDefault();
pxRef.current = Math.max(0, Math.min(1, pxRef.current + d[0]));
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
pbRef.current = perBurst;
gRef.current = gravity;
perRef.current = period;
schedRef.current = makePRNG(0x5eed06);
colRef.current = {
ink: tokRGB(root, "--fire-ink"),
amber: tokRGB(root, "--fire-amber"),
gold: tokRGB(root, "--fire-gold"),
rose: tokRGB(root, "--fire-rose"),
ember: tokRGB(root, "--fire-ember"),
smoke: tokRGB(root, "--fire-smoke"),
};
buildPool();
fit();
function onResize(): void {
fit();
if (reduced()) draw(STILL_T, 0.016);
}
window.addEventListener("resize", onResize);
if (reduced()) {
const { w: W, h: H } = sizeRef.current;
burst(W * 0.28, H * 0.3, 0);
burst(W * 0.7, H * 0.2, 1);
burst(W * 0.5, H * 0.42, 0);
for (let s = 0; s < 46; s++) draw(STILL_T + s * 0.016, 0.016);
draw(STILL_T, 0);
setAlive(aliveRef.current);
setStill(true);
} else {
// 오프닝 폭발 — 첫 프레임부터 하늘에 궤적이 살아 있게(재튜닝: 정적 오프닝 제거)
const { w: W, h: H } = sizeRef.current;
burst(W * 0.3, H * 0.3, 0);
burst(W * 0.68, H * 0.22, 1);
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [perBurst, gravity, period]);
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}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 24a16 16 0 0 1 16 16v8a80.06 80.06 0 0 1 63.94 63.94h8a16 16 0 0 1 0 32h-8A80.06 80.06 0 0 1 144 207.94v8a16 16 0 0 1-32 0v-8A80.06 80.06 0 0 1 48.06 144h-8a16 16 0 0 1 0-32h8A80.06 80.06 0 0 1 112 48.06v-8A16 16 0 0 1 128 24m-16 57.35V96a16 16 0 0 0 32 0V81.35A48.09 48.09 0 0 0 81.35 112H96a16 16 0 0 0 0-32h14.65M174.65 144H160a16 16 0 0 0 0 32h14.65A48.09 48.09 0 0 0 144 174.65V160a16 16 0 0 0-32 0v14.65A48.09 48.09 0 0 0 81.35 144" opacity=".2"/><path d="M232 128a104 104 0 1 1-104-104a104 104 0 0 1 104 104m-32 0a8 8 0 0 0-8-8h-9.34A56.1 56.1 0 0 0 136 77.34V68a8 8 0 0 0-16 0v9.34A56.1 56.1 0 0 0 77.34 120H68a8 8 0 0 0 0 16h9.34A56.1 56.1 0 0 0 120 179.66V188a8 8 0 0 0 16 0v-8.34A56.1 56.1 0 0 0 178.66 136H192a8 8 0 0 0 8-8m-40 0a32 32 0 1 1-32-32a32 32 0 0 1 32 32m-16 0a16 16 0 1 0-16 16a16 16 0 0 0 16-16"/></g></svg>
<span>파편이 중력으로 포물선을 그리며 색온도대로 식어 사라집니다 — 클릭으로 직접 터뜨립니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerDown={onDown}
onPointerMove={onMove}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{alive}</b> 살아 있는 파편 · g {gravity}px/s²</span>
<span className={styles.metaDim}>lighter 합성 · 색온도 사다리</span>
</div>
</div>
</main>
);
}06 花火の爆発 — 重力と残り火の算数 — 計測 累積面積 39.459% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · キャンバス 炎 アニメーション9選 — シード固定パーティクルで描く火のシーン
このデモが属する記事
キャンバス 炎 アニメーション9選 — シード固定パーティクルで描く火のシーン炎 アニメーションとは、燃焼画像を順番に再生する画面ではなく、燃料の値が毎フレーム減り、浮力で上昇し、色温度の階段を下っていく規則を計算して描く画面です。ライブラリ·WebGLなし、キャンバスのピクセル計算だけで、ろうそくから火種の拡散まで この記事のデモをすべて見る


