キャンバス 炎 アニメーション9選 — シード固定パーティクルで描く火のシーン
炎 アニメーションとは、燃焼画像を順番に再生する画面ではなく、燃料の値が毎フレーム減り、浮力で上昇し、色温度の階段を下っていく規則を計算して描く画面です。ライブラリ·WebGLなし、キャンバスのピクセル計算だけで、ろうそくから火種の拡散までの9つの場面を実測しました。
01 fire-candle-wick
02 fire-bonfire-curl
03 fire-torch-wind-vector
04 fire-lamp-halo
05 fire-lava-heat-cells
06 fire-firework-burst
07 fire-wind-pointer-lean
08 fire-meteor-trail
09 fire-spread-fuel-grid
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ろうそく一本 — りんの上に立つ最小の火
- 02 焚き火 — カールノイズが揺らす火の柱
- 03 松明 — 風ベクトルを背負った炎
- 04 ランプの炎 — ガラスの後ろに滲む後光
- 05 溶岩の流れ — 格子セルに積もる熱
- 06 花火の爆発 — 重力と残り火の算数
- 07 ろうそくと風 — ポインタへ寝る炎
- 08 流星 — 残像を残す火の軌跡
- 09 火種の拡散 — 燃料セルが燃えて広がる格子
燃料セルひとつの規則が九つの炎に枝分かれする理由
並び順の基準は派手さではなく、炎がいくつの力を背負っているかです。01のろうそくは燃料パーティクルが生まれて浮力で上り、色温度で冷えていく最小単位です。02はその単位を450個集め、ポテンシャル場の回転(カール)から作った風でひと塊にうねらせます。03と07は同じ浮力の上に外のベクトルを乗せますが、03が向き変わらない風に緩やかな揺れを足すのに対し、07はpointermoveで測った実座標から風を作ります。04はパーティクルの代わりに半径ごとに減衰する光の層で後光を描き、06と08は全パーティクルの予算を一瞬の爆発と一つの進行頭に注ぎ込みます。05と09はパーティクルを捨てて、熱と燃料を格子セルに保存し隣とやり取りする拡散の計算に移ります — 9つ全部を貫く軸は絵ではなく、減っていく燃料というひとつの数です。
燃料の数がすべての名前になっている
パーティクルのkも格子セルのheatも、中身は同じ「1に近い値から0へ向かって減る数」です。明るさ・半径・色温度の階段は全部この数から読み取ります。css 炎 エフェクトをキーフレームで作ると形そのものを手で描くことになりますが、計算で作る炎はこの引き算ひとつから形が勝手に生えます。
炎はなぜ計算で描くのか — 燃料セル·浮力·カール乱流の計算
燃料は0へ向かって数えるただの数
パーティクルのkもセルのheatも、名前が違うだけで同じ「1に近い値から0へ向かう数」です。炎 アニメーション 描き方をスプライトシートで探すと毎フレーム絵を用意することになりますが、燃料が数になった瞬間に色の階段·縮む半径·消える時刻の全部がフレームごとの一回の引き算から落ちてきます。フレームを増やしてもファイルは重くならず、ポインターの入力にその場で反応できます。
浮力は速度ではなく加速である
本物の炎と煙は一定の速さで漂うのではなく、加速します — 熱い気体が周囲の空気より軽く、押し上げられ続けるからです。01と04は生まれた瞬間のvyをパーティクルごとに違う乱数で一度だけ決め、02はさらに毎フレームMath.min(1, dt * 3.2)でカール場の目標速度へ引き寄せた上にBUOY * 190の加速を足します。 rocket のような硬い上昇にならない鍵は、どちらもパーティクルごとに違う時刻·違う強さで揺れることにあります。
カール乱流は集まらずに回り続ける風
02の風の場はスカラー場ψの回転v = (∂ψ/∂y, −∂ψ/∂x)から作り、この作り方には発散がどこでもゼロになる性質があります。単純な乱数の風でパーティクルを運ぶとたまに全部が一点に寄ってしまいますが、発散ゼロの場は質量を回すことしかできないので、3本の火柱は互いに巻き込みながらもひとつの塊に潰れません。
格子の拡散はパーティクルなしで燃焼を運ぶ
05と09はParticleをひとつも作りません。熱と燃料を格子と同じ大きさの2つの平らな配列に保存し、各セルが毎ステップ隣の平均を取るだけです。パーティクル方式が個々の火の粒の動きを見せるのに対し、格子方式は燃焼が地図を食う面積の変化を見せます。同じ炎のアニメーションでも、見せるものが違うのです。
項目コード
各項目とも、動作を決める定数の名前を明示し、動いているデモのソースからそのまま写した関数を示します。打ち直した要約ではなく実コードなので、このページを炎 アニメーション 作り方の引きとして、そのまま自分の画面に持ち込めます。
01ろうそく一本 — りんの上に立つ最小の火
りんの先で生まれたパーティクルが浮力で上り、寿命とともに黄→橙→赤へ冷えていく — その規則だけでろうそくになります。瞑想・夜のムードページの背景のろうそくに、これ以上のものはいりません。実測では画面の38.292%がフレーム間で変化し、23/23フレームが動いていました。

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__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 1 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>りんで生まれた燃料パーティクルが浮力で上り、黄→橙→赤へ色温度が下がります — ポインターxは風、yは火力</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">240</b>燃料パーティクル・色温度冷却</span>
<span class="cd__meta cd__meta--dim">fillRect · globalCompositeOperation · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 촛불 하나(01). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(연료 입자 부력 + 색온도 냉각 + 후광 lighter 합성)가 만든다.
// 무대는 잉크(#241b1d — 어두운 실내). 파스텔 고정 팔레트를 --fire-* 로 편 공통 규격(01~09 동일).
$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; // 연기
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --fire-* 를 받아 쓴다
--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 공통 — 실사용은 $r-* SCSS 토큰, 표기 참조용)
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
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: $fire-smoke; // 잉크 무대 위 — 연기 톤 글자
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $fire-smoke; }
.cd__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;
}
.cd__field:focus-visible { outline: 2px solid $fire-amber; 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($fire-smoke, .72); }
.cd__val { color: $fire-gold; font-weight: 700; } // 잉크 위 금빛 라벨
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.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 {
--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;
}
.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: #c9c2c6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #c9c2c6;
}
.cd__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;
}
.cd__field:focus-visible {
outline: 2px solid #f0a868;
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(201, 194, 198, 0.72);
}
.cd__val {
color: #f6cf8a;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 촛불 하나(01). 불꽃은 연료 입자 다발 — 심지 끝에서 태어나 부력으로 오르며
// 연료 k(1→0) 에 따라 노랑(gold)→주황(amber)→빨강(ember) 으로 색온도가 내려가고
// 반경도 줄어든다(냉각). 살아 있는 연료 총합 flick 로 후광 3겹의 반경·알파를 정해
// 촛불이 숨쉬는 밝기를 계산으로 만든다. 입자는 fillRect 사각형 픽셀로 찍고
// 합성은 globalCompositeOperation 'lighter' 가 담당한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N_PART = 240; // 연료 입자 수 — 변형 "입자 수" 축(240 → 480)
var LIFT = 1.0; // 부력 배율 — 불꽃 높이 축(0.7 → 1.4)
var TONE = 0; // 코어 색 변형 — 0: amber(#f0a868), 1: rose(#eca0a8)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 촛불로 살아 움직인다
var SEED = 0x5eed01;
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 로 읽는다 — 파스텔 토큰이 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_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');
var C_CORE = TONE ? C_ROSE : C_AMBER; // 색 변형 — 코어 톤 교체
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; // 가상 風/화력 포인터(0..1)
var manual = false;
// ── 연료 입자 — 심지 끝에서 태어나 부력으로 오르며 식는다 ──
var parts = [];
function spawn(p) {
p.x = (rand() - 0.5) * 3; // 심지 폭 안 (px)
p.y = 0; // 심지 끝에서 출발
p.vx = (rand() - 0.5) * 7;
p.vy = -20 - rand() * 28; // 부력 — 위로 (px/s)
p.k = 1; // 연료(열). 매초 K_BURN 닳는다
p.ph = rand() * TAU; // 개별 흔들림 위상
}
for (var i = 0; i < N_PART; i++) {
var p = {}; spawn(p); p.y = -rand() * 42; p.k = rand(); // 첫 프레임부터 불꽃이 차 있는 상태로
parts.push(p);
}
// ── 연기 입자 — 불꽃 끝에서 피어오르는 옅은 연기(소량) ──
var SMK = 14;
var smokes = [];
function spawnSmk(s) {
s.x = (rand() - 0.5) * 6;
s.y = 0;
s.vx = (rand() - 0.5) * 6;
s.vy = -13 - rand() * 13;
s.k = 1;
s.ph = rand() * TAU;
}
for (var s0 = 0; s0 < SMK; s0++) {
var sm = {}; spawnSmk(sm); sm.y = -rand() * 72; sm.k = rand();
smokes.push(sm);
}
var K_BURN = 2.3; // 연료 소모율 (1/s)
var STILL_T = 1.6; // reduce 에서 그리는 시각 — 후광이 가장 읽히는 한 장
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 cx = W * 0.5;
var ground = H * 0.88; // 촛대 바닥
var candleTop = ground - 54; // 초 몸통 윗면
var wickTop = candleTop - 9; // 심지 끝 = 불꽃 태생점
var wind = (px01 - 0.5) * 2; // -1..1 — 포인터 x
var power = 0.6 + py01 * 0.8; // 포인터 y — 화력(부력·후광 세기)
// ── 2) 앙비언트 글로우 — 불은 주변 실내를 밝힌다 ──
// 반경별 감쇠 빛 겹 LAYERS 장을 lighter 로 쌓는다. 반경은 R0→RMAX(대각선 끝까지),
// 앞은 촘촘히(밝은 코어)·뒤는 성기게(넓은 은은함). 알파 바닥 0.05 — 가장 바깥 겹도 산다.
// A0 는 살아있는 연료 총합 flick 과 느린 sin 펄스로 매 프레임 재계산 —
// 글로우가 숨쉬는 밝기가 계산에서 나온다(고정값 아님).
var flick = 0;
for (var f = 0; f < N_PART; f++) flick += parts[f].k;
flick = (flick / N_PART) * power; // 0..1 — 현재 불꽃 세기
// 반경 자체가 느리게 부풀었다 줄어든다(breathe) — 겹마다 지름이 진동해 그라디언트 정지점이
// 프레임마다 재배치되므로, 화면 넓은 영역이(중심에서 멀어도) 실제로 값이 바뀐다.
var breathe = (0.62 + 0.38 * flick) * (0.82 + 0.18 * Math.sin(tSec * 1.9));
var hx = cx + wind * 26; // 글로우 중심이 바람에 실려 흔들린다(재튜닝: 게인 18→26)
var hy = wickTop - 17 - (power - 1) * 10 + Math.sin(tSec * 1.7) * 5;
var R0 = Math.min(W, H) * 0.13 * breathe;
var A0 = 0.60 * (0.66 + 0.34 * flick) * (0.72 + 0.28 * Math.sin(tSec * 2.3 + 0.9));
var RMAX = Math.hypot(W, H) * 0.99; // 마지막 겹이 대각선 끝까지 — 방 전체를 데운다
var LAYERS = 13;
var FALLOFF = 1.05; // 완만한 감쇠 — 바닥알파에 갇히는 영역을 줄여 넓게 살아 있게 한다
ctx.globalCompositeOperation = 'lighter';
for (var L = 0; L < LAYERS; L++) {
var Lt = L / (LAYERS - 1); // 0..1
var R = R0 + (RMAX - R0) * Math.pow(Lt, 0.72); // 앞은 촘촘, 뒤는 성기게
var a = Math.max(0.06, A0 * Math.pow(R0 / R, FALLOFF));
var m1 = Lt; // 안쪽 gold → 바깥 코어 톤으로 식는다
var lr = C_GOLD[0] + (C_CORE[0] - C_GOLD[0]) * m1;
var lg = C_GOLD[1] + (C_CORE[1] - C_GOLD[1]) * m1;
var lb = C_GOLD[2] + (C_CORE[2] - C_GOLD[2]) * m1;
var rg = ctx.createRadialGradient(hx, hy, R * 0.04, hx, hy, R);
rg.addColorStop(0, 'rgba(' + (lr | 0) + ',' + (lg | 0) + ',' + (lb | 0) + ',' + a.toFixed(3) + ')');
rg.addColorStop(0.72, 'rgba(' + (lr | 0) + ',' + (lg | 0) + ',' + (lb | 0) + ',' + (a * 0.6).toFixed(3) + ')');
rg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = rg;
ctx.fillRect(hx - R, hy - R, R * 2, R * 2);
}
// ── 잔열 바닥 띠 — 캔버스 전체 폭이 순수 검정으로 죽지 않게, 실내 바닥 전체가
// 은은한 잔열로 미세하게 맥동한다(중심이 좌우로 느리게 흘러 폭 전체가 실제로 변한다).
var hb = ground + 6;
var hbW = W * 0.75;
var hbX = cx + Math.sin(tSec * 0.6) * W * 0.3;
var hbA = 0.12 * (0.6 + 0.4 * flick) * (0.7 + 0.3 * Math.sin(tSec * 1.3 + 2.0));
var hbg = ctx.createRadialGradient(hbX, hb, 2, hbX, hb, hbW);
hbg.addColorStop(0, 'rgba(' + C_EMBER[0] + ',' + C_EMBER[1] + ',' + C_EMBER[2] + ',' + hbA.toFixed(3) + ')');
hbg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = hbg;
ctx.fillRect(0, hb - hbW, W, hbW * 2);
// 반대쪽 모서리도 살짝 — 두 번째 잔열 점을 반대 위상으로 흘려보내 폭 전체의 죽은 구석을 없앤다
var hb2X = cx - Math.sin(tSec * 0.6) * W * 0.3;
var hbg2 = ctx.createRadialGradient(hb2X, hb, 2, hb2X, hb, hbW * 0.7);
hbg2.addColorStop(0, 'rgba(' + C_ROSE[0] + ',' + C_ROSE[1] + ',' + C_ROSE[2] + ',' + (hbA * 0.7).toFixed(3) + ')');
hbg2.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = hbg2;
ctx.fillRect(0, hb - hbW * 0.7, W, hbW * 1.4);
// ── 3) 연료 입자 — 색온도 사다리(gold→core→ember), fillRect 픽셀로 찍는다 ──
// 風: 위치가 wind * (1-k) * 26 만큼 옮겨간다 — 꼭대기(다 식은) 입자가 더 많이 눕는다
for (var j = 0; j < N_PART; j++) {
var q = parts[j];
q.k -= K_BURN * dt;
if (q.k <= 0) { spawn(q); continue; }
var sway = Math.sin(tSec * 7.3 + q.ph) * 8 * q.k;
var fx = cx + q.x + q.vx * q.k + sway + wind * (1 - q.k) * 26;
var fy = wickTop + q.y * (0.6 + 0.4 * power);
q.x += q.vx * dt; q.y += q.vy * LIFT * dt;
// 색온도 사다리 — k 가 1(갓 태어남·gold) → 0(다 탐·ember) 로 내려간다
var rC, gC, bC, rad;
if (q.k > 0.66) {
rC = C_GOLD[0]; gC = C_GOLD[1]; bC = C_GOLD[2];
rad = 2.4 + 1.6 * (q.k - 0.66) / 0.34;
} else if (q.k > 0.33) {
var m2 = (q.k - 0.33) / 0.33;
rC = C_GOLD[0] + (C_CORE[0] - C_GOLD[0]) * m2;
gC = C_GOLD[1] + (C_CORE[1] - C_GOLD[1]) * m2;
bC = C_GOLD[2] + (C_CORE[2] - C_GOLD[2]) * m2;
rad = 1.8 + 0.6 * m2;
} else {
var m3 = q.k / 0.33;
rC = C_CORE[0] + (C_EMBER[0] - C_CORE[0]) * (1 - m3);
gC = C_CORE[1] + (C_EMBER[1] - C_CORE[1]) * (1 - m3);
bC = C_CORE[2] + (C_EMBER[2] - C_CORE[2]) * (1 - m3);
rad = 1.0 + 0.8 * m3;
}
ctx.fillStyle = 'rgba(' + (rC | 0) + ',' + (gC | 0) + ',' + (bC | 0) + ',' + (0.16 + 0.5 * q.k).toFixed(3) + ')';
ctx.fillRect(fx - rad, fy - rad, rad * 2, rad * 2);
}
// ── 4) 연기 — 다 식은 입자 위의 옅은 연기 톤. 바람을 넓게 받아 흩어진다 ──
for (var m = 0; m < SMK; m++) {
var s2 = smokes[m];
s2.k -= 0.55 * dt;
if (s2.k <= 0) { spawnSmk(s2); continue; }
var sx = cx + s2.x + s2.vx * (1 - s2.k) + Math.sin(tSec * 1.6 + s2.ph) * 6 * (1 - s2.k) + wind * (1 - s2.k) * 34;
var sy = wickTop - 46 + s2.y;
s2.x += s2.vx * dt; s2.y += s2.vy * dt;
var sr = 1.5 + (1 - s2.k) * 4;
ctx.fillStyle = 'rgba(' + C_SMOKE[0] + ',' + C_SMOKE[1] + ',' + C_SMOKE[2] + ',' + (0.10 * s2.k).toFixed(3) + ')';
ctx.fillRect(sx - sr, sy - sr, sr * 2, sr * 2);
}
// ── 5) 초 몸통·심지 — 면 계산(source-over)으로 마감 ──
ctx.globalCompositeOperation = 'source-over';
var wax = [
C_INK[0] + (C_AMBER[0] - C_INK[0]) * 0.20,
C_INK[1] + (C_AMBER[1] - C_INK[1]) * 0.20,
C_INK[2] + (C_AMBER[2] - C_INK[2]) * 0.20
];
ctx.fillStyle = 'rgb(' + (wax[0] | 0) + ',' + (wax[1] | 0) + ',' + (wax[2] | 0) + ')';
ctx.fillRect(cx - 11, candleTop, 22, ground - candleTop);
ctx.beginPath();
ctx.ellipse(cx, candleTop, 11, 3.4, 0, 0, TAU);
ctx.fill();
// 불꽃 쪽 몸통 면 — 림 라이트(온 금빛 한 줌)
ctx.fillStyle = 'rgba(' + C_GOLD[0] + ',' + C_GOLD[1] + ',' + C_GOLD[2] + ',0.12)';
ctx.fillRect(wind > 0 ? cx + 6 : cx - 10, candleTop + 2, 4, ground - candleTop - 4);
// 심지
ctx.strokeStyle = 'rgba(' + C_SMOKE[0] + ',' + C_SMOKE[1] + ',' + C_SMOKE[2] + ',0.55)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx, candleTop + 2);
ctx.lineTo(cx + wind * 2, wickTop);
ctx.stroke();
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
nOut.textContent = String(N_PART);
}
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;
if (!manual) {
// 미리보기 가상 風 — 시드 고정 Lissajous. x 가 風, y 가 화력
var ph = t * TAU / 3.2;
px01 = 0.5 + 0.34 * Math.sin(ph + 0.4);
py01 = 0.5 + 0.30 * Math.sin(2 * ph + 0.7);
}
draw(t, dt);
raf = requestAnimationFrame(frame);
}
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));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) draw(STILL_T, 0.016);
});
field.addEventListener('pointerleave', function () {
if (manual) { px01 = 0.5; py01 = 0.5; }
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); }
px01 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
if (reduce) draw(STILL_T, 0.016);
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T, 0.016); });
fit();
if (reduce) {
// reduce — rAF 를 돌리지 않고 후광이 가장 읽히는 한 장만 그린다
draw(STILL_T, 0);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// CandleWick — vanilla 판(run/314/demo/01-fire-candle-wick/index.html) 의 React 포트.
// 심지 끝에서 태어난 연료 입자가 부력으로 오르며 색온도(gold→amber/rose→ember)로 식고,
// 후광 3겹이 살아있는 연료 총합(flick)으로 숨쉰다. 미리보기는 시드 고정 Lissajous 가상 바람.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CandleWick.module.scss";
export type CandleWickProps = {
duration?: string;
easing?: string;
color?: string;
particles?: number; // 연료 입자 수 — 변형 "입자 수" 축(240 → 480)
lift?: number; // 부력 배율 — 불꽃 높이 축
tone?: 0 | 1; // 코어 색 변형 — 0: amber, 1: rose
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type PointerEvt = { clientX: number; clientY: number };
type KeyEvt = { key: string; preventDefault(): void };
type RGB = [number, number, number];
type Frag = { x: number; y: number; vx: number; vy: number; k: number; ph: number };
type Smk = { x: number; y: number; vx: number; vy: number; k: number; ph: number };
type Colors = { ink: RGB; amber: RGB; gold: RGB; rose: RGB; ember: RGB; smoke: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "촛불 파티클 실험판";
const STILL_T = 1.6; // reduce 에서 그리는 시각
const K_BURN = 2.3; // 연료 소모율 (1/s)
const SMK = 14;
// 시드 고정 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 CandleWick({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f0a868",
particles = 240,
lift = 1.0,
tone = 0,
}: CandleWickProps) {
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 npRef = useRef(particles) as Box<number>;
const liftRef = useRef(lift) as Box<number>;
const toneRef = useRef(tone) as Box<number>;
const partsRef = useRef<Frag[]>([]) as Box<Frag[]>;
const smokesRef = useRef<Smk[]>([]) as Box<Smk[]>;
const pxRef = useRef(0.5) as Box<number>;
const pyRef = 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 [n, setN] = useState(String(particles));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// 색은 CSS 커스텀 프로퍼티(--fire-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function coreRGB(col: Colors): RGB {
return toneRef.current ? col.rose : col.amber;
}
// 입자·연기 풀 리빌드 — particles prop 이 바뀌거나 최초 마운트 때
function buildParts(): void {
const rand = makePRNG(0x5eed01);
const n2 = npRef.current;
const arr: Frag[] = [];
for (let i = 0; i < n2; i++) {
arr.push({
x: (rand() - 0.5) * 3,
y: -rand() * 42,
vx: (rand() - 0.5) * 7,
vy: -20 - rand() * 28,
k: rand(),
ph: rand() * TAU,
});
}
partsRef.current = arr;
const rand2 = makePRNG(0x5eed01 + 991);
const sm: Smk[] = [];
for (let i = 0; i < SMK; i++) {
sm.push({
x: (rand2() - 0.5) * 6,
y: -rand2() * 72,
vx: (rand2() - 0.5) * 6,
vy: -13 - rand2() * 13,
k: rand2(),
ph: rand2() * TAU,
});
}
smokesRef.current = sm;
}
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;
setN(String(npRef.current));
}
function spawn(f: Frag, rand: () => number): void {
f.x = (rand() - 0.5) * 3;
f.y = 0;
f.vx = (rand() - 0.5) * 7;
f.vy = -20 - rand() * 28;
f.k = 1;
f.ph = rand() * TAU;
}
function spawnSmk(s: Smk, rand: () => number): void {
s.x = (rand() - 0.5) * 6;
s.y = 0;
s.vx = (rand() - 0.5) * 6;
s.vy = -13 - rand() * 13;
s.k = 1;
s.ph = rand() * TAU;
}
function draw(tSec: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const parts = partsRef.current;
const smokes = smokesRef.current;
if (!ctx || !col || parts.length === 0) return;
const { w: W, h: H } = sizeRef.current;
const rand = makePRNG(0x5eed01 + ((tSec * 61) | 0));
const core = coreRGB(col);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgb(${col.ink[0]},${col.ink[1]},${col.ink[2]})`;
ctx.fillRect(0, 0, W, H);
const cx = W * 0.5;
const ground = H * 0.88;
const candleTop = ground - 54;
const wickTop = candleTop - 9;
const wind = (pxRef.current - 0.5) * 2;
const power = 0.6 + pyRef.current * 0.8;
let flick = 0;
for (let f = 0; f < parts.length; f++) flick += parts[f].k;
flick = (flick / parts.length) * power;
// 앙비언트 글로우 — 반경이 breathe 로 진동하고 알파는 flick·sin 펄스로 매 프레임 재계산.
// 겹마다 그라디언트 정지점이 재배치되어 화면 넓은 영역이 실제로 변한다.
const breathe = (0.62 + 0.38 * flick) * (0.82 + 0.18 * Math.sin(tSec * 1.9));
const hx = cx + wind * 26;
const hy = wickTop - 17 - (power - 1) * 10 + Math.sin(tSec * 1.7) * 5;
const R0 = Math.min(W, H) * 0.13 * breathe;
const A0 = 0.60 * (0.66 + 0.34 * flick) * (0.72 + 0.28 * Math.sin(tSec * 2.3 + 0.9));
const RMAX = Math.hypot(W, H) * 0.99;
const LAYERS = 13;
const FALLOFF = 1.05;
ctx.globalCompositeOperation = "lighter";
for (let L = 0; L < LAYERS; L++) {
const Lt = L / (LAYERS - 1);
const R = R0 + (RMAX - R0) * Math.pow(Lt, 0.72);
const a = Math.max(0.06, A0 * Math.pow(R0 / R, FALLOFF));
const m1 = Lt;
const lr = col.gold[0] + (core[0] - col.gold[0]) * m1;
const lg = col.gold[1] + (core[1] - col.gold[1]) * m1;
const lb = col.gold[2] + (core[2] - col.gold[2]) * m1;
const rg = ctx.createRadialGradient(hx, hy, R * 0.04, hx, hy, R);
rg.addColorStop(0, `rgba(${lr | 0},${lg | 0},${lb | 0},${a.toFixed(3)})`);
rg.addColorStop(0.72, `rgba(${lr | 0},${lg | 0},${lb | 0},${(a * 0.6).toFixed(3)})`);
rg.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = rg;
ctx.fillRect(hx - R, hy - R, R * 2, R * 2);
}
// 잔열 바닥 띠 — 전체 폭이 은은히 맥동(중심 좌우 유동) + 반대 위상 두 번째 점
const hb = ground + 6;
const hbW = W * 0.75;
const hbX = cx + Math.sin(tSec * 0.6) * W * 0.3;
const hbA = 0.12 * (0.6 + 0.4 * flick) * (0.7 + 0.3 * Math.sin(tSec * 1.3 + 2.0));
const hbg = ctx.createRadialGradient(hbX, hb, 2, hbX, hb, hbW);
hbg.addColorStop(0, `rgba(${col.ember[0]},${col.ember[1]},${col.ember[2]},${hbA.toFixed(3)})`);
hbg.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = hbg;
ctx.fillRect(0, hb - hbW, W, hbW * 2);
const hb2X = cx - Math.sin(tSec * 0.6) * W * 0.3;
const hbg2 = ctx.createRadialGradient(hb2X, hb, 2, hb2X, hb, hbW * 0.7);
hbg2.addColorStop(0, `rgba(${col.rose[0]},${col.rose[1]},${col.rose[2]},${(hbA * 0.7).toFixed(3)})`);
hbg2.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = hbg2;
ctx.fillRect(0, hb - hbW * 0.7, W, hbW * 1.4);
for (let j = 0; j < parts.length; j++) {
const q = parts[j];
q.k -= K_BURN * dt;
if (q.k <= 0) { spawn(q, rand); continue; }
const sway = Math.sin(tSec * 7.3 + q.ph) * 8 * q.k;
const fx = cx + q.x + q.vx * q.k + sway + wind * (1 - q.k) * 26;
const fy = wickTop + q.y * (0.6 + 0.4 * power);
q.x += q.vx * dt;
q.y += q.vy * liftRef.current * dt;
let rC: number, gC: number, bC: number, rad: number;
if (q.k > 0.66) {
rC = col.gold[0]; gC = col.gold[1]; bC = col.gold[2];
rad = 2.4 + 1.6 * ((q.k - 0.66) / 0.34);
} else if (q.k > 0.33) {
const m2 = (q.k - 0.33) / 0.33;
rC = col.gold[0] + (core[0] - col.gold[0]) * m2;
gC = col.gold[1] + (core[1] - col.gold[1]) * m2;
bC = col.gold[2] + (core[2] - col.gold[2]) * m2;
rad = 1.8 + 0.6 * m2;
} else {
const m3 = q.k / 0.33;
rC = core[0] + (col.ember[0] - core[0]) * (1 - m3);
gC = core[1] + (col.ember[1] - core[1]) * (1 - m3);
bC = core[2] + (col.ember[2] - core[2]) * (1 - m3);
rad = 1.0 + 0.8 * m3;
}
ctx.fillStyle = `rgba(${rC | 0},${gC | 0},${bC | 0},${(0.16 + 0.5 * q.k).toFixed(3)})`;
ctx.fillRect(fx - rad, fy - rad, rad * 2, rad * 2);
}
for (let m = 0; m < smokes.length; m++) {
const s2 = smokes[m];
s2.k -= 0.55 * dt;
if (s2.k <= 0) { spawnSmk(s2, rand); continue; }
const sx = cx + s2.x + s2.vx * (1 - s2.k) + Math.sin(tSec * 1.6 + s2.ph) * 6 * (1 - s2.k) + wind * (1 - s2.k) * 34;
const sy = wickTop - 46 + s2.y;
s2.x += s2.vx * dt;
s2.y += s2.vy * dt;
const sr = 1.5 + (1 - s2.k) * 4;
ctx.fillStyle = `rgba(${col.smoke[0]},${col.smoke[1]},${col.smoke[2]},${(0.1 * s2.k).toFixed(3)})`;
ctx.fillRect(sx - sr, sy - sr, sr * 2, sr * 2);
}
ctx.globalCompositeOperation = "source-over";
const wax: RGB = [
col.ink[0] + (col.amber[0] - col.ink[0]) * 0.2,
col.ink[1] + (col.amber[1] - col.ink[1]) * 0.2,
col.ink[2] + (col.amber[2] - col.ink[2]) * 0.2,
];
ctx.fillStyle = `rgb(${wax[0] | 0},${wax[1] | 0},${wax[2] | 0})`;
ctx.fillRect(cx - 11, candleTop, 22, ground - candleTop);
ctx.beginPath();
ctx.ellipse(cx, candleTop, 11, 3.4, 0, 0, TAU);
ctx.fill();
ctx.fillStyle = `rgba(${col.gold[0]},${col.gold[1]},${col.gold[2]},0.12)`;
ctx.fillRect(wind > 0 ? cx + 6 : cx - 10, candleTop + 2, 4, ground - candleTop - 4);
ctx.strokeStyle = `rgba(${col.smoke[0]},${col.smoke[1]},${col.smoke[2]},0.55)`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx, candleTop + 2);
ctx.lineTo(cx + wind * 2, wickTop);
ctx.stroke();
}
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;
if (!manualRef.current) {
const ph = (t * TAU) / 3.2;
pxRef.current = 0.5 + 0.34 * Math.sin(ph + 0.4);
pyRef.current = 0.5 + 0.3 * Math.sin(2 * ph + 0.7);
}
draw(t, dt);
rafRef.current = requestAnimationFrame(frame);
}
function onPoint(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));
pyRef.current = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manualRef.current) manualRef.current = true;
if (reduced()) draw(STILL_T, 0.016);
}
function onLeave(): void {
if (manualRef.current) { pxRef.current = 0.5; pyRef.current = 0.5; }
}
function onKeyDown(e: KeyEvt): void {
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06],
};
const d = deltas[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) manualRef.current = true;
pxRef.current = Math.max(0, Math.min(1, pxRef.current + d[0]));
pyRef.current = Math.max(0, Math.min(1, pyRef.current + d[1]));
if (reduced()) draw(STILL_T, 0.016);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
npRef.current = particles;
liftRef.current = lift;
toneRef.current = tone;
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"),
};
buildParts();
fit();
function onResize(): void {
fit();
if (reduced()) draw(STILL_T, 0.016);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(STILL_T, 0);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [particles, lift, tone]);
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>심지에서 태어난 연료 입자가 부력으로 오르며 노랑→주황→빨강으로 색온도가 내려갑니다 — 포인터 x 는 바람, y 는 화력</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPoint}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>개 연료 입자 · 색온도 냉각</span>
<span className={styles.metaDim}>fillRect · globalCompositeOperation · requestAnimationFrame</span>
</div>
</div>
</main>
);
}01 · ろうそく一本 — りんの上に立つ最小の火 — 計測 累積面積 38.292% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
燃料パーティクルひとつがろうそくになる規則
var K_BURN = 2.3; // 燃料消費率 (1/s)
var STILL_T = 1.6; // reduced-motion で描く時刻 — 後光が最も読める一場面
for (var j = 0; j < N_PART; j++) {
var q = parts[j];
q.k -= K_BURN * dt;
if (q.k <= 0) { spawn(q); continue; }
var sway = Math.sin(tSec * 7.3 + q.ph) * 8 * q.k;
var fx = cx + q.x + q.vx * q.k + sway + wind * (1 - q.k) * 26;
var fy = wickTop + q.y * (0.6 + 0.4 * power);
q.x += q.vx * dt;
q.y += q.vy * LIFT * dt;
}
パーティクルが持つ値は位置·速度·燃料kだけで、N_PART240個が毎秒K_BURN2.3ずつkを失い、0になったらりんから生まれ直します。りんに立つ火の細さは生まれた瞬間のvy(-20〜-48)がパーティクルごとに違うことで生まれ、光の層はLAYERS13枚をFALLOFF1.05で減衰させて重ねます。
02焚き火 — カールノイズが揺らす火の柱
ポテンシャル場の回転(カール)をサンプリングして各パーティクルに接線の風を与えると、薪がひと塊になってうねる焚き火が計算で立ち上がります。キャンプ·アウトドアLPのヒーロー背景です。風の場は発散ゼロなのでN_COL3本の火柱は互いの乱流に巻き込まれながら、一点に潰れることもありません。

<main class="stage">
<div class="bf is-demo" id="bfRoot">
<p class="bf__hint">
<svg class="bf__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 1 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>薪ごとの火柱がポテンシャル場のカール(curl)ノイズ風に接線方向へ押され、ひと塊になってうねります — ポインターで薪の位置を動かします</span>
</p>
<div class="bf__field" id="bfField" role="application" aria-label="焚き火カールノイズ実験盤" tabindex="0">
<canvas class="bf__cv" id="bfCv"></canvas>
</div>
<div class="bf__bar">
<span class="bf__meta"><b class="bf__val" id="bfB">0.35</b> 浮力係数・ <b class="bf__val">3</b>本の火柱</span>
<span class="bf__meta bf__meta--dim">カールノイズ・lighter合成</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 모닥불(02). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(포텐셜 필드 컬 노이즈 + 부력 + 색온도 냉각)가 만든다.
// 무대는 잉크(#241b1d — 어두운 야외). 파스텔 고정 팔레트를 --fire-* 로 편 공통 규격(01~09 동일).
$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; // 연기
.bf {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --fire-* 를 받아 쓴다
--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 공통 — 실사용은 $r-* SCSS 토큰, 표기 참조용)
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.bf__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; // 잉크 무대 위 — 연기 톤 글자
}
.bf__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $fire-smoke; }
.bf__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;
}
.bf__field:focus-visible { outline: 2px solid $fire-amber; outline-offset: 2px; }
.bf__cv { display: block; width: 100%; height: 100%; }
.bf__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.bf__meta { font-size: 10px; font-weight: 500; color: rgba($fire-smoke, .72); }
.bf__val { color: $fire-gold; font-weight: 700; } // 잉크 위 금빛 라벨
.bf__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.bf { gap: $sp-1; }
.bf__field { height: 100px; }
.bf__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 한 장만 남는다. 커서 신호만 거둔다.
@media (prefers-reduced-motion: reduce) {
.bf.is-still .bf__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);
}
.bf {
--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;
}
.bf__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;
}
.bf__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #c9c2c6;
}
.bf__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;
}
.bf__field:focus-visible {
outline: 2px solid #f0a868;
outline-offset: 2px;
}
.bf__cv {
display: block;
width: 100%;
height: 100%;
}
.bf__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.bf__meta {
font-size: 10px;
font-weight: 500;
color: rgba(201, 194, 198, 0.72);
}
.bf__val {
color: #f6cf8a;
font-weight: 700;
}
.bf__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.bf {
gap: 4px;
}
.bf__field {
height: 100px;
}
.bf__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.bf.is-still .bf__field {
cursor: default !important;
}
}// 모닥불(02). 불기둥 3개가 각자 장작 위에서 솟지만, 바람은 하나의 포텐셜 필드에서 나온다.
// 필드 ψ 를 시드 고정 값노이즈 격자로 만들고 컬(curl) 을 해석적으로 계산한다:
// v = (∂ψ/∂y, −∂ψ/∂x)
// 컬은 발산이 0 이라 입자가 쥐어짜이지 않고 회전만 한다 — 여러 불기둥이 서로의 소용돌이에
// 말려 들어 한 덩어리로 넘실대는 이유. 입자는 연료 k 로 gold→amber→ember 냉각 사다리를 타고
// 부력 계수 BUOY(0.35) 로 오른다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var BUOY = 0.35; // 부력 계수 — 변형 "부력" 축(0.35 → 0.7)
var N_COL = 3; // 불기둥(장작) 수 — 밀도 축(2 → 5)
var TONE = 0; // 코어 색 변형 — 0: amber(#f0a868), 1: rose(#eca0a8)
// ────────────────────────────────────────────────────
var root = document.getElementById('bfRoot');
var field = document.getElementById('bfField');
var cv = document.getElementById('bfCv');
var ctx = cv.getContext('2d');
var bOut = document.getElementById('bfB');
// 시드 고정 PRNG — mulberry32. 필드 격자값·입자 초기값 전부 여기서 나온다
var SEED = 0x5eed02;
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 로 읽는다 — 파스텔 토큰이 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_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');
var C_CORE = TONE ? C_ROSE : C_AMBER; // 색 변형 — 코어 톤 교체
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; // 가상 장작/화력 포인터(0..1)
var manual = false;
// ── 컬 노이즈 포텐셜 필드 ──
// ψ(x, y, t) = Σ A_i * sin(kx_i·x + ky_i·y + w_i·t + φ_i) — 시드 고정 파수 6개
// 컬: v = (∂ψ/∂y, −∂ψ/∂x) = Σ A_i * (ky_i, −kx_i) * cos(·)
// 발산 0 — 회전만 있는 다이버전스프리 장. 파수·위상이 시드에서 나왔으니 매번 같은 風.
var NW = 6;
var nkx = [], nky = [], nw = [], nph = [], namp = [];
for (var w0 = 0; w0 < NW; w0++) {
var ang = rand() * TAU;
var len = 0.9 + rand() * 2.3;
nkx.push(Math.cos(ang) * len);
nky.push(Math.sin(ang) * len);
nw.push(0.4 + rand() * 1.1);
nph.push(rand() * TAU);
namp.push(26 + rand() * 34);
}
function curl(x, y, t) {
// 좌표는 필드 단위(초당 px)로 정규화 — 캔버스 크기와 무관한 소용돌이 결
var u = 0, v = 0;
var fx = x / 180, fy = y / 180;
for (var i = 0; i < NW; i++) {
var c = Math.cos(nkx[i] * fx + nky[i] * fy + nw[i] * t + nph[i]);
u += namp[i] * nky[i] * c;
v -= namp[i] * nkx[i] * c;
}
return [u, v]; // 접선 방향 風 (px/s)
}
// ── 연료 입자 — 장작 위에서 태어나 컬 風 + 부력으로 오르며 식는다 ──
var N_EACH = 150; // 불기둥 하나당 입자 수
var N_PART = N_EACH * N_COL;
var parts = [];
function spawn(p) {
// 어느 장작(불기둥)에서 태어날지 — 입자는 장작에 묶여 있고 장작은 포인터로 이동
p.col = (rand() * N_COL) | 0;
p.x = 0; // 장작 기준 상대 좌표 (px)
p.y = 0;
p.vx = (rand() - 0.5) * 9;
p.vy = -24 - rand() * 30;
p.k = 1;
p.ph = rand() * TAU;
}
for (var i = 0; i < N_PART; i++) {
var p = {}; spawn(p); p.y = -rand() * 60; p.k = rand(); // 첫 프레임부터 장작이 타 있는 상태로
parts.push(p);
}
// 장작(불기둥 밑동) 위치 — 포인터 x 로 뭉치 중심, y 로 화력
function logX(ci, cx, W) {
var spread = Math.min(W * 0.30, 92);
var slot = N_COL === 1 ? 0 : (ci / (N_COL - 1) - 0.5);
return cx + slot * spread;
}
var K_BURN = 2.0; // 연료 소모율 (1/s)
var STILL_T = 1.7; // reduce 에서 그리는 시각
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 cx = W * 0.5;
var ground = H * 0.84;
var power = 0.6 + py01 * 0.8;
// ── 2) 앙비언트 글로우 — 모닥불은 밤 야영지 전체를 데운다 ──
// 반경별 감쇠 빛 겹 LAYERS 장을 lighter 로 쌓는다. 반경은 R0→RMAX(대각선 끝까지),
// 앞은 촘촘히(밝은 코어)·뒤는 성기게(넓은 은은함). 알파 바닥 0.05 — 가장 바깥 겹도 산다.
// A0 는 살아있는 연료 총합 flick(컬 바람에 출렁이는 값)과 느린 sin 펄스로 매 프레임 재계산.
var flick = 0;
for (var f = 0; f < N_PART; f++) flick += parts[f].k;
flick = (flick / N_PART) * power;
// 반경 자체가 느리게 부풀었다 줄어든다(breathe) — 겹마다 지름이 진동해 그라디언트 정지점이
// 프레임마다 재배치되므로, 화면 넓은 영역이(중심에서 멀어도) 실제로 값이 바뀐다.
var breathe = (0.5 + 0.5 * flick) * (0.72 + 0.28 * Math.sin(tSec * 1.9));
var hx = cx + Math.sin(tSec * 0.9) * 14;
var hy = ground - 42 + Math.cos(tSec * 1.3) * 8;
var R0 = Math.min(W, H) * 0.17 * breathe;
var A0 = 0.85 * (0.55 + 0.45 * flick) * (0.62 + 0.38 * Math.sin(tSec * 2.1 + 1.4));
var RMAX = Math.hypot(W, H) * 0.99;
var LAYERS = 15;
var FALLOFF = 0.9;
ctx.globalCompositeOperation = 'lighter';
for (var L = 0; L < LAYERS; L++) {
var Lt = L / (LAYERS - 1); // 0..1
var R = R0 + (RMAX - R0) * Math.pow(Lt, 0.72); // 앞은 촘촘, 뒤는 성기게
var a = Math.max(0.06, A0 * Math.pow(R0 / R, FALLOFF));
var m1 = Lt; // 안쪽 gold → 바깥 코어 톤으로 식는다
var lr = C_GOLD[0] + (C_CORE[0] - C_GOLD[0]) * m1;
var lg = C_GOLD[1] + (C_CORE[1] - C_GOLD[1]) * m1;
var lb = C_GOLD[2] + (C_CORE[2] - C_GOLD[2]) * m1;
var rg = ctx.createRadialGradient(hx, hy, R * 0.04, hx, hy, R);
rg.addColorStop(0, 'rgba(' + (lr | 0) + ',' + (lg | 0) + ',' + (lb | 0) + ',' + a.toFixed(3) + ')');
rg.addColorStop(0.72, 'rgba(' + (lr | 0) + ',' + (lg | 0) + ',' + (lb | 0) + ',' + (a * 0.6).toFixed(3) + ')');
rg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = rg;
ctx.fillRect(hx - R, hy - R, R * 2, R * 2);
}
// ── 잔열 바닥 띠 — 캔버스 전체 폭이 순수 검정으로 죽지 않게, 야영지 바닥 전체가
// 은은한 잔열로 미세하게 맥동한다(중심이 좌우로 느리게 흘러 폭 전체가 실제로 변한다).
var hb = ground + 8;
var hbW = W * 0.78;
var hbX = cx + Math.sin(tSec * 0.5) * W * 0.3;
var hbA = 0.12 * (0.6 + 0.4 * flick) * (0.7 + 0.3 * Math.sin(tSec * 1.2 + 2.0));
var hbg = ctx.createRadialGradient(hbX, hb, 2, hbX, hb, hbW);
hbg.addColorStop(0, 'rgba(' + C_EMBER[0] + ',' + C_EMBER[1] + ',' + C_EMBER[2] + ',' + hbA.toFixed(3) + ')');
hbg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = hbg;
ctx.fillRect(0, hb - hbW, W, hbW * 2);
var hb2X = cx - Math.sin(tSec * 0.5) * W * 0.3;
var hbg2 = ctx.createRadialGradient(hb2X, hb, 2, hb2X, hb, hbW * 0.7);
hbg2.addColorStop(0, 'rgba(' + C_ROSE[0] + ',' + C_ROSE[1] + ',' + C_ROSE[2] + ',' + (hbA * 0.7).toFixed(3) + ')');
hbg2.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = hbg2;
ctx.fillRect(0, hb - hbW * 0.7, W, hbW * 1.4);
// ── 3) 연료 입자 — 컬 바람을 받아 접선으로 흘러간다 ──
// 상태 갱신: vx,vy 를 컬 장으로 'lerp' — 입자가 관성을 가진 채 소용돌이에 말려든다
for (var j = 0; j < N_PART; j++) {
var q = parts[j];
q.k -= K_BURN * dt;
if (q.k <= 0) { spawn(q); continue; }
var baseX = logX(q.col, cx, W);
var wx = baseX + q.x; // 세계 좌표 — 컬 필드는 여기를 샘플링
var wy = ground + q.y;
var cw = curl(wx, wy, tSec);
q.vx += (cw[0] - q.vx) * Math.min(1, dt * 3.2);
q.vy += (cw[1] - q.vy) * Math.min(1, dt * 3.2) - BUOY * 190 * dt; // 부력 계수 — 위로
q.x += q.vx * dt; q.y += q.vy * dt;
// 불꽃 안쪽일수록 중심으로 끌려들어가 뭉친다(불기둥 형태 유지)
var dxa = q.x / 26;
var pull = Math.exp(-dxa * dxa) * 34 * dt;
q.x -= q.x * pull * 0.06;
// 그리기 — 색온도 사다리(gold→core→ember)
var fx = baseX + q.x;
var fy = ground + q.y;
var rC, gC, bC, rad;
if (q.k > 0.66) {
rC = C_GOLD[0]; gC = C_GOLD[1]; bC = C_GOLD[2];
rad = 2.2 + 1.5 * (q.k - 0.66) / 0.34;
} else if (q.k > 0.33) {
var m2 = (q.k - 0.33) / 0.33;
rC = C_GOLD[0] + (C_CORE[0] - C_GOLD[0]) * m2;
gC = C_GOLD[1] + (C_CORE[1] - C_GOLD[1]) * m2;
bC = C_GOLD[2] + (C_CORE[2] - C_GOLD[2]) * m2;
rad = 1.7 + 0.5 * m2;
} else {
var m3 = q.k / 0.33;
rC = C_CORE[0] + (C_EMBER[0] - C_CORE[0]) * (1 - m3);
gC = C_CORE[1] + (C_EMBER[1] - C_CORE[1]) * (1 - m3);
bC = C_CORE[2] + (C_EMBER[2] - C_CORE[2]) * (1 - m3);
rad = 1.0 + 0.7 * m3;
}
ctx.fillStyle = 'rgba(' + (rC | 0) + ',' + (gC | 0) + ',' + (bC | 0) + ',' + (0.16 + 0.5 * q.k).toFixed(3) + ')';
ctx.fillRect(fx - rad, fy - rad, rad * 2, rad * 2);
}
// ── 4) 장작·불씨 받침 — 면 계산(source-over) 마감 ──
ctx.globalCompositeOperation = 'source-over';
for (var lg2 = 0; lg2 < N_COL; lg2++) {
var lx = logX(lg2, cx, W);
var log = [
C_INK[0] + (C_EMBER[0] - C_INK[0]) * 0.26,
C_INK[1] + (C_EMBER[1] - C_INK[1]) * 0.26,
C_INK[2] + (C_EMBER[2] - C_INK[2]) * 0.26
];
ctx.fillStyle = 'rgb(' + (log[0] | 0) + ',' + (log[1] | 0) + ',' + (log[2] | 0) + ')';
ctx.save();
ctx.translate(lx, ground + 4);
ctx.rotate((lg2 - (N_COL - 1) / 2) * 0.3);
ctx.beginPath();
ctx.roundRect ? ctx.roundRect(-22, -5, 44, 9, 4) : ctx.rect(-22, -5, 44, 9);
ctx.fill();
ctx.restore();
// 불씨 — 장작 밑동 잔불
ctx.fillStyle = 'rgba(' + C_EMBER[0] + ',' + C_EMBER[1] + ',' + C_EMBER[2] + ',0.5)';
ctx.fillRect(lx - 12, ground - 2, 24, 3);
}
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
bOut.textContent = BUOY.toFixed(2);
}
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;
if (!manual) {
// 미리보기 가상 포인터 — 시드 고정 Lissajous. x 가 장작 뭉치 중심, y 가 화력
var ph = t * TAU / 3.2;
px01 = 0.5 + 0.20 * Math.sin(ph + 0.4);
py01 = 0.5 + 0.30 * Math.sin(2 * ph + 0.7);
}
draw(t, dt);
raf = requestAnimationFrame(frame);
}
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));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) draw(STILL_T, 0.016);
});
field.addEventListener('pointerleave', function () {
if (manual) { px01 = 0.5; py01 = 0.5; }
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); }
px01 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
if (reduce) draw(STILL_T, 0.016);
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T, 0.016); });
fit();
if (reduce) {
// reduce — rAF 를 돌리지 않고 불기둥이 가장 읽히는 한 장만 그린다
draw(STILL_T, 0);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// BonfireCurl — vanilla 판(run/314/demo/02-fire-bonfire-curl/index.html) 의 React 포트.
// 불기둥 여러 개가 각자 장작 위에서 솟지만, 바람은 하나의 포텐셜 필드(ψ)에서 나온다.
// 컬(curl) = (∂ψ/∂y, −∂ψ/∂x) — 발산이 0 이라 입자가 쥐어짜이지 않고 회전만 하며
// 여러 불기둥이 서로의 소용돌이에 말려 한 덩어리로 넘실댄다. 미리보기는 시드 고정 Lissajous.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BonfireCurl.module.scss";
export type BonfireCurlProps = {
duration?: string;
easing?: string;
color?: string;
buoy?: number; // 부력 계수 — 변형 "부력" 축(0.35 → 0.7)
columns?: number; // 불기둥(장작) 수
tone?: 0 | 1; // 코어 색 변형 — 0: amber, 1: rose
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type PointerEvt = { clientX: number; clientY: number };
type KeyEvt = { key: string; preventDefault(): void };
type RGB = [number, number, number];
type Frag = { col: number; x: number; y: number; vx: number; vy: number; k: number; ph: number };
type Colors = { ink: RGB; amber: RGB; gold: RGB; rose: RGB; ember: RGB; smoke: RGB };
type Field = { kx: number[]; ky: number[]; w: number[]; ph: number[]; amp: number[] };
const TAU = Math.PI * 2;
const FIELD_LABEL = "모닥불 컬 노이즈 실험판";
const STILL_T = 1.7; // reduce 에서 그리는 시각
const K_BURN = 2.0; // 연료 소모율 (1/s)
const N_EACH = 150; // 불기둥 하나당 입자 수
const NW = 6; // 컬 노이즈 파수 개수
// 시드 고정 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;
};
}
function buildField(rand: () => number): Field {
const kx: number[] = [], ky: number[] = [], w: number[] = [], ph: number[] = [], amp: number[] = [];
for (let i = 0; i < NW; i++) {
const ang = rand() * TAU;
const len = 0.9 + rand() * 2.3;
kx.push(Math.cos(ang) * len);
ky.push(Math.sin(ang) * len);
w.push(0.4 + rand() * 1.1);
ph.push(rand() * TAU);
amp.push(26 + rand() * 34);
}
return { kx, ky, w, ph, amp };
}
// 포텐셜 필드 ψ 의 컬 — 다이버전스프리 접선 바람 (px/s)
function curl(field: Field, x: number, y: number, t: number): [number, number] {
let u = 0, v = 0;
const fx = x / 180, fy = y / 180;
for (let i = 0; i < NW; i++) {
const c = Math.cos(field.kx[i] * fx + field.ky[i] * fy + field.w[i] * t + field.ph[i]);
u += field.amp[i] * field.ky[i] * c;
v -= field.amp[i] * field.kx[i] * c;
}
return [u, v];
}
export default function BonfireCurl({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f0a868",
buoy = 0.35,
columns = 3,
tone = 0,
}: BonfireCurlProps) {
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 buoyRef = useRef(buoy) as Box<number>;
const nColRef = useRef(columns) as Box<number>;
const toneRef = useRef(tone) as Box<number>;
const noiseRef = useRef<Field | null>(null);
const partsRef = useRef<Frag[]>([]) as Box<Frag[]>;
const pxRef = useRef(0.5) as Box<number>;
const pyRef = 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 [b, setB] = useState(buoy.toFixed(2));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// 색은 CSS 커스텀 프로퍼티(--fire-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function coreRGB(col: Colors): RGB {
return toneRef.current ? col.rose : col.amber;
}
function logX(ci: number, cx: number, W: number): number {
const nCol = nColRef.current;
const spread = Math.min(W * 0.3, 92);
const slot = nCol === 1 ? 0 : ci / (nCol - 1) - 0.5;
return cx + slot * spread;
}
function buildParts(): void {
const rand = makePRNG(0x5eed02);
noiseRef.current = buildField(rand);
const nCol = nColRef.current;
const total = N_EACH * nCol;
const arr: Frag[] = [];
for (let i = 0; i < total; i++) {
const f: Frag = {
col: (rand() * nCol) | 0,
x: 0,
y: -rand() * 60,
vx: (rand() - 0.5) * 9,
vy: -24 - rand() * 30,
k: rand(),
ph: rand() * TAU,
};
arr.push(f);
}
partsRef.current = arr;
}
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;
setB(buoyRef.current.toFixed(2));
}
function spawn(f: Frag, rand: () => number): void {
f.col = (rand() * nColRef.current) | 0;
f.x = 0;
f.y = 0;
f.vx = (rand() - 0.5) * 9;
f.vy = -24 - rand() * 30;
f.k = 1;
f.ph = rand() * TAU;
}
function draw(tSec: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const parts = partsRef.current;
const field = noiseRef.current;
if (!ctx || !col || !field || parts.length === 0) return;
const { w: W, h: H } = sizeRef.current;
const rand = makePRNG(0x5eed02 + ((tSec * 61) | 0));
const core = coreRGB(col);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgb(${col.ink[0]},${col.ink[1]},${col.ink[2]})`;
ctx.fillRect(0, 0, W, H);
const cx = W * 0.5;
const ground = H * 0.84;
const power = 0.6 + pyRef.current * 0.8;
let flick = 0;
for (let f = 0; f < parts.length; f++) flick += parts[f].k;
flick = (flick / parts.length) * power;
// 앙비언트 글로우 — 반경이 breathe 로 진동하고 알파는 flick·sin 펄스로 매 프레임 재계산.
const breathe = (0.5 + 0.5 * flick) * (0.72 + 0.28 * Math.sin(tSec * 1.9));
const hx = cx + Math.sin(tSec * 0.9) * 14;
const hy = ground - 42 + Math.cos(tSec * 1.3) * 8;
const R0 = Math.min(W, H) * 0.17 * breathe;
const A0 = 0.85 * (0.55 + 0.45 * flick) * (0.62 + 0.38 * Math.sin(tSec * 2.1 + 1.4));
const RMAX = Math.hypot(W, H) * 0.99;
const LAYERS = 15;
const FALLOFF = 0.9;
ctx.globalCompositeOperation = "lighter";
for (let L = 0; L < LAYERS; L++) {
const Lt = L / (LAYERS - 1);
const R = R0 + (RMAX - R0) * Math.pow(Lt, 0.72);
const a = Math.max(0.06, A0 * Math.pow(R0 / R, FALLOFF));
const m1 = Lt;
const lr = col.gold[0] + (core[0] - col.gold[0]) * m1;
const lg = col.gold[1] + (core[1] - col.gold[1]) * m1;
const lb = col.gold[2] + (core[2] - col.gold[2]) * m1;
const rg = ctx.createRadialGradient(hx, hy, R * 0.04, hx, hy, R);
rg.addColorStop(0, `rgba(${lr | 0},${lg | 0},${lb | 0},${a.toFixed(3)})`);
rg.addColorStop(0.72, `rgba(${lr | 0},${lg | 0},${lb | 0},${(a * 0.6).toFixed(3)})`);
rg.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = rg;
ctx.fillRect(hx - R, hy - R, R * 2, R * 2);
}
// 잔열 바닥 띠 — 전체 폭이 은은히 맥동(중심 좌우 유동) + 반대 위상 두 번째 점
const hb = ground + 8;
const hbW = W * 0.78;
const hbX = cx + Math.sin(tSec * 0.5) * W * 0.3;
const hbA = 0.12 * (0.6 + 0.4 * flick) * (0.7 + 0.3 * Math.sin(tSec * 1.2 + 2.0));
const hbg = ctx.createRadialGradient(hbX, hb, 2, hbX, hb, hbW);
hbg.addColorStop(0, `rgba(${col.ember[0]},${col.ember[1]},${col.ember[2]},${hbA.toFixed(3)})`);
hbg.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = hbg;
ctx.fillRect(0, hb - hbW, W, hbW * 2);
const hb2X = cx - Math.sin(tSec * 0.5) * W * 0.3;
const hbg2 = ctx.createRadialGradient(hb2X, hb, 2, hb2X, hb, hbW * 0.7);
hbg2.addColorStop(0, `rgba(${col.rose[0]},${col.rose[1]},${col.rose[2]},${(hbA * 0.7).toFixed(3)})`);
hbg2.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = hbg2;
ctx.fillRect(0, hb - hbW * 0.7, W, hbW * 1.4);
for (let j = 0; j < parts.length; j++) {
const q = parts[j];
q.k -= K_BURN * dt;
if (q.k <= 0) { spawn(q, rand); continue; }
const baseX = logX(q.col, cx, W);
const wx = baseX + q.x;
const wy = ground + q.y;
const cw = curl(field, wx, wy, tSec);
q.vx += (cw[0] - q.vx) * Math.min(1, dt * 3.2);
q.vy += (cw[1] - q.vy) * Math.min(1, dt * 3.2) - buoyRef.current * 190 * dt;
q.x += q.vx * dt;
q.y += q.vy * dt;
const dxa = q.x / 26;
const pull = Math.exp(-dxa * dxa) * 34 * dt;
q.x -= q.x * pull * 0.06;
const fx = baseX + q.x;
const fy = ground + q.y;
let rC: number, gC: number, bC: number, rad: number;
if (q.k > 0.66) {
rC = col.gold[0]; gC = col.gold[1]; bC = col.gold[2];
rad = 2.2 + 1.5 * ((q.k - 0.66) / 0.34);
} else if (q.k > 0.33) {
const m2 = (q.k - 0.33) / 0.33;
rC = col.gold[0] + (core[0] - col.gold[0]) * m2;
gC = col.gold[1] + (core[1] - col.gold[1]) * m2;
bC = col.gold[2] + (core[2] - col.gold[2]) * m2;
rad = 1.7 + 0.5 * m2;
} else {
const m3 = q.k / 0.33;
rC = core[0] + (col.ember[0] - core[0]) * (1 - m3);
gC = core[1] + (col.ember[1] - core[1]) * (1 - m3);
bC = core[2] + (col.ember[2] - core[2]) * (1 - m3);
rad = 1.0 + 0.7 * m3;
}
ctx.fillStyle = `rgba(${rC | 0},${gC | 0},${bC | 0},${(0.16 + 0.5 * q.k).toFixed(3)})`;
ctx.fillRect(fx - rad, fy - rad, rad * 2, rad * 2);
}
ctx.globalCompositeOperation = "source-over";
const nCol = nColRef.current;
for (let lg2 = 0; lg2 < nCol; lg2++) {
const lx = logX(lg2, cx, W);
const log: RGB = [
col.ink[0] + (col.ember[0] - col.ink[0]) * 0.26,
col.ink[1] + (col.ember[1] - col.ink[1]) * 0.26,
col.ink[2] + (col.ember[2] - col.ink[2]) * 0.26,
];
ctx.fillStyle = `rgb(${log[0] | 0},${log[1] | 0},${log[2] | 0})`;
ctx.save();
ctx.translate(lx, ground + 4);
ctx.rotate((lg2 - (nCol - 1) / 2) * 0.3);
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(-22, -5, 44, 9, 4);
else ctx.rect(-22, -5, 44, 9);
ctx.fill();
ctx.restore();
ctx.fillStyle = `rgba(${col.ember[0]},${col.ember[1]},${col.ember[2]},0.5)`;
ctx.fillRect(lx - 12, ground - 2, 24, 3);
}
}
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;
if (!manualRef.current) {
const ph = (t * TAU) / 3.2;
pxRef.current = 0.5 + 0.2 * Math.sin(ph + 0.4);
pyRef.current = 0.5 + 0.3 * Math.sin(2 * ph + 0.7);
}
draw(t, dt);
rafRef.current = requestAnimationFrame(frame);
}
function onPoint(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));
pyRef.current = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manualRef.current) manualRef.current = true;
if (reduced()) draw(STILL_T, 0.016);
}
function onLeave(): void {
if (manualRef.current) { pxRef.current = 0.5; pyRef.current = 0.5; }
}
function onKeyDown(e: KeyEvt): void {
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06],
};
const d = deltas[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) manualRef.current = true;
pxRef.current = Math.max(0, Math.min(1, pxRef.current + d[0]));
pyRef.current = Math.max(0, Math.min(1, pyRef.current + d[1]));
if (reduced()) draw(STILL_T, 0.016);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
buoyRef.current = buoy;
nColRef.current = columns;
toneRef.current = tone;
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"),
};
buildParts();
fit();
function onResize(): void {
fit();
if (reduced()) draw(STILL_T, 0.016);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(STILL_T, 0);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [buoy, columns, tone]);
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>장작마다 솟은 불기둥이 포텐셜 필드의 컬(curl) 노이즈 바람에 접선으로 밀려 한 덩어리로 넘실댑니다 — 포인터로 장작 위치를 옮깁니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPoint}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{b}</b> 부력 계수 · <b className={styles.val}>{columns}</b>개 불기둥</span>
<span className={styles.metaDim}>curl noise · lighter 합성</span>
</div>
</div>
</main>
);
}02 · 焚き火 — カールノイズが揺らす火の柱 — 計測 累積面積 37.507% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
接線への漸近が慣性になる補間
for (var j = 0; j < N_PART; j++) {
var q = parts[j];
q.k -= K_BURN * dt;
if (q.k <= 0) { spawn(q); continue; }
var wx = baseX + q.x; // 世界座標 — カール場はここをサンプリング
var wy = ground + q.y;
var cw = curl(wx, wy, tSec);
q.vx += (cw[0] - q.vx) * Math.min(1, dt * 3.2);
q.vy += (cw[1] - q.vy) * Math.min(1, dt * 3.2) - BUOY * 190 * dt;
q.x += q.vx * dt; q.y += q.vy * dt;
}
カール場の目標速度に毎フレーム少しずつ寄せるMath.min(1, dt * 3.2)の補間が慣性で、そこにBUOY0.35の定数加速が上向きに加わります。ポインターは火の位置ではなく火力だけを動かします — yがpower(0.6〜1.4)を決め、xの値は保持されるだけで描画には使われません。この火力の介入のみがパーティクルの運動に外から触る値です。
03松明 — 風ベクトルを背負った炎
一定の風ベクトルにゆっくりした揺れを足して炎全体を一方向へ流すと、持ち歩く松明の傾いた火になります。ゲーム·イベントページの聖火の松明です。WIND_VX46は自分では変わらず、変わるのはその向きの角度だけです。

