コイントス 9種 — 横の軸で回る3Dの表裏の入れ替えから回数が増えるほど半分へ寄る集計まで
コイントスの表裏はどう決まるのでしょうか。03番は滞空時間に回転の速さを掛けて半回転の偶奇で面を読み、05番は回数が増えるほど半分へ狭まる帯を描き、08番は積んだコインの上を弾んで落ちます。九つの場面は、回転角ひとつがコインの形と上になった面をいっしょに決めるキャンバスのデモです。
コイントスは面を選ぶことではなく角度を読むことです — コイントスのシミュレーションは回転角ひとつで形と面をいっしょに決めるところから始まります
01 sc-coin-axis-spin-ellipse-flip
02 sc-coin-rim-thickness-shade-ring
03 sc-coin-toss-arc-spin-parity
04 sc-coin-edge-stand-tilt-window
05 sc-coin-tally-ratio-converge
06 sc-coin-weighted-center-shift
07 sc-coin-streak-run-strip
08 sc-coin-stack-drop-bounce-chain
09 sc-coin-hero-decision-table
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 横の軸で回って平たくなり表裏が入れ替わるコイン
- 02 側面の厚みと光の向きで塗る金属のふち
- 03 滞空時間に回転の速さを掛けて半回転の数で面を読む放物線の投げ
- 04 厚みがつくる狭いすきまでふちを下に立つ着地
- 05 投げる回数が増えるほど表の割合が半分へ寄って狭まる帯
- 06 重心をずらしたコインが片面へ寄る割合
- 07 同じ面が続くと長さが伸びる連続の帯
- 08 積んだコインの上へ落ちて弾む連鎖
- 09 二つの選択肢を掛けて投げる机の決定ヒーロー
九つの場面は同じ決まりで動きます。コインの平たさは角θのcosの値が決め、見える面はそのcosの値の符号が決め、投げの結果は半回転の数の偶奇だけから出ます。乱数は線形合同法の生成器 seeded() 一つだけなので、同じシードからは同じ結果ができ、ソースには Math.random・Date・fetch の呼び出しが0件です。コイン 投げ シミュレーションとして見るなら、この編は計算が見えるデモであり、本物のコインが公平かどうかを調べる道具ではありません。
並び順は一枚のコインから集計へ広がります。01・02は形と側面の光、03・04は投げとふちの着地、05・06・07は投げた結果が積み上げる割合・かたより・連続、08・09は積んだ山の上のはねと二択の決定です。
コイントス 9種 — 平たくなって入れ替わる3Dの表裏・光で塗る金属のふち・半回転の数で面を読む放物線の投げ・狭いすきまで立つふちの着地・半分へ寄る集計の帯・重心をずらしたコイン・連続が伸びる帯・積んだコインの上のはね・二択の決定ヒーロー
九つの場面の面積と動き
| 場面 | 累積面積 | 動いたフレーム | 強さの平均 |
|---|---|---|---|
| 01 横軸の回転 | 24.5% | 23/23 | 63.7 |
| 02 側面の光 | 13.8% | 23/23 | 65.5 |
| 03 投げ | 17.1% | 23/23 | 48.5 |
| 04 ふち | 18.1% | 23/23 | 85.6 |
| 05 収束の帯 | 19.1% | 23/23 | 78.3 |
| 06 かたよったコイン | 11.4% | 23/23 | 93.9 |
| 07 連続の帯 | 18.3% | 23/23 | 59.7 |
| 08 はね | 15.8% | 23/23 | 69.1 |
| 09 決定ヒーロー | 20.3% | 23/23 | 64.0 |
コインが大きく画面を使う01が24.5%でもっとも広く、二枚のコインの割合の帯だけが伸びる06は11.4%でもっとも狭いのに、強さの平均は93.9で最大です。九つとも23/23なので、描いたフレームのあいだで止まった区間はありません。
01横の軸で回って平たくなり表裏が入れ替わるコイン
コインは直径が変わらない円盤なので、ANGLE() が回した角θでは縦の直径だけが COS_SQUASH 倍に縮み、角が90度を過ぎてcosθが負になると表の模様と裏の模様が入れ替わります。右の小さなコインは同じ平たさで表の模様だけを描きつづけ、面が替わるコインと見比べられます。一回転は OMEGA 4.2ラジアン/秒で1.50秒、最初の面の入れ替えは0.30秒ごろです。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="コイントスの舞台 — Enter・Space・クリックで投げる、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="横の軸で回る二枚のコイン。左のコインは回転角に応じて縦の直径がcos倍に縮み、角が90度を過ぎてcosが負になる瞬間に金色の表の模様と空色の裏の模様が入れ替わって描かれます。右のコインは面を替えず表の模様だけを描きます。下の目盛りがcosの値を示します"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">コイントスのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 누르면 던진다. 키보드 포커스도 여기서 받는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP = [250, 246, 252];
const BG_BOT = [228, 234, 250];
const TABLE_TOP = [240, 226, 238];
const TABLE_BOT = [222, 208, 236];
const GOLD = [250, 226, 150];
const GOLD_DEEP = [196, 150, 52];
const SKY = [176, 210, 246];
const SKY_DEEP = [74, 120, 176];
const RIM_LO = [184, 156, 100];
const RIM_HI = [255, 248, 222];
const ROSE = [240, 150, 176];
const ROSE_DEEP = [190, 52, 86];
const MINT = [150, 214, 190];
const MINT_DEEP = [22, 112, 82];
const LAV = [188, 172, 236];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function tossHalf(k, t) { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k) { return k.h0 + k.hr * k.T; }
function tossXY(k, t) {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(半回転の数 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k, t) {
const q = t - (k.t0 + k.T);
if (q <= 0) {
return tossHalf(k, t);
}
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k, t) {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h) { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 偶数 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face, rng, minPairs) {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 表(0)과 裏(1) — 값이 p 보다 작으면 表
function outcomes(seed, n, p) {
const rng = seeded(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(rng() < p ? 0 : 1);
}
return out;
}
// 가장 긴 연속 길이
function longestRun(seq) {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) {
best = cur;
}
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x, y, rx, ry, c, a) {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) {
return;
}
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face, R, a) {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) {
ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
else {
ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
}
else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) {
disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a);
}
}
}
function coinShadow(cx, gy, R, lift, a) {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx, cy, R, th, thick, lx, ly, lz, a, startFace) {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) {
continue;
}
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx, cy, R, thick, a) {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest() {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart() {
lap += 1;
doneSaid = false;
if (still) {
showRest();
return;
}
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function () {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 01 가로축을 돌며 납작해졌다 뒤집히는 3D 앞뒤 전환 ─────────────────────────
// 회전각 ANGLE 하나가 모양(세로 지름 R·cos)과 면(cos 의 부호)을 함께 정한다
const OMEGA = 4.2;
const COIN_R = 70;
const THICK_PX = 9;
const LAP_S = 9.6;
const START_S = 0.0;
const REST_S = 0.0;
const RESULT_S = 8.8;
let angle0 = 0.3;
let omegaNow = OMEGA;
function build() {
angle0 = 0.3 + lap * 0.7;
// 던질 때마다 속도가 조금씩 달라진다
omegaNow = OMEGA * (1 + 0.12 * (lap % 3));
}
function update(dt) { if (dt < 0) {
return;
} }
// 움직임 줄이기에서는 회전 없이 表 정면 한 장
function ANGLE() { return still ? 0 : angle0 + omegaNow * sceneT; }
function faceName(n) { return n === 0 ? '表' : '裏'; }
function draw() {
skyFill(BG_TOP, BG_BOT);
tableBack(230);
const th = ANGLE();
const COS_SQUASH = Math.cos(th);
const cx = 168;
const cy = 154;
// 동전 둘레 후광 — 세로로 얼마나 펴졌는지(|cos|)에 비례해 퍼진다
disc(cx, cy, COIN_R * (1.15 + 0.35 * Math.abs(COS_SQUASH)), COS_SQUASH >= 0 ? GOLD : SKY, 0.34 * Math.abs(COS_SQUASH));
oval(cx, 240, COIN_R * (0.8 + 0.3 * Math.abs(COS_SQUASH)), COIN_R * 0.26, INK, 0.2);
drawCoin(cx, cy, COIN_R, th, THICK_PX, 0.36, -0.46, 0.81, 1, 0);
// 오른쪽 대비판 — 면을 바꾸지 않고 늘 表 무늬만 그린다. 가장 좁을 때도 무늬가 그대로라 어색하다
const sx = 372;
const sy = 160;
const sr = 42;
coinShadow(sx, 240, sr, 0, 1);
ctx.save();
ctx.translate(sx, sy);
ctx.scale(1, Math.max(Math.abs(COS_SQUASH), 0.012));
faceArt(0, sr, 1);
ctx.restore();
tag('面が入れ替わるコイン', cx, 66, MINT, INK, 11, 'center');
tag('表だけを描くコイン', sx, 66, ROSE, INK, 11, 'center');
// cos 눈금 — 가운데가 0, 오른쪽이 表(양수), 왼쪽이 裏(음수)
const gx0 = 70;
const gw = 340;
box(gx0, 258, gw, 14, 7, [255, 255, 255], 0.75);
const mid = gx0 + gw / 2;
const fw = (gw / 2) * COS_SQUASH;
box(Math.min(mid, mid + fw), 258, Math.abs(fw), 14, 7, COS_SQUASH >= 0 ? GOLD : SKY, 1);
seg(mid, 254, mid, 276, 2, INK, 0.8);
tag('cos θ = {v}'.replace('{v}', (COS_SQUASH >= 0 ? '+' : '') + COS_SQUASH.toFixed(2)), mid, 288, PAPER, INK, 10, 'center');
}
function statusText() {
const th = ANGLE();
const deg = Math.round(((th * 180 / PI) % 360 + 360) % 360);
return '回転角 {a}度・{f}が見える'.replace('{a}', String(deg)).replace('{f}', faceName(Math.cos(th) >= 0 ? 0 : 1));
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// AxisSpinEllipseFlip - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./AxisSpinEllipseFlip.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function AxisSpinEllipseFlip() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP: RGB = [250, 246, 252];
const BG_BOT: RGB = [228, 234, 250];
const TABLE_TOP: RGB = [240, 226, 238];
const TABLE_BOT: RGB = [222, 208, 236];
const GOLD: RGB = [250, 226, 150];
const GOLD_DEEP: RGB = [196, 150, 52];
const SKY: RGB = [176, 210, 246];
const SKY_DEEP: RGB = [74, 120, 176];
const RIM_LO: RGB = [184, 156, 100];
const RIM_HI: RGB = [255, 248, 222];
const ROSE: RGB = [240, 150, 176];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [150, 214, 190];
const MINT_DEEP: RGB = [22, 112, 82];
const LAV: RGB = [188, 172, 236];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function mix(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)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 던지기 물리 ───────────────────────────────────────────
// 한 번의 던지기: 출발 (x0, y0) → 도착 (x1, y1), 체공 T 초, 꼭대기 peak 픽셀, 초당 반 바퀴 수 hr, 시작 반 바퀴 수 h0.
// 위치는 포물선(중력만), 각도는 반 바퀴 수 × π — 체공 중 회전 속도는 일정하다
type Toss = { t0: number; T: number; x0: number; y0: number; x1: number; y1: number; peak: number; hr: number; h0: number };
function tossHalf(k: Toss, t: number): number { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k: Toss): number { return k.h0 + k.hr * k.T; }
function tossXY(k: Toss, t: number): [number, number] {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(반 바퀴 수 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
if (q <= 0) { return tossHalf(k, t); }
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h: number): number { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 짝수 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face: number, rng: () => number, minPairs: number): number {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 앞면(0)과 뒷면(1) — 값이 p 보다 작으면 앞면
function outcomes(seed: number, n: number, p: number): number[] {
const rng = seeded(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(rng() < p ? 0 : 1); }
return out;
}
// 가장 긴 연속 길이
function longestRun(seq: number[]): number {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) { best = cur; }
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x: number, y: number, rx: number, ry: number, c: RGB, a: number): void {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) { return; }
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face: number, R: number, a: number): void {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) { ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr); } else { ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr); }
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
} else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) { disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a); }
}
}
function coinShadow(cx: number, gy: number, R: number, lift: number, a: number): void {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx: number, cy: number, R: number, th: number, thick: number, lx: number, ly: number, lz: number, a: number, startFace: number): void {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) { continue; }
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx: number, cy: number, R: number, thick: number, a: number): void {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest(): void {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart(): void {
lap += 1;
doneSaid = false;
if (still) { showRest(); return; }
begin();
showNow();
if (!paused) { startLoop(); }
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function (): void {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 01 가로축을 돌며 납작해졌다 뒤집히는 3D 앞뒤 전환 ─────────────────────────
// 회전각 ANGLE 하나가 모양(세로 지름 R·cos)과 면(cos 의 부호)을 함께 정한다
const OMEGA = 4.2;
const COIN_R = 70;
const THICK_PX = 9;
const LAP_S = 9.6;
const START_S = 0.0;
const REST_S = 0.0;
const RESULT_S = 8.8;
let angle0 = 0.3;
let omegaNow = OMEGA;
function build(): void {
angle0 = 0.3 + lap * 0.7;
// 던질 때마다 속도가 조금씩 달라진다
omegaNow = OMEGA * (1 + 0.12 * (lap % 3));
}
function update(dt: number): void { if (dt < 0) { return; } }
// 움직임 줄이기에서는 회전 없이 앞면 정면 한 장
function ANGLE(): number { return still ? 0 : angle0 + omegaNow * sceneT; }
function faceName(n: number): string { return n === 0 ? '앞면' : '뒷면'; }
function draw(): void {
skyFill(BG_TOP, BG_BOT);
tableBack(230);
const th = ANGLE();
const COS_SQUASH = Math.cos(th);
const cx = 168;
const cy = 154;
// 동전 둘레 후광 — 세로로 얼마나 펴졌는지(|cos|)에 비례해 퍼진다
disc(cx, cy, COIN_R * (1.15 + 0.35 * Math.abs(COS_SQUASH)), COS_SQUASH >= 0 ? GOLD : SKY, 0.34 * Math.abs(COS_SQUASH));
oval(cx, 240, COIN_R * (0.8 + 0.3 * Math.abs(COS_SQUASH)), COIN_R * 0.26, INK, 0.2);
drawCoin(cx, cy, COIN_R, th, THICK_PX, 0.36, -0.46, 0.81, 1, 0);
// 오른쪽 대비판 — 면을 바꾸지 않고 늘 앞면 무늬만 그린다. 가장 좁을 때도 무늬가 그대로라 어색하다
const sx = 372;
const sy = 160;
const sr = 42;
coinShadow(sx, 240, sr, 0, 1);
ctx.save();
ctx.translate(sx, sy);
ctx.scale(1, Math.max(Math.abs(COS_SQUASH), 0.012));
faceArt(0, sr, 1);
ctx.restore();
tag('면이 바뀌는 동전', cx, 66, MINT, INK, 11, 'center');
tag('앞면만 그리는 동전', sx, 66, ROSE, INK, 11, 'center');
// cos 눈금 — 가운데가 0, 오른쪽이 앞면(양수), 왼쪽이 뒷면(음수)
const gx0 = 70;
const gw = 340;
box(gx0, 258, gw, 14, 7, [255, 255, 255], 0.75);
const mid = gx0 + gw / 2;
const fw = (gw / 2) * COS_SQUASH;
box(Math.min(mid, mid + fw), 258, Math.abs(fw), 14, 7, COS_SQUASH >= 0 ? GOLD : SKY, 1);
seg(mid, 254, mid, 276, 2, INK, 0.8);
tag('cos θ = {v}'.replace('{v}', (COS_SQUASH >= 0 ? '+' : '') + COS_SQUASH.toFixed(2)), mid, 288, PAPER, INK, 10, 'center');
}
function statusText(): string {
const th = ANGLE();
const deg = Math.round(((th * 180 / PI) % 360 + 360) % 360);
return '회전각 {a}도 · {f}이 보인다'.replace('{a}', String(deg)).replace('{f}', faceName(Math.cos(th) >= 0 ? 0 : 1));
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'동전 던지기 시연 무대 — Enter 나 Space 나 클릭으로 던지기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'가로축으로 도는 동전 두 개. 왼쪽 동전은 회전각에 따라 세로 지름이 cos 배로 줄었다가 각이 90도를 지나 cos 가 음수가 되는 순간 앞면 금빛 무늬와 뒷면 하늘빛 무늬가 바뀌어 그려지고, 오른쪽 동전은 면을 바꾸지 않고 앞면 무늬만 그린다. 아래 눈금이 cos 값을 보여 준다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'동전 던지기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}01 · 横の軸で回って平たくなり表裏が入れ替わるコイン — 計測 累積面積 24.478% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function ANGLE() { return still ? 0 : angle0 + omegaNow * sceneT; }
function faceName(n) { return n === 0 ? '表' : '裏'; }
function draw() {
skyFill(BG_TOP, BG_BOT);
tableBack(230);
const th = ANGLE();
const COS_SQUASH = Math.cos(th);
const cx = 168;
const cy = 154;
// コインの周りの後光 — 縦にどれだけ開いたか(|cos|)に比例して広がる
disc(cx, cy, COIN_R * (1.15 + 0.35 * Math.abs(COS_SQUASH)), COS_SQUASH >= 0 ? GOLD : SKY, 0.34 * Math.abs(COS_SQUASH));
oval(cx, 240, COIN_R * (0.8 + 0.3 * Math.abs(COS_SQUASH)), COIN_R * 0.26, INK, 0.2);
drawCoin(cx, cy, COIN_R, th, THICK_PX, 0.36, -0.46, 0.81, 1, 0);
02側面の厚みと光の向きで塗る金属のふち
コインをななめに立てると二枚の円盤のあいだに側面の帯が見え、側面の周りを RIM_N 40の部分に分けて、部分ごとに法線と光の向きの内積 dotLight で明るさを決めます。回るにつれて明るい部分が片側へ寄って流れ、下の40マスの目盛りも同じ関数の値をそのまま塗ります。右のコインは厚みが0なので側面の部分がなく、紙のように薄く見えます。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="コイントスの舞台 — Enter・Space・クリックで投げる、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="側面の厚みが16のコインと厚み0のコインが同じ角度で回ります。側面は40の部分に分かれ、各部分の法線と光の向きの内積で明るさが決まり、回るにつれて明るい部分が横へ流れ、厚み0のコインは紙のように薄く見えます。コインの下の帯が40の部分の明るさをそのまま示します"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">コイントスのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 누르면 던진다. 키보드 포커스도 여기서 받는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP = [250, 246, 252];
const BG_BOT = [228, 234, 250];
const TABLE_TOP = [240, 226, 238];
const TABLE_BOT = [222, 208, 236];
const GOLD = [250, 226, 150];
const GOLD_DEEP = [196, 150, 52];
const SKY = [176, 210, 246];
const SKY_DEEP = [74, 120, 176];
const RIM_LO = [184, 156, 100];
const RIM_HI = [255, 248, 222];
const ROSE = [240, 150, 176];
const ROSE_DEEP = [190, 52, 86];
const MINT = [150, 214, 190];
const MINT_DEEP = [22, 112, 82];
const LAV = [188, 172, 236];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function tossHalf(k, t) { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k) { return k.h0 + k.hr * k.T; }
function tossXY(k, t) {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(半回転の数 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k, t) {
const q = t - (k.t0 + k.T);
if (q <= 0) {
return tossHalf(k, t);
}
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k, t) {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h) { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 偶数 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face, rng, minPairs) {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 表(0)과 裏(1) — 값이 p 보다 작으면 表
function outcomes(seed, n, p) {
const rng = seeded(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(rng() < p ? 0 : 1);
}
return out;
}
// 가장 긴 연속 길이
function longestRun(seq) {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) {
best = cur;
}
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x, y, rx, ry, c, a) {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) {
return;
}
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face, R, a) {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) {
ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
else {
ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
}
else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) {
disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a);
}
}
}
function coinShadow(cx, gy, R, lift, a) {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx, cy, R, th, thick, lx, ly, lz, a, startFace) {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) {
continue;
}
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx, cy, R, thick, a) {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest() {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart() {
lap += 1;
doneSaid = false;
if (still) {
showRest();
return;
}
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function () {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 02 옆면 두께와 빛 방향으로 칠하는 금속 가장자리 ─────────────────────────────
// 옆면 둘레를 RIM_N 조각으로 나눠 조각마다 (법선 · 빛 방향) 으로 밝기를 정한다
const OMEGA2 = 2.3;
const THICK_RIM = 16;
const COIN_R = 56;
const LIGHT = [-0.5, -0.6, 0.62];
const LAP_S = 9.6;
const START_S = 0.0;
const REST_S = 0.0;
const RESULT_S = 8.8;
let angle0 = 0.6;
function build() { angle0 = 0.6 + lap * 0.9; }
function update(dt) { if (dt < 0) {
return;
} }
function theta() { return still ? 1.15 : angle0 + OMEGA2 * sceneT; }
function lightLen() { return Math.hypot(LIGHT[0], LIGHT[1], LIGHT[2]); }
// 조각 하나의 밝기 — 조각 법선과 빛 방향의 내적을 0 에서 1 로
function dotLight(pm, th) {
const n = lightLen();
const d = (Math.cos(pm) * LIGHT[0] + Math.sin(pm) * Math.cos(th) * LIGHT[1] + Math.sin(pm) * Math.sin(th) * LIGHT[2]) / n;
return clamp(0.5 + 0.5 * d, 0, 1);
}
function draw() {
skyFill(BG_TOP, BG_BOT);
tableBack(214);
const th = theta();
const n = lightLen();
const lx = LIGHT[0] / n;
const ly = LIGHT[1] / n;
const lz = LIGHT[2] / n;
const xs = [132, 348];
const thick = [THICK_RIM, 0];
for (let k = 0; k < 2; k += 1) {
const cx = xs[k];
coinShadow(cx, 226, COIN_R, 0, 1);
// 옆면 반짝임이 탁자에 번진 자국 — 보이는 조각의 가장 밝은 값에 비례
let best = 0;
for (let j = 0; j < RIM_N; j += 1) {
const pm = (j + 0.5) * TAU / RIM_N;
if (Math.sin(pm) * Math.sin(th) > 0) {
best = Math.max(best, dotLight(pm, th));
}
}
if (thick[k] > 0) {
oval(cx, 228, COIN_R * 1.3 * best, 10 * best, [255, 248, 222], 0.55 * best);
}
drawCoin(cx, 146, COIN_R, th, thick[k], lx, ly, lz, 1, 0);
// 조각 밝기 띠 — 40 조각의 내적 값을 그대로 칠한 눈금
for (let j = 0; j < RIM_N; j += 1) {
const pm = (j + 0.5) * TAU / RIM_N;
const v = dotLight(pm, th);
const col = mix(RIM_LO, RIM_HI, v);
box(cx - 80 + j * 4, 246, 3.4, 16, 1, col, 1);
}
}
tag('厚み {t}'.replace('{t}', String(THICK_RIM)), xs[0], 70, MINT, INK, 11, 'center');
tag('厚み 0', xs[1], 70, ROSE, INK, 11, 'center');
}
function statusText() {
const th = theta();
let best = 0;
for (let j = 0; j < RIM_N; j += 1) {
const pm = (j + 0.5) * TAU / RIM_N;
if (Math.sin(pm) * Math.sin(th) > 0) {
best = Math.max(best, dotLight(pm, th));
}
}
return '側面でいちばん明るい部分 {v}・厚み {t} 対 0'.replace('{v}', best.toFixed(2)).replace('{t}', String(THICK_RIM));
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// RimThicknessShadeRing - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./RimThicknessShadeRing.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function RimThicknessShadeRing() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP: RGB = [250, 246, 252];
const BG_BOT: RGB = [228, 234, 250];
const TABLE_TOP: RGB = [240, 226, 238];
const TABLE_BOT: RGB = [222, 208, 236];
const GOLD: RGB = [250, 226, 150];
const GOLD_DEEP: RGB = [196, 150, 52];
const SKY: RGB = [176, 210, 246];
const SKY_DEEP: RGB = [74, 120, 176];
const RIM_LO: RGB = [184, 156, 100];
const RIM_HI: RGB = [255, 248, 222];
const ROSE: RGB = [240, 150, 176];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [150, 214, 190];
const MINT_DEEP: RGB = [22, 112, 82];
const LAV: RGB = [188, 172, 236];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function mix(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)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 던지기 물리 ───────────────────────────────────────────
// 한 번의 던지기: 출발 (x0, y0) → 도착 (x1, y1), 체공 T 초, 꼭대기 peak 픽셀, 초당 반 바퀴 수 hr, 시작 반 바퀴 수 h0.
// 위치는 포물선(중력만), 각도는 반 바퀴 수 × π — 체공 중 회전 속도는 일정하다
type Toss = { t0: number; T: number; x0: number; y0: number; x1: number; y1: number; peak: number; hr: number; h0: number };
function tossHalf(k: Toss, t: number): number { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k: Toss): number { return k.h0 + k.hr * k.T; }
function tossXY(k: Toss, t: number): [number, number] {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(반 바퀴 수 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
if (q <= 0) { return tossHalf(k, t); }
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h: number): number { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 짝수 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face: number, rng: () => number, minPairs: number): number {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 앞면(0)과 뒷면(1) — 값이 p 보다 작으면 앞면
function outcomes(seed: number, n: number, p: number): number[] {
const rng = seeded(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(rng() < p ? 0 : 1); }
return out;
}
// 가장 긴 연속 길이
function longestRun(seq: number[]): number {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) { best = cur; }
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x: number, y: number, rx: number, ry: number, c: RGB, a: number): void {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) { return; }
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face: number, R: number, a: number): void {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) { ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr); } else { ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr); }
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
} else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) { disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a); }
}
}
function coinShadow(cx: number, gy: number, R: number, lift: number, a: number): void {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx: number, cy: number, R: number, th: number, thick: number, lx: number, ly: number, lz: number, a: number, startFace: number): void {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) { continue; }
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx: number, cy: number, R: number, thick: number, a: number): void {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest(): void {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart(): void {
lap += 1;
doneSaid = false;
if (still) { showRest(); return; }
begin();
showNow();
if (!paused) { startLoop(); }
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function (): void {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 02 옆면 두께와 빛 방향으로 칠하는 금속 가장자리 ─────────────────────────────
// 옆면 둘레를 RIM_N 조각으로 나눠 조각마다 (법선 · 빛 방향) 으로 밝기를 정한다
const OMEGA2 = 2.3;
const THICK_RIM = 16;
const COIN_R = 56;
const LIGHT: number[] = [-0.5, -0.6, 0.62];
const LAP_S = 9.6;
const START_S = 0.0;
const REST_S = 0.0;
const RESULT_S = 8.8;
let angle0 = 0.6;
function build(): void { angle0 = 0.6 + lap * 0.9; }
function update(dt: number): void { if (dt < 0) { return; } }
function theta(): number { return still ? 1.15 : angle0 + OMEGA2 * sceneT; }
function lightLen(): number { return Math.hypot(LIGHT[0], LIGHT[1], LIGHT[2]); }
// 조각 하나의 밝기 — 조각 법선과 빛 방향의 내적을 0 에서 1 로
function dotLight(pm: number, th: number): number {
const n = lightLen();
const d = (Math.cos(pm) * LIGHT[0] + Math.sin(pm) * Math.cos(th) * LIGHT[1] + Math.sin(pm) * Math.sin(th) * LIGHT[2]) / n;
return clamp(0.5 + 0.5 * d, 0, 1);
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
tableBack(214);
const th = theta();
const n = lightLen();
const lx = LIGHT[0] / n;
const ly = LIGHT[1] / n;
const lz = LIGHT[2] / n;
const xs = [132, 348];
const thick = [THICK_RIM, 0];
for (let k = 0; k < 2; k += 1) {
const cx = xs[k];
coinShadow(cx, 226, COIN_R, 0, 1);
// 옆면 반짝임이 탁자에 번진 자국 — 보이는 조각의 가장 밝은 값에 비례
let best = 0;
for (let j = 0; j < RIM_N; j += 1) {
const pm = (j + 0.5) * TAU / RIM_N;
if (Math.sin(pm) * Math.sin(th) > 0) { best = Math.max(best, dotLight(pm, th)); }
}
if (thick[k] > 0) { oval(cx, 228, COIN_R * 1.3 * best, 10 * best, [255, 248, 222], 0.55 * best); }
drawCoin(cx, 146, COIN_R, th, thick[k], lx, ly, lz, 1, 0);
// 조각 밝기 띠 — 40 조각의 내적 값을 그대로 칠한 눈금
for (let j = 0; j < RIM_N; j += 1) {
const pm = (j + 0.5) * TAU / RIM_N;
const v = dotLight(pm, th);
const col = mix(RIM_LO, RIM_HI, v);
box(cx - 80 + j * 4, 246, 3.4, 16, 1, col, 1);
}
}
tag('두께 {t}'.replace('{t}', String(THICK_RIM)), xs[0], 70, MINT, INK, 11, 'center');
tag('두께 0', xs[1], 70, ROSE, INK, 11, 'center');
}
function statusText(): string {
const th = theta();
let best = 0;
for (let j = 0; j < RIM_N; j += 1) {
const pm = (j + 0.5) * TAU / RIM_N;
if (Math.sin(pm) * Math.sin(th) > 0) { best = Math.max(best, dotLight(pm, th)); }
}
return '옆면 가장 밝은 조각 {v} · 두께 {t} 대 0'.replace('{v}', best.toFixed(2)).replace('{t}', String(THICK_RIM));
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'동전 던지기 시연 무대 — Enter 나 Space 나 클릭으로 던지기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'옆면 두께 16 인 동전과 두께 0 인 동전이 같은 각도로 돈다. 옆면은 40 조각으로 나뉘어 조각 법선과 빛 방향의 내적으로 밝기가 정해지고, 돌수록 밝은 조각이 옆으로 흘러 지나가며 두께 0 인 동전은 종이처럼 얇게 보인다. 동전 아래 띠가 40 조각의 밝기를 그대로 보여 준다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'동전 던지기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}02 · 側面の厚みと光の向きで塗る金属のふち — 計測 累積面積 13.775% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function lightLen() { return Math.hypot(LIGHT[0], LIGHT[1], LIGHT[2]); }
// 部分一つの明るさ — 部分の法線と光の向きの内積を0から1へ
function dotLight(pm, th) {
const n = lightLen();
const d = (Math.cos(pm) * LIGHT[0] + Math.sin(pm) * Math.cos(th) * LIGHT[1] + Math.sin(pm) * Math.sin(th) * LIGHT[2]) / n;
return clamp(0.5 + 0.5 * d, 0, 1);
}
03滞空時間に回転の速さを掛けて半回転の数で面を読む放物線の投げ
手を離れたコインは重力だけが働く放物線を描き、AIR_T 0.9秒に毎秒の半回転の数 hr(= 2 * spinOf(k))を掛けた値が halfTurns です。着地の瞬間に Math.round で四捨五入して偶数なら投げる前の面、奇数なら反対の面が上になります。最初の投げは半回転12.6が13で奇数なので裏、二回目は13.68が14で偶数なので表、三回目は14.76が15で奇数なので裏です。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="コイントスの舞台 — Enter・Space・クリックで投げる、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="手から投げられたコインは放物線を描いて毎秒7回転で回りながら飛び、着地すると半回転の数を四捨五入して、偶数なら表、奇数なら裏と読みます。投げるたびに毎秒の回転数が0.6ずつ上がり、三回投げるあいだに結果の面は裏、表、裏と変わります"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">コイントスのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 누르면 던진다. 키보드 포커스도 여기서 받는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP = [250, 246, 252];
const BG_BOT = [228, 234, 250];
const TABLE_TOP = [240, 226, 238];
const TABLE_BOT = [222, 208, 236];
const GOLD = [250, 226, 150];
const GOLD_DEEP = [196, 150, 52];
const SKY = [176, 210, 246];
const SKY_DEEP = [74, 120, 176];
const RIM_LO = [184, 156, 100];
const RIM_HI = [255, 248, 222];
const ROSE = [240, 150, 176];
const ROSE_DEEP = [190, 52, 86];
const MINT = [150, 214, 190];
const MINT_DEEP = [22, 112, 82];
const LAV = [188, 172, 236];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function tossHalf(k, t) { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k) { return k.h0 + k.hr * k.T; }
function tossXY(k, t) {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(半回転の数 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k, t) {
const q = t - (k.t0 + k.T);
if (q <= 0) {
return tossHalf(k, t);
}
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k, t) {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h) { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 偶数 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face, rng, minPairs) {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 表(0)과 裏(1) — 값이 p 보다 작으면 表
function outcomes(seed, n, p) {
const rng = seeded(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(rng() < p ? 0 : 1);
}
return out;
}
// 가장 긴 연속 길이
function longestRun(seq) {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) {
best = cur;
}
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x, y, rx, ry, c, a) {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) {
return;
}
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face, R, a) {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) {
ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
else {
ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
}
else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) {
disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a);
}
}
}
function coinShadow(cx, gy, R, lift, a) {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx, cy, R, th, thick, lx, ly, lz, a, startFace) {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) {
continue;
}
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx, cy, R, thick, a) {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest() {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart() {
lap += 1;
doneSaid = false;
if (still) {
showRest();
return;
}
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function () {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 03 체공 시간에 회전 속도를 곱해 반 바퀴 수로 면을 읽는 포물선 던지기 ─────────────
// 半回転の数 = 체공 시간(AIR_T) × 초당 회전수(SPIN_RATE) × 2. 반올림한 수가 짝수면 던지기 전 면, 홀수면 반대 면
const AIR_T = 0.9;
const SPIN_RATE = 7;
// 던질 때마다 초당 회전수를 이만큼 올린다
const SPIN_STEP = 0.6;
const PEAK = 150;
const TOSS_GAP = 3.4;
const GROUND_Y = 226;
const LAP_S = 10.4;
const START_S = 0.0;
const REST_S = 1.9;
const RESULT_S = 1.8;
let tosses = [];
function spinOf(k) { return SPIN_RATE + SPIN_STEP * k; }
function build() {
tosses = [];
for (let k = 0; k < 3; k += 1) {
const t0 = 0.2 + TOSS_GAP * k;
tosses.push({ t0: t0, T: AIR_T, x0: 64, y0: 206, x1: 300 + 34 * k, y1: GROUND_Y, peak: PEAK, hr: 2 * spinOf(k), h0: 0 });
}
}
function update(dt) { if (dt < 0) {
return;
} }
// 지금 보이는 던지기 — 시각이 이미 지난 마지막 것
function curK() {
let k = 0;
for (let i = 0; i < tosses.length; i += 1) {
if (sceneT >= tosses[i].t0) {
k = i;
}
}
return k;
}
function faceName(n) { return n === 0 ? '表' : '裏'; }
function halfTurns(k) { return tosses[k].hr * AIR_T; }
function drawToss(k, t) {
const p = tossXY(k, t);
const h = halfAt(k, t);
const lift = hopLift(k, t);
const th = PI * h;
const groundLift = GROUND_Y - p[1] + lift;
// 지나온 자리 — 포물선 위 점선
const tt = clamp(t - k.t0, 0, k.T);
for (let i = 0; i < 18; i += 1) {
const tq = k.t0 + (k.T * i) / 18;
if (tq > k.t0 + tt) {
break;
}
const q = tossXY(k, tq);
disc(q[0], q[1], 2.2, LAV, 0.8);
}
if (t < k.t0) {
return;
}
coinShadow(p[0], GROUND_Y + 12, 30, groundLift, 1);
// 빠르게 도는 동안의 잔상 — 지난 몇 걸음의 각도에 흐린 동전
if (t - k.t0 < k.T) {
for (let g = 3; g >= 1; g -= 1) {
const tg = t - g * 0.012;
drawCoin(tossXY(k, tg)[0], tossXY(k, tg)[1], 30, PI * halfAt(k, tg), 6, 0.36, -0.46, 0.81, 0.22, 0);
}
}
drawCoin(p[0], p[1] - lift, 30, th, 6, 0.36, -0.46, 0.81, 1, 0);
}
function draw() {
skyFill(BG_TOP, BG_BOT);
tableBack(GROUND_Y + 12);
// 손 자리
disc(64, 222, 16, LAV, 0.85);
const k = curK();
for (let i = 0; i < k; i += 1) {
// 앞선 던지기의 착지 자리는 흐리게 남긴다
const e = tosses[i];
drawCoin(e.x1, e.y1, 30, PI * Math.round(landHalf(e)), 6, 0.36, -0.46, 0.81, 0.35, 0);
}
drawToss(tosses[k], sceneT);
// 半回転の数 판독 — 던지는 동안 숫자가 오르고, 착지하면 반올림해 면을 말한다
const e = tosses[k];
const live = tossHalf(e, sceneT);
const landed = sceneT >= e.t0 + e.T;
box(286, 66, 184, 74, 12, PAPER, 0.9);
text('半回転の数', 296, 82, INK, 1, 11, 700, 'left');
text(live.toFixed(1), 462, 84, INK, 1, 22, 800, 'right');
if (landed) {
const r = Math.round(landHalf(e));
const verdict = '半回転 {r}・{p} → {f}'.replace('{r}', String(r)).replace('{p}', r % 2 === 0 ? '偶数' : '奇数').replace('{f}', faceName(upFace(r)));
tag(verdict, 462, 124, upFace(r) === 0 ? GOLD : SKY, INK, 12, 'right');
}
else {
text('毎秒の回転数 {s}'.replace('{s}', spinOf(k).toFixed(1)), 296, 124, INK, 1, 11, 700, 'left');
}
}
function statusText() {
const k = curK();
const e = tosses[k];
if (sceneT >= e.t0 + e.T) {
const r = Math.round(landHalf(e));
return '投げ {n}・半回転 {h} を四捨五入して {r}・{p} → {f}'.replace('{n}', String(k + 1)).replace('{h}', halfTurns(k).toFixed(1)).replace('{r}', String(r)).replace('{p}', r % 2 === 0 ? '偶数' : '奇数').replace('{f}', faceName(upFace(r)));
}
return '投げ {n}・回転中 半回転 {h}'.replace('{n}', String(k + 1)).replace('{h}', tossHalf(e, sceneT).toFixed(1));
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// TossArcSpinParity - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./TossArcSpinParity.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TossArcSpinParity() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP: RGB = [250, 246, 252];
const BG_BOT: RGB = [228, 234, 250];
const TABLE_TOP: RGB = [240, 226, 238];
const TABLE_BOT: RGB = [222, 208, 236];
const GOLD: RGB = [250, 226, 150];
const GOLD_DEEP: RGB = [196, 150, 52];
const SKY: RGB = [176, 210, 246];
const SKY_DEEP: RGB = [74, 120, 176];
const RIM_LO: RGB = [184, 156, 100];
const RIM_HI: RGB = [255, 248, 222];
const ROSE: RGB = [240, 150, 176];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [150, 214, 190];
const MINT_DEEP: RGB = [22, 112, 82];
const LAV: RGB = [188, 172, 236];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function mix(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)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 던지기 물리 ───────────────────────────────────────────
// 한 번의 던지기: 출발 (x0, y0) → 도착 (x1, y1), 체공 T 초, 꼭대기 peak 픽셀, 초당 반 바퀴 수 hr, 시작 반 바퀴 수 h0.
// 위치는 포물선(중력만), 각도는 반 바퀴 수 × π — 체공 중 회전 속도는 일정하다
type Toss = { t0: number; T: number; x0: number; y0: number; x1: number; y1: number; peak: number; hr: number; h0: number };
function tossHalf(k: Toss, t: number): number { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k: Toss): number { return k.h0 + k.hr * k.T; }
function tossXY(k: Toss, t: number): [number, number] {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(반 바퀴 수 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
if (q <= 0) { return tossHalf(k, t); }
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h: number): number { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 짝수 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face: number, rng: () => number, minPairs: number): number {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 앞면(0)과 뒷면(1) — 값이 p 보다 작으면 앞면
function outcomes(seed: number, n: number, p: number): number[] {
const rng = seeded(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(rng() < p ? 0 : 1); }
return out;
}
// 가장 긴 연속 길이
function longestRun(seq: number[]): number {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) { best = cur; }
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x: number, y: number, rx: number, ry: number, c: RGB, a: number): void {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) { return; }
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face: number, R: number, a: number): void {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) { ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr); } else { ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr); }
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
} else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) { disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a); }
}
}
function coinShadow(cx: number, gy: number, R: number, lift: number, a: number): void {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx: number, cy: number, R: number, th: number, thick: number, lx: number, ly: number, lz: number, a: number, startFace: number): void {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) { continue; }
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx: number, cy: number, R: number, thick: number, a: number): void {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest(): void {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart(): void {
lap += 1;
doneSaid = false;
if (still) { showRest(); return; }
begin();
showNow();
if (!paused) { startLoop(); }
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function (): void {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 03 체공 시간에 회전 속도를 곱해 반 바퀴 수로 면을 읽는 포물선 던지기 ─────────────
// 반 바퀴 수 = 체공 시간(AIR_T) × 초당 회전수(SPIN_RATE) × 2. 반올림한 수가 짝수면 던지기 전 면, 홀수면 반대 면
const AIR_T = 0.9;
const SPIN_RATE = 7;
// 던질 때마다 초당 회전수를 이만큼 올린다
const SPIN_STEP = 0.6;
const PEAK = 150;
const TOSS_GAP = 3.4;
const GROUND_Y = 226;
const LAP_S = 10.4;
const START_S = 0.0;
const REST_S = 1.9;
const RESULT_S = 1.8;
let tosses: Toss[] = [];
function spinOf(k: number): number { return SPIN_RATE + SPIN_STEP * k; }
function build(): void {
tosses = [];
for (let k = 0; k < 3; k += 1) {
const t0 = 0.2 + TOSS_GAP * k;
tosses.push({ t0: t0, T: AIR_T, x0: 64, y0: 206, x1: 300 + 34 * k, y1: GROUND_Y, peak: PEAK, hr: 2 * spinOf(k), h0: 0 });
}
}
function update(dt: number): void { if (dt < 0) { return; } }
// 지금 보이는 던지기 — 시각이 이미 지난 마지막 것
function curK(): number {
let k = 0;
for (let i = 0; i < tosses.length; i += 1) { if (sceneT >= tosses[i].t0) { k = i; } }
return k;
}
function faceName(n: number): string { return n === 0 ? '앞면' : '뒷면'; }
function halfTurns(k: number): number { return tosses[k].hr * AIR_T; }
function drawToss(k: Toss, t: number): void {
const p = tossXY(k, t);
const h = halfAt(k, t);
const lift = hopLift(k, t);
const th = PI * h;
const groundLift = GROUND_Y - p[1] + lift;
// 지나온 자리 — 포물선 위 점선
const tt = clamp(t - k.t0, 0, k.T);
for (let i = 0; i < 18; i += 1) {
const tq = k.t0 + (k.T * i) / 18;
if (tq > k.t0 + tt) { break; }
const q = tossXY(k, tq);
disc(q[0], q[1], 2.2, LAV, 0.8);
}
if (t < k.t0) { return; }
coinShadow(p[0], GROUND_Y + 12, 30, groundLift, 1);
// 빠르게 도는 동안의 잔상 — 지난 몇 걸음의 각도에 흐린 동전
if (t - k.t0 < k.T) {
for (let g = 3; g >= 1; g -= 1) {
const tg = t - g * 0.012;
drawCoin(tossXY(k, tg)[0], tossXY(k, tg)[1], 30, PI * halfAt(k, tg), 6, 0.36, -0.46, 0.81, 0.22, 0);
}
}
drawCoin(p[0], p[1] - lift, 30, th, 6, 0.36, -0.46, 0.81, 1, 0);
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
tableBack(GROUND_Y + 12);
// 손 자리
disc(64, 222, 16, LAV, 0.85);
const k = curK();
for (let i = 0; i < k; i += 1) {
// 앞선 던지기의 착지 자리는 흐리게 남긴다
const e = tosses[i];
drawCoin(e.x1, e.y1, 30, PI * Math.round(landHalf(e)), 6, 0.36, -0.46, 0.81, 0.35, 0);
}
drawToss(tosses[k], sceneT);
// 반 바퀴 수 판독 — 던지는 동안 숫자가 오르고, 착지하면 반올림해 면을 말한다
const e = tosses[k];
const live = tossHalf(e, sceneT);
const landed = sceneT >= e.t0 + e.T;
box(286, 66, 184, 74, 12, PAPER, 0.9);
text('반 바퀴 수', 296, 82, INK, 1, 11, 700, 'left');
text(live.toFixed(1), 462, 84, INK, 1, 22, 800, 'right');
if (landed) {
const r = Math.round(landHalf(e));
const verdict = '반 바퀴 {r} · {p} → {f}'.replace('{r}', String(r)).replace('{p}', r % 2 === 0 ? '짝수' : '홀수').replace('{f}', faceName(upFace(r)));
tag(verdict, 462, 124, upFace(r) === 0 ? GOLD : SKY, INK, 12, 'right');
} else {
text('초당 회전수 {s}'.replace('{s}', spinOf(k).toFixed(1)), 296, 124, INK, 1, 11, 700, 'left');
}
}
function statusText(): string {
const k = curK();
const e = tosses[k];
if (sceneT >= e.t0 + e.T) {
const r = Math.round(landHalf(e));
return '던지기 {n} · 반 바퀴 {h} 반올림 {r} · {p} → {f}'.replace('{n}', String(k + 1)).replace('{h}', halfTurns(k).toFixed(1)).replace('{r}', String(r)).replace('{p}', r % 2 === 0 ? '짝수' : '홀수').replace('{f}', faceName(upFace(r)));
}
return '던지기 {n} · 도는 중 반 바퀴 {h}'.replace('{n}', String(k + 1)).replace('{h}', tossHalf(e, sceneT).toFixed(1));
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'동전 던지기 시연 무대 — Enter 나 Space 나 클릭으로 던지기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'손에서 던져진 동전이 포물선을 그리며 날면서 초당 회전수 7 로 돌고, 착지하면 반 바퀴 수를 반올림해 짝수면 앞면 홀수면 뒷면으로 읽는다. 던질 때마다 초당 회전수가 0.6 씩 올라 세 번 던지는 동안 결과 면이 뒷면 앞면 뒷면으로 바뀐다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'동전 던지기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}03 · 滞空時間に回転の速さを掛けて半回転の数で面を読む放物線の投げ — 計測 累積面積 17.137% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 半回転の数の読み取り — 投げるあいだ数字が上がり、着地すると四捨五入して面を告げる
const e = tosses[k];
const live = tossHalf(e, sceneT);
const landed = sceneT >= e.t0 + e.T;
box(286, 66, 184, 74, 12, PAPER, 0.9);
text('半回転の数', 296, 82, INK, 1, 11, 700, 'left');
text(live.toFixed(1), 462, 84, INK, 1, 22, 800, 'right');
if (landed) {
const r = Math.round(landHalf(e));
const verdict = '半回転 {r}・{p} → {f}'.replace('{r}', String(r)).replace('{p}', r % 2 === 0 ? '偶数' : '奇数').replace('{f}', faceName(upFace(r)));
tag(verdict, 462, 124, upFace(r) === 0 ? GOLD : SKY, INK, 12, 'right');
}
else {
text('毎秒の回転数 {s}'.replace('{s}', spinOf(k).toFixed(1)), 296, 124, INK, 1, 11, 700, 'left');
}
04厚みがつくる狭いすきまでふちを下に立つ着地
コインが立つには、着地の角が EDGE_WIN(thick) で決まる狭い窓に入る必要があり、窓は厚みと直径の比のatanです。N_THROWS 100回を同じシードの数列で投げると、厚み8のコインは8回、厚み16なら14回立ち、厚み0は一度も立ちません。厚み8のとき窓の全幅は半円の7.9%なので、立つのはまれです。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="コイントスの舞台 — Enter・Space・クリックで投げる、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="同じ決まった数列で投げた100回の着地の傾きを二つの升目に積みます。左は厚み8のコインなので、立つ角の近くの狭い窓に入った投げだけがピンクでふちに立ちます。右は厚み0で窓がなく、立つコインはありません。中央の半円ゲージの針が、いま投げた傾きを指します"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">コイントスのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 누르면 던진다. 키보드 포커스도 여기서 받는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP = [250, 246, 252];
const BG_BOT = [228, 234, 250];
const TABLE_TOP = [240, 226, 238];
const TABLE_BOT = [222, 208, 236];
const GOLD = [250, 226, 150];
const GOLD_DEEP = [196, 150, 52];
const SKY = [176, 210, 246];
const SKY_DEEP = [74, 120, 176];
const RIM_LO = [184, 156, 100];
const RIM_HI = [255, 248, 222];
const ROSE = [240, 150, 176];
const ROSE_DEEP = [190, 52, 86];
const MINT = [150, 214, 190];
const MINT_DEEP = [22, 112, 82];
const LAV = [188, 172, 236];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function tossHalf(k, t) { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k) { return k.h0 + k.hr * k.T; }
function tossXY(k, t) {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(半回転の数 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k, t) {
const q = t - (k.t0 + k.T);
if (q <= 0) {
return tossHalf(k, t);
}
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k, t) {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h) { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 偶数 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face, rng, minPairs) {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 表(0)과 裏(1) — 값이 p 보다 작으면 表
function outcomes(seed, n, p) {
const rng = seeded(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(rng() < p ? 0 : 1);
}
return out;
}
// 가장 긴 연속 길이
function longestRun(seq) {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) {
best = cur;
}
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x, y, rx, ry, c, a) {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) {
return;
}
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face, R, a) {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) {
ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
else {
ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
}
else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) {
disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a);
}
}
}
function coinShadow(cx, gy, R, lift, a) {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx, cy, R, th, thick, lx, ly, lz, a, startFace) {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) {
continue;
}
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx, cy, R, thick, a) {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest() {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart() {
lap += 1;
doneSaid = false;
if (still) {
showRest();
return;
}
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function () {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 04 두께가 만드는 아주 좁은 틈에서 가장자리로 서는 착지 ─────────────────────────
// 착지 기울기 ang 가 세워진 각(π/2) 에서 EDGE_WIN 안이면 서고, 아니면 앞뒤로 쓰러진다. EDGE_WIN = atan(두께 / 지름)
const N_THROWS = 100;
const SEED = 4960;
const DIAM_E = 64;
const THICK_E = 8;
const THROW_GAP = 0.04;
const LAP_S = 8.4;
const START_S = 1.5;
const REST_S = 6.0;
const RESULT_S = 5.8;
// SIM-BEGIN
function EDGE_WIN(thick) { return Math.atan(thick / DIAM_E); }
// 던질 때마다의 착지 기울기(라디안, 0 에서 π) — 같은 약속 수열
function tiltAtLand(seed, n) {
const rng = seeded(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(rng() * PI);
}
return out;
}
// 0 表 · 1 裏 · 2 가장자리
function landKind(ang, thick) {
if (thick > 0 && Math.abs(ang - PI / 2) < EDGE_WIN(thick)) {
return 2;
}
return ang < PI / 2 ? 0 : 1;
}
function countEdge(seed, n, thick) {
const a = tiltAtLand(seed, n);
let c = 0;
for (let i = 0; i < n; i += 1) {
if (landKind(a[i], thick) === 2) {
c += 1;
}
}
return c;
}
// SIM-END
let tilts = [];
let shown = 0;
let needle = 0;
function build() {
tilts = tiltAtLand(SEED + lap, N_THROWS);
shown = 0;
needle = PI / 2;
}
function update(dt) {
if (dt < 0) {
return;
}
shown = clamp(Math.floor((sceneT - 0.3) / THROW_GAP) + 1, 0, N_THROWS);
}
function countKind(kind, thick) {
let c = 0;
for (let i = 0; i < shown; i += 1) {
if (landKind(tilts[i], thick) === kind) {
c += 1;
}
}
return c;
}
function grid(x0, thick, label) {
for (let i = 0; i < N_THROWS; i += 1) {
const gx = x0 + (i % 10) * 15;
const gy = 64 + Math.floor(i / 10) * 15;
if (i >= shown) {
box(gx, gy, 13, 13, 4, [255, 255, 255], 0.55);
continue;
}
const kind = landKind(tilts[i], thick);
const col = kind === 0 ? GOLD : (kind === 1 ? SKY : ROSE_DEEP);
const age = clamp((sceneT - (0.3 + i * THROW_GAP)) / 0.15, 0, 1);
const s = 13 * (0.6 + 0.4 * ease(age));
box(gx + (13 - s) / 2, gy + (13 - s) / 2, s, s, 4, col, 1);
}
tag(label, x0 + 75, 232, MINT, INK, 11, 'center');
tag('立ったコイン {n}'.replace('{n}', String(countKind(2, thick))), x0 + 75, 254, PAPER, INK, 11, 'center');
}
function draw() {
skyFill(BG_TOP, BG_BOT);
grid(8, THICK_E, '厚み {t}'.replace('{t}', String(THICK_E)));
grid(322, 0, '厚み 0');
// 가운데 — 지금 던진 착지 기울기 게이지. 장밋빛 부채꼴이 설 수 있는 창
const gx = 240;
const gy = 214;
const rr = 66;
const win = EDGE_WIN(THICK_E);
ctx.beginPath();
ctx.moveTo(gx, gy);
ctx.arc(gx, gy, rr, PI, TAU, false);
ctx.closePath();
ctx.fillStyle = rgba([255, 255, 255], 0.7);
ctx.fill();
ctx.beginPath();
ctx.moveTo(gx, gy);
ctx.arc(gx, gy, rr, -PI / 2 - win, -PI / 2 + win, false);
ctx.closePath();
ctx.fillStyle = rgba(ROSE, 0.85);
ctx.fill();
const cur = shown > 0 ? tilts[shown - 1] : PI / 2;
needle += (cur - needle) * 0.6;
const na = PI + needle;
seg(gx, gy, gx + Math.cos(na) * (rr - 4), gy + Math.sin(na) * (rr - 4), 3, INK, 1);
disc(gx, gy, 5, INK, 1);
// 동전 — 착지 기울기 그대로 돌아선 모습
const kind = shown > 0 ? landKind(cur, THICK_E) : 0;
coinShadow(gx, 150, 32, 0, 1);
if (kind === 2) {
drawEdge(gx, 110, 32, THICK_E, 1);
}
else {
drawCoin(gx, 110, 32, cur, THICK_E, 0.36, -0.46, 0.81, 1, 0);
}
tag(kind === 2 ? 'ふちで立つ' : (kind === 0 ? '表を上に倒れる' : '裏を上に倒れる'), gx, 166, kind === 2 ? ROSE : (kind === 0 ? GOLD : SKY), INK, 11, 'center');
}
function statusText() {
return '投げた{n}回・厚み{t}の立ったコイン {a}・厚み0の立ったコイン {b}'.replace('{n}', String(shown)).replace('{t}', String(THICK_E)).replace('{a}', String(countKind(2, THICK_E))).replace('{b}', String(countKind(2, 0)));
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// EdgeStandTiltWindow - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./EdgeStandTiltWindow.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function EdgeStandTiltWindow() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP: RGB = [250, 246, 252];
const BG_BOT: RGB = [228, 234, 250];
const TABLE_TOP: RGB = [240, 226, 238];
const TABLE_BOT: RGB = [222, 208, 236];
const GOLD: RGB = [250, 226, 150];
const GOLD_DEEP: RGB = [196, 150, 52];
const SKY: RGB = [176, 210, 246];
const SKY_DEEP: RGB = [74, 120, 176];
const RIM_LO: RGB = [184, 156, 100];
const RIM_HI: RGB = [255, 248, 222];
const ROSE: RGB = [240, 150, 176];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [150, 214, 190];
const MINT_DEEP: RGB = [22, 112, 82];
const LAV: RGB = [188, 172, 236];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function mix(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)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 던지기 물리 ───────────────────────────────────────────
// 한 번의 던지기: 출발 (x0, y0) → 도착 (x1, y1), 체공 T 초, 꼭대기 peak 픽셀, 초당 반 바퀴 수 hr, 시작 반 바퀴 수 h0.
// 위치는 포물선(중력만), 각도는 반 바퀴 수 × π — 체공 중 회전 속도는 일정하다
type Toss = { t0: number; T: number; x0: number; y0: number; x1: number; y1: number; peak: number; hr: number; h0: number };
function tossHalf(k: Toss, t: number): number { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k: Toss): number { return k.h0 + k.hr * k.T; }
function tossXY(k: Toss, t: number): [number, number] {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(반 바퀴 수 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
if (q <= 0) { return tossHalf(k, t); }
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h: number): number { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 짝수 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face: number, rng: () => number, minPairs: number): number {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 앞면(0)과 뒷면(1) — 값이 p 보다 작으면 앞면
function outcomes(seed: number, n: number, p: number): number[] {
const rng = seeded(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(rng() < p ? 0 : 1); }
return out;
}
// 가장 긴 연속 길이
function longestRun(seq: number[]): number {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) { best = cur; }
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x: number, y: number, rx: number, ry: number, c: RGB, a: number): void {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) { return; }
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face: number, R: number, a: number): void {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) { ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr); } else { ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr); }
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
} else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) { disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a); }
}
}
function coinShadow(cx: number, gy: number, R: number, lift: number, a: number): void {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx: number, cy: number, R: number, th: number, thick: number, lx: number, ly: number, lz: number, a: number, startFace: number): void {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) { continue; }
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx: number, cy: number, R: number, thick: number, a: number): void {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest(): void {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart(): void {
lap += 1;
doneSaid = false;
if (still) { showRest(); return; }
begin();
showNow();
if (!paused) { startLoop(); }
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function (): void {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 04 두께가 만드는 아주 좁은 틈에서 가장자리로 서는 착지 ─────────────────────────
// 착지 기울기 ang 가 세워진 각(π/2) 에서 EDGE_WIN 안이면 서고, 아니면 앞뒤로 쓰러진다. EDGE_WIN = atan(두께 / 지름)
const N_THROWS = 100;
const SEED = 4960;
const DIAM_E = 64;
const THICK_E = 8;
const THROW_GAP = 0.04;
const LAP_S = 8.4;
const START_S = 1.5;
const REST_S = 6.0;
const RESULT_S = 5.8;
// SIM-BEGIN
function EDGE_WIN(thick: number): number { return Math.atan(thick / DIAM_E); }
// 던질 때마다의 착지 기울기(라디안, 0 에서 π) — 같은 약속 수열
function tiltAtLand(seed: number, n: number): number[] {
const rng = seeded(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(rng() * PI); }
return out;
}
// 0 앞면 · 1 뒷면 · 2 가장자리
function landKind(ang: number, thick: number): number {
if (thick > 0 && Math.abs(ang - PI / 2) < EDGE_WIN(thick)) { return 2; }
return ang < PI / 2 ? 0 : 1;
}
function countEdge(seed: number, n: number, thick: number): number {
const a = tiltAtLand(seed, n);
let c = 0;
for (let i = 0; i < n; i += 1) { if (landKind(a[i], thick) === 2) { c += 1; } }
return c;
}
// SIM-END
let tilts: number[] = [];
let shown = 0;
let needle = 0;
function build(): void {
tilts = tiltAtLand(SEED + lap, N_THROWS);
shown = 0;
needle = PI / 2;
}
function update(dt: number): void {
if (dt < 0) { return; }
shown = clamp(Math.floor((sceneT - 0.3) / THROW_GAP) + 1, 0, N_THROWS);
}
function countKind(kind: number, thick: number): number {
let c = 0;
for (let i = 0; i < shown; i += 1) { if (landKind(tilts[i], thick) === kind) { c += 1; } }
return c;
}
function grid(x0: number, thick: number, label: string): void {
for (let i = 0; i < N_THROWS; i += 1) {
const gx = x0 + (i % 10) * 15;
const gy = 64 + Math.floor(i / 10) * 15;
if (i >= shown) { box(gx, gy, 13, 13, 4, [255, 255, 255], 0.55); continue; }
const kind = landKind(tilts[i], thick);
const col = kind === 0 ? GOLD : (kind === 1 ? SKY : ROSE_DEEP);
const age = clamp((sceneT - (0.3 + i * THROW_GAP)) / 0.15, 0, 1);
const s = 13 * (0.6 + 0.4 * ease(age));
box(gx + (13 - s) / 2, gy + (13 - s) / 2, s, s, 4, col, 1);
}
tag(label, x0 + 75, 232, MINT, INK, 11, 'center');
tag('서 있는 동전 {n}'.replace('{n}', String(countKind(2, thick))), x0 + 75, 254, PAPER, INK, 11, 'center');
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
grid(8, THICK_E, '두께 {t}'.replace('{t}', String(THICK_E)));
grid(322, 0, '두께 0');
// 가운데 — 지금 던진 착지 기울기 게이지. 장밋빛 부채꼴이 설 수 있는 창
const gx = 240;
const gy = 214;
const rr = 66;
const win = EDGE_WIN(THICK_E);
ctx.beginPath();
ctx.moveTo(gx, gy);
ctx.arc(gx, gy, rr, PI, TAU, false);
ctx.closePath();
ctx.fillStyle = rgba([255, 255, 255], 0.7);
ctx.fill();
ctx.beginPath();
ctx.moveTo(gx, gy);
ctx.arc(gx, gy, rr, -PI / 2 - win, -PI / 2 + win, false);
ctx.closePath();
ctx.fillStyle = rgba(ROSE, 0.85);
ctx.fill();
const cur = shown > 0 ? tilts[shown - 1] : PI / 2;
needle += (cur - needle) * 0.6;
const na = PI + needle;
seg(gx, gy, gx + Math.cos(na) * (rr - 4), gy + Math.sin(na) * (rr - 4), 3, INK, 1);
disc(gx, gy, 5, INK, 1);
// 동전 — 착지 기울기 그대로 돌아선 모습
const kind = shown > 0 ? landKind(cur, THICK_E) : 0;
coinShadow(gx, 150, 32, 0, 1);
if (kind === 2) { drawEdge(gx, 110, 32, THICK_E, 1); } else { drawCoin(gx, 110, 32, cur, THICK_E, 0.36, -0.46, 0.81, 1, 0); }
tag(kind === 2 ? '가장자리로 선다' : (kind === 0 ? '앞면으로 눕는다' : '뒷면으로 눕는다'), gx, 166, kind === 2 ? ROSE : (kind === 0 ? GOLD : SKY), INK, 11, 'center');
}
function statusText(): string {
return '던진 {n}번 · 두께 {t} 서 있는 동전 {a} · 두께 0 서 있는 동전 {b}'.replace('{n}', String(shown)).replace('{t}', String(THICK_E)).replace('{a}', String(countKind(2, THICK_E))).replace('{b}', String(countKind(2, 0)));
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'동전 던지기 시연 무대 — Enter 나 Space 나 클릭으로 던지기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'던지기 100번의 착지 기울기를 같은 수열로 두 칸 모눈에 쌓는다. 왼쪽은 두께 8 동전이라 세워진 각 근처 좁은 창에 든 던지기만 분홍으로 가장자리에 서고, 오른쪽은 두께 0 이라 창이 없어 서는 동전이 없다. 가운데 반원 게이지 바늘이 방금 던진 기울기를 가리킨다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'동전 던지기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}04 · 厚みがつくる狭いすきまでふちを下に立つ着地 — 計測 累積面積 18.149% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function EDGE_WIN(thick) { return Math.atan(thick / DIAM_E); }
// 投げるたびの着地の傾き(ラジアン、0からπ) — 同じ決まった数列
function tiltAtLand(seed, n) {
const rng = seeded(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(rng() * PI);
}
return out;
}
// 0 表 · 1 裏 · 2 ふち
function landKind(ang, thick) {
if (thick > 0 && Math.abs(ang - PI / 2) < EDGE_WIN(thick)) {
return 2;
}
return ang < PI / 2 ? 0 : 1;
}
05投げる回数が増えるほど表の割合が半分へ寄って狭まる帯
決まった数列で投げた結果を一回ずつ積み、ratioLine で表の割合を折れ線につなぎ、その周りに bandHalf(n) = 1/(2√n) の幅の帯を重ねます。割合は10回目が0.600、25回目が0.560、50回目が0.520、100回目が0.560で、帯の外へ出た点は outsideCount で数えると100回目までに7個です。横軸がln nなので、最初の数回が横に大きく広がります。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="コイントスの舞台 — Enter・Space・クリックで投げる、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="投げた回数100回までの表の割合を折れ線でつなぎます。横軸は投げた回数の対数で、周りのミント色の帯は半幅1を2√nで割った値で狭まり、帯の外へ出た点はピンクで示されます。上の小さなコインは投げるたびに裏返ります"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">コイントスのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 누르면 던진다. 키보드 포커스도 여기서 받는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP = [250, 246, 252];
const BG_BOT = [228, 234, 250];
const TABLE_TOP = [240, 226, 238];
const TABLE_BOT = [222, 208, 236];
const GOLD = [250, 226, 150];
const GOLD_DEEP = [196, 150, 52];
const SKY = [176, 210, 246];
const SKY_DEEP = [74, 120, 176];
const RIM_LO = [184, 156, 100];
const RIM_HI = [255, 248, 222];
const ROSE = [240, 150, 176];
const ROSE_DEEP = [190, 52, 86];
const MINT = [150, 214, 190];
const MINT_DEEP = [22, 112, 82];
const LAV = [188, 172, 236];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function tossHalf(k, t) { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k) { return k.h0 + k.hr * k.T; }
function tossXY(k, t) {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(半回転の数 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k, t) {
const q = t - (k.t0 + k.T);
if (q <= 0) {
return tossHalf(k, t);
}
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k, t) {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h) { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 偶数 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face, rng, minPairs) {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 表(0)과 裏(1) — 값이 p 보다 작으면 表
function outcomes(seed, n, p) {
const rng = seeded(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(rng() < p ? 0 : 1);
}
return out;
}
// 가장 긴 연속 길이
function longestRun(seq) {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) {
best = cur;
}
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x, y, rx, ry, c, a) {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) {
return;
}
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face, R, a) {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) {
ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
else {
ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
}
else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) {
disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a);
}
}
}
function coinShadow(cx, gy, R, lift, a) {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx, cy, R, th, thick, lx, ly, lz, a, startFace) {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) {
continue;
}
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx, cy, R, thick, a) {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest() {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart() {
lap += 1;
doneSaid = false;
if (still) {
showRest();
return;
}
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function () {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 05 던진 횟수가 쌓일수록 表 비율이 5:5 로 모이는 좁아지는 띠 ─────────────────────
// 같은 수열에서 表 비율 ratioLine 을 잇고, 둘레에 반폭 bandHalf = 1 / (2√n) 의 띠를 겹친다. 선 밖으로 나간 점은 분홍
const TOSS_N = 100;
const SEED = 5055;
const TOSS_GAP = 0.07;
const T_START = 0.1;
const LAP_S = 9.6;
const START_S = 0.0;
const REST_S = 8.6;
const RESULT_S = 8.2;
const PX0 = 40;
const PX1 = 456;
const PY0 = 74;
const PY1 = 262;
// SIM-BEGIN
function bandHalf(n) { return 1 / (2 * Math.sqrt(n)); }
// n 번째 던지기까지의 表 비율(n = 1 에서 시작)
function ratioLine(seq) {
const out = [];
let heads = 0;
for (let i = 0; i < seq.length; i += 1) {
if (seq[i] === 0) {
heads += 1;
}
out.push(heads / (i + 1));
}
return out;
}
// 띠 밖으로 나간 던지기 번호 수 — n 번째 비율이 0.5 에서 bandHalf(n) 보다 멀면 센다
function outsideCount(seq) {
const r = ratioLine(seq);
let c = 0;
for (let i = 0; i < r.length; i += 1) {
if (Math.abs(r[i] - 0.5) > bandHalf(i + 1) + 1e-9) {
c += 1;
}
}
return c;
}
// SIM-END
let seq = [];
let ratios = [];
function build() {
seq = outcomes(SEED + lap, TOSS_N, 0.5);
ratios = ratioLine(seq);
}
function update(dt) { if (dt < 0) {
return;
} }
function nShown() { return clamp(Math.floor((sceneT - T_START) / TOSS_GAP) + 1, 0, TOSS_N); }
function px(n) { return PX0 + (PX1 - PX0) * Math.log(n) / Math.log(TOSS_N); }
// 세로 범위는 0.2 에서 0.8 — 첫 몇 번의 0 이나 1 은 가장자리에 붙여 그린다
function py(v) { return PY1 - (PY1 - PY0) * (clamp(v, 0.2, 0.8) - 0.2) / 0.6; }
function draw() {
skyFill(BG_TOP, BG_BOT);
box(PX0 - 14, PY0 - 14, PX1 - PX0 + 28, PY1 - PY0 + 28, 14, [255, 255, 255], 0.7);
const n = nShown();
// 눈금 — 0.3, 0.5, 0.7
for (let k = 0; k < 3; k += 1) {
const v = 0.3 + 0.2 * k;
seg(PX0, py(v), PX1, py(v), k === 1 ? 2 : 1, k === 1 ? INK : LAV, k === 1 ? 0.55 : 0.6);
text(v.toFixed(1), PX0 - 18, py(v), INK, 0.8, 10, 700, 'right');
}
if (n >= 1) {
// 띠 — 반폭이 1/(2√n) 로 좁아진다
ctx.beginPath();
for (let i = 1; i <= n; i += 1) {
ctx.lineTo(px(i), py(0.5 + bandHalf(i)));
}
for (let i = n; i >= 1; i -= 1) {
ctx.lineTo(px(i), py(0.5 - bandHalf(i)));
}
ctx.closePath();
ctx.fillStyle = rgba(MINT, 0.55);
ctx.fill();
// 비율 선
ctx.beginPath();
for (let i = 1; i <= n; i += 1) {
if (i === 1) {
ctx.moveTo(px(i), py(ratios[i - 1]));
}
else {
ctx.lineTo(px(i), py(ratios[i - 1]));
}
}
ctx.lineWidth = 2.6;
ctx.lineJoin = 'round';
ctx.strokeStyle = rgba(GOLD_DEEP, 1);
ctx.stroke();
for (let i = 1; i <= n; i += 1) {
const out = Math.abs(ratios[i - 1] - 0.5) > bandHalf(i) + 1e-9;
disc(px(i), py(ratios[i - 1]), out ? 3.6 : 2.4, out ? ROSE_DEEP : GOLD_DEEP, 1);
}
// 끝점 윤기
disc(px(n), py(ratios[n - 1]), 7, GOLD, 0.8);
// 지금 던지는 동전
const lastFace = seq[n - 1];
const prev = n > 1 ? seq[n - 2] : 0;
const u = clamp((sceneT - (T_START + (n - 1) * TOSS_GAP)) / TOSS_GAP, 0, 1);
const h0 = prev;
const h1 = h0 + 2 + (lastFace !== prev ? 1 : 0);
coinShadow(420, 120, 18, 0, 1);
drawCoin(420, 100, 18, PI * (h0 + (h1 - h0) * ease(u)), 5, 0.36, -0.46, 0.81, 1, 0);
tag('投げた{n}回・表の割合 {r}'.replace('{n}', String(n)).replace('{r}', ratios[n - 1].toFixed(2)), 40, 282, PAPER, INK, 11, 'left');
tag('帯の半幅 {b}'.replace('{b}', bandHalf(n).toFixed(3)), 456, 282, MINT, INK, 11, 'right');
}
}
function statusText() {
const n = nShown();
if (n < 1) {
return '投げるのを待っています';
}
return '投げた{n}回・表の割合 {r}・帯の半幅 {b}'.replace('{n}', String(n)).replace('{r}', ratios[n - 1].toFixed(2)).replace('{b}', bandHalf(n).toFixed(3));
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// TallyRatioConverge - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./TallyRatioConverge.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TallyRatioConverge() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP: RGB = [250, 246, 252];
const BG_BOT: RGB = [228, 234, 250];
const TABLE_TOP: RGB = [240, 226, 238];
const TABLE_BOT: RGB = [222, 208, 236];
const GOLD: RGB = [250, 226, 150];
const GOLD_DEEP: RGB = [196, 150, 52];
const SKY: RGB = [176, 210, 246];
const SKY_DEEP: RGB = [74, 120, 176];
const RIM_LO: RGB = [184, 156, 100];
const RIM_HI: RGB = [255, 248, 222];
const ROSE: RGB = [240, 150, 176];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [150, 214, 190];
const MINT_DEEP: RGB = [22, 112, 82];
const LAV: RGB = [188, 172, 236];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function mix(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)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 던지기 물리 ───────────────────────────────────────────
// 한 번의 던지기: 출발 (x0, y0) → 도착 (x1, y1), 체공 T 초, 꼭대기 peak 픽셀, 초당 반 바퀴 수 hr, 시작 반 바퀴 수 h0.
// 위치는 포물선(중력만), 각도는 반 바퀴 수 × π — 체공 중 회전 속도는 일정하다
type Toss = { t0: number; T: number; x0: number; y0: number; x1: number; y1: number; peak: number; hr: number; h0: number };
function tossHalf(k: Toss, t: number): number { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k: Toss): number { return k.h0 + k.hr * k.T; }
function tossXY(k: Toss, t: number): [number, number] {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(반 바퀴 수 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
if (q <= 0) { return tossHalf(k, t); }
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h: number): number { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 짝수 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face: number, rng: () => number, minPairs: number): number {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 앞면(0)과 뒷면(1) — 값이 p 보다 작으면 앞면
function outcomes(seed: number, n: number, p: number): number[] {
const rng = seeded(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(rng() < p ? 0 : 1); }
return out;
}
// 가장 긴 연속 길이
function longestRun(seq: number[]): number {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) { best = cur; }
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x: number, y: number, rx: number, ry: number, c: RGB, a: number): void {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) { return; }
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face: number, R: number, a: number): void {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) { ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr); } else { ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr); }
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
} else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) { disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a); }
}
}
function coinShadow(cx: number, gy: number, R: number, lift: number, a: number): void {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx: number, cy: number, R: number, th: number, thick: number, lx: number, ly: number, lz: number, a: number, startFace: number): void {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) { continue; }
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx: number, cy: number, R: number, thick: number, a: number): void {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest(): void {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart(): void {
lap += 1;
doneSaid = false;
if (still) { showRest(); return; }
begin();
showNow();
if (!paused) { startLoop(); }
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function (): void {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 05 던진 횟수가 쌓일수록 앞면 비율이 5:5 로 모이는 좁아지는 띠 ─────────────────────
// 같은 수열에서 앞면 비율 ratioLine 을 잇고, 둘레에 반폭 bandHalf = 1 / (2√n) 의 띠를 겹친다. 선 밖으로 나간 점은 분홍
const TOSS_N = 100;
const SEED = 5055;
const TOSS_GAP = 0.07;
const T_START = 0.1;
const LAP_S = 9.6;
const START_S = 0.0;
const REST_S = 8.6;
const RESULT_S = 8.2;
const PX0 = 40;
const PX1 = 456;
const PY0 = 74;
const PY1 = 262;
// SIM-BEGIN
function bandHalf(n: number): number { return 1 / (2 * Math.sqrt(n)); }
// n 번째 던지기까지의 앞면 비율(n = 1 에서 시작)
function ratioLine(seq: number[]): number[] {
const out: number[] = [];
let heads = 0;
for (let i = 0; i < seq.length; i += 1) {
if (seq[i] === 0) { heads += 1; }
out.push(heads / (i + 1));
}
return out;
}
// 띠 밖으로 나간 던지기 번호 수 — n 번째 비율이 0.5 에서 bandHalf(n) 보다 멀면 센다
function outsideCount(seq: number[]): number {
const r = ratioLine(seq);
let c = 0;
for (let i = 0; i < r.length; i += 1) { if (Math.abs(r[i] - 0.5) > bandHalf(i + 1) + 1e-9) { c += 1; } }
return c;
}
// SIM-END
let seq: number[] = [];
let ratios: number[] = [];
function build(): void {
seq = outcomes(SEED + lap, TOSS_N, 0.5);
ratios = ratioLine(seq);
}
function update(dt: number): void { if (dt < 0) { return; } }
function nShown(): number { return clamp(Math.floor((sceneT - T_START) / TOSS_GAP) + 1, 0, TOSS_N); }
function px(n: number): number { return PX0 + (PX1 - PX0) * Math.log(n) / Math.log(TOSS_N); }
// 세로 범위는 0.2 에서 0.8 — 첫 몇 번의 0 이나 1 은 가장자리에 붙여 그린다
function py(v: number): number { return PY1 - (PY1 - PY0) * (clamp(v, 0.2, 0.8) - 0.2) / 0.6; }
function draw(): void {
skyFill(BG_TOP, BG_BOT);
box(PX0 - 14, PY0 - 14, PX1 - PX0 + 28, PY1 - PY0 + 28, 14, [255, 255, 255], 0.7);
const n = nShown();
// 눈금 — 0.3, 0.5, 0.7
for (let k = 0; k < 3; k += 1) {
const v = 0.3 + 0.2 * k;
seg(PX0, py(v), PX1, py(v), k === 1 ? 2 : 1, k === 1 ? INK : LAV, k === 1 ? 0.55 : 0.6);
text(v.toFixed(1), PX0 - 18, py(v), INK, 0.8, 10, 700, 'right');
}
if (n >= 1) {
// 띠 — 반폭이 1/(2√n) 로 좁아진다
ctx.beginPath();
for (let i = 1; i <= n; i += 1) { ctx.lineTo(px(i), py(0.5 + bandHalf(i))); }
for (let i = n; i >= 1; i -= 1) { ctx.lineTo(px(i), py(0.5 - bandHalf(i))); }
ctx.closePath();
ctx.fillStyle = rgba(MINT, 0.55);
ctx.fill();
// 비율 선
ctx.beginPath();
for (let i = 1; i <= n; i += 1) {
if (i === 1) { ctx.moveTo(px(i), py(ratios[i - 1])); } else { ctx.lineTo(px(i), py(ratios[i - 1])); }
}
ctx.lineWidth = 2.6;
ctx.lineJoin = 'round';
ctx.strokeStyle = rgba(GOLD_DEEP, 1);
ctx.stroke();
for (let i = 1; i <= n; i += 1) {
const out = Math.abs(ratios[i - 1] - 0.5) > bandHalf(i) + 1e-9;
disc(px(i), py(ratios[i - 1]), out ? 3.6 : 2.4, out ? ROSE_DEEP : GOLD_DEEP, 1);
}
// 끝점 윤기
disc(px(n), py(ratios[n - 1]), 7, GOLD, 0.8);
// 지금 던지는 동전
const lastFace = seq[n - 1];
const prev = n > 1 ? seq[n - 2] : 0;
const u = clamp((sceneT - (T_START + (n - 1) * TOSS_GAP)) / TOSS_GAP, 0, 1);
const h0 = prev;
const h1 = h0 + 2 + (lastFace !== prev ? 1 : 0);
coinShadow(420, 120, 18, 0, 1);
drawCoin(420, 100, 18, PI * (h0 + (h1 - h0) * ease(u)), 5, 0.36, -0.46, 0.81, 1, 0);
tag('던진 {n}번 · 앞면 비율 {r}'.replace('{n}', String(n)).replace('{r}', ratios[n - 1].toFixed(2)), 40, 282, PAPER, INK, 11, 'left');
tag('띠 반폭 {b}'.replace('{b}', bandHalf(n).toFixed(3)), 456, 282, MINT, INK, 11, 'right');
}
}
function statusText(): string {
const n = nShown();
if (n < 1) { return '던지기를 기다리는 중'; }
return '던진 {n}번 · 앞면 비율 {r} · 띠 반폭 {b}'.replace('{n}', String(n)).replace('{r}', ratios[n - 1].toFixed(2)).replace('{b}', bandHalf(n).toFixed(3));
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'동전 던지기 시연 무대 — Enter 나 Space 나 클릭으로 던지기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'던진 횟수 100 까지 앞면 비율을 꺾은선으로 잇는다. 가로는 던진 횟수의 로그이고 둘레의 민트색 띠는 반폭 1 나누기 2√n 으로 좁아지며, 띠 밖으로 나간 점은 분홍으로 표시된다. 위쪽 작은 동전이 던질 때마다 뒤집힌다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'동전 던지기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}05 · 投げる回数が増えるほど表の割合が半分へ寄って狭まる帯 — 計測 累積面積 19.085% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function bandHalf(n) { return 1 / (2 * Math.sqrt(n)); }
// n回目の投げまでの表の割合(n = 1から始まる)
function ratioLine(seq) {
const out = [];
let heads = 0;
for (let i = 0; i < seq.length; i += 1) {
if (seq[i] === 0) {
heads += 1;
}
out.push(heads / (i + 1));
}
return out;
}
// 帯の外へ出た投げの回数 — n回目の割合が0.5からbandHalf(n)より離れていれば数える
function outsideCount(seq) {
const r = ratioLine(seq);
let c = 0;
for (let i = 0; i < r.length; i += 1) {
if (Math.abs(r[i] - 0.5) > bandHalf(i + 1) + 1e-9) {
c += 1;
}
}
return c;
}
06重心をずらしたコインが片面へ寄る割合
コインの片側に重さをかたよらせると重い面が下へ沈む角の取り分が大きくなり、ソースの headsP(bias) は0.5にかたよりの半分を足します。同じ数 u でふつうのコイン(u < 0.5)とかたよったコイン(u < 0.55)を分ける pairSeq なので、二枚は同じ投げのなかで分かれます。100回投げると、ふつうのコインは表が51回、BIAS_OFFSET 0.10のかたよったコインは58回です。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="コイントスの舞台 — Enter・Space・クリックで投げる、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="同じ数列で投げる、ふつうのコインと重心のかたよったコイン。二つのコインは0.1秒ごとに跳ねて裏返り、下の帯が表と裏の割合を積みます。ふつうのコインは5対5の近くに集まり、かたよったコインは表の割合がより高く積み上がります"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">コイントスのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 누르면 던진다. 키보드 포커스도 여기서 받는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP = [250, 246, 252];
const BG_BOT = [228, 234, 250];
const TABLE_TOP = [240, 226, 238];
const TABLE_BOT = [222, 208, 236];
const GOLD = [250, 226, 150];
const GOLD_DEEP = [196, 150, 52];
const SKY = [176, 210, 246];
const SKY_DEEP = [74, 120, 176];
const RIM_LO = [184, 156, 100];
const RIM_HI = [255, 248, 222];
const ROSE = [240, 150, 176];
const ROSE_DEEP = [190, 52, 86];
const MINT = [150, 214, 190];
const MINT_DEEP = [22, 112, 82];
const LAV = [188, 172, 236];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function tossHalf(k, t) { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k) { return k.h0 + k.hr * k.T; }
function tossXY(k, t) {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(半回転の数 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k, t) {
const q = t - (k.t0 + k.T);
if (q <= 0) {
return tossHalf(k, t);
}
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k, t) {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h) { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 偶数 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face, rng, minPairs) {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 表(0)과 裏(1) — 값이 p 보다 작으면 表
function outcomes(seed, n, p) {
const rng = seeded(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(rng() < p ? 0 : 1);
}
return out;
}
// 가장 긴 연속 길이
function longestRun(seq) {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) {
best = cur;
}
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x, y, rx, ry, c, a) {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) {
return;
}
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face, R, a) {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) {
ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
else {
ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
}
else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) {
disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a);
}
}
}
function coinShadow(cx, gy, R, lift, a) {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx, cy, R, th, thick, lx, ly, lz, a, startFace) {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) {
continue;
}
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx, cy, R, thick, a) {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest() {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart() {
lap += 1;
doneSaid = false;
if (still) {
showRest();
return;
}
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function () {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 06 무게중심을 옮긴 동전이 한쪽으로 쏠리는 비율 ─────────────────────────────────
// 같은 수열(값 u)로 두 동전을 던진다. 보통 동전은 u < 0.5 이면 表, 치우친 동전은 u < headsP(BIAS_OFFSET) 이면 表
const TOSS_N = 100;
const SEED = 6056;
const BIAS_OFFSET = 0.1;
const TOSS_GAP = 0.1;
const T_START = 0.3;
const COIN_R = 50;
const LAP_S = 11.6;
const START_S = 1.9;
const REST_S = 11.0;
const RESULT_S = 10.8;
// SIM-BEGIN
// 치우친 동전의 앞면이 위로 올 확률 — 무거운 뒷면이 아래로 내려앉는 각의 몫이라 5:5 에 쏠림(지름 대비)의 절반을 더한다
function headsP(bias) { return 0.5 + bias * 0.5; }
function pairSeq(seed, n, bias) {
const rng = seeded(seed);
const fair = [];
const heavy = [];
const p = headsP(bias);
for (let i = 0; i < n; i += 1) {
const u = rng();
fair.push(u < 0.5 ? 0 : 1);
heavy.push(u < p ? 0 : 1);
}
return [fair, heavy];
}
function headsOf(seq, n) {
let c = 0;
for (let i = 0; i < n; i += 1) {
if (seq[i] === 0) {
c += 1;
}
}
return c;
}
// SIM-END
let fairSeq = [];
let heavySeq = [];
function build() {
const pr = pairSeq(SEED + lap, TOSS_N, BIAS_OFFSET);
fairSeq = pr[0];
heavySeq = pr[1];
}
function update(dt) { if (dt < 0) {
return;
} }
function nShown() { return clamp(Math.floor((sceneT - T_START) / TOSS_GAP) + 1, 0, TOSS_N); }
// 한 번의 작은 던지기 — 0.1초 안에 위로 튀며 半回転の数 짝/홀로 면이 정해진다
function coinHalf(seq, n) {
// n 번째까지 쌓인 半回転の数 — 던질 때마다 (면이 바뀌면 奇数, 같으면 偶数) 만큼 더한다
let h = 0;
for (let i = 0; i < n; i += 1) {
const prev = i > 0 ? seq[i - 1] : 0;
h += 2 + (seq[i] !== prev ? 1 : 0);
}
return h;
}
function drawOne(cx, seq, weighted, label) {
const n = nShown();
const u = n > 0 ? clamp((sceneT - (T_START + (n - 1) * TOSS_GAP)) / TOSS_GAP, 0, 1) : 1;
const prevN = Math.max(n - 1, 0);
const hBase = coinHalf(seq, prevN);
const hNow = n > 0 ? hBase + (coinHalf(seq, n) - hBase) * ease(u) : 0;
const lift = 14 * Math.sin(PI * u) * (n > 0 && n < TOSS_N + 1 ? 1 : 0);
coinShadow(cx, 206, COIN_R, lift * 3, 1);
drawCoin(cx, 150 - lift, COIN_R, PI * hNow, 8, 0.36, -0.46, 0.81, 1, 0);
if (weighted) {
// 무게중심 — 지름의 BIAS_OFFSET 배만큼 한쪽으로 쏠린 점
disc(cx, 150 - lift + BIAS_OFFSET * 2 * COIN_R, 5, ROSE_DEEP, 1);
ring(cx, 150 - lift + BIAS_OFFSET * 2 * COIN_R, 8, 1.5, ROSE_DEEP, 0.7);
}
tag(label, cx, 72, weighted ? ROSE : MINT, INK, 11, 'center');
const h = headsOf(seq, n);
const bw = 190;
const bx = cx - bw / 2;
box(bx, 222, bw, 28, 10, SKY, 1);
const hw = n > 0 ? bw * h / n : bw / 2;
box(bx, 222, hw, 28, 10, GOLD, 1);
seg(cx, 218, cx, 254, 2, INK, 0.7);
tag('表 {r}'.replace('{r}', n > 0 ? (h / n).toFixed(2) : '0.00'), cx, 268, PAPER, INK, 12, 'center');
}
function draw() {
skyFill(BG_TOP, BG_BOT);
tableBack(200);
drawOne(124, fairSeq, false, 'ふつうのコイン');
drawOne(356, heavySeq, true, 'かたよったコイン');
const n = nShown();
tag('投げた{n}回'.replace('{n}', String(n)), 240, 289, PAPER, INK, 11, 'center');
}
function statusText() {
const n = nShown();
const a = n > 0 ? headsOf(fairSeq, n) / n : 0;
const b = n > 0 ? headsOf(heavySeq, n) / n : 0;
return '投げた{n}回・ふつうのコインの表 {a}・かたよったコインの表 {b}'.replace('{n}', String(n)).replace('{a}', a.toFixed(2)).replace('{b}', b.toFixed(2));
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// WeightedCenterShift - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./WeightedCenterShift.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function WeightedCenterShift() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP: RGB = [250, 246, 252];
const BG_BOT: RGB = [228, 234, 250];
const TABLE_TOP: RGB = [240, 226, 238];
const TABLE_BOT: RGB = [222, 208, 236];
const GOLD: RGB = [250, 226, 150];
const GOLD_DEEP: RGB = [196, 150, 52];
const SKY: RGB = [176, 210, 246];
const SKY_DEEP: RGB = [74, 120, 176];
const RIM_LO: RGB = [184, 156, 100];
const RIM_HI: RGB = [255, 248, 222];
const ROSE: RGB = [240, 150, 176];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [150, 214, 190];
const MINT_DEEP: RGB = [22, 112, 82];
const LAV: RGB = [188, 172, 236];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function mix(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)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 던지기 물리 ───────────────────────────────────────────
// 한 번의 던지기: 출발 (x0, y0) → 도착 (x1, y1), 체공 T 초, 꼭대기 peak 픽셀, 초당 반 바퀴 수 hr, 시작 반 바퀴 수 h0.
// 위치는 포물선(중력만), 각도는 반 바퀴 수 × π — 체공 중 회전 속도는 일정하다
type Toss = { t0: number; T: number; x0: number; y0: number; x1: number; y1: number; peak: number; hr: number; h0: number };
function tossHalf(k: Toss, t: number): number { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k: Toss): number { return k.h0 + k.hr * k.T; }
function tossXY(k: Toss, t: number): [number, number] {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(반 바퀴 수 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
if (q <= 0) { return tossHalf(k, t); }
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h: number): number { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 짝수 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face: number, rng: () => number, minPairs: number): number {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 앞면(0)과 뒷면(1) — 값이 p 보다 작으면 앞면
function outcomes(seed: number, n: number, p: number): number[] {
const rng = seeded(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(rng() < p ? 0 : 1); }
return out;
}
// 가장 긴 연속 길이
function longestRun(seq: number[]): number {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) { best = cur; }
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x: number, y: number, rx: number, ry: number, c: RGB, a: number): void {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) { return; }
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face: number, R: number, a: number): void {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) { ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr); } else { ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr); }
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
} else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) { disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a); }
}
}
function coinShadow(cx: number, gy: number, R: number, lift: number, a: number): void {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx: number, cy: number, R: number, th: number, thick: number, lx: number, ly: number, lz: number, a: number, startFace: number): void {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) { continue; }
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx: number, cy: number, R: number, thick: number, a: number): void {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest(): void {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart(): void {
lap += 1;
doneSaid = false;
if (still) { showRest(); return; }
begin();
showNow();
if (!paused) { startLoop(); }
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function (): void {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 06 무게중심을 옮긴 동전이 한쪽으로 쏠리는 비율 ─────────────────────────────────
// 같은 수열(값 u)로 두 동전을 던진다. 보통 동전은 u < 0.5 이면 앞면, 치우친 동전은 u < headsP(BIAS_OFFSET) 이면 앞면
const TOSS_N = 100;
const SEED = 6056;
const BIAS_OFFSET = 0.1;
const TOSS_GAP = 0.1;
const T_START = 0.3;
const COIN_R = 50;
const LAP_S = 11.6;
const START_S = 1.9;
const REST_S = 11.0;
const RESULT_S = 10.8;
// SIM-BEGIN
// 치우친 동전의 앞면이 위로 올 확률 — 무거운 뒷면이 아래로 내려앉는 각의 몫이라 5:5 에 쏠림(지름 대비)의 절반을 더한다
function headsP(bias: number): number { return 0.5 + bias * 0.5; }
function pairSeq(seed: number, n: number, bias: number): number[][] {
const rng = seeded(seed);
const fair: number[] = [];
const heavy: number[] = [];
const p = headsP(bias);
for (let i = 0; i < n; i += 1) {
const u = rng();
fair.push(u < 0.5 ? 0 : 1);
heavy.push(u < p ? 0 : 1);
}
return [fair, heavy];
}
function headsOf(seq: number[], n: number): number {
let c = 0;
for (let i = 0; i < n; i += 1) { if (seq[i] === 0) { c += 1; } }
return c;
}
// SIM-END
let fairSeq: number[] = [];
let heavySeq: number[] = [];
function build(): void {
const pr = pairSeq(SEED + lap, TOSS_N, BIAS_OFFSET);
fairSeq = pr[0];
heavySeq = pr[1];
}
function update(dt: number): void { if (dt < 0) { return; } }
function nShown(): number { return clamp(Math.floor((sceneT - T_START) / TOSS_GAP) + 1, 0, TOSS_N); }
// 한 번의 작은 던지기 — 0.1초 안에 위로 튀며 반 바퀴 수 짝/홀로 면이 정해진다
function coinHalf(seq: number[], n: number): number {
// n 번째까지 쌓인 반 바퀴 수 — 던질 때마다 (면이 바뀌면 홀수, 같으면 짝수) 만큼 더한다
let h = 0;
for (let i = 0; i < n; i += 1) {
const prev = i > 0 ? seq[i - 1] : 0;
h += 2 + (seq[i] !== prev ? 1 : 0);
}
return h;
}
function drawOne(cx: number, seq: number[], weighted: boolean, label: string): void {
const n = nShown();
const u = n > 0 ? clamp((sceneT - (T_START + (n - 1) * TOSS_GAP)) / TOSS_GAP, 0, 1) : 1;
const prevN = Math.max(n - 1, 0);
const hBase = coinHalf(seq, prevN);
const hNow = n > 0 ? hBase + (coinHalf(seq, n) - hBase) * ease(u) : 0;
const lift = 14 * Math.sin(PI * u) * (n > 0 && n < TOSS_N + 1 ? 1 : 0);
coinShadow(cx, 206, COIN_R, lift * 3, 1);
drawCoin(cx, 150 - lift, COIN_R, PI * hNow, 8, 0.36, -0.46, 0.81, 1, 0);
if (weighted) {
// 무게중심 — 지름의 BIAS_OFFSET 배만큼 한쪽으로 쏠린 점
disc(cx, 150 - lift + BIAS_OFFSET * 2 * COIN_R, 5, ROSE_DEEP, 1);
ring(cx, 150 - lift + BIAS_OFFSET * 2 * COIN_R, 8, 1.5, ROSE_DEEP, 0.7);
}
tag(label, cx, 72, weighted ? ROSE : MINT, INK, 11, 'center');
const h = headsOf(seq, n);
const bw = 190;
const bx = cx - bw / 2;
box(bx, 222, bw, 28, 10, SKY, 1);
const hw = n > 0 ? bw * h / n : bw / 2;
box(bx, 222, hw, 28, 10, GOLD, 1);
seg(cx, 218, cx, 254, 2, INK, 0.7);
tag('앞면 {r}'.replace('{r}', n > 0 ? (h / n).toFixed(2) : '0.00'), cx, 268, PAPER, INK, 12, 'center');
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
tableBack(200);
drawOne(124, fairSeq, false, '보통 동전');
drawOne(356, heavySeq, true, '치우친 동전');
const n = nShown();
tag('던진 {n}번'.replace('{n}', String(n)), 240, 289, PAPER, INK, 11, 'center');
}
function statusText(): string {
const n = nShown();
const a = n > 0 ? headsOf(fairSeq, n) / n : 0;
const b = n > 0 ? headsOf(heavySeq, n) / n : 0;
return '던진 {n}번 · 보통 동전 앞면 {a} · 치우친 동전 앞면 {b}'.replace('{n}', String(n)).replace('{a}', a.toFixed(2)).replace('{b}', b.toFixed(2));
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'동전 던지기 시연 무대 — Enter 나 Space 나 클릭으로 던지기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'같은 수열로 던지는 보통 동전과 무게중심이 쏠린 동전. 두 동전이 0.1초마다 튀며 뒤집히고 아래 띠가 앞면과 뒷면 몫을 쌓는다. 보통 동전은 5 대 5 근처에 모이고 치우친 동전은 앞면 쪽 몫이 더 높게 쌓인다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'동전 던지기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}06 · 重心をずらしたコインが片面へ寄る割合 — 計測 累積面積 11.394% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// かたよったコインの表が上になる確率 — 重い裏が下へ沈む角の取り分なので、0.5にかたより(直径比)の半分を足す
function headsP(bias) { return 0.5 + bias * 0.5; }
function pairSeq(seed, n, bias) {
const rng = seeded(seed);
const fair = [];
const heavy = [];
const p = headsP(bias);
for (let i = 0; i < n; i += 1) {
const u = rng();
fair.push(u < 0.5 ? 0 : 1);
heavy.push(u < p ? 0 : 1);
}
return [fair, heavy];
}
07同じ面が続くと長さが伸びる連続の帯
投げた結果を一マスずつ横の帯に塗り、cellRun がマスごとに終わる連続の長さを数えて、いちばん長い連続に太い枠を付けます。この200回の数列でいちばん長い連続は8マスで、下の帯の handMade の数列は同じ面を30%の確率でつなぎ、三マス続くと面を替えるので3マスを超えません。表の数は投げた数列が95、作った数列が92なので、個数だけでは見分けられません。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="コイントスの舞台 — Enter・Space・クリックで投げる、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="投げた数列200マスと、人が手で作った数列200マスが、マスごとに金色の表と空色の裏で塗られ、いちばん長い連続に太い枠が付きます。投げた数列は連続が長く育ち、手で作った数列は3マスを超えません"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">コイントスのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 누르면 던진다. 키보드 포커스도 여기서 받는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP = [250, 246, 252];
const BG_BOT = [228, 234, 250];
const TABLE_TOP = [240, 226, 238];
const TABLE_BOT = [222, 208, 236];
const GOLD = [250, 226, 150];
const GOLD_DEEP = [196, 150, 52];
const SKY = [176, 210, 246];
const SKY_DEEP = [74, 120, 176];
const RIM_LO = [184, 156, 100];
const RIM_HI = [255, 248, 222];
const ROSE = [240, 150, 176];
const ROSE_DEEP = [190, 52, 86];
const MINT = [150, 214, 190];
const MINT_DEEP = [22, 112, 82];
const LAV = [188, 172, 236];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function tossHalf(k, t) { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k) { return k.h0 + k.hr * k.T; }
function tossXY(k, t) {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(半回転の数 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k, t) {
const q = t - (k.t0 + k.T);
if (q <= 0) {
return tossHalf(k, t);
}
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k, t) {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h) { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 偶数 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face, rng, minPairs) {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 表(0)과 裏(1) — 값이 p 보다 작으면 表
function outcomes(seed, n, p) {
const rng = seeded(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(rng() < p ? 0 : 1);
}
return out;
}
// 가장 긴 연속 길이
function longestRun(seq) {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) {
best = cur;
}
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x, y, rx, ry, c, a) {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) {
return;
}
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face, R, a) {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) {
ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
else {
ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
}
else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) {
disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a);
}
}
}
function coinShadow(cx, gy, R, lift, a) {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx, cy, R, th, thick, lx, ly, lz, a, startFace) {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) {
continue;
}
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx, cy, R, thick, a) {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest() {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart() {
lap += 1;
doneSaid = false;
if (still) {
showRest();
return;
}
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function () {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 07 같은 면이 이어질 때 길이가 자라는 연속 띠 ───────────────────────────────────
// 던진 결과를 칸 하나씩 띠에 칠한다. 같은 면이 이어지는 구간의 길이 cellRun 을 세고 가장 긴 연속(RUN_MAX)에 굵은 테두리를 두른다.
// 아래 띠는 같은 길이로 사람이 손으로 지어낸 수열 — 같은 면을 세 칸 넘게 잇지 않는다
const STRIP_N = 200;
const SEED = 7003;
const CELL_GAP = 0.018;
const T_START = 0.25;
const COLS = 40;
const CW = 11;
const CH = 13;
const LAP_S = 9.0;
const START_S = 1.0;
const REST_S = 5.6;
const RESULT_S = 5.4;
// SIM-BEGIN
// 칸마다 그 칸에서 끝나는 연속 길이
function cellRun(seq) {
const out = [];
for (let i = 0; i < seq.length; i += 1) {
out.push(i > 0 && seq[i] === seq[i - 1] ? out[i - 1] + 1 : 1);
}
return out;
}
// 가장 긴 연속의 끝 칸 번호(앞쪽 우선)
function runEnd(seq, upto) {
const r = cellRun(seq);
let best = -1;
let bl = 0;
for (let i = 0; i < upto; i += 1) {
if (r[i] > bl) {
bl = r[i];
best = i;
}
}
return best;
}
// 사람이 지어낸 수열 — 앞 칸과 같은 면을 고를 확률은 30% 이고, 이미 세 칸 이어졌으면 반드시 바꾼다
function handMade(seed, n) {
const rng = seeded(seed);
const out = [];
let run = 0;
for (let i = 0; i < n; i += 1) {
const prev = i > 0 ? out[i - 1] : 1;
let next = rng() < 0.3 ? prev : 1 - prev;
if (i > 0 && next === prev && run >= 3) {
next = 1 - prev;
}
run = (i > 0 && next === prev) ? run + 1 : 1;
out.push(next);
}
return out;
}
// SIM-END
let real = [];
let human = [];
function build() {
real = outcomes(SEED + lap, STRIP_N, 0.5);
human = handMade(SEED + 77 + lap, STRIP_N);
}
function update(dt) { if (dt < 0) {
return;
} }
function nShown() { return clamp(Math.floor((sceneT - T_START) / CELL_GAP) + 1, 0, STRIP_N); }
function cellXY(i, y0) { return [20 + (i % COLS) * CW, y0 + Math.floor(i / COLS) * CH]; }
function strip(seq, y0, outlineRun, label, labelBg) {
const n = nShown();
const runs = cellRun(seq);
box(14, y0 - 6, COLS * CW + 12, 5 * CH + 12, 8, [255, 255, 255], 0.65);
for (let i = 0; i < STRIP_N; i += 1) {
const p = cellXY(i, y0);
if (i >= n) {
box(p[0] + 1, p[1] + 1, CW - 2, CH - 2, 3, [235, 230, 245], 0.8);
continue;
}
const age = clamp((sceneT - (T_START + i * CELL_GAP)) / 0.12, 0, 1);
const s = 0.55 + 0.45 * ease(age);
box(p[0] + 1 + (CW - 2) * (1 - s) / 2, p[1] + 1 + (CH - 2) * (1 - s) / 2, (CW - 2) * s, (CH - 2) * s, 3, seq[i] === 0 ? GOLD : SKY, 1);
}
// 가장 긴 연속 — 지금까지 칠한 칸 안에서 굵은 테두리
const e = runEnd(seq, n);
if (e >= 0) {
const len = runs[e];
for (let j = e - len + 1; j <= e; j += 1) {
const p = cellXY(j, y0);
outline(p[0] + 0.5, p[1] + 0.5, CW - 1, CH - 1, 3, 2.2, outlineRun ? ROSE_DEEP : INK, 1);
}
tag(label.replace('{m}', String(len)), 20 + COLS * CW, y0 + 5 * CH + 13, labelBg, INK, 11, 'right');
}
}
function statusText() {
const n = nShown();
const re = runEnd(real, n);
const he = runEnd(human, n);
return '{n}回投げた・投げた数列のいちばん長い連続 {a}・作った数列 {b}'.replace('{n}', String(n)).replace('{a}', String(re >= 0 ? cellRun(real)[re] : 0)).replace('{b}', String(he >= 0 ? cellRun(human)[he] : 0));
}
function draw() {
skyFill(BG_TOP, BG_BOT);
strip(real, 66, true, '投げた数列・いちばん長い連続 {m}', ROSE);
strip(human, 174, false, '作った数列・いちばん長い連続 {m}', MINT);
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// StreakRunStrip - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./StreakRunStrip.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function StreakRunStrip() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP: RGB = [250, 246, 252];
const BG_BOT: RGB = [228, 234, 250];
const TABLE_TOP: RGB = [240, 226, 238];
const TABLE_BOT: RGB = [222, 208, 236];
const GOLD: RGB = [250, 226, 150];
const GOLD_DEEP: RGB = [196, 150, 52];
const SKY: RGB = [176, 210, 246];
const SKY_DEEP: RGB = [74, 120, 176];
const RIM_LO: RGB = [184, 156, 100];
const RIM_HI: RGB = [255, 248, 222];
const ROSE: RGB = [240, 150, 176];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [150, 214, 190];
const MINT_DEEP: RGB = [22, 112, 82];
const LAV: RGB = [188, 172, 236];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function mix(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)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 던지기 물리 ───────────────────────────────────────────
// 한 번의 던지기: 출발 (x0, y0) → 도착 (x1, y1), 체공 T 초, 꼭대기 peak 픽셀, 초당 반 바퀴 수 hr, 시작 반 바퀴 수 h0.
// 위치는 포물선(중력만), 각도는 반 바퀴 수 × π — 체공 중 회전 속도는 일정하다
type Toss = { t0: number; T: number; x0: number; y0: number; x1: number; y1: number; peak: number; hr: number; h0: number };
function tossHalf(k: Toss, t: number): number { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k: Toss): number { return k.h0 + k.hr * k.T; }
function tossXY(k: Toss, t: number): [number, number] {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(반 바퀴 수 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
if (q <= 0) { return tossHalf(k, t); }
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h: number): number { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 짝수 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face: number, rng: () => number, minPairs: number): number {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 앞면(0)과 뒷면(1) — 값이 p 보다 작으면 앞면
function outcomes(seed: number, n: number, p: number): number[] {
const rng = seeded(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(rng() < p ? 0 : 1); }
return out;
}
// 가장 긴 연속 길이
function longestRun(seq: number[]): number {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) { best = cur; }
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x: number, y: number, rx: number, ry: number, c: RGB, a: number): void {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) { return; }
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face: number, R: number, a: number): void {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) { ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr); } else { ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr); }
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
} else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) { disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a); }
}
}
function coinShadow(cx: number, gy: number, R: number, lift: number, a: number): void {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx: number, cy: number, R: number, th: number, thick: number, lx: number, ly: number, lz: number, a: number, startFace: number): void {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) { continue; }
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx: number, cy: number, R: number, thick: number, a: number): void {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest(): void {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart(): void {
lap += 1;
doneSaid = false;
if (still) { showRest(); return; }
begin();
showNow();
if (!paused) { startLoop(); }
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function (): void {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 07 같은 면이 이어질 때 길이가 자라는 연속 띠 ───────────────────────────────────
// 던진 결과를 칸 하나씩 띠에 칠한다. 같은 면이 이어지는 구간의 길이 cellRun 을 세고 가장 긴 연속(RUN_MAX)에 굵은 테두리를 두른다.
// 아래 띠는 같은 길이로 사람이 손으로 지어낸 수열 — 같은 면을 세 칸 넘게 잇지 않는다
const STRIP_N = 200;
const SEED = 7003;
const CELL_GAP = 0.018;
const T_START = 0.25;
const COLS = 40;
const CW = 11;
const CH = 13;
const LAP_S = 9.0;
const START_S = 1.0;
const REST_S = 5.6;
const RESULT_S = 5.4;
// SIM-BEGIN
// 칸마다 그 칸에서 끝나는 연속 길이
function cellRun(seq: number[]): number[] {
const out: number[] = [];
for (let i = 0; i < seq.length; i += 1) { out.push(i > 0 && seq[i] === seq[i - 1] ? out[i - 1] + 1 : 1); }
return out;
}
// 가장 긴 연속의 끝 칸 번호(앞쪽 우선)
function runEnd(seq: number[], upto: number): number {
const r = cellRun(seq);
let best = -1;
let bl = 0;
for (let i = 0; i < upto; i += 1) { if (r[i] > bl) { bl = r[i]; best = i; } }
return best;
}
// 사람이 지어낸 수열 — 앞 칸과 같은 면을 고를 확률은 30% 이고, 이미 세 칸 이어졌으면 반드시 바꾼다
function handMade(seed: number, n: number): number[] {
const rng = seeded(seed);
const out: number[] = [];
let run = 0;
for (let i = 0; i < n; i += 1) {
const prev = i > 0 ? out[i - 1] : 1;
let next = rng() < 0.3 ? prev : 1 - prev;
if (i > 0 && next === prev && run >= 3) { next = 1 - prev; }
run = (i > 0 && next === prev) ? run + 1 : 1;
out.push(next);
}
return out;
}
// SIM-END
let real: number[] = [];
let human: number[] = [];
function build(): void {
real = outcomes(SEED + lap, STRIP_N, 0.5);
human = handMade(SEED + 77 + lap, STRIP_N);
}
function update(dt: number): void { if (dt < 0) { return; } }
function nShown(): number { return clamp(Math.floor((sceneT - T_START) / CELL_GAP) + 1, 0, STRIP_N); }
function cellXY(i: number, y0: number): number[] { return [20 + (i % COLS) * CW, y0 + Math.floor(i / COLS) * CH]; }
function strip(seq: number[], y0: number, outlineRun: boolean, label: string, labelBg: RGB): void {
const n = nShown();
const runs = cellRun(seq);
box(14, y0 - 6, COLS * CW + 12, 5 * CH + 12, 8, [255, 255, 255], 0.65);
for (let i = 0; i < STRIP_N; i += 1) {
const p = cellXY(i, y0);
if (i >= n) { box(p[0] + 1, p[1] + 1, CW - 2, CH - 2, 3, [235, 230, 245], 0.8); continue; }
const age = clamp((sceneT - (T_START + i * CELL_GAP)) / 0.12, 0, 1);
const s = 0.55 + 0.45 * ease(age);
box(p[0] + 1 + (CW - 2) * (1 - s) / 2, p[1] + 1 + (CH - 2) * (1 - s) / 2, (CW - 2) * s, (CH - 2) * s, 3, seq[i] === 0 ? GOLD : SKY, 1);
}
// 가장 긴 연속 — 지금까지 칠한 칸 안에서 굵은 테두리
const e = runEnd(seq, n);
if (e >= 0) {
const len = runs[e];
for (let j = e - len + 1; j <= e; j += 1) {
const p = cellXY(j, y0);
outline(p[0] + 0.5, p[1] + 0.5, CW - 1, CH - 1, 3, 2.2, outlineRun ? ROSE_DEEP : INK, 1);
}
tag(label.replace('{m}', String(len)), 20 + COLS * CW, y0 + 5 * CH + 13, labelBg, INK, 11, 'right');
}
}
function statusText(): string {
const n = nShown();
const re = runEnd(real, n);
const he = runEnd(human, n);
return '{n}번 던짐 · 던진 수열 가장 긴 연속 {a} · 지어낸 수열 {b}'.replace('{n}', String(n)).replace('{a}', String(re >= 0 ? cellRun(real)[re] : 0)).replace('{b}', String(he >= 0 ? cellRun(human)[he] : 0));
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
strip(real, 66, true, '던진 수열 · 가장 긴 연속 {m}', ROSE);
strip(human, 174, false, '지어낸 수열 · 가장 긴 연속 {m}', MINT);
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'동전 던지기 시연 무대 — Enter 나 Space 나 클릭으로 던지기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'던진 수열 200칸과 사람이 지어낸 수열 200칸이 칸마다 금빛 앞면과 하늘빛 뒷면으로 칠해지고 가장 긴 연속에 굵은 테두리가 둘러진다. 던진 수열은 연속이 길게 자라고 지어낸 수열은 세 칸을 넘기지 않는다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'동전 던지기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}07 · 同じ面が続くと長さが伸びる連続の帯 — 計測 累積面積 18.313% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// マスごとにそのマスで終わる連続の長さ
function cellRun(seq) {
const out = [];
for (let i = 0; i < seq.length; i += 1) {
out.push(i > 0 && seq[i] === seq[i - 1] ? out[i - 1] + 1 : 1);
}
return out;
}
// いちばん長い連続の終わりのマス番号(前のほうを優先)
function runEnd(seq, upto) {
const r = cellRun(seq);
let best = -1;
let bl = 0;
for (let i = 0; i < upto; i += 1) {
if (r[i] > bl) {
bl = r[i];
best = i;
}
}
return best;
}
08積んだコインの上へ落ちて弾む連鎖
積んだ山の上へ落ちると、hopPeaks が落ちた距離から最初の着地の速さを REST_E 0.45倍にして弾む高さを一つずつ出し、速さが HOP_MIN 30を下回ると横になります。山が3枚、4枚、5枚のとき落ちる距離は127.7、116.7、105.7ピクセルで、最初のはねの高さは25.8、23.6、21.4ピクセルと低くなります。はねは三回(25.8、5.2、1.1ピクセル)で、3枚のときの弾んだ時間の合計は0.614秒です。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="コイントスの舞台 — Enter・Space・クリックで投げる、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="積まれたコインの山の上にコインが落ち、反発係数0.45で跳ね上がります。跳ねるたびに高さが下がり、何回かはねて横になり、右の棒がはねた高さを示して、山は一枚ずつ増えます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">コイントスのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 누르면 던진다. 키보드 포커스도 여기서 받는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP = [250, 246, 252];
const BG_BOT = [228, 234, 250];
const TABLE_TOP = [240, 226, 238];
const TABLE_BOT = [222, 208, 236];
const GOLD = [250, 226, 150];
const GOLD_DEEP = [196, 150, 52];
const SKY = [176, 210, 246];
const SKY_DEEP = [74, 120, 176];
const RIM_LO = [184, 156, 100];
const RIM_HI = [255, 248, 222];
const ROSE = [240, 150, 176];
const ROSE_DEEP = [190, 52, 86];
const MINT = [150, 214, 190];
const MINT_DEEP = [22, 112, 82];
const LAV = [188, 172, 236];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function tossHalf(k, t) { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k) { return k.h0 + k.hr * k.T; }
function tossXY(k, t) {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(半回転の数 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k, t) {
const q = t - (k.t0 + k.T);
if (q <= 0) {
return tossHalf(k, t);
}
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k, t) {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h) { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 偶数 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face, rng, minPairs) {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 表(0)과 裏(1) — 값이 p 보다 작으면 表
function outcomes(seed, n, p) {
const rng = seeded(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(rng() < p ? 0 : 1);
}
return out;
}
// 가장 긴 연속 길이
function longestRun(seq) {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) {
best = cur;
}
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x, y, rx, ry, c, a) {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) {
return;
}
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face, R, a) {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) {
ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
else {
ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
}
else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) {
disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a);
}
}
}
function coinShadow(cx, gy, R, lift, a) {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx, cy, R, th, thick, lx, ly, lz, a, startFace) {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) {
continue;
}
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx, cy, R, thick, a) {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest() {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart() {
lap += 1;
doneSaid = false;
if (still) {
showRest();
return;
}
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function () {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 08 쌓인 동전 위로 떨어져 튕기는 연쇄 ─────────────────────────────────────────────
// 떨어진 동전은 더미 맨 위에서 반발 계수 REST_E 만큼 속도를 지켜 튀어 오른다. 튈 때마다 높이가 REST_E 의 제곱으로 줄고,
// 속도가 HOP_MIN 밑이면 눕는다. 더미가 한 장 늘면 착지 높이가 화면에서 THICK_S × sin(TH_FLAT) 만큼 올라간다
const REST_E = 0.45;
const GRAV = 1500;
const HOP_MIN = 30;
const COIN_R = 54;
const THICK_S = 12;
const TH_FLAT = PI / 2 - 0.42;
const TABLE_Y = 236;
const START_Y = 70;
const STACK0 = 3;
const DROP_GAP = 1.0;
const COIN_X = 190;
const LAP_S = 4.6;
const START_S = 0.0;
const REST_S = 3.6;
const RESULT_S = 3.5;
// SIM-BEGIN
// 한 번의 낙하 — 떨어진 거리 d 에서 첫 착지 속도와 튄 높이들
function hopPeaks(d, e) {
const out = [];
let v = Math.sqrt(2 * GRAV * d) * e;
while (v >= HOP_MIN) {
out.push(v * v / (2 * GRAV));
v *= e;
}
return out;
}
// 착지 뒤 q 초 때의 튄 높이
function hopY(d, e, q) {
let v = Math.sqrt(2 * GRAV * d) * e;
let t = q;
while (v >= HOP_MIN) {
const dur = 2 * v / GRAV;
if (t < dur) {
return v * t - GRAV * t * t / 2;
}
t -= dur;
v *= e;
}
return 0;
}
function hopTotal(d, e) {
let v = Math.sqrt(2 * GRAV * d) * e;
let s = 0;
while (v >= HOP_MIN) {
s += 2 * v / GRAV;
v *= e;
}
return s;
}
let drops = [];
let stack0 = STACK0;
function topY(level) { return TABLE_Y - (level + 0.5) * THICK_S * Math.sin(TH_FLAT); }
function build() {
stack0 = STACK0 + (lap % 3);
drops = [];
for (let k = 0; k < 3; k += 1) {
const lv = stack0 + k;
const d = topY(lv) - START_Y;
const t0 = 0.1 + DROP_GAP * k;
const ti = t0 + Math.sqrt(2 * d / GRAV);
drops.push({ t0: t0, level: lv, d: d, tImp: ti, tEnd: ti + hopTotal(d, REST_E) });
}
}
function update(dt) { if (dt < 0) {
return;
} }
function doneCount() {
let c = 0;
for (let i = 0; i < drops.length; i += 1) {
if (sceneT >= drops[i].tEnd) {
c += 1;
}
}
return c;
}
function curDrop() {
for (let i = 0; i < drops.length; i += 1) {
if (sceneT < drops[i].tEnd) {
return i;
}
}
return -1;
}
function stackCoin(k) {
drawCoin(COIN_X, topY(k), COIN_R, TH_FLAT, THICK_S, 0.36, -0.46, 0.81, 1, k % 2);
}
function draw() {
skyFill(BG_TOP, BG_BOT);
tableBack(TABLE_Y - 14);
const n = stack0 + doneCount();
oval(COIN_X, TABLE_Y + 2, COIN_R * 1.05, COIN_R * 0.3, INK, 0.2);
for (let k = 0; k < n; k += 1) {
stackCoin(k);
}
const ci = curDrop();
if (ci >= 0 && sceneT >= drops[ci].t0) {
const dr = drops[ci];
let y = START_Y;
let th = TH_FLAT;
if (sceneT < dr.tImp) {
const q = sceneT - dr.t0;
y = START_Y + GRAV * q * q / 2;
th = TH_FLAT - 9 * (dr.tImp - sceneT);
}
else {
const q = sceneT - dr.tImp;
y = topY(dr.level) - hopY(dr.d, REST_E, q);
th = TH_FLAT + 0.16 * Math.exp(-6 * q) * Math.sin(30 * q);
// 부딪힌 자리에서 퍼지는 물결
ring(COIN_X, TABLE_Y + 2, COIN_R * (0.9 + q * 1.4), 2.2, LAV, 0.8 * Math.max(0, 1 - q / 0.7));
// 튄 높이 막대 — 이 동전이 튄 높이들(첫 튐이 가장 높다)
const peaks = hopPeaks(dr.d, REST_E);
for (let j = 0; j < peaks.length; j += 1) {
const h = peaks[j] * 3.4;
const fill = clamp((q - j * 0.16) / 0.16, 0, 1);
box(342 + j * 36, 226 - h * fill, 28, h * fill, 6, j === 0 ? ROSE : LAV, 1);
text(String(j + 1), 356 + j * 36, 240, INK, 0.9, 11, 700, 'center');
}
}
const lift = TABLE_Y - y;
coinShadow(COIN_X, TABLE_Y + 2, COIN_R, lift, 1);
drawCoin(COIN_X, y, COIN_R, th, THICK_S, 0.36, -0.46, 0.81, 1, n % 2);
}
tag('反発係数 {e}'.replace('{e}', REST_E.toFixed(2)), 400, 66, MINT, INK, 11, 'center');
tag('積んだコイン {n}'.replace('{n}', String(n)), 400, 264, PAPER, INK, 11, 'center');
}
function statusText() {
const ci = curDrop();
const n = stack0 + doneCount();
if (ci < 0) {
return '積んだコイン {n}枚・すべて横になった'.replace('{n}', String(n));
}
const dr = drops[ci];
const hops = hopPeaks(dr.d, REST_E).length;
return '積んだコイン {n}枚・このコインは{h}回はねる'.replace('{n}', String(n)).replace('{h}', String(hops));
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// StackDropBounceChain - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./StackDropBounceChain.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function StackDropBounceChain() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP: RGB = [250, 246, 252];
const BG_BOT: RGB = [228, 234, 250];
const TABLE_TOP: RGB = [240, 226, 238];
const TABLE_BOT: RGB = [222, 208, 236];
const GOLD: RGB = [250, 226, 150];
const GOLD_DEEP: RGB = [196, 150, 52];
const SKY: RGB = [176, 210, 246];
const SKY_DEEP: RGB = [74, 120, 176];
const RIM_LO: RGB = [184, 156, 100];
const RIM_HI: RGB = [255, 248, 222];
const ROSE: RGB = [240, 150, 176];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [150, 214, 190];
const MINT_DEEP: RGB = [22, 112, 82];
const LAV: RGB = [188, 172, 236];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function mix(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)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 던지기 물리 ───────────────────────────────────────────
// 한 번의 던지기: 출발 (x0, y0) → 도착 (x1, y1), 체공 T 초, 꼭대기 peak 픽셀, 초당 반 바퀴 수 hr, 시작 반 바퀴 수 h0.
// 위치는 포물선(중력만), 각도는 반 바퀴 수 × π — 체공 중 회전 속도는 일정하다
type Toss = { t0: number; T: number; x0: number; y0: number; x1: number; y1: number; peak: number; hr: number; h0: number };
function tossHalf(k: Toss, t: number): number { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k: Toss): number { return k.h0 + k.hr * k.T; }
function tossXY(k: Toss, t: number): [number, number] {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(반 바퀴 수 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
if (q <= 0) { return tossHalf(k, t); }
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h: number): number { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 짝수 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face: number, rng: () => number, minPairs: number): number {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 앞면(0)과 뒷면(1) — 값이 p 보다 작으면 앞면
function outcomes(seed: number, n: number, p: number): number[] {
const rng = seeded(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(rng() < p ? 0 : 1); }
return out;
}
// 가장 긴 연속 길이
function longestRun(seq: number[]): number {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) { best = cur; }
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x: number, y: number, rx: number, ry: number, c: RGB, a: number): void {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) { return; }
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face: number, R: number, a: number): void {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) { ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr); } else { ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr); }
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
} else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) { disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a); }
}
}
function coinShadow(cx: number, gy: number, R: number, lift: number, a: number): void {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx: number, cy: number, R: number, th: number, thick: number, lx: number, ly: number, lz: number, a: number, startFace: number): void {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) { continue; }
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx: number, cy: number, R: number, thick: number, a: number): void {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest(): void {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart(): void {
lap += 1;
doneSaid = false;
if (still) { showRest(); return; }
begin();
showNow();
if (!paused) { startLoop(); }
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function (): void {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 08 쌓인 동전 위로 떨어져 튕기는 연쇄 ─────────────────────────────────────────────
// 떨어진 동전은 더미 맨 위에서 반발 계수 REST_E 만큼 속도를 지켜 튀어 오른다. 튈 때마다 높이가 REST_E 의 제곱으로 줄고,
// 속도가 HOP_MIN 밑이면 눕는다. 더미가 한 장 늘면 착지 높이가 화면에서 THICK_S × sin(TH_FLAT) 만큼 올라간다
const REST_E = 0.45;
const GRAV = 1500;
const HOP_MIN = 30;
const COIN_R = 54;
const THICK_S = 12;
const TH_FLAT = PI / 2 - 0.42;
const TABLE_Y = 236;
const START_Y = 70;
const STACK0 = 3;
const DROP_GAP = 1.0;
const COIN_X = 190;
const LAP_S = 4.6;
const START_S = 0.0;
const REST_S = 3.6;
const RESULT_S = 3.5;
// SIM-BEGIN
// 한 번의 낙하 — 떨어진 거리 d 에서 첫 착지 속도와 튄 높이들
function hopPeaks(d: number, e: number): number[] {
const out: number[] = [];
let v = Math.sqrt(2 * GRAV * d) * e;
while (v >= HOP_MIN) {
out.push(v * v / (2 * GRAV));
v *= e;
}
return out;
}
// 착지 뒤 q 초 때의 튄 높이
function hopY(d: number, e: number, q: number): number {
let v = Math.sqrt(2 * GRAV * d) * e;
let t = q;
while (v >= HOP_MIN) {
const dur = 2 * v / GRAV;
if (t < dur) { return v * t - GRAV * t * t / 2; }
t -= dur;
v *= e;
}
return 0;
}
function hopTotal(d: number, e: number): number {
let v = Math.sqrt(2 * GRAV * d) * e;
let s = 0;
while (v >= HOP_MIN) { s += 2 * v / GRAV; v *= e; }
return s;
}
// SIM-END
type Drop = { t0: number; level: number; d: number; tImp: number; tEnd: number };
let drops: Drop[] = [];
let stack0 = STACK0;
function topY(level: number): number { return TABLE_Y - (level + 0.5) * THICK_S * Math.sin(TH_FLAT); }
function build(): void {
stack0 = STACK0 + (lap % 3);
drops = [];
for (let k = 0; k < 3; k += 1) {
const lv = stack0 + k;
const d = topY(lv) - START_Y;
const t0 = 0.1 + DROP_GAP * k;
const ti = t0 + Math.sqrt(2 * d / GRAV);
drops.push({ t0: t0, level: lv, d: d, tImp: ti, tEnd: ti + hopTotal(d, REST_E) });
}
}
function update(dt: number): void { if (dt < 0) { return; } }
function doneCount(): number {
let c = 0;
for (let i = 0; i < drops.length; i += 1) { if (sceneT >= drops[i].tEnd) { c += 1; } }
return c;
}
function curDrop(): number {
for (let i = 0; i < drops.length; i += 1) { if (sceneT < drops[i].tEnd) { return i; } }
return -1;
}
function stackCoin(k: number): void {
drawCoin(COIN_X, topY(k), COIN_R, TH_FLAT, THICK_S, 0.36, -0.46, 0.81, 1, k % 2);
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
tableBack(TABLE_Y - 14);
const n = stack0 + doneCount();
oval(COIN_X, TABLE_Y + 2, COIN_R * 1.05, COIN_R * 0.3, INK, 0.2);
for (let k = 0; k < n; k += 1) { stackCoin(k); }
const ci = curDrop();
if (ci >= 0 && sceneT >= drops[ci].t0) {
const dr = drops[ci];
let y = START_Y;
let th = TH_FLAT;
if (sceneT < dr.tImp) {
const q = sceneT - dr.t0;
y = START_Y + GRAV * q * q / 2;
th = TH_FLAT - 9 * (dr.tImp - sceneT);
} else {
const q = sceneT - dr.tImp;
y = topY(dr.level) - hopY(dr.d, REST_E, q);
th = TH_FLAT + 0.16 * Math.exp(-6 * q) * Math.sin(30 * q);
// 부딪힌 자리에서 퍼지는 물결
ring(COIN_X, TABLE_Y + 2, COIN_R * (0.9 + q * 1.4), 2.2, LAV, 0.8 * Math.max(0, 1 - q / 0.7));
// 튄 높이 막대 — 이 동전이 튄 높이들(첫 튐이 가장 높다)
const peaks = hopPeaks(dr.d, REST_E);
for (let j = 0; j < peaks.length; j += 1) {
const h = peaks[j] * 3.4;
const fill = clamp((q - j * 0.16) / 0.16, 0, 1);
box(342 + j * 36, 226 - h * fill, 28, h * fill, 6, j === 0 ? ROSE : LAV, 1);
text(String(j + 1), 356 + j * 36, 240, INK, 0.9, 11, 700, 'center');
}
}
const lift = TABLE_Y - y;
coinShadow(COIN_X, TABLE_Y + 2, COIN_R, lift, 1);
drawCoin(COIN_X, y, COIN_R, th, THICK_S, 0.36, -0.46, 0.81, 1, n % 2);
}
tag('반발 계수 {e}'.replace('{e}', REST_E.toFixed(2)), 400, 66, MINT, INK, 11, 'center');
tag('쌓인 동전 {n}'.replace('{n}', String(n)), 400, 264, PAPER, INK, 11, 'center');
}
function statusText(): string {
const ci = curDrop();
const n = stack0 + doneCount();
if (ci < 0) { return '쌓인 동전 {n}장 · 모두 눕혔다'.replace('{n}', String(n)); }
const dr = drops[ci];
const hops = hopPeaks(dr.d, REST_E).length;
return '쌓인 동전 {n}장 · 이번 동전은 {h}번 튄다'.replace('{n}', String(n)).replace('{h}', String(hops));
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'동전 던지기 시연 무대 — Enter 나 Space 나 클릭으로 던지기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'쌓인 동전 더미 위로 동전이 떨어져 반발 계수 0.45 로 튀어 오른다. 튈 때마다 높이가 줄어 몇 번 통통거리다 눕고, 오른쪽 막대가 튄 높이를 보이며 더미가 한 장씩 늘어난다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'동전 던지기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}08 · 積んだコインの上へ落ちて弾む連鎖 — 計測 累積面積 15.761% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 一回の落下 — 落ちた距離dから最初の着地の速さと弾んだ高さたち
function hopPeaks(d, e) {
const out = [];
let v = Math.sqrt(2 * GRAV * d) * e;
while (v >= HOP_MIN) {
out.push(v * v / (2 * GRAV));
v *= e;
}
return out;
}
09二つの選択肢を掛けて投げる机の決定ヒーロー
机の左に二つの選択肢(本を読む・散歩する)が置かれ、コインが着地すると halfFor が作った半回転の数の偶奇が面を決め、その面の選択肢が明るくなります。最初の一回分の四投は裏・裏・表・裏で、半回転17.15・13.11・15.98・17.09が17・13・16・17に四捨五入されて、本を読む1、散歩する3が積み上がります。tally(face) は着地の0.25秒後に回数を数えます。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="コイントスの舞台 — Enter・Space・クリックで投げる、P で一時停止、N で一歩、R でやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="机の上に二つの選択肢、読書と散歩。コインが放物線で飛んで着地すると、上になった面に合う選択肢が明るくなり、下に選択肢ごとの回数と表の割合が積み上がります"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">コイントスのデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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-cream;
}
// 무대 상자 — 누르면 던진다. 키보드 포커스도 여기서 받는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
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: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP = [250, 246, 252];
const BG_BOT = [228, 234, 250];
const TABLE_TOP = [240, 226, 238];
const TABLE_BOT = [222, 208, 236];
const GOLD = [250, 226, 150];
const GOLD_DEEP = [196, 150, 52];
const SKY = [176, 210, 246];
const SKY_DEEP = [74, 120, 176];
const RIM_LO = [184, 156, 100];
const RIM_HI = [255, 248, 222];
const ROSE = [240, 150, 176];
const ROSE_DEEP = [190, 52, 86];
const MINT = [150, 214, 190];
const MINT_DEEP = [22, 112, 82];
const LAV = [188, 172, 236];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
let script = [];
let evAt = 0;
function tossHalf(k, t) { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k) { return k.h0 + k.hr * k.T; }
function tossXY(k, t) {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(半回転の数 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k, t) {
const q = t - (k.t0 + k.T);
if (q <= 0) {
return tossHalf(k, t);
}
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k, t) {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h) { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 偶数 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face, rng, minPairs) {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 表(0)과 裏(1) — 값이 p 보다 작으면 表
function outcomes(seed, n, p) {
const rng = seeded(seed);
const out = [];
for (let i = 0; i < n; i += 1) {
out.push(rng() < p ? 0 : 1);
}
return out;
}
// 가장 긴 연속 길이
function longestRun(seq) {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) {
best = cur;
}
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x, y, rx, ry, c, a) {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) {
return;
}
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face, R, a) {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) {
ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
else {
ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr);
}
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
}
else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) {
disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a);
}
}
}
function coinShadow(cx, gy, R, lift, a) {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx, cy, R, th, thick, lx, ly, lz, a, startFace) {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) {
continue;
}
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx, cy, R, thick, a) {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) {
lap += 1;
begin();
return;
}
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
const DT_MAX = 0.05;
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest() {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart() {
lap += 1;
doneSaid = false;
if (still) {
showRest();
return;
}
begin();
showNow();
if (!paused) {
startLoop();
}
}
// 一歩進める — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function () {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 09 두 선택지를 걸고 던지는 탁자 결정 히어로 ─────────────────────────────────────
// 탁자 위에 선택지 둘. 동전이 날아 착지하면 위로 온 면(半回転の数 짝수면 表, 홀수면 裏)에 맞는 선택지가 밝아지고 tally 가 쌓인다
const SEED = 9001;
const AIR_T = 1.0;
const TOSS_GAP = 2.2;
const TOSS_COUNT = 4;
const GROUND_Y = 206;
const LAP_S = 9.4;
const START_S = 0.0;
const REST_S = 1.9;
const RESULT_S = 1.8;
let tosses = [];
let faces = [];
function build() {
const rng = seeded(SEED + lap * 31);
tosses = [];
faces = [];
for (let k = 0; k < TOSS_COUNT; k += 1) {
const f = rng() < 0.5 ? 0 : 1;
faces.push(f);
const h = halfFor(f, rng, 6);
tosses.push({ t0: 0.2 + TOSS_GAP * k, T: AIR_T, x0: 262, y0: 214, x1: 344 + (k % 2) * 36, y1: GROUND_Y, peak: 120, hr: h / AIR_T, h0: 0 });
}
}
function update(dt) { if (dt < 0) {
return;
} }
function landedCount() {
let c = 0;
for (let i = 0; i < tosses.length; i += 1) {
if (sceneT >= tosses[i].t0 + tosses[i].T + 0.25) {
c += 1;
}
}
return c;
}
function tally(face) {
const n = landedCount();
let c = 0;
for (let i = 0; i < n; i += 1) {
if (upFace(landHalf(tosses[i])) === face) {
c += 1;
}
}
return c;
}
function curK() {
let k = 0;
for (let i = 0; i < tosses.length; i += 1) {
if (sceneT >= tosses[i].t0) {
k = i;
}
}
return k;
}
function card(y, face, label, lit) {
box(20, y, 190, 62, 14, lit ? MINT : PAPER, 1);
outline(20, y, 190, 62, 14, lit ? 3 : 1.5, lit ? MINT_DEEP : LAV, 1);
ctx.save();
ctx.translate(52, y + 31);
faceArt(face, 20, 1);
ctx.restore();
text(label, 84, y + 31, INK, 1, 15, 800, 'left');
}
function draw() {
skyFill(BG_TOP, BG_BOT);
tableBack(GROUND_Y + 14);
const k = curK();
const e = tosses[k];
const landed = sceneT >= e.t0 + e.T + 0.1;
const up = upFace(landHalf(e));
card(66, 0, '読書', landed && up === 0);
card(136, 1, '散歩', landed && up === 1);
// 던진 동전
const p = tossXY(e, sceneT);
const lift = hopLift(e, sceneT);
coinShadow(p[0], GROUND_Y + 22, 38, GROUND_Y - p[1] + lift, 1);
if (sceneT >= e.t0 - 0.1) {
drawCoin(p[0], p[1] - lift, 38, PI * halfAt(e, sceneT), 7, 0.36, -0.46, 0.81, 1, 0);
}
const live = tossHalf(e, sceneT);
tag(landed ? '半回転 {h} → {p}'.replace('{h}', String(Math.round(landHalf(e)))).replace('{p}', Math.round(landHalf(e)) % 2 === 0 ? '偶数' : '奇数') : '半回転 {h}'.replace('{h}', live.toFixed(1)), 392, 80, PAPER, INK, 12, 'center');
const a = tally(0);
const b = tally(1);
tag('読書 {a}・散歩 {b}'.replace('{a}', String(a)).replace('{b}', String(b)), 115, 232, PAPER, INK, 12, 'center');
const n = a + b;
tag('表の割合 {r}'.replace('{r}', n > 0 ? (a / n).toFixed(2) : '0.00'), 115, 258, MINT, INK, 12, 'center');
}
function statusText() {
const k = curK();
const e = tosses[k];
const a = tally(0);
const b = tally(1);
if (sceneT >= e.t0 + e.T + 0.1) {
return '投げ {n}・{f}・読書 {a} 散歩 {b}'.replace('{n}', String(k + 1)).replace('{f}', upFace(landHalf(e)) === 0 ? '表なので読書' : '裏なので散歩').replace('{a}', String(a)).replace('{b}', String(b));
}
return '投げ {n}・回転中・読書 {a} 散歩 {b}'.replace('{n}', String(k + 1)).replace('{a}', String(a)).replace('{b}', String(b));
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// HeroDecisionTable - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./HeroDecisionTable.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function HeroDecisionTable() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const PI = Math.PI;
const TAU = Math.PI * 2;
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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 수열
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 앞면은 금빛, 뒷면은 하늘빛, 치우침·경고는 분홍
const BG_TOP: RGB = [250, 246, 252];
const BG_BOT: RGB = [228, 234, 250];
const TABLE_TOP: RGB = [240, 226, 238];
const TABLE_BOT: RGB = [222, 208, 236];
const GOLD: RGB = [250, 226, 150];
const GOLD_DEEP: RGB = [196, 150, 52];
const SKY: RGB = [176, 210, 246];
const SKY_DEEP: RGB = [74, 120, 176];
const RIM_LO: RGB = [184, 156, 100];
const RIM_HI: RGB = [255, 248, 222];
const ROSE: RGB = [240, 150, 176];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [150, 214, 190];
const MINT_DEEP: RGB = [22, 112, 82];
const LAV: RGB = [188, 172, 236];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
function mix(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)]; }
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) + ')';
}
// 장면 시계(초)·판 반복 번호·시간표 — 시간표는 (시각, 할 일) 목록이고 시각이 지나면 한 번씩 부른다
let sceneT = 0;
let lap = 0;
type Ev = { t: number; f: () => void };
let script: Ev[] = [];
let evAt = 0;
// ── 던지기 물리 ───────────────────────────────────────────
// 한 번의 던지기: 출발 (x0, y0) → 도착 (x1, y1), 체공 T 초, 꼭대기 peak 픽셀, 초당 반 바퀴 수 hr, 시작 반 바퀴 수 h0.
// 위치는 포물선(중력만), 각도는 반 바퀴 수 × π — 체공 중 회전 속도는 일정하다
type Toss = { t0: number; T: number; x0: number; y0: number; x1: number; y1: number; peak: number; hr: number; h0: number };
function tossHalf(k: Toss, t: number): number { return k.h0 + k.hr * clamp(t - k.t0, 0, k.T); }
function landHalf(k: Toss): number { return k.h0 + k.hr * k.T; }
function tossXY(k: Toss, t: number): [number, number] {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 착지한 뒤 — 가장 가까운 평평한 각(반 바퀴 수 반올림)으로 감쇠 진동하며 눕는다
function halfAt(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
if (q <= 0) { return tossHalf(k, t); }
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k: Toss, t: number): number {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
function upFace(h: number): number { return Math.abs(Math.round(h)) % 2; }
// 원하는 면이 나오도록 반 바퀴 수를 고른다 — 짝수 + 면 번호, 소수 부분은 ±0.2 안에서 흔든다
function halfFor(face: number, rng: () => number, minPairs: number): number {
return 2 * (minPairs + Math.floor(rng() * 3)) + face + (rng() - 0.5) * 0.4;
}
// 같은 수열에서 앞면(0)과 뒷면(1) — 값이 p 보다 작으면 앞면
function outcomes(seed: number, n: number, p: number): number[] {
const rng = seeded(seed);
const out: number[] = [];
for (let i = 0; i < n; i += 1) { out.push(rng() < p ? 0 : 1); }
return out;
}
// 가장 긴 연속 길이
function longestRun(seq: number[]): number {
let best = 0;
let cur = 0;
for (let i = 0; i < seq.length; i += 1) {
cur = (i > 0 && seq[i] === seq[i - 1]) ? cur + 1 : 1;
if (cur > best) { best = cur; }
}
return best;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function oval(x: number, y: number, rx: number, ry: number, c: RGB, a: number): void {
if (rx <= 0.2 || ry <= 0.1 || a <= 0) { return; }
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 탁자 — 위쪽 가장자리 높이 y 부터 아래까지
function tableBack(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(TABLE_TOP, 1));
g.addColorStop(1, rgba(TABLE_BOT, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
seg(0, y + 0.5, W, y + 0.5, 1.5, [255, 255, 255], 0.7);
}
// ── 동전 그리기 ───────────────────────────────────────────
// 동전은 원판이다. 가로축으로 th 만큼 돌면 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원으로 보이고,
// cos th 의 부호가 음수가 되는 순간 보이는 면이 바뀐다(앞/뒤 판정은 법선의 부호 하나).
// 옆면은 둘레를 RIM_N 조각으로 나눠, 조각 법선과 빛 방향(lx, ly, lz)의 내적으로 밝기를 정한다
const RIM_N = 40;
function faceArt(face: number, R: number, a: number): void {
const base = face === 0 ? GOLD : SKY;
const deep = face === 0 ? GOLD_DEEP : SKY_DEEP;
disc(0, 0, R, base, a);
ring(0, 0, R * 0.84, R * 0.07, deep, a);
for (let k = 0; k < 14; k += 1) {
disc(Math.cos(k * TAU / 14) * R * 0.93, Math.sin(k * TAU / 14) * R * 0.93, R * 0.03, deep, a * 0.8);
}
if (face === 0) {
// 별 — 바깥 반지름 0.5R, 안쪽 0.2R
ctx.beginPath();
for (let k = 0; k < 10; k += 1) {
const rr = (k % 2 === 0 ? 0.5 : 0.21) * R;
const an = -PI / 2 + k * PI / 5;
if (k === 0) { ctx.moveTo(Math.cos(an) * rr, Math.sin(an) * rr); } else { ctx.lineTo(Math.cos(an) * rr, Math.sin(an) * rr); }
}
ctx.closePath();
ctx.fillStyle = rgba(deep, a);
ctx.fill();
} else {
// 초승달과 점 세 개
disc(-R * 0.05, -R * 0.08, R * 0.44, deep, a);
disc(R * 0.14, -R * 0.14, R * 0.38, base, a);
for (let k = 0; k < 3; k += 1) { disc((k - 1) * R * 0.24, R * 0.52, R * 0.06, deep, a); }
}
}
function coinShadow(cx: number, gy: number, R: number, lift: number, a: number): void {
const k = clamp(lift / 260, 0, 0.75);
oval(cx, gy, R * (1.02 - k * 0.5), R * (1.02 - k * 0.5) * 0.26, INK, a * 0.26 * (1 - k));
}
function drawCoin(cx: number, cy: number, R: number, th: number, thick: number, lx: number, ly: number, lz: number, a: number, startFace: number): void {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
oval(cx, cy + far, R, R * ac, RIM_LO, a);
for (let k = 0; k < RIM_N; k += 1) {
const p0 = k * TAU / RIM_N;
const p1 = (k + 1) * TAU / RIM_N;
const pm = (p0 + p1) / 2;
if (Math.sin(pm) * s <= 0) { continue; }
const dot = Math.cos(pm) * lx + Math.sin(pm) * c * ly + Math.sin(pm) * s * lz;
const col = mix(RIM_LO, RIM_HI, clamp(0.5 + 0.5 * dot, 0, 1));
const x0 = cx + R * Math.cos(p0);
const x1 = cx + R * Math.cos(p1);
const y0 = R * Math.sin(p0) * c;
const y1 = R * Math.sin(p1) * c;
ctx.beginPath();
ctx.moveTo(x0, cy + near + y0);
ctx.lineTo(x1, cy + near + y1);
ctx.lineTo(x1, cy + far + y1);
ctx.lineTo(x0, cy + far + y0);
ctx.closePath();
ctx.fillStyle = rgba(col, a);
ctx.fill();
ctx.lineWidth = 0.8;
ctx.strokeStyle = rgba(col, a);
ctx.stroke();
}
}
ctx.save();
ctx.translate(cx, cy + near);
ctx.scale(1, ac);
faceArt(nearFace, R, a);
// 빛 쪽으로 쏠린 윤기
disc(lx * R * 0.3, -ly * R * 0.3 - R * 0.18, R * 0.3, [255, 255, 255], 0.22 * a);
ctx.restore();
}
// 서 있는 동전 — 가장자리가 위를 향해 옆에서 본 모습(너비 2R, 높이 thick)
function drawEdge(cx: number, cy: number, R: number, thick: number, a: number): void {
const g = ctx.createLinearGradient(0, cy - thick / 2, 0, cy + thick / 2);
g.addColorStop(0, rgba(RIM_HI, a));
g.addColorStop(1, rgba(RIM_LO, a));
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(cx - R, cy - thick / 2, R * 2, thick, Math.min(thick / 2, 4));
ctx.fill();
}
// ── 공통 틀 ───────────────────────────────────────────────
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
while (evAt < script.length && script[evAt].t <= sceneT) {
const ev = script[evAt];
evAt += 1;
ev.f();
}
if (sceneT >= LAP_S) { lap += 1; begin(); return; }
update(dt);
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 동전이 이미 돌고 있는 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
evAt = 0;
script = [];
build();
if (lap === 0 && !still) { runTo(START_S); }
}
const DT_MAX = 0.05;
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
showNow();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 던진 결과 한 장. 회전 없이 결과 면이 나온 장면에서 멈춘다
function showRest(): void {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
// 던지기 — 새 수열로 처음부터 다시. 움직임 줄이기에서는 새 결과 한 장
function restart(): void {
lap += 1;
doneSaid = false;
if (still) { showRest(); return; }
begin();
showNow();
if (!paused) { startLoop(); }
}
// 한 걸음 — 멈춘 채 시간표의 다음 일 하나까지 시계를 보낸다. 더 부를 일이 없으면 다음 던지기를 연다
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
if (evAt >= script.length) {
restart();
return;
}
runTo(script[evAt].t + 0.05);
showNow();
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. Enter·Space 는 던지기, P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
// 마우스·터치 — 무대를 누르면 던진다
pad.addEventListener('click', function (): void {
wake();
restart();
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 결과 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 09 두 선택지를 걸고 던지는 탁자 결정 히어로 ─────────────────────────────────────
// 탁자 위에 선택지 둘. 동전이 날아 착지하면 위로 온 면(반 바퀴 수 짝수면 앞면, 홀수면 뒷면)에 맞는 선택지가 밝아지고 tally 가 쌓인다
const SEED = 9001;
const AIR_T = 1.0;
const TOSS_GAP = 2.2;
const TOSS_COUNT = 4;
const GROUND_Y = 206;
const LAP_S = 9.4;
const START_S = 0.0;
const REST_S = 1.9;
const RESULT_S = 1.8;
let tosses: Toss[] = [];
let faces: number[] = [];
function build(): void {
const rng = seeded(SEED + lap * 31);
tosses = [];
faces = [];
for (let k = 0; k < TOSS_COUNT; k += 1) {
const f = rng() < 0.5 ? 0 : 1;
faces.push(f);
const h = halfFor(f, rng, 6);
tosses.push({ t0: 0.2 + TOSS_GAP * k, T: AIR_T, x0: 262, y0: 214, x1: 344 + (k % 2) * 36, y1: GROUND_Y, peak: 120, hr: h / AIR_T, h0: 0 });
}
}
function update(dt: number): void { if (dt < 0) { return; } }
function landedCount(): number {
let c = 0;
for (let i = 0; i < tosses.length; i += 1) { if (sceneT >= tosses[i].t0 + tosses[i].T + 0.25) { c += 1; } }
return c;
}
function tally(face: number): number {
const n = landedCount();
let c = 0;
for (let i = 0; i < n; i += 1) { if (upFace(landHalf(tosses[i])) === face) { c += 1; } }
return c;
}
function curK(): number {
let k = 0;
for (let i = 0; i < tosses.length; i += 1) { if (sceneT >= tosses[i].t0) { k = i; } }
return k;
}
function card(y: number, face: number, label: string, lit: boolean): void {
box(20, y, 190, 62, 14, lit ? MINT : PAPER, 1);
outline(20, y, 190, 62, 14, lit ? 3 : 1.5, lit ? MINT_DEEP : LAV, 1);
ctx.save();
ctx.translate(52, y + 31);
faceArt(face, 20, 1);
ctx.restore();
text(label, 84, y + 31, INK, 1, 15, 800, 'left');
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
tableBack(GROUND_Y + 14);
const k = curK();
const e = tosses[k];
const landed = sceneT >= e.t0 + e.T + 0.1;
const up = upFace(landHalf(e));
card(66, 0, '책 읽기', landed && up === 0);
card(136, 1, '산책하기', landed && up === 1);
// 던진 동전
const p = tossXY(e, sceneT);
const lift = hopLift(e, sceneT);
coinShadow(p[0], GROUND_Y + 22, 38, GROUND_Y - p[1] + lift, 1);
if (sceneT >= e.t0 - 0.1) { drawCoin(p[0], p[1] - lift, 38, PI * halfAt(e, sceneT), 7, 0.36, -0.46, 0.81, 1, 0); }
const live = tossHalf(e, sceneT);
tag(landed ? '반 바퀴 {h} → {p}'.replace('{h}', String(Math.round(landHalf(e)))).replace('{p}', Math.round(landHalf(e)) % 2 === 0 ? '짝수' : '홀수') : '반 바퀴 {h}'.replace('{h}', live.toFixed(1)), 392, 80, PAPER, INK, 12, 'center');
const a = tally(0);
const b = tally(1);
tag('책 읽기 {a} · 산책하기 {b}'.replace('{a}', String(a)).replace('{b}', String(b)), 115, 232, PAPER, INK, 12, 'center');
const n = a + b;
tag('앞면 비율 {r}'.replace('{r}', n > 0 ? (a / n).toFixed(2) : '0.00'), 115, 258, MINT, INK, 12, 'center');
}
function statusText(): string {
const k = curK();
const e = tosses[k];
const a = tally(0);
const b = tally(1);
if (sceneT >= e.t0 + e.T + 0.1) {
return '던지기 {n} · {f} · 책 읽기 {a} 산책하기 {b}'.replace('{n}', String(k + 1)).replace('{f}', upFace(landHalf(e)) === 0 ? '앞면이라 책 읽기' : '뒷면이라 산책하기').replace('{a}', String(a)).replace('{b}', String(b));
}
return '던지기 {n} · 도는 중 · 책 읽기 {a} 산책하기 {b}'.replace('{n}', String(k + 1)).replace('{a}', String(a)).replace('{b}', String(b));
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'동전 던지기 시연 무대 — Enter 나 Space 나 클릭으로 던지기, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'탁자 위에 선택지 둘, 책 읽기와 산책하기. 동전이 포물선으로 날아 착지하면 위로 온 면에 맞는 선택지가 밝아지고 아래에 선택지별 횟수와 앞면 비율이 쌓인다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'동전 던지기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}09 · 二つの選択肢を掛けて投げる机の決定ヒーロー — 計測 累積面積 20.299% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function build() {
const rng = seeded(SEED + lap * 31);
tosses = [];
faces = [];
for (let k = 0; k < TOSS_COUNT; k += 1) {
const f = rng() < 0.5 ? 0 : 1;
faces.push(f);
const h = halfFor(f, rng, 6);
tosses.push({ t0: 0.2 + TOSS_GAP * k, T: AIR_T, x0: 262, y0: 214, x1: 344 + (k % 2) * 36, y1: GROUND_Y, peak: 120, hr: h / AIR_T, h0: 0 });
}
}
コイントスのコードを書くと、コインはいくつの値で動くのでしょうか?
コイン一枚の形と面を決める値は四つです
drawCoin の引数は十個で、そのうち回転角 th、厚み thick、光の向き lx·ly·lz、始まりの面 startFace の四つが形と面を決めます。上の円盤は縦の半径が R·|cos th| の楕円で、cos th が正なら始まりの面、負なら反対の面を描きます。側面は周りの40の部分のうち sin(pm) * s が正のものだけを塗ります。
function drawCoin(cx, cy, R, th, thick, lx, ly, lz, a, startFace) {
const c = Math.cos(th);
const s = Math.sin(th);
const sg = c >= 0 ? 1 : -1;
const ac = Math.max(Math.abs(c), 0.012);
const half = thick / 2;
const near = -sg * s * half;
const far = -near;
const nearFace = sg > 0 ? startFace : 1 - startFace;
if (thick > 0) {
| 値 | 意味 |
|---|---|
th |
回転角。形と面がここから出ます |
thick |
側面の厚み。0なら側面の部分がありません |
lx·ly·lz |
光の向き。部分の明るさの内積に使います |
startFace |
角が0のとき見える面。0が表です |
最初の画面に置くとき
コイントス サイトに頼らず自分のページの最初の画面に置くなら、09の二つの選択肢のカードが決定の手伝いになります。カードの文字を替えるだけで、昼ごはんや発表の順番のような二つの道から選ぶ場面に使え、面を決める計算は halfFor と upFace の二つの関数に集まっています。この編の03は半回転の数の偶奇で二つの面を分け、サイコロの編は回転行列で立方体の上の面を読む点が違います。
確率の授業で使うとき
授業なら05の帯がまず使われます。投げる回数を100から400にして一ラウンドの時間も延ばすと、帯が半分の幅へ狭まる様子が見え、06は同じ数列で二枚のコインを並べてかたよりが割合にどれだけ出るかを比べさせます。07は人が作った数列と投げた数列を並べ、いちばん長い連続を探させる課題に使えます。
投げる強さを決め、滞空時間を求め、回転数から着地の角を読み、上になった面を決めるまで — コイントスが一回計算される順番です
一回の投げの計算の順番
まず Toss のオブジェクトに出発点・到着点・滞空時間 T・頂点の高さ peak・毎秒の半回転の数 hr を決めます。位置は tossXY が放物線で、角は tossHalf が時間に比例して増やし、着地のあとは halfAt が最も近い整数の半回転へ減衰しながら振動して横にします。最後に upFace が四捨五入した半回転の数の偶奇で上になった面を決めます。
function tossXY(k, t) {
const u = clamp((t - k.t0) / k.T, 0, 1);
return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
}
// 着地のあと — いちばん近い平らな角(半回転の数の四捨五入)へ減衰しながら振動して横になる
function halfAt(k, t) {
const q = t - (k.t0 + k.T);
if (q <= 0) {
return tossHalf(k, t);
}
const hl = landHalf(k);
const hf = Math.round(hl);
return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
}
function hopLift(k, t) {
const q = t - (k.t0 + k.T);
return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
}
// 上になった面 — 半回転の数を四捨五入して偶数なら投げる前の面(0)、奇数なら反対の面(1)
function upFace(h) { return Math.abs(Math.round(h)) % 2; }
一ラウンドがくり返されるときシードが替わる
一ラウンドは LAP_S が過ぎると lap が1上がり、build() が新しい投げと数列を作りますが、04・05・06・07ではシードが SEED + lap に替わります。デモが同じ結果ばかりをくり返さないのはこのシードのおかげで、人がEnter・Space・クリックで投げると restart() が lap を上げて新しいラウンドを開きます。最初のラウンドは START_S だけ先から始まるので、04・06・07は開いた瞬間にもう積み上がった姿で見えます。
コイントスが壊れるのは、面が替わっても模様が同じままのコイン・着地の角が半回転の境目でゆらぐ判定・厚みが0でふちに立てないコインです
面が替わっても模様が同じままのコイン
01の右のコインは scale(1, |cos θ|) だけをかけて faceArt(0, sr, 1) で表の模様ばかりを描くので、いちばん細くなる瞬間にも裏が出ず、平たいシールのように見えます。面の入れ替えをcosθの符号で決める一行が抜けると、コインではなく平たいシールの絵になります。
// 右の見比べ版 — 面を替えず表の模様だけを描く。いちばん細いときも模様が同じで不自然
const sx = 372;
const sy = 160;
const sr = 42;
coinShadow(sx, 240, sr, 0, 1);
ctx.save();
ctx.translate(sx, sy);
ctx.scale(1, Math.max(Math.abs(COS_SQUASH), 0.012));
faceArt(0, sr, 1);
ctx.restore();
着地の角が半回転の境目でゆらぐ判定
四捨五入の境目は、半回転の数の小数部分が0.5になるところです。03の最初の投げの12.6は境目の12.5から0.1しか離れておらず、SPIN_RATE が7から8に上がるだけで14.4になって面が替わります。09は halfFor が整数の半回転に±0.2しか振らないので境目から0.3以上離れ、判定がゆらぎません。
厚みが0でふちに立てないコイン
04の landKind は thick > 0 のときだけふちを判定し、EDGE_WIN(0) も0なので窓がありません。04の厚み0のコインが100回投げても立たないのはこのためで、厚みを8にすると同じ数列で8回立ちます。立つ回数を増やしたいなら、直径を縮めるか厚みを増やして窓を広げます。
コイントス九場面の完成ソースとReact版のファイルは下のzipに入っていて、展開に使うパスワードはjfztqwghです。
投げる回数とおもりのかたよりと回転の速さを変えると結果の割合はどれだけ変わるのでしょうか — 回数・かたより・速さを変えて比べます
| 名前 | 変えた値 | 感じ |
|---|---|---|
| 投げる回数を増やして | 05 TOSS_N 100 → 400 |
帯の半幅 1/(2√n) が0.050から0.025へ半分になり、シード5055の数列の表の割合は100回目の0.560(半分から0.060離れて帯の外)から400回目の0.4925(0.0075の差で帯の中)へ落ち着きます。帯の外の点の数は累積なので7個から25個に増えます。ただし TOSS_GAP が0.07なので400回目は28秒ごろに描かれ、画面で見るには LAP_S・RESULT_S・REST_S もいっしょに増やす必要があり、そのままだと一ラウンドで136回目までしか出ません |
| 重さをもっとかたよらせて | 06 BIAS_OFFSET 0.10 → 0.25 |
headsP が0.55から0.625へ上がり、シード6056の数列100回で表が58回から66回に増え、ふつうのコインは51回のままです |
| もっと速く回して | 03 SPIN_RATE 7 → 8 |
滞空0.9秒のあいだに最初の投げの半回転の数が12.6から14.4になり、四捨五入が13(奇数)から14(偶数)に変わって、上になる面が裏から表へ入れ替わります |
値を変える前に見ること
三つの値は別々の場面の定数なので、一つの場面で一つずつだけ変えると比べがきれいです。05と06はシードが固定で同じ値なら同じ数が出ますが、03の SPIN_RATE は投げるたびに SPIN_STEP 0.6ずつ上がるので、最初の投げだけを比べの基準にします。
動きを減らす設定では結果の一枚です — 表裏の結果を文で読み上げ、キーボードでもう一度投げるアクセシビリティです
Enter・Spaceで投げてP・N・Rで操作する
九つの場面ともステージの箱が role="application" で、aria-label に操作が書いてあります。Enter・Space・クリックは新しいシードでの投げ直し、Pは一時停止、Nは一歩進めるキーですが、九つの項目とも時間表の script に項目がないので stepOnce は restart へ行き、止まったまま次の投げの最初の場面を開くだけです。Rは最初からやり直しで、キャンバスは role="img" の aria-label が場面を文で説明します。
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
wake();
restart();
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
wake();
restart();
}
}, { signal: off.signal });
状態の行はデモのあいだ静かです
状態の行はデモ中 aria-live="off" なので、コインが回っているあいだ文が流れ込まず、人が最初に触れた瞬間に wake() が polite に替えます。一ラウンドの結果の文は role="status" の data-role="done" の段落に、場面ごとに決めた RESULT_S 秒に一度だけ書き、restart() が doneSaid を解いて新しいラウンドの結果をもう一度知らせます。
// 一ラウンドのデモが終わったことをスクリーンリーダーに一度だけ知らせる — ラウンドがくり返されても読み直さない
let doneSaid = false;
function checkDone() {
if (!doneSaid && sceneT >= RESULT_S) {
doneSaid = true;
doneP.textContent = statusText();
}
}
動きを減らす設定と隠れたタブ
prefers-reduced-motion が有効なら、ループなしで showRest が REST_S 秒の地点まで時間表をまわして結果の一枚を描きます。01と02は回転なしで角を固定し(01は表の正面、02はθ = 1.15)、03・09は最初の投げが着地した場面、04・05・06・07・08は投げた結果が積み上がった場面で止まります。REST_S は03・09が1.9秒、08が3.6秒、07が5.6秒、04が6.0秒、05が8.6秒、06が11.0秒です。タブが隠れるとループを止め、戻ったときの最初のフレームの時間差を捨てます。
function showRest() {
stopLoop();
sceneT = 0;
evAt = 0;
script = [];
build();
runTo(REST_S);
doneSaid = false;
showNow();
}
文字は濃いインク (40, 36, 54) の上で、ピル形ラベルの地とのコントラストが金色11.7、空色9.6、ミント9.1、ピンク6.9、紙色14.0対1です。
コイントスのよくある質問 — ライブラリなしで作る方法・コイントスの確率が本当に半分かどうか・コイントスのサイトの代わりに自分で作るときに必要なもの
コイントス 確率が本当に半分かどうかはこのコードでわかりますか
わかりません。05はシード5055で固定した数列が100回目の0.560から400回目の0.4925へ半分に近づく形を見せる説明用で、本物のコインの公平さを判定する検定はしません。
コイントスにライブラリは必要ですか
必要ありません。キャンバス2Dだけで描かれ、九つの場面のソースにライブラリ・WebGL・CDNが0件で、乱数の呼び出しも0件なので同じシードは同じ結果を出します。
自分で作って抽選に使うには何を足せばよいですか
シードを毎回新しく受け取る部分です。このデモはデモが再現されるようにシードをソースに固定しているので、抽選に使うならシードをユーザーが書き換えられない値で決めるコードを足す必要があり、この編はその部分を作っていません。
参考として、MDNのellipseの文書とrequestAnimationFrameの文書、基本のアニメーションのチュートリアル、動きを減らす設定はprefers-reduced-motionの文書が基準です。回る物はこまの編を、回って止まる盤はルーレットの編を続けて見るとよいです。









