04찌른 자리부터 구멍 가장자리가 말려 나가며 물방울이 튀는 터짐
누른 지점에 구멍을 열고, 둘레 120방향마다 가장자리가 지나는 자리의 막이 얇을수록 빠르게(두께의 제곱근에 반비례) 넓혀 간다. 말려 나간 가장자리는 작은 물방울로 끊어져 중력을 받으며 떨어진다.
쓰임클릭 피드백·좋아요 반응
RIM_V_KDROPLET_NSPRAY_G

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="찌른 자리에 구멍이 열려 가장자리가 막 두께로 정한 속도로 말려 나가고 물방울이 떨어지는 비눗방울. 방울을 누르면 그 자리부터 터진다"></canvas>
<p class="hs__status" data-role="status" aria-live="off">찌르면 그 자리부터 터진다</p>
<div class="hs__tools">
<button class="hs__btn" data-role="poke" type="button">찌르기</button>
<button class="hs__btn" data-role="new" type="button">새 방울</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.hs {
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;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.hs__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.hs__btn {
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;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}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);
}
.hs {
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;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hs__btn {
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;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막을 얹어 명도 차로 방울이 읽힌다 (막의 무지갯빛은 이 표가 아니라 두께 계산에서 나온다)
const BG = [29, 26, 44];
const DEEP = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 장면이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row, label, value) {
const y = 112 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 2D 값 노이즈 — 정수 격자 꼭짓점마다 시드 난수 값(−1~1)을 두고 그 사이를 부드러운 곡선으로 잇는다
const NOISE_SEED = 465;
const noisePerm = new Uint8Array(512);
const noiseVal = new Float32Array(256);
{
const nr = makeRng(NOISE_SEED);
for (let i = 0; i < 256; i += 1) {
noisePerm[i] = i;
noiseVal[i] = nr() * 2 - 1;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(nr() * (i + 1));
const t = noisePerm[i];
noisePerm[i] = noisePerm[j];
noisePerm[j] = t;
}
for (let i = 0; i < 256; i += 1) {
noisePerm[i + 256] = noisePerm[i];
}
}
function noiseHash(ix, iy) {
return noiseVal[noisePerm[noisePerm[ix & 255] + (iy & 255)]];
}
function noise2(x, y) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const a = noiseHash(xi, yi);
const b = noiseHash(xi + 1, yi);
const c = noiseHash(xi, yi + 1);
const d = noiseHash(xi + 1, yi + 1);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
// 막의 굴절률 — 비눗물 막을 물과 같은 값으로 둔다
const FILM_N = 1.33;
// 세 파장(nm) — 화면의 빨강·초록·파랑 채널을 이 파장 하나씩으로 대신한다
const WAVE_RGB_NM = [650, 530, 450];
// 표를 만들 두께 범위(nm) — 1nm 눈금마다 세 채널 값을 미리 구해 둔다
const LUT_D_MAX = 1600;
const filmLut = new Float32Array((LUT_D_MAX + 1) * 3);
// 막 앞면과 뒷면에서 튕긴 두 빛이 두께만큼 어긋나 파장마다 남는 몫 — sin²(2π·n·d/λ).
// 앞면에서 튕긴 빛만 위상이 뒤집히므로 두께 0 이면 세 채널이 모두 0(검은 막). 값은 0~1 로 맞춘 비율이지 실제 반사율(몇 %)이 아니다
function reflectRGB(dNm) {
const out = [0, 0, 0];
for (let c = 0; c < 3; c += 1) {
const s = Math.sin(2 * Math.PI * FILM_N * dNm / WAVE_RGB_NM[c]);
out[c] = s * s;
}
return out;
}
for (let d = 0; d <= LUT_D_MAX; d += 1) {
const r = reflectRGB(d);
filmLut[d * 3] = r[0];
filmLut[d * 3 + 1] = r[1];
filmLut[d * 3 + 2] = r[2];
}
// 비스듬히 보면 빛이 막 안에서 꺾여 지나므로 어긋남이 cos(꺾인 각)배로 줄어든다 — cosView 는 시선과 법선 사이 각의 cos
function insideCos(cosView) {
const s2 = (1 - cosView * cosView) / (FILM_N * FILM_N);
return Math.sqrt(1 - s2);
}
// 두께 d(nm) 에서 채널 c 의 몫 — 표를 1nm 사이에서 선형 보간해 읽는다
function filmAt(dNm, c) {
const d = clamp(dNm, 0, LUT_D_MAX - 1);
const i = Math.floor(d);
const f = d - i;
return filmLut[i * 3 + c] * (1 - f) + filmLut[(i + 1) * 3 + c] * f;
}
// 캔버스 선·면에 쓸 막 색 — 어두운 무대 위에 세 채널 몫을 밝기로 얹는다
function filmColor(dNm, gain) {
return [BG[0] + filmAt(dNm, 0) * 235 * gain, BG[1] + filmAt(dNm, 1) * 235 * gain, BG[2] + filmAt(dNm, 2) * 235 * gain];
}
// 화소 하나를 막 색으로 — under 는 방울 뒤 바탕색, nx·ny·z 는 구면 위 법선(z 는 시선 쪽 성분)
function shadeFilm(px, o, dNm, nx, ny, z, under, alpha) {
const dEff = dNm * insideCos(z);
// 가장자리일수록 시선이 막을 비스듬히 지나 색이 진하다 — 가운데는 뒤 바탕이 비쳐 보이게 낮춘다
const amp = 0.22 + 0.78 * Math.pow(1 - z, 0.6);
// 왼쪽 위에서 오는 빛 하나의 반짝임
const hl = Math.pow(Math.max(0, nx * -0.42 + ny * -0.5 + z * 0.76), 60);
for (let c = 0; c < 3; c += 1) {
const v = under[c] + filmAt(dEff, c) * 235 * amp + hl * 220;
px[o + c] = under[c] + (v - under[c]) * alpha;
}
px[o + 3] = 255;
}
function makePix(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const pc = c.getContext('2d');
if (!pc) {
return null;
}
const img = pc.createImageData(w, h);
return { cvs: c, pc: pc, img: img, px: img.data, w: w, h: h };
}
// putImageData 는 캔버스 변환(배율)을 무시한다 — 작은 캔버스에 올린 뒤 drawImage 로 장면 좌표에 그린다
function showPix(p, x, y) {
p.pc.putImageData(p.img, 0, 0);
ctx.drawImage(p.cvs, x, y, p.w, p.h);
}
// 04 — 찌른 자리에 구멍이 열리고, 가장자리가 막 두께로 정한 일정한 속도로 말려 나가며 물방울을 흩뿌린다
// 가장자리 속도 배율 — 속도 = RIM_V_K × RIM_V0 × √(D_RIM_REF / 그 자리 두께)
const RIM_V_K = 1.0;
// 기준 두께에서의 가장자리 속도(px/s)와 그 기준 두께(nm)
const RIM_V0 = 560;
const D_RIM_REF = 400;
// 말려 나간 가장자리가 끊어져 생기는 물방울 수
const DROPLET_N = 28;
// 물방울을 끌어내리는 중력(px/s²)
const SPRAY_G = 900;
// 구멍 둘레를 나눈 방향 수 — 방향마다 가장자리 거리를 따로 키운다
const RIM_N = 120;
// 말린 가장자리 띠의 굵기(px)
const RIM_W = 3.5;
// 터진 뒤 새 방울이 부풀어 오르는 시간과 그 전 쉼(ms)
const HOLD_MS = 380;
const REGROW_MS = 520;
const BCX = 318;
const BCY = 146;
const BR = 118;
const TXT_READY = '찌르면 그 자리부터 터진다';
const TXT_POP = '가장자리가 말려 나가는 중';
const TXT_GONE = '다 터짐 — 물방울이 떨어지는 중';
const pokeBtn = root.querySelector('[data-role="poke"]');
const newBtn = root.querySelector('[data-role="new"]');
const BOX = BR * 2 + 2;
const PX0 = BCX - BR - 1;
const PY0 = BCY - BR - 1;
const pix = makePix(BOX, BOX);
const under = [BG[0], BG[1], BG[2]];
const rimR = new Float32Array(RIM_N);
const rimMax = new Float32Array(RIM_N);
const drops = [];
// 0 멀쩡 · 1 터지는 중 · 2 다 터짐 · 3 다시 부푸는 중
let phase = 0;
let phaseMs = 0;
let hx = BCX;
let hy = BCY;
let aimX = BCX - 40;
let aimY = BCY - 50;
let popMs = 0;
let lastPopMs = 0;
let spawned = 0;
let pokeN = 0;
let filmT = 0;
let rnd = makeRng(465);
// 막 두께(nm) — 위가 얇고 아래가 두껍고, 노이즈로 얼룩진다. nx·ny 는 방울 중심 기준 −1~1
function thicknessAt(nx, ny) {
return Math.max(420 * (1 + 0.45 * ny) * (1 + 0.18 * noise2(nx * 1.8 + filmT * 0.2, ny * 1.8 + 4.1)), 20);
}
function rimSpeed(x, y) {
const d = thicknessAt((x - BCX) / BR, (y - BCY) / BR);
return RIM_V_K * RIM_V0 * Math.sqrt(D_RIM_REF / d);
}
function poke(x, y) {
if (phase !== 0) {
return false;
}
let dx = x - BCX;
let dy = y - BCY;
const r = Math.hypot(dx, dy);
if (r > BR * 0.92) {
dx = dx / r * BR * 0.92;
dy = dy / r * BR * 0.92;
}
hx = BCX + dx;
hy = BCY + dy;
// 방향마다 구멍 중심에서 방울 테두리까지의 거리 — 가장자리는 여기서 멈춘다
for (let k = 0; k < RIM_N; k += 1) {
const a = k / RIM_N * Math.PI * 2;
const ux = Math.cos(a);
const uy = Math.sin(a);
const pu = dx * ux + dy * uy;
rimMax[k] = -pu + Math.sqrt(pu * pu - (dx * dx + dy * dy - BR * BR));
rimR[k] = 0;
}
drops.length = 0;
rnd = makeRng(465 + pokeN);
pokeN += 1;
for (let i = 0; i < DROPLET_N; i += 1) {
const k = Math.floor(rnd() * RIM_N);
drops.push({ x: 0, y: 0, vx: 0, vy: 0, at: rimMax[k] * (0.25 + rnd() * 0.7), k: k, live: false, done: false });
}
spawned = 0;
phase = 1;
phaseMs = 0;
popMs = 0;
return true;
}
function stepPop(dt) {
let open = 0;
for (let k = 0; k < RIM_N; k += 1) {
if (rimR[k] >= rimMax[k]) {
open += 1;
continue;
}
const a = k / RIM_N * Math.PI * 2;
const v = rimSpeed(hx + Math.cos(a) * rimR[k], hy + Math.sin(a) * rimR[k]);
rimR[k] = Math.min(rimR[k] + v * dt, rimMax[k]);
}
for (const d of drops) {
if (d.live || d.done || rimR[d.k] < d.at) {
continue;
}
const a = d.k / RIM_N * Math.PI * 2;
const v = rimSpeed(hx + Math.cos(a) * d.at, hy + Math.sin(a) * d.at);
d.x = hx + Math.cos(a) * d.at;
d.y = hy + Math.sin(a) * d.at;
d.vx = Math.cos(a) * v * 0.35;
d.vy = Math.sin(a) * v * 0.35 - 60;
d.live = true;
spawned += 1;
}
popMs += dt * 1000;
if (open === RIM_N) {
lastPopMs = popMs;
phase = 2;
phaseMs = 0;
}
}
function stepDrops(dt) {
for (const d of drops) {
if (!d.live) {
continue;
}
d.vy += SPRAY_G * dt;
d.x += d.vx * dt;
d.y += d.vy * dt;
if (d.y > H + 10) {
d.live = false;
d.done = true;
}
}
}
function step() {
const dt = FIXED_DT / 1000;
filmT += dt;
phaseMs += FIXED_DT;
if (phase === 1) {
stepPop(dt);
}
else if (phase === 2 && phaseMs >= HOLD_MS) {
phase = 3;
phaseMs = 0;
}
else if (phase === 3 && phaseMs >= REGROW_MS) {
phase = 0;
phaseMs = 0;
}
stepDrops(dt);
report();
}
// 움직임 줄이기 — 진행 중인 터짐을 끝까지 계산하고 물방울은 0.3초 떨어진 자리에 멈춘 한 장
function settle() {
let n = 0;
while (phase === 1 && n < 2000) {
stepPop(0.004);
n += 1;
}
if (phase === 1 || phase === 2) {
for (let k = 0; k < 19; k += 1) {
stepDrops(FIXED_DT / 1000);
}
}
if (phase === 3) {
phase = 0;
}
report();
}
function report() {
if (phase === 1) {
say(TXT_POP);
}
else if (phase === 2 || phase === 3) {
say(TXT_GONE);
}
else {
say(TXT_READY);
}
}
// 시연 — 멀쩡한 방울이 260ms 쉬면 다음 자리를 찌른다 (첫 찌르기는 200ms)
const DEMO_POKES = [[-0.45, -0.55], [0.5, -0.2], [-0.15, 0.55], [0.4, 0.45]];
let demoIdx = 0;
function demoTick(t) {
if (phase !== 0) {
return;
}
if ((demoIdx === 0 && t >= 200) || (demoIdx > 0 && phaseMs >= 260)) {
const p = DEMO_POKES[demoIdx % DEMO_POKES.length];
poke(BCX + p[0] * BR, BCY + p[1] * BR);
demoIdx += 1;
}
}
function paintFilm() {
if (!pix) {
return;
}
const px = pix.px;
const sc = phase === 3 ? 0.55 + 0.45 * smooth(phaseMs / REGROW_MS) : 1;
const fade = phase === 3 ? smooth(phaseMs / REGROW_MS) : 1;
const rr = BR * sc;
const holed = phase === 1;
for (let j = 0; j < BOX; j += 1) {
const y = PY0 + j + 0.5;
const ny = (y - BCY) / rr;
for (let i = 0; i < BOX; i += 1) {
const x = PX0 + i + 0.5;
const nx = (x - BCX) / rr;
const r2 = nx * nx + ny * ny;
const o = (j * BOX + i) * 4;
if (r2 >= 1 || phase === 2) {
px[o + 3] = 0;
continue;
}
if (holed) {
const dx = x - hx;
const dy = y - hy;
const dist = Math.hypot(dx, dy);
// 이 화소 방향의 가장자리 거리 — 이웃한 두 방향 사이를 보간
let f = Math.atan2(dy, dx) / (Math.PI * 2) * RIM_N;
if (f < 0) {
f += RIM_N;
}
const k0 = Math.floor(f) % RIM_N;
const k1 = (k0 + 1) % RIM_N;
const t = f - Math.floor(f);
const edge = rimR[k0] * (1 - t) + rimR[k1] * t;
if (dist < edge) {
px[o + 3] = 0;
continue;
}
if (dist < edge + RIM_W && edge > 0.5) {
// 말려 모인 가장자리 — 두꺼워 거의 흰빛
px[o] = 250;
px[o + 1] = 246;
px[o + 2] = 236;
px[o + 3] = 240;
continue;
}
}
const z = Math.sqrt(1 - r2);
shadeFilm(px, o, thicknessAt(nx, ny), nx, ny, z, under, 1);
px[o + 3] = 255 * clamp((1 - Math.sqrt(r2)) * rr, 0, 1) * fade;
}
}
showPix(pix, PX0, PY0);
}
function draw() {
clearStage();
ctx.fillStyle = rgba(DEEP, 0.9);
ctx.beginPath();
ctx.ellipse(BCX, BCY + BR + 12, BR * 0.62, 7, 0, 0, Math.PI * 2);
ctx.fill();
paintFilm();
ctx.fillStyle = rgba(SKY, 0.9);
for (const d of drops) {
if (!d.live && !(still && d.done)) {
continue;
}
ctx.beginPath();
ctx.arc(d.x, d.y, 2.2, 0, Math.PI * 2);
ctx.fill();
}
if (phase === 0) {
ctx.strokeStyle = rgba(PAPER, 0.55);
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(aimX, aimY, 6, 0, Math.PI * 2);
ctx.stroke();
}
panelRow(0, '가운데 가장자리 속도', String(Math.round(rimSpeed(BCX, BCY))) + ' px/s');
panelRow(1, '터지는 데 걸린 시간', lastPopMs > 0 ? String(Math.round(lastPopMs)) + ' ms' : '-');
panelRow(2, '튄 물방울', String(spawned) + ' / ' + String(DROPLET_N));
}
function after() {
if (still) {
settle();
}
report();
draw();
}
function fresh() {
phase = 0;
phaseMs = 0;
drops.length = 0;
spawned = 0;
after();
}
cv.addEventListener('pointerdown', function (ev) {
ev.preventDefault();
wake();
const p = toScene(ev);
aimX = p[0];
aimY = p[1];
if (Math.hypot(p[0] - BCX, p[1] - BCY) <= BR) {
poke(p[0], p[1]);
}
after();
});
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
wake();
aimX = clamp(aimX + (k === 'ArrowLeft' ? -10 : (k === 'ArrowRight' ? 10 : 0)), BCX - BR, BCX + BR);
aimY = clamp(aimY + (k === 'ArrowUp' ? -10 : (k === 'ArrowDown' ? 10 : 0)), BCY - BR, BCY + BR);
after();
}
else if (k === 'Enter' || k === ' ' || k === 'Spacebar') {
ev.preventDefault();
wake();
poke(aimX, aimY);
after();
}
else if (k === 'Home') {
ev.preventDefault();
wake();
fresh();
}
});
if (pokeBtn) {
pokeBtn.addEventListener('click', function () { wake(); poke(aimX, aimY); after(); });
}
if (newBtn) {
newBtn.addEventListener('click', function () { wake(); fresh(); });
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
let looping = false;
function frame(ts) {
if (!looping) {
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (looping) {
return;
}
looping = true;
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
looping = false;
window.cancelAnimationFrame(rafId);
}
// 움직임 줄이기 설정을 페이지를 연 채로 바꿔도 바로 따른다 — 켜면 루프를 멈추고 끝난 장면 한 장, 끄면 다시 돈다
function applyMotion() {
still = reduceQuery.matches;
if (still) {
stopLoop();
settle();
draw();
}
else {
startLoop();
}
}
reduceQuery.addEventListener('change', applyMotion);
applyMotion();
return function () {
stopLoop();
reduceQuery.removeEventListener('change', applyMotion);
window.removeEventListener('resize', onResize);
};
})();// SbPokePopRimRetract - vanilla 판(04-sb-poke-pop-rim-retract) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SbPokePopRimRetract.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SbPokePopRimRetract() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막을 얹어 명도 차로 방울이 읽힌다 (막의 무지갯빛은 이 표가 아니라 두께 계산에서 나온다)
const BG: RGB = [29, 26, 44];
const DEEP: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
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 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 mix(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)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 장면이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row: number, label: string, value: string): void {
const y = 112 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 2D 값 노이즈 — 정수 격자 꼭짓점마다 시드 난수 값(−1~1)을 두고 그 사이를 부드러운 곡선으로 잇는다
const NOISE_SEED = 465;
const noisePerm = new Uint8Array(512);
const noiseVal = new Float32Array(256);
{
const nr = makeRng(NOISE_SEED);
for (let i = 0; i < 256; i += 1) {
noisePerm[i] = i;
noiseVal[i] = nr() * 2 - 1;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(nr() * (i + 1));
const t = noisePerm[i];
noisePerm[i] = noisePerm[j];
noisePerm[j] = t;
}
for (let i = 0; i < 256; i += 1) { noisePerm[i + 256] = noisePerm[i]; }
}
function noiseHash(ix: number, iy: number): number {
return noiseVal[noisePerm[noisePerm[ix & 255] + (iy & 255)]];
}
function noise2(x: number, y: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const a = noiseHash(xi, yi);
const b = noiseHash(xi + 1, yi);
const c = noiseHash(xi, yi + 1);
const d = noiseHash(xi + 1, yi + 1);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
// 막의 굴절률 — 비눗물 막을 물과 같은 값으로 둔다
const FILM_N = 1.33;
// 세 파장(nm) — 화면의 빨강·초록·파랑 채널을 이 파장 하나씩으로 대신한다
const WAVE_RGB_NM: RGB = [650, 530, 450];
// 표를 만들 두께 범위(nm) — 1nm 눈금마다 세 채널 값을 미리 구해 둔다
const LUT_D_MAX = 1600;
const filmLut = new Float32Array((LUT_D_MAX + 1) * 3);
// 막 앞면과 뒷면에서 튕긴 두 빛이 두께만큼 어긋나 파장마다 남는 몫 — sin²(2π·n·d/λ).
// 앞면에서 튕긴 빛만 위상이 뒤집히므로 두께 0 이면 세 채널이 모두 0(검은 막). 값은 0~1 로 맞춘 비율이지 실제 반사율(몇 %)이 아니다
function reflectRGB(dNm: number): RGB {
const out: RGB = [0, 0, 0];
for (let c = 0; c < 3; c += 1) {
const s = Math.sin(2 * Math.PI * FILM_N * dNm / WAVE_RGB_NM[c]);
out[c] = s * s;
}
return out;
}
for (let d = 0; d <= LUT_D_MAX; d += 1) {
const r = reflectRGB(d);
filmLut[d * 3] = r[0];
filmLut[d * 3 + 1] = r[1];
filmLut[d * 3 + 2] = r[2];
}
// 비스듬히 보면 빛이 막 안에서 꺾여 지나므로 어긋남이 cos(꺾인 각)배로 줄어든다 — cosView 는 시선과 법선 사이 각의 cos
function insideCos(cosView: number): number {
const s2 = (1 - cosView * cosView) / (FILM_N * FILM_N);
return Math.sqrt(1 - s2);
}
// 두께 d(nm) 에서 채널 c 의 몫 — 표를 1nm 사이에서 선형 보간해 읽는다
function filmAt(dNm: number, c: number): number {
const d = clamp(dNm, 0, LUT_D_MAX - 1);
const i = Math.floor(d);
const f = d - i;
return filmLut[i * 3 + c] * (1 - f) + filmLut[(i + 1) * 3 + c] * f;
}
// 캔버스 선·면에 쓸 막 색 — 어두운 무대 위에 세 채널 몫을 밝기로 얹는다
function filmColor(dNm: number, gain: number): RGB {
return [BG[0] + filmAt(dNm, 0) * 235 * gain, BG[1] + filmAt(dNm, 1) * 235 * gain, BG[2] + filmAt(dNm, 2) * 235 * gain];
}
// 화소 하나를 막 색으로 — under 는 방울 뒤 바탕색, nx·ny·z 는 구면 위 법선(z 는 시선 쪽 성분)
function shadeFilm(px: Uint8ClampedArray, o: number, dNm: number, nx: number, ny: number, z: number, under: RGB, alpha: number): void {
const dEff = dNm * insideCos(z);
// 가장자리일수록 시선이 막을 비스듬히 지나 색이 진하다 — 가운데는 뒤 바탕이 비쳐 보이게 낮춘다
const amp = 0.22 + 0.78 * Math.pow(1 - z, 0.6);
// 왼쪽 위에서 오는 빛 하나의 반짝임
const hl = Math.pow(Math.max(0, nx * -0.42 + ny * -0.5 + z * 0.76), 60);
for (let c = 0; c < 3; c += 1) {
const v = under[c] + filmAt(dEff, c) * 235 * amp + hl * 220;
px[o + c] = under[c] + (v - under[c]) * alpha;
}
px[o + 3] = 255;
}
// 화소 계산판 — 작은 캔버스 하나에 ImageData 를 붙여 두고 화소를 직접 채운다
type Pix = { cvs: HTMLCanvasElement; pc: CanvasRenderingContext2D; img: ImageData; px: Uint8ClampedArray; w: number; h: number };
function makePix(w: number, h: number): Pix | null {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const pc = c.getContext('2d');
if (!pc) { return null; }
const img = pc.createImageData(w, h);
return { cvs: c, pc: pc, img: img, px: img.data, w: w, h: h };
}
// putImageData 는 캔버스 변환(배율)을 무시한다 — 작은 캔버스에 올린 뒤 drawImage 로 장면 좌표에 그린다
function showPix(p: Pix, x: number, y: number): void {
p.pc.putImageData(p.img, 0, 0);
ctx.drawImage(p.cvs, x, y, p.w, p.h);
}
// 04 — 찌른 자리에 구멍이 열리고, 가장자리가 막 두께로 정한 일정한 속도로 말려 나가며 물방울을 흩뿌린다
// 가장자리 속도 배율 — 속도 = RIM_V_K × RIM_V0 × √(D_RIM_REF / 그 자리 두께)
const RIM_V_K = 1.0;
// 기준 두께에서의 가장자리 속도(px/s)와 그 기준 두께(nm)
const RIM_V0 = 560;
const D_RIM_REF = 400;
// 말려 나간 가장자리가 끊어져 생기는 물방울 수
const DROPLET_N = 28;
// 물방울을 끌어내리는 중력(px/s²)
const SPRAY_G = 900;
// 구멍 둘레를 나눈 방향 수 — 방향마다 가장자리 거리를 따로 키운다
const RIM_N = 120;
// 말린 가장자리 띠의 굵기(px)
const RIM_W = 3.5;
// 터진 뒤 새 방울이 부풀어 오르는 시간과 그 전 쉼(ms)
const HOLD_MS = 380;
const REGROW_MS = 520;
const BCX = 318;
const BCY = 146;
const BR = 118;
const TXT_READY = '찌르면 그 자리부터 터진다';
const TXT_POP = '가장자리가 말려 나가는 중';
const TXT_GONE = '다 터짐 — 물방울이 떨어지는 중';
const pokeBtn = root.querySelector('[data-role="poke"]') as HTMLButtonElement | null;
const newBtn = root.querySelector('[data-role="new"]') as HTMLButtonElement | null;
const BOX = BR * 2 + 2;
const PX0 = BCX - BR - 1;
const PY0 = BCY - BR - 1;
const pix = makePix(BOX, BOX);
const under: RGB = [BG[0], BG[1], BG[2]];
const rimR = new Float32Array(RIM_N);
const rimMax = new Float32Array(RIM_N);
type Drop = { x: number; y: number; vx: number; vy: number; at: number; k: number; live: boolean; done: boolean };
const drops: Drop[] = [];
// 0 멀쩡 · 1 터지는 중 · 2 다 터짐 · 3 다시 부푸는 중
let phase = 0;
let phaseMs = 0;
let hx = BCX;
let hy = BCY;
let aimX = BCX - 40;
let aimY = BCY - 50;
let popMs = 0;
let lastPopMs = 0;
let spawned = 0;
let pokeN = 0;
let filmT = 0;
let rnd = makeRng(465);
// 막 두께(nm) — 위가 얇고 아래가 두껍고, 노이즈로 얼룩진다. nx·ny 는 방울 중심 기준 −1~1
function thicknessAt(nx: number, ny: number): number {
return Math.max(420 * (1 + 0.45 * ny) * (1 + 0.18 * noise2(nx * 1.8 + filmT * 0.2, ny * 1.8 + 4.1)), 20);
}
function rimSpeed(x: number, y: number): number {
const d = thicknessAt((x - BCX) / BR, (y - BCY) / BR);
return RIM_V_K * RIM_V0 * Math.sqrt(D_RIM_REF / d);
}
function poke(x: number, y: number): boolean {
if (phase !== 0) { return false; }
let dx = x - BCX;
let dy = y - BCY;
const r = Math.hypot(dx, dy);
if (r > BR * 0.92) { dx = dx / r * BR * 0.92; dy = dy / r * BR * 0.92; }
hx = BCX + dx;
hy = BCY + dy;
// 방향마다 구멍 중심에서 방울 테두리까지의 거리 — 가장자리는 여기서 멈춘다
for (let k = 0; k < RIM_N; k += 1) {
const a = k / RIM_N * Math.PI * 2;
const ux = Math.cos(a);
const uy = Math.sin(a);
const pu = dx * ux + dy * uy;
rimMax[k] = -pu + Math.sqrt(pu * pu - (dx * dx + dy * dy - BR * BR));
rimR[k] = 0;
}
drops.length = 0;
rnd = makeRng(465 + pokeN);
pokeN += 1;
for (let i = 0; i < DROPLET_N; i += 1) {
const k = Math.floor(rnd() * RIM_N);
drops.push({ x: 0, y: 0, vx: 0, vy: 0, at: rimMax[k] * (0.25 + rnd() * 0.7), k: k, live: false, done: false });
}
spawned = 0;
phase = 1;
phaseMs = 0;
popMs = 0;
return true;
}
function stepPop(dt: number): void {
let open = 0;
for (let k = 0; k < RIM_N; k += 1) {
if (rimR[k] >= rimMax[k]) { open += 1; continue; }
const a = k / RIM_N * Math.PI * 2;
const v = rimSpeed(hx + Math.cos(a) * rimR[k], hy + Math.sin(a) * rimR[k]);
rimR[k] = Math.min(rimR[k] + v * dt, rimMax[k]);
}
for (const d of drops) {
if (d.live || d.done || rimR[d.k] < d.at) { continue; }
const a = d.k / RIM_N * Math.PI * 2;
const v = rimSpeed(hx + Math.cos(a) * d.at, hy + Math.sin(a) * d.at);
d.x = hx + Math.cos(a) * d.at;
d.y = hy + Math.sin(a) * d.at;
d.vx = Math.cos(a) * v * 0.35;
d.vy = Math.sin(a) * v * 0.35 - 60;
d.live = true;
spawned += 1;
}
popMs += dt * 1000;
if (open === RIM_N) {
lastPopMs = popMs;
phase = 2;
phaseMs = 0;
}
}
function stepDrops(dt: number): void {
for (const d of drops) {
if (!d.live) { continue; }
d.vy += SPRAY_G * dt;
d.x += d.vx * dt;
d.y += d.vy * dt;
if (d.y > H + 10) { d.live = false; d.done = true; }
}
}
function step(): void {
const dt = FIXED_DT / 1000;
filmT += dt;
phaseMs += FIXED_DT;
if (phase === 1) { stepPop(dt); }
else if (phase === 2 && phaseMs >= HOLD_MS) { phase = 3; phaseMs = 0; }
else if (phase === 3 && phaseMs >= REGROW_MS) { phase = 0; phaseMs = 0; }
stepDrops(dt);
report();
}
// 움직임 줄이기 — 진행 중인 터짐을 끝까지 계산하고 물방울은 0.3초 떨어진 자리에 멈춘 한 장
function settle(): void {
let n = 0;
while (phase === 1 && n < 2000) { stepPop(0.004); n += 1; }
if (phase === 1 || phase === 2) {
for (let k = 0; k < 19; k += 1) { stepDrops(FIXED_DT / 1000); }
}
if (phase === 3) { phase = 0; }
report();
}
function report(): void {
if (phase === 1) { say(TXT_POP); }
else if (phase === 2 || phase === 3) { say(TXT_GONE); }
else { say(TXT_READY); }
}
// 시연 — 멀쩡한 방울이 260ms 쉬면 다음 자리를 찌른다 (첫 찌르기는 200ms)
const DEMO_POKES: [number, number][] = [[-0.45, -0.55], [0.5, -0.2], [-0.15, 0.55], [0.4, 0.45]];
let demoIdx = 0;
function demoTick(t: number): void {
if (phase !== 0) { return; }
if ((demoIdx === 0 && t >= 200) || (demoIdx > 0 && phaseMs >= 260)) {
const p = DEMO_POKES[demoIdx % DEMO_POKES.length];
poke(BCX + p[0] * BR, BCY + p[1] * BR);
demoIdx += 1;
}
}
function paintFilm(): void {
if (!pix) { return; }
const px = pix.px;
const sc = phase === 3 ? 0.55 + 0.45 * smooth(phaseMs / REGROW_MS) : 1;
const fade = phase === 3 ? smooth(phaseMs / REGROW_MS) : 1;
const rr = BR * sc;
const holed = phase === 1;
for (let j = 0; j < BOX; j += 1) {
const y = PY0 + j + 0.5;
const ny = (y - BCY) / rr;
for (let i = 0; i < BOX; i += 1) {
const x = PX0 + i + 0.5;
const nx = (x - BCX) / rr;
const r2 = nx * nx + ny * ny;
const o = (j * BOX + i) * 4;
if (r2 >= 1 || phase === 2) { px[o + 3] = 0; continue; }
if (holed) {
const dx = x - hx;
const dy = y - hy;
const dist = Math.hypot(dx, dy);
// 이 화소 방향의 가장자리 거리 — 이웃한 두 방향 사이를 보간
let f = Math.atan2(dy, dx) / (Math.PI * 2) * RIM_N;
if (f < 0) { f += RIM_N; }
const k0 = Math.floor(f) % RIM_N;
const k1 = (k0 + 1) % RIM_N;
const t = f - Math.floor(f);
const edge = rimR[k0] * (1 - t) + rimR[k1] * t;
if (dist < edge) { px[o + 3] = 0; continue; }
if (dist < edge + RIM_W && edge > 0.5) {
// 말려 모인 가장자리 — 두꺼워 거의 흰빛
px[o] = 250; px[o + 1] = 246; px[o + 2] = 236; px[o + 3] = 240;
continue;
}
}
const z = Math.sqrt(1 - r2);
shadeFilm(px, o, thicknessAt(nx, ny), nx, ny, z, under, 1);
px[o + 3] = 255 * clamp((1 - Math.sqrt(r2)) * rr, 0, 1) * fade;
}
}
showPix(pix, PX0, PY0);
}
function draw(): void {
clearStage();
ctx.fillStyle = rgba(DEEP, 0.9);
ctx.beginPath();
ctx.ellipse(BCX, BCY + BR + 12, BR * 0.62, 7, 0, 0, Math.PI * 2);
ctx.fill();
paintFilm();
ctx.fillStyle = rgba(SKY, 0.9);
for (const d of drops) {
if (!d.live && !(still && d.done)) { continue; }
ctx.beginPath();
ctx.arc(d.x, d.y, 2.2, 0, Math.PI * 2);
ctx.fill();
}
if (phase === 0) {
ctx.strokeStyle = rgba(PAPER, 0.55);
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(aimX, aimY, 6, 0, Math.PI * 2);
ctx.stroke();
}
panelRow(0, '가운데 가장자리 속도', String(Math.round(rimSpeed(BCX, BCY))) + ' px/s');
panelRow(1, '터지는 데 걸린 시간', lastPopMs > 0 ? String(Math.round(lastPopMs)) + ' ms' : '-');
panelRow(2, '튄 물방울', String(spawned) + ' / ' + String(DROPLET_N));
}
function after(): void {
if (still) { settle(); }
report();
draw();
}
function fresh(): void {
phase = 0;
phaseMs = 0;
drops.length = 0;
spawned = 0;
after();
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
ev.preventDefault();
wake();
const p = toScene(ev);
aimX = p[0];
aimY = p[1];
if (Math.hypot(p[0] - BCX, p[1] - BCY) <= BR) { poke(p[0], p[1]); }
after();
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
ev.preventDefault();
wake();
aimX = clamp(aimX + (k === 'ArrowLeft' ? -10 : (k === 'ArrowRight' ? 10 : 0)), BCX - BR, BCX + BR);
aimY = clamp(aimY + (k === 'ArrowUp' ? -10 : (k === 'ArrowDown' ? 10 : 0)), BCY - BR, BCY + BR);
after();
} else if (k === 'Enter' || k === ' ' || k === 'Spacebar') {
ev.preventDefault();
wake();
poke(aimX, aimY);
after();
} else if (k === 'Home') {
ev.preventDefault();
wake();
fresh();
}
});
if (pokeBtn) { pokeBtn.addEventListener('click', function (): void { wake(); poke(aimX, aimY); after(); }); }
if (newBtn) { newBtn.addEventListener('click', function (): void { wake(); fresh(); }); }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
let looping = false;
function frame(ts: number): void {
if (!looping) { return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (looping) { return; }
looping = true;
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
looping = false;
window.cancelAnimationFrame(rafId);
}
// 움직임 줄이기 설정을 페이지를 연 채로 바꿔도 바로 따른다 — 켜면 루프를 멈추고 끝난 장면 한 장, 끄면 다시 돈다
function applyMotion(): void {
still = reduceQuery.matches;
if (still) {
stopLoop();
settle();
draw();
} else {
startLoop();
}
}
reduceQuery.addEventListener('change', applyMotion);
applyMotion();
return function (): void {
stopLoop();
reduceQuery.removeEventListener('change', applyMotion);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="찌른 자리에 구멍이 열려 가장자리가 막 두께로 정한 속도로 말려 나가고 물방울이 떨어지는 비눗방울. 방울을 누르면 그 자리부터 터진다"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">찌르면 그 자리부터 터진다</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="poke" type="button">찌르기</button>
<button className={cx("hs__btn")} data-role="new" type="button">새 방울</button>
</div>
</div>
);
}04 찌른 자리부터 구멍 가장자리가 말려 나가며 물방울이 튀는 터짐 — 측정 누적면적 27.03% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비눗방울 효과 9종 — 막 두께 색·터짐·두 방울 붙기 계산
이 데모가 속한 글
비눗방울 효과 9종 — 막 두께 색·터짐·두 방울 붙기 계산비눗방울의 무지갯빛은 칠한 색이 아니라 막 두께가 만든 빛의 어긋남이다 — 01번은 두께에서 색을 계산하고, 04번은 찌른 자리부터 가장자리가 말려 터지며, 06번은 붙은 두 방울 사이에 휜 막이 생긴다. 여기 모은 이 글의 데모 전부 보기


