02도는 빠르기를 공에 실어 쏘는 플리퍼
축을 중심으로 휘두르는 막대의 가장 가까운 점을 찾아, 회전 빠르기에 축까지의 거리를 곱한 접점 속도를 공의 상대 속도에 얹어 끝으로 칠수록 공이 더 세게 날아간다.
쓰임브라우저 미니게임 이벤트 페이지
FLIPPER_SPEEDFLIPPER_LENGTHFLIPPER_REST_ANGLE

<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">공이 플리퍼로 굴러오는 중</p>
<div class="pb__controls">
<button type="button" class="pb__btn" data-role="flip" data-hold="1">플리퍼</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;
}
// 02 플리퍼 — 선분 위 가장 가까운 점 + 회전 빠르기 × 축까지 거리 = 접점 속도
// 회전 빠르기(라디안/초) — 누르면 이 빠르기로 올라가고, 떼면 같은 빠르기로 내려온다
const FLIPPER_SPEED = 12;
// 축에서 끝까지 길이(px)
const FLIPPER_LENGTH = 86;
// 쉬는 각(라디안) — 화면 오른쪽 아래로 이만큼 기운다. 누르면 FLIPPER_UP_ANGLE 까지 올라간다
const FLIPPER_REST_ANGLE = 0.5;
const FLIPPER_UP_ANGLE = -0.42;
const FLIPPER_HALF = 6;
// 한 틱(16ms)을 몇 걸음으로 나누나 — 줄이면 빠르게 도는 끝이 한 걸음에 공 반지름보다 멀리 가 공이 막대를 뚫는다
const SUBSTEPS = 6;
const GRAVITY = 640;
const BALL_R = 10;
const RESTITUTION = 0.45;
const MAX_SPEED = 1600;
const PIVOT_X = 168;
const PIVOT_Y = 244;
// 가상 입력 — 시연 중 공이 막대 위쪽 구역(축에서 길이의 0.3 배 너머, 윗면에서 30px 안)에 들어오면 누르고 FLIP_HOLD_MS 뒤 뗀다
const PRESS_ZONE_MIN = 0.3;
const PRESS_ZONE_GAP = 30;
const FLIP_HOLD_MS = 240;
const FLIP_COOLDOWN_MS = 200;
const DEMO_SEED = 4762;
const SETTLE_TICKS = 240;
const walls = [
[36, 90, 36, 176], [36, 90, 86, 40], [86, 40, 394, 40], [394, 40, 444, 90], [444, 90, 444, 150],
// 왼쪽 안내 비탈 — 공을 플리퍼 축 쪽으로 굴려 보낸다
[36, 176, PIVOT_X - 12, PIVOT_Y - 6],
// 오른쪽 비탈 — 공을 플리퍼 끝 쪽으로 모은다. 끝과 이 비탈 사이 틈으로 떨어지면 다시 위에서 나온다
[444, 150, 278, 282]
];
// 맞히면 빛나는 표적 둘
const lamps = [{ x: 190, y: 92, glow: 0 }, { x: 306, y: 84, glow: 0 }];
const LAMP_R = 9;
const flipper = makeFlipper(PIVOT_X, PIVOT_Y, FLIPPER_LENGTH, FLIPPER_HALF, FLIPPER_REST_ANGLE, FLIPPER_UP_ANGLE, FLIPPER_SPEED);
let rng = makeRng(1);
let ball = makeBall(0, 0, BALL_R, PEACH, '');
let pressMs = 0;
let coolMs = 0;
let drains = 0;
// 마지막으로 친 순간 — 축에서 접점까지 거리·접점 속도·친 직후 공 빠르기
let hitR = 0;
let hitSurf = 0;
let hitBall = 0;
let hitFade = 0;
let hitX = 0;
let hitY = 0;
function respawn() {
ball = makeBall(360 + rng() * 50, 54, BALL_R, PEACH, '');
ball.vx = -60 - rng() * 120;
}
function onRestart() {
rng = makeRng(seed);
respawn();
flipper.angle = FLIPPER_REST_ANGLE;
flipper.w = 0;
flipper.pressed = false;
flipper.ghosts = [];
pressMs = 0;
coolMs = 0;
drains = 0;
hitR = 0;
hitSurf = 0;
hitBall = 0;
hitFade = 0;
}
// 가상 입력 — 손대기 전까지는 구역 규칙이 누르고 뗀다(같은 시드면 같은 순간에 누른다)
function virtualInput() {
if (pressMs > 0) {
pressMs -= FIXED_DT;
if (pressMs <= 0) {
flipper.pressed = false;
coolMs = FLIP_COOLDOWN_MS;
}
}
else if (coolMs > 0) {
coolMs -= FIXED_DT;
}
else if (inFlipZone(ball, flipper, PRESS_ZONE_MIN, PRESS_ZONE_GAP)) {
flipper.pressed = true;
pressMs = FLIP_HOLD_MS;
}
}
function step() {
if (demo) {
virtualInput();
}
const hs = DT / SUBSTEPS;
for (let s = 0; s < SUBSTEPS; s += 1) {
moveFlipper(flipper, hs);
integrate(ball, 0, GRAVITY, hs);
const vn = hitFlipper(ball, flipper, RESTITUTION);
if (vn > 0 && Math.abs(flipper.w) > 0.5) {
hitR = flipper.hitR;
hitSurf = flipper.hitSurf;
hitBall = Math.hypot(ball.vx, ball.vy);
hitFade = 1;
hitX = contact.px;
hitY = contact.py;
}
hitWalls(ball, walls, RESTITUTION);
for (let i = 0; i < lamps.length; i += 1) {
if (hitCircle(ball, lamps[i].x, lamps[i].y, LAMP_R, 0.6) > 0) {
lamps[i].glow = 1;
}
}
capSpeed(ball, MAX_SPEED);
}
recordGhost(flipper);
pushTrail(ball, 22);
hitFade *= 0.94;
for (let i = 0; i < lamps.length; i += 1) {
lamps[i].glow *= 0.93;
}
if (ball.y > H + 30) {
drains += 1;
respawn();
}
}
function finished() { return false; }
function onControl(role, down) {
if (role === 'flip') {
flipper.pressed = down;
pressMs = 0;
coolMs = 0;
}
}
function draw() {
clearStage();
for (let i = 0; i < walls.length; i += 1) {
strokeSeg(walls[i], 4, CREAM, 0.5);
}
for (let i = 0; i < lamps.length; i += 1) {
const l = lamps[i];
if (l.glow > 0.02) {
ctx.fillStyle = rgba(LEMON, 0.45 * l.glow);
ctx.beginPath();
ctx.arc(l.x, l.y, LAMP_R + 14 * l.glow, 0, TAU);
ctx.fill();
}
ctx.fillStyle = rgba(mixRGB(LILAC, LEMON, l.glow), 1);
ctx.beginPath();
ctx.arc(l.x, l.y, LAMP_R, 0, TAU);
ctx.fill();
}
drawTrail(ball, PEACH, ball.r * 1.3);
drawFlipper(flipper, MINT);
// 맞은 자리 — 접점에서 퍼지는 고리
if (hitFade > 0.05) {
ctx.strokeStyle = rgba(LEMON, hitFade);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(hitX, hitY, 6 + (1 - hitFade) * 22, 0, TAU);
ctx.stroke();
}
drawBall(ball);
drawBand();
}
function stateText() {
if (hitBall > 0) {
return '축에서 ' + Math.round(hitR) + 'px · 접점 ' + Math.round(hitSurf) + ' → 공 ' + Math.round(hitBall) + 'px/s';
}
return '공이 플리퍼로 굴러오는 중';
}
// ── 공통 꼬리: 시계·버튼·동작 줄이기·정리 ──
// 결과를 보여 준 시간(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();
};
})();// FlipperAngularKick - vanilla 판(02-pb-flipper-angular-kick) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./FlipperAngularKick.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FlipperAngularKick() {
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;
}
// 02 플리퍼 — 선분 위 가장 가까운 점 + 회전 빠르기 × 축까지 거리 = 접점 속도
// 회전 빠르기(라디안/초) — 누르면 이 빠르기로 올라가고, 떼면 같은 빠르기로 내려온다
const FLIPPER_SPEED = 12;
// 축에서 끝까지 길이(px)
const FLIPPER_LENGTH = 86;
// 쉬는 각(라디안) — 화면 오른쪽 아래로 이만큼 기운다. 누르면 FLIPPER_UP_ANGLE 까지 올라간다
const FLIPPER_REST_ANGLE = 0.5;
const FLIPPER_UP_ANGLE = -0.42;
const FLIPPER_HALF = 6;
// 한 틱(16ms)을 몇 걸음으로 나누나 — 줄이면 빠르게 도는 끝이 한 걸음에 공 반지름보다 멀리 가 공이 막대를 뚫는다
const SUBSTEPS = 6;
const GRAVITY = 640;
const BALL_R = 10;
const RESTITUTION = 0.45;
const MAX_SPEED = 1600;
const PIVOT_X = 168;
const PIVOT_Y = 244;
// 가상 입력 — 시연 중 공이 막대 위쪽 구역(축에서 길이의 0.3 배 너머, 윗면에서 30px 안)에 들어오면 누르고 FLIP_HOLD_MS 뒤 뗀다
const PRESS_ZONE_MIN = 0.3;
const PRESS_ZONE_GAP = 30;
const FLIP_HOLD_MS = 240;
const FLIP_COOLDOWN_MS = 200;
const DEMO_SEED = 4762;
const SETTLE_TICKS = 240;
const walls: Seg[] = [
[36, 90, 36, 176], [36, 90, 86, 40], [86, 40, 394, 40], [394, 40, 444, 90], [444, 90, 444, 150],
// 왼쪽 안내 비탈 — 공을 플리퍼 축 쪽으로 굴려 보낸다
[36, 176, PIVOT_X - 12, PIVOT_Y - 6],
// 오른쪽 비탈 — 공을 플리퍼 끝 쪽으로 모은다. 끝과 이 비탈 사이 틈으로 떨어지면 다시 위에서 나온다
[444, 150, 278, 282]
];
// 맞히면 빛나는 표적 둘
const lamps: Peg[] = [{ x: 190, y: 92, glow: 0 }, { x: 306, y: 84, glow: 0 }];
const LAMP_R = 9;
const flipper: Flipper = makeFlipper(PIVOT_X, PIVOT_Y, FLIPPER_LENGTH, FLIPPER_HALF, FLIPPER_REST_ANGLE, FLIPPER_UP_ANGLE, FLIPPER_SPEED);
let rng = makeRng(1);
let ball: Ball = makeBall(0, 0, BALL_R, PEACH, '');
let pressMs = 0;
let coolMs = 0;
let drains = 0;
// 마지막으로 친 순간 — 축에서 접점까지 거리·접점 속도·친 직후 공 빠르기
let hitR = 0;
let hitSurf = 0;
let hitBall = 0;
let hitFade = 0;
let hitX = 0;
let hitY = 0;
function respawn(): void {
ball = makeBall(360 + rng() * 50, 54, BALL_R, PEACH, '');
ball.vx = -60 - rng() * 120;
}
function onRestart(): void {
rng = makeRng(seed);
respawn();
flipper.angle = FLIPPER_REST_ANGLE;
flipper.w = 0;
flipper.pressed = false;
flipper.ghosts = [];
pressMs = 0;
coolMs = 0;
drains = 0;
hitR = 0;
hitSurf = 0;
hitBall = 0;
hitFade = 0;
}
// 가상 입력 — 손대기 전까지는 구역 규칙이 누르고 뗀다(같은 시드면 같은 순간에 누른다)
function virtualInput(): void {
if (pressMs > 0) {
pressMs -= FIXED_DT;
if (pressMs <= 0) {
flipper.pressed = false;
coolMs = FLIP_COOLDOWN_MS;
}
} else if (coolMs > 0) {
coolMs -= FIXED_DT;
} else if (inFlipZone(ball, flipper, PRESS_ZONE_MIN, PRESS_ZONE_GAP)) {
flipper.pressed = true;
pressMs = FLIP_HOLD_MS;
}
}
function step(): void {
if (demo) { virtualInput(); }
const hs = DT / SUBSTEPS;
for (let s = 0; s < SUBSTEPS; s += 1) {
moveFlipper(flipper, hs);
integrate(ball, 0, GRAVITY, hs);
const vn = hitFlipper(ball, flipper, RESTITUTION);
if (vn > 0 && Math.abs(flipper.w) > 0.5) {
hitR = flipper.hitR;
hitSurf = flipper.hitSurf;
hitBall = Math.hypot(ball.vx, ball.vy);
hitFade = 1;
hitX = contact.px;
hitY = contact.py;
}
hitWalls(ball, walls, RESTITUTION);
for (let i = 0; i < lamps.length; i += 1) {
if (hitCircle(ball, lamps[i].x, lamps[i].y, LAMP_R, 0.6) > 0) { lamps[i].glow = 1; }
}
capSpeed(ball, MAX_SPEED);
}
recordGhost(flipper);
pushTrail(ball, 22);
hitFade *= 0.94;
for (let i = 0; i < lamps.length; i += 1) { lamps[i].glow *= 0.93; }
if (ball.y > H + 30) {
drains += 1;
respawn();
}
}
function finished(): boolean { return false; }
function onControl(role: string, down: boolean): void {
if (role === 'flip') {
flipper.pressed = down;
pressMs = 0;
coolMs = 0;
}
}
function draw(): void {
clearStage();
for (let i = 0; i < walls.length; i += 1) { strokeSeg(walls[i], 4, CREAM, 0.5); }
for (let i = 0; i < lamps.length; i += 1) {
const l = lamps[i];
if (l.glow > 0.02) {
ctx.fillStyle = rgba(LEMON, 0.45 * l.glow);
ctx.beginPath();
ctx.arc(l.x, l.y, LAMP_R + 14 * l.glow, 0, TAU);
ctx.fill();
}
ctx.fillStyle = rgba(mixRGB(LILAC, LEMON, l.glow), 1);
ctx.beginPath();
ctx.arc(l.x, l.y, LAMP_R, 0, TAU);
ctx.fill();
}
drawTrail(ball, PEACH, ball.r * 1.3);
drawFlipper(flipper, MINT);
// 맞은 자리 — 접점에서 퍼지는 고리
if (hitFade > 0.05) {
ctx.strokeStyle = rgba(LEMON, hitFade);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(hitX, hitY, 6 + (1 - hitFade) * 22, 0, TAU);
ctx.stroke();
}
drawBall(ball);
drawBand();
}
function stateText(): string {
if (hitBall > 0) { return '축에서 ' + Math.round(hitR) + 'px · 접점 ' + Math.round(hitSurf) + ' → 공 ' + Math.round(hitBall) + 'px/s'; }
return '공이 플리퍼로 굴러오는 중';
}
// ── 공통 꼬리: 시계·버튼·동작 줄이기·정리 ──
// 결과를 보여 준 시간(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">공이 플리퍼로 굴러오는 중</p>
<div className={cx("pb__controls")}>
<button type="button" className={cx("pb__btn")} data-role="flip" data-hold="1">플리퍼</button>
</div>
</div>
</div>
);
}02 도는 빠르기를 공에 실어 쏘는 플리퍼 — 측정 누적면적 12.542% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 핀볼 뽑기 9종 — 이름 공 추첨판·플리퍼·범퍼·구슬 레이스
이 데모가 속한 글
핀볼 뽑기 9종 — 이름 공 추첨판·플리퍼·범퍼·구슬 레이스핀볼 뽑기는 이름을 단 공을 핀·막대·범퍼 사이로 굴려 당첨을 정하는 캔버스 추첨판이다. 공을 같은 자리에서 떨어뜨리고 칸마다 이름을 붙이면 왜 가운데 이름만 자꾸 당첨될까? 08번은 떨어진 칸을 쌓아 종 모양을 드 이 글의 데모 전부 보기