<main class="stage">
<div class="tc is-demo" id="tcRoot">
<p class="tc__hint">
<svg class="tc__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 1 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>一定の風ベクトルと緩やかなsin揺れで聖火全体が一方向へ流れます — ポインターxは風の強さ、yは方向</span>
</p>
<div class="tc__field" id="tcField" role="application" aria-label="松明の風ベクトル実験盤" tabindex="0">
<canvas class="tc__cv" id="tcCv"></canvas>
</div>
<div class="tc__bar">
<span class="tc__meta">風 <b class="tc__val" id="tcW">0.62</b> ・sin基準の揺れ</span>
<span class="tc__meta tc__meta--dim">requestAnimationFrame・色温度ラダー</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 횃불(03). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(일정 바람 벡터 + 느린 sin 요동 + 색온도 냉각)가 만든다.
// 무대는 잉크(#241b1d — 어두운 야외). 파스텔 고정 팔레트를 --fire-* 로 편 공통 규격(01~09 동일).
$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; // 연기
.tc {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --fire-* 를 받아 쓴다
--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 공통 — 실사용은 $r-* SCSS 토큰, 표기 참조용)
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.tc__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; // 잉크 무대 위 — 연기 톤 글자
}
.tc__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $fire-smoke; }
.tc__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;
}
.tc__field:focus-visible { outline: 2px solid $fire-amber; outline-offset: 2px; }
.tc__cv { display: block; width: 100%; height: 100%; }
.tc__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.tc__meta { font-size: 10px; font-weight: 500; color: rgba($fire-smoke, .72); }
.tc__val { color: $fire-gold; font-weight: 700; } // 잉크 위 금빛 라벨
.tc__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.tc { gap: $sp-1; }
.tc__field { height: 100px; }
.tc__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 한 장만 남는다. 커서 신호만 거둔다.
@media (prefers-reduced-motion: reduce) {
.tc.is-still .tc__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);
}
.tc {
--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;
}
.tc__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;
}
.tc__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #c9c2c6;
}
.tc__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;
}
.tc__field:focus-visible {
outline: 2px solid #f0a868;
outline-offset: 2px;
}
.tc__cv {
display: block;
width: 100%;
height: 100%;
}
.tc__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.tc__meta {
font-size: 10px;
font-weight: 500;
color: rgba(201, 194, 198, 0.72);
}
.tc__val {
color: #f6cf8a;
font-weight: 700;
}
.tc__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.tc {
gap: 4px;
}
.tc__field {
height: 100px;
}
.tc__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.tc.is-still .tc__field {
cursor: default !important;
}
}// 횃불(03). 불꽃 전체를 기울이는 힘은 두 겹이다:
// 1) 일정한 風 벡터(WIND_VX, WIND_VY) — 매 프레임 변하지 않는 상수 방향
// 2) 느린 요동 — sin(t * 저주파) 로 벡터 자체가 아주 천천히 흔들려 성화가 "숨쉬며" 쏠린다
// 입자는 연료 k(1→0) 로 gold→amber→ember 색온도 사다리를 타고 부력으로 오르며,
// k 가 낮을수록(위로 갈수록) 바람을 더 많이 받는다 — 근원(횃불 머리)은 곧고 끝은 크게 눕는다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var WIND_VX = 46; // 風 벡터 x(px/s) — 변형 "風 세기" 축(20 → 70)
var WIND_VY = -6; // 風 벡터 y(px/s) — 살짝 위로 미는 성분
var WOBBLE = 0.35; // 느린 요동 진폭(라디안) — 변형 "요동 폭" 축
var TONE = 0; // 코어 색 변형 — 0: amber(#f0a868), 1: rose(#eca0a8)
// ────────────────────────────────────────────────────
var root = document.getElementById('tcRoot');
var field = document.getElementById('tcField');
var cv = document.getElementById('tcCv');
var ctx = cv.getContext('2d');
var wOut = document.getElementById('tcW');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 성화로 살아 움직인다
var SEED = 0x5eed03;
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 로 읽는다 — 파스텔 토큰이 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_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');
var C_CORE = TONE ? C_ROSE : C_AMBER; // 색 변형 — 코어 톤 교체
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; // 가상 風 세기/방향 포인터(0..1)
var manual = false;
// ── 연료 입자 — 횃불 머리에서 태어나 부력 + 風 벡터로 오르내리며 식는다 ──
var N_PART = 220;
var parts = [];
function spawn(p) {
p.x = (rand() - 0.5) * 8;
p.y = 0;
p.vx = (rand() - 0.5) * 8;
p.vy = -22 - rand() * 30;
p.k = 1;
p.ph = rand() * TAU;
}
for (var i = 0; i < N_PART; i++) {
var p = {}; spawn(p); p.y = -rand() * 46; p.k = rand();
parts.push(p);
}
var K_BURN = 2.1; // 연료 소모율 (1/s)
var STILL_T = 1.55; // reduce 에서 그리는 시각 — 성화가 가장 기운 순간
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 cx = W * 0.5;
var ground = H * 0.90;
var poleTop = ground - 96; // 횃불 자루 윗면 = 머리 밑동
var strength = 0.5 + px01; // 포인터 x — 風 세기 배율(0.5~1.5)
var dirTilt = (py01 - 0.5) * 0.7; // 포인터 y — 風 방향 미세 회전
// ── 두 겹 힘 ──
// 1) 일정한 風 벡터: (WIND_VX, WIND_VY) * strength, dirTilt 만큼 회전
// 2) 느린 요동: 벡터 각도에 WOBBLE * sin(t * 0.35) 를 더해 성화가 천천히 숨쉬며 쏠린다
var baseAng = Math.atan2(WIND_VY, WIND_VX) + dirTilt;
var wobAng = baseAng + WOBBLE * Math.sin(tSec * 0.35);
var windMag = Math.hypot(WIND_VX, WIND_VY) * strength;
var windVX = Math.cos(wobAng) * windMag;
var windVY = Math.sin(wobAng) * windMag;
// ── 2) 앙비언트 글로우 — 성화는 밤의 벌판을 밝힌다 ──
// 반경별 감쇠 빛 겹 LAYERS 장을 lighter 로 쌓는다. 반경은 R0→RMAX(대각선 끝까지),
// 앞은 촘촘히(밝은 코어)·뒤는 성기게(넓은 은은함). 알파 바닥 0.05 — 가장 바깥 겹도 산다.
// A0 는 살아있는 연료 총합 flick 과 느린 sin 펄스로 매 프레임 재계산 —
// 중심(hx,hy)도 요동 각도를 그대로 받아 그 자리에서 움직인다.
var flick = 0;
for (var f = 0; f < N_PART; f++) flick += parts[f].k;
flick = flick / N_PART;
// 반경 자체가 느리게 부풀었다 줄어든다(breathe) — 겹마다 지름이 진동해 그라디언트 정지점이
// 프레임마다 재배치되므로, 화면 넓은 영역이(중심에서 멀어도) 실제로 값이 바뀐다.
var breathe = (0.5 + 0.5 * flick) * (0.72 + 0.28 * Math.sin(tSec * 1.9));
var hx = cx + windVX * 0.5 + Math.sin(tSec * 1.4) * 6;
var hy = poleTop - 22 + windVY * 0.5 + Math.cos(tSec * 1.1) * 4;
var R0 = Math.min(W, H) * 0.16 * breathe;
var A0 = 0.85 * (0.55 + 0.45 * flick) * (0.62 + 0.38 * Math.sin(tSec * 2.4 + 0.6));
var RMAX = Math.hypot(W, H) * 0.99;
var LAYERS = 15;
var FALLOFF = 0.9;
ctx.globalCompositeOperation = 'lighter';
for (var L = 0; L < LAYERS; L++) {
var Lt = L / (LAYERS - 1); // 0..1
var R = R0 + (RMAX - R0) * Math.pow(Lt, 0.72); // 앞은 촘촘, 뒤는 성기게
var a = Math.max(0.06, A0 * Math.pow(R0 / R, FALLOFF));
var m1 = Lt; // 안쪽 gold → 바깥 코어 톤으로 식는다
var lr = C_GOLD[0] + (C_CORE[0] - C_GOLD[0]) * m1;
var lg = C_GOLD[1] + (C_CORE[1] - C_GOLD[1]) * m1;
var lb = C_GOLD[2] + (C_CORE[2] - C_GOLD[2]) * m1;
var rg = ctx.createRadialGradient(hx, hy, R * 0.04, hx, hy, R);
rg.addColorStop(0, 'rgba(' + (lr | 0) + ',' + (lg | 0) + ',' + (lb | 0) + ',' + a.toFixed(3) + ')');
rg.addColorStop(0.72, 'rgba(' + (lr | 0) + ',' + (lg | 0) + ',' + (lb | 0) + ',' + (a * 0.6).toFixed(3) + ')');
rg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = rg;
ctx.fillRect(hx - R, hy - R, R * 2, R * 2);
}
// ── 잔열 바닥 띠 — 캔버스 전체 폭이 순수 검정으로 죽지 않게, 벌판 전체가
// 은은한 잔열로 미세하게 맥동한다(중심이 좌우로 느리게 흘러 폭 전체가 실제로 변한다).
var hb = ground - 4;
var hbW = W * 0.78;
var hbX = cx + Math.sin(tSec * 0.55) * W * 0.3;
var hbA = 0.12 * (0.6 + 0.4 * flick) * (0.7 + 0.3 * Math.sin(tSec * 1.25 + 2.0));
var hbg = ctx.createRadialGradient(hbX, hb, 2, hbX, hb, hbW);
hbg.addColorStop(0, 'rgba(' + C_EMBER[0] + ',' + C_EMBER[1] + ',' + C_EMBER[2] + ',' + hbA.toFixed(3) + ')');
hbg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = hbg;
ctx.fillRect(0, hb - hbW, W, hbW * 2);
var hb2X = cx - Math.sin(tSec * 0.55) * W * 0.3;
var hbg2 = ctx.createRadialGradient(hb2X, hb, 2, hb2X, hb, hbW * 0.7);
hbg2.addColorStop(0, 'rgba(' + C_ROSE[0] + ',' + C_ROSE[1] + ',' + C_ROSE[2] + ',' + (hbA * 0.7).toFixed(3) + ')');
hbg2.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = hbg2;
ctx.fillRect(0, hb - hbW * 0.7, W, hbW * 1.4);
// ── 3) 연료 입자 — 위로 갈수록(k 가 작을수록) 바람을 더 많이 받아 크게 눕는다 ──
for (var j = 0; j < N_PART; j++) {
var q = parts[j];
q.k -= K_BURN * dt;
if (q.k <= 0) { spawn(q); continue; }
var lean = (1 - q.k); // 0(태생) → 1(다 탐) — 風 노출 정도
var sway = Math.sin(tSec * 6.6 + q.ph) * 6 * q.k;
var fx = cx + q.x + q.vx * q.k + sway + windVX * lean * 0.55 * dt * 60;
var fy = poleTop + q.y * q.k + windVY * lean * 0.4 * dt * 60;
q.x += (q.vx + windVX * lean * 0.4) * dt;
q.y += (q.vy + windVY * lean * 0.3) * dt;
var rC, gC, bC, rad;
if (q.k > 0.66) {
rC = C_GOLD[0]; gC = C_GOLD[1]; bC = C_GOLD[2];
rad = 2.3 + 1.5 * (q.k - 0.66) / 0.34;
} else if (q.k > 0.33) {
var m2 = (q.k - 0.33) / 0.33;
rC = C_GOLD[0] + (C_CORE[0] - C_GOLD[0]) * m2;
gC = C_GOLD[1] + (C_CORE[1] - C_GOLD[1]) * m2;
bC = C_GOLD[2] + (C_CORE[2] - C_GOLD[2]) * m2;
rad = 1.8 + 0.5 * m2;
} else {
var m3 = q.k / 0.33;
rC = C_CORE[0] + (C_EMBER[0] - C_CORE[0]) * (1 - m3);
gC = C_CORE[1] + (C_EMBER[1] - C_CORE[1]) * (1 - m3);
bC = C_CORE[2] + (C_EMBER[2] - C_CORE[2]) * (1 - m3);
rad = 1.0 + 0.7 * m3;
}
ctx.fillStyle = 'rgba(' + (rC | 0) + ',' + (gC | 0) + ',' + (bC | 0) + ',' + (0.16 + 0.5 * q.k).toFixed(3) + ')';
ctx.beginPath();
ctx.arc(fx, fy, rad, 0, TAU);
ctx.fill();
}
// ── 4) 횃불 자루·묶음 머리 — 면 계산(source-over) 마감 ──
ctx.globalCompositeOperation = 'source-over';
var poleAng = wobAng * 0.10; // 자루는 살짝만 따라 기운다(단단한 나무)
ctx.save();
ctx.translate(cx, ground);
ctx.rotate(poleAng);
// 자루
ctx.fillStyle = 'rgb(' + ((C_INK[0] + C_EMBER[0] * 0.3) | 0) + ',' + ((C_INK[1] + C_EMBER[1] * 0.3) | 0) + ',' + ((C_INK[2] + C_EMBER[2] * 0.3) | 0) + ')';
ctx.fillRect(-6, -(ground - poleTop), 12, ground - poleTop);
// 묶음 머리(연기 톤 헝겊)
ctx.fillStyle = 'rgba(' + C_SMOKE[0] + ',' + C_SMOKE[1] + ',' + C_SMOKE[2] + ',0.6)';
ctx.beginPath();
ctx.ellipse(0, -(ground - poleTop) - 2, 12, 16, 0, 0, TAU);
ctx.fill();
ctx.restore();
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
wOut.textContent = (0.5 + px01).toFixed(2);
}
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;
if (!manual) {
// 미리보기 가상 風 — 시드 고정 Lissajous. x 가 세기, y 가 방향
var ph = t * TAU / 3.6;
px01 = 0.5 + 0.34 * Math.sin(ph + 0.4);
py01 = 0.5 + 0.24 * Math.sin(2 * ph + 0.7);
}
draw(t, dt);
wOut.textContent = (0.5 + px01).toFixed(2);
raf = requestAnimationFrame(frame);
}
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));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) draw(STILL_T, 0.016);
});
field.addEventListener('pointerleave', function () {
if (manual) { px01 = 0.5; py01 = 0.5; }
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); }
px01 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
if (reduce) draw(STILL_T, 0.016);
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T, 0.016); });
fit();
if (reduce) {
// reduce — rAF 를 돌리지 않고 성화가 가장 기운 한 장만 그린다
draw(STILL_T, 0);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// TorchWindVector — vanilla 판(run/314/demo/03-fire-torch-wind-vector/index.html) 의 React 포트.
// 일정한 바람 벡터 + 느린 sin 요동으로 성화 전체가 한쪽으로 쏠린다. 입자는 연료 k 로
// gold→amber/rose→ember 색온도 사다리를 타고, k 가 낮을수록 바람을 더 받아 크게 눕는다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TorchWindVector.module.scss";
export type TorchWindVectorProps = {
duration?: string;
easing?: string;
color?: string;
wind?: number; // 바람 벡터 x 성분(px/s) — 변형 "바람 세기" 축
wobble?: number; // 느린 요동 진폭(라디안) — 변형 "요동 폭" 축
tone?: 0 | 1; // 코어 색 변형 — 0: amber, 1: rose
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type PointerEvt = { clientX: number; clientY: number };
type KeyEvt = { key: string; preventDefault(): void };
type RGB = [number, number, number];
type Frag = { x: number; y: number; vx: number; vy: number; k: number; ph: number };
type Colors = { ink: RGB; amber: RGB; gold: RGB; rose: RGB; ember: RGB; smoke: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "횃불 바람 벡터 실험판";
const STILL_T = 1.55; // reduce 에서 그리는 시각 — 성화가 가장 기운 순간
const K_BURN = 2.1; // 연료 소모율 (1/s)
const N_PART = 220;
const WIND_VY = -6; // 바람 벡터 y 성분(살짝 위로)
// 시드 고정 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 TorchWindVector({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f0a868",
wind = 46,
wobble = 0.35,
tone = 0,
}: TorchWindVectorProps) {
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 windRef = useRef(wind) as Box<number>;
const wobRef = useRef(wobble) as Box<number>;
const toneRef = useRef(tone) as Box<number>;
const partsRef = useRef<Frag[]>([]) as Box<Frag[]>;
const pxRef = useRef(0.5) as Box<number>;
const pyRef = 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 [w, setW] = useState((0.5 + pxRef.current).toFixed(2));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// 색은 CSS 커스텀 프로퍼티(--fire-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function coreRGB(col: Colors): RGB {
return toneRef.current ? col.rose : col.amber;
}
function buildParts(): void {
const rand = makePRNG(0x5eed03);
const arr: Frag[] = [];
for (let i = 0; i < N_PART; i++) {
arr.push({
x: (rand() - 0.5) * 8,
y: -rand() * 46,
vx: (rand() - 0.5) * 8,
vy: -22 - rand() * 30,
k: rand(),
ph: rand() * TAU,
});
}
partsRef.current = arr;
}
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;
setW((0.5 + pxRef.current).toFixed(2));
}
function spawn(f: Frag, rand: () => number): void {
f.x = (rand() - 0.5) * 8;
f.y = 0;
f.vx = (rand() - 0.5) * 8;
f.vy = -22 - rand() * 30;
f.k = 1;
f.ph = rand() * TAU;
}
function draw(tSec: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const parts = partsRef.current;
if (!ctx || !col || parts.length === 0) return;
const { w: W, h: H } = sizeRef.current;
const rand = makePRNG(0x5eed03 + ((tSec * 61) | 0));
const core = coreRGB(col);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgb(${col.ink[0]},${col.ink[1]},${col.ink[2]})`;
ctx.fillRect(0, 0, W, H);
const cx = W * 0.5;
const ground = H * 0.9;
const poleTop = ground - 96;
const strength = 0.5 + pxRef.current;
const dirTilt = (pyRef.current - 0.5) * 0.7;
// 두 겹 힘 — 일정 바람 벡터 + 느린 sin 요동
const baseAng = Math.atan2(WIND_VY, windRef.current) + dirTilt;
const wobAng = baseAng + wobRef.current * Math.sin(tSec * 0.35);
const windMag = Math.hypot(windRef.current, WIND_VY) * strength;
const windVX = Math.cos(wobAng) * windMag;
const windVY = Math.sin(wobAng) * windMag;
let flick = 0;
for (let f = 0; f < parts.length; f++) flick += parts[f].k;
flick = flick / parts.length;
// 앙비언트 글로우 — 반경이 breathe 로 진동하고 알파는 flick·sin 펄스로 매 프레임 재계산.
const breathe = (0.5 + 0.5 * flick) * (0.72 + 0.28 * Math.sin(tSec * 1.9));
const hx = cx + windVX * 0.5 + Math.sin(tSec * 1.4) * 6;
const hy = poleTop - 22 + windVY * 0.5 + Math.cos(tSec * 1.1) * 4;
const R0 = Math.min(W, H) * 0.16 * breathe;
const A0 = 0.85 * (0.55 + 0.45 * flick) * (0.62 + 0.38 * Math.sin(tSec * 2.4 + 0.6));
const RMAX = Math.hypot(W, H) * 0.99;
const LAYERS = 15;
const FALLOFF = 0.9;
ctx.globalCompositeOperation = "lighter";
for (let L = 0; L < LAYERS; L++) {
const Lt = L / (LAYERS - 1);
const R = R0 + (RMAX - R0) * Math.pow(Lt, 0.72);
const a = Math.max(0.06, A0 * Math.pow(R0 / R, FALLOFF));
const m1 = Lt;
const lr = col.gold[0] + (core[0] - col.gold[0]) * m1;
const lg = col.gold[1] + (core[1] - col.gold[1]) * m1;
const lb = col.gold[2] + (core[2] - col.gold[2]) * m1;
const rg = ctx.createRadialGradient(hx, hy, R * 0.04, hx, hy, R);
rg.addColorStop(0, `rgba(${lr | 0},${lg | 0},${lb | 0},${a.toFixed(3)})`);
rg.addColorStop(0.72, `rgba(${lr | 0},${lg | 0},${lb | 0},${(a * 0.6).toFixed(3)})`);
rg.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = rg;
ctx.fillRect(hx - R, hy - R, R * 2, R * 2);
}
// 잔열 바닥 띠 — 전체 폭이 은은히 맥동(중심 좌우 유동) + 반대 위상 두 번째 점
const hb = ground - 4;
const hbW = W * 0.78;
const hbX = cx + Math.sin(tSec * 0.55) * W * 0.3;
const hbA = 0.12 * (0.6 + 0.4 * flick) * (0.7 + 0.3 * Math.sin(tSec * 1.25 + 2.0));
const hbg = ctx.createRadialGradient(hbX, hb, 2, hbX, hb, hbW);
hbg.addColorStop(0, `rgba(${col.ember[0]},${col.ember[1]},${col.ember[2]},${hbA.toFixed(3)})`);
hbg.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = hbg;
ctx.fillRect(0, hb - hbW, W, hbW * 2);
const hb2X = cx - Math.sin(tSec * 0.55) * W * 0.3;
const hbg2 = ctx.createRadialGradient(hb2X, hb, 2, hb2X, hb, hbW * 0.7);
hbg2.addColorStop(0, `rgba(${col.rose[0]},${col.rose[1]},${col.rose[2]},${(hbA * 0.7).toFixed(3)})`);
hbg2.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = hbg2;
ctx.fillRect(0, hb - hbW * 0.7, W, hbW * 1.4);
for (let j = 0; j < parts.length; j++) {
const q = parts[j];
q.k -= K_BURN * dt;
if (q.k <= 0) { spawn(q, rand); continue; }
const lean = 1 - q.k;
const sway = Math.sin(tSec * 6.6 + q.ph) * 6 * q.k;
const fx = cx + q.x + q.vx * q.k + sway + windVX * lean * 0.55 * dt * 60;
const fy = poleTop + q.y * q.k + windVY * lean * 0.4 * dt * 60;
q.x += (q.vx + windVX * lean * 0.4) * dt;
q.y += (q.vy + windVY * lean * 0.3) * dt;
let rC: number, gC: number, bC: number, rad: number;
if (q.k > 0.66) {
rC = col.gold[0]; gC = col.gold[1]; bC = col.gold[2];
rad = 2.3 + 1.5 * ((q.k - 0.66) / 0.34);
} else if (q.k > 0.33) {
const m2 = (q.k - 0.33) / 0.33;
rC = col.gold[0] + (core[0] - col.gold[0]) * m2;
gC = col.gold[1] + (core[1] - col.gold[1]) * m2;
bC = col.gold[2] + (core[2] - col.gold[2]) * m2;
rad = 1.8 + 0.5 * m2;
} else {
const m3 = q.k / 0.33;
rC = core[0] + (col.ember[0] - core[0]) * (1 - m3);
gC = core[1] + (col.ember[1] - core[1]) * (1 - m3);
bC = core[2] + (col.ember[2] - core[2]) * (1 - m3);
rad = 1.0 + 0.7 * m3;
}
ctx.fillStyle = `rgba(${rC | 0},${gC | 0},${bC | 0},${(0.16 + 0.5 * q.k).toFixed(3)})`;
ctx.beginPath();
ctx.arc(fx, fy, rad, 0, TAU);
ctx.fill();
}
// 횃불 자루·묶음 머리 — 면 계산 마감
ctx.globalCompositeOperation = "source-over";
const poleAng = wobAng * 0.1;
ctx.save();
ctx.translate(cx, ground);
ctx.rotate(poleAng);
ctx.fillStyle = `rgb(${(col.ink[0] + col.ember[0] * 0.3) | 0},${(col.ink[1] + col.ember[1] * 0.3) | 0},${(col.ink[2] + col.ember[2] * 0.3) | 0})`;
ctx.fillRect(-6, -(ground - poleTop), 12, ground - poleTop);
ctx.fillStyle = `rgba(${col.smoke[0]},${col.smoke[1]},${col.smoke[2]},0.6)`;
ctx.beginPath();
ctx.ellipse(0, -(ground - poleTop) - 2, 12, 16, 0, 0, TAU);
ctx.fill();
ctx.restore();
}
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;
if (!manualRef.current) {
const ph = (t * TAU) / 3.6;
pxRef.current = 0.5 + 0.34 * Math.sin(ph + 0.4);
pyRef.current = 0.5 + 0.24 * Math.sin(2 * ph + 0.7);
}
draw(t, dt);
setW((0.5 + pxRef.current).toFixed(2));
rafRef.current = requestAnimationFrame(frame);
}
function onPoint(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));
pyRef.current = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manualRef.current) manualRef.current = true;
if (reduced()) draw(STILL_T, 0.016);
}
function onLeave(): void {
if (manualRef.current) { pxRef.current = 0.5; pyRef.current = 0.5; }
}
function onKeyDown(e: KeyEvt): void {
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06],
};
const d = deltas[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) manualRef.current = true;
pxRef.current = Math.max(0, Math.min(1, pxRef.current + d[0]));
pyRef.current = Math.max(0, Math.min(1, pyRef.current + d[1]));
if (reduced()) draw(STILL_T, 0.016);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
windRef.current = wind;
wobRef.current = wobble;
toneRef.current = tone;
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"),
};
buildParts();
fit();
function onResize(): void {
fit();
if (reduced()) draw(STILL_T, 0.016);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(STILL_T, 0);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wind, wobble, tone]);
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>일정한 바람 벡터와 느린 sin 요동에 성화 전체가 한쪽으로 쏠립니다 — 포인터 x 는 바람 세기, y 는 방향</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPoint}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>바람 <b className={styles.val}>{w}</b> · 요동 sin 기반</span>
<span className={styles.metaDim}>requestAnimationFrame · 색온도 사다리</span>
</div>
</div>
</main>
);
}03 · 松明 — 風ベクトルを背負った炎 — 計測 累積面積 34.055% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
一定ベクトルに足す回転ひとつ
var baseAng = Math.atan2(WIND_VY, WIND_VX) + dirTilt;
var wobAng = baseAng + Math.sin(tSec * 0.7) * 0.35;
var windMag = Math.hypot(WIND_VX, WIND_VY) * strength;
var windVX = Math.cos(wobAng) * windMag;
var windVY = Math.sin(wobAng) * windMag;
var fx = cx + q.x + q.vx * q.k + sway
+ windVX * lean * 0.55 * dt * 60;
q.x += (q.vx + windVX * lean * 0.4) * dt;
q.y += (q.vy + windVY * lean * 0.3) * dt;
基本の角度にMath.sin(tSec * 0.7) * 0.35という低い周波数の回転をひとつ足すだけで、火柱が硬く死なず聖火が「息をしながら寝る」ように見えます。LAYERS15枚の光の層がFALLOFF0.9で減衰しながら夜の原を照らします。
04ランプの炎 — ガラスの後ろに滲む後光
小さな炎に半径ごとに減衰する光の層を重ねると、ガラスランプの中で揺れる火が周囲を温める場面になります。静かな店舗·ホテルの紹介画面です。LAYERS11枚のグラデーション環は炎の揺れを追うひとつの中心を共有し、外側の環も完全には消えません。

<main class="stage">
<div class="lp is-demo" id="lpRoot">
<p class="lp__hint">
<svg class="lp__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 1 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="lp__field" id="lpField" role="application" aria-label="ランプの炎の後光実験盤" tabindex="0">
<canvas class="lp__cv" id="lpCv"></canvas>
</div>
<div class="lp__bar">
<span class="lp__meta"><b class="lp__val" id="lpLay">11</b>層の光・減衰指数1.35</span>
<span class="lp__meta lp__meta--dim">createRadialGradient・lighter合成</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 램프 불꽃(04). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(파티클 부력 + 빛 겹 lighter 합성)가 만든다.
// 무대는 잉크(#241b1d — 어두운 실내). 파스텔 고정 팔레트를 --fire-* 로 편 공통 규격(01~09 동일).
$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; // 연기
.lp {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --fire-* 를 받아 쓴다
--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;
}
.lp__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; // 잉크 무대 위 — 연기 톤 글자
}
.lp__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $fire-smoke; }
.lp__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;
}
.lp__field:focus-visible { outline: 2px solid $fire-amber; outline-offset: 2px; }
.lp__cv { display: block; width: 100%; height: 100%; }
.lp__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lp__meta { font-size: 10px; font-weight: 500; color: rgba($fire-smoke, .72); }
.lp__val { color: $fire-gold; font-weight: 700; } // 잉크 위 금빛 라벨
.lp__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lp { gap: $sp-1; }
.lp__field { height: 100px; }
.lp__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 한 장만 남는다. 커서 신호만 거둔다.
@media (prefers-reduced-motion: reduce) {
.lp.is-still .lp__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);
}
.lp {
--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;
}
.lp__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;
}
.lp__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #c9c2c6;
}
.lp__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;
}
.lp__field:focus-visible {
outline: 2px solid #f0a868;
outline-offset: 2px;
}
.lp__cv {
display: block;
width: 100%;
height: 100%;
}
.lp__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lp__meta {
font-size: 10px;
font-weight: 500;
color: rgba(201, 194, 198, 0.72);
}
.lp__val {
color: #f6cf8a;
font-weight: 700;
}
.lp__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lp {
gap: 4px;
}
.lp__field {
height: 100px;
}
.lp__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lp.is-still .lp__field {
cursor: default !important;
}
}// 램프 불꽃(04). 불꽃 자체는 작은 파티클 다발(연료 입자가 타올라 부력으로 오르며
// 색온도에 따라 gold→amber→rose 로 식는 시뮬레이션)이고, 장면의 정체는 그 주위에
// createRadialGradient 로 반경별 감쇠 빛 겹(LAYERS 장)을 lighter 로 쌓은 유리 램프 후광이다.
// 포인터 x 는 風 방향(불꽃 기울기·후광 중심 흔들림), y 는 유리 유목 높낮이.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var LAYERS = 11; // 빛 겹 수 — 변형 "빛 겹 수" 축(3 → 9). 04 재튜닝(2026-09-15): 6→11 화면 40%+ 덮게
var FALLOFF = 1.35; // 감쇠 지수 — 변형 "감쇠" 축(1.4 → 2.8). 클수록 후광이 유리에 붙는다. 완만하게 낮춤(2.1→1.35)
var N_PART = 240; // 불꽃 파티클 수 — 변형 "밀도" 축(70 → 280). 140→240
// ────────────────────────────────────────────────────
var root = document.getElementById('lpRoot');
var field = document.getElementById('lpField');
var cv = document.getElementById('lpCv');
var ctx = cv.getContext('2d');
var layOut = document.getElementById('lpLay');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 불꽃으로 살아 움직인다
var SEED = 0x5eed04;
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 로 읽는다 — 파스텔 토큰이 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_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');
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; // 가상 風/유목 포인터(0..1)
var manual = false;
// ── 불꽃 파티클 — 연료 입자가 심지에서 태어나 부력으로 오르며 식는다 ──
// 각 입자는 연료 k(1→0) 를 갖고, k 가 줄수록 반경이 줄고 색이 gold→amber→ember 로 내려간다.
var parts = [];
function spawn(p) {
p.x = (rand() - 0.5) * 7; // 심지 폭 안 (px)
p.y = 0; // 심지 끝에서 출발
p.vx = (rand() - 0.5) * 6;
p.vy = -18 - rand() * 26; // 부력 — 위로 (px/s)
p.k = 1; // 연료(열). 매초 K_BURN 닳는다
p.ph = rand() * TAU; // 개별 흔들림 위상
}
for (var i = 0; i < N_PART; i++) {
var p = {}; spawn(p); p.y = -rand() * 34; p.k = rand(); // 첫 프레임부터 불꽃이 차 있는 상태로
parts.push(p);
}
var K_BURN = 1.35; // 연료 소모율 (1/s). 재튜닝: 2.2→1.35 — 입자가 오래 타서 후광이 잔류한다
var STILL_T = 1.85; // reduce 에서 그리는 시각 — 후광이 가장 읽히는 한 장
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 cx = W * 0.5, ground = H * 0.78;
var wind = (px01 - 0.5) * 2; // -1..1 — 포인터 x
var lift = (py01 - 0.5) * 2; // -1..1 — 포인터 y(유목 높이)
// ── 2) 빛 겹(LAYERS 장) — 반경별 감쇠 후광. 겹마다 반지름이 커지고 알파는 지수로 줄어든다 ──
// lighter 합성이라 겹친 곳은 더해져 밝아진다 — 유리 안에서 빛이 차오르는 이유.
// 불꽃 밝기(살아 있는 연료 총합)로 전체 후광 세기를 구면, 램프가 "숨쉬는" 느낌이 계산에서 나온다.
var flick = 0;
for (var f = 0; f < N_PART; f++) flick += parts[f].k;
flick = flick / N_PART; // 0..1 — 현재 연료 평균 = 불꽃 세기
// 재튜닝: 호흡 변조 추가 — 반경 자체가 느리게 부풀었다 줄어들어 후광 경계가 화면을 쓴다
var breathe = (0.72 + 0.28 * flick) * (0.90 + 0.10 * Math.sin(tSec * 1.9));
// 유리 등 병(램프 유목) — 반경 감쇠 원 3장으로 어두운 유리 실루엣을 먼저 얹는다
ctx.globalCompositeOperation = 'lighter';
for (var g = 0; g < 3; g++) {
var gr = (0.10 + g * 0.045) * Math.min(W, H) * breathe;
var gg = ctx.createRadialGradient(cx, ground - gr * 0.9, gr * 0.05, cx, ground - gr * 0.9, gr);
var ga = 0.05 * (1 - g * 0.28);
gg.addColorStop(0, 'rgba(' + C_SMOKE[0] + ',' + C_SMOKE[1] + ',' + C_SMOKE[2] + ',' + ga + ')');
gg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = gg;
ctx.fillRect(cx - gr, ground - gr * 1.9, gr * 2, gr * 2);
}
// ── 빛 겹 LAYERS 장 — 이 항목의 주인공 ──
// 겹 i: 반지름 R_i — 앞쪽은 촘촘히(밝은 코어), 바깥쪽은 성기게(넓게 퍼진다), 마지막 겹은 대각선 끝까지
// 알파 a_i = max(바닥알파, A0 * (R0 / R_i)^FALLOFF) — 가장 바깥 겹도 문턱 아래로 죽지 않는다
// 중심은 불꽃 실제 위치(바람에 기울어진)를 따라간다 → 후광이 불꽃을 쫓는다
var hx = cx + wind * 26; // 재튜닝: 風 게인 8→26 — 후광 중심이 실제로 이동
var hy = ground - 26 - lift * 10 + Math.sin(tSec * 1.7) * 5; // 재튜닝: 수직 흔들림 증폭
var R0 = Math.min(W, H) * 0.11 * breathe;
var A0 = 0.52 * (0.8 + 0.2 * flick) * (0.86 + 0.14 * Math.sin(tSec * 2.6 + 1.1)); // 재튜닝: 알파도 느리게 맥동
var RMAX = Math.hypot(W, H) * 0.98; // 재튜닝: 마지막 겹이 대각선 끝까지 닿게 — 화면 40%+ 커버
for (var L = 0; L < LAYERS; L++) {
var Lt = L / Math.max(1, LAYERS - 1); // 0..1
var R = R0 + (RMAX - R0) * Math.pow(Lt, 0.72); // 앞은 촘촘, 뒤는 성기게 — 코어는 밝게 유지하며 넓게 퍼진다
var a = Math.max(0.06, A0 * Math.pow(R0 / R, FALLOFF)); // 바닥알파 0.06 — 가장 바깥 겹도 은은하게 남는다
// 안쪽 겹은 불꽃온도(gold), 바깥 겹은 실내 온도(rose)로 색이 식는다
var mixk = L / Math.max(1, LAYERS - 1);
var lr = C_GOLD[0] + (C_ROSE[0] - C_GOLD[0]) * mixk;
var lg = C_GOLD[1] + (C_ROSE[1] - C_GOLD[1]) * mixk;
var lb = C_GOLD[2] + (C_ROSE[2] - C_GOLD[2]) * mixk;
var rg = ctx.createRadialGradient(hx, hy, R * 0.04, hx, hy, R);
rg.addColorStop(0, 'rgba(' + (lr | 0) + ',' + (lg | 0) + ',' + (lb | 0) + ',' + a.toFixed(3) + ')');
rg.addColorStop(0.72, 'rgba(' + (lr | 0) + ',' + (lg | 0) + ',' + (lb | 0) + ',' + (a * 0.6).toFixed(3) + ')');
rg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = rg;
ctx.fillRect(hx - R, hy - R, R * 2, R * 2);
}
// ── 3) 불꽃 파티클 — 연료 k 로 크기·색을 계산해 그린다 ──
// 風: vx += wind * 22 * dt — 포인터가 불꽃을 실제로 눕힌다
// 개별 흔들림: sin(t*7 + ph) * 9 — 촛불 떨림(시드 고정이라 항상 같은 결)
for (var j = 0; j < N_PART; j++) {
var q = parts[j];
q.k -= K_BURN * dt;
if (q.k <= 0) { spawn(q); continue; }
var sway = Math.sin(tSec * 7 + q.ph) * 9 * q.k;
var fx = cx + q.x + q.vx * q.k + sway + wind * (1 - q.k) * 30;
var fy = ground + q.y * q.k - (1 - q.k) * lift * 8;
var fy2 = ground + (q.y + q.vy * dt) * (q.k - K_BURN * dt);
q.x += q.vx * dt; q.y += q.vy * dt;
// 색온도 사다리 — k 가 1(갓 태어남·gold) → 0(다 탐·ember) 로 내려간다
var rC, gC, bC, rad;
if (q.k > 0.66) {
var m1 = (q.k - 0.66) / 0.34;
rC = C_GOLD[0] + (C_GOLD[0] - C_GOLD[0]) * m1; gC = C_GOLD[1]; bC = C_GOLD[2];
rad = 2.2 + 1.6 * m1;
} else if (q.k > 0.33) {
var m2 = (q.k - 0.33) / 0.33;
rC = C_GOLD[0] + (C_AMBER[0] - C_GOLD[0]) * m2;
gC = C_GOLD[1] + (C_AMBER[1] - C_GOLD[1]) * m2;
bC = C_GOLD[2] + (C_AMBER[2] - C_GOLD[2]) * m2;
rad = 1.8 + 0.4 * m2;
} else {
var m3 = q.k / 0.33;
rC = C_AMBER[0] + (C_EMBER[0] - C_AMBER[0]) * (1 - m3);
gC = C_AMBER[1] + (C_EMBER[1] - C_AMBER[1]) * (1 - m3);
bC = C_AMBER[2] + (C_EMBER[2] - C_AMBER[2]) * (1 - m3);
rad = 1.0 + 0.8 * m3;
}
ctx.fillStyle = 'rgba(' + (rC | 0) + ',' + (gC | 0) + ',' + (bC | 0) + ',' + (0.16 + 0.5 * q.k).toFixed(3) + ')';
ctx.beginPath();
ctx.arc(fx, fy, rad, 0, TAU);
ctx.fill();
}
// ── 4) 심지와 받침 — 면 계산(source-over)으로 마감 ──
ctx.globalCompositeOperation = 'source-over';
// 심지
ctx.strokeStyle = 'rgba(' + C_SMOKE[0] + ',' + C_SMOKE[1] + ',' + C_SMOKE[2] + ',0.5)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx + wind * 2, ground + 10);
ctx.lineTo(cx + wind * 5, ground - 6);
ctx.stroke();
// 받침 접시 — 타원
ctx.fillStyle = 'rgba(' + C_EMBER[0] + ',' + C_EMBER[1] + ',' + C_EMBER[2] + ',0.55)';
ctx.beginPath();
ctx.ellipse(cx, ground + 12, 26, 6, 0, 0, TAU);
ctx.fill();
// 유리 병 외곽선 — 연기 톤 아치(후광 위에 살짝)
ctx.strokeStyle = 'rgba(' + C_SMOKE[0] + ',' + C_SMOKE[1] + ',' + C_SMOKE[2] + ',0.30)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.ellipse(cx, ground - 14, 30, 38, wind * 0.06, 0, TAU);
ctx.stroke();
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
layOut.textContent = String(LAYERS);
}
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;
if (!manual) {
// 미리보기 가상 風 — 시드 고정 Lissajous. x 가 風, y 가 유목 높이
// 재튜닝: 진폭 확대(0.42→0.46, 0.30→0.34) — 후광이 무대 좌우를 넓게 쓸게
var ph = t * TAU / 3.2;
px01 = 0.5 + 0.46 * Math.sin(ph + 0.4);
py01 = 0.5 + 0.34 * Math.sin(2 * ph + 0.7);
}
draw(t, dt);
raf = requestAnimationFrame(frame);
}
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));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) { px01 = px01; draw(STILL_T, 0.016); }
});
field.addEventListener('pointerleave', function () {
if (manual) { px01 = 0.5; py01 = 0.5; }
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); }
px01 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
if (reduce) draw(STILL_T, 0.016);
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T, 0.016); });
fit();
if (reduce) {
// reduce — rAF 를 돌리지 않고 후광이 가장 읽히는 한 장만 그린다
draw(STILL_T, 0);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// FireLampHalo — vanilla 판(run/314/demo/04-fire-lamp-halo/index.html) 의 React 포트.
// 불꽃 파티클(연료 k 가 부력으로 오르며 gold→amber→ember 로 식는다) 주위에
// createRadialGradient 로 반경별 감쇠 빛 겹을 lighter 로 쌓아 유리 램프 후광을 만든다.
// 미리보기는 시드 고정 Lissajous 가상 바람.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FireLampHalo.module.scss";
export type FireLampHaloProps = {
duration?: string;
easing?: string;
color?: string;
layers?: number; // 빛 겹 수 — 변형 "빛 겹 수" 축
falloff?: number; // 감쇠 지수 — 변형 "감쇠" 축
particles?: number; // 불꽃 파티클 수 — 변형 "밀도" 축
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type PointerEvt = { clientX: number; clientY: number };
type KeyEvt = { key: string; preventDefault(): void };
type RGB = [number, number, number];
type Frag = { x: number; y: number; vx: number; vy: number; k: number; ph: number };
type Colors = { ink: RGB; amber: RGB; gold: RGB; rose: RGB; ember: RGB; smoke: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "램프 불꽃 후광 실험판";
const STILL_T = 1.85; // reduce 에서 그리는 시각
const K_BURN = 1.35; // 연료 소모율 (1/s) — 04 재튜닝(2026-09-15) 2.2→1.35
// 시드 고정 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 FireLampHalo({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#eca0a8",
layers = 11,
falloff = 1.35,
particles = 240,
}: FireLampHaloProps) {
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 layRef = useRef(layers) as Box<number>;
const fallRef = useRef(falloff) as Box<number>;
const npRef = useRef(particles) as Box<number>;
const partsRef = useRef<Frag[]>([]) as Box<Frag[]>;
const pxRef = useRef(0.5) as Box<number>;
const pyRef = 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 [lay, setLay] = useState(String(layers));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// 색은 CSS 커스텀 프로퍼티(--fire-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
// 파편 풀 리빌드 — particles prop 이 바뀌거나 최초 마운트 때
function buildParts(): void {
const rand = makePRNG(0x5eed04);
const n = npRef.current;
const arr: Frag[] = [];
for (let i = 0; i < n; i++) {
const f: Frag = {
x: (rand() - 0.5) * 7,
y: -rand() * 34,
vx: (rand() - 0.5) * 6,
vy: -18 - rand() * 26,
k: rand(),
ph: rand() * TAU,
};
arr.push(f);
}
partsRef.current = arr;
}
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;
setLay(String(layRef.current));
}
function spawn(f: Frag, rand: () => number): void {
f.x = (rand() - 0.5) * 7;
f.y = 0;
f.vx = (rand() - 0.5) * 6;
f.vy = -18 - rand() * 26;
f.k = 1;
f.ph = rand() * TAU;
}
function draw(tSec: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const parts = partsRef.current;
if (!ctx || !col || parts.length === 0) return;
const { w: W, h: H } = sizeRef.current;
const rand = makePRNG(0x5eed04 + ((tSec * 61) | 0));
// 1) 무대 — 잉크
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgb(${col.ink[0]},${col.ink[1]},${col.ink[2]})`;
ctx.fillRect(0, 0, W, H);
const cx = W * 0.5;
const ground = H * 0.78;
const wind = (pxRef.current - 0.5) * 2;
const lift = (pyRef.current - 0.5) * 2;
// 2) 빛 겹 — 반경별 감쇠 후광(LAYERS 장). 겹친 곳은 lighter 로 더해진다
let flick = 0;
for (let f = 0; f < parts.length; f++) flick += parts[f].k;
flick = flick / parts.length;
// 재튜닝: 호흡 변조 — 반경 자체가 느리게 부풀었다 줄어든다
const breathe = (0.72 + 0.28 * flick) * (0.9 + 0.1 * Math.sin(tSec * 1.9));
ctx.globalCompositeOperation = "lighter";
// 유리 유목 실루엣(연기 톤 원 3장)
for (let g = 0; g < 3; g++) {
const gr = (0.1 + g * 0.045) * Math.min(W, H) * breathe;
const gy = ground - gr * 0.9;
const gg = ctx.createRadialGradient(cx, gy, gr * 0.05, cx, gy, gr);
const ga = 0.05 * (1 - g * 0.28);
gg.addColorStop(0, `rgba(${col.smoke[0]},${col.smoke[1]},${col.smoke[2]},${ga.toFixed(3)})`);
gg.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = gg;
ctx.fillRect(cx - gr, gy - gr, gr * 2, gr * 2);
}
// 빛 겹 — 안쪽은 gold, 바깷겹은 rose 로 식는다
const hx = cx + wind * 26; // 재튜닝: 바람 게인 8→26 — 후광 중심이 실제로 이동
const hy = ground - 26 - lift * 10 + Math.sin(tSec * 1.7) * 5;
const R0 = Math.min(W, H) * 0.11 * breathe;
const A0 = 0.52 * (0.8 + 0.2 * flick) * (0.86 + 0.14 * Math.sin(tSec * 2.6 + 1.1)); // 재튜닝: 알파 맥동
const RMAX = Math.hypot(W, H) * 0.98; // 재튜닝: 마지막 겹이 대각선 끝까지 — 화면 40%+ 커버
const LAYERS = layRef.current;
for (let L = 0; L < LAYERS; L++) {
const Lt = L / Math.max(1, LAYERS - 1);
const R = R0 + (RMAX - R0) * Math.pow(Lt, 0.72); // 앞은 촘촘, 뒤는 성기게
const a = Math.max(0.06, A0 * Math.pow(R0 / R, fallRef.current)); // 바닥알파 0.06
const mixk = L / Math.max(1, LAYERS - 1);
const lr = col.gold[0] + (col.rose[0] - col.gold[0]) * mixk;
const lg = col.gold[1] + (col.rose[1] - col.gold[1]) * mixk;
const lb = col.gold[2] + (col.rose[2] - col.gold[2]) * mixk;
const rg = ctx.createRadialGradient(hx, hy, R * 0.04, hx, hy, R);
rg.addColorStop(0, `rgba(${lr | 0},${lg | 0},${lb | 0},${a.toFixed(3)})`);
rg.addColorStop(0.72, `rgba(${lr | 0},${lg | 0},${lb | 0},${(a * 0.6).toFixed(3)})`);
rg.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = rg;
ctx.fillRect(hx - R, hy - R, R * 2, R * 2);
}
// 3) 불꽃 파티클 — 색온도 사다리(gold→amber→ember)
for (let j = 0; j < parts.length; j++) {
const q = parts[j];
q.k -= K_BURN * dt;
if (q.k <= 0) { spawn(q, rand); continue; }
const sway = Math.sin(tSec * 7 + q.ph) * 9 * q.k;
const fx = cx + q.x + q.vx * q.k + sway + wind * (1 - q.k) * 30;
const fy = ground + q.y * q.k - (1 - q.k) * lift * 8;
q.x += q.vx * dt;
q.y += q.vy * dt;
let rC: number, gC: number, bC: number, rad: number;
if (q.k > 0.66) {
const m1 = (q.k - 0.66) / 0.34;
rC = col.gold[0]; gC = col.gold[1]; bC = col.gold[2];
rad = 2.2 + 1.6 * m1;
} else if (q.k > 0.33) {
const m2 = (q.k - 0.33) / 0.33;
rC = col.gold[0] + (col.amber[0] - col.gold[0]) * m2;
gC = col.gold[1] + (col.amber[1] - col.gold[1]) * m2;
bC = col.gold[2] + (col.amber[2] - col.gold[2]) * m2;
rad = 1.8 + 0.4 * m2;
} else {
const m3 = q.k / 0.33;
rC = col.amber[0] + (col.ember[0] - col.amber[0]) * (1 - m3);
gC = col.amber[1] + (col.ember[1] - col.amber[1]) * (1 - m3);
bC = col.amber[2] + (col.ember[2] - col.amber[2]) * (1 - m3);
rad = 1.0 + 0.8 * m3;
}
ctx.fillStyle = `rgba(${rC | 0},${gC | 0},${bC | 0},${(0.16 + 0.5 * q.k).toFixed(3)})`;
ctx.beginPath();
ctx.arc(fx, fy, rad, 0, TAU);
ctx.fill();
}
// 4) 심지·받침·유리 외곽선 — 면 계산 마감
ctx.globalCompositeOperation = "source-over";
ctx.strokeStyle = `rgba(${col.smoke[0]},${col.smoke[1]},${col.smoke[2]},0.5)`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx + wind * 2, ground + 10);
ctx.lineTo(cx + wind * 5, ground - 6);
ctx.stroke();
ctx.fillStyle = `rgba(${col.ember[0]},${col.ember[1]},${col.ember[2]},0.55)`;
ctx.beginPath();
ctx.ellipse(cx, ground + 12, 26, 6, 0, 0, TAU);
ctx.fill();
ctx.strokeStyle = `rgba(${col.smoke[0]},${col.smoke[1]},${col.smoke[2]},0.3)`;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.ellipse(cx, ground - 14, 30, 38, wind * 0.06, 0, TAU);
ctx.stroke();
}
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;
if (!manualRef.current) {
const ph = (t * TAU) / 3.2;
pxRef.current = 0.5 + 0.46 * Math.sin(ph + 0.4); // 재튜닝: 진폭 0.42→0.46
pyRef.current = 0.5 + 0.34 * Math.sin(2 * ph + 0.7); // 재튜닝: 진폭 0.30→0.34
}
draw(t, dt);
rafRef.current = requestAnimationFrame(frame);
}
function onPoint(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));
pyRef.current = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manualRef.current) manualRef.current = true;
if (reduced()) draw(STILL_T, 0.016);
}
function onLeave(): void {
if (manualRef.current) { pxRef.current = 0.5; pyRef.current = 0.5; }
}
function onKeyDown(e: KeyEvt): void {
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06],
};
const d = deltas[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) manualRef.current = true;
pxRef.current = Math.max(0, Math.min(1, pxRef.current + d[0]));
pyRef.current = Math.max(0, Math.min(1, pyRef.current + d[1]));
if (reduced()) draw(STILL_T, 0.016);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
layRef.current = layers;
fallRef.current = falloff;
npRef.current = particles;
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"),
};
buildParts();
fit();
function onResize(): void {
fit();
if (reduced()) draw(STILL_T, 0.016);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(STILL_T, 0);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layers, falloff, particles]);
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}
onPointerMove={onPoint}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{lay}</b>개 빛 겹 · 감쇠 지수 {falloff}</span>
<span className={styles.metaDim}>createRadialGradient · lighter 합성</span>
</div>
</div>
</main>
);
}04 · ランプの炎 — ガラスの後ろに滲む後光 — 計測 累積面積 43.983% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
半径の減衰が主役、パーティクルは従
var R0 = Math.min(W, H) * 0.11 * breathe;
var A0 = 0.52 * (0.8 + 0.2 * flick) * (0.86 + 0.14 * Math.sin(tSec * 2.6 + 1.1));
var RMAX = Math.hypot(W, H) * 0.98; // 最外環は遠い隅まで届く
for (var L = 0; L < LAYERS; L++) {
var Lt = L / Math.max(1, LAYERS - 1);
var R = R0 + (RMAX - R0) * Math.pow(Lt, 0.72);
var a = Math.max(0.06, A0 * Math.pow(R0 / R, FALLOFF));
// 環 L は半径 R・不透明度 a の放射グラデーションで描く
}
環ごとにcreateRadialGradientを作り直してlighter合成で重ねると、中心の熱々な金色と外側の室内温度のローズトーンが自然に混ざります。K_BURN1.35と消費率を低く抑えた分だけ各パーティクルが長く生き、後光が細かく途切れません。
05溶岩の流れ — 格子セルに積もる熱
画面をセルに分けて隣接セル同士が熱をやり取りさせ、閾値を超えたセルだけ溶岩色で描くと、固まっては溶ける流れが低解像度格子に現れます。ゲーム背景·火山コンセプトの紹介画面が得るのは、パーティクルの飛沫ではなく地質的な遅い流れです。セルは隣接4マスの平均を取って熱を配り、T_MELT0.62を超えたマスだけが橙に溶けます。

