javascript ピンボール 9種 — 抽選盤・フリッパー・バンパー・ビー玉レース
javascript ピンボールは、名前を付けたボールをピンや棒やバンパーの間に転がして当選を決める、キャンバスの抽選盤です。ボールを同じ所から落として枠ごとに名前を付けると、なぜ真ん中の名前ばかり当たるのでしょうか。
抽選盤からミニテーブルまで — ボールが転がる九つの舞台
01 pb-name-ball-peg-drop-picker
02 pb-flipper-angular-kick
03 pb-pop-bumper-impulse-flash
04 pb-slingshot-rubber-kicker
05 pb-plunger-spring-charge-launch
06 pb-windmill-rotor-obstacle
07 pb-marble-race-ramp-rank
08 pb-landing-slot-bell-histogram
09 pb-mini-table-score-hero
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 名前のボールがピンの間を落ちる抽選盤
- 02 回る速さをボールに乗せて打ち出すフリッパー
- 03 触れた瞬間に決まった強さで押し返すバンパー
- 04 ゴムのようにしなって弾く三角キッカー
- 05 引いた分だけ力がたまる発射バネ
- 06 回る風車の棒に巻き込まれて弾かれるビー玉
- 07 坂を転がった着順で順位が決まるビー玉レース
- 08 落ちた枠を積み上げて釣り鐘の形が現れる分布盤
- 09 フリッパー・バンパー・スコア表示をまとめたミニピンボール台
何も触らなければ、どの枠でも同じシードの盤がひとりでに動きます。終わりのある01・07・08は結果を1,600ms見せてから同じシードで最初から転がし直し、02・09のフリッパーは、ボールが棒の上面の区域に入ると押して240ms(09は220ms)後に離す仮想の入力が代わりに打ちます。05・09の発射バネもボールが上がってくると自動で引いて離し、09は実演の1,300msに一度台を揺らしてから3,600msごとに揺らし直します。ボタンを初めて押した瞬間に実演は止まり、そこからは押したとおりに動きます。01・07・08の「もう一度抽選」と03・04・06の「もう一度転がす」は、シードを一つ上げて新しい盤を始めます。
並べ方は、ボールが受ける力が一種類ずつ増えるようにしました。01は止まったピンにぶつかるだけで、02は動く棒が自分の速さをボールに乗せ、03は触れた瞬間に決まった衝撃を、04は斜辺で上乗せの力を、05は引いた長さに比例した力を与えます。06は休まず回る棒、07は坂と転がり摩擦で順位を分け、08はボール一つではなく60個が積もる形で確率を見せ、09は02・03・05を一台にまとめます。
javascript ピンボール 9種 — 名前ボールの抽選盤・フリッパー・バンパー・三角キッカー・発射バネ・風車の棒・ビー玉レース・分布盤・ミニピンボール台
九つの舞台は、キャンバス2DとJavaScriptだけで動いています。物理エンジン・ライブラリ・WebGL・CDN・画像ファイルは一つもなく、乱数もMath.randomではなく32ビット整数をかき混ぜるmakeRngの一か所からしか出てきません。画面の更新の合図(requestAnimationFrame の解説)は16msの計算ティックに置き換えて積み、舞台ごとにその1ティックをさらに4〜6歩(SUBSTEPS)に刻んで衝突を測ります。
480×300の窓で撮った24枚と、隣り合う23回の差分
各舞台を480×300の窓で2秒レンダーし、直前の1枚と比べて一度でも色が変わった画素が舞台に占めた広さを累積面積、23回の比較のうち画面が変わった回数を動いたフレームとして記録しました。
| 舞台 | 累積面積 | 動いたフレーム | 強度の平均 |
|---|---|---|---|
| 01 名前ボールの抽選盤 | 19.1% | 23/23 | 99.9 |
| 02 フリッパー | 12.5% | 23/23 | 98.5 |
| 03 バンパー | 21.5% | 23/23 | 83.1 |
| 04 三角キッカー | 16.8% | 23/23 | 97.3 |
| 05 発射バネ | 8.5% | 23/23 | 98.7 |
| 06 風車の棒 | 28.8% | 23/23 | 103.9 |
| 07 ビー玉レース | 14.2% | 23/23 | 72.4 |
| 08 分布盤 | 8.8% | 23/23 | 82.7 |
| 09 ミニピンボール台 | 14.3% | 23/23 | 85.5 |
九つとも、23回の比較のたびに画面のどこかが変わっていました。06が28.8%でいちばん広いのは、羽根三枚の棒二本が休まず回って7個のビー玉を舞台のあちこちへ打ち出すためと考えられ、05が8.5%でいちばん狭いのは、バネを引いて離すあいだ舞台の大半が止まっているためと考えられます。
配信の視聴者抽選からチーム発表の順番決めまで、置き場所
配信画面に視聴者抽選を出すなら、01がいちばん近い形です。ゴール線を先に越えたボールを当選として読むので、見ている人の誰もが結果を目で追え、いまA〜Hの文字が入っている8個のボールの名札を参加者の名前に替えれば使えます。一人だけを手早く決める場面なら、web ルーレットの回転盤のほうが単純で済むかもしれません。
集まりで発表の順番を決めるなら07が向いています。一人だけが残る01と違い、順位表が6行すべてをゴールした順とタイムで残すからです。08は結果より過程を見せたいときに使い、真ん中へ集まる偏りを先に描いてから名前を混ぜて割り当てるので、景品抽選の案内ページで「枠の位置で有利にはならない」という説明を図が代わりに引き受けます。ひとりで遊べる「ピンボール ブラウザゲーム」をページに一台置きたいなら、フリッパー・バンパー・発射バネを一台に組んだ09がそのまま土台になります。
実際の抽選に移すとき、替えるべきものが一つあります。デモはシード固定なので結果が再現されます — 実際の抽選に使うときは、シードを外部の入力から受け取る必要があります。シード4761の盤は何度開き直してもAが1,040msで当選し、「もう一度抽選」もseed += 1のあとでrestart()を呼ぶだけなので、4761の次の盤は4762と決まっています。
01名前のボールがピンの間を落ちる抽選盤
A〜Hの名札を付けた8個のボールが66本のピンの間を落ち、ピンと重なった深さの分だけ押し出され、近づいていた法線方向の速度だけが0.5倍で裏返って跳ねます。ボール全体がゴール線(y 238)を先に越えたものが当選なので、配信の視聴者抽選画面では誰が先に入ったかをそのまま結果に使えます。シード4761の盤ではAが1,040msで当選し、ピンに127回触れたあと5,616msで8個すべてが枠に収まります。

<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">抽選中 — ボール 8個が落ちていく</p>
<div class="pb__controls">
<button type="button" class="pb__btn" data-role="redraw">もう一度抽選</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);
}
// 01 이름 공 추첨판 — 엇갈린 핀 숲 + 원-원 충돌(겹친 만큼 밀어내기·되튐 계수) + 서브스텝 + 시드 고정 난수
// 핀 줄 수 — 짝수 줄 9個·홀수 줄 8個, 줄마다 반 칸씩 어긋난다
const PEG_ROWS = 7;
// 되튐 계수 — 닿기 전 법선 방향 빠르기 가운데 되돌려 받는 몫(0 이면 붙고 1 이면 그대로 튄다). 핀·벽·공끼리 같은 값
const RESTITUTION = 0.5;
// 한 틱(16ms)을 몇 번으로 잘게 나눠 계산하나 — 적으면 빠른 공이 한 걸음에 핀을 많이 파고든다
const SUBSTEPS = 4;
const GRAVITY = 760;
const BALL_COUNT = 8;
const BALL_R = 8;
const PEG_R = 4;
const PEG_GAP_X = 44;
const PEG_GAP_Y = 22;
const PEG_TOP = 74;
// 벽 쪽 덧핀 자리 — 벽에서 핀 중심까지(px). 틈(14 - 핀 반지름 4 = 10px)이 공 지름(16px)보다 좁아 벽 타기를 막는다
const PEG_WALL_GAP = 14;
const WALL_L = 24;
const WALL_R = 456;
// 결승선 — 공 전체가 이 아래로 내려가면 지난 것으로 센다. 맨 먼저 지난 공이 당첨
const FINISH_Y = 238;
const FLOOR_Y = 292;
const SLOTS = 8;
// 끼임 — 결승선 위에서 이 빠르기(px/s) 아래로 STUCK_MS 동안 머물면 끼었다고 보고 좌우 한쪽으로 쳐 준다
const STUCK_SPEED = 12;
const STUCK_MS = 500;
const STUCK_KICK = 90;
const MAX_SPEED = 1400;
const MAX_RUN_MS = 14000;
const DEMO_SEED = 4761;
const SETTLE_TICKS = 1200;
const MID_X = (WALL_L + WALL_R) / 2;
const pegs = [];
for (let r = 0; r < PEG_ROWS; r += 1) {
const count = r % 2 === 0 ? 9 : 8;
for (let k = 0; k < count; k += 1) {
pegs.push({ x: MID_X + (k - (count - 1) / 2) * PEG_GAP_X, y: PEG_TOP + r * PEG_GAP_Y, glow: 0 });
}
// 홀수 줄은 벽 쪽 틈이 넓어 공이 벽을 타고 핀 없이 곧장 떨어진다 — 벽에서 PEG_WALL_GAP 떨어진 자리에 핀을 하나씩 더 박는다
if (r % 2 === 1) {
pegs.push({ x: WALL_L + PEG_WALL_GAP, y: PEG_TOP + r * PEG_GAP_Y, glow: 0 });
pegs.push({ x: WALL_R - PEG_WALL_GAP, y: PEG_TOP + r * PEG_GAP_Y, glow: 0 });
}
}
const SLOT_W = (WALL_R - WALL_L) / SLOTS;
const walls = [[WALL_L, HEADER, WALL_L, FLOOR_Y], [WALL_R, HEADER, WALL_R, FLOOR_Y], [WALL_L, FLOOR_Y, WALL_R, FLOOR_Y]];
// 칸 칸막이 — 결승선 조금 아래부터 바닥까지
const dividers = [];
for (let i = 1; i < SLOTS; i += 1) {
dividers.push([WALL_L + i * SLOT_W, FINISH_Y + 8, WALL_L + i * SLOT_W, FLOOR_Y]);
}
let rng = makeRng(1);
let balls = [];
// 결승선을 지난 순서(공 번호)
let order = [];
let stuckCount = 0;
let calmTicks = 0;
function slotOf(b) { return clamp(Math.floor((b.x - WALL_L) / SLOT_W), 0, SLOTS - 1); }
function onRestart() {
rng = makeRng(seed);
balls = [];
for (let i = 0; i < BALL_COUNT; i += 1) {
// 출발 자리 — 가운데에 한 줄로 모아 두고, 시드 난수로 몇 px 씩 비켜 놓는다(이 몇 px 이 결과를 가른다)
const b = makeBall(MID_X + (i - (BALL_COUNT - 1) / 2) * 21 + (rng() - 0.5) * 6, 46 + (rng() - 0.5) * 4, BALL_R, BALL_COLORS[i % BALL_COLORS.length], LABELS[i]);
b.vx = (rng() - 0.5) * 60;
balls.push(b);
}
order = [];
stuckCount = 0;
calmTicks = 0;
for (let i = 0; i < pegs.length; i += 1) {
pegs[i].glow = 0;
}
}
function collideWorld(b) {
for (let i = 0; i < pegs.length; i += 1) {
const p = pegs[i];
// 세로로 먼 핀은 건너뛴다
if (Math.abs(p.y - b.y) > b.r + PEG_R) {
continue;
}
const vn = hitCircle(b, p.x, p.y, PEG_R, RESTITUTION);
if (vn > 0) {
p.glow = Math.min(1, p.glow + vn / 500);
}
}
hitWalls(b, walls, RESTITUTION);
for (let i = 0; i < dividers.length; i += 1) {
const s = dividers[i];
hitSegment(b, s[0], s[1], s[2], s[3], 1.5, RESTITUTION, 0, 0, 0);
}
}
// 한 틱 — 16ms 를 SUBSTEPS 걸음으로 잘라: 적분 → 공끼리 → 핀·벽·칸막이. 걸음이 작을수록 한 번에 파고드는 깊이가 얕다
function step() {
const hs = DT / SUBSTEPS;
for (let s = 0; s < SUBSTEPS; s += 1) {
for (let i = 0; i < balls.length; i += 1) {
integrate(balls[i], 0, GRAVITY, hs);
}
for (let i = 0; i < balls.length; i += 1) {
for (let j = i + 1; j < balls.length; j += 1) {
hitBalls(balls[i], balls[j], RESTITUTION);
}
}
for (let i = 0; i < balls.length; i += 1) {
collideWorld(balls[i]);
capSpeed(balls[i], MAX_SPEED);
}
}
let calm = true;
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
const sp = Math.hypot(b.vx, b.vy);
if (b.finishMs < 0 && b.y - b.r > FINISH_Y) {
b.finishMs = clock;
order.push(i);
}
if (b.finishMs < 0 && sp < STUCK_SPEED) {
b.slow += FIXED_DT;
if (b.slow >= STUCK_MS) {
// 끼임 탈출 — 시드 난수로 좌우를 골라 옆으로 치고 살짝 띄운다
b.vx += (rng() < 0.5 ? -1 : 1) * STUCK_KICK;
b.vy -= STUCK_KICK * 0.3;
b.slow = 0;
stuckCount += 1;
}
}
else {
b.slow = 0;
}
if (b.finishMs < 0 || sp > 20) {
calm = false;
}
pushTrail(b, 14);
}
calmTicks = calm ? calmTicks + 1 : 0;
for (let i = 0; i < pegs.length; i += 1) {
pegs[i].glow *= 0.9;
}
}
function finished() { return calmTicks >= 20 || clock >= MAX_RUN_MS; }
function onControl(role, down) {
// もう一度抽選 — 시드를 하나 올려 출발 자리의 몇 px 이 달라진다
if (role === 'redraw' && down) {
seed += 1;
restart();
}
}
function draw() {
clearStage();
const win = order.length ? balls[order[0]] : null;
for (let k = 0; k < SLOTS; k += 1) {
const lit = win !== null && slotOf(win) === k;
ctx.fillStyle = lit ? rgba(LEMON, 0.34) : rgba(GUIDE, k % 2 ? 0.14 : 0.22);
ctx.fillRect(WALL_L + k * SLOT_W, FINISH_Y + 8, SLOT_W, FLOOR_Y - FINISH_Y - 8);
}
for (let i = 0; i < walls.length; i += 1) {
strokeSeg(walls[i], 3, CREAM, 0.45);
}
for (let i = 0; i < dividers.length; i += 1) {
strokeSeg(dividers[i], 3, CREAM, 0.5);
}
ctx.setLineDash([6, 6]);
strokeSeg([WALL_L, FINISH_Y, WALL_R, FINISH_Y], 2, LEMON, 0.8);
ctx.setLineDash([]);
for (let i = 0; i < pegs.length; i += 1) {
const p = pegs[i];
// 맞은 핀 — 다가오던 빠르기만큼 빛이 번졌다가 틱마다 0.9 배로 잦아든다
if (p.glow > 0.02) {
ctx.fillStyle = rgba(LILAC, 0.5 * p.glow);
ctx.beginPath();
ctx.arc(p.x, p.y, PEG_R + 8 * p.glow, 0, TAU);
ctx.fill();
}
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(p.x, p.y, PEG_R, 0, TAU);
ctx.fill();
}
for (let i = 0; i < balls.length; i += 1) {
drawTrail(balls[i], balls[i].color, balls[i].r * 1.2);
}
for (let i = 0; i < balls.length; i += 1) {
drawBall(balls[i]);
}
if (win) {
ctx.strokeStyle = rgba(LEMON, 0.95);
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(win.x, win.y, win.r + 4 + Math.sin(clock / 90) * 1.5, 0, TAU);
ctx.stroke();
}
drawBand();
}
function stateText() {
if (order.length) {
return '当選: ' + balls[order[0]].label;
}
return '抽選中 — ボール ' + BALL_COUNT + '個が落ちていく';
}
// ── 공통 꼬리: 시계·버튼·동작 줄이기·정리 ──
// 결과를 보여 준 시간(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();
};
})();// NameBallPegDropPicker - vanilla 판(01-pb-name-ball-peg-drop-picker) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./NameBallPegDropPicker.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function NameBallPegDropPicker() {
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);
}
// 01 이름 공 추첨판 — 엇갈린 핀 숲 + 원-원 충돌(겹친 만큼 밀어내기·되튐 계수) + 서브스텝 + 시드 고정 난수
// 핀 줄 수 — 짝수 줄 9개·홀수 줄 8개, 줄마다 반 칸씩 어긋난다
const PEG_ROWS = 7;
// 되튐 계수 — 닿기 전 법선 방향 빠르기 가운데 되돌려 받는 몫(0 이면 붙고 1 이면 그대로 튄다). 핀·벽·공끼리 같은 값
const RESTITUTION = 0.5;
// 한 틱(16ms)을 몇 번으로 잘게 나눠 계산하나 — 적으면 빠른 공이 한 걸음에 핀을 많이 파고든다
const SUBSTEPS = 4;
const GRAVITY = 760;
const BALL_COUNT = 8;
const BALL_R = 8;
const PEG_R = 4;
const PEG_GAP_X = 44;
const PEG_GAP_Y = 22;
const PEG_TOP = 74;
// 벽 쪽 덧핀 자리 — 벽에서 핀 중심까지(px). 틈(14 - 핀 반지름 4 = 10px)이 공 지름(16px)보다 좁아 벽 타기를 막는다
const PEG_WALL_GAP = 14;
const WALL_L = 24;
const WALL_R = 456;
// 결승선 — 공 전체가 이 아래로 내려가면 지난 것으로 센다. 맨 먼저 지난 공이 당첨
const FINISH_Y = 238;
const FLOOR_Y = 292;
const SLOTS = 8;
// 끼임 — 결승선 위에서 이 빠르기(px/s) 아래로 STUCK_MS 동안 머물면 끼었다고 보고 좌우 한쪽으로 쳐 준다
const STUCK_SPEED = 12;
const STUCK_MS = 500;
const STUCK_KICK = 90;
const MAX_SPEED = 1400;
const MAX_RUN_MS = 14000;
const DEMO_SEED = 4761;
const SETTLE_TICKS = 1200;
const MID_X = (WALL_L + WALL_R) / 2;
const pegs: Peg[] = [];
for (let r = 0; r < PEG_ROWS; r += 1) {
const count = r % 2 === 0 ? 9 : 8;
for (let k = 0; k < count; k += 1) {
pegs.push({ x: MID_X + (k - (count - 1) / 2) * PEG_GAP_X, y: PEG_TOP + r * PEG_GAP_Y, glow: 0 });
}
// 홀수 줄은 벽 쪽 틈이 넓어 공이 벽을 타고 핀 없이 곧장 떨어진다 — 벽에서 PEG_WALL_GAP 떨어진 자리에 핀을 하나씩 더 박는다
if (r % 2 === 1) {
pegs.push({ x: WALL_L + PEG_WALL_GAP, y: PEG_TOP + r * PEG_GAP_Y, glow: 0 });
pegs.push({ x: WALL_R - PEG_WALL_GAP, y: PEG_TOP + r * PEG_GAP_Y, glow: 0 });
}
}
const SLOT_W = (WALL_R - WALL_L) / SLOTS;
const walls: Seg[] = [[WALL_L, HEADER, WALL_L, FLOOR_Y], [WALL_R, HEADER, WALL_R, FLOOR_Y], [WALL_L, FLOOR_Y, WALL_R, FLOOR_Y]];
// 칸 칸막이 — 결승선 조금 아래부터 바닥까지
const dividers: Seg[] = [];
for (let i = 1; i < SLOTS; i += 1) { dividers.push([WALL_L + i * SLOT_W, FINISH_Y + 8, WALL_L + i * SLOT_W, FLOOR_Y]); }
let rng = makeRng(1);
let balls: Ball[] = [];
// 결승선을 지난 순서(공 번호)
let order: number[] = [];
let stuckCount = 0;
let calmTicks = 0;
function slotOf(b: Ball): number { return clamp(Math.floor((b.x - WALL_L) / SLOT_W), 0, SLOTS - 1); }
function onRestart(): void {
rng = makeRng(seed);
balls = [];
for (let i = 0; i < BALL_COUNT; i += 1) {
// 출발 자리 — 가운데에 한 줄로 모아 두고, 시드 난수로 몇 px 씩 비켜 놓는다(이 몇 px 이 결과를 가른다)
const b = makeBall(MID_X + (i - (BALL_COUNT - 1) / 2) * 21 + (rng() - 0.5) * 6, 46 + (rng() - 0.5) * 4, BALL_R, BALL_COLORS[i % BALL_COLORS.length], LABELS[i]);
b.vx = (rng() - 0.5) * 60;
balls.push(b);
}
order = [];
stuckCount = 0;
calmTicks = 0;
for (let i = 0; i < pegs.length; i += 1) { pegs[i].glow = 0; }
}
function collideWorld(b: Ball): void {
for (let i = 0; i < pegs.length; i += 1) {
const p = pegs[i];
// 세로로 먼 핀은 건너뛴다
if (Math.abs(p.y - b.y) > b.r + PEG_R) { continue; }
const vn = hitCircle(b, p.x, p.y, PEG_R, RESTITUTION);
if (vn > 0) { p.glow = Math.min(1, p.glow + vn / 500); }
}
hitWalls(b, walls, RESTITUTION);
for (let i = 0; i < dividers.length; i += 1) {
const s = dividers[i];
hitSegment(b, s[0], s[1], s[2], s[3], 1.5, RESTITUTION, 0, 0, 0);
}
}
// 한 틱 — 16ms 를 SUBSTEPS 걸음으로 잘라: 적분 → 공끼리 → 핀·벽·칸막이. 걸음이 작을수록 한 번에 파고드는 깊이가 얕다
function step(): void {
const hs = DT / SUBSTEPS;
for (let s = 0; s < SUBSTEPS; s += 1) {
for (let i = 0; i < balls.length; i += 1) { integrate(balls[i], 0, GRAVITY, hs); }
for (let i = 0; i < balls.length; i += 1) {
for (let j = i + 1; j < balls.length; j += 1) { hitBalls(balls[i], balls[j], RESTITUTION); }
}
for (let i = 0; i < balls.length; i += 1) {
collideWorld(balls[i]);
capSpeed(balls[i], MAX_SPEED);
}
}
let calm = true;
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
const sp = Math.hypot(b.vx, b.vy);
if (b.finishMs < 0 && b.y - b.r > FINISH_Y) {
b.finishMs = clock;
order.push(i);
}
if (b.finishMs < 0 && sp < STUCK_SPEED) {
b.slow += FIXED_DT;
if (b.slow >= STUCK_MS) {
// 끼임 탈출 — 시드 난수로 좌우를 골라 옆으로 치고 살짝 띄운다
b.vx += (rng() < 0.5 ? -1 : 1) * STUCK_KICK;
b.vy -= STUCK_KICK * 0.3;
b.slow = 0;
stuckCount += 1;
}
} else {
b.slow = 0;
}
if (b.finishMs < 0 || sp > 20) { calm = false; }
pushTrail(b, 14);
}
calmTicks = calm ? calmTicks + 1 : 0;
for (let i = 0; i < pegs.length; i += 1) { pegs[i].glow *= 0.9; }
}
function finished(): boolean { return calmTicks >= 20 || clock >= MAX_RUN_MS; }
function onControl(role: string, down: boolean): void {
// 다시 추첨 — 시드를 하나 올려 출발 자리의 몇 px 이 달라진다
if (role === 'redraw' && down) {
seed += 1;
restart();
}
}
function draw(): void {
clearStage();
const win = order.length ? balls[order[0]] : null;
for (let k = 0; k < SLOTS; k += 1) {
const lit = win !== null && slotOf(win) === k;
ctx.fillStyle = lit ? rgba(LEMON, 0.34) : rgba(GUIDE, k % 2 ? 0.14 : 0.22);
ctx.fillRect(WALL_L + k * SLOT_W, FINISH_Y + 8, SLOT_W, FLOOR_Y - FINISH_Y - 8);
}
for (let i = 0; i < walls.length; i += 1) { strokeSeg(walls[i], 3, CREAM, 0.45); }
for (let i = 0; i < dividers.length; i += 1) { strokeSeg(dividers[i], 3, CREAM, 0.5); }
ctx.setLineDash([6, 6]);
strokeSeg([WALL_L, FINISH_Y, WALL_R, FINISH_Y], 2, LEMON, 0.8);
ctx.setLineDash([]);
for (let i = 0; i < pegs.length; i += 1) {
const p = pegs[i];
// 맞은 핀 — 다가오던 빠르기만큼 빛이 번졌다가 틱마다 0.9 배로 잦아든다
if (p.glow > 0.02) {
ctx.fillStyle = rgba(LILAC, 0.5 * p.glow);
ctx.beginPath();
ctx.arc(p.x, p.y, PEG_R + 8 * p.glow, 0, TAU);
ctx.fill();
}
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(p.x, p.y, PEG_R, 0, TAU);
ctx.fill();
}
for (let i = 0; i < balls.length; i += 1) { drawTrail(balls[i], balls[i].color, balls[i].r * 1.2); }
for (let i = 0; i < balls.length; i += 1) { drawBall(balls[i]); }
if (win) {
ctx.strokeStyle = rgba(LEMON, 0.95);
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(win.x, win.y, win.r + 4 + Math.sin(clock / 90) * 1.5, 0, TAU);
ctx.stroke();
}
drawBand();
}
function stateText(): string {
if (order.length) { return '당첨: ' + balls[order[0]].label; }
return '추첨 중 — 공 ' + BALL_COUNT + '개가 떨어진다';
}
// ── 공통 꼬리: 시계·버튼·동작 줄이기·정리 ──
// 결과를 보여 준 시간(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">추첨 중 — 공 8개가 떨어진다</p>
<div className={cx("pb__controls")}>
<button type="button" className={cx("pb__btn")} data-role="redraw">다시 추첨</button>
</div>
</div>
</div>
);
}01 · 名前のボールがピンの間を落ちる抽選盤 — 計測 累積面積 19.085% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
02回る速さをボールに乗せて打ち出すフリッパー
棒の上でボールにいちばん近い点を探し、軸からその点までのベクトルを90°回して角速度を掛けた値を接点の速度として渡し、跳ね返りを測ります。先端の接点は12ラジアン/秒 × 86pxで1,032px/sになり、一振りに77msかかるので、棒の上面に載せたボールは軸から長さの0.3の位置なら815px/s、先端なら1,496px/sで飛びます。ブラウザミニゲームのイベントページでは、上面の区域に入ったら押すという実演の規則を人の手に置き換えることになり、この規則は時刻表ではなく区域で決まっているので、シードが同じなら同じ瞬間に打ちます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 1歩(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;
}
03触れた瞬間に決まった強さで押し返すバンパー
ボールを円どうしの衝突で0.5倍に跳ね返したあと、触れた法線の向きへBUMPER_KICKの320px/sを足すので、真上から100・300・600px/sでまっすぐ入ったボールは370・470・620px/sで出ていきます。バンパーの半径は絵の中だけでばねとして動き、当たったあと11.8%ふくらんでから−6.8%まで行き過ぎて縮み、480msで±1%以内に落ち着きますが、当たり判定は半径24のままです。アプリ公開記念のイベントバナーのようにボールが何個か休まず跳ね続けてほしい場所に合い、実演の10秒間で4つのバンパーは合わせて17回当たりました。

