08A Landing Board Whose Stacked Slots Reveal a Bell Shape
Balls drop one after another, each slot's count is tallied and its bar height eases upward, the theoretical expected curve is laid over it to show why the middle slots fill most, and then names are shuffled across the slots.
Best forPage explaining a fair prize draw
DROP_INTERVAL_MSSLOT_COUNTSHUFFLE_SLOTS

<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">Balls dropped: 0 — middle slot 0%</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);
}
// 08 분포판 — 칸별 개수 누적 + 막대 높이 lerp + 이항 Expected 곡선 + 이름을 칸에 섞어 배정
// 공을 하나씩 떨어뜨리는 간격(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]);
}
// 이항 Expected 몫 — 줄마다 반반 갈리면 k 칸에 들 몫 = (ROWS marbles 중 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);
// 이항 Expected 곡선 — 지금까지 떨어진 개수 × Expected 몫 높이를 칸 가운데마다 잇는다
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('Balls dropped: ' + landed, px + 10, 58, 12, CREAM, 0.95, 'left');
drawText('Middle slot ' + Math.round(middleShare() * 100) + '%', px + 10, 80, 12, SKY, 1, 'left');
drawText('Expected ' + Math.round(EXPECT[ROWS / 2] * 100) + '%', px + 10, 100, 12, PINK, 1, 'left');
drawText('Bars = actual counts', px + 10, 132, 10, CREAM, 0.7, 'left');
drawText('Dashed = binomial expected curve', px + 10, 150, 10, CREAM, 0.7, 'left');
drawText(SHUFFLE_SLOTS ? 'Names shuffled' : 'Names in order', px + 10, 178, 11, LILAC, 1, 'left');
if (winSlot >= 0) {
drawText('Winning slot ' + (winSlot + 1) + ' → ' + names[winSlot], px + 10, 210, 13, LEMON, 1, 'left');
}
drawBand();
}
function stateText() {
if (winSlot >= 0) {
return 'Winning slot ' + (winSlot + 1) + ' → ' + names[winSlot];
}
return 'Balls dropped: ' + landed + ' — middle slot ' + 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 A Landing Board Whose Stacked Slots Reveal a Bell Shape — Measured area 8.804% · 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 A Board Where Distance Becomes Heat Color and the Answer Appears as a ValleyMaze Generator: 9 Carve-and-Solve Scenes
game02 Insertion Sort That Lifts One Bar and Slots It Into the GapSorting Algorithm Visualizer: 9 Sort Scenes
game09 Random slot pickerHTML CSS JS mini games: 9 copy-paste mini apps