<main class="stage">
<div class="lv is-demo" id="lvRoot">
<p class="lv__hint">
<svg class="lv__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="lv__field" id="lvField" role="application" aria-label="溶岩熱拡散格子実験盤" tabindex="0">
<canvas class="lv__cv" id="lvCv"></canvas>
</div>
<div class="lv__bar">
<span class="lv__meta"><b class="lv__val" id="lvRes">96×54</b> 熱格子・k 0.34</span>
<span class="lv__meta lv__meta--dim">ImageData · putImageData</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 용암 흐름(05). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 열 확산 시뮬레이션(putImageData)이 만든다.
// 무대는 잉크(#241b1d — 화산 바닥). 파스텔 고정 팔레트를 --fire-* 로 편 편 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-orange-deep;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(BD9~11 원칙) ──
$fire-ink: #241b1d;
$fire-amber: #f0a868;
$fire-gold: #f6cf8a;
$fire-rose: #eca0a8;
$fire-ember: #d97a52;
$fire-smoke: #c9c2c6;
.lv {
--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;
}
.lv__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;
}
.lv__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $fire-smoke; }
.lv__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;
}
.lv__field:focus-visible { outline: 2px solid $fire-amber; outline-offset: 2px; }
.lv__cv { display: block; width: 100%; height: 100%; }
.lv__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lv__meta { font-size: 10px; font-weight: 500; color: rgba($fire-smoke, .72); }
.lv__val { color: $fire-amber; font-weight: 700; }
.lv__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lv { gap: $sp-1; }
.lv__field { height: 100px; }
.lv__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 시뮬레이션 스텝만 쌓아 한 장을 남긴다.
@media (prefers-reduced-motion: reduce) {
.lv.is-still .lv__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);
}
.lv {
--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;
}
.lv__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;
}
.lv__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #c9c2c6;
}
.lv__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;
}
.lv__field:focus-visible {
outline: 2px solid #f0a868;
outline-offset: 2px;
}
.lv__cv {
display: block;
width: 100%;
height: 100%;
}
.lv__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lv__meta {
font-size: 10px;
font-weight: 500;
color: rgba(201, 194, 198, 0.72);
}
.lv__val {
color: #f0a868;
font-weight: 700;
}
.lv__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lv {
gap: 4px;
}
.lv__field {
height: 100px;
}
.lv__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lv.is-still .lv__field {
cursor: default !important;
}
}// 용암 흐름(05). 화면을 저해상도 격자(GW×GH)로 나눠 각 셀에 열 h 를 저장한다.
// 매 프레임: next[i] = cur[i] + k * (이웃평균 - cur[i]) — 이것이 확산 방정식의
// 가장 단순한 명시적 근사(라플라시안 이완). 화구(vent) 셀에 열을 계속 붓고,
// 밝기가 임곗값 T_MELT 를 넘는 셀만 용암색, 그 아래는 굳은 암석색으로
// ImageData/putImageData 로 픽셀에 직접 찍는다. 열이 퍼지고 식으며 굳는 과정이
// 격자에서 그대로 보인다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var GW = 96; // 격자 폭(셀) — 변형 "해상도" 축(48 → 144)
var K_DIFF = 0.34; // 확산 계수 — 변형 "확산" 축(0.12 → 0.40). 재튜닝(2026-09-15): 0.24→0.34 — 격자 전체가 순환에 들게
var VENT_N = 5; // 화구 수 — 변형 "화구" 축(1 → 5). 재튜닝: 3→5 — 하단 폭을 넓게 덮는다
// ────────────────────────────────────────────────────
var root = document.getElementById('lvRoot');
var field = document.getElementById('lvField');
var cv = document.getElementById('lvCv');
var ctx = cv.getContext('2d');
var resOut = document.getElementById('lvRes');
// 시드 고정 PRNG — mulberry32
var SEED = 0x5eed05;
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');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, GH = 0;
var img, buf, bctx;
var heat, heat2; // 현재/다음 열장(Float32Array GW×GH)
var vents = []; // 화구 목록 {gx, gy, amp, ph}
var raf = 0, t0 = -1, tick = 0;
var curT = 0; // 재튜닝: render() 의 잔열 셔머가 쓰는 최근 시각(step 에서 갱신)
var px01 = 0.5, py01 = 0.7; // 포인터(화구 후보 위치)
var manual = false;
var T_MELT = 0.62; // 용암 임곗값 — 이 위면 녹는다
var T_GLOW = 0.86; // 코어 임곗값 — 이 위면 하얗게 달아오른다
var COOL = 0.006; // 냉각(방사) — 매 스텝 열이 조금씩 빠져나간다
var STILL_TICK = 210; // reduce 에서 그리는 스텝 — 흐름이 읽히는 한 장
// 색 램프 — 굳은 바위(잉크↔연기) → 잔불 → 주황(amber) → 코어(gold)
// t: 0(차가움) → 1(가장 뜨거움). 4 구간 선형 보간.
var ramp = [];
(function buildRamp() {
var stops = [
[0.00, C_INK[0], C_INK[1], C_INK[2]],
[0.30, mixc(C_INK, C_SMOKE, 0.5)[0], mixc(C_INK, C_SMOKE, 0.5)[1], mixc(C_INK, C_SMOKE, 0.5)[2]],
[0.62, C_EMBER[0], C_EMBER[1], C_EMBER[2]],
[0.82, C_AMBER[0], C_AMBER[1], C_AMBER[2]],
[1.00, C_GOLD[0], C_GOLD[1], C_GOLD[2]]
];
for (var i = 0; i < 256; i++) {
var t = i / 255;
for (var s = 0; s < stops.length - 1; s++) {
if (t >= stops[s][0] && t <= stops[s + 1][0]) {
var f = (t - stops[s][0]) / (stops[s + 1][0] - stops[s][0]);
ramp.push([stops[s][1] + (stops[s + 1][1] - stops[s][1]) * f,
stops[s][2] + (stops[s + 1][2] - stops[s][2]) * f,
stops[s][3] + (stops[s + 1][3] - stops[s][3]) * f]);
break;
}
}
}
})();
function mixc(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
GH = Math.max(24, Math.round(GW * H / W));
buf = document.createElement('canvas');
buf.width = GW; buf.height = GH;
bctx = buf.getContext('2d');
img = bctx.createImageData(GW, GH);
heat = new Float32Array(GW * GH);
heat2 = new Float32Array(GW * GH);
// 화구 — 시드 고정 배치. 하단에 두어 위로 번지게(열 상승 근사: 아래 행이 위 행에 열을 더 준다)
// 재튜닝(2026-09-15): 화구 수(VENT_N)가 늘어도 폭 전체에 고르게 퍼지도록 span 으로 재계산
vents = [];
var span = VENT_N > 1 ? 0.78 / (VENT_N - 1) : 0;
for (var v = 0; v < VENT_N; v++) {
vents.push({
gx: Math.floor(GW * (0.11 + span * v + (rand() - 0.5) * 0.06)),
gy: Math.floor(GH * (0.72 + (rand() - 0.5) * 0.14)),
amp: 0.9 + rand() * 0.35,
ph: rand() * Math.PI * 2
});
}
resOut.textContent = GW + '×' + GH;
}
// ── 시뮬레이션 한 스텝 ──
// ① 화구에 열 주입(시간에 따라 맥동 — 분출이 쉬었다 불다)
// ② 확산: next[i] = cur[i] + k*(이웃평균 - cur[i])
// 이웃평균은 상하좌우 4셀 평균(경계는 클램프 — 벽은 단열)
// ③ 위쪽으로 편향 전달(부유 근사): 아래→위 열 이동을 살짝 비대칭 계수로
// ④ 냉각: h *= (1 - COOL) — 방사로 식는다. 임곗값 아래로 떨어지면 굳는다
function step(tSec) {
curT = tSec;
// ① 화구 — 맥동 진폭(0.35..1). 분출이 쉬는 구간에 흐름이 멈춘 모양이 나온다
for (var v = 0; v < vents.length; v++) {
var vt = vents[v];
var pulse = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(tSec * 1.1 + vt.ph));
for (var dy = -3; dy <= 3; dy++) {
for (var dx = -3; dx <= 3; dx++) {
var x = vt.gx + dx, y = vt.gy + dy;
if (x < 1 || x >= GW - 1 || y < 1 || y >= GH - 1) continue;
var falloff = 1 - Math.sqrt(dx * dx + dy * dy) / 4.4;
if (falloff <= 0) continue;
var idx = y * GW + x;
heat[idx] += vt.amp * pulse * 0.10 * falloff;
}
}
}
// 포인터가 살린 화구(사용자가 대는 손) — manual 일 때만
if (manual) {
var pgx = Math.floor(px01 * (GW - 2)) + 1;
var pgy = Math.floor(py01 * (GH - 2)) + 1;
if (pgx >= 1 && pgx < GW - 1 && pgy >= 1 && pgy < GH - 1) {
heat[pgy * GW + pgx] += 0.12;
}
}
// ②③ 확산 + 부유 편향
for (var yy = 1; yy < GH - 1; yy++) {
for (var xx = 1; xx < GW - 1; xx++) {
var i2 = yy * GW + xx;
var c = heat[i2];
var nU = heat[i2 - GW], nD = heat[i2 + GW], nL = heat[i2 - 1], nR = heat[i2 + 1];
// 이웃평균 — 아래 셀에 가중치를 더 준다(위로 자라는 흐름)
var avg = (nU * 0.9 + nD * 1.1 + nL + nR) / 4;
var nh = c + K_DIFF * (avg - c);
// ④ 냉각 — 매 스텝 소량. 높은 열일수록 더 식는다(방사 ∝ h)
nh = nh - nh * COOL * (0.6 + nh);
if (nh < 0) nh = 0;
if (nh > 1.4) nh = 1.4;
heat2[i2] = nh;
}
}
// 경계는 이전 값 유지(단열 벽)
var tmp = heat; heat = heat2; heat2 = tmp;
}
// ── 렌더 — 열값 → 색 램프 → ImageData 픽셀 ──
// 임곗값 이하(굳은 바위)는 어둡게, T_MELT 위는 용암색.
// 용암 경계에 로즈 톤 스트로크를 1셀 얹어 "갓 굳은 껍질"을 만든다.
function render() {
var data = img.data;
var p = 0;
for (var yy = 0; yy < GH; yy++) {
for (var xx = 0; xx < GW; xx++) {
var i2 = yy * GW + xx;
var h = heat[i2];
var t01 = h >= T_GLOW ? 1 : (h / T_GLOW);
var ci = (t01 * 255) | 0;
var col = ramp[ci > 255 ? 255 : ci];
var r = col[0], g = col[1], b = col[2];
if (h < T_MELT * 0.5) {
// 차가운 암석 — 잉크에 가깝게 눌러 바위와 용암의 대비를 만든다
// 재튜닝(2026-09-15): 잔열색(ember) 셔머를 미묘하게 얹어 정적 검은 영역을 없앤다(위치+시간 위상)
var k = h / (T_MELT * 0.5);
var shimmer = 0.05 + 0.05 * Math.sin(curT * 1.3 + xx * 0.13 + yy * 0.19);
r = C_INK[0] + (r - C_INK[0]) * k + (C_EMBER[0] - C_INK[0]) * shimmer;
g = C_INK[1] + (g - C_INK[1]) * k + (C_EMBER[1] - C_INK[1]) * shimmer;
b = C_INK[2] + (b - C_INK[2]) * k + (C_EMBER[2] - C_INK[2]) * shimmer;
} else if (h >= T_MELT && h < T_MELT + 0.06) {
// 갓 굳은 껍질 — 로즈 톤 혼합
var k2 = (h - T_MELT) / 0.06;
r = r + (C_ROSE[0] - r) * 0.35 * (1 - k2);
g = g + (C_ROSE[1] - g) * 0.35 * (1 - k2);
b = b + (C_ROSE[2] - b) * 0.35 * (1 - k2);
}
data[p] = r | 0; data[p + 1] = g | 0; data[p + 2] = b | 0; data[p + 3] = 255;
p += 4;
}
}
bctx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.clearRect(0, 0, W, H);
ctx.drawImage(buf, 0, 0, W, H);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
step(t);
render();
raf = requestAnimationFrame(frame);
}
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));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) { step(0.9); render(); }
});
field.addEventListener('pointerleave', function () {
if (manual) { px01 = 0.5; py01 = 0.7; }
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.05, 0], ArrowRight: [0.05, 0], ArrowUp: [0, -0.05], ArrowDown: [0, 0.05] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); }
px01 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
if (reduce) { step(0.9); render(); }
});
window.addEventListener('resize', function () { fit(); if (reduce) { for (var s = 0; s < STILL_TICK; s++) step(0.9); render(); } });
fit();
if (reduce) {
// reduce — rAF 없이 STILL_TICK 스텝만 시뮬레이션해 흐름이 자리 잡은 한 장을 그린다
for (var s = 0; s < STILL_TICK; s++) step(0.9);
render();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// FireLavaHeatCells — vanilla 판(run/314/demo/05-fire-lava-heat-cells/index.html) 의 React 포트.
// 저해상도 격자의 각 셀이 열을 저장하고, 매 프레임
// next[i] = cur[i] + k*(이웃평균 - cur[i]) 확산과 냉각을 반복한다.
// 밝기가 임곗값을 넘는 셀만 용암색 램프로 ImageData/putImageData 로 찍는다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FireLavaHeatCells.module.scss";
export type FireLavaHeatCellsProps = {
duration?: string;
easing?: string;
color?: string;
gridWidth?: number; // 격자 폭(셀) — 변형 "해상도" 축
kDiff?: number; // 확산 계수 — 변형 "확산" 축
ventCount?: number; // 화구 수 — 변형 "화구" 축
};
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 Vent = { gx: number; gy: number; amp: number; ph: number };
type Colors = { ink: RGB; amber: RGB; gold: RGB; rose: RGB; ember: RGB; smoke: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "용암 열 확산 격자 실험판";
const STILL_TICK = 210; // reduce 에서 쌓는 시뮬레이션 스텝 수
const T_GLOW = 0.86; // 코어 임곗값 — 램프 정점
const COOL = 0.006; // 냉각(방사) 계수
// 시드 고정 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;
};
}
function mixc(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
// 색 램프(256단계) — 굳은 바위 → 잔불 → 주황 → 코어. vanilla 판과 같은 정점표.
function buildRamp(c: Colors): RGB[] {
const s0: RGB = mixc(c.ink, c.smoke, 0.5);
const stops: Array<[number, RGB]> = [
[0.0, c.ink],
[0.3, s0],
[0.62, c.ember],
[0.82, c.amber],
[1.0, c.gold],
];
const ramp: RGB[] = [];
for (let i = 0; i < 256; i++) {
const t = i / 255;
for (let s = 0; s < stops.length - 1; s++) {
if (t >= stops[s][0] && t <= stops[s + 1][0]) {
const f = (t - stops[s][0]) / (stops[s + 1][0] - stops[s][0]);
ramp.push(mixc(stops[s][1], stops[s + 1][1], f));
break;
}
}
}
return ramp;
}
export default function FireLavaHeatCells({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#d97a52",
gridWidth = 96,
kDiff = 0.34,
ventCount = 5,
}: FireLavaHeatCellsProps) {
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 bufRef = useRef<HTMLCanvasElement | null>(null);
const bctxRef = useRef<CanvasRenderingContext2D | null>(null);
const imgRef = useRef<ImageData | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const rampRef = useRef<RGB[]>([]) as Box<RGB[]>;
const gwRef = useRef(gridWidth) as Box<number>;
const ghRef = useRef(0) as Box<number>;
const kRef = useRef(kDiff) as Box<number>;
const ventNRef = useRef(ventCount) as Box<number>;
const ventsRef = useRef<Vent[]>([]) as Box<Vent[]>;
const heatRef = useRef(new Float32Array(0)) as Box<Float32Array>;
const heat2Ref = useRef(new Float32Array(0)) as Box<Float32Array>;
const pxRef = useRef(0.5) as Box<number>;
const pyRef = useRef(0.7) 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 curTRef = useRef(0) as Box<number>; // 재튜닝: render() 잔열 셔머가 쓰는 최근 시각(step 에서 갱신)
const [res, setRes] = useState("96×54");
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 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;
const GW = gwRef.current;
const GH = Math.max(24, Math.round(GW * r.height / r.width));
ghRef.current = GH;
const buf = document.createElement("canvas");
buf.width = GW; buf.height = GH;
const bctx = buf.getContext("2d");
if (!bctx) return;
bufRef.current = buf;
bctxRef.current = bctx;
imgRef.current = bctx.createImageData(GW, GH);
heatRef.current = new Float32Array(GW * GH);
heat2Ref.current = new Float32Array(GW * GH);
// 화구 — 시드 고정 배치(하단). 재튜닝(2026-09-15): span 으로 화구 수에 맞춰 폭 전체에 고르게 배치
const rand = makePRNG(0x5eed05);
const vents: Vent[] = [];
const span = ventNRef.current > 1 ? 0.78 / (ventNRef.current - 1) : 0;
for (let v = 0; v < ventNRef.current; v++) {
vents.push({
gx: Math.floor(GW * (0.11 + span * v + (rand() - 0.5) * 0.06)),
gy: Math.floor(GH * (0.72 + (rand() - 0.5) * 0.14)),
amp: 0.9 + rand() * 0.35,
ph: rand() * TAU,
});
}
ventsRef.current = vents;
setRes(GW + "×" + GH);
}
// 시뮬레이션 한 스텝 — 화구 주입 → 확산(부유 편향) → 냉각
function step(tSec: number): void {
curTRef.current = tSec;
const GW = gwRef.current, GH = ghRef.current;
const heat = heatRef.current, heat2 = heat2Ref.current;
if (!heat || !heat2 || GW < 3 || GH < 3) return;
const vents = ventsRef.current;
for (let v = 0; v < vents.length; v++) {
const vt = vents[v];
const pulse = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(tSec * 1.1 + vt.ph));
for (let dy = -3; dy <= 3; dy++) {
for (let dx = -3; dx <= 3; dx++) {
const x = vt.gx + dx, y = vt.gy + dy;
if (x < 1 || x >= GW - 1 || y < 1 || y >= GH - 1) continue;
const falloff = 1 - Math.sqrt(dx * dx + dy * dy) / 4.4;
if (falloff <= 0) continue;
heat[y * GW + x] += vt.amp * pulse * 0.1 * falloff;
}
}
}
if (manualRef.current) {
const pgx = Math.floor(pxRef.current * (GW - 2)) + 1;
const pgy = Math.floor(pyRef.current * (GH - 2)) + 1;
if (pgx >= 1 && pgx < GW - 1 && pgy >= 1 && pgy < GH - 1) {
heat[pgy * GW + pgx] += 0.12;
}
}
const K = kRef.current;
for (let yy = 1; yy < GH - 1; yy++) {
for (let xx = 1; xx < GW - 1; xx++) {
const i = yy * GW + xx;
const c = heat[i];
const nU = heat[i - GW], nD = heat[i + GW], nL = heat[i - 1], nR = heat[i + 1];
const avg = (nU * 0.9 + nD * 1.1 + nL + nR) / 4; // 아래 셀 가중 — 위로 자라는 흐름
let nh = c + K * (avg - c);
nh = nh - nh * COOL * (0.6 + nh); // 방사 냉각 ∝ h
if (nh < 0) nh = 0;
if (nh > 1.4) nh = 1.4;
heat2[i] = nh;
}
}
const tmp = heatRef.current;
heatRef.current = heat2Ref.current;
heat2Ref.current = tmp;
}
// 렌더 — 열값 → 램프 → ImageData. T_MELT 경계에 로즈 껍질.
function render(): void {
const img = imgRef.current, bctx = bctxRef.current, buf = bufRef.current, ctx = ctxRef.current;
const col = colRef.current, ramp = rampRef.current;
if (!img || !bctx || !buf || !ctx || !col || ramp.length === 0) return;
const data = img.data;
const GW = gwRef.current, GH = ghRef.current;
const heat = heatRef.current;
const { w: W, h: H } = sizeRef.current;
const T_MELT = 0.62;
let p = 0;
for (let yy = 0; yy < GH; yy++) {
for (let xx = 0; xx < GW; xx++) {
const h = heat[yy * GW + xx];
const t01 = h >= T_GLOW ? 1 : h / T_GLOW;
let ci = (t01 * 255) | 0;
if (ci > 255) ci = 255;
let r = ramp[ci][0], g = ramp[ci][1], b = ramp[ci][2];
if (h < T_MELT * 0.5) {
const k = h / (T_MELT * 0.5);
// 재튜닝(2026-09-15): 잔열색(ember) 셔머 — 정적 검은 영역을 없앤다(위치+시간 위상)
const shimmer = 0.05 + 0.05 * Math.sin(curTRef.current * 1.3 + xx * 0.13 + yy * 0.19);
r = col.ink[0] + (r - col.ink[0]) * k + (col.ember[0] - col.ink[0]) * shimmer;
g = col.ink[1] + (g - col.ink[1]) * k + (col.ember[1] - col.ink[1]) * shimmer;
b = col.ink[2] + (b - col.ink[2]) * k + (col.ember[2] - col.ink[2]) * shimmer;
} else if (h >= T_MELT && h < T_MELT + 0.06) {
const k2 = (h - T_MELT) / 0.06;
r = r + (col.rose[0] - r) * 0.35 * (1 - k2);
g = g + (col.rose[1] - g) * 0.35 * (1 - k2);
b = b + (col.rose[2] - b) * 0.35 * (1 - k2);
}
data[p] = r | 0; data[p + 1] = g | 0; data[p + 2] = b | 0; data[p + 3] = 255;
p += 4;
}
}
bctx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.clearRect(0, 0, W, H);
ctx.drawImage(buf, 0, 0, W, H);
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
step(t);
render();
rafRef.current = requestAnimationFrame(frame);
}
function onPoint(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));
pyRef.current = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manualRef.current) manualRef.current = true;
if (reduced()) { step(0.9); render(); }
}
function onLeave(): void {
if (manualRef.current) { pxRef.current = 0.5; pyRef.current = 0.7; }
}
function onKeyDown(e: KeyEvt): void {
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-0.05, 0], ArrowRight: [0.05, 0], ArrowUp: [0, -0.05], ArrowDown: [0, 0.05],
};
const d = deltas[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) manualRef.current = true;
pxRef.current = Math.max(0, Math.min(1, pxRef.current + d[0]));
pyRef.current = Math.max(0, Math.min(1, pyRef.current + d[1]));
if (reduced()) { step(0.9); render(); }
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
gwRef.current = gridWidth;
kRef.current = kDiff;
ventNRef.current = ventCount;
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"),
};
rampRef.current = buildRamp(colRef.current);
fit();
function onResize(): void {
fit();
if (reduced()) { for (let s = 0; s < STILL_TICK; s++) step(0.9); render(); }
}
window.addEventListener("resize", onResize);
if (reduced()) {
for (let s = 0; s < STILL_TICK; s++) step(0.9);
render();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gridWidth, kDiff, ventCount]);
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}
onPointerMove={onPoint}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{res}</b> 열 격자 · k {kDiff}</span>
<span className={styles.metaDim}>ImageData · putImageData</span>
</div>
</div>
</main>
);
}05 · 溶岩の流れ — 格子セルに積もる熱 — 計測 累積面積 39.884% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
隣接平均と放射冷却の拡散規則
for (var yy = 1; yy < GH - 1; yy++) {
for (var xx = 1; xx < GW - 1; xx++) {
var i2 = yy * GW + xx;
var c = heat[i2];
var nU = heat[i2 - GW], nD = heat[i2 + GW], nL = heat[i2 - 1], nR = heat[i2 + 1];
var avg = (nU * 0.9 + nD * 1.1 + nL + nR) / 4;
var nh = c + K_DIFF * (avg - c);
nh = nh - nh * COOL * (0.6 + nh); // 熱いセルほど強く冷える
heat2[i2] = nh < 0 ? 0 : (nh > 1.4 ? 1.4 : nh);
}
}
隣接4マスの平均を取るとき下側を0.9·1.1と非対称に重み付けすると、熱は拡散するだけでなく上へ育つ流れに見えます。K_DIFF0.34は格子全体が循環に入るように調整された値で、VENT_N5つの火口が下端の幅全体に均等に並びます。
06花火の爆発 — 重力と残り火の算数
一点から飛び出した破片が重力で放物線を描き、色温度の階段を下って消える — この規則だけで夜空の爆発が完成します。お祝い·年末LPのハイライトは、この落下から来ています。PER_BURST170個の破片が環状の角度で飛び出し、GRAV34がすべてを引き下げます。

