07A Marble Race Ranked by Arrival Down the Ramps
On zigzag ramps the downhill part of gravity and rolling friction split the marbles' speeds, collisions between marbles swap places, and the leaderboard, which reorders by who is ahead, locks each row with a time in the order they cross the finish line.
Best forDraw page for team presentation order
RAMP_FRICTIONRAMP_SLOPEMARBLE_COUNT

<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">Racing — 6 marbles</p>
<div class="pb__controls">
<button type="button" class="pb__btn" data-role="redraw">Draw again</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;
}
}
// 마지막 충돌 — 법선(공 쪽을 향함)과 닿은 자리. Bumpers hit·키커가 이 법선 방향으로 덧힘을 준다
const contact = { nx: 0, ny: -1, px: 0, py: 0 };
// 원-원 충돌(움직이지 않는 원: 핀·Bumpers hit) — 겹친 깊이만큼 법선 방향 바깥으로 밀어내고,
// 법선 방향 속도를 되튐 계수 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];
}
// 원-선분 충돌 — 가장 가까운 점까지 거리가 (반지름 + 선 두께의 반)보다 짧으면 겹친 만큼 밀어낸다.
// 선분이 움직이면(도는 Flipper·풍차 막대) 접점의 속도(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 구슬 레이스 — 비탈 위 중력(선분 충돌이 비탈 방향 성분만 남긴다) + 구름 마찰 + 결승선 통과 순서 Rank
// 구름 마찰(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('Rank', 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 'Ranking: ' + names.join(', ');
}
if (order.length) {
return 'Racing — leader ' + balls[order[0]].label;
}
return 'Racing — ' + MARBLE_COUNT + ' marbles';
}
// ── 공통 꼬리: 시계·버튼·동작 줄이기·정리 ──
// 결과를 보여 준 시간(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 A Marble Race Ranked by Arrival Down the Ramps — Measured area 14.235% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · JavaScript Pinball Game: 9 Physics Scenes
The post this demo lives in
JavaScript Pinball Game: 9 Physics ScenesJavaScript pinball game for raffles. Balls dropped onto named slots: why do middle names keep winning? 08 stacks landing See all demos in this post
If you like this effect - similar demos
game08 Tumble Check That Lays the Top Down Past a Tilt LimitSpinning Top Animation: 9 Canvas Physics Scenes
game09 A Snake That Rides Down the Slope of a Distance Wave Spreading From the FoodCSS Snake Game: 9 Boards That Find Their Own Path
game07 A Race Meter That Pits Four Sorts on the Same Array by Comparisons and MovesSorting Algorithm Visualizer: 9 Sort Scenes