<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="redraw">もう一度転がす</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 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();
}
// 03 バンパー — 닿은 방향 고정 충격량 + 반지름 스프링 오버슛
// 닿은 방향(법선)으로 더하는 빠르기(px/s) — 들어온 빠르기와 상관없이 같은 세기
const BUMPER_KICK = 320;
// バンパー 반지름(px) — 맞는 자리. 부풀어 보이는 것은 그림만
const BUMPER_RADIUS = 24;
// 반지름 스프링의 되당김 세기(1/s²) — 클수록 빨리 떨린다
const BUMPER_STIFFNESS = 900;
const BUMPER_DAMPING = 10;
// 맞는 순간 부풀기 빠르기에 더하는 값(1/s)
const BUMPER_POP = 5;
const KICKER_R = 16;
const GRAVITY = 520;
const BALL_COUNT = 3;
const BALL_R = 8;
const RESTITUTION = 0.7;
const BUMPER_RESTITUTION = 0.5;
const MAX_SPEED = 1000;
const SUBSTEPS = 4;
const DEMO_SEED = 4763;
const SETTLE_TICKS = 180;
const walls = [
[40, 80, 40, 246], [40, 80, 82, 38], [82, 38, 398, 38], [398, 38, 440, 80], [440, 80, 440, 246],
// 깔때기 바닥 — 공을 가운데 아래 작은 バンパー(되차기)로 모은다
[40, 246, 222, 288], [440, 246, 258, 288]
];
const bumpers = [
makeBumper(168, 112, BUMPER_RADIUS, BUMPER_KICK, BUMPER_STIFFNESS, BUMPER_DAMPING, BUMPER_POP),
makeBumper(312, 112, BUMPER_RADIUS, BUMPER_KICK, BUMPER_STIFFNESS, BUMPER_DAMPING, BUMPER_POP),
makeBumper(240, 186, BUMPER_RADIUS, BUMPER_KICK, BUMPER_STIFFNESS, BUMPER_DAMPING, BUMPER_POP),
// 바닥 가운데 되차기 — 같은 함수, 반지름만 작다
makeBumper(240, 292, KICKER_R, BUMPER_KICK, BUMPER_STIFFNESS, BUMPER_DAMPING, BUMPER_POP)
];
let sparks = [];
let rng = makeRng(1);
let balls = [];
function totalHits() {
let n = 0;
for (let i = 0; i < bumpers.length; i += 1) {
n += bumpers[i].hits;
}
return n;
}
function onRestart() {
rng = makeRng(seed);
balls = [];
for (let i = 0; i < BALL_COUNT; i += 1) {
const b = makeBall(120 + i * 120 + (rng() - 0.5) * 40, 52 + rng() * 10, BALL_R, BALL_COLORS[(i * 2) % BALL_COLORS.length], '');
b.vx = (rng() - 0.5) * 160;
balls.push(b);
}
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;
}
sparks = [];
}
function step() {
const hs = DT / SUBSTEPS;
for (let s = 0; s < SUBSTEPS; s += 1) {
for (let i = 0; i < balls.length; i += 1) {
integrate(balls[i], 0, GRAVITY, hs);
}
for (let i = 0; i < balls.length; i += 1) {
for (let j = i + 1; j < balls.length; j += 1) {
hitBalls(balls[i], balls[j], RESTITUTION);
}
}
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
for (let k = 0; k < bumpers.length; k += 1) {
if (hitBumper(b, bumpers[k], BUMPER_RESTITUTION) > 0) {
sparks.push({ x: contact.px, y: contact.py, nx: contact.nx, ny: contact.ny, age: 0 });
}
}
hitWalls(b, walls, RESTITUTION);
capSpeed(b, MAX_SPEED);
}
for (let k = 0; k < bumpers.length; k += 1) {
springBumper(bumpers[k], hs);
}
}
for (let i = 0; i < balls.length; i += 1) {
pushTrail(balls[i], 16);
}
for (let i = 0; i < sparks.length; i += 1) {
sparks[i].age += DT;
}
sparks = sparks.filter(function (p) { return p.age < 0.45; });
}
function finished() { return false; }
function onControl(role, down) {
if (role === 'redraw' && down) {
seed += 1;
restart();
}
}
function draw() {
clearStage();
for (let i = 0; i < walls.length; i += 1) {
strokeSeg(walls[i], 4, CREAM, 0.5);
}
// 맞은 자리 — 법선 방향으로 뻗는 짧은 빛살
for (let i = 0; i < sparks.length; i += 1) {
const p = sparks[i];
const k = 1 - p.age / 0.45;
ctx.strokeStyle = rgba(LEMON, k);
ctx.lineWidth = 3 * k + 1;
ctx.beginPath();
ctx.moveTo(p.x + p.nx * 4, p.y + p.ny * 4);
ctx.lineTo(p.x + p.nx * (10 + 30 * (1 - k)), p.y + p.ny * (10 + 30 * (1 - k)));
ctx.stroke();
}
for (let i = 0; i < balls.length; i += 1) {
drawTrail(balls[i], balls[i].color, balls[i].r * 1.3);
}
for (let k = 0; k < bumpers.length; k += 1) {
drawBumper(bumpers[k], k === 3 ? MINT : (k === 2 ? LILAC : PINK), k === 3 ? AQUA : PEACH);
}
for (let i = 0; i < balls.length; i += 1) {
drawBall(balls[i]);
}
drawBand();
}
function stateText() {
return 'バンパー ' + totalHits() + '回 当たった';
}
// ── 공통 꼬리: 시계·버튼·동작 줄이기·정리 ──
// 결과를 보여 준 시간(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();
};
})();// PopBumperImpulseFlash - vanilla 판(03-pb-pop-bumper-impulse-flash) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./PopBumperImpulseFlash.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PopBumperImpulseFlash() {
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);
}
// 범퍼 — 둥근 기둥. 닿은 공을 되튐 계수로 튕긴 뒤, 닿은 방향(법선)으로 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();
}
// 03 범퍼 — 닿은 방향 고정 충격량 + 반지름 스프링 오버슛
// 닿은 방향(법선)으로 더하는 빠르기(px/s) — 들어온 빠르기와 상관없이 같은 세기
const BUMPER_KICK = 320;
// 범퍼 반지름(px) — 맞는 자리. 부풀어 보이는 것은 그림만
const BUMPER_RADIUS = 24;
// 반지름 스프링의 되당김 세기(1/s²) — 클수록 빨리 떨린다
const BUMPER_STIFFNESS = 900;
const BUMPER_DAMPING = 10;
// 맞는 순간 부풀기 빠르기에 더하는 값(1/s)
const BUMPER_POP = 5;
const KICKER_R = 16;
const GRAVITY = 520;
const BALL_COUNT = 3;
const BALL_R = 8;
const RESTITUTION = 0.7;
const BUMPER_RESTITUTION = 0.5;
const MAX_SPEED = 1000;
const SUBSTEPS = 4;
const DEMO_SEED = 4763;
const SETTLE_TICKS = 180;
const walls: Seg[] = [
[40, 80, 40, 246], [40, 80, 82, 38], [82, 38, 398, 38], [398, 38, 440, 80], [440, 80, 440, 246],
// 깔때기 바닥 — 공을 가운데 아래 작은 범퍼(되차기)로 모은다
[40, 246, 222, 288], [440, 246, 258, 288]
];
const bumpers: Bumper[] = [
makeBumper(168, 112, BUMPER_RADIUS, BUMPER_KICK, BUMPER_STIFFNESS, BUMPER_DAMPING, BUMPER_POP),
makeBumper(312, 112, BUMPER_RADIUS, BUMPER_KICK, BUMPER_STIFFNESS, BUMPER_DAMPING, BUMPER_POP),
makeBumper(240, 186, BUMPER_RADIUS, BUMPER_KICK, BUMPER_STIFFNESS, BUMPER_DAMPING, BUMPER_POP),
// 바닥 가운데 되차기 — 같은 함수, 반지름만 작다
makeBumper(240, 292, KICKER_R, BUMPER_KICK, BUMPER_STIFFNESS, BUMPER_DAMPING, BUMPER_POP)
];
type Spark = { x: number; y: number; nx: number; ny: number; age: number };
let sparks: Spark[] = [];
let rng = makeRng(1);
let balls: Ball[] = [];
function totalHits(): number {
let n = 0;
for (let i = 0; i < bumpers.length; i += 1) { n += bumpers[i].hits; }
return n;
}
function onRestart(): void {
rng = makeRng(seed);
balls = [];
for (let i = 0; i < BALL_COUNT; i += 1) {
const b = makeBall(120 + i * 120 + (rng() - 0.5) * 40, 52 + rng() * 10, BALL_R, BALL_COLORS[(i * 2) % BALL_COLORS.length], '');
b.vx = (rng() - 0.5) * 160;
balls.push(b);
}
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;
}
sparks = [];
}
function step(): void {
const hs = DT / SUBSTEPS;
for (let s = 0; s < SUBSTEPS; s += 1) {
for (let i = 0; i < balls.length; i += 1) { integrate(balls[i], 0, GRAVITY, hs); }
for (let i = 0; i < balls.length; i += 1) {
for (let j = i + 1; j < balls.length; j += 1) { hitBalls(balls[i], balls[j], RESTITUTION); }
}
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
for (let k = 0; k < bumpers.length; k += 1) {
if (hitBumper(b, bumpers[k], BUMPER_RESTITUTION) > 0) {
sparks.push({ x: contact.px, y: contact.py, nx: contact.nx, ny: contact.ny, age: 0 });
}
}
hitWalls(b, walls, RESTITUTION);
capSpeed(b, MAX_SPEED);
}
for (let k = 0; k < bumpers.length; k += 1) { springBumper(bumpers[k], hs); }
}
for (let i = 0; i < balls.length; i += 1) { pushTrail(balls[i], 16); }
for (let i = 0; i < sparks.length; i += 1) { sparks[i].age += DT; }
sparks = sparks.filter(function (p: Spark): boolean { return p.age < 0.45; });
}
function finished(): boolean { return false; }
function onControl(role: string, down: boolean): void {
if (role === 'redraw' && down) {
seed += 1;
restart();
}
}
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 < sparks.length; i += 1) {
const p = sparks[i];
const k = 1 - p.age / 0.45;
ctx.strokeStyle = rgba(LEMON, k);
ctx.lineWidth = 3 * k + 1;
ctx.beginPath();
ctx.moveTo(p.x + p.nx * 4, p.y + p.ny * 4);
ctx.lineTo(p.x + p.nx * (10 + 30 * (1 - k)), p.y + p.ny * (10 + 30 * (1 - k)));
ctx.stroke();
}
for (let i = 0; i < balls.length; i += 1) { drawTrail(balls[i], balls[i].color, balls[i].r * 1.3); }
for (let k = 0; k < bumpers.length; k += 1) { drawBumper(bumpers[k], k === 3 ? MINT : (k === 2 ? LILAC : PINK), k === 3 ? AQUA : PEACH); }
for (let i = 0; i < balls.length; i += 1) { drawBall(balls[i]); }
drawBand();
}
function stateText(): string {
return '범퍼 ' + totalHits() + '번 맞음';
}
// ── 공통 꼬리: 시계·버튼·동작 줄이기·정리 ──
// 결과를 보여 준 시간(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="redraw">다시 굴리기</button>
</div>
</div>
</div>
);
}03 · 触れた瞬間に決まった強さで押し返すバンパー — 計測 累積面積 21.528% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// ボールとバンパー — 円と円の衝突で重なった分を押し出して跳ね返したあと、入ってきた速さに関係なく同じ強さ 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);
}
04ゴムのようにしなって弾く三角キッカー
三角形の斜辺を太さの半分が2の線分として衝突させ、近づく速さが40px/sを超え、触れた法線が斜辺の外側と同じ向きのときだけ法線 × 360px/sを上乗せします。斜辺の真ん中は絵の中でだけしなり、正面から150px/sで当たると−2.24pxしなってから+1.27px跳ね返り、292msで±0.3px以内にまっすぐ戻って、ボールは435px/sで出ていきます。ゲームサイトのトップ画面の飾りとして置くと、真ん中のすき間から落ちたボールが上からまた落ちてくるので、実演の10秒で24回弾きます。

