핀볼 뽑기 9종 — 이름 공 추첨판·플리퍼·범퍼·구슬 레이스
핀볼 뽑기는 이름을 단 공을 핀·막대·범퍼 사이로 굴려 당첨을 정하는 캔버스 추첨판이다. 공을 같은 자리에서 떨어뜨리고 칸마다 이름을 붙이면 왜 가운데 이름만 자꾸 당첨될까?
추첨판부터 미니 테이블까지 — 공이 굴러가는 아홉 무대
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 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 를 한 판에 묶는다.
핀볼 뽑기 9종 — 이름 공 추첨판·플리퍼·범퍼·삼각 키커·발사 막대·풍차 막대·구슬 레이스·분포판·미니 테이블
아홉 무대는 캔버스 2D 와 자바스크립트만으로 굴러간다. 물리 엔진·라이브러리·WebGL·CDN·이미지 파일은 하나도 없고, 난수도 Math.random 이 아니라 32비트 정수를 섞는 makeRng 한 곳에서만 나온다. 화면 갱신 간격(requestAnimationFrame 설명)은 16ms 짜리 계산 틱으로 바꿔 쌓고, 항목마다 그 한 틱을 다시 4~6 걸음(SUBSTEPS)으로 쪼개 충돌을 잰다.
2초 동안 찍은 24장을 이웃끼리 견준 23번의 변화
480×300 창에서 렌더한 장면을 바로 앞 장과 비교해, 한 번이라도 색이 바뀐 화소가 무대에서 차지한 넓이를 누적면적으로, 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 |
아홉 무대 가운데 비교할 때마다 화면이 달라지지 않은 곳은 없었다. 06 이 28.8% 로 가장 넓은 것은 날개 셋짜리 막대 두 개가 쉬지 않고 돌면서 구슬 일곱 개를 무대 곳곳으로 쳐 내기 때문으로 보이고, 05 가 8.5% 로 가장 좁은 것은 막대를 당기고 놓는 사이 무대 대부분이 가만히 있기 때문일 것이다.
방송 시청자 추첨부터 팀 발표 순서까지, 붙일 자리
방송 화면에 핀볼 랜덤 추첨기를 띄운다면 01 이 가장 가깝다. 결승선을 먼저 지난 공을 당첨으로 읽으니 화면을 보는 사람 누구나 결과를 눈으로 따라갈 수 있고, 지금 A~H 글자인 공 여덟 개의 이름표 자리에 참가자 이름을 넣어 쓰면 된다. 한 사람만 빠르게 정하는 자리라면 룰렛 돌리기 의 돌림판이 더 단순할 수 있다.
모임에서 발표 순서를 정할 때는 07 이 낫다. 한 명만 남는 01 과 달리 순위표가 여섯 줄 전부를 들어온 차례와 시각으로 남기기 때문이다. 08 은 결과보다 과정을 보여 줄 때 쓴다. 가운데로 몰리는 쏠림을 먼저 그려 보이고 이름을 섞어 배정하니, 경품 추첨 안내 페이지에서 칸 위치가 유리하지 않다는 설명을 그림이 대신한다.
실제 추첨에 옮길 때 바꿔야 할 것이 하나 있다. 데모는 시드 고정이라 결과가 재현된다 — 실제 추첨에 쓸 땐 시드를 외부 입력에서 받아야 한다. 시드 4761 판은 몇 번을 다시 열어도 A 가 1,040ms 에 당첨되고, 「다시 추첨」 도 seed += 1 뒤 restart() 를 부르는 것이라 4761 다음 판은 4762 로 정해져 있다.
01이름 공이 핀 사이로 떨어지는 추첨판
A~H 이름표를 단 공 여덟 개가 핀 66개 사이로 떨어지며, 핀과 겹친 깊이만큼 밀려나고 다가오던 법선 방향 속도만 0.5배로 뒤집혀 튄다. 결승선(y 238)을 공 전체가 먼저 지난 공이 당첨이라 방송 시청자 추첨 화면에서 누가 먼저 들어왔는지를 그대로 결과로 쓸 수 있다. 시드 4761 판은 A 가 1,040ms 에 당첨되고, 핀에 127번 닿은 끝에 5,616ms 에 여덟 개가 모두 칸에서 멎는다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 한 걸음(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초 동안 범퍼 넷이 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 공과 범퍼 — 원-원 충돌로 겹친 만큼 밀어내고 되튀긴 다음, 들어온 빠르기와 상관없이 같은 세기 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 빗변 — 선분 충돌로 되튀긴 뒤, 법선이 빗변 바깥을 향하고 충분히 빨리 닿았으면 법선 방향 덧힘을 얹고 빗변을 안쪽으로 민다
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 발사가 되어 predictLaunch 의 0.85 × √900 × 당김과 같은 값이 나오고, 공은 놓고 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 로 튀어, 끝에 맞을수록 멀리 간다. 단체 게임 순서 정하기 화면에 두면 구슬 일곱 개가 맞은 자리마다 다르게 튕겨 나가는데, 시연 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 날개와 공 — 날개 위 가장 가까운 점의 회전 속도를 접점 속도로
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경사로를 굴러 들어온 순서로 순위가 매겨지는 구슬 레이스
구슬 여섯 개가 지그재그 비탈 네 개를 굴러 내려오고, 비탈에 닿은 걸음마다 접선 빠르기에서 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 코스 진행도 — 위에 지나온 비탈 수 × 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 간격으로 떨어뜨려 칸 아홉 개에 든 수를 세고, 막대 높이는 틱마다 남은 차이의 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 이항 기대 몫 — 줄마다 반반 갈리면 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 흔들기 — 판 위 공에 같은 충격을 더하고, 화면에는 스프링 빠르기를 준다(화면 흔들림은 그림만, 공은 충격만 받는다)
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 가 된다. 벽돌깨기 게임 만들기 의 패들은 맞은 위치로 반사각을 골랐지만, 플리퍼는 맞은 위치가 곧 그 점의 빠르기라 각도와 세기가 함께 바뀐다. 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 는 공을 넘긴 다음에야 켜진다.
핀볼 뽑기 프로그램은 왜 가운데 칸이 더 많이 당첨되는가?
여덟 줄에서 반반 갈린 공이 모이는 자리
08 의 공은 핀 줄 8개를 지나며 줄마다 왼쪽이나 오른쪽으로 갈린다. 오른쪽으로 간 횟수가 그 공이 들어갈 칸 번호가 되는데, 여덟 번 중 네 번 오른쪽으로 가는 길은 70가지이고 여덟 번 모두 오른쪽인 길은 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번 당첨됐다. 당첨을 정하는 레몬 공은 반지름 5 로 보통 공(3.5)보다 커서 가운데 칸 몫이 17% 안팎으로 3,000개 분포보다 낮게 나오는데, 반지름을 3.5 로 맞추면 같은 300판에서 가운데 칸이 91번(30.3%)으로 오른다. SHUFFLE_SLOTS 를 켜면 판마다 이름표를 시드 난수로 뒤에서부터 한 자리씩 맞바꿔 칸과 이름의 관계를 끊고, 같은 300판에서 이름마다 27~40번이 나왔다. 고르게 나누면 한 이름에 약 33번이니 그 둘레로 모인 셈이다.
// 이름 배정 — 섞기면 시드 난수로 뒤에서부터 한 자리씩 맞바꾼다
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곳에서 5곳으로 늘어나므로 아래 절과 함께 봐야 하고, 03 은 맞은 횟수가 세 배를 넘어 공이 범퍼 사이를 더 오래 오가는 것으로 보인다.
빠른 공이 얇은 플리퍼를 뚫고 지나갈 때·구슬이 핀 사이에 끼어 멈출 때·무대 폭이 바뀔 때 — 핀볼 물리가 깨지는 곳
한 걸음에 16px 을 넘게 가면 막대를 건너뛴다
02 의 막대 끝은 한 틱(16ms) 동안 1,032 × 0.016 = 16.51px 를 간다. 공이 막대에 닿았다고 판정되는 거리는 반지름 10 과 두께 반 6 을 더한 16px 이라, 한 틱을 한 걸음(SUBSTEPS 1)으로 계산하면 막대 끝이 공을 한 번에 넘어설 수 있다. 공을 막대 윗면 여덟 자리(축에서 길이의 0.3~1.0)에 얹고 눌러 보니 SUBSTEPS 1 에서는 끝 한 자리에서 공이 막대를 뚫고 아래로 빠졌고, 2·3·6 에서는 한 곳도 없었다. FLIPPER_SPEED 를 20 으로 올리면 한 걸음이 27.52px 가 되어 SUBSTEPS 1 에서 0.6~1.0 다섯 자리가 뚫렸고, 2 걸음(13.76px)부터 다시 0곳이었다. 데모가 쓰는 6 걸음에서는 한 걸음 이동이 2.75px 라 닿는 거리 16px 에 한참 못 미친다. 01 핀판도 같은 꼴인데, 핀에 파고든 최대 깊이가 SUBSTEPS 1·4·8 에서 3.38·0.87·0.58px 로 줄었고 핀과 공 반지름 합이 12px 이라 뚫리지는 않았다.
충돌 함수와 아홉 무대의 index.html, 같은 소스에서 만든 리액트 판은 압축 파일 하나로 받을 수 있고, 여는 비밀번호는 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 와 공 여덟 개의 마지막 x 좌표가 소수 셋째 자리까지 같았고, 캔버스 화소만 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 는 한 틱을 6 걸음으로 나눠 플리퍼 끝 이동을 2.75px 로 묶어 둔다. 탭을 오래 비웠다 돌아오면 화면 갱신 간격이 크게 벌어지는데, 아홉 무대 모두 한 프레임에 따라잡는 시간을 100ms, 계산 틱을 6번까지로 잘라 공이 한꺼번에 멀리 튀지 않게 한다.









