캔버스 불 애니메이션 9종 — 시드 고정 파티클로 그리는 불꽃 효과
불 애니메이션은 사진이나 스프라이트를 재생하는 것이 아니라, 연료 입자가 태어나고 열을 잃고 위로 떠오르는 규칙을 프레임마다 계산해서 그리는 결과입니다. 라이브러리·WebGL 없이 캔버스 픽셀 계산만으로 촛불부터 불씨 확산까지 아홉 장면을 실측했습니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 촛불 하나 — 심지 위에 세운 가장 작은 불
- 02 모닥불 — 컬링 노이즈가 흔드는 불기둥
- 03 횃불 — 바람 벡터를 등에 진 불꽃
- 04 램프 불꽃 — 유리 등 뒤로 번지는 후광
- 05 용암 흐름 — 격자 셀에 쌓이는 열
- 06 불꽃놀이 폭발 — 중력과 잔불의 산수
- 07 촛불과 바람 — 포인터를 향해 눕는 불꽃
- 08 유성 — 잔상이 남는 불의 궤적
- 09 불씨 확산 — 연료 셀이 타고 번지는 격자
연료 셀 하나의 규칙이 아홉 불꽃으로 갈라지는 이유
순서는 화려한 정도가 아니라 불이 무엇을 등에 지고 있는가로 갈립니다. 01 촛불은 연료 입자 하나가 태어나 부력으로 오르고 색온도로 식는 가장 작은 단위이고, 02 모닥불은 그 단위 여러 개에 컬(curl) 노이즈 바람을 얹어 한 덩어리로 넘실대게 만듭니다. 03 횃불과 07 촛불·바람은 같은 부력 위에 외부 벡터(고정 바람 · 포인터 방향)를 태워 불꽃이 한쪽으로 눕는 것을 봅니다. 04 램프는 파티클 대신 반경별 감쇠 겹을 쌓아 후광을, 06 불꽃놀이와 08 유성은 파티클에 중력·냉각을 더해 궤적을 그립니다. 05 용암과 09 불씨 확산은 파티클을 버리고 격자 셀에 열과 연료를 저장해 이웃과 주고받는 확산 계산으로 넘어갑니다 — 이 캔버스 애니메이션 아홉 종을 관통하는 축은 그림이 아니라 연료가 줄어드는 숫자 하나입니다.
불꽃 효과는 왜 계산으로 그리나? — 연료 셀·부력·컬링 난류의 셈
연료가 줄어드는 숫자 하나가 불을 만든다
아홉 판 전부의 공통 규칙은 하나입니다 — 어떤 값(파티클의 k 또는 격자 셀의 heat)이 시간에 따라 줄어들고, 그 값이 색과 크기를 정합니다. 연료가 많으면 밝고 크게, 줄면 어둡고 작게 그리는 것만으로 "타오르다 사그라든다"는 인상이 계산에서 저절로 나옵니다. 스프라이트 시트를 재생하는 방식과 다른 점이 여기 있는데, 프레임 수를 늘려도 파일 용량이 늘지 않고 포인터 입력에 그 자리에서 반응할 수 있습니다.
부력은 위로 가는 속도가 아니라 가속이다
부력을 단순히 "y 속도를 음수로" 주면 불꽃이 로켓처럼 일정 속도로 솟아 뻣뻣해 보입니다. 01·04는 파티클이 태어날 때마다 vy 를 서로 다른 무작위 값으로 한 번 정해 두어 입자마다 오르는 속도가 갈리게 하고, 02는 한 걸음 더 나아가 vy 를 매 프레임 컬 필드 목표값 쪽으로 서서히 당기는 Math.min(1, dt * 3.2) 보간에 BUOY * 190 이라는 상수 가속까지 더해 컬 필드가 흔드는 방향과 위쪽 힘이 함께 섞이게 합니다. 무작위 초기 속도든 매 프레임 보간이든, 입자마다 다른 순간에 다른 세기로 흔들리게 만드는 것이 로켓처럼 뻣뻣해 보이지 않게 하는 핵심입니다.
컬링 난류는 회전이 있는데 흩어지지 않는 바람이다
02의 컬(curl) 노이즈는 포텐셜 필드 ψ 의 회전을 구해 만든 바람장이라, 발산이 0이라는 성질 때문에 입자가 한곳에 모이거나 완전히 흩어지지 않고 서로 감고 도는 소용돌이만 만듭니다. 07의 포인터 바람이 "한 점을 향한 직선 힘"이라면 02의 컬 바람은 "장 전체가 회전하는 힘"이라는 차이가, 모닥불이 한 다발로 넘실대고 촛불이 한쪽으로만 눕는 서로 다른 인상을 만드는 이유입니다.
격자 확산은 파티클 없이 연소를 옮긴다
05·09는 파티클을 아예 쓰지 않습니다. 화면을 저해상도 셀로 나누고 이웃 셀과 값을 주고받는 확산 계산(nh = c + K_DIFF * (avg - c), nheat[ni] += give)만으로 불이 번지거나 굳는 과정을 흉내 냅니다. 파티클 방식은 개별 불씨의 움직임을, 격자 방식은 연소가 지도 위를 먹어 들어가는 면적의 변화를 보여줘서 같은 "불 애니메이션 그리기"라도 보여주려는 것이 다릅니다.
항목별 코드
01촛불 하나 — 심지 위에 세운 가장 작은 불
명상·야간 무드 페이지의 배경 촛불로 쓰는, 심지 끝에서 파티클이 태어나 부력을 받아 올라가며 노랑에서 주황·빨강으로 식는 촛불입니다 — 실측 누적면적 38.292%(화면의 38.292%가 프레임 사이 바뀝니다) · 강도 평균 35.8 · 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
연료 입자 하나가 촛불이 되는 규칙
// ── 연료 입자 — 심지 끝에서 태어나 부력으로 오르며 식는다 ──
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);
}
파티클마다 가진 값은 위치·속도·연료 k 하나뿐이고, N_PART 240개가 매초 K_BURN(2.3) 만큼 k 를 잃다가 0이 되면 심지에서 다시 태어나는 순환이 촛불의 전부입니다. 반경별 빛 겹은 LAYERS 13장을 FALLOFF 1.05로 감쇠시켜 쌓고 SMK 14개의 연기가 따로 떠오르며, 포인터를 옮기면 x 는 바람·y 는 화력이 되어 power가 부력과 후광 세기를 함께 밀어 올립니다.
02모닥불 — 컬링 노이즈가 흔드는 불기둥
캠핑·아웃도어 랜딩의 히어로 배경으로 쓰는, 포텐셜 필드의 회전(컬)을 샘플링해 장작 여러 개가 한 덩어리로 넘실대는 모닥불입니다 — 실측 누적면적 37.507% · 강도 평균 45.5 · 23/23 프레임이 움직였습니다.