<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="redraw">もう一度転がす</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);
}
// 04 삼각 キッカー — 빗변 선분 충돌 + 법선 방향 덧힘 + 빗변 가운데 휨의 감쇠 진동
// 빗변에 닿으면 법선 방향으로 더하는 빠르기(px/s)
const SLING_BOOST = 360;
// 빗변 가운데가 안쪽으로 휠 수 있는 최대 깊이(px)
const SLING_BEND_MAX = 9;
// 휨 진동의 저항(1/s) — 클수록 빨리 펴진다
const SLING_DAMPING = 14;
// 휨을 되펴는 세기(1/s²)
const SLING_STIFFNESS = 1600;
// 다가오던 빠르기 → 휨 빠르기로 넘기는 몫
const SLING_PRESS = 0.8;
// 이보다 느리게 닿으면(얹혀 있을 때 등) 덧힘을 주지 않는다(px/s)
const SLING_MIN = 40;
const GRAVITY = 640;
const BALL_COUNT = 3;
const BALL_R = 8;
const RESTITUTION = 0.5;
const SUBSTEPS = 4;
const MAX_SPEED = 1100;
const DEMO_SEED = 4764;
const SETTLE_TICKS = 180;
function makeSling(a, b, c) {
const ex = c[0] - a[0];
const ey = c[1] - a[1];
const L = Math.hypot(ex, ey);
let nx = ey / L;
let ny = -ex / L;
// 셋째 꼭짓점 b 반대쪽이 바깥
if ((b[0] - a[0]) * nx + (b[1] - a[1]) * ny > 0) {
nx = -nx;
ny = -ny;
}
return { a: a, b: b, c: c, nx: nx, ny: ny, bend: 0, bendV: 0, flash: 0, hits: 0 };
}
const slings = [makeSling([96, 146], [96, 236], [164, 254]), makeSling([384, 146], [384, 236], [316, 254])];
const walls = [
[40, 40, 40, 120], [440, 40, 440, 120],
// 바깥 안내 — 벽에서 キッカー 꼭대기로
[40, 120, 96, 146], [440, 120, 384, 146],
// 키커의 나머지 두 변은 그냥 벽
[96, 146, 96, 236], [96, 236, 164, 254], [384, 146, 384, 236], [384, 236, 316, 254]
];
let rng = makeRng(1);
let balls = [];
function totalHits() { return slings[0].hits + slings[1].hits; }
function spawn(b) {
const side = rng() < 0.5 ? -1 : 1;
b.x = 240 + (rng() - 0.5) * 60;
b.y = 44;
b.vx = side * (150 + rng() * 90);
b.vy = 0;
b.trail = [];
}
function onRestart() {
rng = makeRng(seed);
balls = [];
for (let i = 0; i < BALL_COUNT; i += 1) {
const b = makeBall(0, 0, BALL_R, BALL_COLORS[(i * 3) % BALL_COLORS.length], '');
spawn(b);
b.y = 44 - i * 4;
b.vx = (i % 2 ? 1 : -1) * Math.abs(b.vx);
balls.push(b);
}
for (let i = 0; i < slings.length; i += 1) {
slings[i].bend = 0;
slings[i].bendV = 0;
slings[i].flash = 0;
slings[i].hits = 0;
}
}
// 빗변 — 선분 충돌로 되튀긴 뒤, 법선이 빗변 바깥을 향하고 충분히 빨리 닿았으면 법선 방향 덧힘을 얹고 빗변을 안쪽으로 민다
function hitSling(b, sl) {
const vn = hitSegment(b, sl.a[0], sl.a[1], sl.c[0], sl.c[1], 2, RESTITUTION, 0, 0, 0);
if (vn <= SLING_MIN) {
return;
}
if (contact.nx * sl.nx + contact.ny * sl.ny < 0.5) {
return;
}
b.vx += sl.nx * SLING_BOOST;
b.vy += sl.ny * SLING_BOOST;
sl.bendV -= vn * SLING_PRESS;
sl.flash = 1;
sl.hits += 1;
}
// 휨 — 펴는 힘(stiff × bend)과 저항(damp × bendV)으로 감쇠 진동, 깊이는 SLING_BEND_MAX 안으로 자른다
function bendSling(sl, h) {
sl.bendV += (-SLING_STIFFNESS * sl.bend - SLING_DAMPING * sl.bendV) * h;
sl.bend = clamp(sl.bend + sl.bendV * h, -SLING_BEND_MAX, SLING_BEND_MAX);
sl.flash *= Math.exp(-h * 6);
}
function step() {
const hs = DT / SUBSTEPS;
for (let s = 0; s < SUBSTEPS; s += 1) {
for (let i = 0; i < balls.length; i += 1) {
integrate(balls[i], 0, GRAVITY, hs);
}
for (let i = 0; i < balls.length; i += 1) {
for (let j = i + 1; j < balls.length; j += 1) {
hitBalls(balls[i], balls[j], RESTITUTION);
}
}
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
hitSling(b, slings[0]);
hitSling(b, slings[1]);
hitWalls(b, walls, RESTITUTION);
capSpeed(b, MAX_SPEED);
}
bendSling(slings[0], hs);
bendSling(slings[1], hs);
}
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
pushTrail(b, 16);
// 가운데 틈으로 빠지면 위에서 다시 떨어뜨린다
if (b.y > H + 20 || b.x < 20 || b.x > W - 20) {
spawn(b);
}
}
}
function finished() { return false; }
function onControl(role, down) {
if (role === 'redraw' && down) {
seed += 1;
restart();
}
}
function drawSling(sl) {
ctx.fillStyle = rgba(LILAC, 0.3);
ctx.beginPath();
ctx.moveTo(sl.a[0], sl.a[1]);
ctx.lineTo(sl.b[0], sl.b[1]);
ctx.lineTo(sl.c[0], sl.c[1]);
ctx.closePath();
ctx.fill();
strokeSeg([sl.a[0], sl.a[1], sl.b[0], sl.b[1]], 3, CREAM, 0.45);
strokeSeg([sl.b[0], sl.b[1], sl.c[0], sl.c[1]], 3, CREAM, 0.45);
// 고무 빗변 — 가운데 조절점을 법선 방향으로 bend × 2 만큼 옮긴 2차 곡선(곡선 가운데가 bend 만큼 휜다)
const mx = (sl.a[0] + sl.c[0]) / 2 + sl.nx * sl.bend * 2;
const my = (sl.a[1] + sl.c[1]) / 2 + sl.ny * sl.bend * 2;
if (sl.flash > 0.03) {
ctx.strokeStyle = rgba(LEMON, 0.5 * sl.flash);
ctx.lineWidth = 6 + 12 * sl.flash;
ctx.beginPath();
ctx.moveTo(sl.a[0], sl.a[1]);
ctx.quadraticCurveTo(mx, my, sl.c[0], sl.c[1]);
ctx.stroke();
}
ctx.strokeStyle = rgba(mixRGB(PEACH, CREAM, sl.flash), 1);
ctx.lineWidth = 5;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(sl.a[0], sl.a[1]);
ctx.quadraticCurveTo(mx, my, sl.c[0], sl.c[1]);
ctx.stroke();
// 덧힘 화살 — 빗변 가운데에서 법선 방향
if (sl.flash > 0.05) {
const cx = (sl.a[0] + sl.c[0]) / 2;
const cy = (sl.a[1] + sl.c[1]) / 2;
const L = 14 + 26 * sl.flash;
ctx.strokeStyle = rgba(LEMON, sl.flash);
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(cx + sl.nx * 8, cy + sl.ny * 8);
ctx.lineTo(cx + sl.nx * L, cy + sl.ny * L);
ctx.stroke();
}
}
function draw() {
clearStage();
for (let i = 0; i < walls.length; i += 1) {
if (i < 4) {
strokeSeg(walls[i], 4, CREAM, 0.5);
}
}
drawSling(slings[0]);
drawSling(slings[1]);
for (let i = 0; i < balls.length; i += 1) {
drawTrail(balls[i], balls[i].color, balls[i].r * 1.3);
}
for (let i = 0; i < balls.length; i += 1) {
drawBall(balls[i]);
}
drawBand();
}
function stateText() {
return 'キッカー ' + totalHits() + '回 弾いた';
}
// ── 공통 꼬리: 시계·버튼·동작 줄이기·정리 ──
// 결과를 보여 준 시간(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();
};
})();// SlingshotRubberKicker - vanilla 판(04-pb-slingshot-rubber-kicker) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SlingshotRubberKicker.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SlingshotRubberKicker() {
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);
}
// 04 삼각 키커 — 빗변 선분 충돌 + 법선 방향 덧힘 + 빗변 가운데 휨의 감쇠 진동
// 빗변에 닿으면 법선 방향으로 더하는 빠르기(px/s)
const SLING_BOOST = 360;
// 빗변 가운데가 안쪽으로 휠 수 있는 최대 깊이(px)
const SLING_BEND_MAX = 9;
// 휨 진동의 저항(1/s) — 클수록 빨리 펴진다
const SLING_DAMPING = 14;
// 휨을 되펴는 세기(1/s²)
const SLING_STIFFNESS = 1600;
// 다가오던 빠르기 → 휨 빠르기로 넘기는 몫
const SLING_PRESS = 0.8;
// 이보다 느리게 닿으면(얹혀 있을 때 등) 덧힘을 주지 않는다(px/s)
const SLING_MIN = 40;
const GRAVITY = 640;
const BALL_COUNT = 3;
const BALL_R = 8;
const RESTITUTION = 0.5;
const SUBSTEPS = 4;
const MAX_SPEED = 1100;
const DEMO_SEED = 4764;
const SETTLE_TICKS = 180;
// 키커 — 꼭짓점 a(위)·b(바깥 아래)·c(안쪽 아래). 빗변은 a→c, n 은 빗변의 바깥(판 가운데 위쪽) 법선
type Sling = { a: Pt; b: Pt; c: Pt; nx: number; ny: number; bend: number; bendV: number; flash: number; hits: number };
function makeSling(a: Pt, b: Pt, c: Pt): Sling {
const ex = c[0] - a[0];
const ey = c[1] - a[1];
const L = Math.hypot(ex, ey);
let nx = ey / L;
let ny = -ex / L;
// 셋째 꼭짓점 b 반대쪽이 바깥
if ((b[0] - a[0]) * nx + (b[1] - a[1]) * ny > 0) { nx = -nx; ny = -ny; }
return { a: a, b: b, c: c, nx: nx, ny: ny, bend: 0, bendV: 0, flash: 0, hits: 0 };
}
const slings: Sling[] = [makeSling([96, 146], [96, 236], [164, 254]), makeSling([384, 146], [384, 236], [316, 254])];
const walls: Seg[] = [
[40, 40, 40, 120], [440, 40, 440, 120],
// 바깥 안내 — 벽에서 키커 꼭대기로
[40, 120, 96, 146], [440, 120, 384, 146],
// 키커의 나머지 두 변은 그냥 벽
[96, 146, 96, 236], [96, 236, 164, 254], [384, 146, 384, 236], [384, 236, 316, 254]
];
let rng = makeRng(1);
let balls: Ball[] = [];
function totalHits(): number { return slings[0].hits + slings[1].hits; }
function spawn(b: Ball): void {
const side = rng() < 0.5 ? -1 : 1;
b.x = 240 + (rng() - 0.5) * 60;
b.y = 44;
b.vx = side * (150 + rng() * 90);
b.vy = 0;
b.trail = [];
}
function onRestart(): void {
rng = makeRng(seed);
balls = [];
for (let i = 0; i < BALL_COUNT; i += 1) {
const b = makeBall(0, 0, BALL_R, BALL_COLORS[(i * 3) % BALL_COLORS.length], '');
spawn(b);
b.y = 44 - i * 4;
b.vx = (i % 2 ? 1 : -1) * Math.abs(b.vx);
balls.push(b);
}
for (let i = 0; i < slings.length; i += 1) {
slings[i].bend = 0;
slings[i].bendV = 0;
slings[i].flash = 0;
slings[i].hits = 0;
}
}
// 빗변 — 선분 충돌로 되튀긴 뒤, 법선이 빗변 바깥을 향하고 충분히 빨리 닿았으면 법선 방향 덧힘을 얹고 빗변을 안쪽으로 민다
function hitSling(b: Ball, sl: Sling): void {
const vn = hitSegment(b, sl.a[0], sl.a[1], sl.c[0], sl.c[1], 2, RESTITUTION, 0, 0, 0);
if (vn <= SLING_MIN) { return; }
if (contact.nx * sl.nx + contact.ny * sl.ny < 0.5) { return; }
b.vx += sl.nx * SLING_BOOST;
b.vy += sl.ny * SLING_BOOST;
sl.bendV -= vn * SLING_PRESS;
sl.flash = 1;
sl.hits += 1;
}
// 휨 — 펴는 힘(stiff × bend)과 저항(damp × bendV)으로 감쇠 진동, 깊이는 SLING_BEND_MAX 안으로 자른다
function bendSling(sl: Sling, h: number): void {
sl.bendV += (-SLING_STIFFNESS * sl.bend - SLING_DAMPING * sl.bendV) * h;
sl.bend = clamp(sl.bend + sl.bendV * h, -SLING_BEND_MAX, SLING_BEND_MAX);
sl.flash *= Math.exp(-h * 6);
}
function step(): void {
const hs = DT / SUBSTEPS;
for (let s = 0; s < SUBSTEPS; s += 1) {
for (let i = 0; i < balls.length; i += 1) { integrate(balls[i], 0, GRAVITY, hs); }
for (let i = 0; i < balls.length; i += 1) {
for (let j = i + 1; j < balls.length; j += 1) { hitBalls(balls[i], balls[j], RESTITUTION); }
}
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
hitSling(b, slings[0]);
hitSling(b, slings[1]);
hitWalls(b, walls, RESTITUTION);
capSpeed(b, MAX_SPEED);
}
bendSling(slings[0], hs);
bendSling(slings[1], hs);
}
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
pushTrail(b, 16);
// 가운데 틈으로 빠지면 위에서 다시 떨어뜨린다
if (b.y > H + 20 || b.x < 20 || b.x > W - 20) { spawn(b); }
}
}
function finished(): boolean { return false; }
function onControl(role: string, down: boolean): void {
if (role === 'redraw' && down) {
seed += 1;
restart();
}
}
function drawSling(sl: Sling): void {
ctx.fillStyle = rgba(LILAC, 0.3);
ctx.beginPath();
ctx.moveTo(sl.a[0], sl.a[1]);
ctx.lineTo(sl.b[0], sl.b[1]);
ctx.lineTo(sl.c[0], sl.c[1]);
ctx.closePath();
ctx.fill();
strokeSeg([sl.a[0], sl.a[1], sl.b[0], sl.b[1]], 3, CREAM, 0.45);
strokeSeg([sl.b[0], sl.b[1], sl.c[0], sl.c[1]], 3, CREAM, 0.45);
// 고무 빗변 — 가운데 조절점을 법선 방향으로 bend × 2 만큼 옮긴 2차 곡선(곡선 가운데가 bend 만큼 휜다)
const mx = (sl.a[0] + sl.c[0]) / 2 + sl.nx * sl.bend * 2;
const my = (sl.a[1] + sl.c[1]) / 2 + sl.ny * sl.bend * 2;
if (sl.flash > 0.03) {
ctx.strokeStyle = rgba(LEMON, 0.5 * sl.flash);
ctx.lineWidth = 6 + 12 * sl.flash;
ctx.beginPath();
ctx.moveTo(sl.a[0], sl.a[1]);
ctx.quadraticCurveTo(mx, my, sl.c[0], sl.c[1]);
ctx.stroke();
}
ctx.strokeStyle = rgba(mixRGB(PEACH, CREAM, sl.flash), 1);
ctx.lineWidth = 5;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(sl.a[0], sl.a[1]);
ctx.quadraticCurveTo(mx, my, sl.c[0], sl.c[1]);
ctx.stroke();
// 덧힘 화살 — 빗변 가운데에서 법선 방향
if (sl.flash > 0.05) {
const cx = (sl.a[0] + sl.c[0]) / 2;
const cy = (sl.a[1] + sl.c[1]) / 2;
const L = 14 + 26 * sl.flash;
ctx.strokeStyle = rgba(LEMON, sl.flash);
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(cx + sl.nx * 8, cy + sl.ny * 8);
ctx.lineTo(cx + sl.nx * L, cy + sl.ny * L);
ctx.stroke();
}
}
function draw(): void {
clearStage();
for (let i = 0; i < walls.length; i += 1) { if (i < 4) { strokeSeg(walls[i], 4, CREAM, 0.5); } }
drawSling(slings[0]);
drawSling(slings[1]);
for (let i = 0; i < balls.length; i += 1) { drawTrail(balls[i], balls[i].color, balls[i].r * 1.3); }
for (let i = 0; i < balls.length; i += 1) { drawBall(balls[i]); }
drawBand();
}
function stateText(): string {
return '키커 ' + totalHits() + '번 튕김';
}
// ── 공통 꼬리: 시계·버튼·동작 줄이기·정리 ──
// 결과를 보여 준 시간(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="redraw">다시 굴리기</button>
</div>
</div>
</div>
);
}04 · ゴムのようにしなって弾く三角キッカー — 計測 累積面積 16.78% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 斜辺 — 線分の衝突で跳ね返したあと、法線が斜辺の外を向き十分速く当たったなら法線方向の上乗せを加え、斜辺を内側へ押す
function hitSling(b, sl) {
const vn = hitSegment(b, sl.a[0], sl.a[1], sl.c[0], sl.c[1], 2, RESTITUTION, 0, 0, 0);
if (vn <= SLING_MIN) {
return;
}
if (contact.nx * sl.nx + contact.ny * sl.ny < 0.5) {
return;
}
b.vx += sl.nx * SLING_BOOST;
b.vy += sl.ny * SLING_BOOST;
sl.bendV -= vn * SLING_PRESS;
sl.flash = 1;
sl.hits += 1;
}
// しなり — 戻す力(stiff × bend)と抵抗(damp × bendV)で減衰振動、深さは SLING_BEND_MAX 以内に切る
function bendSling(sl, h) {
sl.bendV += (-SLING_STIFFNESS * sl.bend - SLING_DAMPING * sl.bendV) * h;
sl.bend = clamp(sl.bend + sl.bendV * h, -SLING_BEND_MAX, SLING_BEND_MAX);
sl.flash *= Math.exp(-h * 6);
}
05引いた分だけ力がたまる発射バネ
押している間はバネが60px/sで最大40pxまで引かれ、離すと引いた長さ × 900の加速度で戻っていき、初めて0を通る瞬間にバネの速さの0.85倍をボールの上向きの速度として渡します。引き10・20・30・40pxは255・510・765・1,020px/sの発射になり、ボールは離してから64ms後に飛び出します。ゲーム風の会員登録お祝い画面なら、ボタンを押している長さがそのまま強さになるので説明がなくても手になじみ、ボールを渡したバネは休む位置より24px上まで行き過ぎてから352msで±1px以内に止まります。