<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 に同梱 · パスワードを見る
ひとつの爆発、環状の角度と二段の速度
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;
placed++;
}
}
GRAV34が破片を引き下げ、DRAG0.985が毎フレーム速度を削って軌道を丸くたわませます。5個に1個はshell0.62の内環に回り、二段の速度差が爆発の立体感を作ります。MAXP1,700は同時多発発射に耐えるプールです。
07ろうそくと風 — ポインタへ寝る炎
ポインター位置から炎までの方向を風ベクトルに入れ、距離で強さを減衰させると、手を近づけるたびに寝るろうそくになります。インタラクティブカード·特別な瞬間の飾りが、実際のカーソルに反応します。pointermoveの実座標を読むのはこの項目だけで、力はMath.pow(60 / dist, FALLOFF)で遠ざかるほど急に弱まります。

<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="M184 184a32 32 0 0 1-32 32c-13.7 0-26.95-8.93-31.5-21.22a8 8 0 0 1 15-5.56C137.74 195.27 145 200 152 200a16 16 0 0 0 0-32H40a8 8 0 0 1 0-16h112a32 32 0 0 1 32 32m-64-80a32 32 0 0 0 0-64c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C105.74 60.73 113 56 120 56a16 16 0 0 1 0 32H24a8 8 0 0 0 0 16Zm88-32c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C193.74 92.73 201 88 208 88a16 16 0 0 1 0 32H32a8 8 0 0 0 0 16h176a32 32 0 0 0 0-64" opacity=".2"/><path d="M184 184a32 32 0 0 1-32 32c-13.7 0-26.95-8.93-31.5-21.22a8 8 0 0 1 15-5.56C137.74 195.27 145 200 152 200a16 16 0 0 0 0-32H40a8 8 0 0 1 0-16h112a32 32 0 0 1 32 32m-64-80a32 32 0 0 0 0-64c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C105.74 60.73 113 56 120 56a16 16 0 0 1 0 32H24a8 8 0 0 0 0 16Zm88-32c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C193.74 92.73 201 88 208 88a16 16 0 0 1 0 32H32a8 8 0 0 0 0 16h176a32 32 0 0 0 0-64"/></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="fwWind">そよ風</b> 風・パーティクル220</span>
<span class="fw__meta fw__meta--dim">pointermove実測・距離の二乗反比例減衰</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 촛불과 바람(07). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(포인터 실측 바람 벡터 + 입자 부력)가 만든다.
// 무대는 잉크(#241b1d — 어두운 실내). 파스텔 고정 팔레트를 --fire-* 로 편 공통 규격(01~09 동일).
$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 {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --fire-* 를 받아 쓴다
--fire-ink: #{$fire-ink};
--fire-amber: #{$fire-amber};
--fire-gold: #{$fire-gold};
--fire-rose: #{$fire-rose};
--fire-ember: #{$fire-ember};
--fire-smoke: #{$fire-smoke};
// 반경은 SCSS 토큰으로만 — var(--radius-*) 변수 선언은 BD17 위반(04-06 서브 실측 정정)
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 — 루프가 아예 안 돈다. pointermove 리스너도 붙이지 않으니
// 커서 신호만 거둔다. 곧은 불꽃 한 장이 남는다.
@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;
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;
}
}// 촛불과 風(07). 이 편에서 유일하게 pointermove 실측 입력을 받는 항목이다 —
// 미리보기(자동 재생)는 시드 고정 가상 포인터로 같은 경로를 돌지만, 사람이 손을 올리면
// pointermove 핸들러가 실제 포인터 좌표를 읽어 그 자리에서부터 촛불 심지까지의
// 방향 벡터를 바람으로 쓴다. 세기는 거리 역제곱으로 감쇠 — 손이 가까우면 강풍, 멀면 軽い風.
// 포인터가 판을 떠나거나 2.4초 이상 움직이지 않으면 시드 기반 기본 미풍으로 되돌아간다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N_PART = 220; // 불꽃 입자 수 — 변형 "파티클 수" 축(240 → 480 기준의 밀도)
var WIND_MAX = 3.2; // 風 최대 가속(px/frame^2) — 포인터가 심지에 붙었을 때의 순간 세기
var FALLOFF = 2.0; // 거리 감쇠 지수 — 변형 "민감도" 축(1 = 역비례, 2 = 역제곱)
// ────────────────────────────────────────────────────
var root = document.getElementById('fwRoot');
var field = document.getElementById('fwField');
var cv = document.getElementById('fwCv');
var ctx = cv.getContext('2d');
var windOut = document.getElementById('fwWind');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 불꽃으로 살아 움직인다
var SEED = 0x5eed07;
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 로 읽는다 — 파스텔 토큰이 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_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');
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 windX = 0, windY = 0; // 현재 風 가속 벡터(px/frame^2)
var tgtWX = 0, tgtWY = 0; // 목표 風(감쇠 적용된 값) — lerp 로 부드럽게 좇는다
var px = -1, py = -1; // 마지막 실측 포인터(판 좌표). -1 이면 판 밖
var manualUntil = -1; // 이 시각(ms)까지는 실측 바람을 쓴다
var IDLE_MS = 2400; // 이 이상 조용하면 기본 미풍으로 복귀
var LOOP_MS = 2000; // 미리보기 가상 포인터 주기
// ── 불꽃 입자 — 연료 k(1→0) 를 태우며 심지에서 태어나 부력으로 오른다 ──
// 바람은 매 프레임 전 입자 가속도에 더해진다. k 가 줄수록 색이 gold→amber→ember 로 식는다.
var parts = [];
function spawn() {
// 심지 위 넓은 분포 — 촛불 심지 폭
return {
x: W * 0.5 + (rand() - 0.5) * 10,
y: H * 0.62,
vx: (rand() - 0.5) * 0.5,
vy: -(0.5 + rand() * 0.8),
k: 0.85 + rand() * 0.15, // 연료 — 태어날 때 남은 만큼 오래 탄다
r: 5 + rand() * 5,
seed: rand() * TAU // 흔들림 위상 — 입자마다 다른 위상으로 불꽃이 뭉치지 않는다
};
}
for (var i = 0; i < N_PART; i++) {
var p0 = spawn();
p0.y = H * 0.62 - rand() * 60; // 첫 프레임부터 어느 정도 오른 불꽃
p0.k = rand();
parts.push(p0);
}
// 風 라벨 — 현재 세기를 눈으로 읽게 한다
function windLabel(mag) {
if (mag < 0.25) return 'そよ風';
if (mag < 0.9) return '軽い風';
if (mag < 1.8) return '強い風';
return '突風';
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
}
// ── 포인터 실측 → 風 벡터 ──
// 방향: 포인터 위치 → 촛불 심지. 포인터가 오른쪽에 있으면 불꽃이 오른쪽에서
// 불어오는 風(=심지에서 포인터 쪽으로 눕는다)이 아니라, 포인터가 "손"이 되어
// 심지 쪽으로 바람을 밀어 보낸다. 세기: 거리 역제곱 + 상한 클램프.
function pointerWind() {
if (px < 0 || py < 0) return [0, 0];
var wickX = W * 0.5, wickY = H * 0.62;
var dx = wickX - px, dy = wickY - py;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1) return [0, -WIND_MAX]; // 바로 위 — 위로 치솟는다
var norm = 1 / dist;
var s = Math.min(WIND_MAX, WIND_MAX * Math.pow(60 / dist, FALLOFF));
return [dx * norm * s, dy * norm * s];
}
// 기본 そよ風 — 시드 고정 노이즈 두 층(느린 파 + 빠른 파)으로 좌우로 살랑인다.
// rand() 를 프레임마다 소비하면 결정론이 깨지므로 시간의 삼각파로 만든다
function breeze(tSec) {
return Math.sin(tSec * 1.1) * 0.16 + Math.sin(tSec * 2.9 + 1.7) * 0.1;
}
function draw(tSec) {
// 심지·초 몸통 — 캔버스에 직접 그린다(정지 부품은 매 프레임 같은 자리)
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
// 초 몸통 — 연기 톤 둥근 사각(모서리 반경 20px — $r-card 와 같은 값, 캔버스 그리기)
ctx.fillStyle = 'rgba(' + C_SMOKE[0] + ',' + C_SMOKE[1] + ',' + C_SMOKE[2] + ',0.22)';
var bodyW = 34, bodyH = 64, bx = W * 0.5 - bodyW / 2, by = H * 0.62;
ctx.beginPath();
ctx.moveTo(bx + 20, by);
ctx.lineTo(bx + bodyW - 20, by);
ctx.quadraticCurveTo(bx + bodyW, by, bx + bodyW, by + 20);
ctx.lineTo(bx + bodyW, by + bodyH - 20);
ctx.quadraticCurveTo(bx + bodyW, by + bodyH, bx + bodyW - 20, by + bodyH);
ctx.lineTo(bx + 20, by + bodyH);
ctx.quadraticCurveTo(bx, by + bodyH, bx, by + bodyH - 20);
ctx.lineTo(bx, by + 20);
ctx.quadraticCurveTo(bx, by, bx + 20, by);
ctx.fill();
// 심지
ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',0.8)';
ctx.fillRect(W * 0.5 - 1.5, H * 0.62 - 10, 3, 10);
// ── 風 계산 — 실측 우선, 오래 없으면 そよ風 ──
var now = t0 < 0 ? 0 : performance.now();
var pw;
if (t0 >= 0 && now < manualUntil && px >= 0) {
pw = pointerWind(); // 실측 pointermove 風
} else {
pw = [breeze(tSec), -0.05]; // 기본 そよ風(거의 세로 부력만)
}
// 목표를 lerp 로 좇는다 — 바람이 갑자기 튀지 않고 스며든다
tgtWX = pw[0]; tgtWY = pw[1];
windX += (tgtWX - windX) * 0.12;
windY += (tgtWY - windY) * 0.12;
var mag = Math.sqrt(windX * windX + windY * windY);
windOut.textContent = windLabel(mag);
// ── 앙비언트 글로우 — 불꽃이 판 전체에 뿌리는 빛 ──
// 고정 배경이 아니라 매 프레임 재계산한다: 알파는 플리커(불꽃 에너지의 요동)에 연동돼
// 펄스 치고, 광원 중심은 바람에 불꽃이 눕는 만큼 치우친다. lighter 로 덧그려 발광처럼 쌓는다.
var flick = 0.5 + 0.22 * Math.sin(tSec * 3.1) + 0.16 * Math.sin(tSec * 7.3 + 1.1) + 0.08 * Math.sin(tSec * 13.7 + 4.2);
flick = Math.max(0.12, Math.min(1, flick));
var ga = 0.18 + 0.34 * flick;
var gx = W * 0.5 + windX * 9; // 風 벡터 → 광원 치우침(포인터 실측 그대로 반영)
var gyc = H * 0.62 - 14;
var gR = W * 0.62; // 캔버스 폭 62% 반경 — 방 전체를 스치는 빛
var amb = ctx.createRadialGradient(gx, gyc, 0, gx, gyc, gR);
amb.addColorStop(0, 'rgba(' + C_AMBER[0] + ',' + C_AMBER[1] + ',' + C_AMBER[2] + ',' + ga.toFixed(3) + ')');
amb.addColorStop(0.55, 'rgba(' + C_AMBER[0] + ',' + C_AMBER[1] + ',' + C_AMBER[2] + ',' + (ga * 0.62).toFixed(3) + ')');
amb.addColorStop(1, 'rgba(' + C_AMBER[0] + ',' + C_AMBER[1] + ',' + C_AMBER[2] + ',0)');
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = amb;
ctx.fillRect(0, 0, W, H);
// ── 입자 물리 — 부력 + 風 + 위상 흔들림 ──
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
p.k -= 0.012;
if (p.k <= 0) {
var np = spawn();
p.x = np.x; p.y = np.y; p.vx = np.vx; p.vy = np.vy;
p.k = np.k; p.r = np.r; p.seed = np.seed;
continue;
}
// 부력은 연료가 남은 만큼 — 식으면서 가벼워진다
p.vy -= 0.018 * p.k;
// 風 가속 + 입자별 위상 흔들림(불꽃 내부 난류)
p.vx += windX * 0.014 + Math.sin(tSec * 7 + p.seed) * 0.03;
p.vy += windY * 0.014;
// 항력 — 꼬리가 늘어지지 않게
p.vx *= 0.955; p.vy *= 0.965;
p.x += p.vx; p.y += p.vy;
// 색온도 — k 가 1(갓 태어남·gold)에서 0(다 탐·ember)으로
var cr, cg, cb;
if (p.k > 0.55) {
var u1 = (p.k - 0.55) / 0.45;
cr = C_GOLD[0] + (C_AMBER[0] - C_GOLD[0]) * (1 - u1);
cg = C_GOLD[1] + (C_AMBER[1] - C_GOLD[1]) * (1 - u1);
cb = C_GOLD[2] + (C_AMBER[2] - C_GOLD[2]) * (1 - u1);
} else {
var u2 = p.k / 0.55;
cr = C_EMBER[0] + (C_AMBER[0] - C_EMBER[0]) * u2;
cg = C_EMBER[1] + (C_AMBER[1] - C_EMBER[1]) * u2;
cb = C_EMBER[2] + (C_AMBER[2] - C_EMBER[2]) * u2;
}
var rad = p.r * (0.35 + p.k * 0.65);
var a = 0.1 + p.k * 0.32;
// 코어 입자 일부는 로즈 변형 — 파스텔 팔레트의 로즈 톤을 섞어 촛불 특유의 따뜻함
if (p.seed > 5.4 && p.k > 0.5) {
cr = C_ROSE[0]; cg = C_ROSE[1]; cb = C_ROSE[2];
}
var g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, rad * 2.2);
g.addColorStop(0, 'rgba(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ',' + a.toFixed(3) + ')');
g.addColorStop(1, 'rgba(' + (cr | 0) + ',' + (cg | 0) + ',' + (cb | 0) + ',0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(p.x, p.y, rad * 2.2, 0, TAU);
ctx.fill();
}
// 심지 광점 — 불꽃 밑동의 밝은 코어
var wickG = ctx.createRadialGradient(W * 0.5, H * 0.62 - 6, 0, W * 0.5, H * 0.62 - 6, 22);
wickG.addColorStop(0, 'rgba(' + C_GOLD[0] + ',' + C_GOLD[1] + ',' + C_GOLD[2] + ',0.5)');
wickG.addColorStop(1, 'rgba(' + C_GOLD[0] + ',' + C_GOLD[1] + ',' + C_GOLD[2] + ',0)');
ctx.fillStyle = wickG;
ctx.beginPath();
ctx.arc(W * 0.5, H * 0.62 - 6, 22, 0, TAU);
ctx.fill();
ctx.globalCompositeOperation = 'source-over';
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
// 미리보기 가상 포인터 — 사람 손이 없을 때만(시드 고정 리사주 경로).
// 실 pointermove 가 들어온 순간(manualUntil 갱신) 이 경로는 끊긴다.
if (now >= manualUntil) {
var ph = ((now - t0) % LOOP_MS) / LOOP_MS * TAU;
px = W * (0.5 + 0.38 * Math.sin(ph));
py = H * (0.5 + 0.3 * Math.sin(2 * ph + 0.7));
root.classList.add('is-demo');
} else {
root.classList.remove('is-demo');
}
draw(t);
raf = requestAnimationFrame(frame);
}
// ── 실사용 pointermove 프로브 — 이 편의 유일한 실측 입력 ──
// CSS :hover 가 아니라 JS 로 좌표를 읽어 불꽃 입자 가속도에 실시간 반영한다.
// reduce 모드에선 rAF 루프가 없어 실시간 추적이 무의미하므로 아예 붙이지 않는다(정적 한 장만 남는다).
if (!reduce) {
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
px = e.clientX - r.left;
py = e.clientY - r.top;
if (px < 0 || py < 0 || px > r.width || py > r.height) { px = -1; py = -1; return; }
manualUntil = performance.now() + IDLE_MS;
root.classList.remove('is-demo');
});
field.addEventListener('pointerleave', function () {
px = -1; py = -1;
manualUntil = performance.now() + 600; // 떠나면 곧 미풍으로
});
// 터치 — pointermove 가 touch 에도 같은 이벤트로 온다(touch-action: none 이 스크롤을 막는다)
field.addEventListener('pointerdown', function (e) {
var r = field.getBoundingClientRect();
px = e.clientX - r.left; py = e.clientY - r.top;
manualUntil = performance.now() + IDLE_MS;
root.classList.remove('is-demo');
});
}
// 키보드 화살표는 reduce 에서도 최소한으로 남긴다(접근성 — 정적 한 장을 한 번 다시 그리는 것뿐, 루프 아님)
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-16, 0], ArrowRight: [16, 0], ArrowUp: [0, -16], ArrowDown: [0, 16] }[e.key];
if (!d) return;
e.preventDefault();
if (px < 0) { px = W * 0.5; py = H * 0.4; }
px = Math.max(0, Math.min(W, px + d[0]));
py = Math.max(0, Math.min(H, py + d[1]));
manualUntil = performance.now() + IDLE_MS;
root.classList.remove('is-demo');
if (reduce) draw(0.8);
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(0.8); });
fit();
if (reduce) {
// reduce — 루프 없이 風 없는 곧은 불꽃 한 장. pointermove 리스너도 붙이지 않는다.
root.classList.remove('is-demo');
root.classList.add('is-still');
windX = 0; windY = 0;
draw(0.8);
} else {
raf = requestAnimationFrame(frame);
}
})();// FireWindPointerLean — vanilla 판(run/314/demo/07-fire-wind-pointer-lean/index.html) 의 React 포트.
// 이 편에서 유일하게 pointermove 실측 입력을 받는 항목이다 — 포인터→심지 방향 벡터를
// 거리 역제곱 감쇠로 바람 가속에 넣고, 불꽃 입자(연료 k 소모 + 부력)가 그 바람에 눕는다.
// 포인터가 판을 떠나거나 오래 없으면 시드 기반 기본 미풍으로 복귀한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FireWindPointerLean.module.scss";
export type FireWindPointerLeanProps = {
duration?: string;
easing?: string;
color?: string;
particles?: number; // 불꽃 입자 수 — 변형 "파티클 수" 축
windMax?: number; // 바람 최대 가속 — 포인터가 심지에 붙었을 때의 순간 세기
falloff?: number; // 거리 감쇠 지수 — 변형 "민감도" 축(1 = 역비례, 2 = 역제곱)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type PointerEvt = { clientX: number; clientY: number };
type KeyEvt = { key: string; preventDefault(): void };
type RGB = [number, number, number];
type Particle = { x: number; y: number; vx: number; vy: number; k: number; r: number; seed: number };
type Cols = { ink: RGB; amber: RGB; gold: RGB; rose: RGB; ember: RGB; smoke: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "촛불 바람 실험판";
const IDLE_MS = 2400; // 이 이상 조용하면 기본 미풍으로 복귀
const LOOP_MS = 2000; // 미리보기 가상 포인터 주기
const STILL_T = 0.8; // reduce 에서 그리는 시각 — 곧은 불꽃 한 장
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 불꽃으로 살아 움직인다
const SEED = 0x5eed07;
let prngState = SEED >>> 0;
function rand(): number {
prngState = (prngState + 0x6D2B79F5) >>> 0;
let 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;
}
function windLabel(mag: number): string {
if (mag < 0.25) return "미풍";
if (mag < 0.9) return "산들바람";
if (mag < 1.8) return "강한 바람";
return "돌풍";
}
export default function FireWindPointerLean({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f0a868",
particles = 220,
windMax = 3.2,
falloff = 2.0,
}: FireWindPointerLeanProps) {
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<Cols | null>(null);
const nRef = useRef(particles) as Box<number>;
const windMaxRef = useRef(windMax) as Box<number>;
const falloffRef = useRef(falloff) as Box<number>;
const partsRef = useRef<Particle[]>([]) as Box<Particle[]>;
const windRef = useRef({ x: 0, y: 0 }) as Box<{ x: number; y: number }>;
const ptrRef = useRef({ x: -1, y: -1 }) as Box<{ x: number; y: number }>;
const manualUntilRef = useRef(-1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const [wind, setWind] = useState("미풍");
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// 색은 CSS 커스텀 프로퍼티(--fire-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const size = sizeRef.current;
const r = field.getBoundingClientRect();
size.w = r.width; size.h = r.height;
cv.width = Math.round(size.w); cv.height = Math.round(size.h);
ctxRef.current = ctx;
// 입자 풀 — 크기가 바뀌면 다시 채운다
const parts = partsRef.current;
const w = size.w, h = size.h;
while (parts.length < nRef.current) {
const p0: Particle = spawn(w, h);
p0.y = h * 0.62 - rand() * 60;
p0.k = rand();
parts.push(p0);
}
parts.length = Math.min(parts.length, nRef.current);
}
function spawn(w: number, h: number): Particle {
// 심지 위 넓은 분포 — 촛불 심지 폭
return {
x: w * 0.5 + (rand() - 0.5) * 10,
y: h * 0.62,
vx: (rand() - 0.5) * 0.5,
vy: -(0.5 + rand() * 0.8),
k: 0.85 + rand() * 0.15, // 연료 — 태어날 때 남은 만큼 오래 탄다
r: 5 + rand() * 5,
seed: rand() * TAU // 흔들림 위상 — 입자마다 다른 위상으로 불꽃이 뭉치지 않는다
};
}
// ── 포인터 실측 → 바람 벡터 ──
// 방향: 포인터 위치 → 촛불 심지. 세기: 거리 역제곱 + 상한 클램프.
function pointerWind(): [number, number] {
const { w, h } = sizeRef.current;
const px = ptrRef.current.x, py = ptrRef.current.y;
if (px < 0 || py < 0) return [0, 0];
const wickX = w * 0.5, wickY = h * 0.62;
const dx = wickX - px, dy = wickY - py;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1) return [0, -windMaxRef.current]; // 바로 위 — 위로 치솟는다
const norm = 1 / dist;
const s = Math.min(windMaxRef.current, windMaxRef.current * Math.pow(60 / dist, falloffRef.current));
return [dx * norm * s, dy * norm * s];
}
// 기본 미풍 — 두 층의 느린/빠른 파로 좌우로 살랑인다(결정론 — rand 를 안 쓴다)
function breeze(tSec: number): number {
return Math.sin(tSec * 1.1) * 0.16 + Math.sin(tSec * 2.9 + 1.7) * 0.1;
}
function draw(tSec: number): void {
const ctx = ctxRef.current, col = colRef.current;
if (!ctx || !col) return;
const { w: W, h: H } = sizeRef.current;
const wind = windRef.current;
// 심지·초 몸통 — 캔버스에 직접 그린다(정지 부품은 매 프레임 같은 자리)
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "rgb(" + col.ink[0] + "," + col.ink[1] + "," + col.ink[2] + ")";
ctx.fillRect(0, 0, W, H);
// 초 몸통 — 연기 톤 둥근 사각(모서리 반경 20px — $r-card 와 같은 값, 캔버스 그리기)
ctx.fillStyle = "rgba(" + col.smoke[0] + "," + col.smoke[1] + "," + col.smoke[2] + ",0.22)";
const bodyW = 34, bodyH = 64, bx = W * 0.5 - bodyW / 2, by = H * 0.62;
ctx.beginPath();
ctx.moveTo(bx + 20, by);
ctx.lineTo(bx + bodyW - 20, by);
ctx.quadraticCurveTo(bx + bodyW, by, bx + bodyW, by + 20);
ctx.lineTo(bx + bodyW, by + bodyH - 20);
ctx.quadraticCurveTo(bx + bodyW, by + bodyH, bx + bodyW - 20, by + bodyH);
ctx.lineTo(bx + 20, by + bodyH);
ctx.quadraticCurveTo(bx, by + bodyH, bx, by + bodyH - 20);
ctx.lineTo(bx, by + 20);
ctx.quadraticCurveTo(bx, by, bx + 20, by);
ctx.fill();
// 심지
ctx.fillStyle = "rgba(" + col.ink[0] + "," + col.ink[1] + "," + col.ink[2] + ",0.8)";
ctx.fillRect(W * 0.5 - 1.5, H * 0.62 - 10, 3, 10);
// ── 바람 계산 — 실측 우선, 오래 없으면 미풍 ──
const now = performance.now();
let pw: [number, number];
if (t0Ref.current >= 0 && now < manualUntilRef.current && ptrRef.current.x >= 0) {
pw = pointerWind(); // 실측 pointermove 바람
} else {
pw = [breeze(tSec), -0.05]; // 기본 미풍(거의 세로 부력만)
}
// 목표를 lerp 로 좇는다 — 바람이 갑자기 튀지 않고 스며든다
wind.x += (pw[0] - wind.x) * 0.12;
wind.y += (pw[1] - wind.y) * 0.12;
const mag = Math.sqrt(wind.x * wind.x + wind.y * wind.y);
setWind(windLabel(mag));
// ── 앙비언트 글로우 — 불꽃이 판 전체에 뿌리는 빛 ──
// 고정 배경이 아니라 매 프레임 재계산한다: 알파는 플리커(불꽃 에너지의 요동)에 연동돼
// 펄스 치고, 광원 중심은 바람에 불꽃이 눕는 만큼 치우친다. lighter 로 덧그려 발광처럼 쌓는다.
let flick = 0.5 + 0.22 * Math.sin(tSec * 3.1) + 0.16 * Math.sin(tSec * 7.3 + 1.1) + 0.08 * Math.sin(tSec * 13.7 + 4.2);
flick = Math.max(0.12, Math.min(1, flick));
const ga = 0.18 + 0.34 * flick;
const gx = W * 0.5 + wind.x * 9; // 바람 벡터 → 광원 치우침(포인터 실측 그대로 반영)
const gyc = H * 0.62 - 14;
const gR = W * 0.62; // 캔버스 폭 62% 반경 — 방 전체를 스치는 빛
const amb = ctx.createRadialGradient(gx, gyc, 0, gx, gyc, gR);
amb.addColorStop(0, "rgba(" + col.amber[0] + "," + col.amber[1] + "," + col.amber[2] + "," + ga.toFixed(3) + ")");
amb.addColorStop(0.55, "rgba(" + col.amber[0] + "," + col.amber[1] + "," + col.amber[2] + "," + (ga * 0.62).toFixed(3) + ")");
amb.addColorStop(1, "rgba(" + col.amber[0] + "," + col.amber[1] + "," + col.amber[2] + ",0)");
ctx.globalCompositeOperation = "lighter";
ctx.fillStyle = amb;
ctx.fillRect(0, 0, W, H);
// ── 입자 물리 — 부력 + 바람 + 위상 흔들림 ──
ctx.globalCompositeOperation = "lighter";
const parts = partsRef.current;
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
p.k -= 0.012;
if (p.k <= 0) {
const np = spawn(W, H);
p.x = np.x; p.y = np.y; p.vx = np.vx; p.vy = np.vy;
p.k = np.k; p.r = np.r; p.seed = np.seed;
continue;
}
// 부력은 연료가 남은 만큼 — 식으면서 가벼워진다
p.vy -= 0.018 * p.k;
// 바람 가속 + 입자별 위상 흔들림(불꽃 내부 난류)
p.vx += wind.x * 0.014 + Math.sin(tSec * 7 + p.seed) * 0.03;
p.vy += wind.y * 0.014;
// 항력 — 꼬리가 늘어지지 않게
p.vx *= 0.955; p.vy *= 0.965;
p.x += p.vx; p.y += p.vy;
// 색온도 — k 가 1(갓 태어남·gold)에서 0(다 탐·ember)으로
let cr: number, cg: number, cb: number;
if (p.k > 0.55) {
const u1 = (p.k - 0.55) / 0.45;
cr = col.gold[0] + (col.amber[0] - col.gold[0]) * (1 - u1);
cg = col.gold[1] + (col.amber[1] - col.gold[1]) * (1 - u1);
cb = col.gold[2] + (col.amber[2] - col.gold[2]) * (1 - u1);
} else {
const u2 = p.k / 0.55;
cr = col.ember[0] + (col.amber[0] - col.ember[0]) * u2;
cg = col.ember[1] + (col.amber[1] - col.ember[1]) * u2;
cb = col.ember[2] + (col.amber[2] - col.ember[2]) * u2;
}
const rad = p.r * (0.35 + p.k * 0.65);
const a = 0.1 + p.k * 0.32;
// 코어 입자 일부는 로즈 변형 — 파스텔 팔레트의 로즈 톤을 섞어 촛불 특유의 따뜻함
if (p.seed > 5.4 && p.k > 0.5) {
cr = col.rose[0]; cg = col.rose[1]; cb = col.rose[2];
}
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, rad * 2.2);
g.addColorStop(0, "rgba(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + "," + a.toFixed(3) + ")");
g.addColorStop(1, "rgba(" + (cr | 0) + "," + (cg | 0) + "," + (cb | 0) + ",0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(p.x, p.y, rad * 2.2, 0, TAU);
ctx.fill();
}
// 심지 광점 — 불꽃 밑동의 밝은 코어
const wickG = ctx.createRadialGradient(W * 0.5, H * 0.62 - 6, 0, W * 0.5, H * 0.62 - 6, 22);
wickG.addColorStop(0, "rgba(" + col.gold[0] + "," + col.gold[1] + "," + col.gold[2] + ",0.5)");
wickG.addColorStop(1, "rgba(" + col.gold[0] + "," + col.gold[1] + "," + col.gold[2] + ",0)");
ctx.fillStyle = wickG;
ctx.beginPath();
ctx.arc(W * 0.5, H * 0.62 - 6, 22, 0, TAU);
ctx.fill();
ctx.globalCompositeOperation = "source-over";
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
const { w: W, h: H } = sizeRef.current;
// 미리보기 가상 포인터 — 사람 손이 없을 때만(시드 고정 리사주 경로)
if (now >= manualUntilRef.current) {
const ph = (((now - t0Ref.current) % LOOP_MS) / LOOP_MS) * TAU;
ptrRef.current.x = W * (0.5 + 0.38 * Math.sin(ph));
ptrRef.current.y = H * (0.5 + 0.3 * Math.sin(2 * ph + 0.7));
}
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
// ── 실사용 pointermove 프로브 — 이 편의 유일한 실측 입력 ──
function onPoint(e: PointerEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const px = e.clientX - r.left;
const py = e.clientY - r.top;
if (px < 0 || py < 0 || px > r.width || py > r.height) {
ptrRef.current.x = -1; ptrRef.current.y = -1;
return;
}
ptrRef.current.x = px; ptrRef.current.y = py;
manualUntilRef.current = performance.now() + IDLE_MS;
if (reduced()) draw(STILL_T);
}
function onLeave(): void {
ptrRef.current.x = -1; ptrRef.current.y = -1;
manualUntilRef.current = performance.now() + 600; // 떠나면 곧 미풍으로
}
function onDown(e: PointerEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
ptrRef.current.x = e.clientX - r.left;
ptrRef.current.y = e.clientY - r.top;
manualUntilRef.current = performance.now() + IDLE_MS;
}
function onKeyDown(e: KeyEvt): void {
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-16, 0], ArrowRight: [16, 0], ArrowUp: [0, -16], ArrowDown: [0, 16],
};
const d = deltas[e.key];
if (!d) return;
e.preventDefault();
const { w: W, h: H } = sizeRef.current;
const ptr = ptrRef.current;
if (ptr.x < 0) { ptr.x = W * 0.5; ptr.y = H * 0.4; }
ptr.x = Math.max(0, Math.min(W, ptr.x + d[0]));
ptr.y = Math.max(0, Math.min(H, ptr.y + d[1]));
manualUntilRef.current = performance.now() + IDLE_MS;
if (reduced()) draw(STILL_T);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nRef.current = particles;
windMaxRef.current = windMax;
falloffRef.current = falloff;
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"),
};
fit();
function onResize(): void {
fit();
if (reduced()) draw(STILL_T);
}
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프 없이 바람 없는 곧은 불꽃 한 장
windRef.current.x = 0; windRef.current.y = 0;
draw(STILL_T);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [particles, windMax, falloff]);
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="M184 184a32 32 0 0 1-32 32c-13.7 0-26.95-8.93-31.5-21.22a8 8 0 0 1 15-5.56C137.74 195.27 145 200 152 200a16 16 0 0 0 0-32H40a8 8 0 0 1 0-16h112a32 32 0 0 1 32 32m-64-80a32 32 0 0 0 0-64c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C105.74 60.73 113 56 120 56a16 16 0 0 1 0 32H24a8 8 0 0 0 0 16Zm88-32c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C193.74 92.73 201 88 208 88a16 16 0 0 1 0 32H32a8 8 0 0 0 0 16h176a32 32 0 0 0 0-64" opacity=".2"/><path d="M184 184a32 32 0 0 1-32 32c-13.7 0-26.95-8.93-31.5-21.22a8 8 0 0 1 15-5.56C137.74 195.27 145 200 152 200a16 16 0 0 0 0-32H40a8 8 0 0 1 0-16h112a32 32 0 0 1 32 32m-64-80a32 32 0 0 0 0-64c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C105.74 60.73 113 56 120 56a16 16 0 0 1 0 32H24a8 8 0 0 0 0 16Zm88-32c-13.7 0-26.95 8.93-31.5 21.22a8 8 0 0 0 15 5.56C193.74 92.73 201 88 208 88a16 16 0 0 1 0 32H32a8 8 0 0 0 0 16h176a32 32 0 0 0 0-64"/></g></svg>
<span>포인터에서 촛불 쪽으로 향하는 실제 바람 벡터에 불꽃이 눕습니다 — 가까울수록 세게</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={still ? undefined : onPoint}
onPointerLeave={still ? undefined : onLeave}
onPointerDown={still ? undefined : onDown}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{wind}</b> 바람 · 입자 {particles}</span>
<span className={styles.metaDim}>pointermove 실측 · 거리 역제곱 감쇠</span>
</div>
</div>
</main>
);
}07 · ろうそくと風 — ポインタへ寝る炎 — 計測 累積面積 51.817% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
測ったポインタからの二乗反比例の風
function pointerWind() {
if (px < 0 || py < 0) return [0, 0];
var wickX = W * 0.5, wickY = H * 0.62;
var dx = wickX - px, dy = wickY - py;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1) return [0, -WIND_MAX];
var s = Math.min(WIND_MAX, WIND_MAX * Math.pow(60 / dist, FALLOFF));
return [(dx / dist) * s, (dy / dist) * s];
}
WIND_MAX3.2がポインタがりんに寄った瞬間の最大加速、FALLOFF2.0が距離の二乗反比例の指数です。IDLE_MS2,400ミリ秒のあいだ入力が静まると、りんの真上の風へbreezeの縦の浮力で戻ります。
08流星 — 残像を残す火の軌跡
先頭の後ろにパーティクルの尾を付け、通った場所をゆっくり冷やすと、大気で燃え尽きる流星の軌跡が画面を横切ります。夜景·天気コンセプトページの背景です。頭の軌道はリサージュ曲線で、TAIL_N42個の尾が毎フレームCOOL0.017ずつ冷えながら残像を伸ばします。