<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">curl noise · 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
포텐셜 필드의 컬을 접선 바람으로 쓰는 법
// ── 연료 입자 — 컬 바람을 받아 접선으로 흘러간다 ──
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;
}
값노이즈 격자로 만든 포텐셜 필드 ψ 의 회전을 해석적으로 계산해 curl(x, y, t) 로 얻고 각 입자를 그 방향에 서서히 수렴시키면(Math.min(1, dt * 3.2) 관성), 발산 없는 벡터장이라 입자가 모이지도 흩어지지도 않고 서로 감고 도는 것이 불기둥이 "넘실댄다"고 느껴지는 이유입니다. N_COL 3개의 장작이 N_EACH 150개씩 총 450개 입자를 나눠 가지고 BUOY 계수 0.35가 위로 밀어 올립니다.
03횃불 — 바람 벡터를 등에 진 불꽃
게임·이벤트 페이지의 성화 횃불로 쓰는, 일정한 바람 벡터에 느린 요동을 더해 불꽃 전체가 한 방향으로 쓸리는 횃불입니다 — 실측 누적면적 34.055% · 강도 평균 34.3 · 23/23 프레임이 움직였습니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
고정 벡터 위에 얹는 느린 숨쉬기
var strength = 0.5 + px01; // 포인터 x — 바람 세기 배율(0.5~1.5)
var dirTilt = (py01 - 0.5) * 0.7; // 포인터 y — 바람 방향 미세 회전
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;
WIND_VX 46 · WIND_VY −6 이 이 판의 기본 바람이고, 그 위에 WOBBLE * Math.sin(tSec * 0.35) 로 저주파 각도 흔들림 하나만 더해야 불꽃이 뻣뻣하게 죽지 않고 성화가 "숨 쉬며 눕는" 것처럼 보입니다. LAYERS 15장의 앙비언트 글로우가 FALLOFF 0.9로 감쇠하며 밤의 벌판을 밝힙니다.
04램프 불꽃 — 유리 등 뒤로 번지는 후광
고즈넉한 상점·호텔 소개 화면에 쓰는, 반경별로 감쇠하는 빛 겹을 더해 유리 램프 안에서 흔들리는 불빛이 주변을 데우는 장면입니다 — 실측 누적면적 43.983% · 강도 평균 46.8 · 23/23 프레임이 움직였습니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
createRadialGradient 로 쌓는 온도 겹
// 안쪽 겹은 불꽃온도(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);
LAYERS 11장을 createRadialGradient 로 겹마다 다시 만들어 lighter 합성으로 쌓으면 중심부의 뜨거운 금빛과 바깥 실내 온도의 로즈 톤이 자연스럽게 섞입니다. K_BURN 1.35로 소모율을 낮게 잡아 파티클 각각이 오래 살아 후광이 자잘하게 끊기지 않고, STILL_T 1.85초 시점이 후광이 가장 넓게 퍼져 읽히는 순간입니다.
05용암 흐름 — 격자 셀에 쌓이는 열
게임 배경·화산 콘셉트 소개 화면에 쓰는, 화면을 셀로 나눠 이웃끼리 열을 주고받고 임곗값 넘는 셀만 용암색으로 그리는 흐름입니다 — 실측 누적면적 39.884% · 강도 평균 17.0 · 23/23 프레임이 움직였습니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
이웃 평균과 방사 냉각의 확산 규칙
// ②③ 확산 + 부유 편향
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); // 방사 냉각 — 열이 셀수록 더 식는다
if (nh < 0) nh = 0;
if (nh > 1.4) nh = 1.4;
이웃 4칸의 평균을 낼 때 아래쪽에 0.9·1.1로 비대칭 가중을 줘서 열이 확산만 하지 않고 위로 자라는 흐름처럼 보이게 하고, K_DIFF(확산 계수) 0.34는 낮추면 화구 주변만 밝고 격자 전체가 순환에 들지 않아 재튜닝한 값입니다. 밝기가 T_MELT 0.62를 넘으면 녹은 용암색, T_GLOW 0.86을 넘으면 하얗게 달아오르며, VENT_N 5개의 화구가 하단 폭 전체에 고르게 퍼집니다.
06불꽃놀이 폭발 — 중력과 잔불의 산수
축하·연말 랜딩의 하이라이트로 쓰는, 한 점에서 튀어나간 파편이 중력으로 포물선을 그리며 색온도대로 식어 사라지는 폭발입니다 — 실측 누적면적 39.459% · 강도 평균 122.2 · 23/23 프레임이 움직였습니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
색온도 사다리를 내려가는 파편
// k > 0.92 : 흰 섬광(터진 직후)
// 0.92..0.64 : gold → amber (활활)
// 0.64..0.34 : amber → ember (식어가는 주황)
// 0.34..0.12 : ember → ink+연기 (잔불)
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;
}
GRAV 34가 파편을 아래로 당기고 DRAG 0.985가 매 프레임 속도를 깎아 궤적을 둥글게 처지게 하며, 연료 소모율 BURN 은 값이 크면 잔불이 프레임 사이에서 끊겨 점선처럼 보이던 것을 0.62에서 0.34로 낮춰 재튜닝했습니다. PER_BURST 170개가 한 번 터질 때의 파편 수이고 MAXP(1,700)는 여러 번 터뜨려도 버틸 풀 크기입니다.
07촛불과 바람 — 포인터를 향해 눕는 불꽃
인터랙티브 카드·특별한 순간의 장식으로 쓰는, 포인터에서 불꽃까지의 방향을 바람 벡터로 바꿔 손을 다가갈 때마다 눕는 촛불입니다 — 실측 누적면적 51.817% · 강도 평균 26.7 · 23/23 프레임이 움직였습니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
거리 역제곱으로 줄어드는 바람 세기
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];
}
이 항목만 pointermove 실측 좌표를 그대로 받아 심지까지의 방향 벡터를 만들고, 거리가 멀어질수록 Math.pow(60 / dist, FALLOFF)(FALLOFF 2.0)로 세기를 급하게 줄입니다. WIND_MAX 3.2가 심지에 포인터가 붙었을 때의 최대 가속이고, IDLE_MS 2,400ms 동안 조용하면 시드 고정 미풍(breeze 함수의 이중 사인파)으로 되돌아갑니다.
08유성 — 잔상이 남는 불의 궤적
야경·날씨 콘셉트 페이지의 배경으로 쓰는, 머리 뒤에 파티클 꼬리를 붙이고 지나간 자리를 천천히 식혀 대기를 가로지르는 유성입니다 — 실측 누적면적 47.778% · 강도 평균 29.7 · 21/23 프레임이 움직였습니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
진행 방향의 반대로 흩뿌리는 꼬리
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
);
}
머리의 진행 방향 단위벡터를 구해 그 반대 방향으로 TAIL_N 42개를 매 프레임 흩뿌리고, 냉각률 COOL 을 0.05에서 0.017로 낮춰 잔상을 더 오래 남기는 만큼 POOL(1,800개) 풀도 함께 키웠습니다. 이 항목은 21/23 프레임만 움직인 값이 실측됐는데, 궤적 진입·소멸 구간 두 프레임은 화면 경계 밖이라 픽셀 변화가 잡히지 않은 것입니다.
09불씨 확산 — 연료 셀이 타고 번지는 격자
게임 상태 화면·시뮬레이션 소개에 쓰는, 각 셀에 연료를 저장해 두고 붙은 셀이 이웃에게 열을 넘기며 연료가 닳는 규칙으로 지도 위를 먹어 들어가는 불꽃 표현입니다 — 실측 누적면적 52.501% · 강도 평균 92.6 · 23/23 프레임이 움직였습니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
8칸 이웃에 열을 나눠 주는 연소
var IGNITE = 0.34;
function sim() {
var i, x, y, dx, dy, nx, ny, ni;
for (i = 0; i < heat.length; i++) {
var f = fuel[i];
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); // 대각선은 1.41배 멀어 덜 번진다
COLS 120칸 격자에서 열이 IGNITE 임곗값 0.34를 넘은 칸만 연료 f를 BURN_RATE(0.0032) 만큼 소모하며 8개 이웃(대각선 포함)에 SPREAD 0.115 비율로 열을 나눠 줍니다. 대각선 이웃은 물리적으로 √2배 멀기 때문에 전달량을 1.41로 나눠 등방적으로 번지게 하는 것이 이 캔버스 구현에서 가장 눈에 띄는 불꽃 표현의 계산 축입니다.
불꽃 이펙트를 실제로 쓰는 화면
성공 메시지나 이벤트 페이지의 축하 연출에는 06 불꽃놀이 폭발처럼 중력과 잔불로 흩어지는 장면이 맞습니다 — 결제 완료나 목표 달성 순간에 한 번만 터뜨리면 됩니다. 다크 모드 토글이나 조명 아이콘 자리에는 04 램프 불꽃처럼 후광만 번지는 작은 장면이 어울립니다.
히어로 배경에 잔잔한 움직임을 더하고 싶다면 08 유성처럼 잔상을 남기며 지나가는 궤적이나, 01 촛불 하나처럼 가장 작은 단위로 시작하는 장면을 낮은 강도로 깔아 둡니다.
부력 값을 과하게 올리면 불꽃이 여기서 깨지는 함정
연료 소모율을 올리면 궤적이 끊긴다
06 불꽃놀이는 처음 BURN 값을 0.62로 뒀더니 파편이 두세 프레임 만에 k 가 0.34 밑으로 떨어져 궤적이 점선처럼 끊겨 보였습니다. 렌더링 자체엔 문제가 없었고, 색온도 사다리를 너무 빨리 통과하는 소모율이 문제였습니다 — 0.34로 낮추고서야 잔불 구간이 프레임 사이를 채울 만큼 오래 남았습니다. 캔버스 렌더링에서 "빨리 사라진다"는 인상은 대개 알파가 아니라 이런 소모 계수 하나가 만듭니다. 08의 냉각률도 같은 함정이라 짧은 잔상 쪽 값(0.05)이 아니라 COOL 을 0.017로 낮게 잡아야 잔상이 프레임 사이를 채울 만큼 오래 남았고, 그만큼 풀 크기 POOL 도 같이 키워야 했습니다.
확산 계수가 낮으면 격자 전체가 순환에 들지 않는다
05 용암은 K_DIFF 를 0.24로 뒀을 때 화구 주변 몇 칸만 밝고 나머지 격자는 영영 임곗값을 못 넘었습니다. 이웃 4칸 평균으로 옮기는 열의 양이 방사 냉각(COOL)을 못 이기면 확산이 냉각에 잡아먹혀 화면 절반이 죽은 그림으로 남습니다. 0.34까지 올리고서야 격자 전체가 데워졌다 식었다 순환에 들었습니다. 아홉 판의 zip 안 소스와 React 판을 열어 이 값을 직접 흔들어 보려면 암호 zip 을 열어야 하고, 여는 암호는 cb4svrhm 입니다.
격자 폭을 좁히면 대각선 확산이 어색해진다
09는 대각선 이웃에 준 열을 1.41(√2)로 나누지 않으면 불이 정사각형 모양으로 번져 격자 티가 심하게 났습니다. COLS 를 늘려 칸을 좁혀도 이 나눗셈이 없으면 같은 문제가 재현되는데, 저해상도 격자에서 등방적으로 보이려면 칸 수보다 이 가중치가 먼저입니다.
입자 수 하나로 갈리는 세 변형과 접근성 처리
값 하나를 바꾼 세 변형
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 파티클 수 | 01 촛불 파티클 240 → 480 | 불꽃이 짙어지는 대가로 프레임이 무거워진다 |
| 부력 | 02 모닥불 상승 계수 BUOY 0.35 → 0.7 | 불기둥이 두 배로 솟지만 옅어진다 |
| 색 | $fire-amber → $fire-rose | 주황 불꽃이 로즈 톤 촛불로 바뀐다 |
셋 다 소스 맨 위 변형 상수 한 줄만 고치는 축입니다. 이런 변형은 파일을 통째로 다시 쓰지 않고 값 하나만 조절해 다른 무드의 불로 바꿀 수 있다는 것을 보여 주는 자리이기도 합니다.
움직임을 줄인 사용자에게 남는 정지 프레임
prefers-reduced-motion: reduce 를 켠 사용자에게는 [requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) 루프를 아예 시작하지 않고, 그 판이 가장 잘 읽히는 한 시각(STILL_T)만 그립니다 — 01은 1.6초(연기까지 차오른 순간), 04는 1.85초(후광이 가장 넓게 퍼진 순간), 05는 격자 판이라 STILL_TICK 210스텝을 미리 돌려 흐름이 안정된 한 장을 보여 주고, 09는 still() 함수가 불씨 두 개를 심은 뒤 40스텝을 미리 돌려 번짐이 자리 잡은 한 장을 보여 줍니다. 계산은 애니메이션을 켰을 때와 똑같이 하고 화면만 한 번 그리는 것이라 품질은 그대로입니다.
키보드·포인터로 만지는 세 판
02·05·07·09는 role="application" 과 tabindex="0" 을 가진 실험판이라 탭으로 초점을 옮길 수 있습니다 — 02·05·07은 포인터(터치 포함)로 직접 만져 02는 화력, 05는 새 화구, 07은 바람 방향을 바꾸고, 09는 포인터 대신 Enter·Space 키를 누를 때마다 격자 위 무작위 자리에 새 불씨를 심습니다. reduced-motion 사용자도 손이 닿는 즉시 한 프레임을 다시 계산해 그리므로 정지 화면이라도 반응 자체는 살아 있습니다.
불꽃 이펙트를 붙이기 전에 자주 나오는 질문
css 불꽃 애니메이션과 캔버스 불 애니메이션은 뭐가 다른가요?
CSS 애니메이션은 정해 둔 키프레임 사이를 브라우저가 보간하는 방식이라 흔들림의 규칙이 몇 개의 값으로 제한됩니다. 여기 아홉 판은 매 프레임 연료·부력·확산을 다시 계산하는 캔버스 애니메이션이라 시드가 같아도 매 순간 다른 조합이 나오고, 포인터·격자 인터랙션처럼 CSS 키프레임으로는 만들기 어려운 반응도 들어갑니다.
불 애니메이션 그리기를 처음 해봐도 이 코드를 그대로 붙여 쓸 수 있나요?
붙여 쓸 수 있습니다. 아홉 판 모두 라이브러리·빌드·서버 없이 캔버스와 순수 자바스크립트만으로 짜여 있어, 암호 zip 에서 바닐라 판을 꺼내 <script> 로 붙이면 그대로 움직입니다. 색은 각 판 scss 맨 위의 파스텔 토큰 변수($fire-amber 등)만 바꾸면 되고, React 판도 같은 zip 에 들어 있습니다.
08 유성만 왜 23/23이 아니라 21/23 프레임이 움직였나요?
궤적이 화면 경계를 드나드는 진입·소멸 두 프레임에서는 머리와 꼬리가 캔버스 밖에 있어 픽셀 변화량이 실측 도구가 잡는 임곗값 아래로 떨어졌기 때문입니다. 궤적 자체의 계산은 23프레임 내내 돌아가고 있고, 화면에 찍히는 픽셀만 두 프레임 적게 잡힌 것이라 눈으로 볼 때는 끊김이 느껴지지 않습니다.









