css fireworks 9가지 — 쏘아 올려 터지고 물 위에 비치는 불꽃놀이 장면
css fireworks 를 터뜨리려면 불꽃 하나가 아니라 알갱이 수백 개의 궤적부터 정해야 한다 — 01번 구면 분포와 07번 이차 분열이 그 궤적을 계산으로 짠다.
아홉 가지 불꽃놀이를 화면에서 바로 터뜨려 본다
01 fw-shell-sphere-burst-drag
02 fw-click-rocket-apex-launch
03 fw-peony-willow-ring-profiles
04 fw-additive-trail-history-glow
05 fw-ember-cooling-flicker-noise
06 fw-water-mirror-ripple-reflection
07 fw-crackle-secondary-split-depth
08 fw-skyline-falloff-light-volley
09 fw-pointer-wind-smoke-drift-hero
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 알갱이가 구 표면에 고르게 퍼지고 공기저항으로 속도가 깎이는 기본 폭발
- 02 클릭한 높이에서 정확히 정점을 찍고 터지는 포물선 발사체
- 03 모란·버들·고리가 속도 분포 하나로 갈라지는 세 가지 셸
- 04 과거 위치 이력을 잇고 겹칠수록 밝아지는 꼬리와 빛무리
- 05 흰빛에서 주황, 붉은 잿빛으로 식으며 깜빡이다 꺼지는 불씨
- 06 수면에 거꾸로 비치고 잔물결에 일그러지는 불꽃의 반사
- 07 알갱이가 수명 끝에서 다시 잘게 터지는 두 겹 크래클
- 08 터질 때마다 하늘이 물들고 건물 실루엣이 거리별로 밝아지는 연속 발사
- 09 포인터 바람에 연기 구름이 밀려나며 불꽃이 쏘아지는 히어로 장면
css fireworks 9가지 — 중력·항력·꼬리 이력으로 터지는 불꽃
먼저 밝혀 둘 것이 있다. 아홉 장면은 CSS 키프레임이 아니라 <canvas> 2D 에 알갱이를 한 개씩 계산해 그린다. 불꽃 하나가 알갱이 수십 개에서 수백 개이고, 알갱이마다 속도와 꼬리 위치가 프레임마다 달라서 키프레임으로 일일이 적기에는 수가 너무 많았다. CSS 는 무대의 크기와 비율, 터치 동작 같은 바깥 틀만 맡는다.
아홉 장면은 같은 뼈대를 쓰고 장면 함수만 갈아 끼운다. FIXED_DT 16ms 간격으로 step 을 돌려 알갱이를 옮기고 그릴 때마다 draw 를 부르며, 난수는 장면마다 시드를 정한 lcg 한 가지뿐이다. Math.random 은 아홉 파일에 한 번도 없어서 같은 시드는 같은 불꽃 순서를 낸다. 장면이 만들어질 때 PREWARM_STEPS 틱을 먼저 돌려 두기 때문에 첫 화면부터 불꽃이 공중에 떠 있고, 라이브러리·CDN·WebGL 도 쓰지 않는다.
계산의 중심은 한 줄이다. 매 틱 v ← v·DRAG + 중력 을 하면 속도가 지수로 줄어서 알갱이는 어딘가에서 멈춘다. 01 의 BURST_SPEED 3.4 와 DRAG 0.975 로 중력을 빼고 따지면 3.4 / 0.025 = 136px 가 도달 거리이고, 모란·버들·꼬리·크래클은 이 한 줄에 초속도 분포나 꼬리 길이, 분열 규칙을 얹은 차이다.
html css fireworks 로 찾아 들어왔다면 쓰임으로 출발점을 고르면 된다. 축하 이벤트 랜딩은 01, 클릭한 높이에서 터져야 하는 보상 연출은 02, 야간 히어로 배경은 04, 촛불 같은 감성 섹션은 05, 호수나 해변 테마는 06, 행사 마감 화면은 08, 제품 발표 히어로는 09 부터 열어 보자.
css fireworks on click 을 기법별 코드로 본다
아래 조각은 장면마다 index.html 의 <script> 에서 이어진 줄을 그대로 옮긴 것이고, 한 조각은 25줄을 넘기지 않는다. 함수 이름과 상수 이름은 소스 그대로라서 zip 을 열면 같은 글자를 검색해 찾을 수 있다.
01알갱이가 구 표면에 고르게 퍼지고 공기저항으로 속도가 깎이는 기본 폭발
알갱이 방향은 피보나치 격자로 나눠 구 표면에 고르게 흩고, 매 틱 속도에 DRAG 를 곱한 뒤 세로에만 GRAVITY 를 더한다. 깊이 z 는 화면에 찍히지 않고 알갱이 크기와 밝기만 바꾸므로 평면에 그려도 공처럼 보인다. 이 장면은 면적 23.0%·강도 86.8 이고 23/23 프레임에서 움직임이 잡혔다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="알갱이가 구 표면에 고르게 퍼지고 공기저항으로 속도가 깎이는 기본 폭발"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">구면 격자 폭발 — 항력이 속도를 깎는다</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">알갱이가 구 표면에 고르게 퍼지고 공기저항으로 속도가 깎이는 기본 폭발</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const PALETTE = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP = [18, 16, 32];
const SKY_BOT = [46, 40, 76];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x, seed) {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx, height) {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce() {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function () {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const BURST_N = 90;
const GRAVITY = 0.045;
const DRAG = 0.975;
const FUSE_MS = 1100;
const BURST_SPEED = 3.4;
const GRAIN_LIFE = 96;
const MAX_SHELLS = 5;
const PREWARM_STEPS = 30;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const TXT_AUTO = '구면 격자 폭발 — 항력이 속도를 깎는다';
const TXT_TAP = '누른 자리에서 터진다 — 알갱이가 구 표면에 고르다';
// 피보나치 격자 — 구 표면에 같은 간격으로 n 개의 방향을 나눈다
function sphereDirs(n) {
const out = [];
for (let i = 0; i < n; i += 1) {
const y = 1 - 2 * (i + 0.5) / n;
const r = Math.sqrt(1 - y * y);
const a = i * GOLDEN_ANGLE;
out.push([r * Math.cos(a), y, r * Math.sin(a)]);
}
return out;
}
// 한 프레임 — 속도에 항력 계수를 곱하고 아래 방향으로 중력을 더한다
function moveGrain(g) {
g.px = g.x;
g.py = g.y;
g.vx *= DRAG;
g.vy = g.vy * DRAG + GRAVITY;
g.vz *= DRAG;
g.x += g.vx;
g.y += g.vy;
g.z += g.vz;
}
function makeShell(x, y, n, rng, hueA, hueB) {
const dirs = sphereDirs(n);
const tilt = rng() * PI2;
const ct = Math.cos(tilt);
const st = Math.sin(tilt);
const grains = [];
for (let i = 0; i < dirs.length; i += 1) {
const d = dirs[i];
// 구를 x 축 둘레로 돌려 터질 때마다 격자 무늬가 다르게 놓인다
const dy = d[1] * ct - d[2] * st;
const dz = d[1] * st + d[2] * ct;
const sp = BURST_SPEED * (0.96 + rng() * 0.08);
grains.push({ x: x, y: y, z: 0, vx: d[0] * sp, vy: dy * sp, vz: dz * sp, px: x, py: y, hue: i % 2 === 0 ? hueA : hueB });
}
return { grains: grains, age: 0, cx: x, cy: y };
}
// 측정용 — 터진 뒤 steps 프레임이 지난 알갱이들이 6px 칸을 몇 개 채우는지, 이웃과의 평균 거리
function shellSpread(n, steps) {
const rng = lcg(7);
const sh = makeShell(0, 0, n, rng, PINK, MINT);
for (let s = 0; s < steps; s += 1) {
for (let i = 0; i < sh.grains.length; i += 1) {
moveGrain(sh.grains[i]);
}
}
const seen = {};
let cells = 0;
let total = 0;
for (let i = 0; i < sh.grains.length; i += 1) {
const a = sh.grains[i];
const key = Math.floor(a.x / 6) + ',' + Math.floor(a.y / 6);
if (!seen[key]) {
seen[key] = true;
cells += 1;
}
let best = 1e9;
for (let j = 0; j < sh.grains.length; j += 1) {
if (j !== i) {
const b = sh.grains[j];
best = Math.min(best, Math.hypot(a.x - b.x, a.y - b.y));
}
}
total += best;
}
return { cells: cells, nearest: total / sh.grains.length };
}
function makeScene(inp, reduce) {
const rng = lcg(457);
const FUSE_STEPS = Math.round(FUSE_MS / FIXED_DT);
let shells = [];
let fuse = 2;
let colorIdx = 0;
function burst(x, y) {
const a = PALETTE[colorIdx % PALETTE.length];
const b = PALETTE[(colorIdx + 2) % PALETTE.length];
colorIdx += 1;
shells.push(makeShell(x, y, BURST_N, rng, a, b));
if (shells.length > MAX_SHELLS) {
shells.shift();
}
}
const scene = {
step() {
fuse -= 1;
if (fuse <= 0) {
burst(90 + rng() * 300, 70 + rng() * 80);
fuse = FUSE_STEPS;
}
const alive = [];
for (let s = 0; s < shells.length; s += 1) {
const sh = shells[s];
sh.age += 1;
for (let i = 0; i < sh.grains.length; i += 1) {
moveGrain(sh.grains[i]);
}
if (sh.age < GRAIN_LIFE) {
alive.push(sh);
}
}
shells = alive;
},
draw(ctx) {
drawSky(ctx, H);
ctx.globalCompositeOperation = 'lighter';
for (let s = 0; s < shells.length; s += 1) {
const sh = shells[s];
const life = clamp(1 - sh.age / GRAIN_LIFE, 0, 1);
const alpha = Math.pow(life, 0.7);
if (sh.age < 10) {
const flashK = (1 - sh.age / 10) * (reduce ? 0.35 : 0.8);
ctx.fillStyle = rgba(CREAM, flashK);
ctx.beginPath();
ctx.arc(sh.cx, sh.cy, 7 + sh.age * 3, 0, PI2);
ctx.fill();
}
for (let i = 0; i < sh.grains.length; i += 1) {
const g = sh.grains[i];
const depth = 1 + g.z * 0.0025;
const front = clamp(0.78 + g.z * 0.004, 0.5, 1);
ctx.strokeStyle = rgba(g.hue, alpha * 0.55 * front);
ctx.lineWidth = 1.5 * depth;
ctx.beginPath();
ctx.moveTo(g.x - g.vx * 2.5, g.y - g.vy * 2.5);
ctx.lineTo(g.x, g.y);
ctx.stroke();
ctx.fillStyle = rgba(g.hue, alpha * front);
ctx.beginPath();
ctx.arc(g.x, g.y, (1.4 + 1.8 * alpha) * depth, 0, PI2);
ctx.fill();
}
}
ctx.globalCompositeOperation = 'source-over';
},
label() {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap() {
burst(inp.x, inp.y);
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./FwShellSphereBurstDrag.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; vx: number; vy: number; dpr: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const PALETTE: RGB[] = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP: RGB = [18, 16, 32];
const SKY_BOT: RGB = [46, 40, 76];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x: number, seed: number): number {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx: CanvasRenderingContext2D, height: number): void {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit(): void {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: any) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce(): void {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: PointerEvent): void {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e: PointerEvent): void {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: KeyboardEvent): void {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function (): void {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const BURST_N = 90;
const GRAVITY = 0.045;
const DRAG = 0.975;
const FUSE_MS = 1100;
const BURST_SPEED = 3.4;
const GRAIN_LIFE = 96;
const MAX_SHELLS = 5;
const PREWARM_STEPS = 30;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const TXT_AUTO = '구면 격자 폭발 — 항력이 속도를 깎는다';
const TXT_TAP = '누른 자리에서 터진다 — 알갱이가 구 표면에 고르다';
type Dir3 = [number, number, number];
interface Grain { x: number; y: number; z: number; vx: number; vy: number; vz: number; px: number; py: number; hue: RGB; }
interface Shell { grains: Grain[]; age: number; cx: number; cy: number; }
// 피보나치 격자 — 구 표면에 같은 간격으로 n 개의 방향을 나눈다
function sphereDirs(n: number): Dir3[] {
const out: Dir3[] = [];
for (let i = 0; i < n; i += 1) {
const y = 1 - 2 * (i + 0.5) / n;
const r = Math.sqrt(1 - y * y);
const a = i * GOLDEN_ANGLE;
out.push([r * Math.cos(a), y, r * Math.sin(a)]);
}
return out;
}
// 한 프레임 — 속도에 항력 계수를 곱하고 아래 방향으로 중력을 더한다
function moveGrain(g: Grain): void {
g.px = g.x;
g.py = g.y;
g.vx *= DRAG;
g.vy = g.vy * DRAG + GRAVITY;
g.vz *= DRAG;
g.x += g.vx;
g.y += g.vy;
g.z += g.vz;
}
function makeShell(x: number, y: number, n: number, rng: () => number, hueA: RGB, hueB: RGB): Shell {
const dirs = sphereDirs(n);
const tilt = rng() * PI2;
const ct = Math.cos(tilt);
const st = Math.sin(tilt);
const grains: Grain[] = [];
for (let i = 0; i < dirs.length; i += 1) {
const d = dirs[i];
// 구를 x 축 둘레로 돌려 터질 때마다 격자 무늬가 다르게 놓인다
const dy = d[1] * ct - d[2] * st;
const dz = d[1] * st + d[2] * ct;
const sp = BURST_SPEED * (0.96 + rng() * 0.08);
grains.push({ x: x, y: y, z: 0, vx: d[0] * sp, vy: dy * sp, vz: dz * sp, px: x, py: y, hue: i % 2 === 0 ? hueA : hueB });
}
return { grains: grains, age: 0, cx: x, cy: y };
}
// 측정용 — 터진 뒤 steps 프레임이 지난 알갱이들이 6px 칸을 몇 개 채우는지, 이웃과의 평균 거리
function shellSpread(n: number, steps: number): { cells: number; nearest: number } {
const rng = lcg(7);
const sh = makeShell(0, 0, n, rng, PINK, MINT);
for (let s = 0; s < steps; s += 1) {
for (let i = 0; i < sh.grains.length; i += 1) {
moveGrain(sh.grains[i]);
}
}
const seen: { [k: string]: boolean } = {};
let cells = 0;
let total = 0;
for (let i = 0; i < sh.grains.length; i += 1) {
const a = sh.grains[i];
const key = Math.floor(a.x / 6) + ',' + Math.floor(a.y / 6);
if (!seen[key]) {
seen[key] = true;
cells += 1;
}
let best = 1e9;
for (let j = 0; j < sh.grains.length; j += 1) {
if (j !== i) {
const b = sh.grains[j];
best = Math.min(best, Math.hypot(a.x - b.x, a.y - b.y));
}
}
total += best;
}
return { cells: cells, nearest: total / sh.grains.length };
}
function makeScene(inp: Input, reduce: boolean): Scene {
const rng = lcg(457);
const FUSE_STEPS = Math.round(FUSE_MS / FIXED_DT);
let shells: Shell[] = [];
let fuse = 2;
let colorIdx = 0;
function burst(x: number, y: number): void {
const a = PALETTE[colorIdx % PALETTE.length];
const b = PALETTE[(colorIdx + 2) % PALETTE.length];
colorIdx += 1;
shells.push(makeShell(x, y, BURST_N, rng, a, b));
if (shells.length > MAX_SHELLS) {
shells.shift();
}
}
const scene: Scene = {
step(): void {
fuse -= 1;
if (fuse <= 0) {
burst(90 + rng() * 300, 70 + rng() * 80);
fuse = FUSE_STEPS;
}
const alive: Shell[] = [];
for (let s = 0; s < shells.length; s += 1) {
const sh = shells[s];
sh.age += 1;
for (let i = 0; i < sh.grains.length; i += 1) {
moveGrain(sh.grains[i]);
}
if (sh.age < GRAIN_LIFE) {
alive.push(sh);
}
}
shells = alive;
},
draw(ctx: CanvasRenderingContext2D): void {
drawSky(ctx, H);
ctx.globalCompositeOperation = 'lighter';
for (let s = 0; s < shells.length; s += 1) {
const sh = shells[s];
const life = clamp(1 - sh.age / GRAIN_LIFE, 0, 1);
const alpha = Math.pow(life, 0.7);
if (sh.age < 10) {
const flashK = (1 - sh.age / 10) * (reduce ? 0.35 : 0.8);
ctx.fillStyle = rgba(CREAM, flashK);
ctx.beginPath();
ctx.arc(sh.cx, sh.cy, 7 + sh.age * 3, 0, PI2);
ctx.fill();
}
for (let i = 0; i < sh.grains.length; i += 1) {
const g = sh.grains[i];
const depth = 1 + g.z * 0.0025;
const front = clamp(0.78 + g.z * 0.004, 0.5, 1);
ctx.strokeStyle = rgba(g.hue, alpha * 0.55 * front);
ctx.lineWidth = 1.5 * depth;
ctx.beginPath();
ctx.moveTo(g.x - g.vx * 2.5, g.y - g.vy * 2.5);
ctx.lineTo(g.x, g.y);
ctx.stroke();
ctx.fillStyle = rgba(g.hue, alpha * front);
ctx.beginPath();
ctx.arc(g.x, g.y, (1.4 + 1.8 * alpha) * depth, 0, PI2);
ctx.fill();
}
}
ctx.globalCompositeOperation = 'source-over';
},
label(): string {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap(): void {
burst(inp.x, inp.y);
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
export default function FwShellSphereBurstDrag() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="알갱이가 구 표면에 고르게 퍼지고 공기저항으로 속도가 깎이는 기본 폭발" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">구면 격자 폭발 — 항력이 속도를 깎는다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>알갱이가 구 표면에 고르게 퍼지고 공기저항으로 속도가 깎이는 기본 폭발</p>
</div>
</main>
);
}01 · 알갱이가 구 표면에 고르게 퍼지고 공기저항으로 속도가 깎이는 기본 폭발 — 측정 누적면적 22.972% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function sphereDirs(n) {
const out = [];
for (let i = 0; i < n; i += 1) {
const y = 1 - 2 * (i + 0.5) / n;
const r = Math.sqrt(1 - y * y);
const a = i * GOLDEN_ANGLE;
out.push([r * Math.cos(a), y, r * Math.sin(a)]);
}
return out;
}
// 한 프레임 — 속도에 항력 계수를 곱하고 아래 방향으로 중력을 더한다
function moveGrain(g) {
g.px = g.x;
g.py = g.y;
g.vx *= DRAG;
g.vy = g.vy * DRAG + GRAVITY;
g.vz *= DRAG;
g.x += g.vx;
g.y += g.vy;
g.z += g.vz;
}
02클릭한 높이에서 정확히 정점을 찍고 터지는 포물선 발사체
누른 높이 h 로 v0 = √(2·LAUNCH_G·h) 를 거꾸로 풀어 초속도를 정하고, 상승 속도가 초속도의 4% 아래로 줄어든 틱에 터뜨려서 누른 높이가 곧 정점이 된다. 표적 y 를 60·100·140·190 으로 놓고 burstHeight() 를 노드에서 불러 보니 실제 터진 높이는 60.3·100.1·140.1·190.1 로 오차가 0.3px 안이었다. 렌더 쪽은 면적 58.2%, 강도 76.6 에 23/23 프레임이 움직인 것으로 잡혔다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="클릭한 높이에서 정확히 정점을 찍고 터지는 포물선 발사체"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">정점에서 터진다 — 표적 높이에서 초속도를 거꾸로 푼다</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">클릭한 높이에서 정확히 정점을 찍고 터지는 포물선 발사체</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const PALETTE = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP = [18, 16, 32];
const SKY_BOT = [46, 40, 76];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x, seed) {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx, height) {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce() {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function () {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const LAUNCH_G = 0.12;
const APEX_FRAC = 0.04;
const TRAIL_LEN = 12;
const CLICK_PAD = 28;
const GROUND_Y = H - 22;
const SHOT_GAP = 40;
const SPARK_N = 72;
const SPARK_DRAG = 0.965;
const SPARK_GRAVITY = 0.04;
const PREWARM_STEPS = 62;
const TXT_AUTO = '정점에서 터진다 — 표적 높이에서 초속도를 거꾸로 푼다';
const TXT_TAP = '누른 높이가 정점이 된다';
// 높이 h 까지 올라가 속도가 0 이 되려면 초속도는 sqrt(2 g h)
function launchSpeed(h) {
return Math.sqrt(2 * LAUNCH_G * h);
}
function aimPoint(x, y) {
return [clamp(x, CLICK_PAD, W - CLICK_PAD), clamp(y, CLICK_PAD, GROUND_Y - 60)];
}
function makeRocket(tx0, ty0, hue) {
const p = aimPoint(tx0, ty0);
const h = GROUND_Y - p[1];
const v0 = launchSpeed(h);
const x0 = lerp(W / 2, p[0], 0.35);
const flight = v0 / LAUNCH_G;
return { x: x0, y: GROUND_Y, vx: (p[0] - x0) / flight, vy: -v0, v0: v0, tx: p[0], ty: p[1], trail: [], hue: hue };
}
// 한 프레임 올리고 정점에 닿았는지 알려 준다 (상승 속도가 정점 문턱 아래로 줄면 참)
function stepRocket(r) {
r.x += r.vx;
r.y += r.vy + LAUNCH_G * 0.5;
r.vy += LAUNCH_G;
r.trail.push(r.x, r.y);
if (r.trail.length > TRAIL_LEN * 2) {
r.trail.splice(0, 2);
}
return r.vy >= -APEX_FRAC * r.v0;
}
// 측정용 — 목표 높이 ty 로 쏘았을 때 실제로 터지는 높이
function burstHeight(ty) {
const r = makeRocket(W / 2, ty, PINK);
for (let i = 0; i < 400; i += 1) {
if (stepRocket(r)) {
return r.y;
}
}
return -1;
}
function makeScene(inp, reduce) {
const rng = lcg(2024);
let rockets = [];
let sparks = [];
let gap = 2;
let colorIdx = 0;
let t = 0;
let lastTarget = 0;
function fire(x, y) {
rockets.push(makeRocket(x, y, PALETTE[colorIdx % PALETTE.length]));
colorIdx += 1;
if (rockets.length > 6) {
rockets.shift();
}
}
function explode(r) {
for (let i = 0; i < SPARK_N; i += 1) {
const a = (i / SPARK_N) * PI2 + rng() * 0.12;
const sp = 2.4 + rng() * 1.5;
sparks.push({ x: r.x, y: r.y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 58 + rng() * 22, hue: i % 3 === 0 ? CREAM : r.hue });
}
}
const scene = {
step() {
t += 1;
gap -= 1;
if (gap <= 0) {
fire(80 + rng() * 320, 50 + rng() * 80);
gap = SHOT_GAP;
}
const rest = [];
for (let i = 0; i < rockets.length; i += 1) {
if (stepRocket(rockets[i])) {
explode(rockets[i]);
lastTarget = t;
}
else {
rest.push(rockets[i]);
}
}
rockets = rest;
const alive = [];
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
s.age += 1;
s.vx *= SPARK_DRAG;
s.vy = s.vy * SPARK_DRAG + SPARK_GRAVITY;
s.x += s.vx;
s.y += s.vy;
if (s.age < s.life) {
alive.push(s);
}
}
sparks = alive;
},
draw(ctx) {
drawSky(ctx, H);
ctx.fillStyle = rgba([24, 20, 38], 1);
ctx.fillRect(0, GROUND_Y, W, H - GROUND_Y);
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < rockets.length; i += 1) {
const r = rockets[i];
// 표적 — 발사체가 이 점에서 정점을 찍는다
const pulse = 0.5 + 0.5 * Math.sin(t * 0.25 + i);
ctx.strokeStyle = rgba(r.hue, 0.35 + 0.3 * pulse);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(r.tx, r.ty, 6 + pulse * 2, 0, PI2);
ctx.stroke();
for (let k = 2; k < r.trail.length; k += 2) {
ctx.strokeStyle = rgba(CREAM, (k / r.trail.length) * 0.8);
ctx.lineWidth = 1 + (k / r.trail.length) * 1.4;
ctx.beginPath();
ctx.moveTo(r.trail[k - 2], r.trail[k - 1]);
ctx.lineTo(r.trail[k], r.trail[k + 1]);
ctx.stroke();
}
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(r.x, r.y, 2.2, 0, PI2);
ctx.fill();
}
const flashAge = t - lastTarget;
if (flashAge < 8 && lastTarget > 0) {
ctx.fillStyle = rgba(LILAC, (1 - flashAge / 8) * (reduce ? 0.03 : 0.09));
ctx.fillRect(0, 0, W, GROUND_Y);
}
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
const a = Math.pow(1 - s.age / s.life, 0.8);
ctx.strokeStyle = rgba(s.hue, a * 0.5);
ctx.lineWidth = 1.1;
ctx.beginPath();
ctx.moveTo(s.x - s.vx * 1.6, s.y - s.vy * 1.6);
ctx.lineTo(s.x, s.y);
ctx.stroke();
ctx.fillStyle = rgba(s.hue, a);
ctx.beginPath();
ctx.arc(s.x, s.y, 1.2 + 1.5 * a, 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
},
label() {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap() {
fire(inp.x, inp.y);
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./FwClickRocketApexLaunch.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; vx: number; vy: number; dpr: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const PALETTE: RGB[] = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP: RGB = [18, 16, 32];
const SKY_BOT: RGB = [46, 40, 76];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x: number, seed: number): number {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx: CanvasRenderingContext2D, height: number): void {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit(): void {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: any) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce(): void {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: PointerEvent): void {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e: PointerEvent): void {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: KeyboardEvent): void {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function (): void {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const LAUNCH_G = 0.12;
const APEX_FRAC = 0.04;
const TRAIL_LEN = 12;
const CLICK_PAD = 28;
const GROUND_Y = H - 22;
const SHOT_GAP = 40;
const SPARK_N = 72;
const SPARK_DRAG = 0.965;
const SPARK_GRAVITY = 0.04;
const PREWARM_STEPS = 62;
const TXT_AUTO = '정점에서 터진다 — 표적 높이에서 초속도를 거꾸로 푼다';
const TXT_TAP = '누른 높이가 정점이 된다';
interface Rocket {
x: number; y: number; vx: number; vy: number; v0: number;
tx: number; ty: number; trail: number[]; hue: RGB;
}
interface Spark { x: number; y: number; vx: number; vy: number; age: number; life: number; hue: RGB; }
// 높이 h 까지 올라가 속도가 0 이 되려면 초속도는 sqrt(2 g h)
function launchSpeed(h: number): number {
return Math.sqrt(2 * LAUNCH_G * h);
}
function aimPoint(x: number, y: number): [number, number] {
return [clamp(x, CLICK_PAD, W - CLICK_PAD), clamp(y, CLICK_PAD, GROUND_Y - 60)];
}
function makeRocket(tx0: number, ty0: number, hue: RGB): Rocket {
const p = aimPoint(tx0, ty0);
const h = GROUND_Y - p[1];
const v0 = launchSpeed(h);
const x0 = lerp(W / 2, p[0], 0.35);
const flight = v0 / LAUNCH_G;
return { x: x0, y: GROUND_Y, vx: (p[0] - x0) / flight, vy: -v0, v0: v0, tx: p[0], ty: p[1], trail: [], hue: hue };
}
// 한 프레임 올리고 정점에 닿았는지 알려 준다 (상승 속도가 정점 문턱 아래로 줄면 참)
function stepRocket(r: Rocket): boolean {
r.x += r.vx;
r.y += r.vy + LAUNCH_G * 0.5;
r.vy += LAUNCH_G;
r.trail.push(r.x, r.y);
if (r.trail.length > TRAIL_LEN * 2) {
r.trail.splice(0, 2);
}
return r.vy >= -APEX_FRAC * r.v0;
}
// 측정용 — 목표 높이 ty 로 쏘았을 때 실제로 터지는 높이
function burstHeight(ty: number): number {
const r = makeRocket(W / 2, ty, PINK);
for (let i = 0; i < 400; i += 1) {
if (stepRocket(r)) {
return r.y;
}
}
return -1;
}
function makeScene(inp: Input, reduce: boolean): Scene {
const rng = lcg(2024);
let rockets: Rocket[] = [];
let sparks: Spark[] = [];
let gap = 2;
let colorIdx = 0;
let t = 0;
let lastTarget = 0;
function fire(x: number, y: number): void {
rockets.push(makeRocket(x, y, PALETTE[colorIdx % PALETTE.length]));
colorIdx += 1;
if (rockets.length > 6) {
rockets.shift();
}
}
function explode(r: Rocket): void {
for (let i = 0; i < SPARK_N; i += 1) {
const a = (i / SPARK_N) * PI2 + rng() * 0.12;
const sp = 2.4 + rng() * 1.5;
sparks.push({ x: r.x, y: r.y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 58 + rng() * 22, hue: i % 3 === 0 ? CREAM : r.hue });
}
}
const scene: Scene = {
step(): void {
t += 1;
gap -= 1;
if (gap <= 0) {
fire(80 + rng() * 320, 50 + rng() * 80);
gap = SHOT_GAP;
}
const rest: Rocket[] = [];
for (let i = 0; i < rockets.length; i += 1) {
if (stepRocket(rockets[i])) {
explode(rockets[i]);
lastTarget = t;
}
else {
rest.push(rockets[i]);
}
}
rockets = rest;
const alive: Spark[] = [];
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
s.age += 1;
s.vx *= SPARK_DRAG;
s.vy = s.vy * SPARK_DRAG + SPARK_GRAVITY;
s.x += s.vx;
s.y += s.vy;
if (s.age < s.life) {
alive.push(s);
}
}
sparks = alive;
},
draw(ctx: CanvasRenderingContext2D): void {
drawSky(ctx, H);
ctx.fillStyle = rgba([24, 20, 38], 1);
ctx.fillRect(0, GROUND_Y, W, H - GROUND_Y);
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < rockets.length; i += 1) {
const r = rockets[i];
// 표적 — 발사체가 이 점에서 정점을 찍는다
const pulse = 0.5 + 0.5 * Math.sin(t * 0.25 + i);
ctx.strokeStyle = rgba(r.hue, 0.35 + 0.3 * pulse);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(r.tx, r.ty, 6 + pulse * 2, 0, PI2);
ctx.stroke();
for (let k = 2; k < r.trail.length; k += 2) {
ctx.strokeStyle = rgba(CREAM, (k / r.trail.length) * 0.8);
ctx.lineWidth = 1 + (k / r.trail.length) * 1.4;
ctx.beginPath();
ctx.moveTo(r.trail[k - 2], r.trail[k - 1]);
ctx.lineTo(r.trail[k], r.trail[k + 1]);
ctx.stroke();
}
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(r.x, r.y, 2.2, 0, PI2);
ctx.fill();
}
const flashAge = t - lastTarget;
if (flashAge < 8 && lastTarget > 0) {
ctx.fillStyle = rgba(LILAC, (1 - flashAge / 8) * (reduce ? 0.03 : 0.09));
ctx.fillRect(0, 0, W, GROUND_Y);
}
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
const a = Math.pow(1 - s.age / s.life, 0.8);
ctx.strokeStyle = rgba(s.hue, a * 0.5);
ctx.lineWidth = 1.1;
ctx.beginPath();
ctx.moveTo(s.x - s.vx * 1.6, s.y - s.vy * 1.6);
ctx.lineTo(s.x, s.y);
ctx.stroke();
ctx.fillStyle = rgba(s.hue, a);
ctx.beginPath();
ctx.arc(s.x, s.y, 1.2 + 1.5 * a, 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
},
label(): string {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap(): void {
fire(inp.x, inp.y);
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
export default function FwClickRocketApexLaunch() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="클릭한 높이에서 정확히 정점을 찍고 터지는 포물선 발사체" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">정점에서 터진다 — 표적 높이에서 초속도를 거꾸로 푼다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>클릭한 높이에서 정확히 정점을 찍고 터지는 포물선 발사체</p>
</div>
</main>
);
}02 · 클릭한 높이에서 정확히 정점을 찍고 터지는 포물선 발사체 — 측정 누적면적 58.175% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function launchSpeed(h) {
return Math.sqrt(2 * LAUNCH_G * h);
}
function aimPoint(x, y) {
return [clamp(x, CLICK_PAD, W - CLICK_PAD), clamp(y, CLICK_PAD, GROUND_Y - 60)];
}
function makeRocket(tx0, ty0, hue) {
const p = aimPoint(tx0, ty0);
const h = GROUND_Y - p[1];
const v0 = launchSpeed(h);
const x0 = lerp(W / 2, p[0], 0.35);
const flight = v0 / LAUNCH_G;
return { x: x0, y: GROUND_Y, vx: (p[0] - x0) / flight, vy: -v0, v0: v0, tx: p[0], ty: p[1], trail: [], hue: hue };
}
// 한 프레임 올리고 정점에 닿았는지 알려 준다 (상승 속도가 정점 문턱 아래로 줄면 참)
function stepRocket(r) {
r.x += r.vx;
r.y += r.vy + LAUNCH_G * 0.5;
r.vy += LAUNCH_G;
r.trail.push(r.x, r.y);
if (r.trail.length > TRAIL_LEN * 2) {
r.trail.splice(0, 2);
}
return r.vy >= -APEX_FRAC * r.v0;
}
03모란·버들·고리가 속도 분포 하나로 갈라지는 세 가지 셸
같은 코드에서 초속도 분포와 항력만 바꿔 모란·버들·고리를 만든다. 모란은 속도를 약 1.8 에서 5.1 까지 넓게 퍼뜨려 속이 찬 구가 되고, 버들은 최대 근처 속도만 써서 얇은 껍질에 꼬리 13점을 달며, 고리는 속도 3.5 하나에 원이 놓인 면을 RING_TILT 만큼 눕힌다. 면적 28.3%, 강도 93.3 으로 아홉 장면 중 강도가 가장 높고 23/23 프레임이 움직였다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="모란·버들·고리가 속도 분포 하나로 갈라지는 세 가지 셸"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">모란 — 속도가 0 부터 최대까지 고르게 퍼져 꽉 찬 구가 된다</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">모란·버들·고리가 속도 분포 하나로 갈라지는 세 가지 셸</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const PALETTE = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP = [18, 16, 32];
const SKY_BOT = [46, 40, 76];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x, seed) {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx, height) {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce() {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function () {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const WILLOW_DRAG = 0.96;
const RING_TILT = 0.9;
const SHELL_TYPES = ['peony', 'willow', 'ring'];
const TAIL_FADE = 0.82;
const PEONY_DRAG = 0.94;
const RING_DRAG = 0.955;
const GRAIN_N = 84;
const SHELL_GAP = 40;
const PREWARM_STEPS = 34;
const TXT_PEONY = '모란 — 속도가 0 부터 최대까지 고르게 퍼져 꽉 찬 구가 된다';
const TXT_WILLOW = '버들 — 얇은 껍질로 퍼진 알갱이가 꼬리를 늘어뜨린다';
const TXT_RING = '고리 — 속도를 하나로 맞추고 면을 기울이면 타원이 된다';
function dragOf(kind) {
if (kind === 'willow') {
return WILLOW_DRAG;
}
return kind === 'ring' ? RING_DRAG : PEONY_DRAG;
}
function makeShell(kind, x, y, rng, hue) {
const grains = [];
for (let i = 0; i < GRAIN_N; i += 1) {
let vx = 0;
let vy = 0;
let z = 0;
if (kind === 'ring') {
// 같은 속도로 원을 그리고, 원이 놓인 면을 RING_TILT 만큼 눕힌다
const a = (i / GRAIN_N) * PI2;
const sp = 3.5;
vx = Math.cos(a) * sp;
vy = Math.sin(a) * Math.cos(RING_TILT) * sp;
z = Math.sin(a) * Math.sin(RING_TILT);
}
else {
const a = rng() * PI2;
// 모란은 0 에서 최대까지, 버들은 최대 근처에만 속도를 둔다
const sp = kind === 'willow' ? 3.1 * (0.85 + 0.15 * rng()) : 5.1 * Math.sqrt(0.12 + 0.88 * rng());
vx = Math.cos(a) * sp;
vy = Math.sin(a) * sp;
}
grains.push({ x: x, y: y, vx: vx, vy: vy, z: z, hist: [] });
}
const tail = kind === 'willow' ? 13 : (kind === 'ring' ? 7 : 5);
return { kind: kind, grains: grains, age: 0, life: kind === 'willow' ? 124 : 84, hue: hue, drag: dragOf(kind), gravity: kind === 'willow' ? 0.05 : (kind === 'ring' ? 0.02 : 0.03), tail: tail };
}
function moveShell(sh) {
sh.age += 1;
for (let i = 0; i < sh.grains.length; i += 1) {
const g = sh.grains[i];
g.hist.push(g.x, g.y);
if (g.hist.length > sh.tail * 2) {
g.hist.splice(0, 2);
}
g.vx *= sh.drag;
g.vy = g.vy * sh.drag + sh.gravity;
g.x += g.vx;
g.y += g.vy;
}
}
// 측정용 — 항력 계수 d 로 life 프레임을 날린 알갱이가 출발점에서 얼마나 멀리 가는가 (평균 px)
function reachOf(d, life) {
const rng = lcg(11);
const sh = makeShell('willow', 0, 0, rng, PINK);
sh.drag = d;
for (let i = 0; i < life; i += 1) {
moveShell(sh);
}
let total = 0;
for (let i = 0; i < sh.grains.length; i += 1) {
total += Math.hypot(sh.grains[i].x, sh.grains[i].y);
}
return total / sh.grains.length;
}
function makeScene(inp, reduce) {
const rng = lcg(303);
let shells = [];
let gap = 2;
let typeIdx = 0;
let lastKind = SHELL_TYPES[0];
function burst(x, y) {
const kind = SHELL_TYPES[typeIdx % SHELL_TYPES.length];
shells.push(makeShell(kind, x, y, rng, PALETTE[(typeIdx * 2) % PALETTE.length]));
lastKind = kind;
typeIdx += 1;
if (shells.length > 5) {
shells.shift();
}
}
const scene = {
step() {
gap -= 1;
if (gap <= 0) {
burst(110 + rng() * 260, 85 + rng() * 45);
gap = SHELL_GAP;
}
const alive = [];
for (let i = 0; i < shells.length; i += 1) {
moveShell(shells[i]);
if (shells[i].age < shells[i].life) {
alive.push(shells[i]);
}
}
shells = alive;
},
draw(ctx) {
drawSky(ctx, H);
ctx.globalCompositeOperation = 'lighter';
for (let s = 0; s < shells.length; s += 1) {
const sh = shells[s];
const a = Math.pow(clamp(1 - sh.age / sh.life, 0, 1), 0.75);
for (let i = 0; i < sh.grains.length; i += 1) {
const g = sh.grains[i];
const n = g.hist.length / 2;
// 꼬리 — 오래된 점일수록 TAIL_FADE 를 거듭 곱해 흐려진다
for (let k = 1; k < n; k += 1) {
const fade = Math.pow(TAIL_FADE, n - k);
ctx.strokeStyle = rgba(sh.hue, a * fade * 0.8);
ctx.lineWidth = 0.8 + fade * 0.9;
ctx.beginPath();
ctx.moveTo(g.hist[(k - 1) * 2], g.hist[(k - 1) * 2 + 1]);
ctx.lineTo(g.hist[k * 2], g.hist[k * 2 + 1]);
ctx.stroke();
}
ctx.fillStyle = rgba(sh.kind === 'willow' ? BUTTER : sh.hue, a * (0.7 + g.z * 0.3));
ctx.beginPath();
ctx.arc(g.x, g.y, 1.4 + 1.5 * a, 0, PI2);
ctx.fill();
}
}
ctx.globalCompositeOperation = 'source-over';
},
label() {
if (lastKind === 'willow') {
return TXT_WILLOW;
}
return lastKind === 'ring' ? TXT_RING : TXT_PEONY;
},
tap() {
burst(inp.x, inp.y);
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./FwPeonyWillowRingProfiles.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; vx: number; vy: number; dpr: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const PALETTE: RGB[] = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP: RGB = [18, 16, 32];
const SKY_BOT: RGB = [46, 40, 76];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x: number, seed: number): number {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx: CanvasRenderingContext2D, height: number): void {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit(): void {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: any) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce(): void {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: PointerEvent): void {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e: PointerEvent): void {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: KeyboardEvent): void {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function (): void {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const WILLOW_DRAG = 0.96;
const RING_TILT = 0.9;
const SHELL_TYPES: string[] = ['peony', 'willow', 'ring'];
const TAIL_FADE = 0.82;
const PEONY_DRAG = 0.94;
const RING_DRAG = 0.955;
const GRAIN_N = 84;
const SHELL_GAP = 40;
const PREWARM_STEPS = 34;
const TXT_PEONY = '모란 — 속도가 0 부터 최대까지 고르게 퍼져 꽉 찬 구가 된다';
const TXT_WILLOW = '버들 — 얇은 껍질로 퍼진 알갱이가 꼬리를 늘어뜨린다';
const TXT_RING = '고리 — 속도를 하나로 맞추고 면을 기울이면 타원이 된다';
interface Grain { x: number; y: number; vx: number; vy: number; z: number; hist: number[]; }
interface Shell { kind: string; grains: Grain[]; age: number; life: number; hue: RGB; drag: number; gravity: number; tail: number; }
function dragOf(kind: string): number {
if (kind === 'willow') {
return WILLOW_DRAG;
}
return kind === 'ring' ? RING_DRAG : PEONY_DRAG;
}
function makeShell(kind: string, x: number, y: number, rng: () => number, hue: RGB): Shell {
const grains: Grain[] = [];
for (let i = 0; i < GRAIN_N; i += 1) {
let vx = 0;
let vy = 0;
let z = 0;
if (kind === 'ring') {
// 같은 속도로 원을 그리고, 원이 놓인 면을 RING_TILT 만큼 눕힌다
const a = (i / GRAIN_N) * PI2;
const sp = 3.5;
vx = Math.cos(a) * sp;
vy = Math.sin(a) * Math.cos(RING_TILT) * sp;
z = Math.sin(a) * Math.sin(RING_TILT);
}
else {
const a = rng() * PI2;
// 모란은 0 에서 최대까지, 버들은 최대 근처에만 속도를 둔다
const sp = kind === 'willow' ? 3.1 * (0.85 + 0.15 * rng()) : 5.1 * Math.sqrt(0.12 + 0.88 * rng());
vx = Math.cos(a) * sp;
vy = Math.sin(a) * sp;
}
grains.push({ x: x, y: y, vx: vx, vy: vy, z: z, hist: [] });
}
const tail = kind === 'willow' ? 13 : (kind === 'ring' ? 7 : 5);
return { kind: kind, grains: grains, age: 0, life: kind === 'willow' ? 124 : 84, hue: hue, drag: dragOf(kind), gravity: kind === 'willow' ? 0.05 : (kind === 'ring' ? 0.02 : 0.03), tail: tail };
}
function moveShell(sh: Shell): void {
sh.age += 1;
for (let i = 0; i < sh.grains.length; i += 1) {
const g = sh.grains[i];
g.hist.push(g.x, g.y);
if (g.hist.length > sh.tail * 2) {
g.hist.splice(0, 2);
}
g.vx *= sh.drag;
g.vy = g.vy * sh.drag + sh.gravity;
g.x += g.vx;
g.y += g.vy;
}
}
// 측정용 — 항력 계수 d 로 life 프레임을 날린 알갱이가 출발점에서 얼마나 멀리 가는가 (평균 px)
function reachOf(d: number, life: number): number {
const rng = lcg(11);
const sh = makeShell('willow', 0, 0, rng, PINK);
sh.drag = d;
for (let i = 0; i < life; i += 1) {
moveShell(sh);
}
let total = 0;
for (let i = 0; i < sh.grains.length; i += 1) {
total += Math.hypot(sh.grains[i].x, sh.grains[i].y);
}
return total / sh.grains.length;
}
function makeScene(inp: Input, reduce: boolean): Scene {
const rng = lcg(303);
let shells: Shell[] = [];
let gap = 2;
let typeIdx = 0;
let lastKind = SHELL_TYPES[0];
function burst(x: number, y: number): void {
const kind = SHELL_TYPES[typeIdx % SHELL_TYPES.length];
shells.push(makeShell(kind, x, y, rng, PALETTE[(typeIdx * 2) % PALETTE.length]));
lastKind = kind;
typeIdx += 1;
if (shells.length > 5) {
shells.shift();
}
}
const scene: Scene = {
step(): void {
gap -= 1;
if (gap <= 0) {
burst(110 + rng() * 260, 85 + rng() * 45);
gap = SHELL_GAP;
}
const alive: Shell[] = [];
for (let i = 0; i < shells.length; i += 1) {
moveShell(shells[i]);
if (shells[i].age < shells[i].life) {
alive.push(shells[i]);
}
}
shells = alive;
},
draw(ctx: CanvasRenderingContext2D): void {
drawSky(ctx, H);
ctx.globalCompositeOperation = 'lighter';
for (let s = 0; s < shells.length; s += 1) {
const sh = shells[s];
const a = Math.pow(clamp(1 - sh.age / sh.life, 0, 1), 0.75);
for (let i = 0; i < sh.grains.length; i += 1) {
const g = sh.grains[i];
const n = g.hist.length / 2;
// 꼬리 — 오래된 점일수록 TAIL_FADE 를 거듭 곱해 흐려진다
for (let k = 1; k < n; k += 1) {
const fade = Math.pow(TAIL_FADE, n - k);
ctx.strokeStyle = rgba(sh.hue, a * fade * 0.8);
ctx.lineWidth = 0.8 + fade * 0.9;
ctx.beginPath();
ctx.moveTo(g.hist[(k - 1) * 2], g.hist[(k - 1) * 2 + 1]);
ctx.lineTo(g.hist[k * 2], g.hist[k * 2 + 1]);
ctx.stroke();
}
ctx.fillStyle = rgba(sh.kind === 'willow' ? BUTTER : sh.hue, a * (0.7 + g.z * 0.3));
ctx.beginPath();
ctx.arc(g.x, g.y, 1.4 + 1.5 * a, 0, PI2);
ctx.fill();
}
}
ctx.globalCompositeOperation = 'source-over';
},
label(): string {
if (lastKind === 'willow') {
return TXT_WILLOW;
}
return lastKind === 'ring' ? TXT_RING : TXT_PEONY;
},
tap(): void {
burst(inp.x, inp.y);
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
export default function FwPeonyWillowRingProfiles() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="모란·버들·고리가 속도 분포 하나로 갈라지는 세 가지 셸" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">모란 — 속도가 0 부터 최대까지 고르게 퍼져 꽉 찬 구가 된다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>모란·버들·고리가 속도 분포 하나로 갈라지는 세 가지 셸</p>
</div>
</main>
);
}03 · 모란·버들·고리가 속도 분포 하나로 갈라지는 세 가지 셸 — 측정 누적면적 28.288% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
for (let i = 0; i < GRAIN_N; i += 1) {
let vx = 0;
let vy = 0;
let z = 0;
if (kind === 'ring') {
// 같은 속도로 원을 그리고, 원이 놓인 면을 RING_TILT 만큼 눕힌다
const a = (i / GRAIN_N) * PI2;
const sp = 3.5;
vx = Math.cos(a) * sp;
vy = Math.sin(a) * Math.cos(RING_TILT) * sp;
z = Math.sin(a) * Math.sin(RING_TILT);
}
else {
const a = rng() * PI2;
// 모란은 0 에서 최대까지, 버들은 최대 근처에만 속도를 둔다
const sp = kind === 'willow' ? 3.1 * (0.85 + 0.15 * rng()) : 5.1 * Math.sqrt(0.12 + 0.88 * rng());
vx = Math.cos(a) * sp;
vy = Math.sin(a) * sp;
}
grains.push({ x: x, y: y, vx: vx, vy: vy, z: z, hist: [] });
}
04과거 위치 이력을 잇고 겹칠수록 밝아지는 꼬리와 빛무리
알갱이마다 최근 위치 16점을 저장해 선분으로 잇고, 뒤로 갈수록 알파가 FADE_ALPHA 의 거듭제곱으로 줄어 꼬리가 옅어진다. 합성 방식을 lighter 로 바꿔 두어서 꼬리와 머리의 빛무리가 겹친 곳은 색이 더해져 흰빛에 가깝게 번진다. 면적은 41.6%, 강도는 72.1 이고 스물세 번의 프레임 간격마다 모두 움직였다(23/23).

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="과거 위치 이력을 잇고 겹칠수록 밝아지는 꼬리와 빛무리"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">꼬리 이력을 가산 합성으로 겹친다 — 겹친 곳이 흰빛으로 번진다</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">과거 위치 이력을 잇고 겹칠수록 밝아지는 꼬리와 빛무리</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const PALETTE = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP = [18, 16, 32];
const SKY_BOT = [46, 40, 76];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x, seed) {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx, height) {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce() {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function () {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TRAIL_LEN = 16;
const GLOW_BLUR = 9;
const FADE_ALPHA = 0.86;
const HEAD_R = 2.1;
const BURST_GAP = 44;
const SPARK_N = 56;
const SPARK_DRAG = 0.972;
const SPARK_GRAVITY = 0.045;
const PREWARM_STEPS = 38;
const TXT_AUTO = '꼬리 이력을 가산 합성으로 겹친다 — 겹친 곳이 흰빛으로 번진다';
const TXT_TAP = '누른 자리에서 빛무리가 터진다';
// 위치 이력 한 줄에서 i 번째 점의 알파 — 머리에서 멀수록 FADE_ALPHA 를 거듭 곱한다
function trailAlpha(back) {
return Math.pow(FADE_ALPHA, back);
}
function makeScene(inp, reduce) {
const rng = lcg(4004);
let sparks = [];
let gap = 2;
let colorIdx = 0;
const glows = [];
function glowFor(idx) {
if (glows[idx]) {
return glows[idx];
}
const size = GLOW_BLUR * 4;
const cv = document.createElement('canvas');
cv.width = size;
cv.height = size;
const c = cv.getContext('2d');
if (c) {
const g = c.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
g.addColorStop(0, rgba(CREAM, 0.9));
g.addColorStop(0.25, rgba(PALETTE[idx], 0.45));
g.addColorStop(1, rgba(PALETTE[idx], 0));
c.fillStyle = g;
c.fillRect(0, 0, size, size);
}
glows[idx] = cv;
return cv;
}
function burst(x, y) {
const hue = colorIdx % PALETTE.length;
colorIdx += 1;
for (let i = 0; i < SPARK_N; i += 1) {
const a = rng() * PI2;
const sp = 2.2 + rng() * 2.4;
sparks.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 70 + rng() * 26, hist: [], hue: i % 4 === 0 ? (hue + 1) % PALETTE.length : hue });
}
if (sparks.length > 330) {
sparks.splice(0, sparks.length - 330);
}
}
const scene = {
step() {
gap -= 1;
if (gap <= 0) {
burst(90 + rng() * 300, 80 + rng() * 70);
gap = BURST_GAP;
}
const alive = [];
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
s.age += 1;
s.hist.push(s.x, s.y);
if (s.hist.length > TRAIL_LEN * 2) {
s.hist.splice(0, 2);
}
s.vx *= SPARK_DRAG;
s.vy = s.vy * SPARK_DRAG + SPARK_GRAVITY;
s.x += s.vx;
s.y += s.vy;
if (s.age < s.life) {
alive.push(s);
}
}
sparks = alive;
},
draw(ctx) {
drawSky(ctx, H);
// 이 항목의 핵심 — 합성 방식을 가산으로 바꾸면 겹친 곳의 색이 더해져 하얗게 번진다
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
const life = Math.pow(1 - s.age / s.life, 0.7);
const n = s.hist.length / 2;
for (let k = 1; k < n; k += 1) {
const a = trailAlpha(n - k) * life;
ctx.strokeStyle = rgba(PALETTE[s.hue], a * 0.85);
ctx.lineWidth = lerp(0.6, HEAD_R * 1.3, k / n);
ctx.beginPath();
ctx.moveTo(s.hist[(k - 1) * 2], s.hist[(k - 1) * 2 + 1]);
ctx.lineTo(s.hist[k * 2], s.hist[k * 2 + 1]);
ctx.stroke();
}
const r = GLOW_BLUR * (0.6 + 0.4 * life);
ctx.globalAlpha = life * (reduce ? 0.6 : 0.85);
ctx.drawImage(glowFor(s.hue), s.x - r, s.y - r, r * 2, r * 2);
ctx.globalAlpha = 1;
ctx.fillStyle = rgba(CREAM, life);
ctx.beginPath();
ctx.arc(s.x, s.y, HEAD_R * (0.5 + 0.5 * life), 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
},
label() {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap() {
burst(inp.x, inp.y);
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./FwAdditiveTrailHistoryGlow.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; vx: number; vy: number; dpr: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const PALETTE: RGB[] = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP: RGB = [18, 16, 32];
const SKY_BOT: RGB = [46, 40, 76];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x: number, seed: number): number {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx: CanvasRenderingContext2D, height: number): void {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit(): void {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: any) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce(): void {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: PointerEvent): void {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e: PointerEvent): void {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: KeyboardEvent): void {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function (): void {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const TRAIL_LEN = 16;
const GLOW_BLUR = 9;
const FADE_ALPHA = 0.86;
const HEAD_R = 2.1;
const BURST_GAP = 44;
const SPARK_N = 56;
const SPARK_DRAG = 0.972;
const SPARK_GRAVITY = 0.045;
const PREWARM_STEPS = 38;
const TXT_AUTO = '꼬리 이력을 가산 합성으로 겹친다 — 겹친 곳이 흰빛으로 번진다';
const TXT_TAP = '누른 자리에서 빛무리가 터진다';
interface Spark { x: number; y: number; vx: number; vy: number; age: number; life: number; hist: number[]; hue: number; }
// 위치 이력 한 줄에서 i 번째 점의 알파 — 머리에서 멀수록 FADE_ALPHA 를 거듭 곱한다
function trailAlpha(back: number): number {
return Math.pow(FADE_ALPHA, back);
}
function makeScene(inp: Input, reduce: boolean): Scene {
const rng = lcg(4004);
let sparks: Spark[] = [];
let gap = 2;
let colorIdx = 0;
const glows: HTMLCanvasElement[] = [];
function glowFor(idx: number): HTMLCanvasElement {
if (glows[idx]) {
return glows[idx];
}
const size = GLOW_BLUR * 4;
const cv = document.createElement('canvas');
cv.width = size;
cv.height = size;
const c = cv.getContext('2d');
if (c) {
const g = c.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
g.addColorStop(0, rgba(CREAM, 0.9));
g.addColorStop(0.25, rgba(PALETTE[idx], 0.45));
g.addColorStop(1, rgba(PALETTE[idx], 0));
c.fillStyle = g;
c.fillRect(0, 0, size, size);
}
glows[idx] = cv;
return cv;
}
function burst(x: number, y: number): void {
const hue = colorIdx % PALETTE.length;
colorIdx += 1;
for (let i = 0; i < SPARK_N; i += 1) {
const a = rng() * PI2;
const sp = 2.2 + rng() * 2.4;
sparks.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 70 + rng() * 26, hist: [], hue: i % 4 === 0 ? (hue + 1) % PALETTE.length : hue });
}
if (sparks.length > 330) {
sparks.splice(0, sparks.length - 330);
}
}
const scene: Scene = {
step(): void {
gap -= 1;
if (gap <= 0) {
burst(90 + rng() * 300, 80 + rng() * 70);
gap = BURST_GAP;
}
const alive: Spark[] = [];
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
s.age += 1;
s.hist.push(s.x, s.y);
if (s.hist.length > TRAIL_LEN * 2) {
s.hist.splice(0, 2);
}
s.vx *= SPARK_DRAG;
s.vy = s.vy * SPARK_DRAG + SPARK_GRAVITY;
s.x += s.vx;
s.y += s.vy;
if (s.age < s.life) {
alive.push(s);
}
}
sparks = alive;
},
draw(ctx: CanvasRenderingContext2D): void {
drawSky(ctx, H);
// 이 항목의 핵심 — 합성 방식을 가산으로 바꾸면 겹친 곳의 색이 더해져 하얗게 번진다
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
const life = Math.pow(1 - s.age / s.life, 0.7);
const n = s.hist.length / 2;
for (let k = 1; k < n; k += 1) {
const a = trailAlpha(n - k) * life;
ctx.strokeStyle = rgba(PALETTE[s.hue], a * 0.85);
ctx.lineWidth = lerp(0.6, HEAD_R * 1.3, k / n);
ctx.beginPath();
ctx.moveTo(s.hist[(k - 1) * 2], s.hist[(k - 1) * 2 + 1]);
ctx.lineTo(s.hist[k * 2], s.hist[k * 2 + 1]);
ctx.stroke();
}
const r = GLOW_BLUR * (0.6 + 0.4 * life);
ctx.globalAlpha = life * (reduce ? 0.6 : 0.85);
ctx.drawImage(glowFor(s.hue), s.x - r, s.y - r, r * 2, r * 2);
ctx.globalAlpha = 1;
ctx.fillStyle = rgba(CREAM, life);
ctx.beginPath();
ctx.arc(s.x, s.y, HEAD_R * (0.5 + 0.5 * life), 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
},
label(): string {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap(): void {
burst(inp.x, inp.y);
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
export default function FwAdditiveTrailHistoryGlow() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="과거 위치 이력을 잇고 겹칠수록 밝아지는 꼬리와 빛무리" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">꼬리 이력을 가산 합성으로 겹친다 — 겹친 곳이 흰빛으로 번진다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>과거 위치 이력을 잇고 겹칠수록 밝아지는 꼬리와 빛무리</p>
</div>
</main>
);
}04 · 과거 위치 이력을 잇고 겹칠수록 밝아지는 꼬리와 빛무리 — 측정 누적면적 41.561% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function trailAlpha(back) {
return Math.pow(FADE_ALPHA, back);
}
const life = Math.pow(1 - s.age / s.life, 0.7);
const n = s.hist.length / 2;
for (let k = 1; k < n; k += 1) {
const a = trailAlpha(n - k) * life;
ctx.strokeStyle = rgba(PALETTE[s.hue], a * 0.85);
ctx.lineWidth = lerp(0.6, HEAD_R * 1.3, k / n);
ctx.beginPath();
ctx.moveTo(s.hist[(k - 1) * 2], s.hist[(k - 1) * 2 + 1]);
ctx.lineTo(s.hist[k * 2], s.hist[k * 2 + 1]);
ctx.stroke();
}
05흰빛에서 주황, 붉은 잿빛으로 식으며 깜빡이다 꺼지는 불씨
불씨의 수명 비율을 다섯 단계 색 목록에 넣어 흰빛에서 연노랑·살구·장밋빛·잿빛으로 식게 하고, 밝기는 1차원 값 노이즈로 흔든다. 흔들림 폭에 곱하는 0.3 + 0.7·frac² 이 수명 끝에 가까울수록 커져서 꺼지기 직전에 가장 많이 깜빡인다. 수치는 면적 25.0%, 강도 64.2 이며 23/23 프레임이 움직였다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="흰빛에서 주황, 붉은 잿빛으로 식으며 깜빡이다 꺼지는 불씨"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">불씨가 식는다 — 수명 비율이 색 단계를 고른다</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">흰빛에서 주황, 붉은 잿빛으로 식으며 깜빡이다 꺼지는 불씨</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const PALETTE = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP = [18, 16, 32];
const SKY_BOT = [46, 40, 76];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x, seed) {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx, height) {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce() {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function () {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const FLICKER_HZ = 9;
const COOL_STOPS = [
[0, [255, 252, 244]],
[0.25, [255, 232, 160]],
[0.55, [255, 176, 128]],
[0.8, [220, 128, 140]],
[1, [128, 108, 130]]
];
const LIFE_MS = 1800;
const NOISE_AMP = 0.55;
const SPAWN_PER_STEP = 4;
const MAX_EMBERS = 420;
const EMBER_G = 0.035;
const EMBER_DRAG = 0.985;
const GROUND_Y = H - 24;
const PREWARM_STEPS = 120;
const TXT_AUTO = '불씨가 식는다 — 수명 비율이 색 단계를 고른다';
const TXT_TAP = '누른 자리에서 불씨가 튄다';
// 수명 비율 t(0 은 갓 태어남, 1 은 꺼짐)를 색 단계 사이 선형 보간으로 색으로 바꾼다
function coolColor(t) {
const c = clamp(t, 0, 1);
for (let i = 1; i < COOL_STOPS.length; i += 1) {
if (c <= COOL_STOPS[i][0]) {
const a = COOL_STOPS[i - 1];
const b = COOL_STOPS[i];
return mixRGB(a[1], b[1], (c - a[0]) / (b[0] - a[0]));
}
}
return COOL_STOPS[COOL_STOPS.length - 1][1];
}
// 불씨 하나의 밝기 — 1차원 노이즈로 흔들고, 식을수록 흔들림 폭이 커진다
function flickerOf(id, seconds, frac, amp) {
const n = noise1(id * 3.7 + seconds * FLICKER_HZ, 7);
return 1 - amp * n * (0.3 + 0.7 * frac * frac);
}
function makeScene(inp, reduce) {
const rng = lcg(505);
const LIFE_STEPS = Math.round(LIFE_MS / FIXED_DT);
const amp = reduce ? NOISE_AMP * 0.4 : NOISE_AMP;
let embers = [];
let t = 0;
let ex = W / 2;
let nextId = 1;
function spawn(x, y, ang, spread, sp0, sp1) {
const a = ang + (rng() - 0.5) * spread;
const sp = sp0 + rng() * (sp1 - sp0);
embers.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: LIFE_STEPS * (0.6 + 0.4 * rng()), id: nextId });
nextId += 1;
}
const scene = {
step() {
t += 1;
const target = inp.idle < IDLE_TICKS ? inp.x : W / 2 + 90 * Math.sin(t * 0.02);
ex = lerp(ex, target, 0.1);
for (let i = 0; i < SPAWN_PER_STEP; i += 1) {
spawn(ex, GROUND_Y, -Math.PI / 2, 1.5, 2.0, 4.6);
}
const alive = [];
for (let i = 0; i < embers.length; i += 1) {
const e = embers[i];
e.age += 1;
// 옆으로 미는 약한 바람도 노이즈에서 받는다
e.vx = e.vx * EMBER_DRAG + (noise1(e.y * 0.03 + t * 0.01, 3) - 0.5) * 0.05;
e.vy = e.vy * EMBER_DRAG + EMBER_G;
e.x += e.vx;
e.y += e.vy;
if (e.age < e.life && e.y < GROUND_Y + 4) {
alive.push(e);
}
}
embers = alive.length > MAX_EMBERS ? alive.slice(alive.length - MAX_EMBERS) : alive;
},
draw(ctx) {
drawSky(ctx, H);
ctx.globalCompositeOperation = 'lighter';
// 불씨가 많을수록 발사점 주변 하늘이 따뜻하게 물든다
const warm = clamp(embers.length / 300, 0, 1) * (reduce ? 0.12 : 0.3);
const g = ctx.createRadialGradient(ex, GROUND_Y, 0, ex, GROUND_Y, 170);
g.addColorStop(0, rgba([255, 176, 128], warm));
g.addColorStop(1, rgba([255, 176, 128], 0));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
const sec = t * FIXED_DT / 1000;
for (let i = 0; i < embers.length; i += 1) {
const e = embers[i];
const frac = e.age / e.life;
const col = coolColor(frac);
const b = flickerOf(e.id, sec, frac, amp);
const a = Math.pow(1 - frac, 0.7) * b;
ctx.fillStyle = rgba(col, a * 0.28);
ctx.beginPath();
ctx.arc(e.x, e.y, 4.2 * (1 - frac * 0.4), 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(col, a);
ctx.beginPath();
ctx.arc(e.x, e.y, 1.6 * (1 - frac * 0.5) + 0.4, 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = rgba([24, 20, 38], 1);
ctx.fillRect(0, GROUND_Y, W, H - GROUND_Y);
},
label() {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap() {
for (let i = 0; i < 60; i += 1) {
spawn(inp.x, inp.y, rng() * PI2, PI2, 0.8, 3.2);
}
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./FwEmberCoolingFlickerNoise.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; vx: number; vy: number; dpr: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const PALETTE: RGB[] = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP: RGB = [18, 16, 32];
const SKY_BOT: RGB = [46, 40, 76];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x: number, seed: number): number {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx: CanvasRenderingContext2D, height: number): void {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit(): void {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: any) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce(): void {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: PointerEvent): void {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e: PointerEvent): void {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: KeyboardEvent): void {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function (): void {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const FLICKER_HZ = 9;
const COOL_STOPS: Array<[number, RGB]> = [
[0, [255, 252, 244]],
[0.25, [255, 232, 160]],
[0.55, [255, 176, 128]],
[0.8, [220, 128, 140]],
[1, [128, 108, 130]]
];
const LIFE_MS = 1800;
const NOISE_AMP = 0.55;
const SPAWN_PER_STEP = 4;
const MAX_EMBERS = 420;
const EMBER_G = 0.035;
const EMBER_DRAG = 0.985;
const GROUND_Y = H - 24;
const PREWARM_STEPS = 120;
const TXT_AUTO = '불씨가 식는다 — 수명 비율이 색 단계를 고른다';
const TXT_TAP = '누른 자리에서 불씨가 튄다';
interface Ember { x: number; y: number; vx: number; vy: number; age: number; life: number; id: number; }
// 수명 비율 t(0 은 갓 태어남, 1 은 꺼짐)를 색 단계 사이 선형 보간으로 색으로 바꾼다
function coolColor(t: number): RGB {
const c = clamp(t, 0, 1);
for (let i = 1; i < COOL_STOPS.length; i += 1) {
if (c <= COOL_STOPS[i][0]) {
const a = COOL_STOPS[i - 1];
const b = COOL_STOPS[i];
return mixRGB(a[1], b[1], (c - a[0]) / (b[0] - a[0]));
}
}
return COOL_STOPS[COOL_STOPS.length - 1][1];
}
// 불씨 하나의 밝기 — 1차원 노이즈로 흔들고, 식을수록 흔들림 폭이 커진다
function flickerOf(id: number, seconds: number, frac: number, amp: number): number {
const n = noise1(id * 3.7 + seconds * FLICKER_HZ, 7);
return 1 - amp * n * (0.3 + 0.7 * frac * frac);
}
function makeScene(inp: Input, reduce: boolean): Scene {
const rng = lcg(505);
const LIFE_STEPS = Math.round(LIFE_MS / FIXED_DT);
const amp = reduce ? NOISE_AMP * 0.4 : NOISE_AMP;
let embers: Ember[] = [];
let t = 0;
let ex = W / 2;
let nextId = 1;
function spawn(x: number, y: number, ang: number, spread: number, sp0: number, sp1: number): void {
const a = ang + (rng() - 0.5) * spread;
const sp = sp0 + rng() * (sp1 - sp0);
embers.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: LIFE_STEPS * (0.6 + 0.4 * rng()), id: nextId });
nextId += 1;
}
const scene: Scene = {
step(): void {
t += 1;
const target = inp.idle < IDLE_TICKS ? inp.x : W / 2 + 90 * Math.sin(t * 0.02);
ex = lerp(ex, target, 0.1);
for (let i = 0; i < SPAWN_PER_STEP; i += 1) {
spawn(ex, GROUND_Y, -Math.PI / 2, 1.5, 2.0, 4.6);
}
const alive: Ember[] = [];
for (let i = 0; i < embers.length; i += 1) {
const e = embers[i];
e.age += 1;
// 옆으로 미는 약한 바람도 노이즈에서 받는다
e.vx = e.vx * EMBER_DRAG + (noise1(e.y * 0.03 + t * 0.01, 3) - 0.5) * 0.05;
e.vy = e.vy * EMBER_DRAG + EMBER_G;
e.x += e.vx;
e.y += e.vy;
if (e.age < e.life && e.y < GROUND_Y + 4) {
alive.push(e);
}
}
embers = alive.length > MAX_EMBERS ? alive.slice(alive.length - MAX_EMBERS) : alive;
},
draw(ctx: CanvasRenderingContext2D): void {
drawSky(ctx, H);
ctx.globalCompositeOperation = 'lighter';
// 불씨가 많을수록 발사점 주변 하늘이 따뜻하게 물든다
const warm = clamp(embers.length / 300, 0, 1) * (reduce ? 0.12 : 0.3);
const g = ctx.createRadialGradient(ex, GROUND_Y, 0, ex, GROUND_Y, 170);
g.addColorStop(0, rgba([255, 176, 128], warm));
g.addColorStop(1, rgba([255, 176, 128], 0));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
const sec = t * FIXED_DT / 1000;
for (let i = 0; i < embers.length; i += 1) {
const e = embers[i];
const frac = e.age / e.life;
const col = coolColor(frac);
const b = flickerOf(e.id, sec, frac, amp);
const a = Math.pow(1 - frac, 0.7) * b;
ctx.fillStyle = rgba(col, a * 0.28);
ctx.beginPath();
ctx.arc(e.x, e.y, 4.2 * (1 - frac * 0.4), 0, PI2);
ctx.fill();
ctx.fillStyle = rgba(col, a);
ctx.beginPath();
ctx.arc(e.x, e.y, 1.6 * (1 - frac * 0.5) + 0.4, 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = rgba([24, 20, 38], 1);
ctx.fillRect(0, GROUND_Y, W, H - GROUND_Y);
},
label(): string {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap(): void {
for (let i = 0; i < 60; i += 1) {
spawn(inp.x, inp.y, rng() * PI2, PI2, 0.8, 3.2);
}
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
export default function FwEmberCoolingFlickerNoise() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="흰빛에서 주황, 붉은 잿빛으로 식으며 깜빡이다 꺼지는 불씨" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">불씨가 식는다 — 수명 비율이 색 단계를 고른다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>흰빛에서 주황, 붉은 잿빛으로 식으며 깜빡이다 꺼지는 불씨</p>
</div>
</main>
);
}05 · 흰빛에서 주황, 붉은 잿빛으로 식으며 깜빡이다 꺼지는 불씨 — 측정 누적면적 24.988% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function coolColor(t) {
const c = clamp(t, 0, 1);
for (let i = 1; i < COOL_STOPS.length; i += 1) {
if (c <= COOL_STOPS[i][0]) {
const a = COOL_STOPS[i - 1];
const b = COOL_STOPS[i];
return mixRGB(a[1], b[1], (c - a[0]) / (b[0] - a[0]));
}
}
return COOL_STOPS[COOL_STOPS.length - 1][1];
}
// 불씨 하나의 밝기 — 1차원 노이즈로 흔들고, 식을수록 흔들림 폭이 커진다
function flickerOf(id, seconds, frac, amp) {
const n = noise1(id * 3.7 + seconds * FLICKER_HZ, 7);
return 1 - amp * n * (0.3 + 0.7 * frac * frac);
}
06수면에 거꾸로 비치고 잔물결에 일그러지는 불꽃의 반사
하늘만 따로 그려 원본으로 두고, 물의 j 번째 줄에 수평선에서 j 줄 위의 하늘을 뒤집어 붙여 반사를 만든다. 줄마다 rowShift 가 사인 파동으로 가로 밀림을 주고 깊을수록 알파가 옅어져서 물결에 일그러진 모습이 된다. 이 장면은 면적 22.8%·강도 85.8·23/23 프레임 움직임으로 면적이 아홉 중 가장 작다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="수면에 거꾸로 비치고 잔물결에 일그러지는 불꽃의 반사"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">수평선 위를 뒤집어 다시 그린다 — 줄마다 사인 파동으로 밀린다</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">수면에 거꾸로 비치고 잔물결에 일그러지는 불꽃의 반사</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const PALETTE = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP = [18, 16, 32];
const SKY_BOT = [46, 40, 76];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x, seed) {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx, height) {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce() {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function () {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const MIRROR_ALPHA = 0.6;
const WAVE_AMP = 3;
const WAVE_K = 0.35;
const HORIZON_Y = 190;
const WAVE_SPEED = 0.1;
const SPARK_N = 60;
const SPARK_DRAG = 0.97;
const SPARK_GRAVITY = 0.04;
const BURST_GAP = 46;
const PREWARM_STEPS = 44;
const WATER_TOP = [30, 32, 62];
const WATER_BOTTOM = [18, 18, 38];
const TXT_AUTO = '수평선 위를 뒤집어 다시 그린다 — 줄마다 사인 파동으로 밀린다';
const TXT_TAP = '누른 자리에서 터진 불꽃이 물 위에 비친다';
// 물 위로 j 번째 줄이 가로로 밀리는 거리 — 깊을수록 크게, 사인 하나에 노이즈를 조금 섞는다
function rowShift(j, tick) {
const depth = 0.5 + j / (H - HORIZON_Y);
return WAVE_AMP * depth * Math.sin(WAVE_K * j + tick * WAVE_SPEED) + WAVE_AMP * 0.35 * (noise1(j * 0.2 + tick * 0.05, 3) - 0.5);
}
// 측정용 — 모든 줄에서 가로 밀림의 최댓값(px)
function maxShift() {
let best = 0;
for (let tick = 0; tick < 120; tick += 1) {
for (let j = 0; j < H - HORIZON_Y; j += 1) {
best = Math.max(best, Math.abs(rowShift(j, tick)));
}
}
return best;
}
function makeScene(inp, reduce) {
const rng = lcg(606);
let sparks = [];
let gap = 2;
let t = 0;
let colorIdx = 0;
let sky = null;
let skyDpr = 0;
function burst(x, y) {
const hue = PALETTE[colorIdx % PALETTE.length];
colorIdx += 1;
for (let i = 0; i < SPARK_N; i += 1) {
const a = (i / SPARK_N) * PI2 + rng() * 0.1;
const sp = 1.8 + rng() * 1.6;
sparks.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 60 + rng() * 30, hue: i % 3 === 0 ? CREAM : hue });
}
if (sparks.length > 300) {
sparks.splice(0, sparks.length - 300);
}
}
function skyCanvas(dpr) {
if (!sky || skyDpr !== dpr) {
sky = document.createElement('canvas');
sky.width = Math.round(W * dpr);
sky.height = Math.round(HORIZON_Y * dpr);
skyDpr = dpr;
}
return sky;
}
// 하늘 부분만 따로 그린다 — 이 그림이 곧 반사의 원본이다
function paintSky(c) {
drawSky(c, HORIZON_Y);
c.fillStyle = rgba([24, 20, 38], 1);
c.beginPath();
c.moveTo(0, HORIZON_Y);
for (let x = 0; x <= W; x += 24) {
c.lineTo(x, HORIZON_Y - 8 - 14 * noise1(x * 0.02, 9));
}
c.lineTo(W, HORIZON_Y);
c.closePath();
c.fill();
c.globalCompositeOperation = 'lighter';
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
if (s.y < HORIZON_Y) {
const a = Math.pow(1 - s.age / s.life, 0.8);
c.strokeStyle = rgba(s.hue, a * 0.5);
c.lineWidth = 1.4;
c.beginPath();
c.moveTo(s.x - s.vx * 1.6, s.y - s.vy * 1.6);
c.lineTo(s.x, s.y);
c.stroke();
c.fillStyle = rgba(s.hue, a);
c.beginPath();
c.arc(s.x, s.y, 1.3 + 1.7 * a, 0, PI2);
c.fill();
}
}
c.globalCompositeOperation = 'source-over';
}
const scene = {
step() {
t += 1;
gap -= 1;
if (gap <= 0) {
burst(90 + rng() * 300, 95 + rng() * 40);
gap = BURST_GAP;
}
const alive = [];
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
s.age += 1;
s.vx *= SPARK_DRAG;
s.vy = s.vy * SPARK_DRAG + SPARK_GRAVITY;
s.x += s.vx;
s.y += s.vy;
if (s.age < s.life) {
alive.push(s);
}
}
sparks = alive;
},
draw(ctx) {
const dpr = inp.dpr;
const cv = skyCanvas(dpr);
const sc = cv.getContext('2d');
if (!sc) {
return;
}
sc.setTransform(dpr, 0, 0, dpr, 0, 0);
paintSky(sc);
const wg = ctx.createLinearGradient(0, HORIZON_Y, 0, H);
wg.addColorStop(0, rgba(WATER_TOP, 1));
wg.addColorStop(1, rgba(WATER_BOTTOM, 1));
ctx.fillStyle = wg;
ctx.fillRect(0, HORIZON_Y, W, H - HORIZON_Y);
ctx.drawImage(cv, 0, 0, W, HORIZON_Y);
const rows = H - HORIZON_Y;
const wobble = reduce ? 0 : t;
for (let j = 0; j < rows; j += 1) {
// 수평선에서 j 줄 아래의 물은 j 줄 위의 하늘을 뒤집어 보여 준다
const sy = (HORIZON_Y - 1 - j) * dpr;
ctx.globalAlpha = MIRROR_ALPHA * (1 - j / (rows * 1.5));
ctx.drawImage(cv, 0, sy, W * dpr, dpr, rowShift(j, wobble), HORIZON_Y + j, W, 1);
}
ctx.globalAlpha = 1;
ctx.fillStyle = rgba(CREAM, 0.16);
ctx.fillRect(0, HORIZON_Y, W, 1);
},
label() {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap() {
burst(inp.x, clamp(inp.y, 40, HORIZON_Y - 30));
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./FwWaterMirrorRippleReflection.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; vx: number; vy: number; dpr: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const PALETTE: RGB[] = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP: RGB = [18, 16, 32];
const SKY_BOT: RGB = [46, 40, 76];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x: number, seed: number): number {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx: CanvasRenderingContext2D, height: number): void {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit(): void {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: any) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce(): void {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: PointerEvent): void {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e: PointerEvent): void {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: KeyboardEvent): void {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function (): void {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const MIRROR_ALPHA = 0.6;
const WAVE_AMP = 3;
const WAVE_K = 0.35;
const HORIZON_Y = 190;
const WAVE_SPEED = 0.1;
const SPARK_N = 60;
const SPARK_DRAG = 0.97;
const SPARK_GRAVITY = 0.04;
const BURST_GAP = 46;
const PREWARM_STEPS = 44;
const WATER_TOP: RGB = [30, 32, 62];
const WATER_BOTTOM: RGB = [18, 18, 38];
const TXT_AUTO = '수평선 위를 뒤집어 다시 그린다 — 줄마다 사인 파동으로 밀린다';
const TXT_TAP = '누른 자리에서 터진 불꽃이 물 위에 비친다';
interface Spark { x: number; y: number; vx: number; vy: number; age: number; life: number; hue: RGB; }
// 물 위로 j 번째 줄이 가로로 밀리는 거리 — 깊을수록 크게, 사인 하나에 노이즈를 조금 섞는다
function rowShift(j: number, tick: number): number {
const depth = 0.5 + j / (H - HORIZON_Y);
return WAVE_AMP * depth * Math.sin(WAVE_K * j + tick * WAVE_SPEED) + WAVE_AMP * 0.35 * (noise1(j * 0.2 + tick * 0.05, 3) - 0.5);
}
// 측정용 — 모든 줄에서 가로 밀림의 최댓값(px)
function maxShift(): number {
let best = 0;
for (let tick = 0; tick < 120; tick += 1) {
for (let j = 0; j < H - HORIZON_Y; j += 1) {
best = Math.max(best, Math.abs(rowShift(j, tick)));
}
}
return best;
}
function makeScene(inp: Input, reduce: boolean): Scene {
const rng = lcg(606);
let sparks: Spark[] = [];
let gap = 2;
let t = 0;
let colorIdx = 0;
let sky: HTMLCanvasElement | null = null;
let skyDpr = 0;
function burst(x: number, y: number): void {
const hue = PALETTE[colorIdx % PALETTE.length];
colorIdx += 1;
for (let i = 0; i < SPARK_N; i += 1) {
const a = (i / SPARK_N) * PI2 + rng() * 0.1;
const sp = 1.8 + rng() * 1.6;
sparks.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 60 + rng() * 30, hue: i % 3 === 0 ? CREAM : hue });
}
if (sparks.length > 300) {
sparks.splice(0, sparks.length - 300);
}
}
function skyCanvas(dpr: number): HTMLCanvasElement {
if (!sky || skyDpr !== dpr) {
sky = document.createElement('canvas');
sky.width = Math.round(W * dpr);
sky.height = Math.round(HORIZON_Y * dpr);
skyDpr = dpr;
}
return sky;
}
// 하늘 부분만 따로 그린다 — 이 그림이 곧 반사의 원본이다
function paintSky(c: CanvasRenderingContext2D): void {
drawSky(c, HORIZON_Y);
c.fillStyle = rgba([24, 20, 38], 1);
c.beginPath();
c.moveTo(0, HORIZON_Y);
for (let x = 0; x <= W; x += 24) {
c.lineTo(x, HORIZON_Y - 8 - 14 * noise1(x * 0.02, 9));
}
c.lineTo(W, HORIZON_Y);
c.closePath();
c.fill();
c.globalCompositeOperation = 'lighter';
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
if (s.y < HORIZON_Y) {
const a = Math.pow(1 - s.age / s.life, 0.8);
c.strokeStyle = rgba(s.hue, a * 0.5);
c.lineWidth = 1.4;
c.beginPath();
c.moveTo(s.x - s.vx * 1.6, s.y - s.vy * 1.6);
c.lineTo(s.x, s.y);
c.stroke();
c.fillStyle = rgba(s.hue, a);
c.beginPath();
c.arc(s.x, s.y, 1.3 + 1.7 * a, 0, PI2);
c.fill();
}
}
c.globalCompositeOperation = 'source-over';
}
const scene: Scene = {
step(): void {
t += 1;
gap -= 1;
if (gap <= 0) {
burst(90 + rng() * 300, 95 + rng() * 40);
gap = BURST_GAP;
}
const alive: Spark[] = [];
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
s.age += 1;
s.vx *= SPARK_DRAG;
s.vy = s.vy * SPARK_DRAG + SPARK_GRAVITY;
s.x += s.vx;
s.y += s.vy;
if (s.age < s.life) {
alive.push(s);
}
}
sparks = alive;
},
draw(ctx: CanvasRenderingContext2D): void {
const dpr = inp.dpr;
const cv = skyCanvas(dpr);
const sc = cv.getContext('2d');
if (!sc) {
return;
}
sc.setTransform(dpr, 0, 0, dpr, 0, 0);
paintSky(sc);
const wg = ctx.createLinearGradient(0, HORIZON_Y, 0, H);
wg.addColorStop(0, rgba(WATER_TOP, 1));
wg.addColorStop(1, rgba(WATER_BOTTOM, 1));
ctx.fillStyle = wg;
ctx.fillRect(0, HORIZON_Y, W, H - HORIZON_Y);
ctx.drawImage(cv, 0, 0, W, HORIZON_Y);
const rows = H - HORIZON_Y;
const wobble = reduce ? 0 : t;
for (let j = 0; j < rows; j += 1) {
// 수평선에서 j 줄 아래의 물은 j 줄 위의 하늘을 뒤집어 보여 준다
const sy = (HORIZON_Y - 1 - j) * dpr;
ctx.globalAlpha = MIRROR_ALPHA * (1 - j / (rows * 1.5));
ctx.drawImage(cv, 0, sy, W * dpr, dpr, rowShift(j, wobble), HORIZON_Y + j, W, 1);
}
ctx.globalAlpha = 1;
ctx.fillStyle = rgba(CREAM, 0.16);
ctx.fillRect(0, HORIZON_Y, W, 1);
},
label(): string {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap(): void {
burst(inp.x, clamp(inp.y, 40, HORIZON_Y - 30));
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
export default function FwWaterMirrorRippleReflection() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="수면에 거꾸로 비치고 잔물결에 일그러지는 불꽃의 반사" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">수평선 위를 뒤집어 다시 그린다 — 줄마다 사인 파동으로 밀린다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>수면에 거꾸로 비치고 잔물결에 일그러지는 불꽃의 반사</p>
</div>
</main>
);
}06 · 수면에 거꾸로 비치고 잔물결에 일그러지는 불꽃의 반사 — 측정 누적면적 22.779% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function rowShift(j, tick) {
const depth = 0.5 + j / (H - HORIZON_Y);
return WAVE_AMP * depth * Math.sin(WAVE_K * j + tick * WAVE_SPEED) + WAVE_AMP * 0.35 * (noise1(j * 0.2 + tick * 0.05, 3) - 0.5);
}
const rows = H - HORIZON_Y;
const wobble = reduce ? 0 : t;
for (let j = 0; j < rows; j += 1) {
// 수평선에서 j 줄 아래의 물은 j 줄 위의 하늘을 뒤집어 보여 준다
const sy = (HORIZON_Y - 1 - j) * dpr;
ctx.globalAlpha = MIRROR_ALPHA * (1 - j / (rows * 1.5));
ctx.drawImage(cv, 0, sy, W * dpr, dpr, rowShift(j, wobble), HORIZON_Y + j, W, 1);
}
07알갱이가 수명 끝에서 다시 잘게 터지는 두 겹 크래클
수명이 끝난 부모 알갱이가 SPLIT_P 확률로 자식 다섯 개를 만들고, 자식은 더 짧게 살다 사라지며 깊이 제한 때문에 더 터지지 않는다. 폭발이 두 겹에서 멈추는 이 장치가 있어야 알갱이 수가 눈덩이처럼 불지 않는다. 면적 65.2% 로 아홉 장면 중 화면을 가장 넓게 쓰고 강도 75.2, 프레임 23/23 이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="알갱이가 수명 끝에서 다시 잘게 터지는 두 겹 크래클"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">수명이 끝난 알갱이가 다시 터진다 — 깊이는 한 단계까지</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">알갱이가 수명 끝에서 다시 잘게 터지는 두 겹 크래클</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const PALETTE = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP = [18, 16, 32];
const SKY_BOT = [46, 40, 76];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x, seed) {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx, height) {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce() {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function () {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const SPLIT_DEPTH = 1;
const CHILD_N = 5;
const CHILD_SPEED = 1.15;
const SPLIT_P = 0.55;
const PARENT_N = 84;
const GRAIN_DRAG = 0.962;
const GRAIN_GRAVITY = 0.04;
const SHELL_GAP = 50;
const AMBIENT_DECAY = 0.965;
const PREWARM_STEPS = 52;
const TXT_AUTO = '수명이 끝난 알갱이가 다시 터진다 — 깊이는 한 단계까지';
const TXT_TAP = '누른 자리에서 두 겹으로 터진다';
// 수명이 끝난 알갱이가 자식을 낳는다 — 깊이가 SPLIT_DEPTH 에 닿았으면 낳지 않는다
function splitChildren(p, rng) {
const kids = [];
if (p.depth >= SPLIT_DEPTH || rng() >= SPLIT_P) {
return kids;
}
const base = rng() * PI2;
for (let i = 0; i < CHILD_N; i += 1) {
const a = base + (i / CHILD_N) * PI2;
const sp = CHILD_SPEED * (0.7 + rng() * 0.6);
kids.push({ x: p.x, y: p.y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 20 + rng() * 10, depth: p.depth + 1, hue: CREAM });
}
return kids;
}
// 측정용 — 부모 n 개가 끝날 때 몇 개가 자식을 낳고 자식 알갱이는 모두 몇 개인가
function splitTally(n, seed) {
const rng = lcg(seed);
let parents = 0;
let kids = 0;
let grand = 0;
for (let i = 0; i < n; i += 1) {
const p = { x: 0, y: 0, vx: 0, vy: 0, age: 0, life: 1, depth: 0, hue: PINK };
const k = splitChildren(p, rng);
if (k.length > 0) {
parents += 1;
kids += k.length;
grand += splitChildren(k[0], rng).length;
}
}
return { parents: parents, kids: kids, grandKids: grand };
}
function makeScene(inp, reduce) {
const rng = lcg(707);
let grains = [];
let pops = [];
let gap = 2;
let ambient = 0;
let colorIdx = 0;
function burst(x, y) {
const hue = PALETTE[colorIdx % PALETTE.length];
colorIdx += 1;
for (let i = 0; i < PARENT_N; i += 1) {
const a = rng() * PI2;
const sp = 0.6 + 2.4 * Math.sqrt(rng());
grains.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 46 + rng() * 30, depth: 0, hue: hue });
}
}
const scene = {
step() {
gap -= 1;
if (gap <= 0) {
burst(100 + rng() * 280, 80 + rng() * 70);
gap = SHELL_GAP;
}
const next = [];
for (let i = 0; i < grains.length; i += 1) {
const g = grains[i];
g.age += 1;
g.vx *= GRAIN_DRAG;
g.vy = g.vy * GRAIN_DRAG + GRAIN_GRAVITY;
g.x += g.vx;
g.y += g.vy;
if (g.age < g.life) {
next.push(g);
}
else {
const kids = splitChildren(g, rng);
if (kids.length > 0) {
pops.push({ x: g.x, y: g.y, age: 0 });
ambient = Math.min(ambient + 0.035, 0.7);
for (let k = 0; k < kids.length; k += 1) {
next.push(kids[k]);
}
}
}
}
grains = next;
const livePops = [];
for (let i = 0; i < pops.length; i += 1) {
pops[i].age += 1;
if (pops[i].age < 9) {
livePops.push(pops[i]);
}
}
pops = livePops;
ambient *= AMBIENT_DECAY;
},
draw(ctx) {
drawSky(ctx, H);
// 터질 때마다 쌓이는 잔광 — 하늘 전체가 라일락빛으로 물든다
ctx.fillStyle = rgba(LILAC, ambient * (reduce ? 0.12 : 0.26));
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < pops.length; i += 1) {
const p = pops[i];
ctx.fillStyle = rgba(CREAM, (1 - p.age / 9) * (reduce ? 0.25 : 0.5));
ctx.beginPath();
ctx.arc(p.x, p.y, 4 + p.age * 1.6, 0, PI2);
ctx.fill();
}
for (let i = 0; i < grains.length; i += 1) {
const g = grains[i];
const a = Math.pow(1 - g.age / g.life, 0.8);
const child = g.depth > 0;
ctx.strokeStyle = rgba(g.hue, a * 0.5);
ctx.lineWidth = child ? 1.8 : 1.4;
ctx.beginPath();
ctx.moveTo(g.x - g.vx * 1.8, g.y - g.vy * 1.8);
ctx.lineTo(g.x, g.y);
ctx.stroke();
ctx.fillStyle = rgba(g.hue, a);
ctx.beginPath();
ctx.arc(g.x, g.y, child ? 2.1 : 1.3 + 1.4 * a, 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
},
label() {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap() {
burst(inp.x, inp.y);
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./FwCrackleSecondarySplitDepth.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; vx: number; vy: number; dpr: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const PALETTE: RGB[] = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP: RGB = [18, 16, 32];
const SKY_BOT: RGB = [46, 40, 76];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x: number, seed: number): number {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx: CanvasRenderingContext2D, height: number): void {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit(): void {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: any) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce(): void {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: PointerEvent): void {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e: PointerEvent): void {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: KeyboardEvent): void {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function (): void {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const SPLIT_DEPTH = 1;
const CHILD_N = 5;
const CHILD_SPEED = 1.15;
const SPLIT_P = 0.55;
const PARENT_N = 84;
const GRAIN_DRAG = 0.962;
const GRAIN_GRAVITY = 0.04;
const SHELL_GAP = 50;
const AMBIENT_DECAY = 0.965;
const PREWARM_STEPS = 52;
const TXT_AUTO = '수명이 끝난 알갱이가 다시 터진다 — 깊이는 한 단계까지';
const TXT_TAP = '누른 자리에서 두 겹으로 터진다';
interface Grain { x: number; y: number; vx: number; vy: number; age: number; life: number; depth: number; hue: RGB; }
interface Pop { x: number; y: number; age: number; }
// 수명이 끝난 알갱이가 자식을 낳는다 — 깊이가 SPLIT_DEPTH 에 닿았으면 낳지 않는다
function splitChildren(p: Grain, rng: () => number): Grain[] {
const kids: Grain[] = [];
if (p.depth >= SPLIT_DEPTH || rng() >= SPLIT_P) {
return kids;
}
const base = rng() * PI2;
for (let i = 0; i < CHILD_N; i += 1) {
const a = base + (i / CHILD_N) * PI2;
const sp = CHILD_SPEED * (0.7 + rng() * 0.6);
kids.push({ x: p.x, y: p.y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 20 + rng() * 10, depth: p.depth + 1, hue: CREAM });
}
return kids;
}
// 측정용 — 부모 n 개가 끝날 때 몇 개가 자식을 낳고 자식 알갱이는 모두 몇 개인가
function splitTally(n: number, seed: number): { parents: number; kids: number; grandKids: number } {
const rng = lcg(seed);
let parents = 0;
let kids = 0;
let grand = 0;
for (let i = 0; i < n; i += 1) {
const p: Grain = { x: 0, y: 0, vx: 0, vy: 0, age: 0, life: 1, depth: 0, hue: PINK };
const k = splitChildren(p, rng);
if (k.length > 0) {
parents += 1;
kids += k.length;
grand += splitChildren(k[0], rng).length;
}
}
return { parents: parents, kids: kids, grandKids: grand };
}
function makeScene(inp: Input, reduce: boolean): Scene {
const rng = lcg(707);
let grains: Grain[] = [];
let pops: Pop[] = [];
let gap = 2;
let ambient = 0;
let colorIdx = 0;
function burst(x: number, y: number): void {
const hue = PALETTE[colorIdx % PALETTE.length];
colorIdx += 1;
for (let i = 0; i < PARENT_N; i += 1) {
const a = rng() * PI2;
const sp = 0.6 + 2.4 * Math.sqrt(rng());
grains.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 46 + rng() * 30, depth: 0, hue: hue });
}
}
const scene: Scene = {
step(): void {
gap -= 1;
if (gap <= 0) {
burst(100 + rng() * 280, 80 + rng() * 70);
gap = SHELL_GAP;
}
const next: Grain[] = [];
for (let i = 0; i < grains.length; i += 1) {
const g = grains[i];
g.age += 1;
g.vx *= GRAIN_DRAG;
g.vy = g.vy * GRAIN_DRAG + GRAIN_GRAVITY;
g.x += g.vx;
g.y += g.vy;
if (g.age < g.life) {
next.push(g);
}
else {
const kids = splitChildren(g, rng);
if (kids.length > 0) {
pops.push({ x: g.x, y: g.y, age: 0 });
ambient = Math.min(ambient + 0.035, 0.7);
for (let k = 0; k < kids.length; k += 1) {
next.push(kids[k]);
}
}
}
}
grains = next;
const livePops: Pop[] = [];
for (let i = 0; i < pops.length; i += 1) {
pops[i].age += 1;
if (pops[i].age < 9) {
livePops.push(pops[i]);
}
}
pops = livePops;
ambient *= AMBIENT_DECAY;
},
draw(ctx: CanvasRenderingContext2D): void {
drawSky(ctx, H);
// 터질 때마다 쌓이는 잔광 — 하늘 전체가 라일락빛으로 물든다
ctx.fillStyle = rgba(LILAC, ambient * (reduce ? 0.12 : 0.26));
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < pops.length; i += 1) {
const p = pops[i];
ctx.fillStyle = rgba(CREAM, (1 - p.age / 9) * (reduce ? 0.25 : 0.5));
ctx.beginPath();
ctx.arc(p.x, p.y, 4 + p.age * 1.6, 0, PI2);
ctx.fill();
}
for (let i = 0; i < grains.length; i += 1) {
const g = grains[i];
const a = Math.pow(1 - g.age / g.life, 0.8);
const child = g.depth > 0;
ctx.strokeStyle = rgba(g.hue, a * 0.5);
ctx.lineWidth = child ? 1.8 : 1.4;
ctx.beginPath();
ctx.moveTo(g.x - g.vx * 1.8, g.y - g.vy * 1.8);
ctx.lineTo(g.x, g.y);
ctx.stroke();
ctx.fillStyle = rgba(g.hue, a);
ctx.beginPath();
ctx.arc(g.x, g.y, child ? 2.1 : 1.3 + 1.4 * a, 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
},
label(): string {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap(): void {
burst(inp.x, inp.y);
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
export default function FwCrackleSecondarySplitDepth() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="알갱이가 수명 끝에서 다시 잘게 터지는 두 겹 크래클" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">수명이 끝난 알갱이가 다시 터진다 — 깊이는 한 단계까지</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>알갱이가 수명 끝에서 다시 잘게 터지는 두 겹 크래클</p>
</div>
</main>
);
}07 · 알갱이가 수명 끝에서 다시 잘게 터지는 두 겹 크래클 — 측정 누적면적 65.167% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function splitChildren(p, rng) {
const kids = [];
if (p.depth >= SPLIT_DEPTH || rng() >= SPLIT_P) {
return kids;
}
const base = rng() * PI2;
for (let i = 0; i < CHILD_N; i += 1) {
const a = base + (i / CHILD_N) * PI2;
const sp = CHILD_SPEED * (0.7 + rng() * 0.6);
kids.push({ x: p.x, y: p.y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 20 + rng() * 10, depth: p.depth + 1, hue: CREAM });
}
return kids;
}
08터질 때마다 하늘이 물들고 건물 실루엣이 거리별로 밝아지는 연속 발사
폭발이 일어난 자리를 광원으로 적어 두고, 건물의 띠마다 모든 광원이 주는 밝기를 더해 색을 정하는데, 광원 하나의 밝기는 (1−나이/60)²/(1+(d/90)²) 이라 막 터졌을 때 가장 세고 90px 떨어진 띠에서는 절반으로 줄어든다. 네 발을 이어 쏘고 쉬는 식이라 앞선 발의 빛이 남은 채 다음 발이 겹친다. 면적 45.1%, 강도 44.2 로 아홉 중 가장 낮고 23/23 프레임이 움직였다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="터질 때마다 하늘이 물들고 건물 실루엣이 거리별로 밝아지는 연속 발사"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">폭발이 광원이 된다 — 거리 제곱에 반비례해 건물이 밝아진다</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">터질 때마다 하늘이 물들고 건물 실루엣이 거리별로 밝아지는 연속 발사</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: manipulation;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const PALETTE = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP = [18, 16, 32];
const SKY_BOT = [46, 40, 76];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x, seed) {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx, height) {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce() {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function () {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const VOLLEY_N = 4;
const SKY_GLOW = 120;
const LIGHT_FALLOFF = 90;
const GAP_MS = 260;
const VOLLEY_PAUSE = 60;
const BUILDING_N = 14;
const FLASH_LIFE = 60;
const SPARK_N = 52;
const SPARK_DRAG = 0.968;
const SPARK_GRAVITY = 0.04;
const BAND_N = 4;
const PREWARM_STEPS = 22;
const BUILDING_BASE = [34, 30, 56];
const TXT_AUTO = '폭발이 광원이 된다 — 거리 제곱에 반비례해 건물이 밝아진다';
const TXT_TAP = '누른 자리가 새 광원이 된다';
// 광원 하나가 점 (px, py) 에 주는 밝기 — 거리 제곱에 반비례하는 감쇠(LIGHT_FALLOFF 만큼 떨어지면 절반)
function lightAt(f, px, py) {
const d = Math.hypot(f.x - px, f.y - py);
const k = 1 - f.age / FLASH_LIFE;
return (k * k) / (1 + (d / LIGHT_FALLOFF) * (d / LIGHT_FALLOFF));
}
function makeScene(inp, reduce) {
const rng = lcg(808);
const GAP_STEPS = Math.round(GAP_MS / FIXED_DT);
const gain = reduce ? 0.5 : 1;
const buildings = [];
let bx = -6;
for (let i = 0; i < BUILDING_N; i += 1) {
const w = 26 + Math.floor(rng() * 18);
buildings.push({ x: bx, w: w, h: 46 + Math.floor(rng() * 72), seed: i * 17 + 3 });
bx += w + 2;
}
let flashes = [];
let sparks = [];
let volleyLeft = VOLLEY_N;
let gapTimer = 2;
let colorIdx = 0;
function burst(x, y) {
const hue = PALETTE[colorIdx % PALETTE.length];
colorIdx += 1;
flashes.push({ x: x, y: y, hue: hue, age: 0 });
if (flashes.length > 8) {
flashes.shift();
}
for (let i = 0; i < SPARK_N; i += 1) {
const a = rng() * PI2;
const sp = 1.8 + rng() * 1.8;
sparks.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 55 + rng() * 20, hue: i % 3 === 0 ? CREAM : hue });
}
if (sparks.length > 280) {
sparks.splice(0, sparks.length - 280);
}
}
const scene = {
step() {
gapTimer -= 1;
if (gapTimer <= 0) {
if (volleyLeft > 0) {
burst(60 + rng() * 360, 55 + rng() * 75);
volleyLeft -= 1;
gapTimer = volleyLeft > 0 ? GAP_STEPS : VOLLEY_PAUSE;
}
else {
volleyLeft = VOLLEY_N;
gapTimer = 1;
}
}
const liveF = [];
for (let i = 0; i < flashes.length; i += 1) {
flashes[i].age += 1;
if (flashes[i].age < FLASH_LIFE) {
liveF.push(flashes[i]);
}
}
flashes = liveF;
const alive = [];
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
s.age += 1;
s.vx *= SPARK_DRAG;
s.vy = s.vy * SPARK_DRAG + SPARK_GRAVITY;
s.x += s.vx;
s.y += s.vy;
if (s.age < s.life) {
alive.push(s);
}
}
sparks = alive;
},
draw(ctx) {
drawSky(ctx, H);
ctx.globalCompositeOperation = 'lighter';
// 하늘 — 광원마다 퍼지는 빛무리
for (let i = 0; i < flashes.length; i += 1) {
const f = flashes[i];
const k = (1 - f.age / FLASH_LIFE);
const r = SKY_GLOW * (0.55 + 0.45 * (1 - k));
const g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, rgba(f.hue, k * k * 0.7 * gain));
g.addColorStop(1, rgba(f.hue, 0));
ctx.fillStyle = g;
ctx.fillRect(f.x - r, f.y - r, r * 2, r * 2);
}
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
const a = Math.pow(1 - s.age / s.life, 0.8);
ctx.strokeStyle = rgba(s.hue, a * 0.5);
ctx.lineWidth = 1.1;
ctx.beginPath();
ctx.moveTo(s.x - s.vx * 1.6, s.y - s.vy * 1.6);
ctx.lineTo(s.x, s.y);
ctx.stroke();
ctx.fillStyle = rgba(s.hue, a);
ctx.beginPath();
ctx.arc(s.x, s.y, 1.3 + 1.7 * a, 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
// 건물 — 띠마다 모든 광원의 밝기를 더해 색을 섞는다
for (let b = 0; b < buildings.length; b += 1) {
const bd = buildings[b];
const bandH = bd.h / BAND_N;
for (let band = 0; band < BAND_N; band += 1) {
const cy = H - bd.h + (band + 0.5) * bandH;
let r = BUILDING_BASE[0];
let gg = BUILDING_BASE[1];
let bb = BUILDING_BASE[2];
for (let i = 0; i < flashes.length; i += 1) {
const l = lightAt(flashes[i], bd.x + bd.w / 2, cy) * 1.9 * gain;
r += flashes[i].hue[0] * l * 0.55;
gg += flashes[i].hue[1] * l * 0.55;
bb += flashes[i].hue[2] * l * 0.55;
}
ctx.fillStyle = 'rgb(' + Math.round(clamp(r, 0, 255)) + ',' + Math.round(clamp(gg, 0, 255)) + ',' + Math.round(clamp(bb, 0, 255)) + ')';
ctx.fillRect(bd.x, H - bd.h + band * bandH, bd.w, bandH + 0.5);
}
// 창문 — 불이 켜진 창은 빛을 받으면 더 밝아진다
for (let wy = 8; wy < bd.h - 4; wy += 11) {
for (let wx = 5; wx < bd.w - 5; wx += 9) {
if (hash2(wx + bd.seed, wy, 5) > 0.6) {
let lit = 0;
for (let i = 0; i < flashes.length; i += 1) {
lit += lightAt(flashes[i], bd.x + wx, H - bd.h + wy);
}
ctx.fillStyle = rgba(BUTTER, clamp(0.25 + lit * 1.4 * gain, 0, 1));
ctx.fillRect(bd.x + wx, H - bd.h + wy, 3, 4);
}
}
}
}
},
label() {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap() {
burst(inp.x, clamp(inp.y, 40, H - 110));
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./FwSkylineFalloffLightVolley.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; vx: number; vy: number; dpr: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const PALETTE: RGB[] = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP: RGB = [18, 16, 32];
const SKY_BOT: RGB = [46, 40, 76];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x: number, seed: number): number {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx: CanvasRenderingContext2D, height: number): void {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit(): void {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: any) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce(): void {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: PointerEvent): void {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e: PointerEvent): void {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: KeyboardEvent): void {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function (): void {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const VOLLEY_N = 4;
const SKY_GLOW = 120;
const LIGHT_FALLOFF = 90;
const GAP_MS = 260;
const VOLLEY_PAUSE = 60;
const BUILDING_N = 14;
const FLASH_LIFE = 60;
const SPARK_N = 52;
const SPARK_DRAG = 0.968;
const SPARK_GRAVITY = 0.04;
const BAND_N = 4;
const PREWARM_STEPS = 22;
const BUILDING_BASE: RGB = [34, 30, 56];
const TXT_AUTO = '폭발이 광원이 된다 — 거리 제곱에 반비례해 건물이 밝아진다';
const TXT_TAP = '누른 자리가 새 광원이 된다';
interface Flash { x: number; y: number; hue: RGB; age: number; }
interface Spark { x: number; y: number; vx: number; vy: number; age: number; life: number; hue: RGB; }
interface Building { x: number; w: number; h: number; seed: number; }
// 광원 하나가 점 (px, py) 에 주는 밝기 — 거리 제곱에 반비례하는 감쇠(LIGHT_FALLOFF 만큼 떨어지면 절반)
function lightAt(f: Flash, px: number, py: number): number {
const d = Math.hypot(f.x - px, f.y - py);
const k = 1 - f.age / FLASH_LIFE;
return (k * k) / (1 + (d / LIGHT_FALLOFF) * (d / LIGHT_FALLOFF));
}
function makeScene(inp: Input, reduce: boolean): Scene {
const rng = lcg(808);
const GAP_STEPS = Math.round(GAP_MS / FIXED_DT);
const gain = reduce ? 0.5 : 1;
const buildings: Building[] = [];
let bx = -6;
for (let i = 0; i < BUILDING_N; i += 1) {
const w = 26 + Math.floor(rng() * 18);
buildings.push({ x: bx, w: w, h: 46 + Math.floor(rng() * 72), seed: i * 17 + 3 });
bx += w + 2;
}
let flashes: Flash[] = [];
let sparks: Spark[] = [];
let volleyLeft = VOLLEY_N;
let gapTimer = 2;
let colorIdx = 0;
function burst(x: number, y: number): void {
const hue = PALETTE[colorIdx % PALETTE.length];
colorIdx += 1;
flashes.push({ x: x, y: y, hue: hue, age: 0 });
if (flashes.length > 8) {
flashes.shift();
}
for (let i = 0; i < SPARK_N; i += 1) {
const a = rng() * PI2;
const sp = 1.8 + rng() * 1.8;
sparks.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 55 + rng() * 20, hue: i % 3 === 0 ? CREAM : hue });
}
if (sparks.length > 280) {
sparks.splice(0, sparks.length - 280);
}
}
const scene: Scene = {
step(): void {
gapTimer -= 1;
if (gapTimer <= 0) {
if (volleyLeft > 0) {
burst(60 + rng() * 360, 55 + rng() * 75);
volleyLeft -= 1;
gapTimer = volleyLeft > 0 ? GAP_STEPS : VOLLEY_PAUSE;
}
else {
volleyLeft = VOLLEY_N;
gapTimer = 1;
}
}
const liveF: Flash[] = [];
for (let i = 0; i < flashes.length; i += 1) {
flashes[i].age += 1;
if (flashes[i].age < FLASH_LIFE) {
liveF.push(flashes[i]);
}
}
flashes = liveF;
const alive: Spark[] = [];
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
s.age += 1;
s.vx *= SPARK_DRAG;
s.vy = s.vy * SPARK_DRAG + SPARK_GRAVITY;
s.x += s.vx;
s.y += s.vy;
if (s.age < s.life) {
alive.push(s);
}
}
sparks = alive;
},
draw(ctx: CanvasRenderingContext2D): void {
drawSky(ctx, H);
ctx.globalCompositeOperation = 'lighter';
// 하늘 — 광원마다 퍼지는 빛무리
for (let i = 0; i < flashes.length; i += 1) {
const f = flashes[i];
const k = (1 - f.age / FLASH_LIFE);
const r = SKY_GLOW * (0.55 + 0.45 * (1 - k));
const g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, rgba(f.hue, k * k * 0.7 * gain));
g.addColorStop(1, rgba(f.hue, 0));
ctx.fillStyle = g;
ctx.fillRect(f.x - r, f.y - r, r * 2, r * 2);
}
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
const a = Math.pow(1 - s.age / s.life, 0.8);
ctx.strokeStyle = rgba(s.hue, a * 0.5);
ctx.lineWidth = 1.1;
ctx.beginPath();
ctx.moveTo(s.x - s.vx * 1.6, s.y - s.vy * 1.6);
ctx.lineTo(s.x, s.y);
ctx.stroke();
ctx.fillStyle = rgba(s.hue, a);
ctx.beginPath();
ctx.arc(s.x, s.y, 1.3 + 1.7 * a, 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
// 건물 — 띠마다 모든 광원의 밝기를 더해 색을 섞는다
for (let b = 0; b < buildings.length; b += 1) {
const bd = buildings[b];
const bandH = bd.h / BAND_N;
for (let band = 0; band < BAND_N; band += 1) {
const cy = H - bd.h + (band + 0.5) * bandH;
let r = BUILDING_BASE[0];
let gg = BUILDING_BASE[1];
let bb = BUILDING_BASE[2];
for (let i = 0; i < flashes.length; i += 1) {
const l = lightAt(flashes[i], bd.x + bd.w / 2, cy) * 1.9 * gain;
r += flashes[i].hue[0] * l * 0.55;
gg += flashes[i].hue[1] * l * 0.55;
bb += flashes[i].hue[2] * l * 0.55;
}
ctx.fillStyle = 'rgb(' + Math.round(clamp(r, 0, 255)) + ',' + Math.round(clamp(gg, 0, 255)) + ',' + Math.round(clamp(bb, 0, 255)) + ')';
ctx.fillRect(bd.x, H - bd.h + band * bandH, bd.w, bandH + 0.5);
}
// 창문 — 불이 켜진 창은 빛을 받으면 더 밝아진다
for (let wy = 8; wy < bd.h - 4; wy += 11) {
for (let wx = 5; wx < bd.w - 5; wx += 9) {
if (hash2(wx + bd.seed, wy, 5) > 0.6) {
let lit = 0;
for (let i = 0; i < flashes.length; i += 1) {
lit += lightAt(flashes[i], bd.x + wx, H - bd.h + wy);
}
ctx.fillStyle = rgba(BUTTER, clamp(0.25 + lit * 1.4 * gain, 0, 1));
ctx.fillRect(bd.x + wx, H - bd.h + wy, 3, 4);
}
}
}
}
},
label(): string {
return inp.idle < IDLE_TICKS ? TXT_TAP : TXT_AUTO;
},
tap(): void {
burst(inp.x, clamp(inp.y, 40, H - 110));
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
export default function FwSkylineFalloffLightVolley() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="터질 때마다 하늘이 물들고 건물 실루엣이 거리별로 밝아지는 연속 발사" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">폭발이 광원이 된다 — 거리 제곱에 반비례해 건물이 밝아진다</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>터질 때마다 하늘이 물들고 건물 실루엣이 거리별로 밝아지는 연속 발사</p>
</div>
</main>
);
}08 · 터질 때마다 하늘이 물들고 건물 실루엣이 거리별로 밝아지는 연속 발사 — 측정 누적면적 45.078% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function lightAt(f, px, py) {
const d = Math.hypot(f.x - px, f.y - py);
const k = 1 - f.age / FLASH_LIFE;
return (k * k) / (1 + (d / LIGHT_FALLOFF) * (d / LIGHT_FALLOFF));
}
for (let band = 0; band < BAND_N; band += 1) {
const cy = H - bd.h + (band + 0.5) * bandH;
let r = BUILDING_BASE[0];
let gg = BUILDING_BASE[1];
let bb = BUILDING_BASE[2];
for (let i = 0; i < flashes.length; i += 1) {
const l = lightAt(flashes[i], bd.x + bd.w / 2, cy) * 1.9 * gain;
r += flashes[i].hue[0] * l * 0.55;
gg += flashes[i].hue[1] * l * 0.55;
bb += flashes[i].hue[2] * l * 0.55;
}
ctx.fillStyle = 'rgb(' + Math.round(clamp(r, 0, 255)) + ',' + Math.round(clamp(gg, 0, 255)) + ',' + Math.round(clamp(bb, 0, 255)) + ')';
ctx.fillRect(bd.x, H - bd.h + band * bandH, bd.w, bandH + 0.5);
}
09포인터 바람에 연기 구름이 밀려나며 불꽃이 쏘아지는 히어로 장면
폭발이 남긴 연기 입자는 노이즈 방향으로 천천히 흐르고, 포인터가 움직인 속도만큼 반경 70px 안의 입자에 바람을 더하는데, 세기는 속도×0.1×(1−d/70)² 이라 가까울수록 세다. 사람이 만지지 않으면 가짜 포인터가 화면 가운데에서 좌우 150px 를 오가며 쓸고 지나가서 연기가 갈라지는 모양을 먼저 보여 준다. 면적 28.3%, 강도 46.5 이고 23/23 프레임이 움직였다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="포인터 바람에 연기 구름이 밀려나며 불꽃이 쏘아지는 히어로 장면"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">가짜 포인터가 연기를 가르며 지나간다 — 바람은 포인터 속도만큼</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">포인터 바람에 연기 구름이 밀려나며 불꽃이 쏘아지는 히어로 장면</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const PALETTE = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP = [18, 16, 32];
const SKY_BOT = [46, 40, 76];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x, seed) {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx, height) {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce() {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function () {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const WIND_R = 70;
const SMOKE_N = 70;
const NOISE_SCALE = 0.012;
const DRAFT = 0.035;
const WIND_GAIN = 0.1;
const SHELL_GAP = 52;
const SMOKE_LIFE = 150;
const MAX_PUFFS = 300;
const SPARK_N = 44;
const PREWARM_STEPS = 100;
const SMOKE_TINT = [206, 196, 236];
const TXT_AUTO = '가짜 포인터가 연기를 가르며 지나간다 — 바람은 포인터 속도만큼';
const TXT_WIND = '포인터가 빠를수록 연기가 크게 밀린다';
// 포인터 바람이 연기에 주는 가속 — 가까울수록 제곱으로 세진다
function windPush(dist, speed) {
if (dist >= WIND_R) {
return 0;
}
const k = 1 - dist / WIND_R;
return speed * WIND_GAIN * k * k;
}
function makeScene(inp, reduce) {
const rng = lcg(909);
let puffs = [];
let sparks = [];
let gap = 2;
let t = 0;
let colorIdx = 0;
let virtX = W / 2;
let virtY = 130;
let virtVx = 0;
let virtVy = 0;
let sprite = null;
function puffSprite() {
if (sprite) {
return sprite;
}
const size = 64;
const cv = document.createElement('canvas');
cv.width = size;
cv.height = size;
const c = cv.getContext('2d');
if (c) {
const g = c.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
g.addColorStop(0, rgba(SMOKE_TINT, 1));
g.addColorStop(1, rgba(SMOKE_TINT, 0));
c.fillStyle = g;
c.fillRect(0, 0, size, size);
}
sprite = cv;
return cv;
}
function burst(x, y) {
const hue = PALETTE[colorIdx % PALETTE.length];
colorIdx += 1;
for (let i = 0; i < SPARK_N; i += 1) {
const a = rng() * PI2;
const sp = 1.6 + rng() * 2.4;
sparks.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 48 + rng() * 24, hue: i % 3 === 0 ? CREAM : hue });
}
for (let i = 0; i < SMOKE_N; i += 1) {
const a = rng() * PI2;
const r = 26 * Math.sqrt(rng());
puffs.push({ x: x + Math.cos(a) * r, y: y + Math.sin(a) * r, vx: Math.cos(a) * 0.4, vy: Math.sin(a) * 0.4, age: 0, size: 14 + rng() * 14 });
}
if (puffs.length > MAX_PUFFS) {
puffs.splice(0, puffs.length - MAX_PUFFS);
}
}
const scene = {
step() {
t += 1;
gap -= 1;
if (gap <= 0) {
burst(100 + rng() * 280, 80 + rng() * 50);
gap = SHELL_GAP;
}
// 사람이 포인터를 쓰는 동안은 그 포인터, 아니면 가짜 포인터가 가로로 쓸고 지나간다
let px = inp.x;
let py = inp.y;
let pvx = inp.vx;
let pvy = inp.vy;
if (inp.idle >= IDLE_TICKS) {
const nx = W / 2 + 150 * Math.sin(t * 0.028);
const ny = 120 + 24 * Math.sin(t * 0.05);
virtVx = nx - virtX;
virtVy = ny - virtY;
virtX = nx;
virtY = ny;
px = virtX;
py = virtY;
pvx = virtVx;
pvy = virtVy;
}
const alive = [];
for (let i = 0; i < puffs.length; i += 1) {
const p = puffs[i];
p.age += 1;
// 노이즈 장이 방향을 정하고, 위로 오르는 기류(DRAFT)가 늘 조금 끌어올린다
const ang = vnoise(p.x * NOISE_SCALE + t * 0.002, p.y * NOISE_SCALE, 5) * PI2 * 2;
p.vx = p.vx * 0.96 + Math.cos(ang) * 0.02;
p.vy = p.vy * 0.96 + Math.sin(ang) * 0.02 - DRAFT * 0.1;
const d = Math.hypot(p.x - px, p.y - py);
p.vx += windPush(d, clamp(pvx, -12, 12));
p.vy += windPush(d, clamp(pvy, -12, 12));
p.x += p.vx;
p.y += p.vy;
if (p.age < SMOKE_LIFE) {
alive.push(p);
}
}
puffs = alive;
const liveS = [];
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
s.age += 1;
s.vx *= 0.965;
s.vy = s.vy * 0.965 + 0.04;
s.x += s.vx;
s.y += s.vy;
if (s.age < s.life) {
liveS.push(s);
}
}
sparks = liveS;
},
draw(ctx) {
drawSky(ctx, H);
const sp = puffSprite();
for (let i = 0; i < puffs.length; i += 1) {
const p = puffs[i];
const f = p.age / SMOKE_LIFE;
const r = p.size * (1 + f * 0.9);
ctx.globalAlpha = 0.2 * Math.min(1, p.age / 12) * (1 - f) * (1 - f);
ctx.drawImage(sp, p.x - r, p.y - r, r * 2, r * 2);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
const a = Math.pow(1 - s.age / s.life, 0.8);
ctx.strokeStyle = rgba(s.hue, a * 0.5);
ctx.lineWidth = 1.1;
ctx.beginPath();
ctx.moveTo(s.x - s.vx * 1.6, s.y - s.vy * 1.6);
ctx.lineTo(s.x, s.y);
ctx.stroke();
ctx.fillStyle = rgba(s.hue, a);
ctx.beginPath();
ctx.arc(s.x, s.y, 1.3 + 1.7 * a, 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
if (inp.idle >= IDLE_TICKS) {
ctx.strokeStyle = rgba(CREAM, 0.45);
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(virtX, virtY, 9, 0, PI2);
ctx.stroke();
ctx.beginPath();
ctx.arc(virtX, virtY, WIND_R, 0, PI2);
ctx.strokeStyle = rgba(CREAM, 0.12);
ctx.stroke();
}
},
label() {
return inp.idle < IDLE_TICKS ? TXT_WIND : TXT_AUTO;
},
tap() {
burst(inp.x, inp.y);
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./FwPointerWindSmokeDriftHero.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; vx: number; vy: number; dpr: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const PALETTE: RGB[] = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP: RGB = [18, 16, 32];
const SKY_BOT: RGB = [46, 40, 76];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x: number, seed: number): number {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx: CanvasRenderingContext2D, height: number): void {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit(): void {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: any) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce(): void {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: PointerEvent): void {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e: PointerEvent): void {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: KeyboardEvent): void {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function (): void {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const WIND_R = 70;
const SMOKE_N = 70;
const NOISE_SCALE = 0.012;
const DRAFT = 0.035;
const WIND_GAIN = 0.1;
const SHELL_GAP = 52;
const SMOKE_LIFE = 150;
const MAX_PUFFS = 300;
const SPARK_N = 44;
const PREWARM_STEPS = 100;
const SMOKE_TINT: RGB = [206, 196, 236];
const TXT_AUTO = '가짜 포인터가 연기를 가르며 지나간다 — 바람은 포인터 속도만큼';
const TXT_WIND = '포인터가 빠를수록 연기가 크게 밀린다';
interface Puff { x: number; y: number; vx: number; vy: number; age: number; size: number; }
interface Spark { x: number; y: number; vx: number; vy: number; age: number; life: number; hue: RGB; }
// 포인터 바람이 연기에 주는 가속 — 가까울수록 제곱으로 세진다
function windPush(dist: number, speed: number): number {
if (dist >= WIND_R) {
return 0;
}
const k = 1 - dist / WIND_R;
return speed * WIND_GAIN * k * k;
}
function makeScene(inp: Input, reduce: boolean): Scene {
const rng = lcg(909);
let puffs: Puff[] = [];
let sparks: Spark[] = [];
let gap = 2;
let t = 0;
let colorIdx = 0;
let virtX = W / 2;
let virtY = 130;
let virtVx = 0;
let virtVy = 0;
let sprite: HTMLCanvasElement | null = null;
function puffSprite(): HTMLCanvasElement {
if (sprite) {
return sprite;
}
const size = 64;
const cv = document.createElement('canvas');
cv.width = size;
cv.height = size;
const c = cv.getContext('2d');
if (c) {
const g = c.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
g.addColorStop(0, rgba(SMOKE_TINT, 1));
g.addColorStop(1, rgba(SMOKE_TINT, 0));
c.fillStyle = g;
c.fillRect(0, 0, size, size);
}
sprite = cv;
return cv;
}
function burst(x: number, y: number): void {
const hue = PALETTE[colorIdx % PALETTE.length];
colorIdx += 1;
for (let i = 0; i < SPARK_N; i += 1) {
const a = rng() * PI2;
const sp = 1.6 + rng() * 2.4;
sparks.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 48 + rng() * 24, hue: i % 3 === 0 ? CREAM : hue });
}
for (let i = 0; i < SMOKE_N; i += 1) {
const a = rng() * PI2;
const r = 26 * Math.sqrt(rng());
puffs.push({ x: x + Math.cos(a) * r, y: y + Math.sin(a) * r, vx: Math.cos(a) * 0.4, vy: Math.sin(a) * 0.4, age: 0, size: 14 + rng() * 14 });
}
if (puffs.length > MAX_PUFFS) {
puffs.splice(0, puffs.length - MAX_PUFFS);
}
}
const scene: Scene = {
step(): void {
t += 1;
gap -= 1;
if (gap <= 0) {
burst(100 + rng() * 280, 80 + rng() * 50);
gap = SHELL_GAP;
}
// 사람이 포인터를 쓰는 동안은 그 포인터, 아니면 가짜 포인터가 가로로 쓸고 지나간다
let px = inp.x;
let py = inp.y;
let pvx = inp.vx;
let pvy = inp.vy;
if (inp.idle >= IDLE_TICKS) {
const nx = W / 2 + 150 * Math.sin(t * 0.028);
const ny = 120 + 24 * Math.sin(t * 0.05);
virtVx = nx - virtX;
virtVy = ny - virtY;
virtX = nx;
virtY = ny;
px = virtX;
py = virtY;
pvx = virtVx;
pvy = virtVy;
}
const alive: Puff[] = [];
for (let i = 0; i < puffs.length; i += 1) {
const p = puffs[i];
p.age += 1;
// 노이즈 장이 방향을 정하고, 위로 오르는 기류(DRAFT)가 늘 조금 끌어올린다
const ang = vnoise(p.x * NOISE_SCALE + t * 0.002, p.y * NOISE_SCALE, 5) * PI2 * 2;
p.vx = p.vx * 0.96 + Math.cos(ang) * 0.02;
p.vy = p.vy * 0.96 + Math.sin(ang) * 0.02 - DRAFT * 0.1;
const d = Math.hypot(p.x - px, p.y - py);
p.vx += windPush(d, clamp(pvx, -12, 12));
p.vy += windPush(d, clamp(pvy, -12, 12));
p.x += p.vx;
p.y += p.vy;
if (p.age < SMOKE_LIFE) {
alive.push(p);
}
}
puffs = alive;
const liveS: Spark[] = [];
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
s.age += 1;
s.vx *= 0.965;
s.vy = s.vy * 0.965 + 0.04;
s.x += s.vx;
s.y += s.vy;
if (s.age < s.life) {
liveS.push(s);
}
}
sparks = liveS;
},
draw(ctx: CanvasRenderingContext2D): void {
drawSky(ctx, H);
const sp = puffSprite();
for (let i = 0; i < puffs.length; i += 1) {
const p = puffs[i];
const f = p.age / SMOKE_LIFE;
const r = p.size * (1 + f * 0.9);
ctx.globalAlpha = 0.2 * Math.min(1, p.age / 12) * (1 - f) * (1 - f);
ctx.drawImage(sp, p.x - r, p.y - r, r * 2, r * 2);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
const a = Math.pow(1 - s.age / s.life, 0.8);
ctx.strokeStyle = rgba(s.hue, a * 0.5);
ctx.lineWidth = 1.1;
ctx.beginPath();
ctx.moveTo(s.x - s.vx * 1.6, s.y - s.vy * 1.6);
ctx.lineTo(s.x, s.y);
ctx.stroke();
ctx.fillStyle = rgba(s.hue, a);
ctx.beginPath();
ctx.arc(s.x, s.y, 1.3 + 1.7 * a, 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
if (inp.idle >= IDLE_TICKS) {
ctx.strokeStyle = rgba(CREAM, 0.45);
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(virtX, virtY, 9, 0, PI2);
ctx.stroke();
ctx.beginPath();
ctx.arc(virtX, virtY, WIND_R, 0, PI2);
ctx.strokeStyle = rgba(CREAM, 0.12);
ctx.stroke();
}
},
label(): string {
return inp.idle < IDLE_TICKS ? TXT_WIND : TXT_AUTO;
},
tap(): void {
burst(inp.x, inp.y);
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
export default function FwPointerWindSmokeDriftHero() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="포인터 바람에 연기 구름이 밀려나며 불꽃이 쏘아지는 히어로 장면" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">가짜 포인터가 연기를 가르며 지나간다 — 바람은 포인터 속도만큼</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>포인터 바람에 연기 구름이 밀려나며 불꽃이 쏘아지는 히어로 장면</p>
</div>
</main>
);
}09 · 포인터 바람에 연기 구름이 밀려나며 불꽃이 쏘아지는 히어로 장면 — 측정 누적면적 28.317% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function windPush(dist, speed) {
if (dist >= WIND_R) {
return 0;
}
const k = 1 - dist / WIND_R;
return speed * WIND_GAIN * k * k;
}
값이 불꽃의 인상을 바꾸는 곳 — 알갱이 수·항력·파동 크기
값 하나로 인상이 크게 달라지는 곳은 셋이고, 정점 오차는 값을 바꾸지 않아도 확인할 만한 숫자다. 아래 숫자는 브라우저 렌더가 아니라 소스의 함수를 노드의 가짜 DOM 에서 직접 불러 센 값이라서, 바로 아래 렌더 측정 표와는 따로 읽어야 한다.
| 장면 | 누적면적 | 강도 평균 | 움직인 프레임 |
|---|---|---|---|
| 01 구면 폭발 | 23.0% | 86.8 | 23/23 |
| 02 정점 발사 | 58.2% | 76.6 | 23/23 |
| 03 모란·버들·고리 | 28.3% | 93.3 | 23/23 |
| 04 가산 꼬리 | 41.6% | 72.1 | 23/23 |
| 05 불씨 | 25.0% | 64.2 | 23/23 |
| 06 물 반사 | 22.8% | 85.8 | 23/23 |
| 07 크래클 | 65.2% | 75.2 | 23/23 |
| 08 도시 실루엣 | 45.1% | 44.2 | 23/23 |
| 09 연기와 바람 | 28.3% | 46.5 | 23/23 |
BURST_N 을 90 에서 240 으로 올리면 칸 점유가 90 에서 227 로 는다
01 에서 80틱이 지난 한 프레임을 6px 칸으로 나누어 세면 서로 다른 칸을 차지한 알갱이가 90개에서 227개가 되고, 가장 가까운 이웃과의 평균 거리는 14.3px 에서 7.4px 로 줄어든다. 30틱 뒤에는 88개에서 210개, 8.8px 에서 4.6px 이다. 점으로 보이던 구가 얇은 막처럼 차오른다.
WILLOW_DRAG 를 0.96 에서 0.99 로 올리면 평균 도달 거리가 2.26배가 된다
03 의 버들이 124틱 동안 출발점에서 날아간 평균 거리는 132.5px 에서 299.5px 로 늘고, 화면 높이가 300px 이니 가지가 바닥 근처까지 드리운다. 항력 계수가 1 에 가까워질수록 속도가 거의 줄지 않기 때문이다. 값을 0.005 만 올려도 길이 차이가 눈에 띄므로 이 상수는 조금씩 움직여야 한다.
WAVE_AMP 를 3 에서 10 으로 키우면 줄 밀림 최댓값이 4.8px 에서 16.1px 가 된다
06 에서 120틱 동안 물의 110개 줄이 가로로 밀린 최댓값이 4.8px 에서 16.1px 로 늘었다. 알갱이 하나의 폭이 약 5px 라서 일그러짐이 폭의 1배 남짓에서 3배 넘게 번지고, 반사가 불꽃 모양으로 알아보기 어려워진다. 틱 10 에서 맨 위 줄은 1.2px 에서 4.0px, 맨 아래 줄은 -4.3px 에서 -14.5px 로 움직였다.
표적 높이 60 에서 190 까지 정점 오차는 0.3px 안이다
02 의 초속도는 표적 y 60·100·140·190 에서 각각 7.23·6.54·5.75·4.60 이고, 실제 터진 높이는 60.3·100.1·140.1·190.1 이다. APEX_FRAC 을 0.04 보다 크게 잡으면 정점에 닿기 전에 터져서 오차가 APEX_FRAC²·h 만큼 커진다. 클릭 위치에서 터지는 느낌은 이 상수가 지킨다.
어디서 깨지는가 — 알갱이가 너무 많을 때·탭이 멈출 때·움직임 줄이기 설정
아홉 장면을 직접 만지다 보면 먼저 눈에 띄는 곳이 알갱이 수, 항력, 입력 처리다. 아래는 소스의 상수와 노드 호출로 확인한 것이고, 느려지는 정도를 브라우저에서 재 보지는 않았다.
알갱이가 많아지면 칸을 나눠 쓰고 그리기 호출만 늘어난다
01 에서 BURST_N 이 240 이면 80틱 뒤에도 13개는 다른 알갱이와 같은 칸에 있다. 같은 장면은 MAX_SHELLS 5 겹이 동시에 살아 있을 수 있어서, 알갱이마다 선 하나와 원 하나를 그린다고 치면 프레임당 240 × 5 × 2 = 2,400번의 그리기 호출이 필요하다. DRAG 를 1 이상으로 올리면 속도가 줄지 않아 화면을 벗어난 알갱이도 GRAIN_LIFE 까지 계속 갱신된다.
버들의 항력이 1 에 가까우면 화면 밖에서도 계산이 계속된다
03 에서 WILLOW_DRAG 가 0.99 이면 평균 도달 거리가 299px 이고 가장 빠른 알갱이는 480×300 화면을 벗어난다. 소스는 수명 124틱이 끝날 때까지 알갱이를 지우지 않으므로 화면 밖 알갱이의 꼬리 계산도 그만큼 이어진다.
불씨는 상한이 잘라 내고 노이즈 진폭은 1 에서 깜빡임이 아니게 된다
05 는 틱마다 불씨 4개를 만들고 MAX_EMBERS 420 을 넘으면 가장 오래된 불씨부터 잘라 낸다. LIFE_MS 를 늘리면 이 상한에 먼저 닿는다. NOISE_AMP 가 1 이면 밝기가 0 까지 흔들려 깜빡이는 것이 아니라 사라졌다 나타나는 것처럼 보인다.
반사는 프레임마다 물의 줄 수만큼 drawImage 를 부른다
06 은 H − HORIZON_Y 인 110줄에 줄 단위 drawImage 를 한 번씩 부르고, getImageData 는 아홉 장면 어디에도 없다. 하늘 원본은 devicePixelRatio 가 바뀌면 다시 만든다. 수평선 아래로 떨어진 알갱이는 하늘 원본에서 그려지지 않아 반사가 생기지 않는다.
부모 1000개 중 571개가 갈라지면 터질 때마다 크래클 양이 다르다
07 에서 부모 1000개를 가정해 splitTally(1000, 5) 를 불러 보니 571개(57.1%)가 갈라져 자식이 2,855개(571 × 5)가 되었고 손자는 0이었다. 실제 한 겹은 부모가 84개라서 갈라지는 수가 46개 안팎에서 흔들리고, 그래서 터질 때마다 크래클의 양이 조금씩 다르다. SPLIT_DEPTH 를 2 로 올리면 자식도 갈라져서 알갱이 수가 갈라질 때마다 SPLIT_P × CHILD_N = 2.75배씩 늘어난다.
네 발이 쏘아지는 동안 첫 발의 빛은 아직 살아 있다
08 은 한 번에 VOLLEY_N 4발을 GAP_MS 260ms(16틱) 간격으로 쏜다. 네 발은 첫 발부터 넷째 발까지 48틱에 걸쳐 나가고, 한 발의 빛은 FLASH_LIFE 60틱 동안 남아서 넷째 발이 터질 때도 첫 발의 빛이 12틱 더 살아 있다. 그래서 네 빛이 한꺼번에 겹치고 그중 방금 터진 넷째 발이 가장 세다. 광원은 8개까지만 두고 넘으면 가장 오래된 것부터 지우며, 불꽃 알갱이도 280개를 넘으면 오래된 것부터 잘라 낸다.
포인터 속도는 주사율을 타고 캔버스 밖으로 나가면 끊긴다
09 의 inp.vx 는 포인터 이벤트마다 갱신되어 주사율이 높은 모니터에서는 같은 손놀림도 속도가 커지므로 소스가 ±12 로 잘라 둔다. 아홉 소스 모두 setPointerCapture 가 0 번이라서 포인터가 캔버스 밖으로 나가면 pointermove 가 끊기고 바람도 멈춘다. 터치에서 끄는 동안 화면이 스크롤되지 않게 하는 touch-action: none 은 09 만 걸고, 나머지 여덟 장면은 manipulation 이다.
탭이 멈췄다 돌아오면 따라잡는 시간에 상한이 있다
아홉 소스는 한 번의 프레임에서 흘려보내는 시간을 Math.min(ts - lastTs, 160) 으로 막는다. 탭을 오래 숨겼다 돌아와도 한 번에 최대 10틱만 진행해서 불꽃이 한꺼번에 폭주하지 않는다. 대신 숨겨 둔 동안의 시간은 따라잡지 않고 버려진다.
02 는 가장자리를 누르면 누른 곳과 터진 곳이 달라진다
02 의 aimPoint 는 가로를 CLICK_PAD 28 안쪽으로, 세로를 GROUND_Y − 60 위쪽으로 당겨 놓는다. 그래서 바닥 근처를 누르면 y 218 에서 터지고 모서리를 누르면 28px 안쪽에서 터진다. 이 값 밖으로 보내려면 aimPoint 를 고쳐야 한다.
상수를 하나씩 바꿔 불꽃이 어디서 무너지는지 직접 보려면 zip 을 열어야 하는데, 비밀번호는 r9k5d5jr 이다. 파일에는 장면 아홉 개의 index.html·style.scss 와 리액트 판이 같이 담겨 있다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 알갱이 수 바꾸기 | 01 BURST_N 90 → 240 | 80틱 뒤 서로 다른 6px 칸을 차지한 알갱이가 90개에서 227개가 되고 이웃 평균 거리는 14.3px 에서 7.4px 로 줄어 점의 구가 얇은 막처럼 찬다. |
| 버들 항력 바꾸기 | 03 WILLOW_DRAG 0.96 → 0.99 | 124틱 동안 날아가는 평균 거리가 133px 에서 299px 로 2.3배 늘어 화면 높이만큼 가지가 드리운다. |
| 물결 크기 바꾸기 | 06 WAVE_AMP 3 → 10 | 줄이 밀리는 최댓값이 4.8px 에서 16.1px 로 늘어 알갱이 폭의 3배 넘게 번지며 반사가 흩어진다. |
세 변형의 숫자도 위 값 절과 같이 소스를 노드에서 직접 불러 센 값이다.
키보드만으로도 아홉 장면을 모두 쏠 수 있다. 캔버스에 tabindex="0" 이 있어 Tab 으로 초점이 오고, aria-label 에는 항목 이름이 들어 있다. Enter·Space 는 눌렀을 때의 동작을 대신하고 방향키는 가상 포인터를 KEY_STEP 14 씩 옮기는데, 스크롤과 Tab 이동을 막지 않도록 preventDefault 는 이 여섯 키에만 건다. 화면 읽기 프로그램용 상태 문구는 aria-live="polite" 인 <p> 하나이고 장면이 정한 문자열이 바뀔 때만 갱신된다.
prefers-reduced-motion 설정을 켠 사람에게는 아홉 장면 모두 requestAnimationFrame 루프를 돌리지 않고 정지한 첫 장면만 보여 준다. 일시정지 버튼 자리는 다음 장면 이 차지해서, 한 번 누를 때마다 0.9초 분량만큼만 불꽃이 나아간다. 번쩍임은 따로 약하게 깐다. 01 의 중심 번쩍임은 알파 0.8 에서 0.35 로, 04 의 빛무리는 0.85 에서 0.6 으로 내리고, 05 는 NOISE_AMP 에 0.4 를 곱하며, 06 은 물결 시계를 세우고, 08 은 gain 을 0.5 로 둔다. 멈춘 장면에도 불꽃이 남아 있어서 빈 화면을 보지는 않는다.
css fireworks overlay 만으로 충분한가?
css fireworks overlay 라는 말에서 페이지 위에 덮는 효과를 떠올리기 쉽지만, 이 편의 아홉 장면은 무대 안에서만 돌려 봤고 페이지 전체에 덮어 본 적은 없다. 덮는다면 캔버스가 아래 요소의 클릭을 가로채지 않는지가 첫 확인거리이고, 입력을 받는 02·09 는 특히 그렇다.
바깥 틀이 하는 일은 무대 크기가 전부다. .sc 는 width: min(92vw, calc(160vh - 12.8vw)) 에 aspect-ratio: 480 / 300 이고, 좌표는 늘 480×300 논리 좌표라서 창 크기가 바뀌어도 장면 계산은 같다. 알갱이 계산이 필요 없는 한 번짜리 효과라면 종이 조각이 떨어지는 컨페티 효과 9종 이 더 가볍고, 불의 움직임을 계산으로 보고 싶으면 캔버스 불 애니메이션 9종 과 이어서 보면 되며, 별이 날아오르는 배경은 css starfield 9가지 에 있다. 계산의 근거는 requestAnimationFrame 과 globalCompositeOperation 문서에서 확인했다.
css fireworks codepen 코드를 가져다 쓰기 전에 자주 묻는 질문
html css fireworks 로 키프레임만 쓰는 판은 없나?
이 편에는 없다. 아홉 장면이 모두 캔버스이고, CSS 는 무대 크기·비율·touch-action 과 버튼 모양만 맡는다. 알갱이 수만큼의 키프레임을 손으로 적어 두는 판은 만들어 보지 않았다.
알갱이를 더 늘리면 어디서 먼저 무거워지나?
소스를 읽어 어림하면 알갱이마다 선 하나와 원 하나를 그리는 01 이 알갱이 수에 곧바로 비례하고, 06 은 알갱이 수와 상관없이 프레임마다 110번의 drawImage 가 고정으로 든다. 늘려서 프레임 시간을 재 보지는 않았다.
화면 크기가 달라지면 불꽃 크기도 변하나?
변한다. 장면 좌표는 480×300 에 고정이고 무대가 뷰포트에 비례해 늘고 줄어서, 폰에서는 알갱이가 그만큼 작게 보인다. 내부 해상도는 devicePixelRatio 를 2 까지 반영해 fit() 이 다시 맞춘다.








