08落ちた枠を積み上げて釣り鐘の形が現れる分布盤
ボールを次々に落として枠ごとの数を数え、棒の高さをなめらかに追わせて伸ばし、理論上の期待曲線を重ねて真ん中の枠に集まる理由を見せたあと、名前を枠に混ぜて割り当てる。
使いどころ公平な景品抽選の案内ページ
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">落ちたボール 0 — 真ん中の枠 0%</p>
<div class="pb__controls">
<button type="button" class="pb__btn" data-role="redraw">もう一度抽選</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pb {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pb__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 머리 줄 — 캔버스 위 머리 띠 자리에 상태 줄(왼쪽)과 버튼(오른쪽)을 겹친다. 상태 줄이 길면 말줄임으로 자르고 버튼 폭은 그대로
.pb__head {
position: absolute;
top: $sp-1;
left: $sp-2;
right: $sp-2;
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
}
.pb__status {
flex: 0 1 auto;
min-width: 0;
margin: 0;
padding: 0 $sp-2;
border-radius: $r-control;
background: rgba(255, 255, 255, .88);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pb__controls {
flex: 0 0 auto;
display: flex;
gap: $sp-1;
}
// 진짜 버튼 — Tab 으로 들어오고 Enter·Space 로 누른다. 누르고 있는 버튼은 스크립트가 is-held 를 붙인다
.pb__btn {
appearance: none;
margin: 0;
padding: 0 $sp-2;
border: 0;
border-radius: $r-control;
background: $subject-lilac;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 18px;
cursor: pointer;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
}
.pb__btn:hover {
background: $subject-cream;
}
.pb__btn:active,
.pb__btn.is-held {
background: $subject-pink;
}
.pb__btn:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 2px;
}
@media (max-width: 340px) {
.pb__head { top: 0; left: $sp-1; right: $sp-1; gap: $sp-1; }
.pb__status, .pb__btn { font-size: 9px; line-height: 16px; padding: 0 $sp-1; }
}
// 동작 줄이기 — 움직임은 스크립트가 멈춘다(같은 시드 판을 끝까지 계산해 마지막 장면만 그린다). 여기서는 버튼 색이 바로 바뀌게만
@media (prefers-reduced-motion: reduce) {
.pb__btn { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pb {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pb__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.pb__head {
position: absolute;
top: 4px;
left: 8px;
right: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.pb__status {
flex: 0 1 auto;
min-width: 0;
margin: 0;
padding: 0 8px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pb__controls {
flex: 0 0 auto;
display: flex;
gap: 4px;
}
.pb__btn {
appearance: none;
margin: 0;
padding: 0 8px;
border: 0;
border-radius: 12px;
background: #b9a0ff;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 18px;
cursor: pointer;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
}
.pb__btn:hover {
background: #fff7e6;
}
.pb__btn:active,
.pb__btn.is-held {
background: #ff9ebb;
}
.pb__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.pb__head {
top: 0;
left: 4px;
right: 4px;
gap: 4px;
}
.pb__status, .pb__btn {
font-size: 9px;
line-height: 16px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.pb__btn {
transition: none !important;
}
}(function () {
const root = document.getElementById("pb");
if (!root) return;
// 논리 무대 — 물리는 화면 폭과 상관없이 이 480×300 좌표에서만 돈다. 화면이 넓든 좁든 그릴 때만 배율을 곱한다
const W = 480;
const H = 300;
// 한 틱 = 16ms 고정. 실제 프레임 간격이 흔들려도 계산은 같은 걸음으로만 나아간다
const FIXED_DT = 16;
const DT = FIXED_DT / 1000;
const TAU = Math.PI * 2;
// 머리 띠 높이(논리 px) — 상태 줄과 버튼이 이 띠 위에 겹친다
const HEADER = 30;
// 시연이 끝난 판을 이만큼(ms) 보여 준 뒤 같은 시드로 처음부터 다시 돈다
const LOOP_HOLD_MS = 1600;
// 파스텔 팔레트 — 어두운 판 위에 밝은 공·핀을 얹어 명도 차로 움직임이 읽힌다
const BG = [30, 27, 48];
const BG_LOW = [46, 39, 70];
const BAND = [20, 18, 32];
const GUIDE = [104, 94, 140];
const CREAM = [255, 248, 234];
const SKY = [146, 208, 255];
const PINK = [255, 172, 200];
const MINT = [144, 232, 202];
const LILAC = [202, 182, 255];
const PEACH = [255, 208, 172];
const LEMON = [250, 234, 156];
const AQUA = [156, 238, 236];
const CORAL = [255, 184, 166];
const INK = [28, 24, 40];
const BALL_COLORS = [PEACH, MINT, SKY, PINK, LILAC, LEMON, AQUA, CORAL, CREAM];
// 공 이름표 — 실제 사람 이름 대신 글자
const LABELS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 출발 자리·같은 결과가 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue) {
let s = seedValue >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function makeBall(x, y, r, color, label) {
return { x: x, y: y, vx: 0, vy: 0, r: r, color: color, label: label, trail: [], slow: 0, finishMs: -1, mul: 1, out: false };
}
// 속도를 먼저 갱신하는 적분 — 이번 걸음의 힘(중력)으로 속도를 바꾼 뒤, 그 새 속도로 자리를 옮긴다. h 는 초
function integrate(b, gx, gy, h) {
b.vx += gx * h;
b.vy += gy * h;
b.x += b.vx * h;
b.y += b.vy * h;
}
function capSpeed(b, max) {
const s = Math.hypot(b.vx, b.vy);
if (s > max) {
b.vx *= max / s;
b.vy *= max / s;
}
}
// 마지막 충돌 — 법선(공 쪽을 향함)과 닿은 자리. バンパー·키커가 이 법선 방향으로 덧힘을 준다
const contact = { nx: 0, ny: -1, px: 0, py: 0 };
// 원-원 충돌(움직이지 않는 원: 핀·バンパー) — 겹친 깊이만큼 법선 방향 바깥으로 밀어내고,
// 법선 방향 속도를 되튐 계수 e 만큼 뒤집는다. 돌려주는 값: 안 닿음 -1 · 닿았지만 멀어지는 중 0 · 다가오던 빠르기(px/s)
function hitCircle(b, cx, cy, cr, e) {
const dx = b.x - cx;
const dy = b.y - cy;
const d = Math.hypot(dx, dy);
const overlap = b.r + cr - d;
if (overlap <= 0) {
return -1;
}
// 중심이 정확히 겹치면 방향을 정할 수 없다 — 위로 민다
const nx = d > 1e-9 ? dx / d : 0;
const ny = d > 1e-9 ? dy / d : -1;
b.x += nx * overlap;
b.y += ny * overlap;
contact.nx = nx;
contact.ny = ny;
contact.px = cx + nx * cr;
contact.py = cy + ny * cr;
const vn = b.vx * nx + b.vy * ny;
if (vn >= 0) {
return 0;
}
b.vx -= (1 + e) * vn * nx;
b.vy -= (1 + e) * vn * ny;
return -vn;
}
// 선분 위 가장 가까운 점 — 공 중심에서 선분으로 내린 수선의 발. t 는 0(시작점)~1(끝점)로 자른다
function closestOnSegment(px, py, ax, ay, bx, by) {
const ex = bx - ax;
const ey = by - ay;
const len2 = ex * ex + ey * ey;
const t = len2 > 0 ? clamp(((px - ax) * ex + (py - ay) * ey) / len2, 0, 1) : 0;
return [ax + ex * t, ay + ey * t, t];
}
// 원-선분 충돌 — 가장 가까운 점까지 거리가 (반지름 + 선 두께의 반)보다 짧으면 겹친 만큼 밀어낸다.
// 선분이 움직이면(도는 フリッパー·풍차 막대) 접점의 속도(svx, svy)에 대한 상대 속도로 되튐을 잰다.
// grip 은 이번 걸음에 접선 방향 상대 속도에서 덜어 내는 몫 — 0 이면 미끄럼, 07 구름 마찰은 0 보다 크다
function hitSegment(b, ax, ay, bx, by, half, e, svx, svy, grip) {
const c = closestOnSegment(b.x, b.y, ax, ay, bx, by);
const dx = b.x - c[0];
const dy = b.y - c[1];
const d = Math.hypot(dx, dy);
const overlap = b.r + half - d;
if (overlap <= 0) {
return -1;
}
let nx = 0;
let ny = -1;
if (d > 1e-9) {
nx = dx / d;
ny = dy / d;
}
else {
// 중심이 선 위에 올라탔다 — 선분의 왼쪽 법선으로 민다
const L = Math.hypot(bx - ax, by - ay) || 1;
nx = (by - ay) / L;
ny = -(bx - ax) / L;
}
b.x += nx * overlap;
b.y += ny * overlap;
contact.nx = nx;
contact.ny = ny;
contact.px = c[0];
contact.py = c[1];
const rvx = b.vx - svx;
const rvy = b.vy - svy;
const vn = rvx * nx + rvy * ny;
if (vn >= 0) {
return 0;
}
const tvx = (rvx - vn * nx) * (1 - grip);
const tvy = (rvy - vn * ny) * (1 - grip);
b.vx = svx + tvx - e * vn * nx;
b.vy = svy + tvy - e * vn * ny;
return -vn;
}
// 움직이지 않는 벽 묶음 — 선분 하나하나에 hitSegment
function hitWalls(b, segs, e) {
for (let i = 0; i < segs.length; i += 1) {
const s = segs[i];
hitSegment(b, s[0], s[1], s[2], s[3], 0, e, 0, 0, 0);
}
}
// 공-공 충돌 — 같은 무게. 겹친 깊이를 반씩 나눠 밀어내고, 법선 방향 속도를 되튐 계수만큼 주고받는다
function hitBalls(a, b, e) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.hypot(dx, dy);
const overlap = a.r + b.r - d;
if (overlap <= 0) {
return -1;
}
const nx = d > 1e-9 ? dx / d : 1;
const ny = d > 1e-9 ? dy / d : 0;
a.x -= nx * overlap * 0.5;
a.y -= ny * overlap * 0.5;
b.x += nx * overlap * 0.5;
b.y += ny * overlap * 0.5;
const vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) {
return 0;
}
const j = -(1 + e) * vn * 0.5;
a.vx -= j * nx;
a.vy -= j * ny;
b.vx += j * nx;
b.vy += j * ny;
return -vn;
}
function pushTrail(b, n) {
b.trail.push(b.x, b.y);
while (b.trail.length > n * 2) {
b.trail.splice(0, 2);
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 꼬리의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 버튼을 처음 누르는 순간 시연(가상 입력·같은 판 되풀이)을 멈추고 결과를 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 판의 시드와 시각(ms). 시각은 16ms 고정 스텝으로만 는다
let seed = 0;
let clock = 0;
// 화면 배율 — 논리 1px 이 화면 CSS px 몇 개인가. 그릴 때만 쓰고 물리 계산에는 넣지 않는다
let viewScale = 1;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const cssW = cv.getBoundingClientRect().width || W;
viewScale = cssW / W;
const k = viewScale * dpr;
cv.width = Math.max(1, Math.round(W * k));
cv.height = Math.max(1, Math.round(H * k));
// 그릴 때만 논리 좌표 → 화소 배율을 곱한다
ctx.setTransform(k, 0, 0, k, 0, 0);
}
function clearStage() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 머리 띠 — 상태 줄과 버튼 뒤의 어두운 띠. 판을 다 그린 뒤 맨 위에 덮는다
function drawBand() {
ctx.fillStyle = rgba(BAND, 0.92);
ctx.fillRect(0, 0, W, HEADER);
}
function strokeSeg(s, width, c, a) {
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(s[0], s[1]);
ctx.lineTo(s[2], s[3]);
ctx.stroke();
}
// 꼬리 — 지나온 자리를 이을수록 굵고 진하게
function drawTrail(b, c, width) {
const t = b.trail;
const n = t.length / 2;
ctx.lineCap = 'round';
for (let i = 1; i < n; i += 1) {
const k = i / n;
ctx.strokeStyle = rgba(c, 0.55 * k);
ctx.lineWidth = width * k;
ctx.beginPath();
ctx.moveTo(t[i * 2 - 2], t[i * 2 - 1]);
ctx.lineTo(t[i * 2], t[i * 2 + 1]);
ctx.stroke();
}
}
// 공 — 바닥 그림자 → 왼쪽 위가 밝은 둥근 몸통 → 진한 테두리 → 이름표 글자
function drawBall(b) {
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.arc(b.x + 2, b.y + 3, b.r, 0, TAU);
ctx.fill();
const g = ctx.createRadialGradient(b.x - b.r * 0.35, b.y - b.r * 0.4, b.r * 0.1, b.x, b.y, b.r);
g.addColorStop(0, rgba(mixRGB(b.color, CREAM, 0.75), 1));
g.addColorStop(1, rgba(b.color, 1));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, TAU);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.6);
ctx.stroke();
if (b.label) {
ctx.fillStyle = rgba(INK, 1);
ctx.font = '700 ' + Math.round(b.r * 1.25) + 'px ' + FONT_FAMILY;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(b.label, b.x, b.y + 0.5);
}
}
function drawText(text, x, y, size, c, a, align) {
ctx.fillStyle = rgba(c, a);
ctx.font = '600 ' + size + 'px ' + FONT_FAMILY;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillText(text, x, y);
}
// 08 분포판 — 칸별 개수 누적 + 막대 높이 lerp + 이항 期待 곡선 + 이름을 칸에 섞어 배정
// 공을 하나씩 떨어뜨리는 간격(ms)
const DROP_INTERVAL_MS = 70;
// 바닥 칸 수 — 핀 줄은 칸 수보다 하나 적다(줄마다 왼쪽·오른쪽 갈림이 한 번)
const SLOT_COUNT = 9;
// 이름을 칸에 섞어 배정하나 — false 면 A 부터 차례로 칸에 붙는다(가운데 이름이 자주 당첨된다)
const SHUFFLE_SLOTS = true;
const DROP_TOTAL = 60;
const ROWS = SLOT_COUNT - 1;
const PEG_DX = 26;
const PEG_DY = 20;
const APEX_X = 168;
const APEX_Y = 66;
const PEG_R = 3;
const BALL_R = 3.5;
const GOLD_R = 5;
const GRAVITY = 2200;
const RESTITUTION = 0.35;
// 핀에 닿을 때마다 — 옆 빠르기를 PEG_DAMP_X 배로 줄이고 시드 난수로 ±PEG_JITTER/2 를 더한다.
// 공과 핀이 완벽히 둥글고 똑같으면 같은 길만 되풀이한다(흔들림 0 이면 칸 몫이 거의 고르다) — 실제 판의 작은 흠 대신 이 흔들림이 갈림을 반반에 가깝게 만든다
const PEG_DAMP_X = 0.45;
const PEG_JITTER = 110;
const SUBSTEPS = 4;
// 막대 높이가 목표를 따라가는 몫 — 틱마다 남은 차의 14%
const BAR_LERP = 0.14;
const BAR_MAX_H = 58;
const BAR_BOTTOM = 284;
const SLOT_TOP = APEX_Y + (ROWS - 1) * PEG_DY + 14;
const SLOT_LEFT = APEX_X - SLOT_COUNT / 2 * PEG_DX;
const SHUFFLE_MS = 700;
const DEMO_SEED = 4768;
const SETTLE_TICKS = 900;
const pegs = [];
for (let r = 0; r < ROWS; r += 1) {
for (let k = 0; k <= r; k += 1) {
pegs.push({ x: APEX_X + (k - r / 2) * PEG_DX, y: APEX_Y + r * PEG_DY, glow: 0 });
}
}
const EDGE = (ROWS - 0.5) * PEG_DX / 2 + 18;
const walls = [
[APEX_X - 11, APEX_Y - 10, APEX_X - EDGE, APEX_Y + (ROWS - 0.5) * PEG_DY],
[APEX_X + 11, APEX_Y - 10, APEX_X + EDGE, APEX_Y + (ROWS - 0.5) * PEG_DY],
[SLOT_LEFT, APEX_Y + (ROWS - 0.5) * PEG_DY, SLOT_LEFT, BAR_BOTTOM],
[SLOT_LEFT + SLOT_COUNT * PEG_DX, APEX_Y + (ROWS - 0.5) * PEG_DY, SLOT_LEFT + SLOT_COUNT * PEG_DX, BAR_BOTTOM]
];
const dividers = [];
for (let k = 1; k < SLOT_COUNT; k += 1) {
dividers.push([SLOT_LEFT + k * PEG_DX, SLOT_TOP, SLOT_LEFT + k * PEG_DX, BAR_BOTTOM]);
}
// 이항 期待 몫 — 줄마다 반반 갈리면 k 칸에 들 몫 = (ROWS 個 중 k 개를 고르는 수) / 2^ROWS
function binomShare(k) {
let c = 1;
for (let i = 0; i < k; i += 1) {
c = c * (ROWS - i) / (i + 1);
}
return c / Math.pow(2, ROWS);
}
const EXPECT = [];
for (let k = 0; k < SLOT_COUNT; k += 1) {
EXPECT.push(binomShare(k));
}
let rng = makeRng(1);
let live = [];
let counts = [];
let shown = [];
let names = [];
let landed = 0;
let dropped = 0;
let dropMs = 0;
let phase = 'drop';
let phaseMs = 0;
let winSlot = -1;
let flash = [];
function slotAt(x) { return clamp(Math.floor((x - SLOT_LEFT) / PEG_DX), 0, SLOT_COUNT - 1); }
function slotX(k) { return SLOT_LEFT + (k + 0.5) * PEG_DX; }
function onRestart() {
rng = makeRng(seed);
live = [];
counts = [];
shown = [];
flash = [];
for (let k = 0; k < SLOT_COUNT; k += 1) {
counts.push(0);
shown.push(0);
flash.push(0);
}
// 이름 배정 — 섞기면 시드 난수로 뒤에서부터 한 자리씩 맞바꾼다
names = LABELS.slice(0, SLOT_COUNT);
if (SHUFFLE_SLOTS) {
for (let i = names.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = names[i];
names[i] = names[j];
names[j] = t;
}
}
landed = 0;
dropped = 0;
dropMs = 0;
phase = 'drop';
phaseMs = 0;
winSlot = -1;
}
function dropBall(gold) {
const b = makeBall(APEX_X + (rng() - 0.5) * 1.5, 44, gold ? GOLD_R : BALL_R, gold ? LEMON : BALL_COLORS[dropped % 6], '');
b.vx = (rng() - 0.5) * 14;
b.mul = gold ? 1 : 0;
live.push(b);
}
function barUnit() {
let peak = 1;
for (let k = 0; k < SLOT_COUNT; k += 1) {
peak = Math.max(peak, counts[k], EXPECT[k] * landed);
}
return Math.min(3, BAR_MAX_H / peak);
}
function step() {
phaseMs += FIXED_DT;
if (phase === 'drop') {
dropMs += FIXED_DT;
while (dropMs >= DROP_INTERVAL_MS && dropped < DROP_TOTAL) {
dropMs -= DROP_INTERVAL_MS;
dropBall(false);
dropped += 1;
}
if (landed >= DROP_TOTAL) {
phase = 'shuffle';
phaseMs = 0;
}
}
else if (phase === 'shuffle' && phaseMs >= SHUFFLE_MS) {
phase = 'gold';
phaseMs = 0;
dropBall(true);
}
const hs = DT / SUBSTEPS;
const unit = barUnit();
for (let s = 0; s < SUBSTEPS; s += 1) {
for (let i = 0; i < live.length; i += 1) {
integrate(live[i], 0, GRAVITY, hs);
}
for (let i = 0; i < live.length; i += 1) {
const b = live[i];
for (let p = 0; p < pegs.length; p += 1) {
const g = pegs[p];
if (Math.abs(g.y - b.y) > b.r + PEG_R) {
continue;
}
if (hitCircle(b, g.x, g.y, PEG_R, RESTITUTION) > 0) {
g.glow = 1;
b.vx = b.vx * PEG_DAMP_X + (rng() - 0.5) * PEG_JITTER;
}
}
hitWalls(b, walls, RESTITUTION);
hitWalls(b, dividers, RESTITUTION);
}
}
// 칸에 닿은 공 — 그 칸 막대 꼭대기에 닿으면 개수에 넣고 판에서 뺀다
const keep = [];
for (let i = 0; i < live.length; i += 1) {
const b = live[i];
const k = slotAt(b.x);
if (b.y > SLOT_TOP && b.y + b.r >= BAR_BOTTOM - shown[k] - 1) {
flash[k] = 1;
if (b.mul > 0) {
winSlot = k;
phase = 'done';
phaseMs = 0;
}
else {
counts[k] += 1;
landed += 1;
}
}
else {
pushTrail(b, 6);
keep.push(b);
}
}
live = keep;
// 막대 높이 — 목표(개수 × 배율)를 틱마다 BAR_LERP 몫씩 따라간다
for (let k = 0; k < SLOT_COUNT; k += 1) {
shown[k] += (counts[k] * unit - shown[k]) * BAR_LERP;
flash[k] *= 0.88;
}
for (let p = 0; p < pegs.length; p += 1) {
pegs[p].glow *= 0.85;
}
}
function finished() { return phase === 'done'; }
function onControl(role, down) {
if (role === 'redraw' && down) {
seed += 1;
restart();
}
}
function middleShare() { return landed ? counts[ROWS / 2] / landed : 0; }
function draw() {
clearStage();
const unit = barUnit();
for (let k = 0; k < SLOT_COUNT; k += 1) {
const x = SLOT_LEFT + k * PEG_DX;
const won = k === winSlot;
ctx.fillStyle = rgba(won ? LEMON : mixRGB(SKY, CREAM, flash[k] * 0.6), won ? 1 : 0.85);
ctx.fillRect(x + 3, BAR_BOTTOM - shown[k], PEG_DX - 6, shown[k]);
}
for (let i = 0; i < walls.length; i += 1) {
strokeSeg(walls[i], 3, CREAM, 0.45);
}
for (let i = 0; i < dividers.length; i += 1) {
strokeSeg(dividers[i], 2, CREAM, 0.4);
}
strokeSeg([SLOT_LEFT, BAR_BOTTOM, SLOT_LEFT + SLOT_COUNT * PEG_DX, BAR_BOTTOM], 2, CREAM, 0.6);
// 이항 期待 곡선 — 지금까지 떨어진 개수 × 期待 몫 높이를 칸 가운데마다 잇는다
if (landed > 0) {
ctx.setLineDash([4, 4]);
ctx.strokeStyle = rgba(PINK, 0.95);
ctx.lineWidth = 2;
ctx.beginPath();
for (let k = 0; k < SLOT_COUNT; k += 1) {
const y = BAR_BOTTOM - EXPECT[k] * landed * unit;
if (k === 0) {
ctx.moveTo(slotX(k), y);
}
else {
ctx.lineTo(slotX(k), y);
}
}
ctx.stroke();
ctx.setLineDash([]);
}
for (let p = 0; p < pegs.length; p += 1) {
const g = pegs[p];
// 맞은 핀 — 빛이 번졌다가 틱마다 0.85 배로 잦아든다(공이 많이 지나는 가운데 길이 더 자주 밝다)
if (g.glow > 0.05) {
ctx.fillStyle = rgba(LILAC, 0.45 * g.glow);
ctx.beginPath();
ctx.arc(g.x, g.y, PEG_R + 7 * g.glow, 0, TAU);
ctx.fill();
}
ctx.fillStyle = rgba(mixRGB(CREAM, LILAC, g.glow), 1);
ctx.beginPath();
ctx.arc(g.x, g.y, PEG_R + g.glow, 0, TAU);
ctx.fill();
}
for (let i = 0; i < live.length; i += 1) {
const b = live[i];
drawTrail(b, b.color, b.r * 1.4);
ctx.fillStyle = rgba(b.color, 1);
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, TAU);
ctx.fill();
}
// 이름표 — 섞는 동안 차례 자리에서 섞인 자리로 미끄러진다
const k0 = phase === 'drop' ? 0 : smooth(phase === 'shuffle' ? phaseMs / SHUFFLE_MS : 1);
for (let k = 0; k < SLOT_COUNT; k += 1) {
const from = LABELS.indexOf(names[k]);
drawText(names[k], lerp(slotX(from), slotX(k), k0), 293, 10, k === winSlot ? LEMON : CREAM, 0.95, 'center');
}
// 오른쪽 판 — 숫자와 범례
const px = 318;
ctx.fillStyle = rgba(BAND, 0.55);
ctx.fillRect(px, 40, W - px - 8, 250);
drawText('落ちたボール ' + landed, px + 10, 58, 12, CREAM, 0.95, 'left');
drawText('真ん中の枠 ' + Math.round(middleShare() * 100) + '%', px + 10, 80, 12, SKY, 1, 'left');
drawText('期待 ' + Math.round(EXPECT[ROWS / 2] * 100) + '%', px + 10, 100, 12, PINK, 1, 'left');
drawText('棒 = 実際の数', px + 10, 132, 10, CREAM, 0.7, 'left');
drawText('点線 = 二項分布の期待曲線', px + 10, 150, 10, CREAM, 0.7, 'left');
drawText(SHUFFLE_SLOTS ? '名前を混ぜて割り当て' : '名前を順番に割り当て', px + 10, 178, 11, LILAC, 1, 'left');
if (winSlot >= 0) {
drawText('当選の枠 ' + (winSlot + 1) + ' → ' + names[winSlot], px + 10, 210, 13, LEMON, 1, 'left');
}
drawBand();
}
function stateText() {
if (winSlot >= 0) {
return '当選の枠 ' + (winSlot + 1) + ' → ' + names[winSlot];
}
return '落ちたボール ' + landed + ' — 真ん中の枠 ' + Math.round(middleShare() * 100) + '%';
}
// ── 공통 꼬리: 시계·버튼·동작 줄이기·정리 ──
// 결과를 보여 준 시간(ms) — 시연 중 판이 끝나면 LOOP_HOLD_MS 뒤 같은 시드로 다시 돈다
let holdMs = 0;
function restart() {
clock = 0;
holdMs = 0;
onRestart();
}
// 한 틱 — 시뮬레이션 시각을 16ms 올리고 항목 계산(step)을 한 번 돌린다
function tick() {
clock += FIXED_DT;
step();
if (demo && !still && finished()) {
holdMs += FIXED_DT;
if (holdMs >= LOOP_HOLD_MS) {
restart();
}
}
}
function render() {
draw();
say(stateText());
}
// 동작 줄이기 — 같은 시드 판을 화면에 그리지 않고 끝까지(끝이 없는 장면은 SETTLE_TICKS 틱까지) 계산한 뒤 마지막 장면만 그린다
function settle() {
for (let k = 0; k < SETTLE_TICKS; k += 1) {
clock += FIXED_DT;
step();
if (finished()) {
break;
}
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after() {
if (still) {
settle();
}
render();
}
function control(role, down) {
wake();
onControl(role, down);
after();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 버튼 — data-role 을 항목의 onControl 에 넘긴다. data-hold 가 있으면 누르고 있는 동안 true, 떼면 false
const buttons = Array.from(root.querySelectorAll('button[data-role]'));
buttons.forEach(function (btn) {
const role = btn.getAttribute('data-role') || '';
if (!btn.hasAttribute('data-hold')) {
// 한 번 누르는 버튼 — 마우스·손가락·Enter·Space 모두 click 하나로 들어온다
btn.addEventListener('click', function () { control(role, true); }, { signal: off.signal });
return;
}
let down = false;
function setHeld(v) {
if (down === v) {
return;
}
down = v;
btn.classList.toggle('is-held', v);
control(role, v);
}
// 누르고 있는 버튼 — 손가락·마우스는 pointerdown·up, 키보드는 Space·Enter 의 keydown·keyup
btn.addEventListener('pointerdown', function (ev) {
if (ev.button !== 0) {
return;
}
btn.setPointerCapture(ev.pointerId);
setHeld(true);
}, { signal: off.signal });
btn.addEventListener('pointerup', function () { setHeld(false); }, { signal: off.signal });
btn.addEventListener('pointercancel', function () { setHeld(false); }, { signal: off.signal });
btn.addEventListener('lostpointercapture', function () { setHeld(false); }, { signal: off.signal });
btn.addEventListener('keydown', function (ev) {
if (ev.key !== ' ' && ev.key !== 'Enter') {
return;
}
ev.preventDefault();
setHeld(true);
}, { signal: off.signal });
btn.addEventListener('keyup', function (ev) {
if (ev.key !== ' ' && ev.key !== 'Enter') {
return;
}
ev.preventDefault();
setHeld(false);
}, { signal: off.signal });
btn.addEventListener('blur', function () { setHeld(false); }, { signal: off.signal });
// 길게 누를 때 뜨는 메뉴를 막는다
btn.addEventListener('contextmenu', function (ev) { ev.preventDefault(); }, { signal: off.signal });
});
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 같은 시드 판의 마지막 장면을, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (demo) {
restart();
}
after();
}
else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
seed = DEMO_SEED;
restart();
// probe-hook
if (still) {
settle();
render();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
off.abort();
};
})();// LandingSlotBellHistogram - vanilla 판(08-pb-landing-slot-bell-histogram) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LandingSlotBellHistogram.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LandingSlotBellHistogram() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
type Seg = [number, number, number, number];
// 논리 무대 — 물리는 화면 폭과 상관없이 이 480×300 좌표에서만 돈다. 화면이 넓든 좁든 그릴 때만 배율을 곱한다
const W = 480;
const H = 300;
// 한 틱 = 16ms 고정. 실제 프레임 간격이 흔들려도 계산은 같은 걸음으로만 나아간다
const FIXED_DT = 16;
const DT = FIXED_DT / 1000;
const TAU = Math.PI * 2;
// 머리 띠 높이(논리 px) — 상태 줄과 버튼이 이 띠 위에 겹친다
const HEADER = 30;
// 시연이 끝난 판을 이만큼(ms) 보여 준 뒤 같은 시드로 처음부터 다시 돈다
const LOOP_HOLD_MS = 1600;
// 파스텔 팔레트 — 어두운 판 위에 밝은 공·핀을 얹어 명도 차로 움직임이 읽힌다
const BG: RGB = [30, 27, 48];
const BG_LOW: RGB = [46, 39, 70];
const BAND: RGB = [20, 18, 32];
const GUIDE: RGB = [104, 94, 140];
const CREAM: RGB = [255, 248, 234];
const SKY: RGB = [146, 208, 255];
const PINK: RGB = [255, 172, 200];
const MINT: RGB = [144, 232, 202];
const LILAC: RGB = [202, 182, 255];
const PEACH: RGB = [255, 208, 172];
const LEMON: RGB = [250, 234, 156];
const AQUA: RGB = [156, 238, 236];
const CORAL: RGB = [255, 184, 166];
const INK: RGB = [28, 24, 40];
const BALL_COLORS: RGB[] = [PEACH, MINT, SKY, PINK, LILAC, LEMON, AQUA, CORAL, CREAM];
// 공 이름표 — 실제 사람 이름 대신 글자
const LABELS: string[] = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'];
const FONT_FAMILY = '"Segoe UI", "Malgun Gothic", "Hiragino Sans", "Noto Sans KR", sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 출발 자리·같은 결과가 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue: number): () => number {
let s = seedValue >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 공 — 자리(x, y)·속도(vx, vy, px/s)·반지름. trail 은 지나온 자리(x, y 를 번갈아), slow 는 거의 멈춰 있던 시간(ms),
// finishMs 는 결승선을 지난 시각(ms, 아직이면 -1), mul 은 공마다 다른 계수(07 구름 마찰), out 은 판 밖에서 쉬는 중
type Ball = { x: number; y: number; vx: number; vy: number; r: number; color: RGB; label: string; trail: number[]; slow: number; finishMs: number; mul: number; out: boolean };
function makeBall(x: number, y: number, r: number, color: RGB, label: string): Ball {
return { x: x, y: y, vx: 0, vy: 0, r: r, color: color, label: label, trail: [], slow: 0, finishMs: -1, mul: 1, out: false };
}
// 속도를 먼저 갱신하는 적분 — 이번 걸음의 힘(중력)으로 속도를 바꾼 뒤, 그 새 속도로 자리를 옮긴다. h 는 초
function integrate(b: Ball, gx: number, gy: number, h: number): void {
b.vx += gx * h;
b.vy += gy * h;
b.x += b.vx * h;
b.y += b.vy * h;
}
function capSpeed(b: Ball, max: number): void {
const s = Math.hypot(b.vx, b.vy);
if (s > max) { b.vx *= max / s; b.vy *= max / s; }
}
// 마지막 충돌 — 법선(공 쪽을 향함)과 닿은 자리. 범퍼·키커가 이 법선 방향으로 덧힘을 준다
const contact = { nx: 0, ny: -1, px: 0, py: 0 };
// 원-원 충돌(움직이지 않는 원: 핀·범퍼) — 겹친 깊이만큼 법선 방향 바깥으로 밀어내고,
// 법선 방향 속도를 되튐 계수 e 만큼 뒤집는다. 돌려주는 값: 안 닿음 -1 · 닿았지만 멀어지는 중 0 · 다가오던 빠르기(px/s)
function hitCircle(b: Ball, cx: number, cy: number, cr: number, e: number): number {
const dx = b.x - cx;
const dy = b.y - cy;
const d = Math.hypot(dx, dy);
const overlap = b.r + cr - d;
if (overlap <= 0) { return -1; }
// 중심이 정확히 겹치면 방향을 정할 수 없다 — 위로 민다
const nx = d > 1e-9 ? dx / d : 0;
const ny = d > 1e-9 ? dy / d : -1;
b.x += nx * overlap;
b.y += ny * overlap;
contact.nx = nx;
contact.ny = ny;
contact.px = cx + nx * cr;
contact.py = cy + ny * cr;
const vn = b.vx * nx + b.vy * ny;
if (vn >= 0) { return 0; }
b.vx -= (1 + e) * vn * nx;
b.vy -= (1 + e) * vn * ny;
return -vn;
}
// 선분 위 가장 가까운 점 — 공 중심에서 선분으로 내린 수선의 발. t 는 0(시작점)~1(끝점)로 자른다
function closestOnSegment(px: number, py: number, ax: number, ay: number, bx: number, by: number): [number, number, number] {
const ex = bx - ax;
const ey = by - ay;
const len2 = ex * ex + ey * ey;
const t = len2 > 0 ? clamp(((px - ax) * ex + (py - ay) * ey) / len2, 0, 1) : 0;
return [ax + ex * t, ay + ey * t, t];
}
// 원-선분 충돌 — 가장 가까운 점까지 거리가 (반지름 + 선 두께의 반)보다 짧으면 겹친 만큼 밀어낸다.
// 선분이 움직이면(도는 플리퍼·풍차 막대) 접점의 속도(svx, svy)에 대한 상대 속도로 되튐을 잰다.
// grip 은 이번 걸음에 접선 방향 상대 속도에서 덜어 내는 몫 — 0 이면 미끄럼, 07 구름 마찰은 0 보다 크다
function hitSegment(b: Ball, ax: number, ay: number, bx: number, by: number, half: number, e: number, svx: number, svy: number, grip: number): number {
const c = closestOnSegment(b.x, b.y, ax, ay, bx, by);
const dx = b.x - c[0];
const dy = b.y - c[1];
const d = Math.hypot(dx, dy);
const overlap = b.r + half - d;
if (overlap <= 0) { return -1; }
let nx = 0;
let ny = -1;
if (d > 1e-9) {
nx = dx / d;
ny = dy / d;
} else {
// 중심이 선 위에 올라탔다 — 선분의 왼쪽 법선으로 민다
const L = Math.hypot(bx - ax, by - ay) || 1;
nx = (by - ay) / L;
ny = -(bx - ax) / L;
}
b.x += nx * overlap;
b.y += ny * overlap;
contact.nx = nx;
contact.ny = ny;
contact.px = c[0];
contact.py = c[1];
const rvx = b.vx - svx;
const rvy = b.vy - svy;
const vn = rvx * nx + rvy * ny;
if (vn >= 0) { return 0; }
const tvx = (rvx - vn * nx) * (1 - grip);
const tvy = (rvy - vn * ny) * (1 - grip);
b.vx = svx + tvx - e * vn * nx;
b.vy = svy + tvy - e * vn * ny;
return -vn;
}
// 움직이지 않는 벽 묶음 — 선분 하나하나에 hitSegment
function hitWalls(b: Ball, segs: Seg[], e: number): void {
for (let i = 0; i < segs.length; i += 1) {
const s = segs[i];
hitSegment(b, s[0], s[1], s[2], s[3], 0, e, 0, 0, 0);
}
}
// 공-공 충돌 — 같은 무게. 겹친 깊이를 반씩 나눠 밀어내고, 법선 방향 속도를 되튐 계수만큼 주고받는다
function hitBalls(a: Ball, b: Ball, e: number): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.hypot(dx, dy);
const overlap = a.r + b.r - d;
if (overlap <= 0) { return -1; }
const nx = d > 1e-9 ? dx / d : 1;
const ny = d > 1e-9 ? dy / d : 0;
a.x -= nx * overlap * 0.5;
a.y -= ny * overlap * 0.5;
b.x += nx * overlap * 0.5;
b.y += ny * overlap * 0.5;
const vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) { return 0; }
const j = -(1 + e) * vn * 0.5;
a.vx -= j * nx;
a.vy -= j * ny;
b.vx += j * nx;
b.vy += j * ny;
return -vn;
}
// 움직이지 않는 둥근 기둥(핀·표적) — glow 는 맞은 뒤 번지는 빛(0~1)
type Peg = { x: number; y: number; glow: number };
function pushTrail(b: Ball, n: number): void {
b.trail.push(b.x, b.y);
while (b.trail.length > n * 2) { b.trail.splice(0, 2); }
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 꼬리의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 버튼을 처음 누르는 순간 시연(가상 입력·같은 판 되풀이)을 멈추고 결과를 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 판의 시드와 시각(ms). 시각은 16ms 고정 스텝으로만 는다
let seed = 0;
let clock = 0;
// 화면 배율 — 논리 1px 이 화면 CSS px 몇 개인가. 그릴 때만 쓰고 물리 계산에는 넣지 않는다
let viewScale = 1;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const cssW = cv.getBoundingClientRect().width || W;
viewScale = cssW / W;
const k = viewScale * dpr;
cv.width = Math.max(1, Math.round(W * k));
cv.height = Math.max(1, Math.round(H * k));
// 그릴 때만 논리 좌표 → 화소 배율을 곱한다
ctx.setTransform(k, 0, 0, k, 0, 0);
}
function clearStage(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(BG, 1));
g.addColorStop(1, rgba(BG_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 머리 띠 — 상태 줄과 버튼 뒤의 어두운 띠. 판을 다 그린 뒤 맨 위에 덮는다
function drawBand(): void {
ctx.fillStyle = rgba(BAND, 0.92);
ctx.fillRect(0, 0, W, HEADER);
}
function strokeSeg(s: Seg, width: number, c: RGB, a: number): void {
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(s[0], s[1]);
ctx.lineTo(s[2], s[3]);
ctx.stroke();
}
// 꼬리 — 지나온 자리를 이을수록 굵고 진하게
function drawTrail(b: Ball, c: RGB, width: number): void {
const t = b.trail;
const n = t.length / 2;
ctx.lineCap = 'round';
for (let i = 1; i < n; i += 1) {
const k = i / n;
ctx.strokeStyle = rgba(c, 0.55 * k);
ctx.lineWidth = width * k;
ctx.beginPath();
ctx.moveTo(t[i * 2 - 2], t[i * 2 - 1]);
ctx.lineTo(t[i * 2], t[i * 2 + 1]);
ctx.stroke();
}
}
// 공 — 바닥 그림자 → 왼쪽 위가 밝은 둥근 몸통 → 진한 테두리 → 이름표 글자
function drawBall(b: Ball): void {
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.arc(b.x + 2, b.y + 3, b.r, 0, TAU);
ctx.fill();
const g = ctx.createRadialGradient(b.x - b.r * 0.35, b.y - b.r * 0.4, b.r * 0.1, b.x, b.y, b.r);
g.addColorStop(0, rgba(mixRGB(b.color, CREAM, 0.75), 1));
g.addColorStop(1, rgba(b.color, 1));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, TAU);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.6);
ctx.stroke();
if (b.label) {
ctx.fillStyle = rgba(INK, 1);
ctx.font = '700 ' + Math.round(b.r * 1.25) + 'px ' + FONT_FAMILY;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(b.label, b.x, b.y + 0.5);
}
}
function drawText(text: string, x: number, y: number, size: number, c: RGB, a: number, align: CanvasTextAlign): void {
ctx.fillStyle = rgba(c, a);
ctx.font = '600 ' + size + 'px ' + FONT_FAMILY;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillText(text, x, y);
}
// 08 분포판 — 칸별 개수 누적 + 막대 높이 lerp + 이항 기대 곡선 + 이름을 칸에 섞어 배정
// 공을 하나씩 떨어뜨리는 간격(ms)
const DROP_INTERVAL_MS = 70;
// 바닥 칸 수 — 핀 줄은 칸 수보다 하나 적다(줄마다 왼쪽·오른쪽 갈림이 한 번)
const SLOT_COUNT = 9;
// 이름을 칸에 섞어 배정하나 — false 면 A 부터 차례로 칸에 붙는다(가운데 이름이 자주 당첨된다)
const SHUFFLE_SLOTS = true;
const DROP_TOTAL = 60;
const ROWS = SLOT_COUNT - 1;
const PEG_DX = 26;
const PEG_DY = 20;
const APEX_X = 168;
const APEX_Y = 66;
const PEG_R = 3;
const BALL_R = 3.5;
const GOLD_R = 5;
const GRAVITY = 2200;
const RESTITUTION = 0.35;
// 핀에 닿을 때마다 — 옆 빠르기를 PEG_DAMP_X 배로 줄이고 시드 난수로 ±PEG_JITTER/2 를 더한다.
// 공과 핀이 완벽히 둥글고 똑같으면 같은 길만 되풀이한다(흔들림 0 이면 칸 몫이 거의 고르다) — 실제 판의 작은 흠 대신 이 흔들림이 갈림을 반반에 가깝게 만든다
const PEG_DAMP_X = 0.45;
const PEG_JITTER = 110;
const SUBSTEPS = 4;
// 막대 높이가 목표를 따라가는 몫 — 틱마다 남은 차의 14%
const BAR_LERP = 0.14;
const BAR_MAX_H = 58;
const BAR_BOTTOM = 284;
const SLOT_TOP = APEX_Y + (ROWS - 1) * PEG_DY + 14;
const SLOT_LEFT = APEX_X - SLOT_COUNT / 2 * PEG_DX;
const SHUFFLE_MS = 700;
const DEMO_SEED = 4768;
const SETTLE_TICKS = 900;
const pegs: Peg[] = [];
for (let r = 0; r < ROWS; r += 1) {
for (let k = 0; k <= r; k += 1) { pegs.push({ x: APEX_X + (k - r / 2) * PEG_DX, y: APEX_Y + r * PEG_DY, glow: 0 }); }
}
const EDGE = (ROWS - 0.5) * PEG_DX / 2 + 18;
const walls: Seg[] = [
[APEX_X - 11, APEX_Y - 10, APEX_X - EDGE, APEX_Y + (ROWS - 0.5) * PEG_DY],
[APEX_X + 11, APEX_Y - 10, APEX_X + EDGE, APEX_Y + (ROWS - 0.5) * PEG_DY],
[SLOT_LEFT, APEX_Y + (ROWS - 0.5) * PEG_DY, SLOT_LEFT, BAR_BOTTOM],
[SLOT_LEFT + SLOT_COUNT * PEG_DX, APEX_Y + (ROWS - 0.5) * PEG_DY, SLOT_LEFT + SLOT_COUNT * PEG_DX, BAR_BOTTOM]
];
const dividers: Seg[] = [];
for (let k = 1; k < SLOT_COUNT; k += 1) { dividers.push([SLOT_LEFT + k * PEG_DX, SLOT_TOP, SLOT_LEFT + k * PEG_DX, BAR_BOTTOM]); }
// 이항 기대 몫 — 줄마다 반반 갈리면 k 칸에 들 몫 = (ROWS 개 중 k 개를 고르는 수) / 2^ROWS
function binomShare(k: number): number {
let c = 1;
for (let i = 0; i < k; i += 1) { c = c * (ROWS - i) / (i + 1); }
return c / Math.pow(2, ROWS);
}
const EXPECT: number[] = [];
for (let k = 0; k < SLOT_COUNT; k += 1) { EXPECT.push(binomShare(k)); }
let rng = makeRng(1);
let live: Ball[] = [];
let counts: number[] = [];
let shown: number[] = [];
let names: string[] = [];
let landed = 0;
let dropped = 0;
let dropMs = 0;
let phase = 'drop';
let phaseMs = 0;
let winSlot = -1;
let flash: number[] = [];
function slotAt(x: number): number { return clamp(Math.floor((x - SLOT_LEFT) / PEG_DX), 0, SLOT_COUNT - 1); }
function slotX(k: number): number { return SLOT_LEFT + (k + 0.5) * PEG_DX; }
function onRestart(): void {
rng = makeRng(seed);
live = [];
counts = [];
shown = [];
flash = [];
for (let k = 0; k < SLOT_COUNT; k += 1) {
counts.push(0);
shown.push(0);
flash.push(0);
}
// 이름 배정 — 섞기면 시드 난수로 뒤에서부터 한 자리씩 맞바꾼다
names = LABELS.slice(0, SLOT_COUNT);
if (SHUFFLE_SLOTS) {
for (let i = names.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = names[i];
names[i] = names[j];
names[j] = t;
}
}
landed = 0;
dropped = 0;
dropMs = 0;
phase = 'drop';
phaseMs = 0;
winSlot = -1;
}
function dropBall(gold: boolean): void {
const b = makeBall(APEX_X + (rng() - 0.5) * 1.5, 44, gold ? GOLD_R : BALL_R, gold ? LEMON : BALL_COLORS[dropped % 6], '');
b.vx = (rng() - 0.5) * 14;
b.mul = gold ? 1 : 0;
live.push(b);
}
function barUnit(): number {
let peak = 1;
for (let k = 0; k < SLOT_COUNT; k += 1) { peak = Math.max(peak, counts[k], EXPECT[k] * landed); }
return Math.min(3, BAR_MAX_H / peak);
}
function step(): void {
phaseMs += FIXED_DT;
if (phase === 'drop') {
dropMs += FIXED_DT;
while (dropMs >= DROP_INTERVAL_MS && dropped < DROP_TOTAL) {
dropMs -= DROP_INTERVAL_MS;
dropBall(false);
dropped += 1;
}
if (landed >= DROP_TOTAL) {
phase = 'shuffle';
phaseMs = 0;
}
} else if (phase === 'shuffle' && phaseMs >= SHUFFLE_MS) {
phase = 'gold';
phaseMs = 0;
dropBall(true);
}
const hs = DT / SUBSTEPS;
const unit = barUnit();
for (let s = 0; s < SUBSTEPS; s += 1) {
for (let i = 0; i < live.length; i += 1) { integrate(live[i], 0, GRAVITY, hs); }
for (let i = 0; i < live.length; i += 1) {
const b = live[i];
for (let p = 0; p < pegs.length; p += 1) {
const g = pegs[p];
if (Math.abs(g.y - b.y) > b.r + PEG_R) { continue; }
if (hitCircle(b, g.x, g.y, PEG_R, RESTITUTION) > 0) {
g.glow = 1;
b.vx = b.vx * PEG_DAMP_X + (rng() - 0.5) * PEG_JITTER;
}
}
hitWalls(b, walls, RESTITUTION);
hitWalls(b, dividers, RESTITUTION);
}
}
// 칸에 닿은 공 — 그 칸 막대 꼭대기에 닿으면 개수에 넣고 판에서 뺀다
const keep: Ball[] = [];
for (let i = 0; i < live.length; i += 1) {
const b = live[i];
const k = slotAt(b.x);
if (b.y > SLOT_TOP && b.y + b.r >= BAR_BOTTOM - shown[k] - 1) {
flash[k] = 1;
if (b.mul > 0) {
winSlot = k;
phase = 'done';
phaseMs = 0;
} else {
counts[k] += 1;
landed += 1;
}
} else {
pushTrail(b, 6);
keep.push(b);
}
}
live = keep;
// 막대 높이 — 목표(개수 × 배율)를 틱마다 BAR_LERP 몫씩 따라간다
for (let k = 0; k < SLOT_COUNT; k += 1) {
shown[k] += (counts[k] * unit - shown[k]) * BAR_LERP;
flash[k] *= 0.88;
}
for (let p = 0; p < pegs.length; p += 1) { pegs[p].glow *= 0.85; }
}
function finished(): boolean { return phase === 'done'; }
function onControl(role: string, down: boolean): void {
if (role === 'redraw' && down) {
seed += 1;
restart();
}
}
function middleShare(): number { return landed ? counts[ROWS / 2] / landed : 0; }
function draw(): void {
clearStage();
const unit = barUnit();
for (let k = 0; k < SLOT_COUNT; k += 1) {
const x = SLOT_LEFT + k * PEG_DX;
const won = k === winSlot;
ctx.fillStyle = rgba(won ? LEMON : mixRGB(SKY, CREAM, flash[k] * 0.6), won ? 1 : 0.85);
ctx.fillRect(x + 3, BAR_BOTTOM - shown[k], PEG_DX - 6, shown[k]);
}
for (let i = 0; i < walls.length; i += 1) { strokeSeg(walls[i], 3, CREAM, 0.45); }
for (let i = 0; i < dividers.length; i += 1) { strokeSeg(dividers[i], 2, CREAM, 0.4); }
strokeSeg([SLOT_LEFT, BAR_BOTTOM, SLOT_LEFT + SLOT_COUNT * PEG_DX, BAR_BOTTOM], 2, CREAM, 0.6);
// 이항 기대 곡선 — 지금까지 떨어진 개수 × 기대 몫 높이를 칸 가운데마다 잇는다
if (landed > 0) {
ctx.setLineDash([4, 4]);
ctx.strokeStyle = rgba(PINK, 0.95);
ctx.lineWidth = 2;
ctx.beginPath();
for (let k = 0; k < SLOT_COUNT; k += 1) {
const y = BAR_BOTTOM - EXPECT[k] * landed * unit;
if (k === 0) { ctx.moveTo(slotX(k), y); } else { ctx.lineTo(slotX(k), y); }
}
ctx.stroke();
ctx.setLineDash([]);
}
for (let p = 0; p < pegs.length; p += 1) {
const g = pegs[p];
// 맞은 핀 — 빛이 번졌다가 틱마다 0.85 배로 잦아든다(공이 많이 지나는 가운데 길이 더 자주 밝다)
if (g.glow > 0.05) {
ctx.fillStyle = rgba(LILAC, 0.45 * g.glow);
ctx.beginPath();
ctx.arc(g.x, g.y, PEG_R + 7 * g.glow, 0, TAU);
ctx.fill();
}
ctx.fillStyle = rgba(mixRGB(CREAM, LILAC, g.glow), 1);
ctx.beginPath();
ctx.arc(g.x, g.y, PEG_R + g.glow, 0, TAU);
ctx.fill();
}
for (let i = 0; i < live.length; i += 1) {
const b = live[i];
drawTrail(b, b.color, b.r * 1.4);
ctx.fillStyle = rgba(b.color, 1);
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, TAU);
ctx.fill();
}
// 이름표 — 섞는 동안 차례 자리에서 섞인 자리로 미끄러진다
const k0 = phase === 'drop' ? 0 : smooth(phase === 'shuffle' ? phaseMs / SHUFFLE_MS : 1);
for (let k = 0; k < SLOT_COUNT; k += 1) {
const from = LABELS.indexOf(names[k]);
drawText(names[k], lerp(slotX(from), slotX(k), k0), 293, 10, k === winSlot ? LEMON : CREAM, 0.95, 'center');
}
// 오른쪽 판 — 숫자와 범례
const px = 318;
ctx.fillStyle = rgba(BAND, 0.55);
ctx.fillRect(px, 40, W - px - 8, 250);
drawText('떨어진 공 ' + landed, px + 10, 58, 12, CREAM, 0.95, 'left');
drawText('가운데 칸 ' + Math.round(middleShare() * 100) + '%', px + 10, 80, 12, SKY, 1, 'left');
drawText('기대 ' + Math.round(EXPECT[ROWS / 2] * 100) + '%', px + 10, 100, 12, PINK, 1, 'left');
drawText('막대 = 실제 개수', px + 10, 132, 10, CREAM, 0.7, 'left');
drawText('점선 = 이항 기대 곡선', px + 10, 150, 10, CREAM, 0.7, 'left');
drawText(SHUFFLE_SLOTS ? '이름 섞어 배정' : '이름 차례 배정', px + 10, 178, 11, LILAC, 1, 'left');
if (winSlot >= 0) { drawText('당첨 칸 ' + (winSlot + 1) + ' → ' + names[winSlot], px + 10, 210, 13, LEMON, 1, 'left'); }
drawBand();
}
function stateText(): string {
if (winSlot >= 0) { return '당첨 칸 ' + (winSlot + 1) + ' → ' + names[winSlot]; }
return '떨어진 공 ' + landed + ' — 가운데 칸 ' + Math.round(middleShare() * 100) + '%';
}
// ── 공통 꼬리: 시계·버튼·동작 줄이기·정리 ──
// 결과를 보여 준 시간(ms) — 시연 중 판이 끝나면 LOOP_HOLD_MS 뒤 같은 시드로 다시 돈다
let holdMs = 0;
function restart(): void {
clock = 0;
holdMs = 0;
onRestart();
}
// 한 틱 — 시뮬레이션 시각을 16ms 올리고 항목 계산(step)을 한 번 돌린다
function tick(): void {
clock += FIXED_DT;
step();
if (demo && !still && finished()) {
holdMs += FIXED_DT;
if (holdMs >= LOOP_HOLD_MS) { restart(); }
}
}
function render(): void {
draw();
say(stateText());
}
// 동작 줄이기 — 같은 시드 판을 화면에 그리지 않고 끝까지(끝이 없는 장면은 SETTLE_TICKS 틱까지) 계산한 뒤 마지막 장면만 그린다
function settle(): void {
for (let k = 0; k < SETTLE_TICKS; k += 1) {
clock += FIXED_DT;
step();
if (finished()) { break; }
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 입력이 낳은 결과까지 바로 계산해 그린다
function after(): void {
if (still) { settle(); }
render();
}
function control(role: string, down: boolean): void {
wake();
onControl(role, down);
after();
}
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 버튼 — data-role 을 항목의 onControl 에 넘긴다. data-hold 가 있으면 누르고 있는 동안 true, 떼면 false
const buttons: HTMLButtonElement[] = Array.from(root.querySelectorAll<HTMLButtonElement>('button[data-role]'));
buttons.forEach(function (btn: HTMLButtonElement): void {
const role = btn.getAttribute('data-role') || '';
if (!btn.hasAttribute('data-hold')) {
// 한 번 누르는 버튼 — 마우스·손가락·Enter·Space 모두 click 하나로 들어온다
btn.addEventListener('click', function (): void { control(role, true); }, { signal: off.signal });
return;
}
let down = false;
function setHeld(v: boolean): void {
if (down === v) { return; }
down = v;
btn.classList.toggle('is-held', v);
control(role, v);
}
// 누르고 있는 버튼 — 손가락·마우스는 pointerdown·up, 키보드는 Space·Enter 의 keydown·keyup
btn.addEventListener('pointerdown', function (ev: PointerEvent): void {
if (ev.button !== 0) { return; }
btn.setPointerCapture(ev.pointerId);
setHeld(true);
}, { signal: off.signal });
btn.addEventListener('pointerup', function (): void { setHeld(false); }, { signal: off.signal });
btn.addEventListener('pointercancel', function (): void { setHeld(false); }, { signal: off.signal });
btn.addEventListener('lostpointercapture', function (): void { setHeld(false); }, { signal: off.signal });
btn.addEventListener('keydown', function (ev: KeyboardEvent): void {
if (ev.key !== ' ' && ev.key !== 'Enter') { return; }
ev.preventDefault();
setHeld(true);
}, { signal: off.signal });
btn.addEventListener('keyup', function (ev: KeyboardEvent): void {
if (ev.key !== ' ' && ev.key !== 'Enter') { return; }
ev.preventDefault();
setHeld(false);
}, { signal: off.signal });
btn.addEventListener('blur', function (): void { setHeld(false); }, { signal: off.signal });
// 길게 누를 때 뜨는 메뉴를 막는다
btn.addEventListener('contextmenu', function (ev: Event): void { ev.preventDefault(); }, { signal: off.signal });
});
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 같은 시드 판의 마지막 장면을, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
if (demo) { restart(); }
after();
} else {
startLoop();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
seed = DEMO_SEED;
restart();
// probe-hook
if (still) {
settle();
render();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
off.abort();
};
}, []);
return (
<div className={cx("pb") + " is-demo"} ref={rootRef}>
<canvas className={cx("pb__canvas")} data-role="canvas" width={480} height={300} aria-hidden="true"></canvas>
<div className={cx("pb__head")}>
<p className={cx("pb__status")} data-role="status" aria-live="off">떨어진 공 0 — 가운데 칸 0%</p>
<div className={cx("pb__controls")}>
<button type="button" className={cx("pb__btn")} data-role="redraw">다시 추첨</button>
</div>
</div>
</div>
);
}08 落ちた枠を積み上げて釣り鐘の形が現れる分布盤 — 計測 累積面積 8.804% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · javascript ピンボール 9種 — 抽選盤・フリッパー・バンパー・ビー玉レース
このデモが属する記事
javascript ピンボール 9種 — 抽選盤・フリッパー・バンパー・ビー玉レースjavascript ピンボールは、名前を付けたボールをピンや棒やバンパーの間に転がして当選を決める、キャンバスの抽選盤です。ボールを同じ所から落として枠ごとに名前を付けると、なぜ真ん中の名前ばかり当たるのでしょうか。08番は落ちた枠を積ん この記事のデモをすべて見る