<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="pull" 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 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;
}
// 05 발사 막대 — 당긴 길이에 비례하는 押し戻す力 → 0 을 지나는 순간의 막대 빠르기 × 배율 = 발사 속도
// 되미는 힘의 세기(1/s²) — 가속도 = -PLUNGER_STIFFNESS × 당긴 길이
const PLUNGER_STIFFNESS = 900;
// 최대로 당길 수 있는 길이(px)
const PLUNGER_MAX_PULL = 40;
// 막대 빠르기 → ボール 발사 속도 배율
const LAUNCH_SCALE = 0.85;
// 공을 넘긴 뒤 막대 출렁임의 저항(1/s)
const PLUNGER_DAMPING = 24;
// 쥐고 있을 때 초당 당겨지는 길이(px/s)
const PULL_RATE = 60;
const GRAVITY = 600;
const BALL_R = 9;
const RESTITUTION = 0.5;
const SUBSTEPS = 6;
const MAX_SPEED = 1400;
const LANE_IN = 418;
const LANE_OUT = 456;
const PLUNGER_TOP = 270;
// 시연 — 공이 올라오면 AUTO_WAIT_MS 쉬고, 시드로 정한 길이까지 당겼다가 AUTO_HOLD_MS 뒤 놓는다
const AUTO_WAIT_MS = 200;
const AUTO_HOLD_MS = 120;
const RETURN_MS = 500;
const DEMO_SEED = 4765;
// 동작 줄이기 — 80 틱(1,280ms)까지 계산해 첫 발사 직후(공이 레인을 오르는 장면)와 발사 속도 글자를 보인다
const SETTLE_TICKS = 80;
// 굽은 길 — 레인 바깥 벽 위쪽을 왼쪽으로 감아 공을 판으로 내보낸다
const ARC = makeArc(352, 140, 104, 0, -2.6, 18);
const walls = ARC.concat([
[LANE_OUT, 140, LANE_OUT, 300], [LANE_IN, 150, LANE_IN, 292],
[24, 34, 352, 34], [24, 34, 24, 240],
// 바닥 비탈 — 오른쪽 아래 되돌림 구멍으로 굴려 보낸다
[24, 240, 404, 292]
]);
const posts = [
{ x: 110, y: 104, glow: 0 }, { x: 184, y: 86, glow: 0 }, { x: 258, y: 128, glow: 0 },
{ x: 144, y: 168, glow: 0 }, { x: 222, y: 196, glow: 0 }, { x: 306, y: 180, glow: 0 }
];
const POST_R = 7;
const plunger = makePlunger((LANE_IN + LANE_OUT) / 2, PLUNGER_TOP, PLUNGER_MAX_PULL, PLUNGER_STIFFNESS, LAUNCH_SCALE, PLUNGER_DAMPING);
let rng = makeRng(1);
let ball = makeBall(0, 0, BALL_R, PEACH, '');
let phase = 'wait';
let phaseMs = 0;
let returnMs = 0;
let launches = 0;
function load() {
ball.out = false;
ball.x = plunger.x;
loadPlunger(plunger, ball);
phase = 'wait';
phaseMs = 0;
// 이번에 당길 길이 — 최대의 65~100% 를 시드 난수로
plunger.cap = demo ? PLUNGER_MAX_PULL * (0.65 + 0.35 * rng()) : PLUNGER_MAX_PULL;
}
function onRestart() {
rng = makeRng(seed);
ball = makeBall(plunger.x, PLUNGER_TOP - BALL_R - 3, BALL_R, PEACH, '');
plunger.pull = 0;
plunger.v = 0;
plunger.held = false;
plunger.free = false;
plunger.lastLaunch = 0;
plunger.lastPull = 0;
returnMs = 0;
launches = 0;
load();
}
// 시연 손 — 기다림 → 引く(cap 까지) → 잠깐 멈춤 → 놓기
function autoHand() {
if (!plunger.ball) {
return;
}
phaseMs += FIXED_DT;
if (phase === 'wait' && phaseMs >= AUTO_WAIT_MS) {
plunger.held = true;
phase = 'pull';
phaseMs = 0;
}
else if (phase === 'pull' && plunger.pull >= plunger.cap - 0.01) {
phase = 'hold';
phaseMs = 0;
}
else if (phase === 'hold' && phaseMs >= AUTO_HOLD_MS) {
plunger.held = false;
phase = 'flight';
}
}
function step() {
if (demo) {
autoHand();
}
const hs = DT / SUBSTEPS;
const hadBall = plunger.ball !== null;
for (let s = 0; s < SUBSTEPS; s += 1) {
movePlunger(plunger, hs, PULL_RATE);
if (plunger.ball || ball.out) {
continue;
}
integrate(ball, 0, GRAVITY, hs);
hitWalls(ball, walls, RESTITUTION);
for (let i = 0; i < posts.length; i += 1) {
const vn = hitCircle(ball, posts[i].x, posts[i].y, POST_R, 0.6);
if (vn > 0) {
posts[i].glow = Math.min(1, vn / 400);
}
}
capSpeed(ball, MAX_SPEED);
}
if (hadBall && !plunger.ball) {
launches += 1;
}
if (!ball.out && !plunger.ball) {
pushTrail(ball, 20);
// 힘이 모자라 레인으로 되떨어지면 막대 위에 다시 얹는다
if (ball.x > LANE_IN && ball.y + ball.r > plungerTipY(plunger) - 2 && ball.vy > 0) {
load();
}
// 되돌림 구멍 — 판 오른쪽 아래에 닿으면 잠깐 쉬었다 막대 위로
else if (ball.x > 392 && ball.x < LANE_IN && ball.y > 268) {
ball.out = true;
returnMs = RETURN_MS;
}
}
if (ball.out) {
returnMs -= FIXED_DT;
if (returnMs <= 0) {
load();
}
}
for (let i = 0; i < posts.length; i += 1) {
posts[i].glow *= 0.92;
}
}
function finished() { return false; }
function onControl(role, down) {
if (role === 'pull') {
plunger.cap = PLUNGER_MAX_PULL;
plunger.held = down;
phase = 'hand';
}
}
function draw() {
clearStage();
ctx.fillStyle = rgba(BAND, 0.35);
ctx.fillRect(LANE_IN, 140, LANE_OUT - LANE_IN, H - 140);
for (let i = 0; i < walls.length; i += 1) {
strokeSeg(walls[i], 4, CREAM, 0.5);
}
for (let i = 0; i < posts.length; i += 1) {
const p = posts[i];
if (p.glow > 0.02) {
ctx.fillStyle = rgba(LEMON, 0.45 * p.glow);
ctx.beginPath();
ctx.arc(p.x, p.y, POST_R + 12 * p.glow, 0, TAU);
ctx.fill();
}
ctx.fillStyle = rgba(mixRGB(LILAC, CREAM, p.glow), 1);
ctx.beginPath();
ctx.arc(p.x, p.y, POST_R, 0, TAU);
ctx.fill();
}
// 되돌림 구멍
ctx.fillStyle = rgba(BAND, 0.9);
ctx.beginPath();
ctx.arc(404, 284, 10, 0, TAU);
ctx.fill();
drawPlunger(plunger, LANE_IN - 14, H - 6);
// 놓으면 낼 빠르기 — 당긴 길이에 비례
if (plunger.ball && plunger.pull > 0.5) {
drawText(Math.round(predictLaunch(plunger, plunger.pull)) + 'px/s', LANE_IN - 18, H - 76, 11, LEMON, 0.95, 'right');
drawText('押し戻す力', LANE_IN - 18, H - 92, 10, CREAM, 0.75, 'right');
}
if (!ball.out) {
drawTrail(ball, PEACH, ball.r * 1.3);
drawBall(ball);
}
drawBand();
}
function stateText() {
if (plunger.ball && plunger.pull > 0.5) {
return '引き ' + Math.round(plunger.pull / 5) * 5 + 'px — 離すと ' + Math.round(predictLaunch(plunger, plunger.pull) / 10) * 10 + 'px/s';
}
if (plunger.lastLaunch > 0) {
return '引き ' + Math.round(plunger.lastPull) + 'px → 発射 ' + Math.round(plunger.lastLaunch) + '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();
};
})();// PlungerSpringChargeLaunch - vanilla 판(05-pb-plunger-spring-charge-launch) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./PlungerSpringChargeLaunch.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PlungerSpringChargeLaunch() {
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);
}
// 발사 막대 — 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;
}
// 05 발사 막대 — 당긴 길이에 비례하는 되미는 힘 → 0 을 지나는 순간의 막대 빠르기 × 배율 = 발사 속도
// 되미는 힘의 세기(1/s²) — 가속도 = -PLUNGER_STIFFNESS × 당긴 길이
const PLUNGER_STIFFNESS = 900;
// 최대로 당길 수 있는 길이(px)
const PLUNGER_MAX_PULL = 40;
// 막대 빠르기 → 공 발사 속도 배율
const LAUNCH_SCALE = 0.85;
// 공을 넘긴 뒤 막대 출렁임의 저항(1/s)
const PLUNGER_DAMPING = 24;
// 쥐고 있을 때 초당 당겨지는 길이(px/s)
const PULL_RATE = 60;
const GRAVITY = 600;
const BALL_R = 9;
const RESTITUTION = 0.5;
const SUBSTEPS = 6;
const MAX_SPEED = 1400;
const LANE_IN = 418;
const LANE_OUT = 456;
const PLUNGER_TOP = 270;
// 시연 — 공이 올라오면 AUTO_WAIT_MS 쉬고, 시드로 정한 길이까지 당겼다가 AUTO_HOLD_MS 뒤 놓는다
const AUTO_WAIT_MS = 200;
const AUTO_HOLD_MS = 120;
const RETURN_MS = 500;
const DEMO_SEED = 4765;
// 동작 줄이기 — 80 틱(1,280ms)까지 계산해 첫 발사 직후(공이 레인을 오르는 장면)와 발사 속도 글자를 보인다
const SETTLE_TICKS = 80;
// 굽은 길 — 레인 바깥 벽 위쪽을 왼쪽으로 감아 공을 판으로 내보낸다
const ARC: Seg[] = makeArc(352, 140, 104, 0, -2.6, 18);
const walls: Seg[] = ARC.concat([
[LANE_OUT, 140, LANE_OUT, 300], [LANE_IN, 150, LANE_IN, 292],
[24, 34, 352, 34], [24, 34, 24, 240],
// 바닥 비탈 — 오른쪽 아래 되돌림 구멍으로 굴려 보낸다
[24, 240, 404, 292]
]);
const posts: Peg[] = [
{ x: 110, y: 104, glow: 0 }, { x: 184, y: 86, glow: 0 }, { x: 258, y: 128, glow: 0 },
{ x: 144, y: 168, glow: 0 }, { x: 222, y: 196, glow: 0 }, { x: 306, y: 180, glow: 0 }
];
const POST_R = 7;
const plunger: Plunger = makePlunger((LANE_IN + LANE_OUT) / 2, PLUNGER_TOP, PLUNGER_MAX_PULL, PLUNGER_STIFFNESS, LAUNCH_SCALE, PLUNGER_DAMPING);
let rng = makeRng(1);
let ball: Ball = makeBall(0, 0, BALL_R, PEACH, '');
let phase = 'wait';
let phaseMs = 0;
let returnMs = 0;
let launches = 0;
function load(): void {
ball.out = false;
ball.x = plunger.x;
loadPlunger(plunger, ball);
phase = 'wait';
phaseMs = 0;
// 이번에 당길 길이 — 최대의 65~100% 를 시드 난수로
plunger.cap = demo ? PLUNGER_MAX_PULL * (0.65 + 0.35 * rng()) : PLUNGER_MAX_PULL;
}
function onRestart(): void {
rng = makeRng(seed);
ball = makeBall(plunger.x, PLUNGER_TOP - BALL_R - 3, BALL_R, PEACH, '');
plunger.pull = 0;
plunger.v = 0;
plunger.held = false;
plunger.free = false;
plunger.lastLaunch = 0;
plunger.lastPull = 0;
returnMs = 0;
launches = 0;
load();
}
// 시연 손 — 기다림 → 당기기(cap 까지) → 잠깐 멈춤 → 놓기
function autoHand(): void {
if (!plunger.ball) { return; }
phaseMs += FIXED_DT;
if (phase === 'wait' && phaseMs >= AUTO_WAIT_MS) {
plunger.held = true;
phase = 'pull';
phaseMs = 0;
} else if (phase === 'pull' && plunger.pull >= plunger.cap - 0.01) {
phase = 'hold';
phaseMs = 0;
} else if (phase === 'hold' && phaseMs >= AUTO_HOLD_MS) {
plunger.held = false;
phase = 'flight';
}
}
function step(): void {
if (demo) { autoHand(); }
const hs = DT / SUBSTEPS;
const hadBall = plunger.ball !== null;
for (let s = 0; s < SUBSTEPS; s += 1) {
movePlunger(plunger, hs, PULL_RATE);
if (plunger.ball || ball.out) { continue; }
integrate(ball, 0, GRAVITY, hs);
hitWalls(ball, walls, RESTITUTION);
for (let i = 0; i < posts.length; i += 1) {
const vn = hitCircle(ball, posts[i].x, posts[i].y, POST_R, 0.6);
if (vn > 0) { posts[i].glow = Math.min(1, vn / 400); }
}
capSpeed(ball, MAX_SPEED);
}
if (hadBall && !plunger.ball) { launches += 1; }
if (!ball.out && !plunger.ball) {
pushTrail(ball, 20);
// 힘이 모자라 레인으로 되떨어지면 막대 위에 다시 얹는다
if (ball.x > LANE_IN && ball.y + ball.r > plungerTipY(plunger) - 2 && ball.vy > 0) { load(); }
// 되돌림 구멍 — 판 오른쪽 아래에 닿으면 잠깐 쉬었다 막대 위로
else if (ball.x > 392 && ball.x < LANE_IN && ball.y > 268) {
ball.out = true;
returnMs = RETURN_MS;
}
}
if (ball.out) {
returnMs -= FIXED_DT;
if (returnMs <= 0) { load(); }
}
for (let i = 0; i < posts.length; i += 1) { posts[i].glow *= 0.92; }
}
function finished(): boolean { return false; }
function onControl(role: string, down: boolean): void {
if (role === 'pull') {
plunger.cap = PLUNGER_MAX_PULL;
plunger.held = down;
phase = 'hand';
}
}
function draw(): void {
clearStage();
ctx.fillStyle = rgba(BAND, 0.35);
ctx.fillRect(LANE_IN, 140, LANE_OUT - LANE_IN, H - 140);
for (let i = 0; i < walls.length; i += 1) { strokeSeg(walls[i], 4, CREAM, 0.5); }
for (let i = 0; i < posts.length; i += 1) {
const p = posts[i];
if (p.glow > 0.02) {
ctx.fillStyle = rgba(LEMON, 0.45 * p.glow);
ctx.beginPath();
ctx.arc(p.x, p.y, POST_R + 12 * p.glow, 0, TAU);
ctx.fill();
}
ctx.fillStyle = rgba(mixRGB(LILAC, CREAM, p.glow), 1);
ctx.beginPath();
ctx.arc(p.x, p.y, POST_R, 0, TAU);
ctx.fill();
}
// 되돌림 구멍
ctx.fillStyle = rgba(BAND, 0.9);
ctx.beginPath();
ctx.arc(404, 284, 10, 0, TAU);
ctx.fill();
drawPlunger(plunger, LANE_IN - 14, H - 6);
// 놓으면 낼 빠르기 — 당긴 길이에 비례
if (plunger.ball && plunger.pull > 0.5) {
drawText(Math.round(predictLaunch(plunger, plunger.pull)) + 'px/s', LANE_IN - 18, H - 76, 11, LEMON, 0.95, 'right');
drawText('되미는 힘', LANE_IN - 18, H - 92, 10, CREAM, 0.75, 'right');
}
if (!ball.out) {
drawTrail(ball, PEACH, ball.r * 1.3);
drawBall(ball);
}
drawBand();
}
function stateText(): string {
if (plunger.ball && plunger.pull > 0.5) { return '당김 ' + Math.round(plunger.pull / 5) * 5 + 'px — 놓으면 ' + Math.round(predictLaunch(plunger, plunger.pull) / 10) * 10 + 'px/s'; }
if (plunger.lastLaunch > 0) { return '당김 ' + Math.round(plunger.lastPull) + 'px → 발사 ' + Math.round(plunger.lastLaunch) + '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="pull" data-hold="1">당기기</button>
</div>
</div>
</div>
);
}05 · 引いた分だけ力がたまる発射バネ — 計測 累積面積 8.451% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
06回る風車の棒に巻き込まれて弾かれるビー玉
羽根三枚の棒二本が6ラジアン/秒で互いに逆向きに回り、ビー玉が当たった羽根の上の点の回転速度がそのまま接点の速度に入ります。止めたビー玉を軸から15・30・45・56pxの位置に置いて羽根で打つと170・295・423・514px/sで弾み、先に当たるほど遠くへ飛びます。グループゲームの順番決め画面に置くと、7個のビー玉が当たった位置ごとに違う勢いで弾かれ、実演の30秒間で軸から25px以内に当たった15回は平均260px/s、45pxより外に当たった79回は平均496px/sでした。