<main class="stage">
<div class="mt is-demo" id="mtRoot">
<p class="mt__hint">
<svg class="mt__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m230.08 78.26l-31.84 26.88l9.76 40.19a5.46 5.46 0 0 1-8.19 5.86L164 129.66l-35.78 21.53a5.46 5.46 0 0 1-8.19-5.86l9.73-40.19l-31.84-26.88a5.38 5.38 0 0 1 3.13-9.48l41.79-3.31l16.1-38.14a5.51 5.51 0 0 1 10.12 0l16.1 38.14L227 68.78a5.38 5.38 0 0 1 3.08 9.48" opacity=".2"/><path d="M239.35 70.08a13.41 13.41 0 0 0-11.77-9.28l-36.94-2.92l-14.21-33.66a13.51 13.51 0 0 0-24.86 0l-14.21 33.66l-36.94 2.92a13.39 13.39 0 0 0-7.66 23.58l28.06 23.68l-8.56 35.39a13.32 13.32 0 0 0 5.1 13.91a13.51 13.51 0 0 0 15 .69L164 139l31.65 19.06a13.54 13.54 0 0 0 15-.69a13.34 13.34 0 0 0 5.09-13.91l-8.56-35.39l28.06-23.68a13.32 13.32 0 0 0 4.11-14.31M193.08 99a8 8 0 0 0-2.61 8l8.28 34.21l-30.62-18.41a8 8 0 0 0-8.25 0l-30.62 18.43l8.28-34.23a8 8 0 0 0-2.62-8L108 76.26l35.52-2.81a8 8 0 0 0 6.74-4.87L164 35.91l13.79 32.67a8 8 0 0 0 6.74 4.87l35.53 2.81Zm-105 24.18l-58.42 58.48a8 8 0 0 1-11.32-11.32l58.45-58.45a8 8 0 0 1 11.32 11.32Zm10.81 49.87a8 8 0 0 1 0 11.31l-53.23 53.3a8 8 0 0 1-11.32-11.32l53.27-53.26a8 8 0 0 1 11.31 0Zm73-1a8 8 0 0 1 0 11.32l-54.28 54.28a8 8 0 0 1-11.32-11.32l54.29-54.28a8 8 0 0 1 11.36.01Z"/></g></svg>
<span>先頭の後ろにパーティクルの尾を流し、通った場所はゆっくり冷えて残像の軌跡になります</span>
</p>
<div class="mt__field" id="mtField" role="application" aria-label="流星軌跡実験盤" tabindex="0">
<canvas class="mt__cv" id="mtCv"></canvas>
</div>
<div class="mt__bar">
<span class="mt__meta"><b class="mt__val" id="mtTrail">28</b>フレーム冷却・尾パーティクル26</span>
<span class="mt__meta mt__meta--dim">heatフィールド・色温度低下</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 유성(08). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(머리 궤도 + 열 필드 냉각)가 만든다.
// 무대는 잉크(#241b1d — 밤하늘). 파스텔 고정 팔레트를 --fire-* 로 편 공통 규격(01~09 동일).
$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; // 연기
.mt {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --fire-* 를 받아 쓴다
--fire-ink: #{$fire-ink};
--fire-amber: #{$fire-amber};
--fire-gold: #{$fire-gold};
--fire-rose: #{$fire-rose};
--fire-ember: #{$fire-ember};
--fire-smoke: #{$fire-smoke};
// 반경은 SCSS 토큰으로만 — var(--radius-*) 변수 선언은 BD17 위반(04-06 서브 실측 정정)
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.mt__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; // 잉크 무대 위 — 연기 톤 글자
}
.mt__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $fire-smoke; }
.mt__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);
}
.mt__field:focus-visible { outline: 2px solid $fire-amber; outline-offset: 2px; }
.mt__cv { display: block; width: 100%; height: 100%; }
.mt__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.mt__meta { font-size: 10px; font-weight: 500; color: rgba($fire-smoke, .72); }
.mt__val { color: $fire-gold; font-weight: 700; } // 잉크 위 금빛 라벨
.mt__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.mt { gap: $sp-1; }
.mt__field { height: 100px; }
.mt__meta--dim { display: none; }
}
// reduce — 루프가 아예 안 돈다. 궤적이 살아 있는 정적 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.mt.is-still .mt__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);
}
.mt {
--fire-ink: #241b1d;
--fire-amber: #f0a868;
--fire-gold: #f6cf8a;
--fire-rose: #eca0a8;
--fire-ember: #d97a52;
--fire-smoke: #c9c2c6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.mt__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;
}
.mt__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #c9c2c6;
}
.mt__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);
}
.mt__field:focus-visible {
outline: 2px solid #f0a868;
outline-offset: 2px;
}
.mt__cv {
display: block;
width: 100%;
height: 100%;
}
.mt__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.mt__meta {
font-size: 10px;
font-weight: 500;
color: rgba(201, 194, 198, 0.72);
}
.mt__val {
color: #f6cf8a;
font-weight: 700;
}
.mt__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.mt {
gap: 4px;
}
.mt__field {
height: 100px;
}
.mt__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.mt.is-still .mt__field {
cursor: default !important;
}
}// 유성(08). 머리(meteor head)가 시드 고정 타원 궤도를 가로지르며, 매 프레임 자기 위치에
// 열(heat)을 꼬리 입자로 남긴다. 열 필드는 프레임마다 일정 비율로 냉각된다(k -= COOL) —
// 이 냉각 곡선이 잔상: 방금 지난 자리는 gold, 오래된 자리는 amber→ember→잉크로 색온도가 내려간다.
// 렌더는 heat 칸마다 rgba 스탬프를 lighter 로 쌓는다(누적 발광). 자동 재생만 — 실측 입력 없음.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var TAIL_N = 42; // 머리 뒤 매 프레임 흘리는 꼬리 입자 수 — 변형 "꼬리 밀도" 축
var COOL = 0.017; // 프레임당 냉각률 — 변형 "잔상 길이" 축(0.05 짧게 → 0.012 길게) — 더 오래 남아 화면을 더 가로지른다
var SPEED = 2.6; // 머리 속도 배율(px/frame) — 변형 "속도" 축
// ────────────────────────────────────────────────────
var root = document.getElementById('mtRoot');
var field = document.getElementById('mtField');
var cv = document.getElementById('mtCv');
var ctx = cv.getContext('2d');
var trailOut = document.getElementById('mtTrail');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 유성으로 날아간다
var SEED = 0x5eed08;
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 로 읽는다 — 파스텔 토큰이 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_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');
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 head = { x: 0, y: 0, px: 0, py: 0 };
// ── 꼬리 입자 풀 — 고정 크기 배열, 죽은 칸은 즉시 재사용(할당 없는 루프) ──
// 꼬리가 더 오래·더 넓게 남도록(COOL 감소) 늘린 만큼 풀도 함께 키운다(TAIL_N × 수명 프레임 이상)
var POOL = 1800;
var parts = [];
for (var i = 0; i < POOL; i++) parts.push({ x: 0, y: 0, vx: 0, vy: 0, k: 0, r: 0, rose: false });
var poolAt = 0;
function emit(x, y, vx, vy, k, r) {
var p = parts[poolAt];
p.x = x; p.y = y; p.vx = vx; p.vy = vy; p.k = k; p.r = r;
p.rose = rand() < 0.12; // 일부는 로즈 톤 — 파스텔 팔레트 변형색
poolAt = (poolAt + 1) % POOL;
}
// 별밤 배경 — 시드 고정. 매 프레임 같은 자리(정지 부품), 위치는 fit 때 한 번만 뽑는다
var stars = [];
function seedStars() {
stars = [];
for (var i = 0; i < 46; i++) {
stars.push({ x: rand() * W, y: rand() * H, r: 0.5 + rand() * 1.1, a: 0.12 + rand() * 0.3 });
}
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
prngState = SEED >>> 0; // 별 위치가 창 크기와 무관하게 같은 시드에서
seedStars();
}
// 머리 위치 — 시간의 리사주. 진폭을 판보다 크게 잡아 화면 밖에서 진입/퇴장한다
// 진폭을 넓혀 더 큰 대각선을 그리며 화면을 크게 가로지른다(누적면적 확장)
function headAt(tSec) {
var u = tSec * 0.5; // 주기 4초 분의 1 속도 인자
return [
W * (0.5 + 0.72 * Math.sin(u * 0.9)),
H * (0.5 + 0.54 * Math.sin(u * 1.7 + 0.6))
];
}
// 색온도 k(1=가장 뜨거움) → 팔레트 색. gold→amber→ember 의 3정 색역 보간
function heatColor(k, rose) {
var base;
if (rose) base = C_ROSE;
else if (k > 0.62) { // 뜨거운 구간 — gold→amber
var u1 = (k - 0.62) / 0.38;
base = [
C_AMBER[0] + (C_GOLD[0] - C_AMBER[0]) * u1,
C_AMBER[1] + (C_GOLD[1] - C_AMBER[1]) * u1,
C_AMBER[2] + (C_GOLD[2] - C_AMBER[2]) * u1
];
} else { // 식는 구간 — ember→amber
var u2 = k / 0.62;
base = [
C_EMBER[0] + (C_AMBER[0] - C_EMBER[0]) * u2,
C_EMBER[1] + (C_AMBER[1] - C_EMBER[1]) * u2,
C_EMBER[2] + (C_AMBER[2] - C_EMBER[2]) * u2
];
}
return base;
}
function draw(tSec) {
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
// 별밤 — 정지. 반짝임 없이(한 애니메이션 원칙: 유성 궤적만 산다)
for (var s = 0; s < stars.length; s++) {
var st = stars[s];
ctx.fillStyle = 'rgba(' + C_SMOKE[0] + ',' + C_SMOKE[1] + ',' + C_SMOKE[2] + ',' + st.a.toFixed(3) + ')';
ctx.beginPath();
ctx.arc(st.x, st.y, st.r, 0, TAU);
ctx.fill();
}
// ── 머리 이동 + 꼬리 방출 ──
var hp = headAt(tSec);
head.px = head.x; head.py = head.y;
head.x = hp[0]; head.y = hp[1];
var dvx = (head.x - head.px), dvy = (head.y - head.py);
var dlen = Math.sqrt(dvx * dvx + dvy * dvy) || 1;
var dirx = dvx / dlen, diry = dvy / dlen; // 진행 방향 단위벡터
for (var n = 0; n < TAIL_N; n++) {
// 머리 뒤쪽(진행 반대)으로 흩뿌린다 — 꼬리가 머리 뒤로 늘어진다(더 넓게, 더 길게)
var back = (n / TAIL_N) * 13 * SPEED;
var jx = (rand() - 0.5) * 8;
var jy = (rand() - 0.5) * 8;
emit(
head.x - dirx * back + jx,
head.y - diry * back + jy,
-dirx * (0.2 + rand() * 0.5) + (rand() - 0.5) * 0.3,
-diry * (0.2 + rand() * 0.5) + (rand() - 0.5) * 0.3 - 0.05,
0.75 + rand() * 0.25,
2.4 + rand() * 3.8
);
}
// ── 앙비언트 글로우 — 유성이 지나가는 자리 주변에 빛을 뿌린다 ──
// 고정 후광이 아니라 매 프레임 재계산: 트레일에 살아 있는 전체 열량에 연동해 반경·알파가
// 펄스 치고, 중심은 머리 위치를 그대로 따라간다. lighter 로 덧그려 화면 전체가 은은히 밝아진다.
var liveK = 0;
for (var lk = 0; lk < POOL; lk++) { if (parts[lk].k > 0) liveK += parts[lk].k; }
var liveNorm = Math.min(1, liveK / (TAIL_N * 9)); // 대략적인 정규화 — 꼬리가 꽉 찼을 때 1 근처
var flick2 = 0.55 + 0.28 * Math.sin(tSec * 4.6) + 0.18 * Math.sin(tSec * 10.3 + 2.0) + 0.08 * Math.sin(tSec * 17.1 + 5.1);
var gAlpha = (0.18 + 0.32 * liveNorm) * Math.max(0.2, Math.min(1, flick2));
var gRad = Math.max(W, H) * 0.66;
ctx.globalCompositeOperation = 'lighter';
var amb = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, gRad);
amb.addColorStop(0, 'rgba(' + C_AMBER[0] + ',' + C_AMBER[1] + ',' + C_AMBER[2] + ',' + gAlpha.toFixed(3) + ')');
amb.addColorStop(0.5, 'rgba(' + C_EMBER[0] + ',' + C_EMBER[1] + ',' + C_EMBER[2] + ',' + (gAlpha * 0.5).toFixed(3) + ')');
amb.addColorStop(1, 'rgba(' + C_EMBER[0] + ',' + C_EMBER[1] + ',' + C_EMBER[2] + ',0)');
ctx.fillStyle = amb;
ctx.fillRect(0, 0, W, H);
// ── 냉각·이동·소거 + 발광 스탬프 ──
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < POOL; i++) {
var p = parts[i];
if (p.k <= 0) continue;
p.k -= COOL; // 열량 감소 = 잔상 냉각
if (p.k <= 0) { p.k = 0; continue; }
p.x += p.vx; p.y += p.vy;
p.vx *= 0.985; p.vy *= 0.985;
var col = heatColor(p.k, p.rose);
var rad = p.r * (0.4 + p.k * 0.9);
var a = Math.min(0.5, p.k * 0.42);
var g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, rad * 2);
g.addColorStop(0, 'rgba(' + (col[0] | 0) + ',' + (col[1] | 0) + ',' + (col[2] | 0) + ',' + a.toFixed(3) + ')');
g.addColorStop(1, 'rgba(' + (col[0] | 0) + ',' + (col[1] | 0) + ',' + (col[2] | 0) + ',0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(p.x, p.y, rad * 2, 0, TAU);
ctx.fill();
}
// ── 머리 — 가장 뜨거운 코어 + 진행 방향 앞쪽 예열 하이라이트 ──
var hg = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 16);
hg.addColorStop(0, 'rgba(' + C_GOLD[0] + ',' + C_GOLD[1] + ',' + C_GOLD[2] + ',0.95)');
hg.addColorStop(0.4, 'rgba(' + C_AMBER[0] + ',' + C_AMBER[1] + ',' + C_AMBER[2] + ',0.5)');
hg.addColorStop(1, 'rgba(' + C_EMBER[0] + ',' + C_EMBER[1] + ',' + C_EMBER[2] + ',0)');
ctx.fillStyle = hg;
ctx.beginPath();
ctx.arc(head.x, head.y, 16, 0, TAU);
ctx.fill();
ctx.globalCompositeOperation = 'source-over';
trailOut.textContent = String(Math.round(1 / COOL));
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
draw(t * SPEED * 0.42);
raf = requestAnimationFrame(frame);
}
// 키보드로 머리를 살짝 미는 보조 — 화살표로 방향에 순풍을 준다(포인터 실측은 07 담당)
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.6, 0], ArrowRight: [0.6, 0], ArrowUp: [0, -0.6], ArrowDown: [0, 0.6] }[e.key];
if (!d) return;
e.preventDefault();
for (var i = 0; i < POOL; i++) {
var p = parts[i];
if (p.k > 0) { p.vx += d[0] * 0.04; p.vy += d[1] * 0.04; }
}
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(1.9); });
fit();
if (reduce) {
// reduce — 루프 없이 궤적이 살아 있는 한 장. 프레임을 몰아 전진해 잔상을 만든 뒤 담는다
root.classList.remove('is-demo');
root.classList.add('is-still');
for (var warm = 0; warm < 46; warm++) {
var hp2 = headAt(warm * 0.09);
head.px = head.x; head.py = head.y; head.x = hp2[0]; head.y = hp2[1];
var dvx2 = head.x - head.px, dvy2 = head.y - head.py;
var dl2 = Math.sqrt(dvx2 * dvx2 + dvy2 * dvy2) || 1;
for (var n2 = 0; n2 < TAIL_N; n2++) {
emit(
head.x - (dvx2 / dl2) * (n2 / TAIL_N) * 13 + (rand() - 0.5) * 8,
head.y - (dvy2 / dl2) * (n2 / TAIL_N) * 13 + (rand() - 0.5) * 8,
-(dvx2 / dl2) * 0.3, -(dvy2 / dl2) * 0.3,
0.8, 2.4 + rand() * 3.8
);
}
for (var i2 = 0; i2 < POOL; i2++) {
var p2 = parts[i2];
if (p2.k > 0) { p2.k -= COOL; p2.x += p2.vx; p2.y += p2.vy; }
}
}
draw(1.9);
} else {
raf = requestAnimationFrame(frame);
}
})();// FireMeteorTrail — vanilla 판(run/314/demo/08-fire-meteor-trail/index.html) 의 React 포트.
// 머리가 시드 고정 리사주 궤도를 가로지르며 매 프레임 꼬리 입자(열)를 남기고, 열 필드가
// 프레임마다 냉각되어 지나간 자리가 gold→amber→ember 로 식는 잔상 궤적이 된다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FireMeteorTrail.module.scss";
export type FireMeteorTrailProps = {
duration?: string;
easing?: string;
color?: string;
tailPerFrame?: number; // 머리 뒤 매 프레임 흘리는 꼬리 입자 수 — 변형 "꼬리 밀도" 축
cool?: number; // 프레임당 냉각률 — 변형 "잔상 길이" 축
speed?: number; // 머리 속도 배율 — 변형 "속도" 축
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type RGB = [number, number, number];
type Particle = { x: number; y: number; vx: number; vy: number; k: number; r: number; rose: boolean };
type Cols = { ink: RGB; amber: RGB; gold: RGB; rose: RGB; ember: RGB; smoke: RGB };
type Star = { x: number; y: number; r: number; a: number };
const TAU = Math.PI * 2;
const FIELD_LABEL = "유성 궤적 실험판";
const POOL = 1800; // 고정 크기 입자 풀 — 죽은 칸은 즉시 재사용(할당 없는 루프).
// 꼬리가 더 오래·더 넓게 남도록(cool 감소) 늘린 만큼 풀도 함께 키운다(tail × 수명 프레임 이상)
const STILL_T = 1.9; // reduce 에서 그리는 시각 — 궤적이 살아 있는 한 장
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 유성으로 날아간다
const SEED = 0x5eed08;
let prngState = SEED >>> 0;
function rand(): number {
prngState = (prngState + 0x6D2B79F5) >>> 0;
let 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;
}
export default function FireMeteorTrail({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f0a868",
tailPerFrame = 42,
cool = 0.017,
speed = 2.6,
}: FireMeteorTrailProps) {
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<Cols | null>(null);
const tailRef = useRef(tailPerFrame) as Box<number>;
const coolRef = useRef(cool) as Box<number>;
const speedRef = useRef(speed) as Box<number>;
const partsRef = useRef<Particle[]>([]) as Box<Particle[]>;
const poolAtRef = useRef(0) as Box<number>;
const starsRef = useRef<Star[]>([]) as Box<Star[]>;
const headRef = useRef({ x: 0, y: 0, px: 0, py: 0 }) as Box<{ x: number; y: number; px: number; py: number }>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const [trail, setTrail] = useState("59");
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// 색은 CSS 커스텀 프로퍼티(--fire-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function emit(x: number, y: number, vx: number, vy: number, k: number, r: number): void {
const parts = partsRef.current;
const p = parts[poolAtRef.current];
p.x = x; p.y = y; p.vx = vx; p.vy = vy; p.k = k; p.r = r;
p.rose = rand() < 0.12; // 일부는 로즈 톤 — 파스텔 팔레트 변형색
poolAtRef.current = (poolAtRef.current + 1) % POOL;
}
function seedStars(w: number, h: number): void {
const stars: Star[] = [];
for (let i = 0; i < 46; i++) {
stars.push({ x: rand() * w, y: rand() * h, r: 0.5 + rand() * 1.1, a: 0.12 + rand() * 0.3 });
}
starsRef.current = stars;
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const size = sizeRef.current;
const r = field.getBoundingClientRect();
size.w = r.width; size.h = r.height;
cv.width = Math.round(size.w); cv.height = Math.round(size.h);
ctxRef.current = ctx;
// 입자 풀 — 비어 있으면 채운다
const parts = partsRef.current;
while (parts.length < POOL) {
parts.push({ x: 0, y: 0, vx: 0, vy: 0, k: 0, r: 0, rose: false });
}
prngState = SEED >>> 0; // 별 위치가 창 크기와 무관하게 같은 시드에서
seedStars(size.w, size.h);
}
// 머리 위치 — 시간의 리사주. 진폭을 판보다 크게 잡아 화면 밖에서 진입/퇴장한다
function headAt(tSec: number): [number, number] {
const { w, h } = sizeRef.current;
const u = tSec * 0.5; // 주기 4초 분의 1 속도 인자
return [
w * (0.5 + 0.72 * Math.sin(u * 0.9)),
h * (0.5 + 0.54 * Math.sin(u * 1.7 + 0.6))
];
}
// 색온도 k(1=가장 뜨거움) → 팔레트 색. gold→amber→ember 의 3정 색역 보간
function heatColor(k: number, rose: boolean, col: Cols): RGB {
if (rose) return col.rose;
if (k > 0.62) { // 뜨거운 구간 — gold→amber
const u1 = (k - 0.62) / 0.38;
return [
col.amber[0] + (col.gold[0] - col.amber[0]) * u1,
col.amber[1] + (col.gold[1] - col.amber[1]) * u1,
col.amber[2] + (col.gold[2] - col.amber[2]) * u1
];
}
// 식는 구간 — ember→amber
const u2 = k / 0.62;
return [
col.ember[0] + (col.amber[0] - col.ember[0]) * u2,
col.ember[1] + (col.amber[1] - col.ember[1]) * u2,
col.ember[2] + (col.amber[2] - col.ember[2]) * u2
];
}
function draw(tSec: number): void {
const ctx = ctxRef.current, col = colRef.current;
if (!ctx || !col) return;
const { w: W, h: H } = sizeRef.current;
const head = headRef.current;
const TAIL_N = tailRef.current;
const SPEED = speedRef.current;
const COOLV = coolRef.current;
ctx.fillStyle = "rgb(" + col.ink[0] + "," + col.ink[1] + "," + col.ink[2] + ")";
ctx.fillRect(0, 0, W, H);
// 별밤 — 정지. 반짝임 없이(한 애니메이션 원칙: 유성 궤적만 산다)
const stars = starsRef.current;
for (let s = 0; s < stars.length; s++) {
const st = stars[s];
ctx.fillStyle = "rgba(" + col.smoke[0] + "," + col.smoke[1] + "," + col.smoke[2] + "," + st.a.toFixed(3) + ")";
ctx.beginPath();
ctx.arc(st.x, st.y, st.r, 0, TAU);
ctx.fill();
}
// ── 머리 이동 + 꼬리 방출 ──
const hp = headAt(tSec);
head.px = head.x; head.py = head.y;
head.x = hp[0]; head.y = hp[1];
const dvx = head.x - head.px, dvy = head.y - head.py;
const dlen = Math.sqrt(dvx * dvx + dvy * dvy) || 1;
const dirx = dvx / dlen, diry = dvy / dlen; // 진행 방향 단위벡터
for (let n = 0; n < TAIL_N; n++) {
// 머리 뒤쪽(진행 반대)으로 흩뿌린다 — 꼬리가 머리 뒤로 늘어진다(더 넓게, 더 길게)
const back = (n / TAIL_N) * 13 * SPEED;
const jx = (rand() - 0.5) * 8;
const jy = (rand() - 0.5) * 8;
emit(
head.x - dirx * back + jx,
head.y - diry * back + jy,
-dirx * (0.2 + rand() * 0.5) + (rand() - 0.5) * 0.3,
-diry * (0.2 + rand() * 0.5) + (rand() - 0.5) * 0.3 - 0.05,
0.75 + rand() * 0.25,
2.4 + rand() * 3.8
);
}
// ── 앙비언트 글로우 — 유성이 지나가는 자리 주변에 빛을 뿌린다 ──
// 고정 후광이 아니라 매 프레임 재계산: 트레일에 살아 있는 전체 열량에 연동해 반경·알파가
// 펄스 치고, 중심은 머리 위치를 그대로 따라간다. lighter 로 덧그려 화면 전체가 은은히 밝아진다.
const parts2 = partsRef.current;
let liveK = 0;
for (let lk = 0; lk < POOL; lk++) { if (parts2[lk].k > 0) liveK += parts2[lk].k; }
const liveNorm = Math.min(1, liveK / (TAIL_N * 9)); // 대략적인 정규화 — 꼬리가 꽉 찼을 때 1 근처
const flick2 = 0.55 + 0.28 * Math.sin(tSec * 4.6) + 0.18 * Math.sin(tSec * 10.3 + 2.0) + 0.08 * Math.sin(tSec * 17.1 + 5.1);
const gAlpha = (0.18 + 0.32 * liveNorm) * Math.max(0.2, Math.min(1, flick2));
const gRad = Math.max(W, H) * 0.66;
ctx.globalCompositeOperation = "lighter";
const amb = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, gRad);
amb.addColorStop(0, "rgba(" + col.amber[0] + "," + col.amber[1] + "," + col.amber[2] + "," + gAlpha.toFixed(3) + ")");
amb.addColorStop(0.5, "rgba(" + col.ember[0] + "," + col.ember[1] + "," + col.ember[2] + "," + (gAlpha * 0.5).toFixed(3) + ")");
amb.addColorStop(1, "rgba(" + col.ember[0] + "," + col.ember[1] + "," + col.ember[2] + ",0)");
ctx.fillStyle = amb;
ctx.fillRect(0, 0, W, H);
// ── 냉각·이동·소거 + 발광 스탬프 ──
ctx.globalCompositeOperation = "lighter";
const parts = partsRef.current;
for (let i = 0; i < POOL; i++) {
const p = parts[i];
if (p.k <= 0) continue;
p.k -= COOLV; // 열량 감소 = 잔상 냉각
if (p.k <= 0) { p.k = 0; continue; }
p.x += p.vx; p.y += p.vy;
p.vx *= 0.985; p.vy *= 0.985;
const c = heatColor(p.k, p.rose, col);
const rad = p.r * (0.4 + p.k * 0.9);
const a = Math.min(0.5, p.k * 0.42);
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, rad * 2);
g.addColorStop(0, "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a.toFixed(3) + ")");
g.addColorStop(1, "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + ",0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(p.x, p.y, rad * 2, 0, TAU);
ctx.fill();
}
// ── 머리 — 가장 뜨거운 코어 ──
const hg = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 16);
hg.addColorStop(0, "rgba(" + col.gold[0] + "," + col.gold[1] + "," + col.gold[2] + ",0.95)");
hg.addColorStop(0.4, "rgba(" + col.amber[0] + "," + col.amber[1] + "," + col.amber[2] + ",0.5)");
hg.addColorStop(1, "rgba(" + col.ember[0] + "," + col.ember[1] + "," + col.ember[2] + ",0)");
ctx.fillStyle = hg;
ctx.beginPath();
ctx.arc(head.x, head.y, 16, 0, TAU);
ctx.fill();
ctx.globalCompositeOperation = "source-over";
setTrail(String(Math.round(1 / COOLV)));
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
draw(t * speedRef.current * 0.42);
rafRef.current = requestAnimationFrame(frame);
}
// 키보드 보조 — 화살표로 꼬리 입자에 순풍(포인터 실측은 07 담당)
function onKeyDown(e: KeyEvt): void {
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-0.6, 0], ArrowRight: [0.6, 0], ArrowUp: [0, -0.6], ArrowDown: [0, 0.6],
};
const d = deltas[e.key];
if (!d) return;
e.preventDefault();
const parts = partsRef.current;
for (let i = 0; i < POOL; i++) {
const p = parts[i];
if (p.k > 0) { p.vx += d[0] * 0.04; p.vy += d[1] * 0.04; }
}
}
// 정지판 — 프레임을 몰아 전진해 잔상을 만든 뒤 한 장을 담는다
function warmStill(): void {
const head = headRef.current;
const TAIL_N = tailRef.current;
const parts = partsRef.current;
for (let wstep = 0; wstep < 46; wstep++) {
const hp = headAt(wstep * 0.09);
head.px = head.x; head.py = head.y; head.x = hp[0]; head.y = hp[1];
const dvx = head.x - head.px, dvy = head.y - head.py;
const dl = Math.sqrt(dvx * dvx + dvy * dvy) || 1;
for (let n = 0; n < TAIL_N; n++) {
emit(
head.x - (dvx / dl) * (n / TAIL_N) * 13 + (rand() - 0.5) * 8,
head.y - (dvy / dl) * (n / TAIL_N) * 13 + (rand() - 0.5) * 8,
-(dvx / dl) * 0.3, -(dvy / dl) * 0.3,
0.8, 2.4 + rand() * 3.8
);
}
for (let i = 0; i < POOL; i++) {
const p = parts[i];
if (p.k > 0) { p.k -= coolRef.current; p.x += p.vx; p.y += p.vy; }
}
}
draw(STILL_T);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
tailRef.current = tailPerFrame;
coolRef.current = cool;
speedRef.current = speed;
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"),
};
fit();
function onResize(): void {
fit();
if (reduced()) draw(STILL_T);
}
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프 없이 궤적이 살아 있는 한 장
warmStill();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tailPerFrame, cool, speed]);
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="m230.08 78.26l-31.84 26.88l9.76 40.19a5.46 5.46 0 0 1-8.19 5.86L164 129.66l-35.78 21.53a5.46 5.46 0 0 1-8.19-5.86l9.73-40.19l-31.84-26.88a5.38 5.38 0 0 1 3.13-9.48l41.79-3.31l16.1-38.14a5.51 5.51 0 0 1 10.12 0l16.1 38.14L227 68.78a5.38 5.38 0 0 1 3.08 9.48" opacity=".2"/><path d="M239.35 70.08a13.41 13.41 0 0 0-11.77-9.28l-36.94-2.92l-14.21-33.66a13.51 13.51 0 0 0-24.86 0l-14.21 33.66l-36.94 2.92a13.39 13.39 0 0 0-7.66 23.58l28.06 23.68l-8.56 35.39a13.32 13.32 0 0 0 5.1 13.91a13.51 13.51 0 0 0 15 .69L164 139l31.65 19.06a13.54 13.54 0 0 0 15-.69a13.34 13.34 0 0 0 5.09-13.91l-8.56-35.39l28.06-23.68a13.32 13.32 0 0 0 4.11-14.31M193.08 99a8 8 0 0 0-2.61 8l8.28 34.21l-30.62-18.41a8 8 0 0 0-8.25 0l-30.62 18.43l8.28-34.23a8 8 0 0 0-2.62-8L108 76.26l35.52-2.81a8 8 0 0 0 6.74-4.87L164 35.91l13.79 32.67a8 8 0 0 0 6.74 4.87l35.53 2.81Zm-105 24.18l-58.42 58.48a8 8 0 0 1-11.32-11.32l58.45-58.45a8 8 0 0 1 11.32 11.32Zm10.81 49.87a8 8 0 0 1 0 11.31l-53.23 53.3a8 8 0 0 1-11.32-11.32l53.27-53.26a8 8 0 0 1 11.31 0Zm73-1a8 8 0 0 1 0 11.32l-54.28 54.28a8 8 0 0 1-11.32-11.32l54.29-54.28a8 8 0 0 1 11.36.01Z"/></g></svg>
<span>머리 뒤로 파티클 꼬리를 흘리며, 지나간 자리는 천천히 식어 잔상 궤적이 됩니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{trail}</b>프레임 냉각 · 꼬리 입자 {tailPerFrame}</span>
<span className={styles.metaDim}>heat 필드 · 색온도 감소</span>
</div>
</div>
</main>
);
}08 · 流星 — 残像を残す火の軌跡 — 計測 累積面積 47.778% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
直線ではなくリサージュの頭
function headAt(tSec) {
var u = tSec * 0.5;
return [
W * (0.5 + 0.72 * Math.sin(u * 0.9)),
H * (0.5 + 0.54 * Math.sin(u * 1.7 + 0.6))
];
}
頭の進行方向の単位ベクトルを取り、その逆向きにTAIL_N42個を毎フレーム撒きます。振幅を画面より大きく取ってあるので、頭は画面の外で入り込み外で抜け、23フレーム中21フレームだけが動いているという実測値になりました。尾の冷却が軌跡を埋める長さを決めます。
09火種の拡散 — 燃料セルが燃えて広がる格子
セルごとに燃料を保存し、燃えているセルが隣へ熱を渡して燃料が減る規則を繰り返すと、地図を食い進む火災の拡散が近似できます。ゲームの状態画面·シミュレーションの紹介では、テクスチャをループさせるのではなく、火が実際に地図を消費する様子を見せられます。熱がIGNITE0.34を超えたマスだけが燃え、8方向の隣人に熱を渡します。

