09플리퍼·범퍼·점수판을 묶은 미니 핀볼 테이블
02 플리퍼와 03 범퍼, 05 발사 막대를 한 판에 묶고 맞힐 때마다 점수 숫자가 굴러 올라가며, 판을 흔들면 판 위 공에 충격이 더해지고 화면이 스프링으로 떨렸다 멈춘다.
쓰임게임 출시 랜딩 첫 화면
TABLE_GRAVITYNUDGE_IMPULSESCORE_ROLL_MS

<main class="stage">
<div class="pb is-demo" id="pb">
<canvas class="pb__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<div class="pb__head">
<p class="pb__status" data-role="status" aria-live="off">점수 0</p>
<div class="pb__controls">
<button type="button" class="pb__btn" data-role="left" data-hold="1" aria-label="왼쪽 플리퍼">◀ 플리퍼</button>
<button type="button" class="pb__btn" data-role="right" data-hold="1" aria-label="오른쪽 플리퍼">플리퍼 ▶</button>
<button type="button" class="pb__btn" data-role="nudge">흔들기</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pb {
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;
}
.pb__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 머리 줄 — 캔버스 위 머리 띠 자리에 상태 줄(왼쪽)과 버튼(오른쪽)을 겹친다. 상태 줄이 길면 말줄임으로 자르고 버튼 폭은 그대로
.pb__head {
position: absolute;
top: $sp-1;
left: $sp-2;
right: $sp-2;
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
}
.pb__status {
flex: 0 1 auto;
min-width: 0;
margin: 0;
padding: 0 $sp-2;
border-radius: $r-control;
background: rgba(255, 255, 255, .88);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pb__controls {
flex: 0 0 auto;
display: flex;
gap: $sp-1;
}
// 진짜 버튼 — Tab 으로 들어오고 Enter·Space 로 누른다. 누르고 있는 버튼은 스크립트가 is-held 를 붙인다
.pb__btn {
appearance: none;
margin: 0;
padding: 0 $sp-2;
border: 0;
border-radius: $r-control;
background: $subject-lilac;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 18px;
cursor: pointer;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
}
.pb__btn:hover {
background: $subject-cream;
}
.pb__btn:active,
.pb__btn.is-held {
background: $subject-pink;
}
.pb__btn:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 2px;
}
@media (max-width: 340px) {
.pb__head { top: 0; left: $sp-1; right: $sp-1; gap: $sp-1; }
.pb__status, .pb__btn { font-size: 9px; line-height: 16px; padding: 0 $sp-1; }
}
// 동작 줄이기 — 움직임은 스크립트가 멈춘다(같은 시드 판을 끝까지 계산해 마지막 장면만 그린다). 여기서는 버튼 색이 바로 바뀌게만
@media (prefers-reduced-motion: reduce) {
.pb__btn { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pb {
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;
}
.pb__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.pb__head {
position: absolute;
top: 4px;
left: 8px;
right: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.pb__status {
flex: 0 1 auto;
min-width: 0;
margin: 0;
padding: 0 8px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pb__controls {
flex: 0 0 auto;
display: flex;
gap: 4px;
}
.pb__btn {
appearance: none;
margin: 0;
padding: 0 8px;
border: 0;
border-radius: 12px;
background: #b9a0ff;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 18px;
cursor: pointer;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
}
.pb__btn:hover {
background: #fff7e6;
}
.pb__btn:active,
.pb__btn.is-held {
background: #ff9ebb;
}
.pb__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.pb__head {
top: 0;
left: 4px;
right: 4px;
gap: 4px;
}
.pb__status, .pb__btn {
font-size: 9px;
line-height: 16px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.pb__btn {
transition: none !important;
}
}(function () {
const root = document.getElementById("pb");
if (!root) return;
// 논리 무대 — 물리는 화면 폭과 상관없이 이 480×300 좌표에서만 돈다. 화면이 넓든 좁든 그릴 때만 배율을 곱한다
const W = 480;
const H = 300;
// 한 틱 = 16ms 고정. 실제 프레임 간격이 흔들려도 계산은 같은 걸음으로만 나아간다
const FIXED_DT = 16;
const DT = FIXED_DT / 1000;
const TAU = Math.PI * 2;
// 머리 띠 높이(논리 px) — 상태 줄과 버튼이 이 띠 위에 겹친다
const HEADER = 30;
// 시연이 끝난 판을 이만큼(ms) 보여 준 뒤 같은 시드로 처음부터 다시 돈다
const LOOP_HOLD_MS = 1600;
// 파스텔 팔레트 — 어두운 판 위에 밝은 공·핀을 얹어 명도 차로 움직임이 읽힌다
const BG = [30, 27, 48];
const BG_LOW = [46, 39, 70];
const BAND = [20, 18, 32];
const GUIDE = [104, 94, 140];
const CREAM = [255, 248, 234];
const SKY = [146, 208, 255];
const PINK = [255, 172, 200];
const MINT = [144, 232, 202];
const LILAC = [202, 182, 255];
const PEACH = [255, 208, 172];
const LEMON = [250, 234, 156];
const AQUA = [156, 238, 236];
const CORAL = [255, 184, 166];
const INK = [28, 24, 40];
const BALL_COLORS = [PEACH, MINT, SKY, PINK, LILAC, LEMON, AQUA, CORAL, CREAM];
// 공 이름표 — 실제 사람 이름 대신 글자
const LABELS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + 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)]; }
// 시드 고정 난수 — 같은 시드면 같은 출발 자리·같은 결과가 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue) {
let s = seedValue >>> 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 makeBall(x, y, r, color, label) {
return { x: x, y: y, vx: 0, vy: 0, r: r, color: color, label: label, trail: [], slow: 0, finishMs: -1, mul: 1, out: false };
}
// 속도를 먼저 갱신하는 적분 — 이번 걸음의 힘(중력)으로 속도를 바꾼 뒤, 그 새 속도로 자리를 옮긴다. h 는 초
function integrate(b, gx, gy, h) {
b.vx += gx * h;
b.vy += gy * h;
b.x += b.vx * h;
b.y += b.vy * h;
}
function capSpeed(b, max) {
const s = Math.hypot(b.vx, b.vy);
if (s > max) {
b.vx *= max / s;
b.vy *= max / s;
}
}
// 마지막 충돌 — 법선(공 쪽을 향함)과 닿은 자리. 범퍼·키커가 이 법선 방향으로 덧힘을 준다
const contact = { nx: 0, ny: -1, px: 0, py: 0 };
// 원-원 충돌(움직이지 않는 원: 핀·범퍼) — 겹친 깊이만큼 법선 방향 바깥으로 밀어내고,
// 법선 방향 속도를 되튐 계수 e 만큼 뒤집는다. 돌려주는 값: 안 닿음 -1 · 닿았지만 멀어지는 중 0 · 다가오던 빠르기(px/s)
function hitCircle(b, cx, cy, cr, e) {
const dx = b.x - cx;
const dy = b.y - cy;
const d = Math.hypot(dx, dy);
const overlap = b.r + cr - d;
if (overlap <= 0) {
return -1;
}
// 중심이 정확히 겹치면 방향을 정할 수 없다 — 위로 민다
const nx = d > 1e-9 ? dx / d : 0;
const ny = d > 1e-9 ? dy / d : -1;
b.x += nx * overlap;
b.y += ny * overlap;
contact.nx = nx;
contact.ny = ny;
contact.px = cx + nx * cr;
contact.py = cy + ny * cr;
const vn = b.vx * nx + b.vy * ny;
if (vn >= 0) {
return 0;
}
b.vx -= (1 + e) * vn * nx;
b.vy -= (1 + e) * vn * ny;
return -vn;
}
// 선분 위 가장 가까운 점 — 공 중심에서 선분으로 내린 수선의 발. t 는 0(시작점)~1(끝점)로 자른다
function closestOnSegment(px, py, ax, ay, bx, by) {
const ex = bx - ax;
const ey = by - ay;
const len2 = ex * ex + ey * ey;
const t = len2 > 0 ? clamp(((px - ax) * ex + (py - ay) * ey) / len2, 0, 1) : 0;
return [ax + ex * t, ay + ey * t, t];
}
// 원-선분 충돌 — 가장 가까운 점까지 거리가 (반지름 + 선 두께의 반)보다 짧으면 겹친 만큼 밀어낸다.
// 선분이 움직이면(도는 플리퍼·풍차 막대) 접점의 속도(svx, svy)에 대한 상대 속도로 되튐을 잰다.
// grip 은 이번 걸음에 접선 방향 상대 속도에서 덜어 내는 몫 — 0 이면 미끄럼, 07 구름 마찰은 0 보다 크다
function hitSegment(b, ax, ay, bx, by, half, e, svx, svy, grip) {
const c = closestOnSegment(b.x, b.y, ax, ay, bx, by);
const dx = b.x - c[0];
const dy = b.y - c[1];
const d = Math.hypot(dx, dy);
const overlap = b.r + half - d;
if (overlap <= 0) {
return -1;
}
let nx = 0;
let ny = -1;
if (d > 1e-9) {
nx = dx / d;
ny = dy / d;
}
else {
// 중심이 선 위에 올라탔다 — 선분의 왼쪽 법선으로 민다
const L = Math.hypot(bx - ax, by - ay) || 1;
nx = (by - ay) / L;
ny = -(bx - ax) / L;
}
b.x += nx * overlap;
b.y += ny * overlap;
contact.nx = nx;
contact.ny = ny;
contact.px = c[0];
contact.py = c[1];
const rvx = b.vx - svx;
const rvy = b.vy - svy;
const vn = rvx * nx + rvy * ny;
if (vn >= 0) {
return 0;
}
const tvx = (rvx - vn * nx) * (1 - grip);
const tvy = (rvy - vn * ny) * (1 - grip);
b.vx = svx + tvx - e * vn * nx;
b.vy = svy + tvy - e * vn * ny;
return -vn;
}
// 움직이지 않는 벽 묶음 — 선분 하나하나에 hitSegment
function hitWalls(b, segs, e) {
for (let i = 0; i < segs.length; i += 1) {
const s = segs[i];
hitSegment(b, s[0], s[1], s[2], s[3], 0, e, 0, 0, 0);
}
}
// 공-공 충돌 — 같은 무게. 겹친 깊이를 반씩 나눠 밀어내고, 법선 방향 속도를 되튐 계수만큼 주고받는다
function hitBalls(a, b, e) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.hypot(dx, dy);
const overlap = a.r + b.r - d;
if (overlap <= 0) {
return -1;
}
const nx = d > 1e-9 ? dx / d : 1;
const ny = d > 1e-9 ? dy / d : 0;
a.x -= nx * overlap * 0.5;
a.y -= ny * overlap * 0.5;
b.x += nx * overlap * 0.5;
b.y += ny * overlap * 0.5;
const vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) {
return 0;
}
const j = -(1 + e) * vn * 0.5;
a.vx -= j * nx;
a.vy -= j * ny;
b.vx += j * nx;
b.vy += j * ny;
return -vn;
}
function pushTrail(b, n) {
b.trail.push(b.x, b.y);
while (b.trail.length > n * 2) {
b.trail.splice(0, 2);
}
}
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)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 꼬리의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 버튼을 처음 누르는 순간 시연(가상 입력·같은 판 되풀이)을 멈추고 결과를 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 판의 시드와 시각(ms). 시각은 16ms 고정 스텝으로만 는다
let seed = 0;
let clock = 0;
// 화면 배율 — 논리 1px 이 화면 CSS px 몇 개인가. 그릴 때만 쓰고 물리 계산에는 넣지 않는다
let viewScale = 1;
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);
const cssW = cv.getBoundingClientRect().width || W;
viewScale = cssW / W;
const k = viewScale * dpr;
cv.width = Math.max(1, Math.round(W * k));
cv.height = Math.max(1, Math.round(H * k));
// 그릴 때만 논리 좌표 → 화소 배율을 곱한다
ctx.setTransform(k, 0, 0, k, 0, 0);
}
function clearStage() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 머리 띠 — 상태 줄과 버튼 뒤의 어두운 띠. 판을 다 그린 뒤 맨 위에 덮는다
function drawBand() {
ctx.fillStyle = rgba(BAND, 0.92);
ctx.fillRect(0, 0, W, HEADER);
}
function strokeSeg(s, width, c, a) {
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(s[0], s[1]);
ctx.lineTo(s[2], s[3]);
ctx.stroke();
}
// 꼬리 — 지나온 자리를 이을수록 굵고 진하게
function drawTrail(b, c, width) {
const t = b.trail;
const n = t.length / 2;
ctx.lineCap = 'round';
for (let i = 1; i < n; i += 1) {
const k = i / n;
ctx.strokeStyle = rgba(c, 0.55 * k);
ctx.lineWidth = width * k;
ctx.beginPath();
ctx.moveTo(t[i * 2 - 2], t[i * 2 - 1]);
ctx.lineTo(t[i * 2], t[i * 2 + 1]);
ctx.stroke();
}
}
// 공 — 바닥 그림자 → 왼쪽 위가 밝은 둥근 몸통 → 진한 테두리 → 이름표 글자
function drawBall(b) {
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.arc(b.x + 2, b.y + 3, b.r, 0, TAU);
ctx.fill();
const g = ctx.createRadialGradient(b.x - b.r * 0.35, b.y - b.r * 0.4, b.r * 0.1, b.x, b.y, b.r);
g.addColorStop(0, rgba(mixRGB(b.color, CREAM, 0.75), 1));
g.addColorStop(1, rgba(b.color, 1));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, TAU);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.6);
ctx.stroke();
if (b.label) {
ctx.fillStyle = rgba(INK, 1);
ctx.font = '700 ' + Math.round(b.r * 1.25) + 'px ' + FONT_FAMILY;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(b.label, b.x, b.y + 0.5);
}
}
function drawText(text, x, y, size, c, a, align) {
ctx.fillStyle = rgba(c, a);
ctx.font = '600 ' + size + 'px ' + FONT_FAMILY;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillText(text, x, y);
}
function makeFlipper(px, py, len, half, rest, up, speed) {
return { px: px, py: py, len: len, half: half, rest: rest, up: up, speed: speed, angle: rest, w: 0, pressed: false, hitR: 0, hitSurf: 0, ghosts: [] };
}
function flipperTip(f) {
return [f.px + Math.cos(f.angle) * f.len, f.py + Math.sin(f.angle) * f.len];
}
// 한 걸음(h 초) — 목표 각까지 남은 차를 speed × h 안으로 잘라 돈다. 끝에 닿으면 w 는 0
function moveFlipper(f, h) {
const target = f.pressed ? f.up : f.rest;
const d = clamp(target - f.angle, -f.speed * h, f.speed * h);
f.angle += d;
f.w = d / h;
}
// 플리퍼와 공 — 막대 위 가장 가까운 점을 찾고, 그 점이 도는 빠르기(각속도 × 축까지 거리)를 상대 속도에 얹는다.
// 축에서 먼 끝으로 칠수록 접점이 빨라 공이 더 세게 날아간다
function hitFlipper(b, f, e) {
const tip = flipperTip(f);
const c = closestOnSegment(b.x, b.y, f.px, f.py, tip[0], tip[1]);
const rx = c[0] - f.px;
const ry = c[1] - f.py;
// 회전으로 생긴 접점 속도 — 축→접점 벡터를 90° 돌려 각속도를 곱한다
const svx = -f.w * ry;
const svy = f.w * rx;
const vn = hitSegment(b, f.px, f.py, tip[0], tip[1], f.half, e, svx, svy, 0);
if (vn > 0 && Math.abs(f.w) > 0.5) {
f.hitR = Math.hypot(rx, ry);
f.hitSurf = Math.abs(f.w) * f.hitR;
}
return vn;
}
// 지나간 각 기록 — 휘두른 자리를 옅은 잔상으로 그린다
function recordGhost(f) {
f.ghosts.push(f.angle);
while (f.ghosts.length > 5) {
f.ghosts.shift();
}
}
function drawFlipperAt(f, angle, body, alpha) {
const tx = f.px + Math.cos(angle) * f.len;
const ty = f.py + Math.sin(angle) * f.len;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(body, alpha);
ctx.lineWidth = f.half * 2;
ctx.beginPath();
ctx.moveTo(f.px, f.py);
ctx.lineTo(tx, ty);
ctx.stroke();
}
function drawFlipper(f, body) {
for (let i = 0; i < f.ghosts.length; i += 1) {
if (Math.abs(f.ghosts[i] - f.angle) > 0.02) {
drawFlipperAt(f, f.ghosts[i], body, 0.12 + 0.05 * i);
}
}
drawFlipperAt(f, f.angle, body, 1);
// 접점 속도 눈금 — 막대를 다섯 칸으로 나눠, 칸마다 각속도 × 거리만큼 수직 화살을 세운다(끝으로 갈수록 길다)
if (Math.abs(f.w) > 0.5) {
const ca = Math.cos(f.angle);
const sa = Math.sin(f.angle);
const sgn = f.w > 0 ? 1 : -1;
ctx.strokeStyle = rgba(PEACH, 0.9);
ctx.lineWidth = 2;
for (let k = 1; k <= 5; k += 1) {
const r = f.len * k / 5;
const x = f.px + ca * r;
const y = f.py + sa * r;
const L = Math.abs(f.w) * r * 0.025;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - sa * L * sgn, y + ca * L * sgn);
ctx.stroke();
}
}
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(f.px, f.py, f.half + 2, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(INK, 0.6);
ctx.beginPath();
ctx.arc(f.px, f.py, 2, 0, TAU);
ctx.fill();
}
// 가상 입력 판정 — 공이 막대 위쪽 구역(축에서 길이의 zoneMin 배 너머, 막대 윗면에서 gap px 안)에서 내려오고 있는가
function inFlipZone(b, f, zoneMin, gap) {
const tx = Math.cos(f.rest) * f.len;
const ty = Math.sin(f.rest) * f.len;
const t = ((b.x - f.px) * tx + (b.y - f.py) * ty) / (f.len * f.len);
// 막대 윗면 쪽 거리 — 막대에 수직인 두 방향 가운데 화면 위를 향한 쪽
let nx = ty / f.len;
let ny = -tx / f.len;
if (ny > 0) {
nx = -nx;
ny = -ny;
}
const above = (b.x - f.px) * nx + (b.y - f.py) * ny;
return t > zoneMin && t < 1.08 && above > -2 && above < b.r + f.half + gap && b.vy > -60;
}
function makeBumper(x, y, r, kick, stiff, damp, pop) {
return { x: x, y: y, r: r, kick: kick, stiff: stiff, damp: damp, pop: pop, d: 0, dv: 0, flash: 0, hits: 0 };
}
// 공과 범퍼 — 원-원 충돌로 겹친 만큼 밀어내고 되튀긴 다음, 들어온 빠르기와 상관없이 같은 세기 kick 을 법선 방향으로 더한다.
// 맞는 자리는 그림의 부푼 반지름이 아니라 r 그대로다(부풀기는 그림만)
function hitBumper(b, m, e) {
const vn = hitCircle(b, m.x, m.y, m.r, e);
if (vn <= 0) {
return vn;
}
b.vx += contact.nx * m.kick;
b.vy += contact.ny * m.kick;
m.dv += m.pop;
m.flash = 1;
m.hits += 1;
return vn;
}
// 반지름 스프링 — 부푼 만큼 되당기는 힘(stiff × d)과 빠르기에 비례하는 저항(damp × dv). 저항이 작아 한 번 넘쳤다가 돌아온다
function springBumper(m, h) {
m.dv += (-m.stiff * m.d - m.damp * m.dv) * h;
m.d += m.dv * h;
m.flash *= Math.exp(-h * 7);
}
function drawBumper(m, ring, cap) {
const rr = m.r * (1 + m.d);
if (m.flash > 0.02) {
const g = ctx.createRadialGradient(m.x, m.y, rr * 0.6, m.x, m.y, rr + 26 * m.flash);
g.addColorStop(0, rgba(ring, 0.55 * m.flash));
g.addColorStop(1, rgba(ring, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(m.x, m.y, rr + 26 * m.flash, 0, TAU);
ctx.fill();
}
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.arc(m.x + 3, m.y + 4, rr, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(ring, 1);
ctx.beginPath();
ctx.arc(m.x, m.y, rr, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(mixRGB(cap, CREAM, m.flash * 0.85), 1);
ctx.beginPath();
ctx.arc(m.x, m.y, rr * 0.7, 0, TAU);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK, 0.35);
ctx.stroke();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(m.x - rr * 0.22, m.y - rr * 0.25, rr * 0.18, 0, TAU);
ctx.fill();
}
function makePlunger(x, top, max, stiff, scale, damp) {
return { x: x, top: top, pull: 0, v: 0, max: max, cap: max, stiff: stiff, scale: scale, damp: damp, held: false, free: false, ball: null, releasedPull: 0, lastPull: 0, lastLaunch: 0 };
}
function plungerTipY(p) { return p.top + p.pull; }
// 막대 위에 공 올리기 — 다음 발사까지 공은 막대 끝에 붙어 함께 움직인다
function loadPlunger(p, b) {
p.ball = b;
b.vx = 0;
b.vy = 0;
b.trail = [];
}
// 놓았을 때 막대 끝이 0 을 지나며 낼 빠르기 — 당긴 만큼 되미는 힘이면 √stiff × 당긴 길이
function predictLaunch(p, pull) { return p.scale * Math.sqrt(p.stiff) * pull; }
function movePlunger(p, h, rate) {
if (p.held) {
p.pull = Math.min(p.cap, p.pull + rate * h);
p.v = 0;
// 놓은 뒤 처음 0 을 지날 때까지는 저항 없이 — 당긴 만큼 다 돌려준다
p.free = true;
p.releasedPull = p.pull;
}
else {
const before = p.pull;
const a = -p.stiff * p.pull - (p.free ? 0 : p.damp * p.v);
// 속도를 먼저 갱신하는 적분
p.v += a * h;
p.pull += p.v * h;
if (p.free && before > 0 && p.pull <= 0) {
p.free = false;
if (p.ball) {
const speed = Math.abs(p.v) * p.scale;
p.ball.vx = 0;
p.ball.vy = -speed;
p.lastPull = p.releasedPull;
p.lastLaunch = speed;
p.ball = null;
}
}
// 공 없이 놓았거나 아주 조금 당겼다 놓았으면 그대로 출렁이다 멈춘다
if (p.free && p.releasedPull <= 0) {
p.free = false;
}
}
if (p.ball) {
p.ball.x = p.x;
p.ball.y = plungerTipY(p) - p.ball.r - 3;
p.ball.vx = 0;
p.ball.vy = 0;
}
}
// 막대 그림 — 끝 마개 + 아래로 이어진 용수철(당길수록 촘촘해진다) + 옆의 힘 막대
function drawPlunger(p, gaugeX, bottom) {
const tip = plungerTipY(p);
const coils = 7;
const span = bottom - tip - 6;
ctx.strokeStyle = rgba(LILAC, 0.95);
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(p.x, tip + 6);
for (let i = 1; i <= coils * 2; i += 1) {
const y = tip + 6 + span * i / (coils * 2);
ctx.lineTo(p.x + (i % 2 ? 8 : -8), y);
}
ctx.stroke();
ctx.fillStyle = rgba(CREAM, 1);
ctx.fillRect(p.x - 11, tip, 22, 7);
ctx.fillStyle = rgba(PEACH, 1);
ctx.fillRect(p.x - 11, tip + 5, 22, 2);
// 되미는 힘 막대 — 높이 = stiff × pull 을 최대 힘 대비 몫으로
const k = clamp(p.pull / p.max, 0, 1);
const gh = 60;
ctx.fillStyle = rgba(GUIDE, 0.5);
ctx.fillRect(gaugeX, bottom - gh, 6, gh);
ctx.fillStyle = rgba(mixRGB(MINT, PINK, k), 1);
ctx.fillRect(gaugeX, bottom - gh * k, 6, gh * k);
}
// 굽은 길 — 원호를 n 토막 선분으로. 반지름 r, 각 a0 → a1(라디안)
function makeArc(cx, cy, r, a0, a1, n) {
const out = [];
for (let i = 0; i < n; i += 1) {
const u = a0 + (a1 - a0) * i / n;
const v = a0 + (a1 - a0) * (i + 1) / n;
out.push([cx + Math.cos(u) * r, cy + Math.sin(u) * r, cx + Math.cos(v) * r, cy + Math.sin(v) * r]);
}
return out;
}
// 09 미니 테이블 — 02 플리퍼·03 범퍼·05 발사 막대를 한 판에 + 점수 숫자 굴림 + 흔들기 충격·화면 스프링
// 판의 기울기만큼 받는 중력(px/s²)
const TABLE_GRAVITY = 520;
// 흔들기 — 판 위 모든 공의 속도에 더하는 빠르기(px/s)
const NUDGE_IMPULSE = 160;
// 점수 숫자가 새 값까지 굴러 올라가는 시간(ms)
const SCORE_ROLL_MS = 600;
// 화면 흔들림 스프링 — 되당김(1/s²)·저항(1/s)·흔들 때 화면에 주는 빠르기(px/s)
const SHAKE_STIFFNESS = 600;
const SHAKE_DAMPING = 8;
const SHAKE_KICK = 220;
const FLIPPER_SPEED = 12;
const FLIPPER_LENGTH = 70;
const FLIPPER_REST_ANGLE = 0.5;
const FLIPPER_UP_ANGLE = -0.45;
const FLIPPER_HALF = 5;
const BUMPER_KICK = 300;
const BUMPER_RADIUS = 19;
const BUMPER_STIFFNESS = 900;
const BUMPER_DAMPING = 10;
const BUMPER_POP = 5;
const PLUNGER_STIFFNESS = 900;
const PLUNGER_MAX_PULL = 40;
const LAUNCH_SCALE = 0.85;
const PLUNGER_DAMPING = 24;
const PULL_RATE = 90;
const SUBSTEPS = 6;
const BALL_R = 8;
const RESTITUTION = 0.45;
const MAX_SPEED = 1500;
const BUMPER_SCORE = 100;
const FLIPPER_SCORE = 10;
// 시연 흔들기 일정 — 처음 NUDGE_FIRST_MS 에 한 번, 그 뒤 NUDGE_EVERY_MS 마다
const NUDGE_FIRST_MS = 1300;
const NUDGE_EVERY_MS = 3600;
const LANE_IN = 420;
const LANE_OUT = 456;
const RETURN_MS = 500;
const AUTO_WAIT_MS = 250;
const AUTO_HOLD_MS = 100;
const DEMO_SEED = 4769;
const SETTLE_TICKS = 240;
const walls = makeArc(372, 118, 84, 0, -Math.PI / 2, 12).concat([
[LANE_OUT, 118, LANE_OUT, 300], [LANE_IN, 128, LANE_IN, 300],
[64, 34, 372, 34], [30, 62, 64, 34], [30, 62, 30, 200],
// 안쪽 길 — 벽에서 플리퍼 축으로
[30, 200, 126, 252], [LANE_IN, 200, 324, 252]
]);
const flippers = [
makeFlipper(136, 258, FLIPPER_LENGTH, FLIPPER_HALF, FLIPPER_REST_ANGLE, FLIPPER_UP_ANGLE, FLIPPER_SPEED),
makeFlipper(314, 258, FLIPPER_LENGTH, FLIPPER_HALF, Math.PI - FLIPPER_REST_ANGLE, Math.PI - FLIPPER_UP_ANGLE, FLIPPER_SPEED)
];
const bumpers = [
makeBumper(150, 100, BUMPER_RADIUS, BUMPER_KICK, BUMPER_STIFFNESS, BUMPER_DAMPING, BUMPER_POP),
makeBumper(256, 90, BUMPER_RADIUS, BUMPER_KICK, BUMPER_STIFFNESS, BUMPER_DAMPING, BUMPER_POP),
makeBumper(204, 158, BUMPER_RADIUS, BUMPER_KICK, BUMPER_STIFFNESS, BUMPER_DAMPING, BUMPER_POP)
];
const plunger = makePlunger((LANE_IN + LANE_OUT) / 2, 278, PLUNGER_MAX_PULL, PLUNGER_STIFFNESS, LAUNCH_SCALE, PLUNGER_DAMPING);
let rng = makeRng(1);
let ball = makeBall(0, 0, BALL_R, PEACH, '');
let returnMs = 0;
let handPhase = 'wait';
let handMs = 0;
let score = 0;
let rollFrom = 0;
let rollStart = 0;
let shakeX = 0;
let shakeV = 0;
let nudgeDir = 1;
let nextNudge = NUDGE_FIRST_MS;
let nudges = 0;
let pressMs = [0, 0];
let coolMs = [0, 0];
// 점수 굴림 — 지금 보이는 값은 이전 값에서 새 값까지 SCORE_ROLL_MS 동안 처음·끝이 느린 곡선으로 간다
function shownScore() { return lerp(rollFrom, score, smooth((clock - rollStart) / SCORE_ROLL_MS)); }
function addScore(n) {
rollFrom = shownScore();
rollStart = clock;
score += n;
}
function load() {
ball.out = false;
loadPlunger(plunger, ball);
handPhase = 'wait';
handMs = 0;
plunger.cap = PLUNGER_MAX_PULL * (0.75 + 0.25 * rng());
}
function onRestart() {
rng = makeRng(seed);
ball = makeBall(plunger.x, 260, BALL_R, PEACH, '');
plunger.pull = 0;
plunger.v = 0;
plunger.held = false;
plunger.free = false;
for (let i = 0; i < flippers.length; i += 1) {
flippers[i].angle = flippers[i].rest;
flippers[i].pressed = false;
flippers[i].ghosts = [];
}
for (let i = 0; i < bumpers.length; i += 1) {
bumpers[i].d = 0;
bumpers[i].dv = 0;
bumpers[i].flash = 0;
bumpers[i].hits = 0;
}
score = 0;
rollFrom = 0;
rollStart = 0;
shakeX = 0;
shakeV = 0;
nudgeDir = rng() < 0.5 ? -1 : 1;
nextNudge = NUDGE_FIRST_MS;
nudges = 0;
pressMs = [0, 0];
coolMs = [0, 0];
returnMs = 0;
load();
}
// 흔들기 — 판 위 공에 같은 충격을 더하고, 화면에는 스프링 빠르기를 준다(화면 흔들림은 그림만, 공은 충격만 받는다)
function nudge() {
if (!plunger.ball && !ball.out) {
ball.vx += nudgeDir * NUDGE_IMPULSE;
ball.vy -= NUDGE_IMPULSE * 0.35;
}
shakeV += nudgeDir * SHAKE_KICK;
nudgeDir = -nudgeDir;
nudges += 1;
}
// 시연 손 — 발사 막대는 사람이 손대도 저절로 당겼다 놓는다(이 판에는 발사 버튼이 없다)
function autoHand() {
if (!plunger.ball) {
return;
}
handMs += FIXED_DT;
if (handPhase === 'wait' && handMs >= AUTO_WAIT_MS) {
plunger.held = true;
handPhase = 'pull';
}
else if (handPhase === 'pull' && plunger.pull >= plunger.cap - 0.01) {
handPhase = 'hold';
handMs = 0;
}
else if (handPhase === 'hold' && handMs >= AUTO_HOLD_MS) {
plunger.held = false;
handPhase = 'flight';
}
}
// 가상 입력 — 02 와 같은 구역 규칙을 플리퍼 둘에 따로
function virtualFlips() {
for (let i = 0; i < flippers.length; i += 1) {
const f = flippers[i];
if (pressMs[i] > 0) {
pressMs[i] -= FIXED_DT;
if (pressMs[i] <= 0) {
f.pressed = false;
coolMs[i] = 180;
}
}
else if (coolMs[i] > 0) {
coolMs[i] -= FIXED_DT;
}
else if (!ball.out && !plunger.ball && inFlipZone(ball, f, 0.25, 28)) {
f.pressed = true;
pressMs[i] = 220;
}
}
if (clock >= nextNudge) {
nudge();
nextNudge += NUDGE_EVERY_MS;
}
}
function step() {
autoHand();
if (demo) {
virtualFlips();
}
const hs = DT / SUBSTEPS;
for (let s = 0; s < SUBSTEPS; s += 1) {
movePlunger(plunger, hs, PULL_RATE);
for (let i = 0; i < flippers.length; i += 1) {
moveFlipper(flippers[i], hs);
}
for (let k = 0; k < bumpers.length; k += 1) {
springBumper(bumpers[k], hs);
}
if (plunger.ball || ball.out) {
continue;
}
integrate(ball, 0, TABLE_GRAVITY, hs);
for (let i = 0; i < flippers.length; i += 1) {
if (hitFlipper(ball, flippers[i], RESTITUTION) > 0 && Math.abs(flippers[i].w) > 0.5) {
addScore(FLIPPER_SCORE);
}
}
for (let k = 0; k < bumpers.length; k += 1) {
if (hitBumper(ball, bumpers[k], 0.5) > 0) {
addScore(BUMPER_SCORE);
}
}
hitWalls(ball, walls, RESTITUTION);
capSpeed(ball, MAX_SPEED);
}
// 화면 스프링 — 흔든 만큼 밀렸다가 되당김·저항으로 떨리며 멈춘다
shakeV += (-SHAKE_STIFFNESS * shakeX - SHAKE_DAMPING * shakeV) * DT;
shakeX += shakeV * DT;
for (let i = 0; i < flippers.length; i += 1) {
recordGhost(flippers[i]);
}
if (!ball.out && !plunger.ball) {
pushTrail(ball, 18);
if (ball.x > LANE_IN && ball.y + ball.r > plungerTipY(plunger) - 2 && ball.vy > 0) {
load();
}
else if (ball.y > H + 20) {
ball.out = true;
returnMs = RETURN_MS;
}
}
if (ball.out) {
returnMs -= FIXED_DT;
if (returnMs <= 0) {
load();
}
}
}
function finished() { return false; }
function onControl(role, down) {
if (role === 'left' || role === 'right') {
const i = role === 'left' ? 0 : 1;
flippers[i].pressed = down;
pressMs[i] = 0;
}
else if (role === 'nudge' && down) {
nudge();
}
}
// 굴러가는 자릿수 — 한 자리씩 그 자리 값의 소수 부분만큼 위로 밀어 다음 숫자가 아래에서 올라오게 한다
function drawScore(x, y) {
const v = shownScore();
const digits = 5;
const cw = 13;
const ch = 20;
ctx.fillStyle = rgba(BAND, 0.85);
ctx.fillRect(x - 4, y - 2, digits * cw + 8, ch + 4);
ctx.save();
ctx.beginPath();
ctx.rect(x - 4, y - 2, digits * cw + 8, ch + 4);
ctx.clip();
for (let i = 0; i < digits; i += 1) {
const p = Math.pow(10, i);
const d = Math.floor(v / p) % 10;
// 아래 자리가 모두 9 를 넘어가는 동안만 이 자리가 구른다
const lower = v % p;
const frac = i === 0 ? v - Math.floor(v) : Math.max(0, lower - (p - 1));
const cx = x + (digits - 1 - i) * cw + cw / 2;
drawText(String(d), cx, y + ch / 2 - frac * ch, 16, LEMON, 1, 'center');
drawText(String((d + 1) % 10), cx, y + ch / 2 + (1 - frac) * ch, 16, LEMON, 1, 'center');
}
ctx.restore();
}
function draw() {
clearStage();
ctx.save();
// 화면 흔들림 — 판 전체를 옆으로(위아래는 0.4 배) 옮겨 그린다. 물리 좌표는 그대로
ctx.translate(shakeX, shakeX * 0.4);
ctx.fillStyle = rgba(BAND, 0.35);
ctx.fillRect(LANE_IN, 118, LANE_OUT - LANE_IN, H - 118);
for (let i = 0; i < walls.length; i += 1) {
strokeSeg(walls[i], 4, CREAM, 0.5);
}
drawScore(40, 112);
for (let k = 0; k < bumpers.length; k += 1) {
drawBumper(bumpers[k], k === 2 ? LILAC : PINK, PEACH);
}
drawPlunger(plunger, LANE_IN - 12, H - 4);
for (let i = 0; i < flippers.length; i += 1) {
drawFlipper(flippers[i], MINT);
}
if (!ball.out) {
drawTrail(ball, PEACH, ball.r * 1.3);
drawBall(ball);
}
ctx.restore();
drawBand();
}
function stateText() {
return '점수 ' + score + (Math.abs(shakeX) > 1.5 ? ' · 판 흔들림' : '');
}
// ── 공통 꼬리: 시계·버튼·동작 줄이기·정리 ──
// 결과를 보여 준 시간(ms) — 시연 중 판이 끝나면 LOOP_HOLD_MS 뒤 같은 시드로 다시 돈다
let holdMs = 0;
function restart() {
clock = 0;
holdMs = 0;
onRestart();
}
// 한 틱 — 시뮬레이션 시각을 16ms 올리고 항목 계산(step)을 한 번 돌린다
function tick() {
clock += FIXED_DT;
step();
if (demo && !still && finished()) {
holdMs += FIXED_DT;
if (holdMs >= LOOP_HOLD_MS) {
restart();
}
}
}
function render() {
draw();
say(stateText());
}
// 동작 줄이기 — 같은 시드 판을 화면에 그리지 않고 끝까지(끝이 없는 장면은 SETTLE_TICKS 틱까지) 계산한 뒤 마지막 장면만 그린다
function settle() {
for (let k = 0; k < SETTLE_TICKS; k += 1) {
clock += FIXED_DT;
step();
if (finished()) {
break;
}
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after() {
if (still) {
settle();
}
render();
}
function control(role, down) {
wake();
onControl(role, down);
after();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 버튼 — data-role 을 항목의 onControl 에 넘긴다. data-hold 가 있으면 누르고 있는 동안 true, 떼면 false
const buttons = Array.from(root.querySelectorAll('button[data-role]'));
buttons.forEach(function (btn) {
const role = btn.getAttribute('data-role') || '';
if (!btn.hasAttribute('data-hold')) {
// 한 번 누르는 버튼 — 마우스·손가락·Enter·Space 모두 click 하나로 들어온다
btn.addEventListener('click', function () { control(role, true); }, { signal: off.signal });
return;
}
let down = false;
function setHeld(v) {
if (down === v) {
return;
}
down = v;
btn.classList.toggle('is-held', v);
control(role, v);
}
// 누르고 있는 버튼 — 손가락·마우스는 pointerdown·up, 키보드는 Space·Enter 의 keydown·keyup
btn.addEventListener('pointerdown', function (ev) {
if (ev.button !== 0) {
return;
}
btn.setPointerCapture(ev.pointerId);
setHeld(true);
}, { signal: off.signal });
btn.addEventListener('pointerup', function () { setHeld(false); }, { signal: off.signal });
btn.addEventListener('pointercancel', function () { setHeld(false); }, { signal: off.signal });
btn.addEventListener('lostpointercapture', function () { setHeld(false); }, { signal: off.signal });
btn.addEventListener('keydown', function (ev) {
if (ev.key !== ' ' && ev.key !== 'Enter') {
return;
}
ev.preventDefault();
setHeld(true);
}, { signal: off.signal });
btn.addEventListener('keyup', function (ev) {
if (ev.key !== ' ' && ev.key !== 'Enter') {
return;
}
ev.preventDefault();
setHeld(false);
}, { signal: off.signal });
btn.addEventListener('blur', function () { setHeld(false); }, { signal: off.signal });
// 길게 누를 때 뜨는 메뉴를 막는다
btn.addEventListener('contextmenu', function (ev) { ev.preventDefault(); }, { signal: off.signal });
});
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 같은 시드 판의 마지막 장면을, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (demo) {
restart();
}
after();
}
else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
seed = DEMO_SEED;
restart();
// probe-hook
if (still) {
settle();
render();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
off.abort();
};
})();// MiniTableScoreHero - vanilla 판(09-pb-mini-table-score-hero) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MiniTableScoreHero.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MiniTableScoreHero() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
type Seg = [number, number, number, number];
// 논리 무대 — 물리는 화면 폭과 상관없이 이 480×300 좌표에서만 돈다. 화면이 넓든 좁든 그릴 때만 배율을 곱한다
const W = 480;
const H = 300;
// 한 틱 = 16ms 고정. 실제 프레임 간격이 흔들려도 계산은 같은 걸음으로만 나아간다
const FIXED_DT = 16;
const DT = FIXED_DT / 1000;
const TAU = Math.PI * 2;
// 머리 띠 높이(논리 px) — 상태 줄과 버튼이 이 띠 위에 겹친다
const HEADER = 30;
// 시연이 끝난 판을 이만큼(ms) 보여 준 뒤 같은 시드로 처음부터 다시 돈다
const LOOP_HOLD_MS = 1600;
// 파스텔 팔레트 — 어두운 판 위에 밝은 공·핀을 얹어 명도 차로 움직임이 읽힌다
const BG: RGB = [30, 27, 48];
const BG_LOW: RGB = [46, 39, 70];
const BAND: RGB = [20, 18, 32];
const GUIDE: RGB = [104, 94, 140];
const CREAM: RGB = [255, 248, 234];
const SKY: RGB = [146, 208, 255];
const PINK: RGB = [255, 172, 200];
const MINT: RGB = [144, 232, 202];
const LILAC: RGB = [202, 182, 255];
const PEACH: RGB = [255, 208, 172];
const LEMON: RGB = [250, 234, 156];
const AQUA: RGB = [156, 238, 236];
const CORAL: RGB = [255, 184, 166];
const INK: RGB = [28, 24, 40];
const BALL_COLORS: RGB[] = [PEACH, MINT, SKY, PINK, LILAC, LEMON, AQUA, CORAL, CREAM];
// 공 이름표 — 실제 사람 이름 대신 글자
const LABELS: string[] = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
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(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + 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)]; }
// 시드 고정 난수 — 같은 시드면 같은 출발 자리·같은 결과가 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue: number): () => number {
let s = seedValue >>> 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;
};
}
// 공 — 자리(x, y)·속도(vx, vy, px/s)·반지름. trail 은 지나온 자리(x, y 를 번갈아), slow 는 거의 멈춰 있던 시간(ms),
// finishMs 는 결승선을 지난 시각(ms, 아직이면 -1), mul 은 공마다 다른 계수(07 구름 마찰), out 은 판 밖에서 쉬는 중
type Ball = { x: number; y: number; vx: number; vy: number; r: number; color: RGB; label: string; trail: number[]; slow: number; finishMs: number; mul: number; out: boolean };
function makeBall(x: number, y: number, r: number, color: RGB, label: string): Ball {
return { x: x, y: y, vx: 0, vy: 0, r: r, color: color, label: label, trail: [], slow: 0, finishMs: -1, mul: 1, out: false };
}
// 속도를 먼저 갱신하는 적분 — 이번 걸음의 힘(중력)으로 속도를 바꾼 뒤, 그 새 속도로 자리를 옮긴다. h 는 초
function integrate(b: Ball, gx: number, gy: number, h: number): void {
b.vx += gx * h;
b.vy += gy * h;
b.x += b.vx * h;
b.y += b.vy * h;
}
function capSpeed(b: Ball, max: number): void {
const s = Math.hypot(b.vx, b.vy);
if (s > max) { b.vx *= max / s; b.vy *= max / s; }
}
// 마지막 충돌 — 법선(공 쪽을 향함)과 닿은 자리. 범퍼·키커가 이 법선 방향으로 덧힘을 준다
const contact = { nx: 0, ny: -1, px: 0, py: 0 };
// 원-원 충돌(움직이지 않는 원: 핀·범퍼) — 겹친 깊이만큼 법선 방향 바깥으로 밀어내고,
// 법선 방향 속도를 되튐 계수 e 만큼 뒤집는다. 돌려주는 값: 안 닿음 -1 · 닿았지만 멀어지는 중 0 · 다가오던 빠르기(px/s)
function hitCircle(b: Ball, cx: number, cy: number, cr: number, e: number): number {
const dx = b.x - cx;
const dy = b.y - cy;
const d = Math.hypot(dx, dy);
const overlap = b.r + cr - d;
if (overlap <= 0) { return -1; }
// 중심이 정확히 겹치면 방향을 정할 수 없다 — 위로 민다
const nx = d > 1e-9 ? dx / d : 0;
const ny = d > 1e-9 ? dy / d : -1;
b.x += nx * overlap;
b.y += ny * overlap;
contact.nx = nx;
contact.ny = ny;
contact.px = cx + nx * cr;
contact.py = cy + ny * cr;
const vn = b.vx * nx + b.vy * ny;
if (vn >= 0) { return 0; }
b.vx -= (1 + e) * vn * nx;
b.vy -= (1 + e) * vn * ny;
return -vn;
}
// 선분 위 가장 가까운 점 — 공 중심에서 선분으로 내린 수선의 발. t 는 0(시작점)~1(끝점)로 자른다
function closestOnSegment(px: number, py: number, ax: number, ay: number, bx: number, by: number): [number, number, number] {
const ex = bx - ax;
const ey = by - ay;
const len2 = ex * ex + ey * ey;
const t = len2 > 0 ? clamp(((px - ax) * ex + (py - ay) * ey) / len2, 0, 1) : 0;
return [ax + ex * t, ay + ey * t, t];
}
// 원-선분 충돌 — 가장 가까운 점까지 거리가 (반지름 + 선 두께의 반)보다 짧으면 겹친 만큼 밀어낸다.
// 선분이 움직이면(도는 플리퍼·풍차 막대) 접점의 속도(svx, svy)에 대한 상대 속도로 되튐을 잰다.
// grip 은 이번 걸음에 접선 방향 상대 속도에서 덜어 내는 몫 — 0 이면 미끄럼, 07 구름 마찰은 0 보다 크다
function hitSegment(b: Ball, ax: number, ay: number, bx: number, by: number, half: number, e: number, svx: number, svy: number, grip: number): number {
const c = closestOnSegment(b.x, b.y, ax, ay, bx, by);
const dx = b.x - c[0];
const dy = b.y - c[1];
const d = Math.hypot(dx, dy);
const overlap = b.r + half - d;
if (overlap <= 0) { return -1; }
let nx = 0;
let ny = -1;
if (d > 1e-9) {
nx = dx / d;
ny = dy / d;
} else {
// 중심이 선 위에 올라탔다 — 선분의 왼쪽 법선으로 민다
const L = Math.hypot(bx - ax, by - ay) || 1;
nx = (by - ay) / L;
ny = -(bx - ax) / L;
}
b.x += nx * overlap;
b.y += ny * overlap;
contact.nx = nx;
contact.ny = ny;
contact.px = c[0];
contact.py = c[1];
const rvx = b.vx - svx;
const rvy = b.vy - svy;
const vn = rvx * nx + rvy * ny;
if (vn >= 0) { return 0; }
const tvx = (rvx - vn * nx) * (1 - grip);
const tvy = (rvy - vn * ny) * (1 - grip);
b.vx = svx + tvx - e * vn * nx;
b.vy = svy + tvy - e * vn * ny;
return -vn;
}
// 움직이지 않는 벽 묶음 — 선분 하나하나에 hitSegment
function hitWalls(b: Ball, segs: Seg[], e: number): void {
for (let i = 0; i < segs.length; i += 1) {
const s = segs[i];
hitSegment(b, s[0], s[1], s[2], s[3], 0, e, 0, 0, 0);
}
}
// 공-공 충돌 — 같은 무게. 겹친 깊이를 반씩 나눠 밀어내고, 법선 방향 속도를 되튐 계수만큼 주고받는다
function hitBalls(a: Ball, b: Ball, e: number): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.hypot(dx, dy);
const overlap = a.r + b.r - d;
if (overlap <= 0) { return -1; }
const nx = d > 1e-9 ? dx / d : 1;
const ny = d > 1e-9 ? dy / d : 0;
a.x -= nx * overlap * 0.5;
a.y -= ny * overlap * 0.5;
b.x += nx * overlap * 0.5;
b.y += ny * overlap * 0.5;
const vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) { return 0; }
const j = -(1 + e) * vn * 0.5;
a.vx -= j * nx;
a.vy -= j * ny;
b.vx += j * nx;
b.vy += j * ny;
return -vn;
}
// 움직이지 않는 둥근 기둥(핀·표적) — glow 는 맞은 뒤 번지는 빛(0~1)
type Peg = { x: number; y: number; glow: number };
function pushTrail(b: Ball, n: number): void {
b.trail.push(b.x, b.y);
while (b.trail.length > n * 2) { b.trail.splice(0, 2); }
}
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)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 꼬리의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 버튼을 처음 누르는 순간 시연(가상 입력·같은 판 되풀이)을 멈추고 결과를 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 판의 시드와 시각(ms). 시각은 16ms 고정 스텝으로만 는다
let seed = 0;
let clock = 0;
// 화면 배율 — 논리 1px 이 화면 CSS px 몇 개인가. 그릴 때만 쓰고 물리 계산에는 넣지 않는다
let viewScale = 1;
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);
const cssW = cv.getBoundingClientRect().width || W;
viewScale = cssW / W;
const k = viewScale * dpr;
cv.width = Math.max(1, Math.round(W * k));
cv.height = Math.max(1, Math.round(H * k));
// 그릴 때만 논리 좌표 → 화소 배율을 곱한다
ctx.setTransform(k, 0, 0, k, 0, 0);
}
function clearStage(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 머리 띠 — 상태 줄과 버튼 뒤의 어두운 띠. 판을 다 그린 뒤 맨 위에 덮는다
function drawBand(): void {
ctx.fillStyle = rgba(BAND, 0.92);
ctx.fillRect(0, 0, W, HEADER);
}
function strokeSeg(s: Seg, width: number, c: RGB, a: number): void {
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(s[0], s[1]);
ctx.lineTo(s[2], s[3]);
ctx.stroke();
}
// 꼬리 — 지나온 자리를 이을수록 굵고 진하게
function drawTrail(b: Ball, c: RGB, width: number): void {
const t = b.trail;
const n = t.length / 2;
ctx.lineCap = 'round';
for (let i = 1; i < n; i += 1) {
const k = i / n;
ctx.strokeStyle = rgba(c, 0.55 * k);
ctx.lineWidth = width * k;
ctx.beginPath();
ctx.moveTo(t[i * 2 - 2], t[i * 2 - 1]);
ctx.lineTo(t[i * 2], t[i * 2 + 1]);
ctx.stroke();
}
}
// 공 — 바닥 그림자 → 왼쪽 위가 밝은 둥근 몸통 → 진한 테두리 → 이름표 글자
function drawBall(b: Ball): void {
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.arc(b.x + 2, b.y + 3, b.r, 0, TAU);
ctx.fill();
const g = ctx.createRadialGradient(b.x - b.r * 0.35, b.y - b.r * 0.4, b.r * 0.1, b.x, b.y, b.r);
g.addColorStop(0, rgba(mixRGB(b.color, CREAM, 0.75), 1));
g.addColorStop(1, rgba(b.color, 1));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, TAU);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.6);
ctx.stroke();
if (b.label) {
ctx.fillStyle = rgba(INK, 1);
ctx.font = '700 ' + Math.round(b.r * 1.25) + 'px ' + FONT_FAMILY;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(b.label, b.x, b.y + 0.5);
}
}
function drawText(text: string, x: number, y: number, size: number, c: RGB, a: number, align: CanvasTextAlign): void {
ctx.fillStyle = rgba(c, a);
ctx.font = '600 ' + size + 'px ' + FONT_FAMILY;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillText(text, x, y);
}
// 플리퍼 — 축(px, py)을 중심으로 도는 막대. angle 은 화면 좌표 각(라디안, 화면에서 시계 방향이 +).
// 누르면 up 각으로, 떼면 rest 각으로 speed(라디안/초)만큼씩 돈다. w 는 이번 걸음의 실제 각속도(라디안/초)
type Flipper = { px: number; py: number; len: number; half: number; rest: number; up: number; speed: number; angle: number; w: number; pressed: boolean; hitR: number; hitSurf: number; ghosts: number[] };
function makeFlipper(px: number, py: number, len: number, half: number, rest: number, up: number, speed: number): Flipper {
return { px: px, py: py, len: len, half: half, rest: rest, up: up, speed: speed, angle: rest, w: 0, pressed: false, hitR: 0, hitSurf: 0, ghosts: [] };
}
function flipperTip(f: Flipper): Pt {
return [f.px + Math.cos(f.angle) * f.len, f.py + Math.sin(f.angle) * f.len];
}
// 한 걸음(h 초) — 목표 각까지 남은 차를 speed × h 안으로 잘라 돈다. 끝에 닿으면 w 는 0
function moveFlipper(f: Flipper, h: number): void {
const target = f.pressed ? f.up : f.rest;
const d = clamp(target - f.angle, -f.speed * h, f.speed * h);
f.angle += d;
f.w = d / h;
}
// 플리퍼와 공 — 막대 위 가장 가까운 점을 찾고, 그 점이 도는 빠르기(각속도 × 축까지 거리)를 상대 속도에 얹는다.
// 축에서 먼 끝으로 칠수록 접점이 빨라 공이 더 세게 날아간다
function hitFlipper(b: Ball, f: Flipper, e: number): number {
const tip = flipperTip(f);
const c = closestOnSegment(b.x, b.y, f.px, f.py, tip[0], tip[1]);
const rx = c[0] - f.px;
const ry = c[1] - f.py;
// 회전으로 생긴 접점 속도 — 축→접점 벡터를 90° 돌려 각속도를 곱한다
const svx = -f.w * ry;
const svy = f.w * rx;
const vn = hitSegment(b, f.px, f.py, tip[0], tip[1], f.half, e, svx, svy, 0);
if (vn > 0 && Math.abs(f.w) > 0.5) {
f.hitR = Math.hypot(rx, ry);
f.hitSurf = Math.abs(f.w) * f.hitR;
}
return vn;
}
// 지나간 각 기록 — 휘두른 자리를 옅은 잔상으로 그린다
function recordGhost(f: Flipper): void {
f.ghosts.push(f.angle);
while (f.ghosts.length > 5) { f.ghosts.shift(); }
}
function drawFlipperAt(f: Flipper, angle: number, body: RGB, alpha: number): void {
const tx = f.px + Math.cos(angle) * f.len;
const ty = f.py + Math.sin(angle) * f.len;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(body, alpha);
ctx.lineWidth = f.half * 2;
ctx.beginPath();
ctx.moveTo(f.px, f.py);
ctx.lineTo(tx, ty);
ctx.stroke();
}
function drawFlipper(f: Flipper, body: RGB): void {
for (let i = 0; i < f.ghosts.length; i += 1) {
if (Math.abs(f.ghosts[i] - f.angle) > 0.02) { drawFlipperAt(f, f.ghosts[i], body, 0.12 + 0.05 * i); }
}
drawFlipperAt(f, f.angle, body, 1);
// 접점 속도 눈금 — 막대를 다섯 칸으로 나눠, 칸마다 각속도 × 거리만큼 수직 화살을 세운다(끝으로 갈수록 길다)
if (Math.abs(f.w) > 0.5) {
const ca = Math.cos(f.angle);
const sa = Math.sin(f.angle);
const sgn = f.w > 0 ? 1 : -1;
ctx.strokeStyle = rgba(PEACH, 0.9);
ctx.lineWidth = 2;
for (let k = 1; k <= 5; k += 1) {
const r = f.len * k / 5;
const x = f.px + ca * r;
const y = f.py + sa * r;
const L = Math.abs(f.w) * r * 0.025;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - sa * L * sgn, y + ca * L * sgn);
ctx.stroke();
}
}
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(f.px, f.py, f.half + 2, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(INK, 0.6);
ctx.beginPath();
ctx.arc(f.px, f.py, 2, 0, TAU);
ctx.fill();
}
// 가상 입력 판정 — 공이 막대 위쪽 구역(축에서 길이의 zoneMin 배 너머, 막대 윗면에서 gap px 안)에서 내려오고 있는가
function inFlipZone(b: Ball, f: Flipper, zoneMin: number, gap: number): boolean {
const tx = Math.cos(f.rest) * f.len;
const ty = Math.sin(f.rest) * f.len;
const t = ((b.x - f.px) * tx + (b.y - f.py) * ty) / (f.len * f.len);
// 막대 윗면 쪽 거리 — 막대에 수직인 두 방향 가운데 화면 위를 향한 쪽
let nx = ty / f.len;
let ny = -tx / f.len;
if (ny > 0) { nx = -nx; ny = -ny; }
const above = (b.x - f.px) * nx + (b.y - f.py) * ny;
return t > zoneMin && t < 1.08 && above > -2 && above < b.r + f.half + gap && b.vy > -60;
}
// 범퍼 — 둥근 기둥. 닿은 공을 되튐 계수로 튕긴 뒤, 닿은 방향(법선)으로 kick(px/s)만큼 더 민다.
// 판 자체는 부푼 정도 d(반지름 대비 몫)를 스프링으로 흔든다 — 맞는 순간 그 빠르기 dv 에 pop 을 더한다
type Bumper = { x: number; y: number; r: number; kick: number; stiff: number; damp: number; pop: number; d: number; dv: number; flash: number; hits: number };
function makeBumper(x: number, y: number, r: number, kick: number, stiff: number, damp: number, pop: number): Bumper {
return { x: x, y: y, r: r, kick: kick, stiff: stiff, damp: damp, pop: pop, d: 0, dv: 0, flash: 0, hits: 0 };
}
// 공과 범퍼 — 원-원 충돌로 겹친 만큼 밀어내고 되튀긴 다음, 들어온 빠르기와 상관없이 같은 세기 kick 을 법선 방향으로 더한다.
// 맞는 자리는 그림의 부푼 반지름이 아니라 r 그대로다(부풀기는 그림만)
function hitBumper(b: Ball, m: Bumper, e: number): number {
const vn = hitCircle(b, m.x, m.y, m.r, e);
if (vn <= 0) { return vn; }
b.vx += contact.nx * m.kick;
b.vy += contact.ny * m.kick;
m.dv += m.pop;
m.flash = 1;
m.hits += 1;
return vn;
}
// 반지름 스프링 — 부푼 만큼 되당기는 힘(stiff × d)과 빠르기에 비례하는 저항(damp × dv). 저항이 작아 한 번 넘쳤다가 돌아온다
function springBumper(m: Bumper, h: number): void {
m.dv += (-m.stiff * m.d - m.damp * m.dv) * h;
m.d += m.dv * h;
m.flash *= Math.exp(-h * 7);
}
function drawBumper(m: Bumper, ring: RGB, cap: RGB): void {
const rr = m.r * (1 + m.d);
if (m.flash > 0.02) {
const g = ctx.createRadialGradient(m.x, m.y, rr * 0.6, m.x, m.y, rr + 26 * m.flash);
g.addColorStop(0, rgba(ring, 0.55 * m.flash));
g.addColorStop(1, rgba(ring, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(m.x, m.y, rr + 26 * m.flash, 0, TAU);
ctx.fill();
}
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.arc(m.x + 3, m.y + 4, rr, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(ring, 1);
ctx.beginPath();
ctx.arc(m.x, m.y, rr, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(mixRGB(cap, CREAM, m.flash * 0.85), 1);
ctx.beginPath();
ctx.arc(m.x, m.y, rr * 0.7, 0, TAU);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK, 0.35);
ctx.stroke();
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(m.x - rr * 0.22, m.y - rr * 0.25, rr * 0.18, 0, TAU);
ctx.fill();
}
// 발사 막대 — pull 은 당긴 길이(px, 아래로 +). 쥐고 있으면 rate(px/s)로 cap 까지 당겨지고,
// 놓으면 당긴 만큼 되미는 힘(가속도 = -stiff × pull)으로 돌아간다. 0 을 지나는 순간 막대 빠르기 × scale 을 공의 발사 속도로 넘긴다
type Plunger = { x: number; top: number; pull: number; v: number; max: number; cap: number; stiff: number; scale: number; damp: number; held: boolean; free: boolean; ball: Ball | null; releasedPull: number; lastPull: number; lastLaunch: number };
function makePlunger(x: number, top: number, max: number, stiff: number, scale: number, damp: number): Plunger {
return { x: x, top: top, pull: 0, v: 0, max: max, cap: max, stiff: stiff, scale: scale, damp: damp, held: false, free: false, ball: null, releasedPull: 0, lastPull: 0, lastLaunch: 0 };
}
function plungerTipY(p: Plunger): number { return p.top + p.pull; }
// 막대 위에 공 올리기 — 다음 발사까지 공은 막대 끝에 붙어 함께 움직인다
function loadPlunger(p: Plunger, b: Ball): void {
p.ball = b;
b.vx = 0;
b.vy = 0;
b.trail = [];
}
// 놓았을 때 막대 끝이 0 을 지나며 낼 빠르기 — 당긴 만큼 되미는 힘이면 √stiff × 당긴 길이
function predictLaunch(p: Plunger, pull: number): number { return p.scale * Math.sqrt(p.stiff) * pull; }
function movePlunger(p: Plunger, h: number, rate: number): void {
if (p.held) {
p.pull = Math.min(p.cap, p.pull + rate * h);
p.v = 0;
// 놓은 뒤 처음 0 을 지날 때까지는 저항 없이 — 당긴 만큼 다 돌려준다
p.free = true;
p.releasedPull = p.pull;
} else {
const before = p.pull;
const a = -p.stiff * p.pull - (p.free ? 0 : p.damp * p.v);
// 속도를 먼저 갱신하는 적분
p.v += a * h;
p.pull += p.v * h;
if (p.free && before > 0 && p.pull <= 0) {
p.free = false;
if (p.ball) {
const speed = Math.abs(p.v) * p.scale;
p.ball.vx = 0;
p.ball.vy = -speed;
p.lastPull = p.releasedPull;
p.lastLaunch = speed;
p.ball = null;
}
}
// 공 없이 놓았거나 아주 조금 당겼다 놓았으면 그대로 출렁이다 멈춘다
if (p.free && p.releasedPull <= 0) { p.free = false; }
}
if (p.ball) {
p.ball.x = p.x;
p.ball.y = plungerTipY(p) - p.ball.r - 3;
p.ball.vx = 0;
p.ball.vy = 0;
}
}
// 막대 그림 — 끝 마개 + 아래로 이어진 용수철(당길수록 촘촘해진다) + 옆의 힘 막대
function drawPlunger(p: Plunger, gaugeX: number, bottom: number): void {
const tip = plungerTipY(p);
const coils = 7;
const span = bottom - tip - 6;
ctx.strokeStyle = rgba(LILAC, 0.95);
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(p.x, tip + 6);
for (let i = 1; i <= coils * 2; i += 1) {
const y = tip + 6 + span * i / (coils * 2);
ctx.lineTo(p.x + (i % 2 ? 8 : -8), y);
}
ctx.stroke();
ctx.fillStyle = rgba(CREAM, 1);
ctx.fillRect(p.x - 11, tip, 22, 7);
ctx.fillStyle = rgba(PEACH, 1);
ctx.fillRect(p.x - 11, tip + 5, 22, 2);
// 되미는 힘 막대 — 높이 = stiff × pull 을 최대 힘 대비 몫으로
const k = clamp(p.pull / p.max, 0, 1);
const gh = 60;
ctx.fillStyle = rgba(GUIDE, 0.5);
ctx.fillRect(gaugeX, bottom - gh, 6, gh);
ctx.fillStyle = rgba(mixRGB(MINT, PINK, k), 1);
ctx.fillRect(gaugeX, bottom - gh * k, 6, gh * k);
}
// 굽은 길 — 원호를 n 토막 선분으로. 반지름 r, 각 a0 → a1(라디안)
function makeArc(cx: number, cy: number, r: number, a0: number, a1: number, n: number): Seg[] {
const out: Seg[] = [];
for (let i = 0; i < n; i += 1) {
const u = a0 + (a1 - a0) * i / n;
const v = a0 + (a1 - a0) * (i + 1) / n;
out.push([cx + Math.cos(u) * r, cy + Math.sin(u) * r, cx + Math.cos(v) * r, cy + Math.sin(v) * r]);
}
return out;
}
// 09 미니 테이블 — 02 플리퍼·03 범퍼·05 발사 막대를 한 판에 + 점수 숫자 굴림 + 흔들기 충격·화면 스프링
// 판의 기울기만큼 받는 중력(px/s²)
const TABLE_GRAVITY = 520;
// 흔들기 — 판 위 모든 공의 속도에 더하는 빠르기(px/s)
const NUDGE_IMPULSE = 160;
// 점수 숫자가 새 값까지 굴러 올라가는 시간(ms)
const SCORE_ROLL_MS = 600;
// 화면 흔들림 스프링 — 되당김(1/s²)·저항(1/s)·흔들 때 화면에 주는 빠르기(px/s)
const SHAKE_STIFFNESS = 600;
const SHAKE_DAMPING = 8;
const SHAKE_KICK = 220;
const FLIPPER_SPEED = 12;
const FLIPPER_LENGTH = 70;
const FLIPPER_REST_ANGLE = 0.5;
const FLIPPER_UP_ANGLE = -0.45;
const FLIPPER_HALF = 5;
const BUMPER_KICK = 300;
const BUMPER_RADIUS = 19;
const BUMPER_STIFFNESS = 900;
const BUMPER_DAMPING = 10;
const BUMPER_POP = 5;
const PLUNGER_STIFFNESS = 900;
const PLUNGER_MAX_PULL = 40;
const LAUNCH_SCALE = 0.85;
const PLUNGER_DAMPING = 24;
const PULL_RATE = 90;
const SUBSTEPS = 6;
const BALL_R = 8;
const RESTITUTION = 0.45;
const MAX_SPEED = 1500;
const BUMPER_SCORE = 100;
const FLIPPER_SCORE = 10;
// 시연 흔들기 일정 — 처음 NUDGE_FIRST_MS 에 한 번, 그 뒤 NUDGE_EVERY_MS 마다
const NUDGE_FIRST_MS = 1300;
const NUDGE_EVERY_MS = 3600;
const LANE_IN = 420;
const LANE_OUT = 456;
const RETURN_MS = 500;
const AUTO_WAIT_MS = 250;
const AUTO_HOLD_MS = 100;
const DEMO_SEED = 4769;
const SETTLE_TICKS = 240;
const walls: Seg[] = makeArc(372, 118, 84, 0, -Math.PI / 2, 12).concat([
[LANE_OUT, 118, LANE_OUT, 300], [LANE_IN, 128, LANE_IN, 300],
[64, 34, 372, 34], [30, 62, 64, 34], [30, 62, 30, 200],
// 안쪽 길 — 벽에서 플리퍼 축으로
[30, 200, 126, 252], [LANE_IN, 200, 324, 252]
]);
const flippers: Flipper[] = [
makeFlipper(136, 258, FLIPPER_LENGTH, FLIPPER_HALF, FLIPPER_REST_ANGLE, FLIPPER_UP_ANGLE, FLIPPER_SPEED),
makeFlipper(314, 258, FLIPPER_LENGTH, FLIPPER_HALF, Math.PI - FLIPPER_REST_ANGLE, Math.PI - FLIPPER_UP_ANGLE, FLIPPER_SPEED)
];
const bumpers: Bumper[] = [
makeBumper(150, 100, BUMPER_RADIUS, BUMPER_KICK, BUMPER_STIFFNESS, BUMPER_DAMPING, BUMPER_POP),
makeBumper(256, 90, BUMPER_RADIUS, BUMPER_KICK, BUMPER_STIFFNESS, BUMPER_DAMPING, BUMPER_POP),
makeBumper(204, 158, BUMPER_RADIUS, BUMPER_KICK, BUMPER_STIFFNESS, BUMPER_DAMPING, BUMPER_POP)
];
const plunger: Plunger = makePlunger((LANE_IN + LANE_OUT) / 2, 278, PLUNGER_MAX_PULL, PLUNGER_STIFFNESS, LAUNCH_SCALE, PLUNGER_DAMPING);
let rng = makeRng(1);
let ball: Ball = makeBall(0, 0, BALL_R, PEACH, '');
let returnMs = 0;
let handPhase = 'wait';
let handMs = 0;
let score = 0;
let rollFrom = 0;
let rollStart = 0;
let shakeX = 0;
let shakeV = 0;
let nudgeDir = 1;
let nextNudge = NUDGE_FIRST_MS;
let nudges = 0;
let pressMs: number[] = [0, 0];
let coolMs: number[] = [0, 0];
// 점수 굴림 — 지금 보이는 값은 이전 값에서 새 값까지 SCORE_ROLL_MS 동안 처음·끝이 느린 곡선으로 간다
function shownScore(): number { return lerp(rollFrom, score, smooth((clock - rollStart) / SCORE_ROLL_MS)); }
function addScore(n: number): void {
rollFrom = shownScore();
rollStart = clock;
score += n;
}
function load(): void {
ball.out = false;
loadPlunger(plunger, ball);
handPhase = 'wait';
handMs = 0;
plunger.cap = PLUNGER_MAX_PULL * (0.75 + 0.25 * rng());
}
function onRestart(): void {
rng = makeRng(seed);
ball = makeBall(plunger.x, 260, BALL_R, PEACH, '');
plunger.pull = 0;
plunger.v = 0;
plunger.held = false;
plunger.free = false;
for (let i = 0; i < flippers.length; i += 1) {
flippers[i].angle = flippers[i].rest;
flippers[i].pressed = false;
flippers[i].ghosts = [];
}
for (let i = 0; i < bumpers.length; i += 1) {
bumpers[i].d = 0;
bumpers[i].dv = 0;
bumpers[i].flash = 0;
bumpers[i].hits = 0;
}
score = 0;
rollFrom = 0;
rollStart = 0;
shakeX = 0;
shakeV = 0;
nudgeDir = rng() < 0.5 ? -1 : 1;
nextNudge = NUDGE_FIRST_MS;
nudges = 0;
pressMs = [0, 0];
coolMs = [0, 0];
returnMs = 0;
load();
}
// 흔들기 — 판 위 공에 같은 충격을 더하고, 화면에는 스프링 빠르기를 준다(화면 흔들림은 그림만, 공은 충격만 받는다)
function nudge(): void {
if (!plunger.ball && !ball.out) {
ball.vx += nudgeDir * NUDGE_IMPULSE;
ball.vy -= NUDGE_IMPULSE * 0.35;
}
shakeV += nudgeDir * SHAKE_KICK;
nudgeDir = -nudgeDir;
nudges += 1;
}
// 시연 손 — 발사 막대는 사람이 손대도 저절로 당겼다 놓는다(이 판에는 발사 버튼이 없다)
function autoHand(): void {
if (!plunger.ball) { return; }
handMs += FIXED_DT;
if (handPhase === 'wait' && handMs >= AUTO_WAIT_MS) {
plunger.held = true;
handPhase = 'pull';
} else if (handPhase === 'pull' && plunger.pull >= plunger.cap - 0.01) {
handPhase = 'hold';
handMs = 0;
} else if (handPhase === 'hold' && handMs >= AUTO_HOLD_MS) {
plunger.held = false;
handPhase = 'flight';
}
}
// 가상 입력 — 02 와 같은 구역 규칙을 플리퍼 둘에 따로
function virtualFlips(): void {
for (let i = 0; i < flippers.length; i += 1) {
const f = flippers[i];
if (pressMs[i] > 0) {
pressMs[i] -= FIXED_DT;
if (pressMs[i] <= 0) {
f.pressed = false;
coolMs[i] = 180;
}
} else if (coolMs[i] > 0) {
coolMs[i] -= FIXED_DT;
} else if (!ball.out && !plunger.ball && inFlipZone(ball, f, 0.25, 28)) {
f.pressed = true;
pressMs[i] = 220;
}
}
if (clock >= nextNudge) {
nudge();
nextNudge += NUDGE_EVERY_MS;
}
}
function step(): void {
autoHand();
if (demo) { virtualFlips(); }
const hs = DT / SUBSTEPS;
for (let s = 0; s < SUBSTEPS; s += 1) {
movePlunger(plunger, hs, PULL_RATE);
for (let i = 0; i < flippers.length; i += 1) { moveFlipper(flippers[i], hs); }
for (let k = 0; k < bumpers.length; k += 1) { springBumper(bumpers[k], hs); }
if (plunger.ball || ball.out) { continue; }
integrate(ball, 0, TABLE_GRAVITY, hs);
for (let i = 0; i < flippers.length; i += 1) {
if (hitFlipper(ball, flippers[i], RESTITUTION) > 0 && Math.abs(flippers[i].w) > 0.5) { addScore(FLIPPER_SCORE); }
}
for (let k = 0; k < bumpers.length; k += 1) {
if (hitBumper(ball, bumpers[k], 0.5) > 0) { addScore(BUMPER_SCORE); }
}
hitWalls(ball, walls, RESTITUTION);
capSpeed(ball, MAX_SPEED);
}
// 화면 스프링 — 흔든 만큼 밀렸다가 되당김·저항으로 떨리며 멈춘다
shakeV += (-SHAKE_STIFFNESS * shakeX - SHAKE_DAMPING * shakeV) * DT;
shakeX += shakeV * DT;
for (let i = 0; i < flippers.length; i += 1) { recordGhost(flippers[i]); }
if (!ball.out && !plunger.ball) {
pushTrail(ball, 18);
if (ball.x > LANE_IN && ball.y + ball.r > plungerTipY(plunger) - 2 && ball.vy > 0) { load(); }
else if (ball.y > H + 20) {
ball.out = true;
returnMs = RETURN_MS;
}
}
if (ball.out) {
returnMs -= FIXED_DT;
if (returnMs <= 0) { load(); }
}
}
function finished(): boolean { return false; }
function onControl(role: string, down: boolean): void {
if (role === 'left' || role === 'right') {
const i = role === 'left' ? 0 : 1;
flippers[i].pressed = down;
pressMs[i] = 0;
} else if (role === 'nudge' && down) {
nudge();
}
}
// 굴러가는 자릿수 — 한 자리씩 그 자리 값의 소수 부분만큼 위로 밀어 다음 숫자가 아래에서 올라오게 한다
function drawScore(x: number, y: number): void {
const v = shownScore();
const digits = 5;
const cw = 13;
const ch = 20;
ctx.fillStyle = rgba(BAND, 0.85);
ctx.fillRect(x - 4, y - 2, digits * cw + 8, ch + 4);
ctx.save();
ctx.beginPath();
ctx.rect(x - 4, y - 2, digits * cw + 8, ch + 4);
ctx.clip();
for (let i = 0; i < digits; i += 1) {
const p = Math.pow(10, i);
const d = Math.floor(v / p) % 10;
// 아래 자리가 모두 9 를 넘어가는 동안만 이 자리가 구른다
const lower = v % p;
const frac = i === 0 ? v - Math.floor(v) : Math.max(0, lower - (p - 1));
const cx = x + (digits - 1 - i) * cw + cw / 2;
drawText(String(d), cx, y + ch / 2 - frac * ch, 16, LEMON, 1, 'center');
drawText(String((d + 1) % 10), cx, y + ch / 2 + (1 - frac) * ch, 16, LEMON, 1, 'center');
}
ctx.restore();
}
function draw(): void {
clearStage();
ctx.save();
// 화면 흔들림 — 판 전체를 옆으로(위아래는 0.4 배) 옮겨 그린다. 물리 좌표는 그대로
ctx.translate(shakeX, shakeX * 0.4);
ctx.fillStyle = rgba(BAND, 0.35);
ctx.fillRect(LANE_IN, 118, LANE_OUT - LANE_IN, H - 118);
for (let i = 0; i < walls.length; i += 1) { strokeSeg(walls[i], 4, CREAM, 0.5); }
drawScore(40, 112);
for (let k = 0; k < bumpers.length; k += 1) { drawBumper(bumpers[k], k === 2 ? LILAC : PINK, PEACH); }
drawPlunger(plunger, LANE_IN - 12, H - 4);
for (let i = 0; i < flippers.length; i += 1) { drawFlipper(flippers[i], MINT); }
if (!ball.out) {
drawTrail(ball, PEACH, ball.r * 1.3);
drawBall(ball);
}
ctx.restore();
drawBand();
}
function stateText(): string {
return '점수 ' + score + (Math.abs(shakeX) > 1.5 ? ' · 판 흔들림' : '');
}
// ── 공통 꼬리: 시계·버튼·동작 줄이기·정리 ──
// 결과를 보여 준 시간(ms) — 시연 중 판이 끝나면 LOOP_HOLD_MS 뒤 같은 시드로 다시 돈다
let holdMs = 0;
function restart(): void {
clock = 0;
holdMs = 0;
onRestart();
}
// 한 틱 — 시뮬레이션 시각을 16ms 올리고 항목 계산(step)을 한 번 돌린다
function tick(): void {
clock += FIXED_DT;
step();
if (demo && !still && finished()) {
holdMs += FIXED_DT;
if (holdMs >= LOOP_HOLD_MS) { restart(); }
}
}
function render(): void {
draw();
say(stateText());
}
// 동작 줄이기 — 같은 시드 판을 화면에 그리지 않고 끝까지(끝이 없는 장면은 SETTLE_TICKS 틱까지) 계산한 뒤 마지막 장면만 그린다
function settle(): void {
for (let k = 0; k < SETTLE_TICKS; k += 1) {
clock += FIXED_DT;
step();
if (finished()) { break; }
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after(): void {
if (still) { settle(); }
render();
}
function control(role: string, down: boolean): void {
wake();
onControl(role, down);
after();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 버튼 — data-role 을 항목의 onControl 에 넘긴다. data-hold 가 있으면 누르고 있는 동안 true, 떼면 false
const buttons: HTMLButtonElement[] = Array.from(root.querySelectorAll<HTMLButtonElement>('button[data-role]'));
buttons.forEach(function (btn: HTMLButtonElement): void {
const role = btn.getAttribute('data-role') || '';
if (!btn.hasAttribute('data-hold')) {
// 한 번 누르는 버튼 — 마우스·손가락·Enter·Space 모두 click 하나로 들어온다
btn.addEventListener('click', function (): void { control(role, true); }, { signal: off.signal });
return;
}
let down = false;
function setHeld(v: boolean): void {
if (down === v) { return; }
down = v;
btn.classList.toggle('is-held', v);
control(role, v);
}
// 누르고 있는 버튼 — 손가락·마우스는 pointerdown·up, 키보드는 Space·Enter 의 keydown·keyup
btn.addEventListener('pointerdown', function (ev: PointerEvent): void {
if (ev.button !== 0) { return; }
btn.setPointerCapture(ev.pointerId);
setHeld(true);
}, { signal: off.signal });
btn.addEventListener('pointerup', function (): void { setHeld(false); }, { signal: off.signal });
btn.addEventListener('pointercancel', function (): void { setHeld(false); }, { signal: off.signal });
btn.addEventListener('lostpointercapture', function (): void { setHeld(false); }, { signal: off.signal });
btn.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key !== ' ' && ev.key !== 'Enter') { return; }
ev.preventDefault();
setHeld(true);
}, { signal: off.signal });
btn.addEventListener('keyup', function (ev: KeyboardEvent): void {
if (ev.key !== ' ' && ev.key !== 'Enter') { return; }
ev.preventDefault();
setHeld(false);
}, { signal: off.signal });
btn.addEventListener('blur', function (): void { setHeld(false); }, { signal: off.signal });
// 길게 누를 때 뜨는 메뉴를 막는다
btn.addEventListener('contextmenu', function (ev: Event): void { ev.preventDefault(); }, { signal: off.signal });
});
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 같은 시드 판의 마지막 장면을, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
if (demo) { restart(); }
after();
} else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
seed = DEMO_SEED;
restart();
// probe-hook
if (still) {
settle();
render();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
off.abort();
};
}, []);
return (
<div className={cx("pb") + " is-demo"} ref={rootRef}>
<canvas className={cx("pb__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<div className={cx("pb__head")}>
<p className={cx("pb__status")} data-role="status" aria-live="off">점수 0</p>
<div className={cx("pb__controls")}>
<button type="button" className={cx("pb__btn")} data-role="left" data-hold="1" aria-label="왼쪽 플리퍼">◀ 플리퍼</button>
<button type="button" className={cx("pb__btn")} data-role="right" data-hold="1" aria-label="오른쪽 플리퍼">플리퍼 ▶</button>
<button type="button" className={cx("pb__btn")} data-role="nudge">흔들기</button>
</div>
</div>
</div>
);
}09 플리퍼·범퍼·점수판을 묶은 미니 핀볼 테이블 — 측정 누적면적 14.274% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 핀볼 뽑기 9종 — 이름 공 추첨판·플리퍼·범퍼·구슬 레이스
이 데모가 속한 글
핀볼 뽑기 9종 — 이름 공 추첨판·플리퍼·범퍼·구슬 레이스핀볼 뽑기는 이름을 단 공을 핀·막대·범퍼 사이로 굴려 당첨을 정하는 캔버스 추첨판이다. 공을 같은 자리에서 떨어뜨리고 칸마다 이름을 붙이면 왜 가운데 이름만 자꾸 당첨될까? 08번은 떨어진 칸을 쌓아 종 모양을 드 이 글의 데모 전부 보기