<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="redraw">もう一度転がす</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);
}
// 06 풍차 막대 — 일정한 빠르기로 도는 막대의 접점마다 회전으로 생긴 속도(각속도 × 축까지 거리)를 상대 속도에 넣는다
// 회전 빠르기(라디안/초) — 둘째 풍차는 반대로 돈다
const ROTOR_SPEED = 6;
// 풍차 하나의 날개 수
const ROTOR_ARMS = 3;
// 軸から 날개 끝까지(px)
const ROTOR_LENGTH = 58;
const ROTOR_HALF = 4;
const HUB_R = 8;
const GRAVITY = 560;
const BALL_COUNT = 7;
const BALL_R = 7;
const RESTITUTION = 0.55;
const SUBSTEPS = 5;
const MAX_SPEED = 1200;
const SPAWN_GAP_MS = 180;
const DEMO_SEED = 4766;
const SETTLE_TICKS = 200;
const rotors = [{ x: 168, y: 138, dir: 1, angle: 0, glow: [] }, { x: 312, y: 196, dir: -1, angle: 0, glow: [] }];
const walls = [[40, 34, 40, 300], [440, 34, 440, 300]];
let rng = makeRng(1);
let balls = [];
let waitMs = [];
// 마지막으로 맞은 순간 — 軸から 접점까지 거리와 맞은 직후 빠르기
let lastR = 0;
let lastV = 0;
function armTip(r, k) {
const a = r.angle + k * TAU / ROTOR_ARMS;
return [r.x + Math.cos(a) * ROTOR_LENGTH, r.y + Math.sin(a) * ROTOR_LENGTH];
}
function spawn(b) {
b.x = 60 + rng() * 360;
b.y = 40;
b.vx = (rng() - 0.5) * 80;
b.vy = 0;
b.trail = [];
b.out = false;
}
function onRestart() {
rng = makeRng(seed);
balls = [];
waitMs = [];
for (let i = 0; i < BALL_COUNT; i += 1) {
const b = makeBall(0, 0, BALL_R, BALL_COLORS[i % BALL_COLORS.length], '');
spawn(b);
// 처음에는 차례로 — 공마다 SPAWN_GAP_MS 씩 늦게 나온다
b.out = i > 0;
balls.push(b);
waitMs.push(i * SPAWN_GAP_MS);
}
for (let i = 0; i < rotors.length; i += 1) {
rotors[i].angle = i * 0.6;
rotors[i].glow = [];
for (let k = 0; k < ROTOR_ARMS; k += 1) {
rotors[i].glow.push(0);
}
}
lastR = 0;
lastV = 0;
}
// 날개와 공 — 날개 위 가장 가까운 점의 회전 속도를 접점 속도로
function hitArm(b, r, k) {
const w = r.dir * ROTOR_SPEED;
const tip = armTip(r, k);
const c = closestOnSegment(b.x, b.y, r.x, r.y, tip[0], tip[1]);
const rx = c[0] - r.x;
const ry = c[1] - r.y;
const vn = hitSegment(b, r.x, r.y, tip[0], tip[1], ROTOR_HALF, RESTITUTION, -w * ry, w * rx, 0);
if (vn > 0) {
lastR = Math.hypot(rx, ry);
lastV = Math.hypot(b.vx, b.vy);
r.glow[k] = 1;
}
}
function step() {
const hs = DT / SUBSTEPS;
for (let s = 0; s < SUBSTEPS; s += 1) {
for (let i = 0; i < rotors.length; i += 1) {
rotors[i].angle += rotors[i].dir * ROTOR_SPEED * hs;
}
for (let i = 0; i < balls.length; i += 1) {
if (!balls[i].out) {
integrate(balls[i], 0, GRAVITY, hs);
}
}
for (let i = 0; i < balls.length; i += 1) {
for (let j = i + 1; j < balls.length; j += 1) {
if (!balls[i].out && !balls[j].out) {
hitBalls(balls[i], balls[j], RESTITUTION);
}
}
}
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
if (b.out) {
continue;
}
for (let q = 0; q < rotors.length; q += 1) {
const r = rotors[q];
for (let k = 0; k < ROTOR_ARMS; k += 1) {
hitArm(b, r, k);
}
hitCircle(b, r.x, r.y, HUB_R, RESTITUTION);
}
hitWalls(b, walls, RESTITUTION);
capSpeed(b, MAX_SPEED);
}
}
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
if (b.out) {
waitMs[i] -= FIXED_DT;
if (waitMs[i] <= 0) {
spawn(b);
}
continue;
}
pushTrail(b, 16);
if (b.y > H + 14 || b.y < -40) {
b.out = true;
waitMs[i] = SPAWN_GAP_MS;
}
}
for (let i = 0; i < rotors.length; i += 1) {
for (let k = 0; k < ROTOR_ARMS; k += 1) {
rotors[i].glow[k] *= 0.9;
}
}
}
function finished() { return false; }
function onControl(role, down) {
if (role === 'redraw' && down) {
seed += 1;
restart();
}
}
function drawRotor(r) {
// 잔상 — 한 틱 전·두 틱 전 각도의 날개를 옅게(회전 방향 반대쪽으로 남는다)
for (let g = 3; g >= 1; g -= 1) {
const back = r.angle - r.dir * ROTOR_SPEED * DT * g * 1.5;
for (let k = 0; k < ROTOR_ARMS; k += 1) {
const a = back + k * TAU / ROTOR_ARMS;
strokeSeg([r.x, r.y, r.x + Math.cos(a) * ROTOR_LENGTH, r.y + Math.sin(a) * ROTOR_LENGTH], ROTOR_HALF * 2, LILAC, 0.1 * (4 - g));
}
}
for (let k = 0; k < ROTOR_ARMS; k += 1) {
const tip = armTip(r, k);
strokeSeg([r.x, r.y, tip[0], tip[1]], ROTOR_HALF * 2, mixRGB(PEACH, LEMON, r.glow[k]), 1);
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(tip[0], tip[1], ROTOR_HALF + 1, 0, TAU);
ctx.fill();
}
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(r.x, r.y, HUB_R, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(INK, 0.5);
ctx.beginPath();
ctx.arc(r.x, r.y, 3, 0, TAU);
ctx.fill();
}
function draw() {
clearStage();
for (let i = 0; i < walls.length; i += 1) {
strokeSeg(walls[i], 4, CREAM, 0.5);
}
for (let i = 0; i < rotors.length; i += 1) {
// 날개 끝이 그리는 원 — 끝의 빠르기 = ROTOR_SPEED × ROTOR_LENGTH
ctx.setLineDash([3, 6]);
ctx.strokeStyle = rgba(GUIDE, 0.8);
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(rotors[i].x, rotors[i].y, ROTOR_LENGTH, 0, TAU);
ctx.stroke();
ctx.setLineDash([]);
}
for (let i = 0; i < balls.length; i += 1) {
if (!balls[i].out) {
drawTrail(balls[i], balls[i].color, balls[i].r * 1.3);
}
}
for (let i = 0; i < rotors.length; i += 1) {
drawRotor(rotors[i]);
}
for (let i = 0; i < balls.length; i += 1) {
if (!balls[i].out) {
drawBall(balls[i]);
}
}
drawBand();
}
function stateText() {
if (lastV > 0) {
return '軸から ' + Math.round(lastR) + 'px の所に当たり ' + Math.round(lastV) + '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();
};
})();// WindmillRotorObstacle - vanilla 판(06-pb-windmill-rotor-obstacle) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./WindmillRotorObstacle.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function WindmillRotorObstacle() {
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);
}
// 06 풍차 막대 — 일정한 빠르기로 도는 막대의 접점마다 회전으로 생긴 속도(각속도 × 축까지 거리)를 상대 속도에 넣는다
// 회전 빠르기(라디안/초) — 둘째 풍차는 반대로 돈다
const ROTOR_SPEED = 6;
// 풍차 하나의 날개 수
const ROTOR_ARMS = 3;
// 축에서 날개 끝까지(px)
const ROTOR_LENGTH = 58;
const ROTOR_HALF = 4;
const HUB_R = 8;
const GRAVITY = 560;
const BALL_COUNT = 7;
const BALL_R = 7;
const RESTITUTION = 0.55;
const SUBSTEPS = 5;
const MAX_SPEED = 1200;
const SPAWN_GAP_MS = 180;
const DEMO_SEED = 4766;
const SETTLE_TICKS = 200;
type Rotor = { x: number; y: number; dir: number; angle: number; glow: number[] };
const rotors: Rotor[] = [{ x: 168, y: 138, dir: 1, angle: 0, glow: [] }, { x: 312, y: 196, dir: -1, angle: 0, glow: [] }];
const walls: Seg[] = [[40, 34, 40, 300], [440, 34, 440, 300]];
let rng = makeRng(1);
let balls: Ball[] = [];
let waitMs: number[] = [];
// 마지막으로 맞은 순간 — 축에서 접점까지 거리와 맞은 직후 빠르기
let lastR = 0;
let lastV = 0;
function armTip(r: Rotor, k: number): Pt {
const a = r.angle + k * TAU / ROTOR_ARMS;
return [r.x + Math.cos(a) * ROTOR_LENGTH, r.y + Math.sin(a) * ROTOR_LENGTH];
}
function spawn(b: Ball): void {
b.x = 60 + rng() * 360;
b.y = 40;
b.vx = (rng() - 0.5) * 80;
b.vy = 0;
b.trail = [];
b.out = false;
}
function onRestart(): void {
rng = makeRng(seed);
balls = [];
waitMs = [];
for (let i = 0; i < BALL_COUNT; i += 1) {
const b = makeBall(0, 0, BALL_R, BALL_COLORS[i % BALL_COLORS.length], '');
spawn(b);
// 처음에는 차례로 — 공마다 SPAWN_GAP_MS 씩 늦게 나온다
b.out = i > 0;
balls.push(b);
waitMs.push(i * SPAWN_GAP_MS);
}
for (let i = 0; i < rotors.length; i += 1) {
rotors[i].angle = i * 0.6;
rotors[i].glow = [];
for (let k = 0; k < ROTOR_ARMS; k += 1) { rotors[i].glow.push(0); }
}
lastR = 0;
lastV = 0;
}
// 날개와 공 — 날개 위 가장 가까운 점의 회전 속도를 접점 속도로
function hitArm(b: Ball, r: Rotor, k: number): void {
const w = r.dir * ROTOR_SPEED;
const tip = armTip(r, k);
const c = closestOnSegment(b.x, b.y, r.x, r.y, tip[0], tip[1]);
const rx = c[0] - r.x;
const ry = c[1] - r.y;
const vn = hitSegment(b, r.x, r.y, tip[0], tip[1], ROTOR_HALF, RESTITUTION, -w * ry, w * rx, 0);
if (vn > 0) {
lastR = Math.hypot(rx, ry);
lastV = Math.hypot(b.vx, b.vy);
r.glow[k] = 1;
}
}
function step(): void {
const hs = DT / SUBSTEPS;
for (let s = 0; s < SUBSTEPS; s += 1) {
for (let i = 0; i < rotors.length; i += 1) { rotors[i].angle += rotors[i].dir * ROTOR_SPEED * hs; }
for (let i = 0; i < balls.length; i += 1) { if (!balls[i].out) { integrate(balls[i], 0, GRAVITY, hs); } }
for (let i = 0; i < balls.length; i += 1) {
for (let j = i + 1; j < balls.length; j += 1) {
if (!balls[i].out && !balls[j].out) { hitBalls(balls[i], balls[j], RESTITUTION); }
}
}
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
if (b.out) { continue; }
for (let q = 0; q < rotors.length; q += 1) {
const r = rotors[q];
for (let k = 0; k < ROTOR_ARMS; k += 1) { hitArm(b, r, k); }
hitCircle(b, r.x, r.y, HUB_R, RESTITUTION);
}
hitWalls(b, walls, RESTITUTION);
capSpeed(b, MAX_SPEED);
}
}
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
if (b.out) {
waitMs[i] -= FIXED_DT;
if (waitMs[i] <= 0) { spawn(b); }
continue;
}
pushTrail(b, 16);
if (b.y > H + 14 || b.y < -40) {
b.out = true;
waitMs[i] = SPAWN_GAP_MS;
}
}
for (let i = 0; i < rotors.length; i += 1) {
for (let k = 0; k < ROTOR_ARMS; k += 1) { rotors[i].glow[k] *= 0.9; }
}
}
function finished(): boolean { return false; }
function onControl(role: string, down: boolean): void {
if (role === 'redraw' && down) {
seed += 1;
restart();
}
}
function drawRotor(r: Rotor): void {
// 잔상 — 한 틱 전·두 틱 전 각도의 날개를 옅게(회전 방향 반대쪽으로 남는다)
for (let g = 3; g >= 1; g -= 1) {
const back = r.angle - r.dir * ROTOR_SPEED * DT * g * 1.5;
for (let k = 0; k < ROTOR_ARMS; k += 1) {
const a = back + k * TAU / ROTOR_ARMS;
strokeSeg([r.x, r.y, r.x + Math.cos(a) * ROTOR_LENGTH, r.y + Math.sin(a) * ROTOR_LENGTH], ROTOR_HALF * 2, LILAC, 0.1 * (4 - g));
}
}
for (let k = 0; k < ROTOR_ARMS; k += 1) {
const tip = armTip(r, k);
strokeSeg([r.x, r.y, tip[0], tip[1]], ROTOR_HALF * 2, mixRGB(PEACH, LEMON, r.glow[k]), 1);
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(tip[0], tip[1], ROTOR_HALF + 1, 0, TAU);
ctx.fill();
}
ctx.fillStyle = rgba(CREAM, 1);
ctx.beginPath();
ctx.arc(r.x, r.y, HUB_R, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(INK, 0.5);
ctx.beginPath();
ctx.arc(r.x, r.y, 3, 0, TAU);
ctx.fill();
}
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 < rotors.length; i += 1) {
// 날개 끝이 그리는 원 — 끝의 빠르기 = ROTOR_SPEED × ROTOR_LENGTH
ctx.setLineDash([3, 6]);
ctx.strokeStyle = rgba(GUIDE, 0.8);
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(rotors[i].x, rotors[i].y, ROTOR_LENGTH, 0, TAU);
ctx.stroke();
ctx.setLineDash([]);
}
for (let i = 0; i < balls.length; i += 1) { if (!balls[i].out) { drawTrail(balls[i], balls[i].color, balls[i].r * 1.3); } }
for (let i = 0; i < rotors.length; i += 1) { drawRotor(rotors[i]); }
for (let i = 0; i < balls.length; i += 1) { if (!balls[i].out) { drawBall(balls[i]); } }
drawBand();
}
function stateText(): string {
if (lastV > 0) { return '막대 축에서 ' + Math.round(lastR) + 'px 자리에 맞아 ' + Math.round(lastV) + '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="redraw">다시 굴리기</button>
</div>
</div>
</div>
);
}06 · 回る風車の棒に巻き込まれて弾かれるビー玉 — 計測 累積面積 28.824% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 羽根とボール — 羽根の上でいちばん近い点の回転速度を接点の速度に
function hitArm(b, r, k) {
const w = r.dir * ROTOR_SPEED;
const tip = armTip(r, k);
const c = closestOnSegment(b.x, b.y, r.x, r.y, tip[0], tip[1]);
const rx = c[0] - r.x;
const ry = c[1] - r.y;
const vn = hitSegment(b, r.x, r.y, tip[0], tip[1], ROTOR_HALF, RESTITUTION, -w * ry, w * rx, 0);
if (vn > 0) {
lastR = Math.hypot(rx, ry);
lastV = Math.hypot(b.vx, b.vy);
r.glow[k] = 1;
}
}
07坂を転がった着順で順位が決まるビー玉レース
6個のビー玉がジグザグの坂4本を転がり下り、坂に触れた一歩ごとに接線方向の速さから 1 − e^(−0.4 × 倍率 × h) の割合を差し引きますが、その倍率がビー玉ごとにシードで0.7〜1.3なので速さが分かれます。シード4767の盤はE・B・A・C・D・Fの順に4.51〜5.18秒でゴールし、摩擦を0にすると同じシードで順位がB・E・C・A・F・Dに変わります。チーム発表の順番抽選ページに使えば、順位表は走っている間はコースの進み具合で並び替わり、ゴール線を越えた順とタイムで確定します。