<main class="stage">
<div class="sg is-demo" id="sgRoot">
<p class="sg__hint">
<svg class="sg__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 144a80 80 0 0 1-160 0c0-30.57 14.42-58.26 31-80l33 32l26.27-72C159.86 41.92 208 88.15 208 144" opacity=".2"/><path d="M183.89 153.34a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68ZM216 144a88 88 0 0 1-176 0c0-27.92 11-56.47 32.66-84.85a8 8 0 0 1 11.93-.89l24.12 23.41l22-60.41a8 8 0 0 1 12.63-3.41C165.21 36 216 84.55 216 144m-16 0c0-46.09-35.79-85.92-58.21-106.33l-22.27 61.07a8 8 0 0 1-13.09 3L80.06 76.16C64.09 99.21 56 122 56 144a72 72 0 0 0 144 0"/></g></svg>
<span>各マスが燃料を持ち、燃えているマスが隣に熱を渡して燃料が減ります — 燃え尽きた場所は消えます</span>
</p>
<div class="sg__field" id="sgField" role="application" aria-label="火種の拡散実験盤" tabindex="0">
<canvas class="sg__cv" id="sgCv"></canvas>
<b class="sg__badge" aria-hidden="true"><span class="sg__badgeNum" id="sgGrid">120×52</span><span class="sg__badgeUnit">燃料格子</span></b>
</div>
<div class="sg__bar">
<span class="sg__meta"><b class="sg__val" id="sgFires">0</b>件の火元・燃料合計100%</span>
<span class="sg__meta sg__meta--dim">ImageData · putImageData</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 불씨 확산(09). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 셀 오톨로지 루프(연료 소모 + 이웃 열 전달)가 만든다.
// 무대는 잉크(#241b1d — 밤 지형). 파스텔 고정 팔레트를 --fire-* 로 편 공통 규격(01~09 동일).
// 반경은 SCSS 토큰으로만 — BD17(var(--radius-*) 변수 선언은 위반, 04-06 서브 실측 정정).
$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; // 연기
.sg {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --fire-* 를 받아 쓴다
--fire-ink: #{$fire-ink};
--fire-amber: #{$fire-amber};
--fire-gold: #{$fire-gold};
--fire-rose: #{$fire-rose};
--fire-ember: #{$fire-ember};
--fire-smoke: #{$fire-smoke};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sg__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; // 잉크 무대 위 — 연기 톤 글자
}
.sg__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $fire-smoke; }
.sg__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden; // 캔버스 감싸는 카드 — $r-card 토큰(BD17)
background: $fire-ink;
box-shadow: inset 0 0 0 1px rgba($fire-smoke, .18);
}
.sg__field:focus-visible { outline: 2px solid $fire-amber; outline-offset: 2px; }
.sg__cv { display: block; width: 100%; height: 100%; }
// 시뮬 격자 크기를 화면에도 적어 둔다 — JS 의 COLS·ROWS 와 같은 값으로 fit() 이 갱신한다
.sg__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($fire-ink, .78);
color: $fire-smoke; font-weight: 700; font-size: 11px;
}
.sg__badgeNum { font-size: 13px; color: $fire-gold; }
.sg__badgeUnit { font-weight: 700; }
.sg__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.sg__meta { font-size: 10px; font-weight: 500; color: rgba($fire-smoke, .72); }
.sg__val { color: $fire-gold; font-weight: 700; } // 잉크 위 금빛 라벨
.sg__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.sg { gap: $sp-1; }
.sg__field { height: 100px; }
.sg__badge { font-size: 10px; padding: 0 $sp-1; }
.sg__badgeNum { font-size: 11px; }
.sg__meta--dim { display: none; }
}
// reduce — 루프가 아예 안 돈다. 화재가 한창 번진 정적 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.sg.is-still .sg__field { cursor: default !important; }
.sg.is-still .sg__badge { opacity: .92 !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);
}
.sg {
--fire-ink: #241b1d;
--fire-amber: #f0a868;
--fire-gold: #f6cf8a;
--fire-rose: #eca0a8;
--fire-ember: #d97a52;
--fire-smoke: #c9c2c6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sg__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;
}
.sg__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #c9c2c6;
}
.sg__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);
}
.sg__field:focus-visible {
outline: 2px solid #f0a868;
outline-offset: 2px;
}
.sg__cv {
display: block;
width: 100%;
height: 100%;
}
.sg__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(36, 27, 29, 0.78);
color: #c9c2c6;
font-weight: 700;
font-size: 11px;
}
.sg__badgeNum {
font-size: 13px;
color: #f6cf8a;
}
.sg__badgeUnit {
font-weight: 700;
}
.sg__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.sg__meta {
font-size: 10px;
font-weight: 500;
color: rgba(201, 194, 198, 0.72);
}
.sg__val {
color: #f6cf8a;
font-weight: 700;
}
.sg__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.sg {
gap: 4px;
}
.sg__field {
height: 100px;
}
.sg__badge {
font-size: 10px;
padding: 0 4px;
}
.sg__badgeNum {
font-size: 11px;
}
.sg__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sg.is-still .sg__field {
cursor: default !important;
}
.sg.is-still .sg__badge {
opacity: 0.92 !important;
}
}// 불씨 확산(09). 셀 격자마다 연료(fuel) 를 저장한다. 상태 규칙은 프레임마다 전 칸에 대해:
// burning = heat > 점화 문턱
// 타는 칸은 매 프레임 fuel -= 소모율, 그리고 이웃 8칸에 heat += 전달량(연료가 많은 만큼 잘 탄다)
// heat 는 확산마다 감쇠, fuel 이 0 이 된 칸은 다신 안 탄다(다 소진)
// 렌더는 ImageData 픽셀을 셀 단위로 직접 쓰고 putImageData 로 올린다 — fillRect 하나 없이
// 화재가 지도를 먹어 들어가는 모습이 프레임마다 다시 그려진다. 자동 재생만 — 실측 입력 없음.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var COLS = 120; // 격자 가로 칸 수 — 변형 "격자 촘촘함" 축(72 → 168)
var SPREAD = 0.115; // 이웃 열 전달률 — 변형 "번짐 속도" 축(0.06 느리게 → 0.19 빠르게)
var BURN_RATE = 0.0032; // 프레임당 연료 소모율 — 변형 "지속력" 축(연료가 오래가면 천천히 다 탄다)
// ────────────────────────────────────────────────────
var root = document.getElementById('sgRoot');
var field = document.getElementById('sgField');
var cv = document.getElementById('sgCv');
var ctx = cv.getContext('2d');
var gridOut = document.getElementById('sgGrid');
var firesOut = document.getElementById('sgFires');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 지형·같은 번짐으로 재생된다
var SEED = 0x5eed09;
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 로 읽는다 — 파스텔 토큰이 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_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');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var ROWS = 0;
var fuel = null, heat = null, nheat = null; // 격자 상태(연료·열·다음 열)
var buf = null, bctx = null, img = null; // 셀당 1픽셀 렌더 버퍼 → 캔버스로 확대
var raf = 0;
var frameNo = 0;
var sparkEvery = 30; // 자동 점화 간격(프레임) — 시드 고정 순서로 옮겨 다닌다
var sparkAt = 0;
// ── 지형 생성 — 연료 지도. 값 0~1. 맨 아래 구석진 지형(숲 가장자리)에 이어지는 패치 ──
// 단순 난수가 아니라 2옥타브 값 노이즈로 "연료가 많은 숲 / 적은 초원"의 덩어리를 만든다
function valueNoiseGrid() {
var coarse = new Float32Array(9 * 5); // 굵은 노이즈 9×5
for (var c = 0; c < coarse.length; c++) coarse[c] = rand();
var fine = new Float32Array(17 * 9); // 가는 노이즈 17×9
for (var f = 0; f < fine.length; f++) fine[f] = rand();
var n = COLS * ROWS;
fuel = new Float32Array(n);
heat = new Float32Array(n);
nheat = new Float32Array(n);
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++) {
// 굵은 노이즈 쌍선형 보간
var gx = x / COLS * 8, gy = y / ROWS * 4;
var x0 = Math.min(7, gx | 0), y0 = Math.min(3, gy | 0);
var fx = gx - x0, fy = gy - y0;
var c00 = coarse[y0 * 9 + x0], c10 = coarse[y0 * 9 + x0 + 1];
var c01 = coarse[(y0 + 1) * 9 + x0], c11 = coarse[(y0 + 1) * 9 + x0 + 1];
var v1 = c00 + (c10 - c00) * fx;
var v2 = c01 + (c11 - c01) * fx;
var coarseV = v1 + (v2 - v1) * fy;
// 가는 노이즈(16×8)
var gx2 = x / COLS * 16, gy2 = y / ROWS * 8;
var x02 = Math.min(15, gx2 | 0), y02 = Math.min(7, gy2 | 0);
var fx2 = gx2 - x02, fy2 = gy2 - y02;
var f00 = fine[y02 * 17 + x02], f10 = fine[y02 * 17 + x02 + 1];
var f01 = fine[(y02 + 1) * 17 + x02], f11 = fine[(y02 + 1) * 17 + x02 + 1];
var w1 = f00 + (f10 - f00) * fx2;
var w2 = f01 + (f11 - f01) * fx2;
var fineV = w1 + (w2 - w1) * fy2;
// 2옥타브 합성 + 하단 가중(아래쪽이 연료 진한 숲)
var v = coarseV * 0.7 + fineV * 0.3 + (y / ROWS) * 0.28 + 0.12;
fuel[y * COLS + x] = Math.max(0.08, Math.min(1, v));
}
}
}
// 점화 — 시드 고정 순서로 옮겨 다니는 불씨(리사주)
function spark(k) {
var th = (k * sparkEvery / 8.1 % 1) * Math.PI * 2;
var gx = Math.round((0.5 + Math.sin(th) * 0.36) * (COLS - 1));
var gy = Math.round((0.72 + Math.sin(th * 2 + 1.2) * 0.2) * (ROWS - 1));
for (var dy = -1; dy <= 1; dy++) {
for (var dx = -1; dx <= 1; dx++) {
var x = gx + dx, y = gy + dy;
if (x < 0 || y < 0 || x >= COLS || y >= ROWS) continue;
heat[y * COLS + x] = 1.0;
}
}
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
ROWS = Math.max(24, Math.round(COLS * H / W));
prngState = SEED >>> 0; // 지형이 창 크기와 무관하게 같은 시드에서
valueNoiseGrid();
frameNo = 0; sparkAt = 0;
// 셀당 1픽셀 버퍼 — putImageData 로 올린 뒤 캔버스 크기로 확대
buf = document.createElement('canvas');
buf.width = COLS; buf.height = ROWS;
bctx = buf.getContext('2d');
img = bctx.createImageData(COLS, ROWS);
gridOut.textContent = COLS + '×' + ROWS;
}
// ── 시뮬 한 프레임 ──
// 타는 칸(heat > IGNITE)의 연료가 닳고, 이웃 8칸으로 열을 넘긴다. 열은 프레임마다 식는다.
var IGNITE = 0.34;
function sim() {
var i, x, y, dx, dy, nx, ny, ni;
var sum = 0;
for (i = 0; i < heat.length; i++) {
var f = fuel[i];
sum += f;
if (heat[i] > IGNITE) {
if (f > 0) {
// 연료 소모 — 남은 연료에 비례해 잘 탄다(연료가 많으면 열도 세다)
var burn = Math.min(f, BURN_RATE * (0.4 + f));
fuel[i] = f - burn;
var give = SPREAD * (0.3 + f * 0.7);
for (dy = -1; dy <= 1; dy++) {
for (dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
nx = (i % COLS) + dx; ny = ((i / COLS) | 0) + dy;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
ni = ny * COLS + nx;
// 대각선은 거리 1.41 배 — 열이 멀리는 덜 간다
nheat[ni] += give / (dx && dy ? 1.41 : 1);
}
}
// 타는 동안 자기 열도 유지된다
nheat[i] += give * 0.8;
}
}
// 냉각 — 열은 매 프레임 식는다
nheat[i] += heat[i] * 0.72;
}
var t = heat; heat = nheat; nheat = t;
nheat.fill(0);
return sum / heat.length;
}
// ── 렌더 — 셀당 픽셀 하나를 ImageData 에 직접 쓴다 ──
// 상태 → 색: 타는 칸은 heat 강도로 gold↔amber, 다 탄 칸은 ember 잔불에서 식어 잉크로,
// 미연 칸은 연료량으로 smoke 색 지형이 드러난다. 로즈는 점화 직후 가장 뜨거운 칸에 섞인다.
function render() {
var d = img.data;
for (var i = 0, j = 0; i < heat.length; i++, j += 4) {
var f = fuel[i], h = heat[i];
var r, g, b;
if (h > IGNITE && f > 0) {
// 활성 화염 — heat 정규화 색온도
var hh = Math.min(1, (h - IGNITE) / 0.9);
if (hh > 0.72 && ((i * 7 + frameNo) & 15) === 0) {
r = C_ROSE[0]; g = C_ROSE[1]; b = C_ROSE[2]; // 최고온 도트에 로즈
} else {
r = C_EMBER[0] + (C_GOLD[0] - C_EMBER[0]) * hh;
g = C_EMBER[1] + (C_GOLD[1] - C_EMBER[1]) * hh;
b = C_EMBER[2] + (C_GOLD[2] - C_EMBER[2]) * hh;
}
} else if (f <= 0) {
// 소진된 칸 — 잉크보다 살짝 밝은 검댓. 프레임이 지나며 서서히 식는다
var cool = Math.max(0, h);
r = C_INK[0] + (C_EMBER[0] - C_INK[0]) * cool * 0.5;
g = C_INK[1] + (C_EMBER[1] - C_INK[1]) * cool * 0.5;
b = C_INK[2] + (C_EMBER[2] - C_INK[2]) * cool * 0.5;
} else {
// 미연 연료 지형 — 연료량이 smoke 톤 명암
var shade = 0.06 + f * 0.16;
r = C_INK[0] + (C_SMOKE[0] - C_INK[0]) * shade;
g = C_INK[1] + (C_SMOKE[1] - C_INK[1]) * shade;
b = C_INK[2] + (C_SMOKE[2] - C_INK[2]) * shade;
}
d[j] = r | 0; d[j + 1] = g | 0; d[j + 2] = b | 0; d[j + 3] = 255;
}
bctx.putImageData(img, 0, 0);
// 셀 1픽셀 버퍼를 캔버스 CSS 크기로 확대 — 매끈한 셀 블록
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, W, H);
ctx.drawImage(buf, 0, 0, W, H);
}
// 다 탄 칸 수(소화전) — 진행률 라벨
function burntCount() {
var n = 0;
for (var i = 0; i < fuel.length; i++) if (fuel[i] <= 0) n++;
return n;
}
function frame() {
frameNo++;
if (frameNo % sparkEvery === 1 && frameNo < sparkEvery * 14) {
spark(sparkAt++);
}
sim();
render();
firesOut.textContent = String(burntCount());
raf = requestAnimationFrame(frame);
}
// 키보드 보조 — 엔터로 불씨 하나 추가(자동 점화와 같은 규칙, 무작위 셀)
field.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
var gx = 1 + Math.floor(rand() * (COLS - 2));
var gy = 1 + Math.floor(rand() * (ROWS - 2));
heat[gy * COLS + gx] = 1.0;
if (reduce) { sim(); render(); firesOut.textContent = String(burntCount()); }
});
window.addEventListener('resize', function () { fit(); if (reduce) still(); });
// 정지판 — 시뮬을 몰아 전진해 화재가 한창 번진 한 장을 담는다
function still() {
spark(0); spark(1);
for (var s = 0; s < 40; s++) sim();
render();
firesOut.textContent = String(burntCount());
}
fit();
if (reduce) {
// reduce — 루프 없이 번진 한 장
root.classList.remove('is-demo');
root.classList.add('is-still');
still();
} else {
raf = requestAnimationFrame(frame);
}
})();// FuelSpreadGrid — vanilla 판(run/314/demo/09-fire-spread-fuel-grid/index.html) 의 React 포트.
// 셀 격자마다 연료(fuel)를 저장하고, 타는 칸(heat > 점화 문턱)이 이웃 8칸에 열을 넘기며
// 자기 연료를 닳는다 — 연료 0칸은 꺼진다. 렌더는 ImageData 픽셀을 셀 단위로 직접 써서
// putImageData 로 올린다(셀당 1픽셀 버퍼를 캔버스로 확대). 자동 재생만 — 실측 입력 없음.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FuelSpreadGrid.module.scss";
export type FuelSpreadGridProps = {
duration?: string;
easing?: string;
color?: string;
cols?: number; // 격자 가로 칸 수 — 변형 "격자 촘촘함" 축(72 → 168)
spread?: number; // 이웃 열 전달률 — 변형 "번짐 속도" 축
burnRate?: number; // 프레임당 연료 소모율 — 변형 "지속력" 축
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type RGB = [number, number, number];
type Cols = { ink: RGB; amber: RGB; gold: RGB; rose: RGB; ember: RGB; smoke: RGB };
type Grid = { rows: number; fuel: Float32Array; heat: Float32Array; nheat: Float32Array };
const IGNITE = 0.34; // 점화 문턱
const SPARK_EVERY = 30; // 자동 점화 간격(프레임) — 시드 고정 순서로 옮겨 다닌다
const FIELD_LABEL = "불씨 확산 실험판";
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 지형·같은 번짐으로 재생된다
const SEED = 0x5eed09;
let prngState = SEED >>> 0;
function rand(): number {
prngState = (prngState + 0x6D2B79F5) >>> 0;
let 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;
}
function resetRand(): void { prngState = SEED >>> 0; }
export default function FuelSpreadGrid({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f0a868",
cols = 120,
spread = 0.115,
burnRate = 0.0032,
}: FuelSpreadGridProps) {
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<Cols | null>(null);
const colsRef = useRef(cols) as Box<number>;
const spreadRef = useRef(spread) as Box<number>;
const burnRateRef = useRef(burnRate) as Box<number>;
const gridRef = useRef<Grid>({ rows: 0, fuel: new Float32Array(0), heat: new Float32Array(0), nheat: new Float32Array(0) }) as Box<Grid>;
const bufRef = useRef<HTMLCanvasElement | null>(null);
const bctxRef = useRef<CanvasRenderingContext2D | null>(null);
const imgRef = useRef<ImageData | null>(null);
const rafRef = useRef(0) as Box<number>;
const frameNoRef = useRef(0) as Box<number>;
const sparkAtRef = useRef(0) as Box<number>;
const [grid, setGrid] = useState("120×52");
const [fires, setFires] = 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;
}
// 색은 CSS 커스텀 프로퍼티(--fire-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
// ── 지형 생성 — 연료 지도. 2옥타브 값 노이즈로 "연료 많은 숲 / 적은 초원" 덩어리를 만든다 ──
function valueNoiseGrid(): void {
const COLS = colsRef.current;
const ROWS = gridRef.current.rows;
const coarse = new Float32Array(9 * 5);
for (let c = 0; c < coarse.length; c++) coarse[c] = rand();
const fine = new Float32Array(17 * 9);
for (let f = 0; f < fine.length; f++) fine[f] = rand();
const g = gridRef.current;
const n = COLS * ROWS;
g.fuel = new Float32Array(n);
g.heat = new Float32Array(n);
g.nheat = new Float32Array(n);
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
// 굵은 노이즈 쌍선형 보간
const gx = x / COLS * 8, gy = y / ROWS * 4;
const x0 = Math.min(7, gx | 0), y0 = Math.min(3, gy | 0);
const fx = gx - x0, fy = gy - y0;
const c00 = coarse[y0 * 9 + x0], c10 = coarse[y0 * 9 + x0 + 1];
const c01 = coarse[(y0 + 1) * 9 + x0], c11 = coarse[(y0 + 1) * 9 + x0 + 1];
const v1 = c00 + (c10 - c00) * fx;
const v2 = c01 + (c11 - c01) * fx;
const coarseV = v1 + (v2 - v1) * fy;
// 가는 노이즈(16×8)
const gx2 = x / COLS * 16, gy2 = y / ROWS * 8;
const x02 = Math.min(15, gx2 | 0), y02 = Math.min(7, gy2 | 0);
const fx2 = gx2 - x02, fy2 = gy2 - y02;
const f00 = fine[y02 * 17 + x02], f10 = fine[y02 * 17 + x02 + 1];
const f01 = fine[(y02 + 1) * 17 + x02], f11 = fine[(y02 + 1) * 17 + x02 + 1];
const w1 = f00 + (f10 - f00) * fx2;
const w2 = f01 + (f11 - f01) * fx2;
const fineV = w1 + (w2 - w1) * fy2;
// 2옥타브 합성 + 하단 가중(아래쪽이 연료 진한 숲)
const v = coarseV * 0.7 + fineV * 0.3 + (y / ROWS) * 0.28 + 0.12;
g.fuel[y * COLS + x] = Math.max(0.08, Math.min(1, v));
}
}
}
// 점화 — 시드 고정 순서로 옮겨 다니는 불씨(리사주)
function spark(k: number): void {
const COLS = colsRef.current;
const ROWS = gridRef.current.rows;
const g = gridRef.current;
const th = (k * SPARK_EVERY / 8.1 % 1) * Math.PI * 2;
const gx = Math.round((0.5 + Math.sin(th) * 0.36) * (COLS - 1));
const gy = Math.round((0.72 + Math.sin(th * 2 + 1.2) * 0.2) * (ROWS - 1));
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const x = gx + dx, y = gy + dy;
if (x < 0 || y < 0 || x >= COLS || y >= ROWS) continue;
g.heat[y * COLS + x] = 1.0;
}
}
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const size = sizeRef.current;
const r = field.getBoundingClientRect();
size.w = r.width; size.h = r.height;
cv.width = Math.round(size.w); cv.height = Math.round(size.h);
ctxRef.current = ctx;
const COLS = colsRef.current;
gridRef.current.rows = Math.max(24, Math.round(COLS * size.h / size.w));
resetRand(); // 지형이 창 크기와 무관하게 같은 시드에서
valueNoiseGrid();
frameNoRef.current = 0; sparkAtRef.current = 0;
// 셀당 1픽셀 버퍼 — putImageData 로 올린 뒤 캔버스 크기로 확대
const buf = document.createElement("canvas");
buf.width = COLS; buf.height = gridRef.current.rows;
bctxRef.current = buf.getContext("2d");
imgRef.current = bctxRef.current ? bctxRef.current.createImageData(COLS, gridRef.current.rows) : null;
bufRef.current = buf;
setGrid(COLS + "×" + gridRef.current.rows);
}
// ── 시뮬 한 프레임 ──
// 타는 칸(heat > IGNITE)의 연료가 닳고, 이웃 8칸으로 열을 넘긴다. 열은 프레임마다 식는다.
function sim(): void {
const COLS = colsRef.current;
const g = gridRef.current;
const ROWS = g.rows;
for (let i = 0; i < g.heat.length; i++) {
const f = g.fuel[i];
if (g.heat[i] > IGNITE && f > 0) {
// 연료 소모 — 남은 연료에 비례해 잘 탄다(연료가 많으면 열도 세다)
const burn = Math.min(f, burnRateRef.current * (0.4 + f));
g.fuel[i] = f - burn;
const give = spreadRef.current * (0.3 + f * 0.7);
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = (i % COLS) + dx, ny = ((i / COLS) | 0) + dy;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
// 대각선은 거리 1.41 배 — 열이 멀리는 덜 간다
g.nheat[ny * COLS + nx] += give / (dx && dy ? 1.41 : 1);
}
}
// 타는 동안 자기 열도 유지된다
g.nheat[i] += give * 0.8;
}
// 냉각 — 열은 매 프레임 식는다
g.nheat[i] += g.heat[i] * 0.72;
}
const t = g.heat; g.heat = g.nheat; g.nheat = t;
g.nheat.fill(0);
}
// ── 렌더 — 셀당 픽셀 하나를 ImageData 에 직접 쓴다 ──
function render(): void {
const ctx = ctxRef.current, col = colRef.current, img = imgRef.current, bctx = bctxRef.current, buf = bufRef.current;
if (!ctx || !col || !img || !bctx || !buf) return;
const g = gridRef.current;
const d = img.data;
const frameNo = frameNoRef.current;
for (let i = 0, j = 0; i < g.heat.length; i++, j += 4) {
const f = g.fuel[i], h = g.heat[i];
let r: number, gg: number, b: number;
if (h > IGNITE && f > 0) {
// 활성 화염 — heat 정규화 색온도(ember↔gold). 최고온 도트에 로즈
const hh = Math.min(1, (h - IGNITE) / 0.9);
if (hh > 0.72 && ((i * 7 + frameNo) & 15) === 0) {
r = col.rose[0]; gg = col.rose[1]; b = col.rose[2];
} else {
r = col.ember[0] + (col.gold[0] - col.ember[0]) * hh;
gg = col.ember[1] + (col.gold[1] - col.ember[1]) * hh;
b = col.ember[2] + (col.gold[2] - col.ember[2]) * hh;
}
} else if (f <= 0) {
// 소진된 칸 — 잉크보다 살짝 밝은 검댓. 남은 열만큼 식어 간다
const cool = Math.max(0, h);
r = col.ink[0] + (col.ember[0] - col.ink[0]) * cool * 0.5;
gg = col.ink[1] + (col.ember[1] - col.ink[1]) * cool * 0.5;
b = col.ink[2] + (col.ember[2] - col.ink[2]) * cool * 0.5;
} else {
// 미연 연료 지형 — 연료량이 smoke 톤 명암
const shade = 0.06 + f * 0.16;
r = col.ink[0] + (col.smoke[0] - col.ink[0]) * shade;
gg = col.ink[1] + (col.smoke[1] - col.ink[1]) * shade;
b = col.ink[2] + (col.smoke[2] - col.ink[2]) * shade;
}
d[j] = r | 0; d[j + 1] = gg | 0; d[j + 2] = b | 0; d[j + 3] = 255;
}
bctx.putImageData(img, 0, 0);
// 셀 1픽셀 버퍼를 캔버스 CSS 크기로 확대 — 매끈한 셀 블록
const { w: W, h: H } = sizeRef.current;
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, W, H);
ctx.drawImage(buf, 0, 0, W, H);
}
// 다 탄 칸 수(소화전) — 진행 라벨. 값이 바뀔 때만 setState 한다
function reportBurnt(): void {
const g = gridRef.current;
let n = 0;
for (let i = 0; i < g.fuel.length; i++) if (g.fuel[i] <= 0) n++;
setFires((prev) => (prev === n ? prev : n));
}
function frame(): void {
frameNoRef.current++;
const f = frameNoRef.current;
if (f % SPARK_EVERY === 1 && f < SPARK_EVERY * 14) {
spark(sparkAtRef.current++);
}
sim();
render();
reportBurnt();
rafRef.current = requestAnimationFrame(frame);
}
// 키보드 보조 — 엔터로 불씨 하나 추가(자동 점화와 같은 규칙, 무작위 셀)
function onKeyDown(e: KeyEvt): void {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
const COLS = colsRef.current;
const g = gridRef.current;
const gx = 1 + Math.floor(rand() * (COLS - 2));
const gy = 1 + Math.floor(rand() * (g.rows - 2));
g.heat[gy * COLS + gx] = 1.0;
if (reduced()) { sim(); render(); reportBurnt(); }
}
// 정지판 — 시뮬을 몰아 전진해 화재가 한창 번진 한 장을 담는다
function stillFrame(): void {
spark(0); spark(1);
for (let s = 0; s < 40; s++) sim();
render();
reportBurnt();
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
colsRef.current = cols;
spreadRef.current = spread;
burnRateRef.current = burnRate;
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"),
};
fit();
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프 없이 번진 한 장
stillFrame();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cols, spread, burnRate]);
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="M208 144a80 80 0 0 1-160 0c0-30.57 14.42-58.26 31-80l33 32l26.27-72C159.86 41.92 208 88.15 208 144" opacity=".2"/><path d="M183.89 153.34a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68ZM216 144a88 88 0 0 1-176 0c0-27.92 11-56.47 32.66-84.85a8 8 0 0 1 11.93-.89l24.12 23.41l22-60.41a8 8 0 0 1 12.63-3.41C165.21 36 216 84.55 216 144m-16 0c0-46.09-35.79-85.92-58.21-106.33l-22.27 61.07a8 8 0 0 1-13.09 3L80.06 76.16C64.09 99.21 56 122 56 144a72 72 0 0 0 144 0"/></g></svg>
<span>각 칸이 연료를 품고, 타는 칸이 이웃에 열을 넘기며 연료가 닳습니다 — 다 탄 자리는 꺼집니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true">
<span className={styles.badgeNum}>{grid}</span>
<span className={styles.badgeUnit}>연료 격자</span>
</b>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{fires}</b>개 소화전 · 연료 총량 100%</span>
<span className={styles.metaDim}>ImageData · putImageData</span>
</div>
</div>
</main>
);
}09 · 火種の拡散 — 燃料セルが燃えて広がる格子 — 計測 累積面積 52.501% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
8隣接への熱の分配、対角は割り引く
if (heat[i] > IGNITE) {
if (f > 0) {
var burn = Math.min(f, BURN_RATE * (0.4 + f));
fuel[i] = f - burn;
var give = SPREAD * (0.3 + f * 0.7);
for (dy = -1; dy <= 1; dy++) {
for (dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
nx = (i % COLS) + dx; ny = ((i / COLS) | 0) + dy;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
ni = ny * COLS + nx;
nheat[ni] += give / (dx && dy ? 1.41 : 1); // 対角は遠い
}
}
}
}
COLS120列の格子で、燃えているセルがBURN_RATE0.0032ずつ燃料を減らしながらSPREAD0.115の割合で熱を配ります。燃料が尽きたマスは二度と燃えない点が、火が地図を食い潰していく理由です。実測では9項目で最も広い52.501%の画面がフレーム間で変化しました。
炎エフェクトを実際に使う画面
成功メッセージやイベントページのお祝い演出には、重力と残り火で散っていく06の花火の爆発が合います。決済完了や目標達成の瞬間に一度だけ弾けさせれば十分です。ダークモードの切り替えや照明アイコンの場所には、後光だけがガラスの奥に滲む04のランプの炎が馴染みます。
ヒーロー背景に静かな動きを足したいときは、残像を残して流れる08の流星や、最小の単位から始まる01のろうそく一本を低い強度で敷いておきます。
浮力の値を上げすぎると炎がここで壊れる落とし穴
燃料消費率を上げると軌跡が点線になる
06の花火は初めBURNを0.62に置いたところ、破片が2〜3フレームでk0.34を下回り、軌跡が点線のように途切れて見えました。描画そのものは正常で、色温度の階段を速く通り過ぎる消費率が悪かった — 0.34まで下げて初めて残り火がフレームのあいだを埋めるだけ生き残りました。08の冷却率も同じ落とし穴で、短い残像側の値ではなくCOOL0.017と低く取って残像がフレームを埋め、その分プールPOOL1,800も一緒に大きくしました。zipのソースとReact版を開いてこの値を直接揺らしてみるには、解凍のパスワードcb4svrhmを入れてください。
拡散係数が低いと格子全体が循環に入らない
05の溶岩はK_DIFFを0.24に置いたとき、火口の周りの数マスだけが明るく、残りの格子は永遠に閾値を超えませんでした。隣接4マスの平均で運ぶ熱の量が放射冷却に勝てないと、拡散が冷却に食われて画面の半分が死んだ絵として残ります。0.34まで上げて初めて格子全体が温まり冷え、循環に入りました。
格子の幅を狭めると対角割引が目立つ
09は対角の隣人に渡す熱を1.41(√2)で割らないと、火が正方形の形に広がって格子の筋が強く出ました。COLSを増やしてマスを狭くしてもこの割り算がなければ同じ問題が再現します。低解像度の格子で等方的に見せるには、マスの数より先にこの重み付けです。
粒子数ひとつで変わる3つのバリエーションとアクセシビリティ対応
定数ひとつで変わる3つのバリエーション
| 名前 | 変える値 | 手触り |
|---|---|---|
| パーティクル数 | 01ろうそく 240 → 480 | 炎が濃くなる代わりにフレームが重くなる |
| 浮力 | 02焚き火 BUOY 0.35 → 0.7 | 火柱が倍の高さに伸びるが薄くなる |
| 色 | $fire-amber → $fire-rose |
橙の炎がローズトーンのろうそくに変わる |
どれもソース先頭の定数ブロックの一行を変えるだけの軸です。同じ計算のまま値ひとつで違うムードの火が手に入るのが、気分ごとに手で作り直すキーフレームに対する正直な利点です。
動きを減らす設定で残る静止フレーム
prefers-reduced-motion: reduceが検知されたとき、9つ全部がrequestAnimationFrameのループを始めません。代わりに各項目が選んだ一時点を1回だけ描きます — 01はSTILL_T1.6秒(後光が最も読める時刻)、04は1.85秒、06は爆発3発が開いた瞬間、08は時間を進めて残像を作ってから1枚を納めます。格子の05と09は時間ではなく手順で進めます。05はSTILL_TICK210ステップを回して流れが落ち着いた一場面を、09はstill()関数が火種を2つ植えて40ステップ回し、燃え広がりが定位置に収まった一枚を描きます。計算はアニメーションと同じで画面だけ一度なので、品質は変わりません。
キーボードとポインタで触れる場面
02·05·07·09はrole="application"とtabindex="0"を持つ実験盤なので、タブで焦点を移せます。02·05·07はポインタ(タッチを含む)で直接触り、02は火力、05は新しい火口を置き、07は風の向きを変えます。09はポインタ入力がなく、Enter·Spaceキーを押すたびに格子の上の乱数の位置に新しい火種を植えます。07だけはreduced-motionではpointermoveの監視そのものを外して静止した一枚を保ちますが、矢印キーなら静止画を1枚描き直せます。
炎エフェクトを組み込む前によくある質問
cssの炎アニメーションとキャンバスの炎アニメーションは何が違いますか?
CSSアニメーションは決めたキーフレームの間をブラウザが補間する方式なので、揺れの規則がいくつかの値に制限されます。ここにある9つは毎フレーム燃料·浮力·拡散を計算し直すキャンバスの炎 アニメーションなので、シードが同じでも毎瞬違う組み合わせが生まれ、ポインターや格子との相互作用のようにCSSのキーフレームでは作りにくい反応も入ります。
ドット 絵 炎 アニメーションみたいなレトロな見た目にもこの手法は使えますか?
使えます。09の燃料格子はそのままドット絵の炎の骨組みです — マスを大きく取れば取るほどピクセルが粗くなり、SPREADとBURN_RATEで燃え広がる速さと持続力を調整できます。05の閾値2段(溶ける·白く熱する)も、色パレットを段差の強いレトロな色に置き換えるだけでドット絵の炎に変わります。
08の流星だけなぜ23フレーム中21フレームしか動いていないのですか?
頭の軌道がリサージュ曲線で、画面の縁に近い折り返し点では頭が数フレームほとんど止まるためです。その間も尾は冷えながら縮んでいますが、隣接する2つのサンプルの間の移動距離が一定速度の直線ほど伸びず、動いたフレームとして計測の閾値に届きませんでした。軌道の計算自体は23フレーム全部で動いています。









