09포인터 바람에 연기 구름이 밀려나며 불꽃이 쏘아지는 히어로 장면
폭발이 남긴 연기 입자를 노이즈 장으로 천천히 흘리고 포인터 이동 속도만큼 바람을 더해, 마우스를 휘저으면 연기가 갈라지며 흩어진다.
쓰임제품 발표 랜딩·시즌 캠페인 히어로
WIND_RSMOKE_NNOISE_SCALEDRAFT

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="포인터 바람에 연기 구름이 밀려나며 불꽃이 쏘아지는 히어로 장면"></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">가짜 포인터가 연기를 가르며 지나간다 — 바람은 포인터 속도만큼</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">포인터 바람에 연기 구름이 밀려나며 불꽃이 쏘아지는 히어로 장면</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK = [255, 170, 196];
const MINT = [150, 226, 196];
const LILAC = [196, 176, 255];
const SKY_BLUE = [150, 214, 255];
const BUTTER = [255, 230, 150];
const CREAM = [255, 247, 230];
const PALETTE = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP = [18, 16, 32];
const SKY_BOT = [46, 40, 76];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i, seed) {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x, y, seed) {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x, seed) {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x, y, seed) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx, height) {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root, canvas, statusEl, btnEl, make) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
const ctx = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function show() {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce() {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms) {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh() {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts) {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function () {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e) {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e) {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function () {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function () {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const WIND_R = 70;
const SMOKE_N = 70;
const NOISE_SCALE = 0.012;
const DRAFT = 0.035;
const WIND_GAIN = 0.1;
const SHELL_GAP = 52;
const SMOKE_LIFE = 150;
const MAX_PUFFS = 300;
const SPARK_N = 44;
const PREWARM_STEPS = 100;
const SMOKE_TINT = [206, 196, 236];
const TXT_AUTO = '가짜 포인터가 연기를 가르며 지나간다 — 바람은 포인터 속도만큼';
const TXT_WIND = '포인터가 빠를수록 연기가 크게 밀린다';
// 포인터 바람이 연기에 주는 가속 — 가까울수록 제곱으로 세진다
function windPush(dist, speed) {
if (dist >= WIND_R) {
return 0;
}
const k = 1 - dist / WIND_R;
return speed * WIND_GAIN * k * k;
}
function makeScene(inp, reduce) {
const rng = lcg(909);
let puffs = [];
let sparks = [];
let gap = 2;
let t = 0;
let colorIdx = 0;
let virtX = W / 2;
let virtY = 130;
let virtVx = 0;
let virtVy = 0;
let sprite = null;
function puffSprite() {
if (sprite) {
return sprite;
}
const size = 64;
const cv = document.createElement('canvas');
cv.width = size;
cv.height = size;
const c = cv.getContext('2d');
if (c) {
const g = c.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
g.addColorStop(0, rgba(SMOKE_TINT, 1));
g.addColorStop(1, rgba(SMOKE_TINT, 0));
c.fillStyle = g;
c.fillRect(0, 0, size, size);
}
sprite = cv;
return cv;
}
function burst(x, y) {
const hue = PALETTE[colorIdx % PALETTE.length];
colorIdx += 1;
for (let i = 0; i < SPARK_N; i += 1) {
const a = rng() * PI2;
const sp = 1.6 + rng() * 2.4;
sparks.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 48 + rng() * 24, hue: i % 3 === 0 ? CREAM : hue });
}
for (let i = 0; i < SMOKE_N; i += 1) {
const a = rng() * PI2;
const r = 26 * Math.sqrt(rng());
puffs.push({ x: x + Math.cos(a) * r, y: y + Math.sin(a) * r, vx: Math.cos(a) * 0.4, vy: Math.sin(a) * 0.4, age: 0, size: 14 + rng() * 14 });
}
if (puffs.length > MAX_PUFFS) {
puffs.splice(0, puffs.length - MAX_PUFFS);
}
}
const scene = {
step() {
t += 1;
gap -= 1;
if (gap <= 0) {
burst(100 + rng() * 280, 80 + rng() * 50);
gap = SHELL_GAP;
}
// 사람이 포인터를 쓰는 동안은 그 포인터, 아니면 가짜 포인터가 가로로 쓸고 지나간다
let px = inp.x;
let py = inp.y;
let pvx = inp.vx;
let pvy = inp.vy;
if (inp.idle >= IDLE_TICKS) {
const nx = W / 2 + 150 * Math.sin(t * 0.028);
const ny = 120 + 24 * Math.sin(t * 0.05);
virtVx = nx - virtX;
virtVy = ny - virtY;
virtX = nx;
virtY = ny;
px = virtX;
py = virtY;
pvx = virtVx;
pvy = virtVy;
}
const alive = [];
for (let i = 0; i < puffs.length; i += 1) {
const p = puffs[i];
p.age += 1;
// 노이즈 장이 방향을 정하고, 위로 오르는 기류(DRAFT)가 늘 조금 끌어올린다
const ang = vnoise(p.x * NOISE_SCALE + t * 0.002, p.y * NOISE_SCALE, 5) * PI2 * 2;
p.vx = p.vx * 0.96 + Math.cos(ang) * 0.02;
p.vy = p.vy * 0.96 + Math.sin(ang) * 0.02 - DRAFT * 0.1;
const d = Math.hypot(p.x - px, p.y - py);
p.vx += windPush(d, clamp(pvx, -12, 12));
p.vy += windPush(d, clamp(pvy, -12, 12));
p.x += p.vx;
p.y += p.vy;
if (p.age < SMOKE_LIFE) {
alive.push(p);
}
}
puffs = alive;
const liveS = [];
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
s.age += 1;
s.vx *= 0.965;
s.vy = s.vy * 0.965 + 0.04;
s.x += s.vx;
s.y += s.vy;
if (s.age < s.life) {
liveS.push(s);
}
}
sparks = liveS;
},
draw(ctx) {
drawSky(ctx, H);
const sp = puffSprite();
for (let i = 0; i < puffs.length; i += 1) {
const p = puffs[i];
const f = p.age / SMOKE_LIFE;
const r = p.size * (1 + f * 0.9);
ctx.globalAlpha = 0.2 * Math.min(1, p.age / 12) * (1 - f) * (1 - f);
ctx.drawImage(sp, p.x - r, p.y - r, r * 2, r * 2);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
const a = Math.pow(1 - s.age / s.life, 0.8);
ctx.strokeStyle = rgba(s.hue, a * 0.5);
ctx.lineWidth = 1.1;
ctx.beginPath();
ctx.moveTo(s.x - s.vx * 1.6, s.y - s.vy * 1.6);
ctx.lineTo(s.x, s.y);
ctx.stroke();
ctx.fillStyle = rgba(s.hue, a);
ctx.beginPath();
ctx.arc(s.x, s.y, 1.3 + 1.7 * a, 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
if (inp.idle >= IDLE_TICKS) {
ctx.strokeStyle = rgba(CREAM, 0.45);
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(virtX, virtY, 9, 0, PI2);
ctx.stroke();
ctx.beginPath();
ctx.arc(virtX, virtY, WIND_R, 0, PI2);
ctx.strokeStyle = rgba(CREAM, 0.12);
ctx.stroke();
}
},
label() {
return inp.idle < IDLE_TICKS ? TXT_WIND : TXT_AUTO;
},
tap() {
burst(inp.x, inp.y);
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
start(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'), makeScene);
})();import { useEffect, useRef } from "react";
import styles from "./FwPointerWindSmokeDriftHero.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const KEY_STEP = 14;
const IDLE_TICKS = 150;
const BTN_PAUSE = '멈춤';
const BTN_PLAY = '재생';
const BTN_NEXT = '다음 장면';
type RGB = [number, number, number];
interface Input { x: number; y: number; idle: number; vx: number; vy: number; dpr: number; }
interface Scene {
step(): void;
draw(ctx: CanvasRenderingContext2D): void;
label(): string;
tap(): void;
}
type Maker = (inp: Input, reduce: boolean) => Scene;
// 파스텔 알갱이 팔레트와 밤하늘 두 끝 색
const PINK: RGB = [255, 170, 196];
const MINT: RGB = [150, 226, 196];
const LILAC: RGB = [196, 176, 255];
const SKY_BLUE: RGB = [150, 214, 255];
const BUTTER: RGB = [255, 230, 150];
const CREAM: RGB = [255, 247, 230];
const PALETTE: RGB[] = [PINK, MINT, LILAC, SKY_BLUE, BUTTER];
const SKY_TOP: RGB = [18, 16, 32];
const SKY_BOT: RGB = [46, 40, 76];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열 (선형 합동 방식)
function lcg(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 정수 하나와 시드로 0 이상 1 미만의 값을 만드는 해시 (같은 입력은 늘 같은 값)
function hash1(i: number, seed: number): number {
let h = (Math.imul(i | 0, 374761393) + Math.imul(seed | 0, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash2(x: number, y: number, seed: number): number {
return hash1((Math.imul(x | 0, 73856093) ^ Math.imul(y | 0, 19349663)) | 0, seed);
}
// 1차원 값 노이즈 — 정수점 해시를 smooth 로 이어 붙인다
function noise1(x: number, seed: number): number {
const xi = Math.floor(x);
return lerp(hash1(xi, seed), hash1(xi + 1, seed), smooth(x - xi));
}
// 2차원 값 노이즈
function vnoise(x: number, y: number, seed: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = smooth(x - xi);
const fy = smooth(y - yi);
const a = lerp(hash2(xi, yi, seed), hash2(xi + 1, yi, seed), fx);
const b = lerp(hash2(xi, yi + 1, seed), hash2(xi + 1, yi + 1, seed), fx);
return lerp(a, b, fy);
}
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB {
return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
}
// 밤하늘 바탕 — 위가 어둡고 아래가 보랏빛
function drawSky(ctx: CanvasRenderingContext2D, height: number): void {
const g = ctx.createLinearGradient(0, 0, 0, height);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, height);
}
function start(root: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLElement, make: Maker): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function (): void { return; };
}
const ctx: CanvasRenderingContext2D = ctx0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const inp: Input = { x: W / 2, y: H / 2, idle: 99999, vx: 0, vy: 0, dpr: 1 };
let lastPx = -1;
let lastPy = -1;
// 화면 배율이 바뀌어도 캔버스 픽셀 수를 다시 맞춘다
function fit(): void {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
inp.dpr = dpr;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
const scene = make(inp, reduce);
let paused = false;
let rafId = 0;
let lastTs = -1;
let accMs = 0;
let lastLabel = '';
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: any) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function show(): void {
ctx.setTransform(inp.dpr, 0, 0, inp.dpr, 0, 0);
scene.draw(ctx);
const t = scene.label();
if (t !== lastLabel) {
lastLabel = t;
statusEl.textContent = t;
}
}
function tickOnce(): void {
inp.idle += 1;
inp.vx *= 0.9;
inp.vy *= 0.9;
scene.step();
}
function advance(ms: number): void {
const n = Math.round(ms / FIXED_DT);
for (let i = 0; i < n; i += 1) {
tickOnce();
}
}
function refresh(): void {
if (reduce) {
advance(FIXED_DT * 6);
show();
}
}
function toLocal(e: PointerEvent): void {
const r = canvas.getBoundingClientRect();
const nx = clamp((e.clientX - r.left) * W / r.width, 0, W);
const ny = clamp((e.clientY - r.top) * H / r.height, 0, H);
if (lastPx >= 0) {
inp.vx = lerp(inp.vx, nx - lastPx, 0.5);
inp.vy = lerp(inp.vy, ny - lastPy, 0.5);
}
lastPx = nx;
lastPy = ny;
inp.x = nx;
inp.y = ny;
inp.idle = 0;
}
function tick(ts: number): void {
if (lastTs < 0) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
tickOnce();
accMs -= FIXED_DT;
}
show();
}
rafId = requestAnimationFrame(tick);
}
btnEl.textContent = reduce ? BTN_NEXT : BTN_PAUSE;
if (reduce) {
root.classList.remove('is-demo');
}
show();
listen(btnEl, 'click', function (): void {
if (reduce) {
advance(REDUCE_STEP_MS);
show();
return;
}
paused = !paused;
if (paused) {
root.classList.remove('is-demo');
}
else {
root.classList.add('is-demo');
}
btnEl.textContent = paused ? BTN_PLAY : BTN_PAUSE;
});
listen(canvas, 'pointermove', function (e: PointerEvent): void {
toLocal(e);
refresh();
});
listen(canvas, 'pointerdown', function (e: PointerEvent): void {
lastPx = -1;
toLocal(e);
scene.tap();
refresh();
});
listen(canvas, 'keydown', function (e: KeyboardEvent): void {
const k = e.key;
let dx = 0;
let dy = 0;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
scene.tap();
refresh();
return;
}
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
inp.x = clamp(inp.x + dx, 0, W);
inp.y = clamp(inp.y + dy, 0, H);
inp.vx = dx * 0.5;
inp.vy = dy * 0.5;
inp.idle = 0;
refresh();
});
listen(window, 'resize', function (): void {
fit();
show();
});
if (!reduce) {
rafId = requestAnimationFrame(tick);
}
return function (): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
};
}
const WIND_R = 70;
const SMOKE_N = 70;
const NOISE_SCALE = 0.012;
const DRAFT = 0.035;
const WIND_GAIN = 0.1;
const SHELL_GAP = 52;
const SMOKE_LIFE = 150;
const MAX_PUFFS = 300;
const SPARK_N = 44;
const PREWARM_STEPS = 100;
const SMOKE_TINT: RGB = [206, 196, 236];
const TXT_AUTO = '가짜 포인터가 연기를 가르며 지나간다 — 바람은 포인터 속도만큼';
const TXT_WIND = '포인터가 빠를수록 연기가 크게 밀린다';
interface Puff { x: number; y: number; vx: number; vy: number; age: number; size: number; }
interface Spark { x: number; y: number; vx: number; vy: number; age: number; life: number; hue: RGB; }
// 포인터 바람이 연기에 주는 가속 — 가까울수록 제곱으로 세진다
function windPush(dist: number, speed: number): number {
if (dist >= WIND_R) {
return 0;
}
const k = 1 - dist / WIND_R;
return speed * WIND_GAIN * k * k;
}
function makeScene(inp: Input, reduce: boolean): Scene {
const rng = lcg(909);
let puffs: Puff[] = [];
let sparks: Spark[] = [];
let gap = 2;
let t = 0;
let colorIdx = 0;
let virtX = W / 2;
let virtY = 130;
let virtVx = 0;
let virtVy = 0;
let sprite: HTMLCanvasElement | null = null;
function puffSprite(): HTMLCanvasElement {
if (sprite) {
return sprite;
}
const size = 64;
const cv = document.createElement('canvas');
cv.width = size;
cv.height = size;
const c = cv.getContext('2d');
if (c) {
const g = c.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
g.addColorStop(0, rgba(SMOKE_TINT, 1));
g.addColorStop(1, rgba(SMOKE_TINT, 0));
c.fillStyle = g;
c.fillRect(0, 0, size, size);
}
sprite = cv;
return cv;
}
function burst(x: number, y: number): void {
const hue = PALETTE[colorIdx % PALETTE.length];
colorIdx += 1;
for (let i = 0; i < SPARK_N; i += 1) {
const a = rng() * PI2;
const sp = 1.6 + rng() * 2.4;
sparks.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 48 + rng() * 24, hue: i % 3 === 0 ? CREAM : hue });
}
for (let i = 0; i < SMOKE_N; i += 1) {
const a = rng() * PI2;
const r = 26 * Math.sqrt(rng());
puffs.push({ x: x + Math.cos(a) * r, y: y + Math.sin(a) * r, vx: Math.cos(a) * 0.4, vy: Math.sin(a) * 0.4, age: 0, size: 14 + rng() * 14 });
}
if (puffs.length > MAX_PUFFS) {
puffs.splice(0, puffs.length - MAX_PUFFS);
}
}
const scene: Scene = {
step(): void {
t += 1;
gap -= 1;
if (gap <= 0) {
burst(100 + rng() * 280, 80 + rng() * 50);
gap = SHELL_GAP;
}
// 사람이 포인터를 쓰는 동안은 그 포인터, 아니면 가짜 포인터가 가로로 쓸고 지나간다
let px = inp.x;
let py = inp.y;
let pvx = inp.vx;
let pvy = inp.vy;
if (inp.idle >= IDLE_TICKS) {
const nx = W / 2 + 150 * Math.sin(t * 0.028);
const ny = 120 + 24 * Math.sin(t * 0.05);
virtVx = nx - virtX;
virtVy = ny - virtY;
virtX = nx;
virtY = ny;
px = virtX;
py = virtY;
pvx = virtVx;
pvy = virtVy;
}
const alive: Puff[] = [];
for (let i = 0; i < puffs.length; i += 1) {
const p = puffs[i];
p.age += 1;
// 노이즈 장이 방향을 정하고, 위로 오르는 기류(DRAFT)가 늘 조금 끌어올린다
const ang = vnoise(p.x * NOISE_SCALE + t * 0.002, p.y * NOISE_SCALE, 5) * PI2 * 2;
p.vx = p.vx * 0.96 + Math.cos(ang) * 0.02;
p.vy = p.vy * 0.96 + Math.sin(ang) * 0.02 - DRAFT * 0.1;
const d = Math.hypot(p.x - px, p.y - py);
p.vx += windPush(d, clamp(pvx, -12, 12));
p.vy += windPush(d, clamp(pvy, -12, 12));
p.x += p.vx;
p.y += p.vy;
if (p.age < SMOKE_LIFE) {
alive.push(p);
}
}
puffs = alive;
const liveS: Spark[] = [];
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
s.age += 1;
s.vx *= 0.965;
s.vy = s.vy * 0.965 + 0.04;
s.x += s.vx;
s.y += s.vy;
if (s.age < s.life) {
liveS.push(s);
}
}
sparks = liveS;
},
draw(ctx: CanvasRenderingContext2D): void {
drawSky(ctx, H);
const sp = puffSprite();
for (let i = 0; i < puffs.length; i += 1) {
const p = puffs[i];
const f = p.age / SMOKE_LIFE;
const r = p.size * (1 + f * 0.9);
ctx.globalAlpha = 0.2 * Math.min(1, p.age / 12) * (1 - f) * (1 - f);
ctx.drawImage(sp, p.x - r, p.y - r, r * 2, r * 2);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < sparks.length; i += 1) {
const s = sparks[i];
const a = Math.pow(1 - s.age / s.life, 0.8);
ctx.strokeStyle = rgba(s.hue, a * 0.5);
ctx.lineWidth = 1.1;
ctx.beginPath();
ctx.moveTo(s.x - s.vx * 1.6, s.y - s.vy * 1.6);
ctx.lineTo(s.x, s.y);
ctx.stroke();
ctx.fillStyle = rgba(s.hue, a);
ctx.beginPath();
ctx.arc(s.x, s.y, 1.3 + 1.7 * a, 0, PI2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
if (inp.idle >= IDLE_TICKS) {
ctx.strokeStyle = rgba(CREAM, 0.45);
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(virtX, virtY, 9, 0, PI2);
ctx.stroke();
ctx.beginPath();
ctx.arc(virtX, virtY, WIND_R, 0, PI2);
ctx.strokeStyle = rgba(CREAM, 0.12);
ctx.stroke();
}
},
label(): string {
return inp.idle < IDLE_TICKS ? TXT_WIND : TXT_AUTO;
},
tap(): void {
burst(inp.x, inp.y);
}
};
for (let i = 0; i < PREWARM_STEPS; i += 1) {
scene.step();
}
return scene;
}
export default function FwPointerWindSmokeDriftHero() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return start(root, canvas, statusEl, btnEl, makeScene);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="포인터 바람에 연기 구름이 밀려나며 불꽃이 쏘아지는 히어로 장면" />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">가짜 포인터가 연기를 가르며 지나간다 — 바람은 포인터 속도만큼</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>포인터 바람에 연기 구름이 밀려나며 불꽃이 쏘아지는 히어로 장면</p>
</div>
</main>
);
}09 포인터 바람에 연기 구름이 밀려나며 불꽃이 쏘아지는 히어로 장면 — 측정 누적면적 28.317% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · css fireworks 9가지 — 쏘아 올려 터지고 물 위에 비치는 불꽃놀이 장면
이 데모가 속한 글
css fireworks 9가지 — 쏘아 올려 터지고 물 위에 비치는 불꽃놀이 장면css fireworks 를 터뜨리려면 불꽃 하나가 아니라 알갱이 수백 개의 궤적부터 정해야 한다 — 01번 구면 분포와 07번 이차 분열이 그 궤적을 계산으로 짠다. 이 글의 데모 전부 보기