<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">レース中 — ビー玉 6個</p>
<div class="pb__controls">
<button type="button" class="pb__btn" data-role="redraw">もう一度抽選</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);
}
// 07 구슬 레이스 — 비탈 위 중력(선분 충돌이 비탈 방향 성분만 남긴다) + 구름 마찰 + 결승선 통과 순서 順位
// 구름 마찰(1/s) — 비탈에 닿아 있는 동안 비탈 방향 빠르기를 초마다 이 비율로 덜어 낸다. 구슬마다 0.7~1.3 배로 다르다
const RAMP_FRICTION = 0.4;
// 비탈 기울기(세로/가로)
const RAMP_SLOPE = 0.2;
const MARBLE_COUNT = 6;
const GRAVITY = 2600;
const BALL_R = 7;
const RESTITUTION = 0.3;
const SUBSTEPS = 5;
const MAX_SPEED = 900;
const RAMP_HALF = 3;
const RAMP_L = 22;
const RAMP_R = 346;
// 비탈 끝과 맞은편 벽 사이 틈(px) — 구슬이 여기로 떨어져 아래 비탈로 넘어간다
const RAMP_GAP = 74;
const RAMP_TOP = 54;
const RAMP_STEP = 60;
const RAMP_COUNT = 4;
// 결승선 — 마지막 비탈에서 이 x 를 지나면 들어온 것
const FINISH_X = 92;
const MAX_RUN_MS = 20000;
const DEMO_SEED = 4767;
const SETTLE_TICKS = 1300;
const ramps = [];
for (let i = 0; i < RAMP_COUNT; i += 1) {
const run = RAMP_R - RAMP_L - RAMP_GAP;
const y0 = RAMP_TOP + i * RAMP_STEP;
if (i % 2 === 0) {
ramps.push([RAMP_L, y0, RAMP_L + run, y0 + run * RAMP_SLOPE]);
}
else {
ramps.push([RAMP_R, y0, RAMP_R - run, y0 + run * RAMP_SLOPE]);
}
}
const LAST = ramps[RAMP_COUNT - 1];
const walls = [[RAMP_L, HEADER, RAMP_L, 292], [RAMP_R, HEADER, RAMP_R, 292], [RAMP_L, 292, RAMP_R, 292]];
let rng = makeRng(1);
let balls = [];
let order = [];
let calmTicks = 0;
function rampY(s, x) { return s[1] + (x - s[0]) * (s[3] - s[1]) / (s[2] - s[0]); }
function onRestart() {
rng = makeRng(seed);
// 출발 줄 — 이름표를 시드로 섞어 세우고, 구슬마다 구름 마찰 배율을 0.7~1.3 으로
const names = LABELS.slice(0, MARBLE_COUNT);
for (let i = names.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = names[i];
names[i] = names[j];
names[j] = t;
}
balls = [];
for (let i = 0; i < MARBLE_COUNT; i += 1) {
const x = RAMP_L + 14 + i * 16.5;
const b = makeBall(x, rampY(ramps[0], x) - BALL_R - RAMP_HALF - 0.5, BALL_R, BALL_COLORS[LABELS.indexOf(names[i]) % BALL_COLORS.length], names[i]);
b.mul = 0.7 + rng() * 0.6;
balls.push(b);
}
order = [];
calmTicks = 0;
}
function step() {
const hs = DT / SUBSTEPS;
for (let s = 0; s < SUBSTEPS; s += 1) {
for (let i = 0; i < balls.length; i += 1) {
integrate(balls[i], 0, GRAVITY, hs);
}
for (let i = 0; i < balls.length; i += 1) {
for (let j = i + 1; j < balls.length; j += 1) {
hitBalls(balls[i], balls[j], RESTITUTION);
}
}
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
// 이번 걸음에 덜어 낼 몫 — 초당 비율을 걸음 길이로 바꾼다(걸음 수가 바뀌어도 1초에 덜어 내는 양은 같다)
const grip = 1 - Math.exp(-RAMP_FRICTION * b.mul * hs);
for (let k = 0; k < ramps.length; k += 1) {
const r = ramps[k];
hitSegment(b, r[0], r[1], r[2], r[3], RAMP_HALF, RESTITUTION, 0, 0, grip);
}
// 바닥도 같은 구름 마찰 — 들어온 구슬이 바닥에서 끝없이 미끄러지지 않고 멈춘다
hitSegment(b, RAMP_L, 292, RAMP_R, 292, 0, RESTITUTION, 0, 0, grip * 4);
hitWalls(b, walls, RESTITUTION);
capSpeed(b, MAX_SPEED);
}
}
let calm = true;
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
if (b.finishMs < 0 && b.y > LAST[1] - 20 && b.x < FINISH_X) {
b.finishMs = clock;
order.push(i);
}
if (b.finishMs < 0 || Math.hypot(b.vx, b.vy) > 20) {
calm = false;
}
pushTrail(b, 12);
}
calmTicks = calm ? calmTicks + 1 : 0;
}
function finished() { return (order.length === MARBLE_COUNT && calmTicks >= 20) || clock >= MAX_RUN_MS; }
function onControl(role, down) {
if (role === 'redraw' && down) {
seed += 1;
restart();
}
}
function draw() {
clearStage();
for (let i = 0; i < walls.length; i += 1) {
strokeSeg(walls[i], 4, CREAM, 0.45);
}
for (let i = 0; i < ramps.length; i += 1) {
const r = ramps[i];
ctx.fillStyle = rgba(LILAC, 0.16);
ctx.beginPath();
ctx.moveTo(r[0], r[1]);
ctx.lineTo(r[2], r[3]);
ctx.lineTo(r[2], r[3] + 8);
ctx.lineTo(r[0], r[1] + 8);
ctx.closePath();
ctx.fill();
strokeSeg(r, RAMP_HALF * 2, CREAM, 0.85);
}
// 결승선
ctx.setLineDash([5, 5]);
strokeSeg([FINISH_X, rampY(LAST, FINISH_X) - 34, FINISH_X, rampY(LAST, FINISH_X) + 4], 2, LEMON, 0.9);
ctx.setLineDash([]);
for (let i = 0; i < balls.length; i += 1) {
drawTrail(balls[i], balls[i].color, balls[i].r * 1.2);
}
// 비탈 빛 — 비탈에 닿아 구르는 구슬 밑을 빠르기만큼 밝힌다(600px/s 에서 가장 밝다)
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
for (let k = 0; k < ramps.length; k += 1) {
const r = ramps[k];
const c = closestOnSegment(b.x, b.y, r[0], r[1], r[2], r[3]);
if (Math.hypot(b.x - c[0], b.y - c[1]) > b.r + RAMP_HALF + 1.5) {
continue;
}
const glow = clamp(Math.hypot(b.vx, b.vy) / 600, 0, 1);
const dx = (r[2] - r[0]) / Math.hypot(r[2] - r[0], r[3] - r[1]);
const dy = (r[3] - r[1]) / Math.hypot(r[2] - r[0], r[3] - r[1]);
const L = 10 + 26 * glow;
strokeSeg([c[0] - dx * L, c[1] - dy * L, c[0] + dx * L, c[1] + dy * L], RAMP_HALF * 2 + 4, b.color, 0.25 + 0.6 * glow);
}
}
for (let i = 0; i < balls.length; i += 1) {
drawBall(balls[i]);
}
// 순위표 — 들어온 구슬은 들어온 차례대로 시각과 함께 굳히고, 아직 달리는 구슬은 지금 앞선 차례(코스 진행도)로 줄을 바꾼다
const live = standings();
const px = RAMP_R + 12;
ctx.fillStyle = rgba(BAND, 0.55);
ctx.fillRect(px, 40, W - px - 8, 250);
drawText('順位', px + 10, 54, 12, CREAM, 0.9, 'left');
for (let k = 0; k < live.length; k += 1) {
const y = 80 + k * 34;
const b = balls[live[k]];
const done = b.finishMs >= 0;
drawText(String(k + 1), px + 14, y, 13, done ? CREAM : GUIDE, 1, 'center');
if (!done) {
ctx.fillStyle = rgba(b.color, 0.16);
ctx.fillRect(px + 26, y - 13, W - px - 42, 26);
}
const tag = makeBall(px + 40, y, 10, b.color, b.label);
drawBall(tag);
if (done) {
drawText((b.finishMs / 1000).toFixed(2) + 's', px + 58, y, 11, k === 0 ? LEMON : CREAM, 0.95, 'left');
}
}
drawBand();
}
// 코스 진행도 — 위에 지나온 비탈 수 × 1000 + 지금 비탈에서 굴러 온 거리. 들어온 구슬은 들어온 차례가 앞선다
function progressOf(b) {
if (b.finishMs >= 0) {
return 1e7 - b.finishMs;
}
let k = 0;
for (let i = 0; i < ramps.length; i += 1) {
const r = ramps[i];
const x = clamp(b.x, Math.min(r[0], r[2]), Math.max(r[0], r[2]));
if (rampY(r, x) < b.y) {
k = i + 1;
}
}
const on = ramps[Math.min(k, ramps.length - 1)];
const along = Math.abs(clamp(b.x, Math.min(on[0], on[2]), Math.max(on[0], on[2])) - on[0]);
return k * 1000 + along;
}
function standings() {
const idx = [];
for (let i = 0; i < balls.length; i += 1) {
idx.push(i);
}
return idx.sort(function (a, b) { return progressOf(balls[b]) - progressOf(balls[a]); });
}
function stateText() {
if (order.length === MARBLE_COUNT) {
const names = [];
for (let k = 0; k < order.length; k += 1) {
names.push(balls[order[k]].label);
}
return '順位: ' + names.join(', ');
}
if (order.length) {
return 'レース中 — 1位 ' + balls[order[0]].label;
}
return 'レース中 — ビー玉 ' + MARBLE_COUNT + '個';
}
// ── 공통 꼬리: 시계·버튼·동작 줄이기·정리 ──
// 결과를 보여 준 시간(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();
};
})();// MarbleRaceRampRank - vanilla 판(07-pb-marble-race-ramp-rank) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./MarbleRaceRampRank.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MarbleRaceRampRank() {
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);
}
// 07 구슬 레이스 — 비탈 위 중력(선분 충돌이 비탈 방향 성분만 남긴다) + 구름 마찰 + 결승선 통과 순서 순위
// 구름 마찰(1/s) — 비탈에 닿아 있는 동안 비탈 방향 빠르기를 초마다 이 비율로 덜어 낸다. 구슬마다 0.7~1.3 배로 다르다
const RAMP_FRICTION = 0.4;
// 비탈 기울기(세로/가로)
const RAMP_SLOPE = 0.2;
const MARBLE_COUNT = 6;
const GRAVITY = 2600;
const BALL_R = 7;
const RESTITUTION = 0.3;
const SUBSTEPS = 5;
const MAX_SPEED = 900;
const RAMP_HALF = 3;
const RAMP_L = 22;
const RAMP_R = 346;
// 비탈 끝과 맞은편 벽 사이 틈(px) — 구슬이 여기로 떨어져 아래 비탈로 넘어간다
const RAMP_GAP = 74;
const RAMP_TOP = 54;
const RAMP_STEP = 60;
const RAMP_COUNT = 4;
// 결승선 — 마지막 비탈에서 이 x 를 지나면 들어온 것
const FINISH_X = 92;
const MAX_RUN_MS = 20000;
const DEMO_SEED = 4767;
const SETTLE_TICKS = 1300;
const ramps: Seg[] = [];
for (let i = 0; i < RAMP_COUNT; i += 1) {
const run = RAMP_R - RAMP_L - RAMP_GAP;
const y0 = RAMP_TOP + i * RAMP_STEP;
if (i % 2 === 0) { ramps.push([RAMP_L, y0, RAMP_L + run, y0 + run * RAMP_SLOPE]); }
else { ramps.push([RAMP_R, y0, RAMP_R - run, y0 + run * RAMP_SLOPE]); }
}
const LAST = ramps[RAMP_COUNT - 1];
const walls: Seg[] = [[RAMP_L, HEADER, RAMP_L, 292], [RAMP_R, HEADER, RAMP_R, 292], [RAMP_L, 292, RAMP_R, 292]];
let rng = makeRng(1);
let balls: Ball[] = [];
let order: number[] = [];
let calmTicks = 0;
function rampY(s: Seg, x: number): number { return s[1] + (x - s[0]) * (s[3] - s[1]) / (s[2] - s[0]); }
function onRestart(): void {
rng = makeRng(seed);
// 출발 줄 — 이름표를 시드로 섞어 세우고, 구슬마다 구름 마찰 배율을 0.7~1.3 으로
const names = LABELS.slice(0, MARBLE_COUNT);
for (let i = names.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = names[i];
names[i] = names[j];
names[j] = t;
}
balls = [];
for (let i = 0; i < MARBLE_COUNT; i += 1) {
const x = RAMP_L + 14 + i * 16.5;
const b = makeBall(x, rampY(ramps[0], x) - BALL_R - RAMP_HALF - 0.5, BALL_R, BALL_COLORS[LABELS.indexOf(names[i]) % BALL_COLORS.length], names[i]);
b.mul = 0.7 + rng() * 0.6;
balls.push(b);
}
order = [];
calmTicks = 0;
}
function step(): void {
const hs = DT / SUBSTEPS;
for (let s = 0; s < SUBSTEPS; s += 1) {
for (let i = 0; i < balls.length; i += 1) { integrate(balls[i], 0, GRAVITY, hs); }
for (let i = 0; i < balls.length; i += 1) {
for (let j = i + 1; j < balls.length; j += 1) { hitBalls(balls[i], balls[j], RESTITUTION); }
}
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
// 이번 걸음에 덜어 낼 몫 — 초당 비율을 걸음 길이로 바꾼다(걸음 수가 바뀌어도 1초에 덜어 내는 양은 같다)
const grip = 1 - Math.exp(-RAMP_FRICTION * b.mul * hs);
for (let k = 0; k < ramps.length; k += 1) {
const r = ramps[k];
hitSegment(b, r[0], r[1], r[2], r[3], RAMP_HALF, RESTITUTION, 0, 0, grip);
}
// 바닥도 같은 구름 마찰 — 들어온 구슬이 바닥에서 끝없이 미끄러지지 않고 멈춘다
hitSegment(b, RAMP_L, 292, RAMP_R, 292, 0, RESTITUTION, 0, 0, grip * 4);
hitWalls(b, walls, RESTITUTION);
capSpeed(b, MAX_SPEED);
}
}
let calm = true;
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
if (b.finishMs < 0 && b.y > LAST[1] - 20 && b.x < FINISH_X) {
b.finishMs = clock;
order.push(i);
}
if (b.finishMs < 0 || Math.hypot(b.vx, b.vy) > 20) { calm = false; }
pushTrail(b, 12);
}
calmTicks = calm ? calmTicks + 1 : 0;
}
function finished(): boolean { return (order.length === MARBLE_COUNT && calmTicks >= 20) || clock >= MAX_RUN_MS; }
function onControl(role: string, down: boolean): void {
if (role === 'redraw' && down) {
seed += 1;
restart();
}
}
function draw(): void {
clearStage();
for (let i = 0; i < walls.length; i += 1) { strokeSeg(walls[i], 4, CREAM, 0.45); }
for (let i = 0; i < ramps.length; i += 1) {
const r = ramps[i];
ctx.fillStyle = rgba(LILAC, 0.16);
ctx.beginPath();
ctx.moveTo(r[0], r[1]);
ctx.lineTo(r[2], r[3]);
ctx.lineTo(r[2], r[3] + 8);
ctx.lineTo(r[0], r[1] + 8);
ctx.closePath();
ctx.fill();
strokeSeg(r, RAMP_HALF * 2, CREAM, 0.85);
}
// 결승선
ctx.setLineDash([5, 5]);
strokeSeg([FINISH_X, rampY(LAST, FINISH_X) - 34, FINISH_X, rampY(LAST, FINISH_X) + 4], 2, LEMON, 0.9);
ctx.setLineDash([]);
for (let i = 0; i < balls.length; i += 1) { drawTrail(balls[i], balls[i].color, balls[i].r * 1.2); }
// 비탈 빛 — 비탈에 닿아 구르는 구슬 밑을 빠르기만큼 밝힌다(600px/s 에서 가장 밝다)
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
for (let k = 0; k < ramps.length; k += 1) {
const r = ramps[k];
const c = closestOnSegment(b.x, b.y, r[0], r[1], r[2], r[3]);
if (Math.hypot(b.x - c[0], b.y - c[1]) > b.r + RAMP_HALF + 1.5) { continue; }
const glow = clamp(Math.hypot(b.vx, b.vy) / 600, 0, 1);
const dx = (r[2] - r[0]) / Math.hypot(r[2] - r[0], r[3] - r[1]);
const dy = (r[3] - r[1]) / Math.hypot(r[2] - r[0], r[3] - r[1]);
const L = 10 + 26 * glow;
strokeSeg([c[0] - dx * L, c[1] - dy * L, c[0] + dx * L, c[1] + dy * L], RAMP_HALF * 2 + 4, b.color, 0.25 + 0.6 * glow);
}
}
for (let i = 0; i < balls.length; i += 1) { drawBall(balls[i]); }
// 순위표 — 들어온 구슬은 들어온 차례대로 시각과 함께 굳히고, 아직 달리는 구슬은 지금 앞선 차례(코스 진행도)로 줄을 바꾼다
const live = standings();
const px = RAMP_R + 12;
ctx.fillStyle = rgba(BAND, 0.55);
ctx.fillRect(px, 40, W - px - 8, 250);
drawText('순위', px + 10, 54, 12, CREAM, 0.9, 'left');
for (let k = 0; k < live.length; k += 1) {
const y = 80 + k * 34;
const b = balls[live[k]];
const done = b.finishMs >= 0;
drawText(String(k + 1), px + 14, y, 13, done ? CREAM : GUIDE, 1, 'center');
if (!done) {
ctx.fillStyle = rgba(b.color, 0.16);
ctx.fillRect(px + 26, y - 13, W - px - 42, 26);
}
const tag = makeBall(px + 40, y, 10, b.color, b.label);
drawBall(tag);
if (done) { drawText((b.finishMs / 1000).toFixed(2) + 's', px + 58, y, 11, k === 0 ? LEMON : CREAM, 0.95, 'left'); }
}
drawBand();
}
// 코스 진행도 — 위에 지나온 비탈 수 × 1000 + 지금 비탈에서 굴러 온 거리. 들어온 구슬은 들어온 차례가 앞선다
function progressOf(b: Ball): number {
if (b.finishMs >= 0) { return 1e7 - b.finishMs; }
let k = 0;
for (let i = 0; i < ramps.length; i += 1) {
const r = ramps[i];
const x = clamp(b.x, Math.min(r[0], r[2]), Math.max(r[0], r[2]));
if (rampY(r, x) < b.y) { k = i + 1; }
}
const on = ramps[Math.min(k, ramps.length - 1)];
const along = Math.abs(clamp(b.x, Math.min(on[0], on[2]), Math.max(on[0], on[2])) - on[0]);
return k * 1000 + along;
}
function standings(): number[] {
const idx: number[] = [];
for (let i = 0; i < balls.length; i += 1) { idx.push(i); }
return idx.sort(function (a: number, b: number): number { return progressOf(balls[b]) - progressOf(balls[a]); });
}
function stateText(): string {
if (order.length === MARBLE_COUNT) {
const names: string[] = [];
for (let k = 0; k < order.length; k += 1) { names.push(balls[order[k]].label); }
return '순위: ' + names.join(', ');
}
if (order.length) { return '레이스 중 — 1위 ' + balls[order[0]].label; }
return '레이스 중 — 구슬 ' + MARBLE_COUNT + '개';
}
// ── 공통 꼬리: 시계·버튼·동작 줄이기·정리 ──
// 결과를 보여 준 시간(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">레이스 중 — 구슬 6개</p>
<div className={cx("pb__controls")}>
<button type="button" className={cx("pb__btn")} data-role="redraw">다시 추첨</button>
</div>
</div>
</div>
);
}07 · 坂を転がった着順で順位が決まるビー玉レース — 計測 累積面積 14.235% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// コースの進み具合 — 上で通り過ぎた坂の数 × 1000 + いまの坂で転がった距離。ゴールしたビー玉はゴールした順が先
function progressOf(b) {
if (b.finishMs >= 0) {
return 1e7 - b.finishMs;
}
let k = 0;
for (let i = 0; i < ramps.length; i += 1) {
const r = ramps[i];
const x = clamp(b.x, Math.min(r[0], r[2]), Math.max(r[0], r[2]));
if (rampY(r, x) < b.y) {
k = i + 1;
}
}
const on = ramps[Math.min(k, ramps.length - 1)];
const along = Math.abs(clamp(b.x, Math.min(on[0], on[2]), Math.max(on[0], on[2])) - on[0]);
return k * 1000 + along;
}
function standings() {
const idx = [];
for (let i = 0; i < balls.length; i += 1) {
idx.push(i);
}
return idx.sort(function (a, b) { return progressOf(balls[b]) - progressOf(balls[a]); });
}
08落ちた枠を積み上げて釣り鐘の形が現れる分布盤
60個のボールを70ms間隔で落として9つの枠に入った数を数え、棒の高さはティックごとに残りの差の0.14だけ追いかけて伸び、その上にbinomShareで出した期待曲線が重なります。シード4768の実演では60個のうち16個(26.7%)が真ん中の枠に入り、期待の割合27.3%に近い値でした。落ちきるとシードで混ぜた名札が700msかけて自分の枠へ滑っていき、レモン色のボールが入った7番の枠のHが当選になるので、公平な景品抽選の案内ページで偏りと混ぜ方を一画面で見せられます。

