css fireworks 9パターン — 打ち上がり・尾を引き・水面に映る花火の場面
css fireworks を弾けさせるには、花火ひとつではなく数百の粒の軌道を先に決める必要がある — 01番の球面分布と07番の二次分裂がその軌道を計算で編む。この記事の九つの場面は、どれも CSS ではなくキャンバスに計算で描いたものです。
九つの花火を画面でそのまま打ち上げてみる
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
自動再生 · タイルを押すとその項目へ · すべて1つの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です。牡丹・柳・尾・クラックルは、この一行に初速の分布や尾の長さ、分裂の規則を載せた違いにすぎません。
pure css fireworks を探してここに来た方は、使い道から出発点を選べば足ります。お祝いイベントのランディングなら01、クリックした高さで開いてほしい報酬演出なら02、夜のヒーロー背景なら04、ロウソクのような情緒的なセクションなら05、湖や海辺のテーマなら06、イベントの締めの画面なら08、製品発表のヒーローなら09から開いてみてください。
css fireworks animation を技法別のコードで見る
ここに載せるコードは、各場面の 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
// 1フレーム — 速度に抗力係数を掛け、下向きに重力を足す
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 };
}
// 1フレーム上げて頂点に届いたかを返す(上昇速度が頂点のしきい値を下回れば真)
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 / (1 + (d/90)²) の形で弱まります。一度に四発を撃って休む仕組みで、発と発の間隔が短いほど空と建物が明るく重なります。面積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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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ポインターの風で煙の雲が流され、花火が打ち上がるヒーロー場面
炸裂の残した煙の粒をノイズの向きでゆっくり流し、ポインターが動いた速さぶんだけ近くの粒に風を足します。人が触れていない間は偽のポインターが横に掃いて通るので、煙が裂ける様子を先に見せてくれます。面積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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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発が同時に生きられるので、粒ごとに線一本と円一つを描くとして、1フレームあたり240 × 5 × 2 = 2400回の描画呼び出しが要ります。DRAG を1以上に上げると速さが減らず、画面を出た粒も GRAIN_LIFE まで更新され続けます。
柳の抗力が1に近いと、画面の外でも計算が続く
03で WILLOW_DRAG が0.99になると平均の到達距離は299pxで、いちばん速い粒は480×300の画面を出てしまいます。ソースは寿命の124ティックが尽きるまで粒を消さないので、画面外の粒の尾の計算もそのぶん続きます。
火の粉は上限で刈られ、ノイズ振幅が1だとちらつきではなくなる
05はティックごとに火の粉を4つ作り、MAX_EMBERS の420を超えると古いものから刈り取ります。標準では平均約360個で上限に届きませんが、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%)が分かれて子が2855個(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ティック残り、四つの光が重なります。ひとつの光の明るさは (1 − age/60)² / (1 + (d/90)²) で、90px離れると半分になり、光源は8個までで、超えると古いものから消します。
ポインターの速さはリフレッシュレートに左右され、キャンバスの外に出ると途切れる
09の inp.vx はポインターのイベントごとに更新され、リフレッシュレートの高いモニターでは同じ手の動きでも速さが大きくなるため、ソースは±12で切り詰めています。風の強さは速さ v に0.1を掛け、距離 d に応じて (1 − d/70)² で弱まり、半径70pxの外では0です。触っていない間は、偽のポインターが 240 + 150·sin(0.028·t) で、中央から左右150pxの幅を往復します。九つのソースはどれも 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 と、React 版です。
変形三つとアクセシビリティ
| 名前 | 値 | 感じ |
|---|---|---|
| 粒の数を変える | 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 background だけで足りるか?
背景一面に敷く効果を思い浮かべがちですが、この記事の九つの場面は舞台の中でしか動かしておらず、ページ全体に被せたことはありません。被せるなら、キャンバスが下の要素のクリックを奪わないかが最初の確認事項で、入力を受ける02と09は特にそうです。
外枠が担うのは舞台の大きさだけです。.sc は width: min(92vw, calc(160vh - 12.8vw)) に aspect-ratio: 480 / 300 で、座標はいつも480×300の論理座標なので、ウィンドウの大きさが変わっても場面の計算は同じです。粒の計算がいらない一回きりの効果なら、紙片が舞う 紙吹雪 アニメーション css キラキラ演出9選 のほうが軽く、火の動きを計算で見たいなら キャンバス 炎 アニメーション9選 — シード固定パーティクルで描く火のシーン とあわせて、星が流れる背景は css starfield 9パターン — 星が奥行きに沿って流れる宇宙の背景 にあります。計算の根拠は requestAnimationFrame と globalCompositeOperation のドキュメントで確認しました。
css fireworks codepen のコードを使い回す前によくある質問
pure css fireworks で、キーフレームだけの版はありませんか?
この記事にはありません。九つの場面はすべてキャンバスで、CSS が受け持つのは舞台の大きさ・縦横比・touch-action とボタンの形だけです。粒の数ぶんのキーフレームを手で書き並べる版は作っていません。
粒を増やすと、どこから先に重くなりますか?
ソースを読んで見積もると、粒ごとに線一本と円一つを描く01は粒の数にそのまま比例し、06は粒の数と関係なく毎フレーム110回の drawImage が固定でかかります。増やしてフレーム時間を測ってはいません。
画面の大きさが変わると、花火の大きさも変わりますか?
変わります。場面の座標は480×300に固定で、舞台がビューポートに比例して伸び縮みするため、スマホでは粒がそのぶん小さく見えます。内部の解像度は devicePixelRatio を2まで反映して、fit() が合わせ直します。