<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 — 真ん中の枠 0%</p>
<div class="pb__controls">
<button type="button" class="pb__btn" data-role="redraw">もう一度抽選</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);
}
// 08 분포판 — 칸별 개수 누적 + 막대 높이 lerp + 이항 期待 곡선 + 이름을 칸에 섞어 배정
// 공을 하나씩 떨어뜨리는 간격(ms)
const DROP_INTERVAL_MS = 70;
// 바닥 칸 수 — 핀 줄은 칸 수보다 하나 적다(줄마다 왼쪽·오른쪽 갈림이 한 번)
const SLOT_COUNT = 9;
// 이름을 칸에 섞어 배정하나 — false 면 A 부터 차례로 칸에 붙는다(가운데 이름이 자주 당첨된다)
const SHUFFLE_SLOTS = true;
const DROP_TOTAL = 60;
const ROWS = SLOT_COUNT - 1;
const PEG_DX = 26;
const PEG_DY = 20;
const APEX_X = 168;
const APEX_Y = 66;
const PEG_R = 3;
const BALL_R = 3.5;
const GOLD_R = 5;
const GRAVITY = 2200;
const RESTITUTION = 0.35;
// 핀에 닿을 때마다 — 옆 빠르기를 PEG_DAMP_X 배로 줄이고 시드 난수로 ±PEG_JITTER/2 를 더한다.
// 공과 핀이 완벽히 둥글고 똑같으면 같은 길만 되풀이한다(흔들림 0 이면 칸 몫이 거의 고르다) — 실제 판의 작은 흠 대신 이 흔들림이 갈림을 반반에 가깝게 만든다
const PEG_DAMP_X = 0.45;
const PEG_JITTER = 110;
const SUBSTEPS = 4;
// 막대 높이가 목표를 따라가는 몫 — 틱마다 남은 차의 14%
const BAR_LERP = 0.14;
const BAR_MAX_H = 58;
const BAR_BOTTOM = 284;
const SLOT_TOP = APEX_Y + (ROWS - 1) * PEG_DY + 14;
const SLOT_LEFT = APEX_X - SLOT_COUNT / 2 * PEG_DX;
const SHUFFLE_MS = 700;
const DEMO_SEED = 4768;
const SETTLE_TICKS = 900;
const pegs = [];
for (let r = 0; r < ROWS; r += 1) {
for (let k = 0; k <= r; k += 1) {
pegs.push({ x: APEX_X + (k - r / 2) * PEG_DX, y: APEX_Y + r * PEG_DY, glow: 0 });
}
}
const EDGE = (ROWS - 0.5) * PEG_DX / 2 + 18;
const walls = [
[APEX_X - 11, APEX_Y - 10, APEX_X - EDGE, APEX_Y + (ROWS - 0.5) * PEG_DY],
[APEX_X + 11, APEX_Y - 10, APEX_X + EDGE, APEX_Y + (ROWS - 0.5) * PEG_DY],
[SLOT_LEFT, APEX_Y + (ROWS - 0.5) * PEG_DY, SLOT_LEFT, BAR_BOTTOM],
[SLOT_LEFT + SLOT_COUNT * PEG_DX, APEX_Y + (ROWS - 0.5) * PEG_DY, SLOT_LEFT + SLOT_COUNT * PEG_DX, BAR_BOTTOM]
];
const dividers = [];
for (let k = 1; k < SLOT_COUNT; k += 1) {
dividers.push([SLOT_LEFT + k * PEG_DX, SLOT_TOP, SLOT_LEFT + k * PEG_DX, BAR_BOTTOM]);
}
// 이항 期待 몫 — 줄마다 반반 갈리면 k 칸에 들 몫 = (ROWS 個 중 k 개를 고르는 수) / 2^ROWS
function binomShare(k) {
let c = 1;
for (let i = 0; i < k; i += 1) {
c = c * (ROWS - i) / (i + 1);
}
return c / Math.pow(2, ROWS);
}
const EXPECT = [];
for (let k = 0; k < SLOT_COUNT; k += 1) {
EXPECT.push(binomShare(k));
}
let rng = makeRng(1);
let live = [];
let counts = [];
let shown = [];
let names = [];
let landed = 0;
let dropped = 0;
let dropMs = 0;
let phase = 'drop';
let phaseMs = 0;
let winSlot = -1;
let flash = [];
function slotAt(x) { return clamp(Math.floor((x - SLOT_LEFT) / PEG_DX), 0, SLOT_COUNT - 1); }
function slotX(k) { return SLOT_LEFT + (k + 0.5) * PEG_DX; }
function onRestart() {
rng = makeRng(seed);
live = [];
counts = [];
shown = [];
flash = [];
for (let k = 0; k < SLOT_COUNT; k += 1) {
counts.push(0);
shown.push(0);
flash.push(0);
}
// 이름 배정 — 섞기면 시드 난수로 뒤에서부터 한 자리씩 맞바꾼다
names = LABELS.slice(0, SLOT_COUNT);
if (SHUFFLE_SLOTS) {
for (let i = names.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = names[i];
names[i] = names[j];
names[j] = t;
}
}
landed = 0;
dropped = 0;
dropMs = 0;
phase = 'drop';
phaseMs = 0;
winSlot = -1;
}
function dropBall(gold) {
const b = makeBall(APEX_X + (rng() - 0.5) * 1.5, 44, gold ? GOLD_R : BALL_R, gold ? LEMON : BALL_COLORS[dropped % 6], '');
b.vx = (rng() - 0.5) * 14;
b.mul = gold ? 1 : 0;
live.push(b);
}
function barUnit() {
let peak = 1;
for (let k = 0; k < SLOT_COUNT; k += 1) {
peak = Math.max(peak, counts[k], EXPECT[k] * landed);
}
return Math.min(3, BAR_MAX_H / peak);
}
function step() {
phaseMs += FIXED_DT;
if (phase === 'drop') {
dropMs += FIXED_DT;
while (dropMs >= DROP_INTERVAL_MS && dropped < DROP_TOTAL) {
dropMs -= DROP_INTERVAL_MS;
dropBall(false);
dropped += 1;
}
if (landed >= DROP_TOTAL) {
phase = 'shuffle';
phaseMs = 0;
}
}
else if (phase === 'shuffle' && phaseMs >= SHUFFLE_MS) {
phase = 'gold';
phaseMs = 0;
dropBall(true);
}
const hs = DT / SUBSTEPS;
const unit = barUnit();
for (let s = 0; s < SUBSTEPS; s += 1) {
for (let i = 0; i < live.length; i += 1) {
integrate(live[i], 0, GRAVITY, hs);
}
for (let i = 0; i < live.length; i += 1) {
const b = live[i];
for (let p = 0; p < pegs.length; p += 1) {
const g = pegs[p];
if (Math.abs(g.y - b.y) > b.r + PEG_R) {
continue;
}
if (hitCircle(b, g.x, g.y, PEG_R, RESTITUTION) > 0) {
g.glow = 1;
b.vx = b.vx * PEG_DAMP_X + (rng() - 0.5) * PEG_JITTER;
}
}
hitWalls(b, walls, RESTITUTION);
hitWalls(b, dividers, RESTITUTION);
}
}
// 칸에 닿은 공 — 그 칸 막대 꼭대기에 닿으면 개수에 넣고 판에서 뺀다
const keep = [];
for (let i = 0; i < live.length; i += 1) {
const b = live[i];
const k = slotAt(b.x);
if (b.y > SLOT_TOP && b.y + b.r >= BAR_BOTTOM - shown[k] - 1) {
flash[k] = 1;
if (b.mul > 0) {
winSlot = k;
phase = 'done';
phaseMs = 0;
}
else {
counts[k] += 1;
landed += 1;
}
}
else {
pushTrail(b, 6);
keep.push(b);
}
}
live = keep;
// 막대 높이 — 목표(개수 × 배율)를 틱마다 BAR_LERP 몫씩 따라간다
for (let k = 0; k < SLOT_COUNT; k += 1) {
shown[k] += (counts[k] * unit - shown[k]) * BAR_LERP;
flash[k] *= 0.88;
}
for (let p = 0; p < pegs.length; p += 1) {
pegs[p].glow *= 0.85;
}
}
function finished() { return phase === 'done'; }
function onControl(role, down) {
if (role === 'redraw' && down) {
seed += 1;
restart();
}
}
function middleShare() { return landed ? counts[ROWS / 2] / landed : 0; }
function draw() {
clearStage();
const unit = barUnit();
for (let k = 0; k < SLOT_COUNT; k += 1) {
const x = SLOT_LEFT + k * PEG_DX;
const won = k === winSlot;
ctx.fillStyle = rgba(won ? LEMON : mixRGB(SKY, CREAM, flash[k] * 0.6), won ? 1 : 0.85);
ctx.fillRect(x + 3, BAR_BOTTOM - shown[k], PEG_DX - 6, shown[k]);
}
for (let i = 0; i < walls.length; i += 1) {
strokeSeg(walls[i], 3, CREAM, 0.45);
}
for (let i = 0; i < dividers.length; i += 1) {
strokeSeg(dividers[i], 2, CREAM, 0.4);
}
strokeSeg([SLOT_LEFT, BAR_BOTTOM, SLOT_LEFT + SLOT_COUNT * PEG_DX, BAR_BOTTOM], 2, CREAM, 0.6);
// 이항 期待 곡선 — 지금까지 떨어진 개수 × 期待 몫 높이를 칸 가운데마다 잇는다
if (landed > 0) {
ctx.setLineDash([4, 4]);
ctx.strokeStyle = rgba(PINK, 0.95);
ctx.lineWidth = 2;
ctx.beginPath();
for (let k = 0; k < SLOT_COUNT; k += 1) {
const y = BAR_BOTTOM - EXPECT[k] * landed * unit;
if (k === 0) {
ctx.moveTo(slotX(k), y);
}
else {
ctx.lineTo(slotX(k), y);
}
}
ctx.stroke();
ctx.setLineDash([]);
}
for (let p = 0; p < pegs.length; p += 1) {
const g = pegs[p];
// 맞은 핀 — 빛이 번졌다가 틱마다 0.85 배로 잦아든다(공이 많이 지나는 가운데 길이 더 자주 밝다)
if (g.glow > 0.05) {
ctx.fillStyle = rgba(LILAC, 0.45 * g.glow);
ctx.beginPath();
ctx.arc(g.x, g.y, PEG_R + 7 * g.glow, 0, TAU);
ctx.fill();
}
ctx.fillStyle = rgba(mixRGB(CREAM, LILAC, g.glow), 1);
ctx.beginPath();
ctx.arc(g.x, g.y, PEG_R + g.glow, 0, TAU);
ctx.fill();
}
for (let i = 0; i < live.length; i += 1) {
const b = live[i];
drawTrail(b, b.color, b.r * 1.4);
ctx.fillStyle = rgba(b.color, 1);
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, TAU);
ctx.fill();
}
// 이름표 — 섞는 동안 차례 자리에서 섞인 자리로 미끄러진다
const k0 = phase === 'drop' ? 0 : smooth(phase === 'shuffle' ? phaseMs / SHUFFLE_MS : 1);
for (let k = 0; k < SLOT_COUNT; k += 1) {
const from = LABELS.indexOf(names[k]);
drawText(names[k], lerp(slotX(from), slotX(k), k0), 293, 10, k === winSlot ? LEMON : CREAM, 0.95, 'center');
}
// 오른쪽 판 — 숫자와 범례
const px = 318;
ctx.fillStyle = rgba(BAND, 0.55);
ctx.fillRect(px, 40, W - px - 8, 250);
drawText('落ちたボール ' + landed, px + 10, 58, 12, CREAM, 0.95, 'left');
drawText('真ん中の枠 ' + Math.round(middleShare() * 100) + '%', px + 10, 80, 12, SKY, 1, 'left');
drawText('期待 ' + Math.round(EXPECT[ROWS / 2] * 100) + '%', px + 10, 100, 12, PINK, 1, 'left');
drawText('棒 = 実際の数', px + 10, 132, 10, CREAM, 0.7, 'left');
drawText('点線 = 二項分布の期待曲線', px + 10, 150, 10, CREAM, 0.7, 'left');
drawText(SHUFFLE_SLOTS ? '名前を混ぜて割り当て' : '名前を順番に割り当て', px + 10, 178, 11, LILAC, 1, 'left');
if (winSlot >= 0) {
drawText('当選の枠 ' + (winSlot + 1) + ' → ' + names[winSlot], px + 10, 210, 13, LEMON, 1, 'left');
}
drawBand();
}
function stateText() {
if (winSlot >= 0) {
return '当選の枠 ' + (winSlot + 1) + ' → ' + names[winSlot];
}
return '落ちたボール ' + landed + ' — 真ん中の枠 ' + Math.round(middleShare() * 100) + '%';
}
// ── 공통 꼬리: 시계·버튼·동작 줄이기·정리 ──
// 결과를 보여 준 시간(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();
};
})();// LandingSlotBellHistogram - vanilla 판(08-pb-landing-slot-bell-histogram) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LandingSlotBellHistogram.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LandingSlotBellHistogram() {
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);
}
// 08 분포판 — 칸별 개수 누적 + 막대 높이 lerp + 이항 기대 곡선 + 이름을 칸에 섞어 배정
// 공을 하나씩 떨어뜨리는 간격(ms)
const DROP_INTERVAL_MS = 70;
// 바닥 칸 수 — 핀 줄은 칸 수보다 하나 적다(줄마다 왼쪽·오른쪽 갈림이 한 번)
const SLOT_COUNT = 9;
// 이름을 칸에 섞어 배정하나 — false 면 A 부터 차례로 칸에 붙는다(가운데 이름이 자주 당첨된다)
const SHUFFLE_SLOTS = true;
const DROP_TOTAL = 60;
const ROWS = SLOT_COUNT - 1;
const PEG_DX = 26;
const PEG_DY = 20;
const APEX_X = 168;
const APEX_Y = 66;
const PEG_R = 3;
const BALL_R = 3.5;
const GOLD_R = 5;
const GRAVITY = 2200;
const RESTITUTION = 0.35;
// 핀에 닿을 때마다 — 옆 빠르기를 PEG_DAMP_X 배로 줄이고 시드 난수로 ±PEG_JITTER/2 를 더한다.
// 공과 핀이 완벽히 둥글고 똑같으면 같은 길만 되풀이한다(흔들림 0 이면 칸 몫이 거의 고르다) — 실제 판의 작은 흠 대신 이 흔들림이 갈림을 반반에 가깝게 만든다
const PEG_DAMP_X = 0.45;
const PEG_JITTER = 110;
const SUBSTEPS = 4;
// 막대 높이가 목표를 따라가는 몫 — 틱마다 남은 차의 14%
const BAR_LERP = 0.14;
const BAR_MAX_H = 58;
const BAR_BOTTOM = 284;
const SLOT_TOP = APEX_Y + (ROWS - 1) * PEG_DY + 14;
const SLOT_LEFT = APEX_X - SLOT_COUNT / 2 * PEG_DX;
const SHUFFLE_MS = 700;
const DEMO_SEED = 4768;
const SETTLE_TICKS = 900;
const pegs: Peg[] = [];
for (let r = 0; r < ROWS; r += 1) {
for (let k = 0; k <= r; k += 1) { pegs.push({ x: APEX_X + (k - r / 2) * PEG_DX, y: APEX_Y + r * PEG_DY, glow: 0 }); }
}
const EDGE = (ROWS - 0.5) * PEG_DX / 2 + 18;
const walls: Seg[] = [
[APEX_X - 11, APEX_Y - 10, APEX_X - EDGE, APEX_Y + (ROWS - 0.5) * PEG_DY],
[APEX_X + 11, APEX_Y - 10, APEX_X + EDGE, APEX_Y + (ROWS - 0.5) * PEG_DY],
[SLOT_LEFT, APEX_Y + (ROWS - 0.5) * PEG_DY, SLOT_LEFT, BAR_BOTTOM],
[SLOT_LEFT + SLOT_COUNT * PEG_DX, APEX_Y + (ROWS - 0.5) * PEG_DY, SLOT_LEFT + SLOT_COUNT * PEG_DX, BAR_BOTTOM]
];
const dividers: Seg[] = [];
for (let k = 1; k < SLOT_COUNT; k += 1) { dividers.push([SLOT_LEFT + k * PEG_DX, SLOT_TOP, SLOT_LEFT + k * PEG_DX, BAR_BOTTOM]); }
// 이항 기대 몫 — 줄마다 반반 갈리면 k 칸에 들 몫 = (ROWS 개 중 k 개를 고르는 수) / 2^ROWS
function binomShare(k: number): number {
let c = 1;
for (let i = 0; i < k; i += 1) { c = c * (ROWS - i) / (i + 1); }
return c / Math.pow(2, ROWS);
}
const EXPECT: number[] = [];
for (let k = 0; k < SLOT_COUNT; k += 1) { EXPECT.push(binomShare(k)); }
let rng = makeRng(1);
let live: Ball[] = [];
let counts: number[] = [];
let shown: number[] = [];
let names: string[] = [];
let landed = 0;
let dropped = 0;
let dropMs = 0;
let phase = 'drop';
let phaseMs = 0;
let winSlot = -1;
let flash: number[] = [];
function slotAt(x: number): number { return clamp(Math.floor((x - SLOT_LEFT) / PEG_DX), 0, SLOT_COUNT - 1); }
function slotX(k: number): number { return SLOT_LEFT + (k + 0.5) * PEG_DX; }
function onRestart(): void {
rng = makeRng(seed);
live = [];
counts = [];
shown = [];
flash = [];
for (let k = 0; k < SLOT_COUNT; k += 1) {
counts.push(0);
shown.push(0);
flash.push(0);
}
// 이름 배정 — 섞기면 시드 난수로 뒤에서부터 한 자리씩 맞바꾼다
names = LABELS.slice(0, SLOT_COUNT);
if (SHUFFLE_SLOTS) {
for (let i = names.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = names[i];
names[i] = names[j];
names[j] = t;
}
}
landed = 0;
dropped = 0;
dropMs = 0;
phase = 'drop';
phaseMs = 0;
winSlot = -1;
}
function dropBall(gold: boolean): void {
const b = makeBall(APEX_X + (rng() - 0.5) * 1.5, 44, gold ? GOLD_R : BALL_R, gold ? LEMON : BALL_COLORS[dropped % 6], '');
b.vx = (rng() - 0.5) * 14;
b.mul = gold ? 1 : 0;
live.push(b);
}
function barUnit(): number {
let peak = 1;
for (let k = 0; k < SLOT_COUNT; k += 1) { peak = Math.max(peak, counts[k], EXPECT[k] * landed); }
return Math.min(3, BAR_MAX_H / peak);
}
function step(): void {
phaseMs += FIXED_DT;
if (phase === 'drop') {
dropMs += FIXED_DT;
while (dropMs >= DROP_INTERVAL_MS && dropped < DROP_TOTAL) {
dropMs -= DROP_INTERVAL_MS;
dropBall(false);
dropped += 1;
}
if (landed >= DROP_TOTAL) {
phase = 'shuffle';
phaseMs = 0;
}
} else if (phase === 'shuffle' && phaseMs >= SHUFFLE_MS) {
phase = 'gold';
phaseMs = 0;
dropBall(true);
}
const hs = DT / SUBSTEPS;
const unit = barUnit();
for (let s = 0; s < SUBSTEPS; s += 1) {
for (let i = 0; i < live.length; i += 1) { integrate(live[i], 0, GRAVITY, hs); }
for (let i = 0; i < live.length; i += 1) {
const b = live[i];
for (let p = 0; p < pegs.length; p += 1) {
const g = pegs[p];
if (Math.abs(g.y - b.y) > b.r + PEG_R) { continue; }
if (hitCircle(b, g.x, g.y, PEG_R, RESTITUTION) > 0) {
g.glow = 1;
b.vx = b.vx * PEG_DAMP_X + (rng() - 0.5) * PEG_JITTER;
}
}
hitWalls(b, walls, RESTITUTION);
hitWalls(b, dividers, RESTITUTION);
}
}
// 칸에 닿은 공 — 그 칸 막대 꼭대기에 닿으면 개수에 넣고 판에서 뺀다
const keep: Ball[] = [];
for (let i = 0; i < live.length; i += 1) {
const b = live[i];
const k = slotAt(b.x);
if (b.y > SLOT_TOP && b.y + b.r >= BAR_BOTTOM - shown[k] - 1) {
flash[k] = 1;
if (b.mul > 0) {
winSlot = k;
phase = 'done';
phaseMs = 0;
} else {
counts[k] += 1;
landed += 1;
}
} else {
pushTrail(b, 6);
keep.push(b);
}
}
live = keep;
// 막대 높이 — 목표(개수 × 배율)를 틱마다 BAR_LERP 몫씩 따라간다
for (let k = 0; k < SLOT_COUNT; k += 1) {
shown[k] += (counts[k] * unit - shown[k]) * BAR_LERP;
flash[k] *= 0.88;
}
for (let p = 0; p < pegs.length; p += 1) { pegs[p].glow *= 0.85; }
}
function finished(): boolean { return phase === 'done'; }
function onControl(role: string, down: boolean): void {
if (role === 'redraw' && down) {
seed += 1;
restart();
}
}
function middleShare(): number { return landed ? counts[ROWS / 2] / landed : 0; }
function draw(): void {
clearStage();
const unit = barUnit();
for (let k = 0; k < SLOT_COUNT; k += 1) {
const x = SLOT_LEFT + k * PEG_DX;
const won = k === winSlot;
ctx.fillStyle = rgba(won ? LEMON : mixRGB(SKY, CREAM, flash[k] * 0.6), won ? 1 : 0.85);
ctx.fillRect(x + 3, BAR_BOTTOM - shown[k], PEG_DX - 6, shown[k]);
}
for (let i = 0; i < walls.length; i += 1) { strokeSeg(walls[i], 3, CREAM, 0.45); }
for (let i = 0; i < dividers.length; i += 1) { strokeSeg(dividers[i], 2, CREAM, 0.4); }
strokeSeg([SLOT_LEFT, BAR_BOTTOM, SLOT_LEFT + SLOT_COUNT * PEG_DX, BAR_BOTTOM], 2, CREAM, 0.6);
// 이항 기대 곡선 — 지금까지 떨어진 개수 × 기대 몫 높이를 칸 가운데마다 잇는다
if (landed > 0) {
ctx.setLineDash([4, 4]);
ctx.strokeStyle = rgba(PINK, 0.95);
ctx.lineWidth = 2;
ctx.beginPath();
for (let k = 0; k < SLOT_COUNT; k += 1) {
const y = BAR_BOTTOM - EXPECT[k] * landed * unit;
if (k === 0) { ctx.moveTo(slotX(k), y); } else { ctx.lineTo(slotX(k), y); }
}
ctx.stroke();
ctx.setLineDash([]);
}
for (let p = 0; p < pegs.length; p += 1) {
const g = pegs[p];
// 맞은 핀 — 빛이 번졌다가 틱마다 0.85 배로 잦아든다(공이 많이 지나는 가운데 길이 더 자주 밝다)
if (g.glow > 0.05) {
ctx.fillStyle = rgba(LILAC, 0.45 * g.glow);
ctx.beginPath();
ctx.arc(g.x, g.y, PEG_R + 7 * g.glow, 0, TAU);
ctx.fill();
}
ctx.fillStyle = rgba(mixRGB(CREAM, LILAC, g.glow), 1);
ctx.beginPath();
ctx.arc(g.x, g.y, PEG_R + g.glow, 0, TAU);
ctx.fill();
}
for (let i = 0; i < live.length; i += 1) {
const b = live[i];
drawTrail(b, b.color, b.r * 1.4);
ctx.fillStyle = rgba(b.color, 1);
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, TAU);
ctx.fill();
}
// 이름표 — 섞는 동안 차례 자리에서 섞인 자리로 미끄러진다
const k0 = phase === 'drop' ? 0 : smooth(phase === 'shuffle' ? phaseMs / SHUFFLE_MS : 1);
for (let k = 0; k < SLOT_COUNT; k += 1) {
const from = LABELS.indexOf(names[k]);
drawText(names[k], lerp(slotX(from), slotX(k), k0), 293, 10, k === winSlot ? LEMON : CREAM, 0.95, 'center');
}
// 오른쪽 판 — 숫자와 범례
const px = 318;
ctx.fillStyle = rgba(BAND, 0.55);
ctx.fillRect(px, 40, W - px - 8, 250);
drawText('떨어진 공 ' + landed, px + 10, 58, 12, CREAM, 0.95, 'left');
drawText('가운데 칸 ' + Math.round(middleShare() * 100) + '%', px + 10, 80, 12, SKY, 1, 'left');
drawText('기대 ' + Math.round(EXPECT[ROWS / 2] * 100) + '%', px + 10, 100, 12, PINK, 1, 'left');
drawText('막대 = 실제 개수', px + 10, 132, 10, CREAM, 0.7, 'left');
drawText('점선 = 이항 기대 곡선', px + 10, 150, 10, CREAM, 0.7, 'left');
drawText(SHUFFLE_SLOTS ? '이름 섞어 배정' : '이름 차례 배정', px + 10, 178, 11, LILAC, 1, 'left');
if (winSlot >= 0) { drawText('당첨 칸 ' + (winSlot + 1) + ' → ' + names[winSlot], px + 10, 210, 13, LEMON, 1, 'left'); }
drawBand();
}
function stateText(): string {
if (winSlot >= 0) { return '당첨 칸 ' + (winSlot + 1) + ' → ' + names[winSlot]; }
return '떨어진 공 ' + landed + ' — 가운데 칸 ' + Math.round(middleShare() * 100) + '%';
}
// ── 공통 꼬리: 시계·버튼·동작 줄이기·정리 ──
// 결과를 보여 준 시간(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 — 가운데 칸 0%</p>
<div className={cx("pb__controls")}>
<button type="button" className={cx("pb__btn")} data-role="redraw">다시 추첨</button>
</div>
</div>
</div>
);
}08 · 落ちた枠を積み上げて釣り鐘の形が現れる分布盤 — 計測 累積面積 8.804% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 二項分布の期待の割合 — 段ごとに半々に分かれると k 番目の枠に入る割合 = (ROWS 個から k 個を選ぶ数) / 2^ROWS
function binomShare(k) {
let c = 1;
for (let i = 0; i < k; i += 1) {
c = c * (ROWS - i) / (i + 1);
}
return c / Math.pow(2, ROWS);
}
09フリッパー・バンパー・スコア表示をまとめたミニピンボール台
02と同じ関数で回るフリッパー二本(長さ70)、03のバンパー三つ(半径19、300px/s)、05の発射バネを一台に載せ、バンパー100点・フリッパーで打つと10点を、600msかけて始めと終わりがゆっくりな曲線で数字を転がして足します。「揺らす」ボタンは台の上のボールに横へ160px/s、上へ56px/sを足し、画面にはばねの速さだけを与えるので、画面が6.39px押されて−3.79px跳ね返り592msで±0.5px以内に止まる間も物理の座標はそのままです。ゲーム発売ランディングのトップ画面に置けば、実演の20秒でスコア1,580点と揺らし6回がひとりでに積み上がります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 揺らす — 台の上のボールに同じ衝撃を足し、画面にはばねの速さを与える(画面の揺れは絵だけ、ボールは衝撃だけを受ける)
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;
}
重なった分だけ押し戻し、触れた向きへ跳ね返す — 円と線分の衝突と、回るフリッパーの接点の速さの計算
重なった深さと法線方向の速度だけを見る円の衝突
「ピンボール 物理」と聞くと難しそうですが、土台になるのはこの円の衝突ひとつです。hitCircleはボールの中心とピンの中心の距離dを測り、半径の和より短ければ、その差である重なりの深さの分だけ、二つの中心を結ぶ向きにボールを押し出します。続いて速度をその向きの成分vnと残りに分け、vnが近づく側のときだけ(1 + e) × vnを引いて、法線成分を−e倍に裏返します。横に滑る成分には手を付けないので、ボールはピンの側面をかすめて斜めに抜け、01のe 0.5では近づいていた速さの半分が跳ね返りとして残ります。文字を箱ひとつとして見てぶつける計算は文字 物理 演算で扱いましたが、ここではボールもピンも円なので、角を考えずに中心線一本で片づきます。
線分はいちばん近い一点で円になる
02・09のフリッパー、04の斜辺、06の羽根、07の坂はどれも線分です。closestOnSegmentがボールの中心から線分に下ろした垂線の足を0〜1の区間で切って求めると、その点を半径が線の太さの半分の円とみなし、同じ押し出しと跳ね返りをかけます。円と違うのは接点が動けることだけなので、hitSegmentはボールの速度から接点の速度(svx, svy)を引いた相対速度で跳ね返りを測ります。07で坂の向きの力を別に計算しなくてもビー玉が転がり下りるのもこのおかげで、衝突が坂に垂直な成分だけを消すため、重力のうち坂に沿う分だけが残ります。
回る棒の上の一点の速さは角速度 × 軸までの距離
軸から接点へのベクトル(rx, ry)を90°回すとその点が回る向きになり、角速度wを掛けた(−w·ry, w·rx)が接点の速度です。大きさはw × rなので、02の先端(86px)は12 × 86 = 1,032px/s、06の先端(58px)は6 × 58 = 348px/sになります。javascript ブロック崩しのパドルは当たった位置で反射角を選びましたが、フリッパーは当たった位置がそのままその点の速さなので、角度と強さが一緒に変わります。02で軸から0.6〜0.7の位置の結果が1,269・1,057px/sとばらついたのは、振っている途中で接点が棒に沿って滑るためと考えられます。
引き40pxが秒速1,020pxになる道筋
05のバネは引いた長さxに対して900 × xの押し戻す加速度を受けます。この力でためたエネルギーをすべて返してもらうと、0を通る瞬間の速さは√900 × x = 30xで、これにLAUNCH_SCALEの0.85を掛けた25.5xがボールに渡ります。そのため40pxは1,020px/s、10pxは255px/sですが、10pxで打ったボールはレーンを登りきれず、いちばん高くてy 204まで上がってからバネの上へ落ちてきます。離してから最初に0を通るまで抵抗を0にしているのは引いた分を少しも削らずに返すためで、抵抗24はボールを渡したあとで初めて効きます。
ボール 抽選 ゲームは、なぜ真ん中の枠ばかり当たるのか?
8段で半々に分かれたボールが集まる場所
08のボールは8段のピンを通り、段ごとに左か右へ分かれます。右へ行った回数がそのボールの入る枠の番号になり、8回のうち4回右へ行く道は70通り、8回とも右へ行く道は1通りしかありません。256通りの道のうち真ん中の枠が70/256 = 27.3%、両端の枠が1/256 = 0.4%を受け持つのはこのためで、ボールを3,000個実際に落とすと真ん中の枠は28.7%、両端は0.3%・0.2%でした。この形は、ピンに触れたとき横の速さを0.45倍に落とし、シードの乱数で±55px/sを混ぜて初めて出てきます。揺れを0にして横の速さをそのまま残すと、枠ごとの割合が7.9〜13.0%とほぼ均等に広がり、釣り鐘の形は消えました。
名前を枠に混ぜて付けると均等に戻ってくる当選
枠が真ん中に偏るということは、枠に名前を固定しておくとその偏りがそのまま人に移るということです。シード1000〜1299の300盤を混ぜずに回すと、真ん中のD・E・Fがそれぞれ51〜52回、端のAは3回、Iは4回当選しました。SHUFFLE_SLOTSを有効にすると盤ごとに名札をシードの乱数で後ろから一つずつ入れ替えて枠と名前の結びつきを切り、同じ300盤で名前ごとに27〜40回になりました。均等に分ければ一つの名前に約33回なので、その周りに集まったことになります。ただし、この300盤が数えたのは盤ごとに60個のあとで落ちるレモン色のボール(GOLD_R 5)一つが入った枠で、前の節の3,000個の分布は普通のボール(BALL_R 3.5)を数えたものです。混ぜずに回した300盤では真ん中の枠に入ったのが52回(17.3%)でしたが、GOLD_Rを3.5に変えると91回(30.3%)に上がり、レモン色のボールが大きいぶん分布が平らになっていたことがわかります。
// 名前の割り当て — 混ぜるならシードの乱数で後ろから一つずつ入れ替える
names = LABELS.slice(0, SLOT_COUNT);
if (SHUFFLE_SLOTS) {
for (let i = names.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = names[i];
names[i] = names[j];
names[j] = t;
}
}
枠は相変わらず真ん中に集まりますが、どの名前が真ん中の枠に座るかが盤ごとに変わります。偏りを消すのではなく、偏りが誰に向かうかを混ぜるやり方です。
もっと高く跳ね、もっと速く打ち、もっと強く押す — 値を一つずつ変えた変形
| 名前 | 変えた値 | 感じ |
|---|---|---|
| ボールをもっと高く跳ねさせる | 01 RESTITUTION 0.5 → 0.8 |
ピンに当たる回数が127回から151回に増えてぽんぽん跳ね、当選はA 1,040msからE 1,456msに、最後のボールは3,360msから5,744msに、すべて止まるまでは5,616msから9,712msに延びます |
| フリッパーをもっと速く | 02 FLIPPER_SPEED 12 → 20 |
先端の接点が1,032px/sから1,720px/sに、一振りが77msから46msに縮み、実演20秒間の打った直後のボールの速さは683〜1,815px/sから1,041〜2,623px/sに上がります(打った回数17回 → 20回) |
| バンパーをもっと強く | 03 BUMPER_KICK 320 → 640 |
上から300px/sで入ったボールが470px/sではなく790px/sで弾き出され、10秒間にバンパーに当たった回数が17回から53回に増えます |
三つとも画面が忙しくなる方向ですが、引き換えになるものはそれぞれ違います。01は結果が出るまでの待ち時間が4秒以上延び、当選のボールもAからEに替わるので、跳ね返り一つが同じシードの当選者を変えたことになります。02を20に上げたまま1ティックを1歩で計算すると、棒をすり抜ける位置が1か所から5か所に増えるので次の節と合わせて読む必要があり、03は当たった回数が3倍を超え、ボールがバンパーの間を長く行き来するようになったと考えられます。
速いボールが薄いフリッパーをすり抜けるとき・ビー玉がピンの間に挟まるとき・舞台の幅が変わるとき — ピンボールの物理が壊れるところ
一歩で16pxを超えて進むと棒を飛び越える
02の棒の先端は1ティック(16ms)の間に1,032 × 0.016 = 16.51px進みます。ボールが棒に触れたと判定される距離は半径10と太さの半分6を足した16pxなので、1ティックを1歩(SUBSTEPS 1)で計算すると、先端がボールを一度に越えてしまうことがあります。ボールを棒の上面の8か所(軸から長さの0.3〜1.0)に載せて押してみると、SUBSTEPS 1では先端の1か所でボールが棒を突き抜けて下へ落ち、2・3・6では1か所もありませんでした。FLIPPER_SPEEDを20に上げると一歩が27.52pxになり、SUBSTEPS 1で0.6〜1.0の5か所が抜け、2歩(13.76px)からはまた0か所でした。デモが使う6歩では一歩の移動が2.75pxで、触れる距離の16pxにはまるで届きません。01のピン盤も同じ形で、ピンに食い込んだ最大の深さはSUBSTEPS 1・4・8で3.38・0.87・0.58pxと減り、ピンとボールの半径の和が12pxなので抜けることはありませんでした。
衝突の関数と九つの舞台のindex.html、同じ計算をReactの部品に移した版はひとつの圧縮ファイルで受け取れ、開くパスワードは z3hydw63 です。
ピンのてっぺんの真ん中に載ったボール
左右がまったく同じ位置に速度0で載ったボールは、押し出しが上にしか働かないので、横へ転がり落ちる理由がありません。ボールを一つ真ん中のピン(240, 74)のてっぺんに置いてSTUCK_KICKを0で回すと、300ティック(4.8秒)たってもx 240.00のまま動きませんでした。90なら、ゴール線より上で12px/s未満のまま500msとどまったボールを挟まったものとみなして512msで一度弾き、768msでピン半マス(22px)の外へ出て一番下の枠まで落ちていきます。参加者の多い配信の抽選でボールが一つゴール線の上で止まってしまうと盤が終わらないので、この一度の軽い一押しが盤を終わらせる仕掛けになります。
画面のpxで物理を回すと同じシードで当選が変わる
物理は表示の大きさに関係なく480×300の論理座標で動き、fitCanvasが描くときだけ表示幅 / 480の倍率を掛けます。480の窓の表示幅418.6px、320の窓の294px、端末の倍率3で同じシードを最後まで回すと、当選A・1,040msと8個のボールの最後のx座標が小数第3位まで一致し、違ったのはキャンバスの画素数419・294・837だけでした。逆に物理を画面のpxで回したとすると、同じ760px/s²が論理座標では480/294倍強く効くことになり、320の窓では同じシードの当選がC・1,376msに変わりました。
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);
}
当選結果は文字でも知らせる — 結果の読み上げ領域と動きを減らす設定のアクセシビリティ
状態の行が結果を読み上げる
九つの舞台はどれも、キャンバスの下の<p data-role="status">の状態の行に結果を文字で書きます。実演の盤が終わると01は「当選: A」、07は「順位: E, B, A, C, D, F」、08は「当選の枠 7 → H」の形で、キャンバスはaria-hidden="true"なので読み上げはこの行が受け持ちます。実演が回っている間aria-liveはoffで、繰り返される実演の結果は読まず、ボタンを初めて押すとpoliteに切り替わります(aria-live の解説)。文字は変わったときだけ書き換え、05のように引いている間に値が変わり続ける状態は5px・10px/s単位で区切って書きます。
動きを減らす設定では終わった盤を一枚
prefers-reduced-motion: reduceのときは九つともアニメーションのループを始めず、同じシードの盤をsettle()で最後まで計算した最後の場面一枚と結果の文字だけを描きます(prefers-reduced-motion の解説)。終わりのある01・07・08はそれぞれ1,200・1,300・900ティック以内で結果が出た瞬間に止まり、終わりのない02〜06・09は80〜240ティックまで計算した場面を見せます。この状態でボタンを押すとその入力の結果まですぐ計算して一度だけ描くので、05は押すと引いた画面、離すと発射後の場面になります。途中で設定を変えてもchangeイベントですぐに追いかけます。
押し続けるボタンもキーボードで
ボタンは九つとも本物の<button type="button">です。02・05のボタンと09のフリッパー二つは押している間だけ働き、ポインターを押す・離すイベントと一緒にSpace・Enterのkeydown・keyupを受けるので、キーを押している間フリッパーは上がったまま、バネは引かれたままになります。フォーカスが外れると(blur)離したものとみなし、フォーカスの外枠は:focus-visibleの2pxの実線で、09の「◀ フリッパー」「フリッパー ▶」はaria-labelで「左のフリッパー」「右のフリッパー」と読まれます。
抽選盤やピンボール台をサイトに載せる前のよくある質問
この抽選盤を本物のイベント抽選にそのまま使えますか?
物理の計算は同じ入力に同じ結果を返すので、公平さはシードをどこから受け取るかで決まります。デモのように4761を書き込んでおくと誰でも結果を前もって回して確かめられるため、抽選の直前に参加者の前で決めた数や、サーバーが出した値をシードとして渡すほうが安心です。枠で選ぶ08なら、名前を混ぜる設定を有効にしておかないと、真ん中の枠の偏りが特定の名前に集まります。
「ピンボール ゲーム 作り方」を初めて調べたら、どの舞台から読めばいいですか?
01のように止まった円にぶつかるところからが入りやすいです。hitCircle一つと重力、固定のティックがあればボールはピンの間を抜けていき、線分の衝突を足すと04の斜辺と07の坂に、接点の速度を足すと02のフリッパーと06の風車になります。09はその三つを合わせた台なので最後に開くのが順番に合い、同じゲームの分野で格子の上の道探しが気になるなら迷路 ゲームが続けて読みやすいはずです。
ボールが壁をすり抜けたり、画面の外へ消えたりするのはどう防げますか?
まず一歩で進む距離を見ます。いちばん速い物体が一歩で進む距離が、ボールの半径と壁の太さの半分の和より短くなければならず、02は1ティックを6歩に分けてフリッパーの先端の移動を2.75pxに抑えています。タブを長く離れてから戻ると画面の更新の間隔が大きく開きますが、九つとも1フレームで追いつく時間を100ms、計算ティックを6回までに切っているので、ボールが一度に遠くへ飛ぶことはありません。









