サイコロ アニメーション 9パターン — 回転行列で上の目を読む3D場面
投げる、跳ねる、止まる — 一回の転がりは三つの動詞だけで、03番は最後の動詞で上の目の数を計算で読み取る。以下の九つは、サイコロ アニメーションをキャンバスの2Dで一歩ずつ計算し直して描いた場面で、zipを開けば追加の設定なしでそのまま動きます。
九つのサイコロの場面をこのページで転がして確かめる
01 spin-dice-scene-cube-rotation-projection
02 spin-dice-scene-throw-arc-bounce
03 spin-dice-scene-settle-top-face-read
04 spin-dice-scene-seeded-sequence
05 spin-dice-scene-two-dice-sum-bars
06 spin-dice-scene-dust-trail-floor
07 spin-dice-scene-cup-shake-noise
08 spin-dice-scene-flat-face-tumble
09 spin-dice-scene-hero-table
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 回転行列で回す3Dサイコロ
- 02 放物線で投げて床で跳ねるサイコロ
- 03 止まりながら上の目を読むサイコロ
- 04 シードで同じ順番を繰り返すサイコロ
- 05 二つのサイコロの合計を棒に積む場面
- 06 転がった跡とほこりが床に残るサイコロ
- 07 カップの中で振られるサイコロ
- 08 平らな面の切り替えで転がる2Dサイコロ
- 09 ヒーロー背景のサイコロテーブル
サイコロ アニメーション 9パターン — 回転行列ひとつが上の目まで決める
サイコロ一つの向きは3×3の回転行列 R 一枚で決まり、08番を除く八つの場面はすべてこの行列の上で動いています。01番は行列を宙で回し続けて見える面を選び、02番は同じ行列に重力と床の衝突を足し、03番は止まるあいだに行列を上の目の側へゆっくり起こします。04番は投げる値をシードの数列から引き、05番は二つのサイコロの目を足して積みます。
06番は床に当たった場所ごとにほこりと跡を残し、07番はカップの中で行列にノイズのずれを足してがたがた鳴らします。08番だけは3D計算を使わず、CSSで面を一枚平らに潰して転がったように見せ、09番が三つのサイコロを一つの盤にまとめた場面です。サイコロ 転がる アニメーションが必要な画面のほとんどは、この九つのどれかから始められるように順番を組みました。
手を離れた瞬間から目が決まる瞬間までの一回の転がりを計算でつないであるので、サイコロの振りを試す画面を作るときも、01番の投影と03番の上の目の読み取りはそのまま持っていけます。近い物理の場面を知りたいときは、跳ねるボール アニメーション 9選 — 衝突と重力の物理計算とコマ 回転 9パターン — 角速度と歳差で描く回転シーンを続けて見てください。
技法別に見るサイコロ回転のコード
コードは各場面の index.html からつながった行をそのまま切り出したもので、描画関数よりも場面ごとに違う計算がある場所を選びました。完全版はzipの中にあります。
01回転行列で回す3Dサイコロ
rotAxis() で作った回転行列を matVec() で八つの頂点に掛けて遠近投影し、法線と視線の内積が0より小さい面だけを塗ってカメラに背を向けた裏側を除きます。宙を漂うサイコロは DEMO_KICK_MS の1500msごとに新しい軸で蹴られ、上の目は一歩ごとに法線yが最大の面として読まれて状態行に出ます。計測では累積面積13.8%・平均強度72.4で、23/23フレームが動きました。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" role="img" aria-label="立方体の八つの頂点に回転行列を掛けて遠近投影で画面へ移し、法線と視線の内積でカメラに背を向けた面を除いて、目の入ったサイコロが宙で回る場面を描く。"></canvas>
<output class="bp__status" aria-live="polite"><span id="bpLabel">上の面</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">振る</button>
<p class="bp__caption">立方体の八つの頂点に回転行列を掛けて遠近投影で画面へ移し、法線と視線の内積でカメラに背を向けた面を除いて、目の入ったサイコロが宙で回る場面を描く。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
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: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
display: flex;
align-items: baseline;
gap: $sp-2;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: $sp-3;
top: $sp-3;
min-height: $sp-8;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-instant $ease-out;
}
.bp__btn:hover { transform: translateY(-1px); }
.bp__btn:active { transform: translateY(1px); }
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .9);
outline-offset: 2px;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__btn { font-size: 11px; padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
.bp__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: 12px;
top: 12px;
min-height: 32px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 140ms ease-out;
}
.bp__btn:hover {
transform: translateY(-1px);
}
.bp__btn:active {
transform: translateY(1px);
}
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.9);
outline-offset: 2px;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
padding: 4px 8px;
}
.bp__btn {
font-size: 11px;
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
.bp__btn {
transition: none;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var labelEl = document.getElementById('bpLabel');
var numEl = document.getElementById('bpNum');
var btn = document.getElementById('bpBtn');
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var DICE_RESTITUTION = 0.45;
var SPIN_DAMPING = 0.985;
var DICE_SEED = 7;
var GRAVITY = 22;
var DIE_HALF = 1;
var INERTIA = 2 * DIE_HALF * DIE_HALF / 3;
var FLOOR_FRICTION = 0.35;
var SETTLE_AFTER_MS = 2000;
var SETTLE_RATE = 0.16;
var HOLD_MS = 700;
var CAM_Y = 5.6, CAM_BACK = 12, FOCAL = 275, VIEW_CX = 160, VIEW_CY = 82;
var TABLE_X = 4.4, TABLE_Z0 = -3, TABLE_Z1 = 5;
var PH_FLY = 0, PH_SETTLE = 1, PH_HOLD = 2, PH_IDLE = 3;
var STRIP_BASE = 128;
var BAR_MAX = 18;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function cross(a, b) { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function unit(a) { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function matMul(a, b) {
var o = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (var i = 0; i < 3; i += 1) {
for (var j = 0; j < 3; j += 1) o[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
}
return o;
}
function matVec(m, v) {
return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
}
// 로드리게스 공식: 단위 축 ax 둘레로 ang 만큼 도는 회전 행렬
function rotAxis(ax, ang) {
var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c];
}
function orthonormalize(m) {
var a = unit([m[0], m[1], m[2]]);
var k = a[0] * m[3] + a[1] * m[4] + a[2] * m[5];
var b = unit([m[3] - a[0] * k, m[4] - a[1] * k, m[5] - a[2] * k]);
var c = cross(a, b);
return [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]];
}
function randAxis(rnd) { return unit([rnd() * 2 - 1, rnd() * 2 - 1, rnd() * 2 - 1]); }
var PITCH = Math.atan2(CAM_Y, CAM_BACK);
var CAM_S = Math.sin(PITCH), CAM_C = Math.cos(PITCH);
function camPoint(p) {
var qy = p[1] - CAM_Y, qz = p[2] + CAM_BACK;
return [p[0], qy * CAM_C + qz * CAM_S, -qy * CAM_S + qz * CAM_C];
}
function camDir(v) { return [v[0], v[1] * CAM_C + v[2] * CAM_S, -v[1] * CAM_S + v[2] * CAM_C]; }
function scr(p) {
var c = camPoint(p);
var k = FOCAL / c[2];
return [VIEW_CX + c[0] * k, VIEW_CY - c[1] * k, k];
}
var FACE_N = [[0, 1, 0], [0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];
var FACE_U = [[1, 0, 0], [1, 0, 0], [0, 1, 0], [0, 1, 0], [1, 0, 0], [1, 0, 0]];
var FACE_V = [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]];
var FACE_VAL = [1, 6, 2, 5, 3, 4];
var PIPS = [[], [[0, 0]], [[-1, -1], [1, 1]], [[-1, -1], [0, 0], [1, 1]],
[[-1, -1], [1, -1], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
[[-1, -1], [-1, 0], [-1, 1], [1, -1], [1, 0], [1, 1]]];
var DICE_PAL = [
{ face: [255, 214, 226], edge: [240, 160, 186], pip: '#4b3a63' },
{ face: [200, 232, 255], edge: [140, 190, 236], pip: '#3b4a6b' },
{ face: [224, 212, 255], edge: [170, 150, 232], pip: '#46386b' },
{ face: [255, 240, 196], edge: [236, 206, 120], pip: '#5a4636' }
];
var LIGHT = unit([-0.45, 0.85, -0.35]);
function newDie(pal, xmin, xmax, zmin, zmax) {
return { p: [0, DIE_HALF, 0], v: [0, 0, 0], R: [1, 0, 0, 0, 1, 0, 0, 0, 1], w: [0, 0, 0], pal: pal, phase: PH_IDLE, t: 0, hold: 0,
bounces: 0, result: 0, top: 1, hit: 0, hx: 0, hz: 0, xmin: xmin, xmax: xmax, zmin: zmin, zmax: zmax };
}
function topIndex(d) {
var best = 0, by = -9;
for (var f = 0; f < 6; f += 1) {
var ny = d.R[3] * FACE_N[f][0] + d.R[4] * FACE_N[f][1] + d.R[5] * FACE_N[f][2];
if (ny > by) { by = ny; best = f; }
}
return best;
}
// 던지기: 시작 위치·속도·회전을 시드 수열에서 순서대로 뽑는다 (뽑는 순서가 곧 재현 규칙)
function launch(d, rnd, x, z, vx, vz, vy0, spin) {
d.p = [x, 2.4, z];
d.v = [vx + (rnd() - 0.5) * 1.6, vy0 + rnd() * 2.2, vz + (rnd() - 0.5) * 1.6];
var ax = randAxis(rnd);
var s = spin * (0.6 + rnd() * 0.8);
d.w = [ax[0] * s, ax[1] * s, ax[2] * s];
d.R = rotAxis(randAxis(rnd), rnd() * 6.283);
d.phase = PH_FLY; d.t = 0; d.hold = 0; d.bounces = 0; d.result = 0; d.hit = 0;
}
function spinStep(d, dt) {
var wl = Math.hypot(d.w[0], d.w[1], d.w[2]);
if (wl > 1e-5) d.R = matMul(rotAxis([d.w[0] / wl, d.w[1] / wl, d.w[2] / wl], wl * dt), d.R);
}
function floorStep(d) {
var mi = 0, my = 99;
var rx = 0, ry = 0, rz = 0;
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? DIE_HALF : -DIE_HALF, ly = (i & 2) ? DIE_HALF : -DIE_HALF, lz = (i & 4) ? DIE_HALF : -DIE_HALF;
var wy = d.R[3] * lx + d.R[4] * ly + d.R[5] * lz;
if (wy < my) { my = wy; mi = i; rx = d.R[0] * lx + d.R[1] * ly + d.R[2] * lz; ry = wy; rz = d.R[6] * lx + d.R[7] * ly + d.R[8] * lz; }
}
var low = d.p[1] + my;
if (low >= 0) return;
d.p[1] -= low;
var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
if (cvy >= 0) return;
var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
d.v[1] += j;
d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;
var tl = Math.hypot(cvx, cvz);
if (tl > 1e-4) {
var jt = Math.min(FLOOR_FRICTION * j, tl * 0.5);
var tx = -cvx / tl * jt, tz = -cvz / tl * jt;
d.v[0] += tx; d.v[2] += tz;
d.w[0] += ry * tz / INERTIA; d.w[1] += (rz * tx - rx * tz) / INERTIA; d.w[2] += -ry * tx / INERTIA;
}
if (cvy < -1.2) { d.bounces += 1; d.hit = -cvy; d.hx = d.p[0] + rx; d.hz = d.p[2] + rz; }
}
function wallStep(d) {
if (d.p[0] < d.xmin) { d.p[0] = d.xmin; d.v[0] = Math.abs(d.v[0]) * 0.5; }
if (d.p[0] > d.xmax) { d.p[0] = d.xmax; d.v[0] = -Math.abs(d.v[0]) * 0.5; }
if (d.p[2] < d.zmin) { d.p[2] = d.zmin; d.v[2] = Math.abs(d.v[2]) * 0.5; }
if (d.p[2] > d.zmax) { d.p[2] = d.zmax; d.v[2] = -Math.abs(d.v[2]) * 0.5; }
}
// 멈추기: 법선 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 정확히 위를 보도록 남은 각도의 SETTLE_RATE 만큼씩 돌린다
function settleStep(d) {
var k = topIndex(d);
d.top = FACE_VAL[k];
var nw = matVec(d.R, FACE_N[k]);
var ang = Math.acos(clamp(nw[1], -1, 1));
if (ang > 1e-4) {
var ax = unit(cross(nw, [0, 1, 0]));
d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
}
d.R = orthonormalize(matMul(rotAxis([0, 1, 0], d.w[1] * dtS), d.R));
d.w[1] *= 0.88;
d.v[0] *= 0.86; d.v[2] *= 0.86; d.v[1] = 0;
d.p[0] += d.v[0] * dtS; d.p[2] += d.v[2] * dtS;
d.p[1] += (DIE_HALF - d.p[1]) * 0.22;
wallStep(d);
if (ang < 0.004 && Math.abs(d.p[1] - DIE_HALF) < 0.01) {
d.p[1] = DIE_HALF;
var q = topIndex(d);
d.R = orthonormalize(matMul(rotAxis(unit(cross(matVec(d.R, FACE_N[q]), [0, 1, 0])), Math.acos(clamp(matVec(d.R, FACE_N[q])[1], -1, 1))), d.R));
d.phase = PH_HOLD; d.hold = 0; d.result = FACE_VAL[q]; d.top = d.result;
}
}
function stepDie(d) {
if (d.phase === PH_FLY) {
d.t += FIXED_DT;
d.v[1] -= GRAVITY * dtS;
d.p[0] += d.v[0] * dtS; d.p[1] += d.v[1] * dtS; d.p[2] += d.v[2] * dtS;
spinStep(d, dtS);
d.w[0] *= SPIN_DAMPING; d.w[1] *= SPIN_DAMPING; d.w[2] *= SPIN_DAMPING;
wallStep(d);
floorStep(d);
var sp = Math.hypot(d.v[0], d.v[1], d.v[2]);
if (d.t > SETTLE_AFTER_MS || (d.bounces >= 3 && sp < 1.6)) d.phase = PH_SETTLE;
} else if (d.phase === PH_SETTLE) {
d.t += FIXED_DT;
settleStep(d);
} else if (d.phase === PH_HOLD) {
d.hold += FIXED_DT;
}
}
function runUntilHold(d) {
for (var i = 0; i < 1200 && d.phase !== PH_HOLD; i += 1) stepDie(d);
return d.result;
}
// ── 그리기 ──
function rgb(c, k) {
return 'rgb(' + Math.min(255, Math.round(c[0] * k)) + ',' + Math.min(255, Math.round(c[1] * k)) + ',' + Math.min(255, Math.round(c[2] * k)) + ')';
}
function quad(c, u, v, s) {
var o = [];
var sg = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
for (var i = 0; i < 4; i += 1) {
o.push(scr([c[0] + (u[0] * sg[i][0] + v[0] * sg[i][1]) * s * DIE_HALF, c[1] + (u[1] * sg[i][0] + v[1] * sg[i][1]) * s * DIE_HALF, c[2] + (u[2] * sg[i][0] + v[2] * sg[i][1]) * s * DIE_HALF]));
}
return o;
}
function fillPoly(pts, color, edge) {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
if (edge > 0) { ctx.strokeStyle = color; ctx.lineWidth = edge; ctx.stroke(); }
}
function drawDie(d, hi, alpha) {
var pal = DICE_PAL[d.pal % DICE_PAL.length];
ctx.globalAlpha = alpha;
ctx.lineJoin = 'round';
for (var f = 0; f < 6; f += 1) {
var nw = matVec(d.R, FACE_N[f]);
var cw = [d.p[0] + nw[0] * DIE_HALF, d.p[1] + nw[1] * DIE_HALF, d.p[2] + nw[2] * DIE_HALF];
var cc = camPoint(cw), nc = camDir(nw);
if (nc[0] * cc[0] + nc[1] * cc[1] + nc[2] * cc[2] >= 0) continue;
var uw = matVec(d.R, FACE_U[f]), vw = matVec(d.R, FACE_V[f]);
var lit = 0.66 + 0.34 * Math.max(0, dot(nw, LIGHT));
var k0 = scr(cw)[2];
fillPoly(quad(cw, uw, vw, 1), rgb(pal.edge, lit), k0 * 0.1);
fillPoly(quad(cw, uw, vw, 0.84), rgb(pal.face, lit), k0 * 0.16);
if (f === hi) fillPoly(quad(cw, uw, vw, 0.84), 'rgba(255,255,255,0.4)', 0);
var val = FACE_VAL[f];
for (var pi = 0; pi < PIPS[val].length; pi += 1) {
var a = PIPS[val][pi][0] * 0.5 * DIE_HALF, b = PIPS[val][pi][1] * 0.5 * DIE_HALF;
var ring = [];
for (var j = 0; j < 10; j += 1) {
var an = j * 0.62832, rr = 0.17 * DIE_HALF;
var ca = Math.cos(an) * rr, sa = Math.sin(an) * rr;
ring.push(scr([cw[0] + uw[0] * (a + ca) + vw[0] * (b + sa) + nw[0] * 0.004, cw[1] + uw[1] * (a + ca) + vw[1] * (b + sa) + nw[1] * 0.004, cw[2] + uw[2] * (a + ca) + vw[2] * (b + sa) + nw[2] * 0.004]));
}
fillPoly(ring, pal.pip, 0);
}
}
ctx.globalAlpha = 1;
}
function drawShadow(d, alpha) {
var s = scr([d.p[0], 0, d.p[2]]);
var h = Math.max(0, d.p[1] - DIE_HALF);
var r = DIE_HALF * s[2] * (1.2 + h * 0.18);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.42);
ctx.beginPath();
ctx.arc(0, 0, r, 0, 6.2832);
ctx.fillStyle = 'rgba(30,60,60,' + (0.34 * alpha / (1 + h * 0.7)) + ')';
ctx.fill();
ctx.restore();
}
var FELT_DOTS = [];
(function () {
var fr = seedRand(99);
for (var i = 0; i < 70; i += 1) FELT_DOTS.push([(fr() * 2 - 1) * TABLE_X, TABLE_Z0 + fr() * (TABLE_Z1 - TABLE_Z0), 0.04 + fr() * 0.08]);
})();
function tablePts(m) {
return [scr([-TABLE_X - m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z1 + m]), scr([-TABLE_X - m, 0, TABLE_Z1 + m])];
}
function drawStage(lightX, lightK) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var bg = ctx.createRadialGradient(160, 80, 10, 160, 80, 210);
bg.addColorStop(0, '#3f3764');
bg.addColorStop(1, '#221d36');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
fillPoly(tablePts(0.5), '#8e80c8', 0);
var tp = tablePts(0);
var g = ctx.createLinearGradient(0, tp[0][1], 0, tp[2][1]);
g.addColorStop(0, '#9fdcc3');
g.addColorStop(1, '#78c8aa');
fillPoly(tp, g, 0);
for (var i = 0; i < FELT_DOTS.length; i += 1) {
var s = scr([FELT_DOTS[i][0], 0, FELT_DOTS[i][1]]);
ctx.beginPath();
ctx.arc(s[0], s[1], FELT_DOTS[i][2] * s[2], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,0.12)';
ctx.fill();
}
var lp = scr([lightX, 0, 1]);
var lg = ctx.createRadialGradient(lp[0], lp[1], 4, lp[0], lp[1], 120);
lg.addColorStop(0, 'rgba(255,255,255,' + (0.3 * lightK) + ')');
lg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.save();
ctx.beginPath();
ctx.moveTo(tp[0][0], tp[0][1]); ctx.lineTo(tp[1][0], tp[1][1]); ctx.lineTo(tp[2][0], tp[2][1]); ctx.lineTo(tp[3][0], tp[3][1]);
ctx.closePath();
ctx.clip();
ctx.fillStyle = lg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.restore();
}
function drawChip(x, y, s, value, pal, mark) {
var p = DICE_PAL[pal % DICE_PAL.length];
ctx.fillStyle = value > 0 ? rgb(p.face, 1) : 'rgba(255,255,255,0.18)';
ctx.strokeStyle = mark ? '#7fe3bd' : rgb(p.edge, 1);
ctx.lineWidth = mark ? 1.8 : 1;
ctx.fillRect(x, y, s, s);
ctx.strokeRect(x, y, s, s);
for (var i = 0; i < PIPS[value].length; i += 1) {
ctx.beginPath();
ctx.arc(x + s / 2 + PIPS[value][i][0] * s * 0.26, y + s / 2 + PIPS[value][i][1] * s * 0.26, s * 0.1, 0, 6.2832);
ctx.fillStyle = p.pip;
ctx.fill();
}
}
// ── 상태 줄 (aria-live) ──
var stLabel = '', stNum = '', stAt = -9999;
function setStatus(label, num, force) {
if (label === stLabel && num === stNum) return;
if (!force && label === stLabel && elapsed - stAt < 450) return;
stLabel = label; stNum = num; stAt = elapsed;
labelEl.textContent = label;
numEl.textContent = num;
}
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
// 기법: 여덟 꼭짓점에 회전 행렬 R 을 곱하고 원근 투영(sx = cx + f * x / z)으로 화면에 옮긴다.
// 보이는 면은 법선과 시선의 내적 부호로 고르고, 윗면은 법선 y 가 가장 큰 면이다.
var T_TOP = '上の面';
var SPIN_BASE = 7;
var DEMO_KICK_MS = 1500;
var die = newDie(0, -9, 9, -9, 9);
var rng = seedRand(DICE_SEED);
var kickAt = 0;
var axisCols = ['#ff9ebb', '#4cd4a6', '#6ec8ff'];
function sceneInit() { die.phase = PH_FLY; die.p = [0, 1.7, 0.4]; die.R = rotAxis(unit([0.4, 1, 0.3]), 0.7); }
function sceneRoll(manual) {
var ax = randAxis(rng);
var s = SPIN_BASE + rng() * 5;
die.w = [ax[0] * s, ax[1] * s, ax[2] * s];
kickAt = elapsed;
if (reduce) die.R = matMul(rotAxis(ax, 1.2 + rng() * 4.4), die.R);
}
function sceneDone() { return true; }
function sceneFinish() { die.top = FACE_VAL[topIndex(die)]; setStatus(T_TOP, String(die.top), true); }
function stepWorld() {
var t = elapsed / 1000;
die.p = [Math.sin(t * 0.9) * 2.8, 1.7 + Math.sin(t * 1.7) * 0.3, Math.cos(t * 0.9) * 1.3 + 0.4];
spinStep(die, dtS);
var damp = demo ? 0.996 : 0.985;
die.w = [die.w[0] * damp, die.w[1] * damp, die.w[2] * damp];
if (demo && elapsed - kickAt > DEMO_KICK_MS) sceneRoll(false);
die.top = FACE_VAL[topIndex(die)];
setStatus(T_TOP, String(die.top), false);
}
function drawWorld() {
drawStage(die.p[0], 0.9);
// 바닥 안내선: 주사위 중심에서 바닥 그림자까지
var a = scr(die.p), b = scr([die.p[0], 0, die.p[2]]);
ctx.setLineDash([2, 3]);
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
ctx.setLineDash([]);
drawShadow(die, 1);
drawDie(die, topIndex(die), 1);
// 회전 행렬의 세 열 = 주사위가 가진 세 축을 투영한 선
for (var c = 0; c < 3; c += 1) {
var e = [die.R[c], die.R[3 + c], die.R[6 + c]];
var q = scr([die.p[0] + e[0] * 2.1, die.p[1] + e[1] * 2.1, die.p[2] + e[2] * 2.1]);
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(q[0], q[1]);
ctx.strokeStyle = axisCols[c]; ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.stroke();
ctx.beginPath(); ctx.arc(q[0], q[1], 2.6, 0, 6.2832); ctx.fillStyle = axisCols[c]; ctx.fill();
}
// 여덟 꼭짓점 점 (가까울수록 크게)
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? 1 : -1, ly = (i & 2) ? 1 : -1, lz = (i & 4) ? 1 : -1;
var v = matVec(die.R, [lx, ly, lz]);
var s = scr([die.p[0] + v[0], die.p[1] + v[1], die.p[2] + v[2]]);
ctx.beginPath(); ctx.arc(s[0], s[1], s[2] * 0.045, 0, 6.2832); ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.fill();
}
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs = -1;
var acc = 0;
function tick(ts) {
if (lastTs < 0) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
// 모션 줄이기: 애니메이션 없이 결과가 나올 때까지 같은 계산을 한 번에 돌려 최종 장면만 그린다
function runToDone() {
var i = 0;
do { elapsed += FIXED_DT; stepWorld(); i += 1; } while (i < 1500 && !sceneDone());
sceneFinish();
drawWorld();
}
function onRollClick() {
stopDemo();
sceneRoll(true);
if (reduce) runToDone();
}
sceneInit();
sceneRoll(false);
if (reduce) {
runToDone();
} else {
for (var w0 = 0; w0 < 8; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
}
btn.addEventListener('click', onRollClick);
cleaners.push(function () { btn.removeEventListener('click', onRollClick); });
fit();
window.addEventListener('resize', fit);
cleaners.push(function () { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
})();import { useEffect, useRef } from "react";
import styles from "./SpinDiceSceneCubeRotationProjection.module.scss";
export default function SpinDiceSceneCubeRotationProjection() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const numRef = useRef<HTMLElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current, canvasEl = canvasRef.current, l0 = labelRef.current, n0 = numRef.current, b0 = btnRef.current;
if (!rootEl || !canvasEl || !l0 || !n0 || !b0) return;
const root: HTMLDivElement = rootEl;
const canvas: HTMLCanvasElement = canvasEl;
const ctx0 = canvas.getContext("2d");
if (!ctx0) return;
const ctx: CanvasRenderingContext2D = ctx0;
const wrap: HTMLElement = canvas.parentElement ?? root;
const labelEl: HTMLElement = l0;
const numEl: HTMLElement = n0;
const btn: HTMLButtonElement = b0;
type Vec = number[];
type Mat = number[];
type RandFn = () => number;
type Fn = () => void;
type Paint = string | CanvasGradient;
type Pal = { face: number[]; edge: number[]; pip: string };
type Die = { p: Vec; v: Vec; R: Mat; w: Vec; pal: number; phase: number; t: number; hold: number; bounces: number; result: number; top: number; hit: number; hx: number; hz: number; xmin: number; xmax: number; zmin: number; zmax: number };
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var DICE_RESTITUTION = 0.45;
var SPIN_DAMPING = 0.985;
var DICE_SEED = 7;
var GRAVITY = 22;
var DIE_HALF = 1;
var INERTIA = 2 * DIE_HALF * DIE_HALF / 3;
var FLOOR_FRICTION = 0.35;
var SETTLE_AFTER_MS = 2000;
var SETTLE_RATE = 0.16;
var HOLD_MS = 700;
var CAM_Y = 5.6, CAM_BACK = 12, FOCAL = 275, VIEW_CX = 160, VIEW_CY = 82;
var TABLE_X = 4.4, TABLE_Z0 = -3, TABLE_Z1 = 5;
var PH_FLY = 0, PH_SETTLE = 1, PH_HOLD = 2, PH_IDLE = 3;
var STRIP_BASE = 128;
var BAR_MAX = 18;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed: number): RandFn {
var s = seed >>> 0;
return function (): number { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function dot(a: Vec, b: Vec): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function cross(a: Vec, b: Vec): Vec { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function unit(a: Vec): Vec { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function matMul(a: Mat, b: Mat): Mat {
var o: Mat = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (var i = 0; i < 3; i += 1) {
for (var j = 0; j < 3; j += 1) o[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
}
return o;
}
function matVec(m: Mat, v: Vec): Vec {
return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
}
// 로드리게스 공식: 단위 축 ax 둘레로 ang 만큼 도는 회전 행렬
function rotAxis(ax: Vec, ang: number): Mat {
var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c];
}
function orthonormalize(m: Mat): Mat {
var a = unit([m[0], m[1], m[2]]);
var k = a[0] * m[3] + a[1] * m[4] + a[2] * m[5];
var b = unit([m[3] - a[0] * k, m[4] - a[1] * k, m[5] - a[2] * k]);
var c = cross(a, b);
return [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]];
}
function randAxis(rnd: RandFn): Vec { return unit([rnd() * 2 - 1, rnd() * 2 - 1, rnd() * 2 - 1]); }
var PITCH = Math.atan2(CAM_Y, CAM_BACK);
var CAM_S = Math.sin(PITCH), CAM_C = Math.cos(PITCH);
function camPoint(p: Vec): Vec {
var qy = p[1] - CAM_Y, qz = p[2] + CAM_BACK;
return [p[0], qy * CAM_C + qz * CAM_S, -qy * CAM_S + qz * CAM_C];
}
function camDir(v: Vec): Vec { return [v[0], v[1] * CAM_C + v[2] * CAM_S, -v[1] * CAM_S + v[2] * CAM_C]; }
function scr(p: Vec): Vec {
var c = camPoint(p);
var k = FOCAL / c[2];
return [VIEW_CX + c[0] * k, VIEW_CY - c[1] * k, k];
}
var FACE_N: Vec[] = [[0, 1, 0], [0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];
var FACE_U: Vec[] = [[1, 0, 0], [1, 0, 0], [0, 1, 0], [0, 1, 0], [1, 0, 0], [1, 0, 0]];
var FACE_V: Vec[] = [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]];
var FACE_VAL: number[] = [1, 6, 2, 5, 3, 4];
var PIPS: number[][][] = [[], [[0, 0]], [[-1, -1], [1, 1]], [[-1, -1], [0, 0], [1, 1]],
[[-1, -1], [1, -1], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
[[-1, -1], [-1, 0], [-1, 1], [1, -1], [1, 0], [1, 1]]];
var DICE_PAL: Pal[] = [
{ face: [255, 214, 226], edge: [240, 160, 186], pip: '#4b3a63' },
{ face: [200, 232, 255], edge: [140, 190, 236], pip: '#3b4a6b' },
{ face: [224, 212, 255], edge: [170, 150, 232], pip: '#46386b' },
{ face: [255, 240, 196], edge: [236, 206, 120], pip: '#5a4636' }
];
var LIGHT: Vec = unit([-0.45, 0.85, -0.35]);
function newDie(pal: number, xmin: number, xmax: number, zmin: number, zmax: number): Die {
return { p: [0, DIE_HALF, 0], v: [0, 0, 0], R: [1, 0, 0, 0, 1, 0, 0, 0, 1], w: [0, 0, 0], pal: pal, phase: PH_IDLE, t: 0, hold: 0,
bounces: 0, result: 0, top: 1, hit: 0, hx: 0, hz: 0, xmin: xmin, xmax: xmax, zmin: zmin, zmax: zmax };
}
function topIndex(d: Die): number {
var best = 0, by = -9;
for (var f = 0; f < 6; f += 1) {
var ny = d.R[3] * FACE_N[f][0] + d.R[4] * FACE_N[f][1] + d.R[5] * FACE_N[f][2];
if (ny > by) { by = ny; best = f; }
}
return best;
}
// 던지기: 시작 위치·속도·회전을 시드 수열에서 순서대로 뽑는다 (뽑는 순서가 곧 재현 규칙)
function launch(d: Die, rnd: RandFn, x: number, z: number, vx: number, vz: number, vy0: number, spin: number): void {
d.p = [x, 2.4, z];
d.v = [vx + (rnd() - 0.5) * 1.6, vy0 + rnd() * 2.2, vz + (rnd() - 0.5) * 1.6];
var ax = randAxis(rnd);
var s = spin * (0.6 + rnd() * 0.8);
d.w = [ax[0] * s, ax[1] * s, ax[2] * s];
d.R = rotAxis(randAxis(rnd), rnd() * 6.283);
d.phase = PH_FLY; d.t = 0; d.hold = 0; d.bounces = 0; d.result = 0; d.hit = 0;
}
function spinStep(d: Die, dt: number): void {
var wl = Math.hypot(d.w[0], d.w[1], d.w[2]);
if (wl > 1e-5) d.R = matMul(rotAxis([d.w[0] / wl, d.w[1] / wl, d.w[2] / wl], wl * dt), d.R);
}
function floorStep(d: Die): void {
var mi = 0, my = 99;
var rx = 0, ry = 0, rz = 0;
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? DIE_HALF : -DIE_HALF, ly = (i & 2) ? DIE_HALF : -DIE_HALF, lz = (i & 4) ? DIE_HALF : -DIE_HALF;
var wy = d.R[3] * lx + d.R[4] * ly + d.R[5] * lz;
if (wy < my) { my = wy; mi = i; rx = d.R[0] * lx + d.R[1] * ly + d.R[2] * lz; ry = wy; rz = d.R[6] * lx + d.R[7] * ly + d.R[8] * lz; }
}
var low = d.p[1] + my;
if (low >= 0) return;
d.p[1] -= low;
var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
if (cvy >= 0) return;
var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
d.v[1] += j;
d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;
var tl = Math.hypot(cvx, cvz);
if (tl > 1e-4) {
var jt = Math.min(FLOOR_FRICTION * j, tl * 0.5);
var tx = -cvx / tl * jt, tz = -cvz / tl * jt;
d.v[0] += tx; d.v[2] += tz;
d.w[0] += ry * tz / INERTIA; d.w[1] += (rz * tx - rx * tz) / INERTIA; d.w[2] += -ry * tx / INERTIA;
}
if (cvy < -1.2) { d.bounces += 1; d.hit = -cvy; d.hx = d.p[0] + rx; d.hz = d.p[2] + rz; }
}
function wallStep(d: Die): void {
if (d.p[0] < d.xmin) { d.p[0] = d.xmin; d.v[0] = Math.abs(d.v[0]) * 0.5; }
if (d.p[0] > d.xmax) { d.p[0] = d.xmax; d.v[0] = -Math.abs(d.v[0]) * 0.5; }
if (d.p[2] < d.zmin) { d.p[2] = d.zmin; d.v[2] = Math.abs(d.v[2]) * 0.5; }
if (d.p[2] > d.zmax) { d.p[2] = d.zmax; d.v[2] = -Math.abs(d.v[2]) * 0.5; }
}
// 멈추기: 법선 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 정확히 위를 보도록 남은 각도의 SETTLE_RATE 만큼씩 돌린다
function settleStep(d: Die): void {
var k = topIndex(d);
d.top = FACE_VAL[k];
var nw = matVec(d.R, FACE_N[k]);
var ang = Math.acos(clamp(nw[1], -1, 1));
if (ang > 1e-4) {
var ax = unit(cross(nw, [0, 1, 0]));
d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
}
d.R = orthonormalize(matMul(rotAxis([0, 1, 0], d.w[1] * dtS), d.R));
d.w[1] *= 0.88;
d.v[0] *= 0.86; d.v[2] *= 0.86; d.v[1] = 0;
d.p[0] += d.v[0] * dtS; d.p[2] += d.v[2] * dtS;
d.p[1] += (DIE_HALF - d.p[1]) * 0.22;
wallStep(d);
if (ang < 0.004 && Math.abs(d.p[1] - DIE_HALF) < 0.01) {
d.p[1] = DIE_HALF;
var q = topIndex(d);
d.R = orthonormalize(matMul(rotAxis(unit(cross(matVec(d.R, FACE_N[q]), [0, 1, 0])), Math.acos(clamp(matVec(d.R, FACE_N[q])[1], -1, 1))), d.R));
d.phase = PH_HOLD; d.hold = 0; d.result = FACE_VAL[q]; d.top = d.result;
}
}
function stepDie(d: Die): void {
if (d.phase === PH_FLY) {
d.t += FIXED_DT;
d.v[1] -= GRAVITY * dtS;
d.p[0] += d.v[0] * dtS; d.p[1] += d.v[1] * dtS; d.p[2] += d.v[2] * dtS;
spinStep(d, dtS);
d.w[0] *= SPIN_DAMPING; d.w[1] *= SPIN_DAMPING; d.w[2] *= SPIN_DAMPING;
wallStep(d);
floorStep(d);
var sp = Math.hypot(d.v[0], d.v[1], d.v[2]);
if (d.t > SETTLE_AFTER_MS || (d.bounces >= 3 && sp < 1.6)) d.phase = PH_SETTLE;
} else if (d.phase === PH_SETTLE) {
d.t += FIXED_DT;
settleStep(d);
} else if (d.phase === PH_HOLD) {
d.hold += FIXED_DT;
}
}
function runUntilHold(d: Die): number {
for (var i = 0; i < 1200 && d.phase !== PH_HOLD; i += 1) stepDie(d);
return d.result;
}
// ── 그리기 ──
function rgb(c: number[], k: number): string {
return 'rgb(' + Math.min(255, Math.round(c[0] * k)) + ',' + Math.min(255, Math.round(c[1] * k)) + ',' + Math.min(255, Math.round(c[2] * k)) + ')';
}
function quad(c: Vec, u: Vec, v: Vec, s: number): number[][] {
var o: number[][] = [];
var sg = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
for (var i = 0; i < 4; i += 1) {
o.push(scr([c[0] + (u[0] * sg[i][0] + v[0] * sg[i][1]) * s * DIE_HALF, c[1] + (u[1] * sg[i][0] + v[1] * sg[i][1]) * s * DIE_HALF, c[2] + (u[2] * sg[i][0] + v[2] * sg[i][1]) * s * DIE_HALF]));
}
return o;
}
function fillPoly(pts: number[][], color: Paint, edge: number): void {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
if (edge > 0) { ctx.strokeStyle = color; ctx.lineWidth = edge; ctx.stroke(); }
}
function drawDie(d: Die, hi: number, alpha: number): void {
var pal = DICE_PAL[d.pal % DICE_PAL.length];
ctx.globalAlpha = alpha;
ctx.lineJoin = 'round';
for (var f = 0; f < 6; f += 1) {
var nw = matVec(d.R, FACE_N[f]);
var cw: Vec = [d.p[0] + nw[0] * DIE_HALF, d.p[1] + nw[1] * DIE_HALF, d.p[2] + nw[2] * DIE_HALF];
var cc = camPoint(cw), nc = camDir(nw);
if (nc[0] * cc[0] + nc[1] * cc[1] + nc[2] * cc[2] >= 0) continue;
var uw = matVec(d.R, FACE_U[f]), vw = matVec(d.R, FACE_V[f]);
var lit = 0.66 + 0.34 * Math.max(0, dot(nw, LIGHT));
var k0 = scr(cw)[2];
fillPoly(quad(cw, uw, vw, 1), rgb(pal.edge, lit), k0 * 0.1);
fillPoly(quad(cw, uw, vw, 0.84), rgb(pal.face, lit), k0 * 0.16);
if (f === hi) fillPoly(quad(cw, uw, vw, 0.84), 'rgba(255,255,255,0.4)', 0);
var val = FACE_VAL[f];
for (var pi = 0; pi < PIPS[val].length; pi += 1) {
var a = PIPS[val][pi][0] * 0.5 * DIE_HALF, b = PIPS[val][pi][1] * 0.5 * DIE_HALF;
var ring: number[][] = [];
for (var j = 0; j < 10; j += 1) {
var an = j * 0.62832, rr = 0.17 * DIE_HALF;
var ca = Math.cos(an) * rr, sa = Math.sin(an) * rr;
ring.push(scr([cw[0] + uw[0] * (a + ca) + vw[0] * (b + sa) + nw[0] * 0.004, cw[1] + uw[1] * (a + ca) + vw[1] * (b + sa) + nw[1] * 0.004, cw[2] + uw[2] * (a + ca) + vw[2] * (b + sa) + nw[2] * 0.004]));
}
fillPoly(ring, pal.pip, 0);
}
}
ctx.globalAlpha = 1;
}
function drawShadow(d: Die, alpha: number): void {
var s = scr([d.p[0], 0, d.p[2]]);
var h = Math.max(0, d.p[1] - DIE_HALF);
var r = DIE_HALF * s[2] * (1.2 + h * 0.18);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.42);
ctx.beginPath();
ctx.arc(0, 0, r, 0, 6.2832);
ctx.fillStyle = 'rgba(30,60,60,' + (0.34 * alpha / (1 + h * 0.7)) + ')';
ctx.fill();
ctx.restore();
}
var FELT_DOTS: number[][] = [];
(function (): void {
var fr = seedRand(99);
for (var i = 0; i < 70; i += 1) FELT_DOTS.push([(fr() * 2 - 1) * TABLE_X, TABLE_Z0 + fr() * (TABLE_Z1 - TABLE_Z0), 0.04 + fr() * 0.08]);
})();
function tablePts(m: number): number[][] {
return [scr([-TABLE_X - m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z1 + m]), scr([-TABLE_X - m, 0, TABLE_Z1 + m])];
}
function drawStage(lightX: number, lightK: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var bg = ctx.createRadialGradient(160, 80, 10, 160, 80, 210);
bg.addColorStop(0, '#3f3764');
bg.addColorStop(1, '#221d36');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
fillPoly(tablePts(0.5), '#8e80c8', 0);
var tp = tablePts(0);
var g = ctx.createLinearGradient(0, tp[0][1], 0, tp[2][1]);
g.addColorStop(0, '#9fdcc3');
g.addColorStop(1, '#78c8aa');
fillPoly(tp, g, 0);
for (var i = 0; i < FELT_DOTS.length; i += 1) {
var s = scr([FELT_DOTS[i][0], 0, FELT_DOTS[i][1]]);
ctx.beginPath();
ctx.arc(s[0], s[1], FELT_DOTS[i][2] * s[2], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,0.12)';
ctx.fill();
}
var lp = scr([lightX, 0, 1]);
var lg = ctx.createRadialGradient(lp[0], lp[1], 4, lp[0], lp[1], 120);
lg.addColorStop(0, 'rgba(255,255,255,' + (0.3 * lightK) + ')');
lg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.save();
ctx.beginPath();
ctx.moveTo(tp[0][0], tp[0][1]); ctx.lineTo(tp[1][0], tp[1][1]); ctx.lineTo(tp[2][0], tp[2][1]); ctx.lineTo(tp[3][0], tp[3][1]);
ctx.closePath();
ctx.clip();
ctx.fillStyle = lg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.restore();
}
function drawChip(x: number, y: number, s: number, value: number, pal: number, mark: boolean): void {
var p = DICE_PAL[pal % DICE_PAL.length];
ctx.fillStyle = value > 0 ? rgb(p.face, 1) : 'rgba(255,255,255,0.18)';
ctx.strokeStyle = mark ? '#7fe3bd' : rgb(p.edge, 1);
ctx.lineWidth = mark ? 1.8 : 1;
ctx.fillRect(x, y, s, s);
ctx.strokeRect(x, y, s, s);
for (var i = 0; i < PIPS[value].length; i += 1) {
ctx.beginPath();
ctx.arc(x + s / 2 + PIPS[value][i][0] * s * 0.26, y + s / 2 + PIPS[value][i][1] * s * 0.26, s * 0.1, 0, 6.2832);
ctx.fillStyle = p.pip;
ctx.fill();
}
}
// ── 상태 줄 (aria-live) ──
var stLabel = '', stNum = '', stAt = -9999;
function setStatus(label: string, num: string, force: boolean): void {
if (label === stLabel && num === stNum) return;
if (!force && label === stLabel && elapsed - stAt < 450) return;
stLabel = label; stNum = num; stAt = elapsed;
labelEl.textContent = label;
numEl.textContent = num;
}
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Fn[] = [];
// 기법: 여덟 꼭짓점에 회전 행렬 R 을 곱하고 원근 투영(sx = cx + f * x / z)으로 화면에 옮긴다.
// 보이는 면은 법선과 시선의 내적 부호로 고르고, 윗면은 법선 y 가 가장 큰 면이다.
var T_TOP = '윗면';
var SPIN_BASE = 7;
var DEMO_KICK_MS = 1500;
var die: Die = newDie(0, -9, 9, -9, 9);
var rng: RandFn = seedRand(DICE_SEED);
var kickAt = 0;
var axisCols: string[] = ['#ff9ebb', '#4cd4a6', '#6ec8ff'];
function sceneInit(): void { die.phase = PH_FLY; die.p = [0, 1.7, 0.4]; die.R = rotAxis(unit([0.4, 1, 0.3]), 0.7); }
function sceneRoll(manual: boolean): void {
var ax = randAxis(rng);
var s = SPIN_BASE + rng() * 5;
die.w = [ax[0] * s, ax[1] * s, ax[2] * s];
kickAt = elapsed;
if (reduce) die.R = matMul(rotAxis(ax, 1.2 + rng() * 4.4), die.R);
}
function sceneDone(): boolean { return true; }
function sceneFinish(): void { die.top = FACE_VAL[topIndex(die)]; setStatus(T_TOP, String(die.top), true); }
function stepWorld(): void {
var t = elapsed / 1000;
die.p = [Math.sin(t * 0.9) * 2.8, 1.7 + Math.sin(t * 1.7) * 0.3, Math.cos(t * 0.9) * 1.3 + 0.4];
spinStep(die, dtS);
var damp = demo ? 0.996 : 0.985;
die.w = [die.w[0] * damp, die.w[1] * damp, die.w[2] * damp];
if (demo && elapsed - kickAt > DEMO_KICK_MS) sceneRoll(false);
die.top = FACE_VAL[topIndex(die)];
setStatus(T_TOP, String(die.top), false);
}
function drawWorld(): void {
drawStage(die.p[0], 0.9);
// 바닥 안내선: 주사위 중심에서 바닥 그림자까지
var a = scr(die.p), b = scr([die.p[0], 0, die.p[2]]);
ctx.setLineDash([2, 3]);
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
ctx.setLineDash([]);
drawShadow(die, 1);
drawDie(die, topIndex(die), 1);
// 회전 행렬의 세 열 = 주사위가 가진 세 축을 투영한 선
for (var c = 0; c < 3; c += 1) {
var e: Vec = [die.R[c], die.R[3 + c], die.R[6 + c]];
var q = scr([die.p[0] + e[0] * 2.1, die.p[1] + e[1] * 2.1, die.p[2] + e[2] * 2.1]);
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(q[0], q[1]);
ctx.strokeStyle = axisCols[c]; ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.stroke();
ctx.beginPath(); ctx.arc(q[0], q[1], 2.6, 0, 6.2832); ctx.fillStyle = axisCols[c]; ctx.fill();
}
// 여덟 꼭짓점 점 (가까울수록 크게)
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? 1 : -1, ly = (i & 2) ? 1 : -1, lz = (i & 4) ? 1 : -1;
var v = matVec(die.R, [lx, ly, lz]);
var s = scr([die.p[0] + v[0], die.p[1] + v[1], die.p[2] + v[2]]);
ctx.beginPath(); ctx.arc(s[0], s[1], s[2] * 0.045, 0, 6.2832); ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.fill();
}
}
function fit(): void {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs = -1;
var acc = 0;
function tick(ts: number): void {
if (lastTs < 0) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
// 모션 줄이기: 애니메이션 없이 결과가 나올 때까지 같은 계산을 한 번에 돌려 최종 장면만 그린다
function runToDone(): void {
var i = 0;
do { elapsed += FIXED_DT; stepWorld(); i += 1; } while (i < 1500 && !sceneDone());
sceneFinish();
drawWorld();
}
function onRollClick(): void {
stopDemo();
sceneRoll(true);
if (reduce) runToDone();
}
sceneInit();
sceneRoll(false);
if (reduce) {
runToDone();
} else {
for (var w0 = 0; w0 < 8; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
}
btn.addEventListener('click', onRollClick);
cleaners.push(function (): void { btn.removeEventListener('click', onRollClick); });
fit();
window.addEventListener('resize', fit);
cleaners.push(function (): void { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
return destroy;
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} role="img" aria-label="정육면체 여덟 꼭짓점에 회전 행렬을 곱하고 원근 투영으로 화면에 옮긴 뒤, 법선과 시선의 내적으로 카메라를 등진 면을 걸러 눈 달린 주사위가 허공에서 도는 장면을 그린다." />
<output className={styles.bp__status} aria-live="polite"><span ref={labelRef}>윗면</span> <b ref={numRef} /></output>
<button type="button" className={styles.bp__btn} ref={btnRef}>굴리기</button>
<p className={styles.bp__caption}>정육면체 여덟 꼭짓점에 회전 행렬을 곱하고 원근 투영으로 화면에 옮긴 뒤, 법선과 시선의 내적으로 카메라를 등진 면을 걸러 눈 달린 주사위가 허공에서 도는 장면을 그린다.</p>
</div>
</main>
);
}01 · 回転行列で回す3Dサイコロ — 計測 累積面積 13.776% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function matVec(m, v) {
return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
}
// ロドリゲスの公式: 単位軸 ax のまわりに ang だけ回す回転行列
function rotAxis(ax, ang) {
var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c];
}
02放物線で投げて床で跳ねるサイコロ
GRAVITY の22が速度を一歩ごとに削って放物線を作り、floorStep() は最も低い頂点が床にめり込んだぶんだけ持ち上げたうえで、接触点の速度が下向きのときだけ DICE_RESTITUTION の0.45を含む撃力を与えます。衝突速度が1.2を超えた接触だけが跳ねた回数に数えられ、壁では wallStep() が速度を半分にして押し戻します。累積面積は23.3%、平均強度は61.8で、動いたのは21/23フレームです。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" role="img" aria-label="投げたサイコロに重力を足して放物線を描かせ、床に当たるたびに接触速度へ反発係数を掛けて、跳ねる頂点の高さが小さくなる様子を影と軌跡の点つきで描く。"></canvas>
<output class="bp__status" aria-live="polite"><span id="bpLabel">転がり中</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">振る</button>
<p class="bp__caption">投げたサイコロに重力を足して放物線を描かせ、床に当たるたびに接触速度へ反発係数を掛けて、跳ねる頂点の高さが小さくなる様子を影と軌跡の点つきで描く。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
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: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
display: flex;
align-items: baseline;
gap: $sp-2;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: $sp-3;
top: $sp-3;
min-height: $sp-8;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-instant $ease-out;
}
.bp__btn:hover { transform: translateY(-1px); }
.bp__btn:active { transform: translateY(1px); }
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .9);
outline-offset: 2px;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__btn { font-size: 11px; padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
.bp__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: 12px;
top: 12px;
min-height: 32px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 140ms ease-out;
}
.bp__btn:hover {
transform: translateY(-1px);
}
.bp__btn:active {
transform: translateY(1px);
}
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.9);
outline-offset: 2px;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
padding: 4px 8px;
}
.bp__btn {
font-size: 11px;
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
.bp__btn {
transition: none;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var labelEl = document.getElementById('bpLabel');
var numEl = document.getElementById('bpNum');
var btn = document.getElementById('bpBtn');
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var DICE_RESTITUTION = 0.45;
var SPIN_DAMPING = 0.985;
var DICE_SEED = 7;
var GRAVITY = 22;
var DIE_HALF = 1;
var INERTIA = 2 * DIE_HALF * DIE_HALF / 3;
var FLOOR_FRICTION = 0.35;
var SETTLE_AFTER_MS = 2000;
var SETTLE_RATE = 0.16;
var HOLD_MS = 700;
var CAM_Y = 5.6, CAM_BACK = 12, FOCAL = 275, VIEW_CX = 160, VIEW_CY = 82;
var TABLE_X = 4.4, TABLE_Z0 = -3, TABLE_Z1 = 5;
var PH_FLY = 0, PH_SETTLE = 1, PH_HOLD = 2, PH_IDLE = 3;
var STRIP_BASE = 128;
var BAR_MAX = 18;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function cross(a, b) { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function unit(a) { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function matMul(a, b) {
var o = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (var i = 0; i < 3; i += 1) {
for (var j = 0; j < 3; j += 1) o[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
}
return o;
}
function matVec(m, v) {
return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
}
// 로드리게스 공식: 단위 축 ax 둘레로 ang 만큼 도는 회전 행렬
function rotAxis(ax, ang) {
var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c];
}
function orthonormalize(m) {
var a = unit([m[0], m[1], m[2]]);
var k = a[0] * m[3] + a[1] * m[4] + a[2] * m[5];
var b = unit([m[3] - a[0] * k, m[4] - a[1] * k, m[5] - a[2] * k]);
var c = cross(a, b);
return [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]];
}
function randAxis(rnd) { return unit([rnd() * 2 - 1, rnd() * 2 - 1, rnd() * 2 - 1]); }
var PITCH = Math.atan2(CAM_Y, CAM_BACK);
var CAM_S = Math.sin(PITCH), CAM_C = Math.cos(PITCH);
function camPoint(p) {
var qy = p[1] - CAM_Y, qz = p[2] + CAM_BACK;
return [p[0], qy * CAM_C + qz * CAM_S, -qy * CAM_S + qz * CAM_C];
}
function camDir(v) { return [v[0], v[1] * CAM_C + v[2] * CAM_S, -v[1] * CAM_S + v[2] * CAM_C]; }
function scr(p) {
var c = camPoint(p);
var k = FOCAL / c[2];
return [VIEW_CX + c[0] * k, VIEW_CY - c[1] * k, k];
}
var FACE_N = [[0, 1, 0], [0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];
var FACE_U = [[1, 0, 0], [1, 0, 0], [0, 1, 0], [0, 1, 0], [1, 0, 0], [1, 0, 0]];
var FACE_V = [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]];
var FACE_VAL = [1, 6, 2, 5, 3, 4];
var PIPS = [[], [[0, 0]], [[-1, -1], [1, 1]], [[-1, -1], [0, 0], [1, 1]],
[[-1, -1], [1, -1], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
[[-1, -1], [-1, 0], [-1, 1], [1, -1], [1, 0], [1, 1]]];
var DICE_PAL = [
{ face: [255, 214, 226], edge: [240, 160, 186], pip: '#4b3a63' },
{ face: [200, 232, 255], edge: [140, 190, 236], pip: '#3b4a6b' },
{ face: [224, 212, 255], edge: [170, 150, 232], pip: '#46386b' },
{ face: [255, 240, 196], edge: [236, 206, 120], pip: '#5a4636' }
];
var LIGHT = unit([-0.45, 0.85, -0.35]);
function newDie(pal, xmin, xmax, zmin, zmax) {
return { p: [0, DIE_HALF, 0], v: [0, 0, 0], R: [1, 0, 0, 0, 1, 0, 0, 0, 1], w: [0, 0, 0], pal: pal, phase: PH_IDLE, t: 0, hold: 0,
bounces: 0, result: 0, top: 1, hit: 0, hx: 0, hz: 0, xmin: xmin, xmax: xmax, zmin: zmin, zmax: zmax };
}
function topIndex(d) {
var best = 0, by = -9;
for (var f = 0; f < 6; f += 1) {
var ny = d.R[3] * FACE_N[f][0] + d.R[4] * FACE_N[f][1] + d.R[5] * FACE_N[f][2];
if (ny > by) { by = ny; best = f; }
}
return best;
}
// 던지기: 시작 위치·속도·회전을 시드 수열에서 순서대로 뽑는다 (뽑는 순서가 곧 재현 규칙)
function launch(d, rnd, x, z, vx, vz, vy0, spin) {
d.p = [x, 2.4, z];
d.v = [vx + (rnd() - 0.5) * 1.6, vy0 + rnd() * 2.2, vz + (rnd() - 0.5) * 1.6];
var ax = randAxis(rnd);
var s = spin * (0.6 + rnd() * 0.8);
d.w = [ax[0] * s, ax[1] * s, ax[2] * s];
d.R = rotAxis(randAxis(rnd), rnd() * 6.283);
d.phase = PH_FLY; d.t = 0; d.hold = 0; d.bounces = 0; d.result = 0; d.hit = 0;
}
function spinStep(d, dt) {
var wl = Math.hypot(d.w[0], d.w[1], d.w[2]);
if (wl > 1e-5) d.R = matMul(rotAxis([d.w[0] / wl, d.w[1] / wl, d.w[2] / wl], wl * dt), d.R);
}
function floorStep(d) {
var mi = 0, my = 99;
var rx = 0, ry = 0, rz = 0;
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? DIE_HALF : -DIE_HALF, ly = (i & 2) ? DIE_HALF : -DIE_HALF, lz = (i & 4) ? DIE_HALF : -DIE_HALF;
var wy = d.R[3] * lx + d.R[4] * ly + d.R[5] * lz;
if (wy < my) { my = wy; mi = i; rx = d.R[0] * lx + d.R[1] * ly + d.R[2] * lz; ry = wy; rz = d.R[6] * lx + d.R[7] * ly + d.R[8] * lz; }
}
var low = d.p[1] + my;
if (low >= 0) return;
d.p[1] -= low;
var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
if (cvy >= 0) return;
var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
d.v[1] += j;
d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;
var tl = Math.hypot(cvx, cvz);
if (tl > 1e-4) {
var jt = Math.min(FLOOR_FRICTION * j, tl * 0.5);
var tx = -cvx / tl * jt, tz = -cvz / tl * jt;
d.v[0] += tx; d.v[2] += tz;
d.w[0] += ry * tz / INERTIA; d.w[1] += (rz * tx - rx * tz) / INERTIA; d.w[2] += -ry * tx / INERTIA;
}
if (cvy < -1.2) { d.bounces += 1; d.hit = -cvy; d.hx = d.p[0] + rx; d.hz = d.p[2] + rz; }
}
function wallStep(d) {
if (d.p[0] < d.xmin) { d.p[0] = d.xmin; d.v[0] = Math.abs(d.v[0]) * 0.5; }
if (d.p[0] > d.xmax) { d.p[0] = d.xmax; d.v[0] = -Math.abs(d.v[0]) * 0.5; }
if (d.p[2] < d.zmin) { d.p[2] = d.zmin; d.v[2] = Math.abs(d.v[2]) * 0.5; }
if (d.p[2] > d.zmax) { d.p[2] = d.zmax; d.v[2] = -Math.abs(d.v[2]) * 0.5; }
}
// 멈추기: 법선 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 정확히 위를 보도록 남은 각도의 SETTLE_RATE 만큼씩 돌린다
function settleStep(d) {
var k = topIndex(d);
d.top = FACE_VAL[k];
var nw = matVec(d.R, FACE_N[k]);
var ang = Math.acos(clamp(nw[1], -1, 1));
if (ang > 1e-4) {
var ax = unit(cross(nw, [0, 1, 0]));
d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
}
d.R = orthonormalize(matMul(rotAxis([0, 1, 0], d.w[1] * dtS), d.R));
d.w[1] *= 0.88;
d.v[0] *= 0.86; d.v[2] *= 0.86; d.v[1] = 0;
d.p[0] += d.v[0] * dtS; d.p[2] += d.v[2] * dtS;
d.p[1] += (DIE_HALF - d.p[1]) * 0.22;
wallStep(d);
if (ang < 0.004 && Math.abs(d.p[1] - DIE_HALF) < 0.01) {
d.p[1] = DIE_HALF;
var q = topIndex(d);
d.R = orthonormalize(matMul(rotAxis(unit(cross(matVec(d.R, FACE_N[q]), [0, 1, 0])), Math.acos(clamp(matVec(d.R, FACE_N[q])[1], -1, 1))), d.R));
d.phase = PH_HOLD; d.hold = 0; d.result = FACE_VAL[q]; d.top = d.result;
}
}
function stepDie(d) {
if (d.phase === PH_FLY) {
d.t += FIXED_DT;
d.v[1] -= GRAVITY * dtS;
d.p[0] += d.v[0] * dtS; d.p[1] += d.v[1] * dtS; d.p[2] += d.v[2] * dtS;
spinStep(d, dtS);
d.w[0] *= SPIN_DAMPING; d.w[1] *= SPIN_DAMPING; d.w[2] *= SPIN_DAMPING;
wallStep(d);
floorStep(d);
var sp = Math.hypot(d.v[0], d.v[1], d.v[2]);
if (d.t > SETTLE_AFTER_MS || (d.bounces >= 3 && sp < 1.6)) d.phase = PH_SETTLE;
} else if (d.phase === PH_SETTLE) {
d.t += FIXED_DT;
settleStep(d);
} else if (d.phase === PH_HOLD) {
d.hold += FIXED_DT;
}
}
function runUntilHold(d) {
for (var i = 0; i < 1200 && d.phase !== PH_HOLD; i += 1) stepDie(d);
return d.result;
}
// ── 그리기 ──
function rgb(c, k) {
return 'rgb(' + Math.min(255, Math.round(c[0] * k)) + ',' + Math.min(255, Math.round(c[1] * k)) + ',' + Math.min(255, Math.round(c[2] * k)) + ')';
}
function quad(c, u, v, s) {
var o = [];
var sg = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
for (var i = 0; i < 4; i += 1) {
o.push(scr([c[0] + (u[0] * sg[i][0] + v[0] * sg[i][1]) * s * DIE_HALF, c[1] + (u[1] * sg[i][0] + v[1] * sg[i][1]) * s * DIE_HALF, c[2] + (u[2] * sg[i][0] + v[2] * sg[i][1]) * s * DIE_HALF]));
}
return o;
}
function fillPoly(pts, color, edge) {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
if (edge > 0) { ctx.strokeStyle = color; ctx.lineWidth = edge; ctx.stroke(); }
}
function drawDie(d, hi, alpha) {
var pal = DICE_PAL[d.pal % DICE_PAL.length];
ctx.globalAlpha = alpha;
ctx.lineJoin = 'round';
for (var f = 0; f < 6; f += 1) {
var nw = matVec(d.R, FACE_N[f]);
var cw = [d.p[0] + nw[0] * DIE_HALF, d.p[1] + nw[1] * DIE_HALF, d.p[2] + nw[2] * DIE_HALF];
var cc = camPoint(cw), nc = camDir(nw);
if (nc[0] * cc[0] + nc[1] * cc[1] + nc[2] * cc[2] >= 0) continue;
var uw = matVec(d.R, FACE_U[f]), vw = matVec(d.R, FACE_V[f]);
var lit = 0.66 + 0.34 * Math.max(0, dot(nw, LIGHT));
var k0 = scr(cw)[2];
fillPoly(quad(cw, uw, vw, 1), rgb(pal.edge, lit), k0 * 0.1);
fillPoly(quad(cw, uw, vw, 0.84), rgb(pal.face, lit), k0 * 0.16);
if (f === hi) fillPoly(quad(cw, uw, vw, 0.84), 'rgba(255,255,255,0.4)', 0);
var val = FACE_VAL[f];
for (var pi = 0; pi < PIPS[val].length; pi += 1) {
var a = PIPS[val][pi][0] * 0.5 * DIE_HALF, b = PIPS[val][pi][1] * 0.5 * DIE_HALF;
var ring = [];
for (var j = 0; j < 10; j += 1) {
var an = j * 0.62832, rr = 0.17 * DIE_HALF;
var ca = Math.cos(an) * rr, sa = Math.sin(an) * rr;
ring.push(scr([cw[0] + uw[0] * (a + ca) + vw[0] * (b + sa) + nw[0] * 0.004, cw[1] + uw[1] * (a + ca) + vw[1] * (b + sa) + nw[1] * 0.004, cw[2] + uw[2] * (a + ca) + vw[2] * (b + sa) + nw[2] * 0.004]));
}
fillPoly(ring, pal.pip, 0);
}
}
ctx.globalAlpha = 1;
}
function drawShadow(d, alpha) {
var s = scr([d.p[0], 0, d.p[2]]);
var h = Math.max(0, d.p[1] - DIE_HALF);
var r = DIE_HALF * s[2] * (1.2 + h * 0.18);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.42);
ctx.beginPath();
ctx.arc(0, 0, r, 0, 6.2832);
ctx.fillStyle = 'rgba(30,60,60,' + (0.34 * alpha / (1 + h * 0.7)) + ')';
ctx.fill();
ctx.restore();
}
var FELT_DOTS = [];
(function () {
var fr = seedRand(99);
for (var i = 0; i < 70; i += 1) FELT_DOTS.push([(fr() * 2 - 1) * TABLE_X, TABLE_Z0 + fr() * (TABLE_Z1 - TABLE_Z0), 0.04 + fr() * 0.08]);
})();
function tablePts(m) {
return [scr([-TABLE_X - m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z1 + m]), scr([-TABLE_X - m, 0, TABLE_Z1 + m])];
}
function drawStage(lightX, lightK) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var bg = ctx.createRadialGradient(160, 80, 10, 160, 80, 210);
bg.addColorStop(0, '#3f3764');
bg.addColorStop(1, '#221d36');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
fillPoly(tablePts(0.5), '#8e80c8', 0);
var tp = tablePts(0);
var g = ctx.createLinearGradient(0, tp[0][1], 0, tp[2][1]);
g.addColorStop(0, '#9fdcc3');
g.addColorStop(1, '#78c8aa');
fillPoly(tp, g, 0);
for (var i = 0; i < FELT_DOTS.length; i += 1) {
var s = scr([FELT_DOTS[i][0], 0, FELT_DOTS[i][1]]);
ctx.beginPath();
ctx.arc(s[0], s[1], FELT_DOTS[i][2] * s[2], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,0.12)';
ctx.fill();
}
var lp = scr([lightX, 0, 1]);
var lg = ctx.createRadialGradient(lp[0], lp[1], 4, lp[0], lp[1], 120);
lg.addColorStop(0, 'rgba(255,255,255,' + (0.3 * lightK) + ')');
lg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.save();
ctx.beginPath();
ctx.moveTo(tp[0][0], tp[0][1]); ctx.lineTo(tp[1][0], tp[1][1]); ctx.lineTo(tp[2][0], tp[2][1]); ctx.lineTo(tp[3][0], tp[3][1]);
ctx.closePath();
ctx.clip();
ctx.fillStyle = lg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.restore();
}
function drawChip(x, y, s, value, pal, mark) {
var p = DICE_PAL[pal % DICE_PAL.length];
ctx.fillStyle = value > 0 ? rgb(p.face, 1) : 'rgba(255,255,255,0.18)';
ctx.strokeStyle = mark ? '#7fe3bd' : rgb(p.edge, 1);
ctx.lineWidth = mark ? 1.8 : 1;
ctx.fillRect(x, y, s, s);
ctx.strokeRect(x, y, s, s);
for (var i = 0; i < PIPS[value].length; i += 1) {
ctx.beginPath();
ctx.arc(x + s / 2 + PIPS[value][i][0] * s * 0.26, y + s / 2 + PIPS[value][i][1] * s * 0.26, s * 0.1, 0, 6.2832);
ctx.fillStyle = p.pip;
ctx.fill();
}
}
// ── 상태 줄 (aria-live) ──
var stLabel = '', stNum = '', stAt = -9999;
function setStatus(label, num, force) {
if (label === stLabel && num === stNum) return;
if (!force && label === stLabel && elapsed - stAt < 450) return;
stLabel = label; stNum = num; stAt = elapsed;
labelEl.textContent = label;
numEl.textContent = num;
}
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
// 기법: 매 걸음 v.y -= GRAVITY * dt 로 포물선을 만들고, 가장 낮은 꼭짓점이 바닥을 뚫으면
// 접촉점 속도를 반사하며 DICE_RESTITUTION 을 곱한다. 튕김 정점 높이가 e^2 비율로 줄어드는 것이 오른쪽 막대다.
var T_BOUNCE = '跳ねた回数';
var T_RESULT = '結果';
var T_ROLLING = '転がり中';
var die = newDie(0, -3.4, 3.4, -1.8, 2.6);
var rng = seedRand(DICE_SEED + 2);
var trail = [];
var rings = [];
var apex = [];
var prevVy = 0;
var lastSettle = 0;
function sceneInit() { die.phase = PH_IDLE; }
function sceneRoll(manual) {
launch(die, rng, -3.2, rng() * 2.2 - 0.6, 3.6, 0.2, 3.4, 15);
trail = []; apex = []; prevVy = 0;
setStatus(T_BOUNCE, '0', true);
}
function sceneDone() { return die.phase === PH_HOLD; }
function sceneFinish() { setStatus(T_RESULT, String(die.result), true); }
function stepWorld() {
if (die.phase === PH_IDLE) return;
var wasFly = die.phase === PH_FLY;
stepDie(die);
if (wasFly) {
if (prevVy > 0 && die.v[1] <= 0 && die.p[1] > DIE_HALF + 0.05) apex.push(die.p[1] - DIE_HALF);
prevVy = die.v[1];
if (Math.round(die.t / FIXED_DT) % 2 === 0) { trail.push([die.p[0], die.p[1], die.p[2]]); if (trail.length > 60) trail.shift(); }
}
if (die.hit > 0) { rings.push([die.hx, die.hz, elapsed]); die.hit = 0; }
var live = [];
for (var i = 0; i < rings.length; i += 1) if (elapsed - rings[i][2] < 600) live.push(rings[i]);
rings = live;
if (die.phase === PH_HOLD) setStatus(T_RESULT, String(die.result), true);
else setStatus(T_BOUNCE, String(die.bounces), false);
if (die.phase === PH_HOLD && demo && die.hold >= HOLD_MS) sceneRoll(false);
}
function drawWorld() {
drawStage(die.p[0], 0.8);
for (var i = 0; i < rings.length; i += 1) {
var f = (elapsed - rings[i][2]) / 600;
var s = scr([rings[i][0], 0, rings[i][1]]);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.4);
ctx.beginPath(); ctx.arc(0, 0, s[2] * (0.5 + f * 2.2), 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,' + (0.8 * (1 - f)) + ')'; ctx.lineWidth = 2.5; ctx.stroke();
ctx.restore();
}
for (var t = 0; t < trail.length; t += 1) {
var q = scr(trail[t]);
ctx.beginPath(); ctx.arc(q[0], q[1], 1 + 2.2 * t / trail.length, 0, 6.2832);
ctx.fillStyle = 'rgba(255,255,255,' + (0.1 + 0.55 * t / trail.length) + ')'; ctx.fill();
}
if (die.phase !== PH_IDLE) {
drawShadow(die, 1);
drawDie(die, -1, 1);
}
// 튕김 정점 높이 막대: 낮아지는 비율이 반발 계수의 제곱에 가깝다
ctx.fillStyle = 'rgba(255,255,255,0.14)';
ctx.fillRect(196, STRIP_BASE - BAR_MAX - 3, 116, BAR_MAX + 5);
for (var k = 0; k < apex.length && k < 7; k += 1) {
var h = clamp(apex[k] / 2.4, 0.04, 1) * BAR_MAX;
ctx.fillStyle = k === apex.length - 1 ? '#ff9ebb' : '#c9b8ff';
ctx.fillRect(202 + k * 15, STRIP_BASE - h, 11, h);
}
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs = -1;
var acc = 0;
function tick(ts) {
if (lastTs < 0) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
// 모션 줄이기: 애니메이션 없이 결과가 나올 때까지 같은 계산을 한 번에 돌려 최종 장면만 그린다
function runToDone() {
var i = 0;
do { elapsed += FIXED_DT; stepWorld(); i += 1; } while (i < 1500 && !sceneDone());
sceneFinish();
drawWorld();
}
function onRollClick() {
stopDemo();
sceneRoll(true);
if (reduce) runToDone();
}
sceneInit();
sceneRoll(false);
if (reduce) {
runToDone();
} else {
for (var w0 = 0; w0 < 8; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
}
btn.addEventListener('click', onRollClick);
cleaners.push(function () { btn.removeEventListener('click', onRollClick); });
fit();
window.addEventListener('resize', fit);
cleaners.push(function () { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
})();import { useEffect, useRef } from "react";
import styles from "./SpinDiceSceneThrowArcBounce.module.scss";
export default function SpinDiceSceneThrowArcBounce() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const numRef = useRef<HTMLElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current, canvasEl = canvasRef.current, l0 = labelRef.current, n0 = numRef.current, b0 = btnRef.current;
if (!rootEl || !canvasEl || !l0 || !n0 || !b0) return;
const root: HTMLDivElement = rootEl;
const canvas: HTMLCanvasElement = canvasEl;
const ctx0 = canvas.getContext("2d");
if (!ctx0) return;
const ctx: CanvasRenderingContext2D = ctx0;
const wrap: HTMLElement = canvas.parentElement ?? root;
const labelEl: HTMLElement = l0;
const numEl: HTMLElement = n0;
const btn: HTMLButtonElement = b0;
type Vec = number[];
type Mat = number[];
type RandFn = () => number;
type Fn = () => void;
type Paint = string | CanvasGradient;
type Pal = { face: number[]; edge: number[]; pip: string };
type Die = { p: Vec; v: Vec; R: Mat; w: Vec; pal: number; phase: number; t: number; hold: number; bounces: number; result: number; top: number; hit: number; hx: number; hz: number; xmin: number; xmax: number; zmin: number; zmax: number };
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var DICE_RESTITUTION = 0.45;
var SPIN_DAMPING = 0.985;
var DICE_SEED = 7;
var GRAVITY = 22;
var DIE_HALF = 1;
var INERTIA = 2 * DIE_HALF * DIE_HALF / 3;
var FLOOR_FRICTION = 0.35;
var SETTLE_AFTER_MS = 2000;
var SETTLE_RATE = 0.16;
var HOLD_MS = 700;
var CAM_Y = 5.6, CAM_BACK = 12, FOCAL = 275, VIEW_CX = 160, VIEW_CY = 82;
var TABLE_X = 4.4, TABLE_Z0 = -3, TABLE_Z1 = 5;
var PH_FLY = 0, PH_SETTLE = 1, PH_HOLD = 2, PH_IDLE = 3;
var STRIP_BASE = 128;
var BAR_MAX = 18;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed: number): RandFn {
var s = seed >>> 0;
return function (): number { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function dot(a: Vec, b: Vec): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function cross(a: Vec, b: Vec): Vec { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function unit(a: Vec): Vec { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function matMul(a: Mat, b: Mat): Mat {
var o: Mat = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (var i = 0; i < 3; i += 1) {
for (var j = 0; j < 3; j += 1) o[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
}
return o;
}
function matVec(m: Mat, v: Vec): Vec {
return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
}
// 로드리게스 공식: 단위 축 ax 둘레로 ang 만큼 도는 회전 행렬
function rotAxis(ax: Vec, ang: number): Mat {
var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c];
}
function orthonormalize(m: Mat): Mat {
var a = unit([m[0], m[1], m[2]]);
var k = a[0] * m[3] + a[1] * m[4] + a[2] * m[5];
var b = unit([m[3] - a[0] * k, m[4] - a[1] * k, m[5] - a[2] * k]);
var c = cross(a, b);
return [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]];
}
function randAxis(rnd: RandFn): Vec { return unit([rnd() * 2 - 1, rnd() * 2 - 1, rnd() * 2 - 1]); }
var PITCH = Math.atan2(CAM_Y, CAM_BACK);
var CAM_S = Math.sin(PITCH), CAM_C = Math.cos(PITCH);
function camPoint(p: Vec): Vec {
var qy = p[1] - CAM_Y, qz = p[2] + CAM_BACK;
return [p[0], qy * CAM_C + qz * CAM_S, -qy * CAM_S + qz * CAM_C];
}
function camDir(v: Vec): Vec { return [v[0], v[1] * CAM_C + v[2] * CAM_S, -v[1] * CAM_S + v[2] * CAM_C]; }
function scr(p: Vec): Vec {
var c = camPoint(p);
var k = FOCAL / c[2];
return [VIEW_CX + c[0] * k, VIEW_CY - c[1] * k, k];
}
var FACE_N: Vec[] = [[0, 1, 0], [0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];
var FACE_U: Vec[] = [[1, 0, 0], [1, 0, 0], [0, 1, 0], [0, 1, 0], [1, 0, 0], [1, 0, 0]];
var FACE_V: Vec[] = [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]];
var FACE_VAL: number[] = [1, 6, 2, 5, 3, 4];
var PIPS: number[][][] = [[], [[0, 0]], [[-1, -1], [1, 1]], [[-1, -1], [0, 0], [1, 1]],
[[-1, -1], [1, -1], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
[[-1, -1], [-1, 0], [-1, 1], [1, -1], [1, 0], [1, 1]]];
var DICE_PAL: Pal[] = [
{ face: [255, 214, 226], edge: [240, 160, 186], pip: '#4b3a63' },
{ face: [200, 232, 255], edge: [140, 190, 236], pip: '#3b4a6b' },
{ face: [224, 212, 255], edge: [170, 150, 232], pip: '#46386b' },
{ face: [255, 240, 196], edge: [236, 206, 120], pip: '#5a4636' }
];
var LIGHT: Vec = unit([-0.45, 0.85, -0.35]);
function newDie(pal: number, xmin: number, xmax: number, zmin: number, zmax: number): Die {
return { p: [0, DIE_HALF, 0], v: [0, 0, 0], R: [1, 0, 0, 0, 1, 0, 0, 0, 1], w: [0, 0, 0], pal: pal, phase: PH_IDLE, t: 0, hold: 0,
bounces: 0, result: 0, top: 1, hit: 0, hx: 0, hz: 0, xmin: xmin, xmax: xmax, zmin: zmin, zmax: zmax };
}
function topIndex(d: Die): number {
var best = 0, by = -9;
for (var f = 0; f < 6; f += 1) {
var ny = d.R[3] * FACE_N[f][0] + d.R[4] * FACE_N[f][1] + d.R[5] * FACE_N[f][2];
if (ny > by) { by = ny; best = f; }
}
return best;
}
// 던지기: 시작 위치·속도·회전을 시드 수열에서 순서대로 뽑는다 (뽑는 순서가 곧 재현 규칙)
function launch(d: Die, rnd: RandFn, x: number, z: number, vx: number, vz: number, vy0: number, spin: number): void {
d.p = [x, 2.4, z];
d.v = [vx + (rnd() - 0.5) * 1.6, vy0 + rnd() * 2.2, vz + (rnd() - 0.5) * 1.6];
var ax = randAxis(rnd);
var s = spin * (0.6 + rnd() * 0.8);
d.w = [ax[0] * s, ax[1] * s, ax[2] * s];
d.R = rotAxis(randAxis(rnd), rnd() * 6.283);
d.phase = PH_FLY; d.t = 0; d.hold = 0; d.bounces = 0; d.result = 0; d.hit = 0;
}
function spinStep(d: Die, dt: number): void {
var wl = Math.hypot(d.w[0], d.w[1], d.w[2]);
if (wl > 1e-5) d.R = matMul(rotAxis([d.w[0] / wl, d.w[1] / wl, d.w[2] / wl], wl * dt), d.R);
}
function floorStep(d: Die): void {
var mi = 0, my = 99;
var rx = 0, ry = 0, rz = 0;
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? DIE_HALF : -DIE_HALF, ly = (i & 2) ? DIE_HALF : -DIE_HALF, lz = (i & 4) ? DIE_HALF : -DIE_HALF;
var wy = d.R[3] * lx + d.R[4] * ly + d.R[5] * lz;
if (wy < my) { my = wy; mi = i; rx = d.R[0] * lx + d.R[1] * ly + d.R[2] * lz; ry = wy; rz = d.R[6] * lx + d.R[7] * ly + d.R[8] * lz; }
}
var low = d.p[1] + my;
if (low >= 0) return;
d.p[1] -= low;
var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
if (cvy >= 0) return;
var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
d.v[1] += j;
d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;
var tl = Math.hypot(cvx, cvz);
if (tl > 1e-4) {
var jt = Math.min(FLOOR_FRICTION * j, tl * 0.5);
var tx = -cvx / tl * jt, tz = -cvz / tl * jt;
d.v[0] += tx; d.v[2] += tz;
d.w[0] += ry * tz / INERTIA; d.w[1] += (rz * tx - rx * tz) / INERTIA; d.w[2] += -ry * tx / INERTIA;
}
if (cvy < -1.2) { d.bounces += 1; d.hit = -cvy; d.hx = d.p[0] + rx; d.hz = d.p[2] + rz; }
}
function wallStep(d: Die): void {
if (d.p[0] < d.xmin) { d.p[0] = d.xmin; d.v[0] = Math.abs(d.v[0]) * 0.5; }
if (d.p[0] > d.xmax) { d.p[0] = d.xmax; d.v[0] = -Math.abs(d.v[0]) * 0.5; }
if (d.p[2] < d.zmin) { d.p[2] = d.zmin; d.v[2] = Math.abs(d.v[2]) * 0.5; }
if (d.p[2] > d.zmax) { d.p[2] = d.zmax; d.v[2] = -Math.abs(d.v[2]) * 0.5; }
}
// 멈추기: 법선 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 정확히 위를 보도록 남은 각도의 SETTLE_RATE 만큼씩 돌린다
function settleStep(d: Die): void {
var k = topIndex(d);
d.top = FACE_VAL[k];
var nw = matVec(d.R, FACE_N[k]);
var ang = Math.acos(clamp(nw[1], -1, 1));
if (ang > 1e-4) {
var ax = unit(cross(nw, [0, 1, 0]));
d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
}
d.R = orthonormalize(matMul(rotAxis([0, 1, 0], d.w[1] * dtS), d.R));
d.w[1] *= 0.88;
d.v[0] *= 0.86; d.v[2] *= 0.86; d.v[1] = 0;
d.p[0] += d.v[0] * dtS; d.p[2] += d.v[2] * dtS;
d.p[1] += (DIE_HALF - d.p[1]) * 0.22;
wallStep(d);
if (ang < 0.004 && Math.abs(d.p[1] - DIE_HALF) < 0.01) {
d.p[1] = DIE_HALF;
var q = topIndex(d);
d.R = orthonormalize(matMul(rotAxis(unit(cross(matVec(d.R, FACE_N[q]), [0, 1, 0])), Math.acos(clamp(matVec(d.R, FACE_N[q])[1], -1, 1))), d.R));
d.phase = PH_HOLD; d.hold = 0; d.result = FACE_VAL[q]; d.top = d.result;
}
}
function stepDie(d: Die): void {
if (d.phase === PH_FLY) {
d.t += FIXED_DT;
d.v[1] -= GRAVITY * dtS;
d.p[0] += d.v[0] * dtS; d.p[1] += d.v[1] * dtS; d.p[2] += d.v[2] * dtS;
spinStep(d, dtS);
d.w[0] *= SPIN_DAMPING; d.w[1] *= SPIN_DAMPING; d.w[2] *= SPIN_DAMPING;
wallStep(d);
floorStep(d);
var sp = Math.hypot(d.v[0], d.v[1], d.v[2]);
if (d.t > SETTLE_AFTER_MS || (d.bounces >= 3 && sp < 1.6)) d.phase = PH_SETTLE;
} else if (d.phase === PH_SETTLE) {
d.t += FIXED_DT;
settleStep(d);
} else if (d.phase === PH_HOLD) {
d.hold += FIXED_DT;
}
}
function runUntilHold(d: Die): number {
for (var i = 0; i < 1200 && d.phase !== PH_HOLD; i += 1) stepDie(d);
return d.result;
}
// ── 그리기 ──
function rgb(c: number[], k: number): string {
return 'rgb(' + Math.min(255, Math.round(c[0] * k)) + ',' + Math.min(255, Math.round(c[1] * k)) + ',' + Math.min(255, Math.round(c[2] * k)) + ')';
}
function quad(c: Vec, u: Vec, v: Vec, s: number): number[][] {
var o: number[][] = [];
var sg = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
for (var i = 0; i < 4; i += 1) {
o.push(scr([c[0] + (u[0] * sg[i][0] + v[0] * sg[i][1]) * s * DIE_HALF, c[1] + (u[1] * sg[i][0] + v[1] * sg[i][1]) * s * DIE_HALF, c[2] + (u[2] * sg[i][0] + v[2] * sg[i][1]) * s * DIE_HALF]));
}
return o;
}
function fillPoly(pts: number[][], color: Paint, edge: number): void {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
if (edge > 0) { ctx.strokeStyle = color; ctx.lineWidth = edge; ctx.stroke(); }
}
function drawDie(d: Die, hi: number, alpha: number): void {
var pal = DICE_PAL[d.pal % DICE_PAL.length];
ctx.globalAlpha = alpha;
ctx.lineJoin = 'round';
for (var f = 0; f < 6; f += 1) {
var nw = matVec(d.R, FACE_N[f]);
var cw: Vec = [d.p[0] + nw[0] * DIE_HALF, d.p[1] + nw[1] * DIE_HALF, d.p[2] + nw[2] * DIE_HALF];
var cc = camPoint(cw), nc = camDir(nw);
if (nc[0] * cc[0] + nc[1] * cc[1] + nc[2] * cc[2] >= 0) continue;
var uw = matVec(d.R, FACE_U[f]), vw = matVec(d.R, FACE_V[f]);
var lit = 0.66 + 0.34 * Math.max(0, dot(nw, LIGHT));
var k0 = scr(cw)[2];
fillPoly(quad(cw, uw, vw, 1), rgb(pal.edge, lit), k0 * 0.1);
fillPoly(quad(cw, uw, vw, 0.84), rgb(pal.face, lit), k0 * 0.16);
if (f === hi) fillPoly(quad(cw, uw, vw, 0.84), 'rgba(255,255,255,0.4)', 0);
var val = FACE_VAL[f];
for (var pi = 0; pi < PIPS[val].length; pi += 1) {
var a = PIPS[val][pi][0] * 0.5 * DIE_HALF, b = PIPS[val][pi][1] * 0.5 * DIE_HALF;
var ring: number[][] = [];
for (var j = 0; j < 10; j += 1) {
var an = j * 0.62832, rr = 0.17 * DIE_HALF;
var ca = Math.cos(an) * rr, sa = Math.sin(an) * rr;
ring.push(scr([cw[0] + uw[0] * (a + ca) + vw[0] * (b + sa) + nw[0] * 0.004, cw[1] + uw[1] * (a + ca) + vw[1] * (b + sa) + nw[1] * 0.004, cw[2] + uw[2] * (a + ca) + vw[2] * (b + sa) + nw[2] * 0.004]));
}
fillPoly(ring, pal.pip, 0);
}
}
ctx.globalAlpha = 1;
}
function drawShadow(d: Die, alpha: number): void {
var s = scr([d.p[0], 0, d.p[2]]);
var h = Math.max(0, d.p[1] - DIE_HALF);
var r = DIE_HALF * s[2] * (1.2 + h * 0.18);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.42);
ctx.beginPath();
ctx.arc(0, 0, r, 0, 6.2832);
ctx.fillStyle = 'rgba(30,60,60,' + (0.34 * alpha / (1 + h * 0.7)) + ')';
ctx.fill();
ctx.restore();
}
var FELT_DOTS: number[][] = [];
(function (): void {
var fr = seedRand(99);
for (var i = 0; i < 70; i += 1) FELT_DOTS.push([(fr() * 2 - 1) * TABLE_X, TABLE_Z0 + fr() * (TABLE_Z1 - TABLE_Z0), 0.04 + fr() * 0.08]);
})();
function tablePts(m: number): number[][] {
return [scr([-TABLE_X - m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z1 + m]), scr([-TABLE_X - m, 0, TABLE_Z1 + m])];
}
function drawStage(lightX: number, lightK: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var bg = ctx.createRadialGradient(160, 80, 10, 160, 80, 210);
bg.addColorStop(0, '#3f3764');
bg.addColorStop(1, '#221d36');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
fillPoly(tablePts(0.5), '#8e80c8', 0);
var tp = tablePts(0);
var g = ctx.createLinearGradient(0, tp[0][1], 0, tp[2][1]);
g.addColorStop(0, '#9fdcc3');
g.addColorStop(1, '#78c8aa');
fillPoly(tp, g, 0);
for (var i = 0; i < FELT_DOTS.length; i += 1) {
var s = scr([FELT_DOTS[i][0], 0, FELT_DOTS[i][1]]);
ctx.beginPath();
ctx.arc(s[0], s[1], FELT_DOTS[i][2] * s[2], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,0.12)';
ctx.fill();
}
var lp = scr([lightX, 0, 1]);
var lg = ctx.createRadialGradient(lp[0], lp[1], 4, lp[0], lp[1], 120);
lg.addColorStop(0, 'rgba(255,255,255,' + (0.3 * lightK) + ')');
lg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.save();
ctx.beginPath();
ctx.moveTo(tp[0][0], tp[0][1]); ctx.lineTo(tp[1][0], tp[1][1]); ctx.lineTo(tp[2][0], tp[2][1]); ctx.lineTo(tp[3][0], tp[3][1]);
ctx.closePath();
ctx.clip();
ctx.fillStyle = lg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.restore();
}
function drawChip(x: number, y: number, s: number, value: number, pal: number, mark: boolean): void {
var p = DICE_PAL[pal % DICE_PAL.length];
ctx.fillStyle = value > 0 ? rgb(p.face, 1) : 'rgba(255,255,255,0.18)';
ctx.strokeStyle = mark ? '#7fe3bd' : rgb(p.edge, 1);
ctx.lineWidth = mark ? 1.8 : 1;
ctx.fillRect(x, y, s, s);
ctx.strokeRect(x, y, s, s);
for (var i = 0; i < PIPS[value].length; i += 1) {
ctx.beginPath();
ctx.arc(x + s / 2 + PIPS[value][i][0] * s * 0.26, y + s / 2 + PIPS[value][i][1] * s * 0.26, s * 0.1, 0, 6.2832);
ctx.fillStyle = p.pip;
ctx.fill();
}
}
// ── 상태 줄 (aria-live) ──
var stLabel = '', stNum = '', stAt = -9999;
function setStatus(label: string, num: string, force: boolean): void {
if (label === stLabel && num === stNum) return;
if (!force && label === stLabel && elapsed - stAt < 450) return;
stLabel = label; stNum = num; stAt = elapsed;
labelEl.textContent = label;
numEl.textContent = num;
}
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Fn[] = [];
// 기법: 매 걸음 v.y -= GRAVITY * dt 로 포물선을 만들고, 가장 낮은 꼭짓점이 바닥을 뚫으면
// 접촉점 속도를 반사하며 DICE_RESTITUTION 을 곱한다. 튕김 정점 높이가 e^2 비율로 줄어드는 것이 오른쪽 막대다.
var T_BOUNCE = '튕김 횟수';
var T_RESULT = '결과';
var T_ROLLING = '굴리는 중';
var die: Die = newDie(0, -3.4, 3.4, -1.8, 2.6);
var rng: RandFn = seedRand(DICE_SEED + 2);
var trail: Vec[] = [];
var rings: number[][] = [];
var apex: number[] = [];
var prevVy = 0;
var lastSettle = 0;
function sceneInit(): void { die.phase = PH_IDLE; }
function sceneRoll(manual: boolean): void {
launch(die, rng, -3.2, rng() * 2.2 - 0.6, 3.6, 0.2, 3.4, 15);
trail = []; apex = []; prevVy = 0;
setStatus(T_BOUNCE, '0', true);
}
function sceneDone(): boolean { return die.phase === PH_HOLD; }
function sceneFinish(): void { setStatus(T_RESULT, String(die.result), true); }
function stepWorld(): void {
if (die.phase === PH_IDLE) return;
var wasFly = die.phase === PH_FLY;
stepDie(die);
if (wasFly) {
if (prevVy > 0 && die.v[1] <= 0 && die.p[1] > DIE_HALF + 0.05) apex.push(die.p[1] - DIE_HALF);
prevVy = die.v[1];
if (Math.round(die.t / FIXED_DT) % 2 === 0) { trail.push([die.p[0], die.p[1], die.p[2]]); if (trail.length > 60) trail.shift(); }
}
if (die.hit > 0) { rings.push([die.hx, die.hz, elapsed]); die.hit = 0; }
var live: number[][] = [];
for (var i = 0; i < rings.length; i += 1) if (elapsed - rings[i][2] < 600) live.push(rings[i]);
rings = live;
if (die.phase === PH_HOLD) setStatus(T_RESULT, String(die.result), true);
else setStatus(T_BOUNCE, String(die.bounces), false);
if (die.phase === PH_HOLD && demo && die.hold >= HOLD_MS) sceneRoll(false);
}
function drawWorld(): void {
drawStage(die.p[0], 0.8);
for (var i = 0; i < rings.length; i += 1) {
var f = (elapsed - rings[i][2]) / 600;
var s = scr([rings[i][0], 0, rings[i][1]]);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.4);
ctx.beginPath(); ctx.arc(0, 0, s[2] * (0.5 + f * 2.2), 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,' + (0.8 * (1 - f)) + ')'; ctx.lineWidth = 2.5; ctx.stroke();
ctx.restore();
}
for (var t = 0; t < trail.length; t += 1) {
var q = scr(trail[t]);
ctx.beginPath(); ctx.arc(q[0], q[1], 1 + 2.2 * t / trail.length, 0, 6.2832);
ctx.fillStyle = 'rgba(255,255,255,' + (0.1 + 0.55 * t / trail.length) + ')'; ctx.fill();
}
if (die.phase !== PH_IDLE) {
drawShadow(die, 1);
drawDie(die, -1, 1);
}
// 튕김 정점 높이 막대: 낮아지는 비율이 반발 계수의 제곱에 가깝다
ctx.fillStyle = 'rgba(255,255,255,0.14)';
ctx.fillRect(196, STRIP_BASE - BAR_MAX - 3, 116, BAR_MAX + 5);
for (var k = 0; k < apex.length && k < 7; k += 1) {
var h = clamp(apex[k] / 2.4, 0.04, 1) * BAR_MAX;
ctx.fillStyle = k === apex.length - 1 ? '#ff9ebb' : '#c9b8ff';
ctx.fillRect(202 + k * 15, STRIP_BASE - h, 11, h);
}
}
function fit(): void {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs = -1;
var acc = 0;
function tick(ts: number): void {
if (lastTs < 0) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
// 모션 줄이기: 애니메이션 없이 결과가 나올 때까지 같은 계산을 한 번에 돌려 최종 장면만 그린다
function runToDone(): void {
var i = 0;
do { elapsed += FIXED_DT; stepWorld(); i += 1; } while (i < 1500 && !sceneDone());
sceneFinish();
drawWorld();
}
function onRollClick(): void {
stopDemo();
sceneRoll(true);
if (reduce) runToDone();
}
sceneInit();
sceneRoll(false);
if (reduce) {
runToDone();
} else {
for (var w0 = 0; w0 < 8; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
}
btn.addEventListener('click', onRollClick);
cleaners.push(function (): void { btn.removeEventListener('click', onRollClick); });
fit();
window.addEventListener('resize', fit);
cleaners.push(function (): void { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
return destroy;
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} role="img" aria-label="던진 주사위에 중력을 더해 포물선을 그리게 하고, 바닥에 닿을 때마다 접촉 속도에 반발 계수를 곱해 튕김 정점 높이가 줄어드는 모습을 그림자와 궤적 점과 함께 그린다." />
<output className={styles.bp__status} aria-live="polite"><span ref={labelRef}>굴리는 중</span> <b ref={numRef} /></output>
<button type="button" className={styles.bp__btn} ref={btnRef}>굴리기</button>
<p className={styles.bp__caption}>던진 주사위에 중력을 더해 포물선을 그리게 하고, 바닥에 닿을 때마다 접촉 속도에 반발 계수를 곱해 튕김 정점 높이가 줄어드는 모습을 그림자와 궤적 점과 함께 그린다.</p>
</div>
</main>
);
}02 · 放物線で投げて床で跳ねるサイコロ — 計測 累積面積 23.341% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var low = d.p[1] + my;
if (low >= 0) return;
d.p[1] -= low;
var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
if (cvy >= 0) return;
var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
d.v[1] += j;
d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;
03止まりながら上の目を読むサイコロ
settleStep() は六つの面の法線のうちyが最大の面を上の目として読み、その面が真上を向くまで残りの角度の SETTLE_RATE 0.16ぶんずつを一歩ごとに回します。投げてから SETTLE_AFTER_MS の2000msが過ぎるか、三回以上跳ねて速度が落ちると止まる段階へ移り、それより前でも readNormals() が六つの目の法線yを棒で見せます。累積面積13.4%・平均強度65.7で、動いたのは18/23フレームでした。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" role="img" aria-label="回転が遅くなるにつれ、六つの面の法線のうち最も上を向く面を選んでその目を結果として読み、その面が真上に来るまで向きをなめらかに補正する。"></canvas>
<output class="bp__status" aria-live="polite"><span id="bpLabel">転がり中</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">振る</button>
<p class="bp__caption">回転が遅くなるにつれ、六つの面の法線のうち最も上を向く面を選んでその目を結果として読み、その面が真上に来るまで向きをなめらかに補正する。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
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: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
display: flex;
align-items: baseline;
gap: $sp-2;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: $sp-3;
top: $sp-3;
min-height: $sp-8;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-instant $ease-out;
}
.bp__btn:hover { transform: translateY(-1px); }
.bp__btn:active { transform: translateY(1px); }
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .9);
outline-offset: 2px;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__btn { font-size: 11px; padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
.bp__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: 12px;
top: 12px;
min-height: 32px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 140ms ease-out;
}
.bp__btn:hover {
transform: translateY(-1px);
}
.bp__btn:active {
transform: translateY(1px);
}
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.9);
outline-offset: 2px;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
padding: 4px 8px;
}
.bp__btn {
font-size: 11px;
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
.bp__btn {
transition: none;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var labelEl = document.getElementById('bpLabel');
var numEl = document.getElementById('bpNum');
var btn = document.getElementById('bpBtn');
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var DICE_RESTITUTION = 0.45;
var SPIN_DAMPING = 0.985;
var DICE_SEED = 7;
var GRAVITY = 22;
var DIE_HALF = 1;
var INERTIA = 2 * DIE_HALF * DIE_HALF / 3;
var FLOOR_FRICTION = 0.35;
var SETTLE_AFTER_MS = 2000;
var SETTLE_RATE = 0.16;
var HOLD_MS = 700;
var CAM_Y = 5.6, CAM_BACK = 12, FOCAL = 275, VIEW_CX = 160, VIEW_CY = 82;
var TABLE_X = 4.4, TABLE_Z0 = -3, TABLE_Z1 = 5;
var PH_FLY = 0, PH_SETTLE = 1, PH_HOLD = 2, PH_IDLE = 3;
var STRIP_BASE = 128;
var BAR_MAX = 18;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function cross(a, b) { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function unit(a) { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function matMul(a, b) {
var o = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (var i = 0; i < 3; i += 1) {
for (var j = 0; j < 3; j += 1) o[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
}
return o;
}
function matVec(m, v) {
return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
}
// 로드리게스 공식: 단위 축 ax 둘레로 ang 만큼 도는 회전 행렬
function rotAxis(ax, ang) {
var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c];
}
function orthonormalize(m) {
var a = unit([m[0], m[1], m[2]]);
var k = a[0] * m[3] + a[1] * m[4] + a[2] * m[5];
var b = unit([m[3] - a[0] * k, m[4] - a[1] * k, m[5] - a[2] * k]);
var c = cross(a, b);
return [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]];
}
function randAxis(rnd) { return unit([rnd() * 2 - 1, rnd() * 2 - 1, rnd() * 2 - 1]); }
var PITCH = Math.atan2(CAM_Y, CAM_BACK);
var CAM_S = Math.sin(PITCH), CAM_C = Math.cos(PITCH);
function camPoint(p) {
var qy = p[1] - CAM_Y, qz = p[2] + CAM_BACK;
return [p[0], qy * CAM_C + qz * CAM_S, -qy * CAM_S + qz * CAM_C];
}
function camDir(v) { return [v[0], v[1] * CAM_C + v[2] * CAM_S, -v[1] * CAM_S + v[2] * CAM_C]; }
function scr(p) {
var c = camPoint(p);
var k = FOCAL / c[2];
return [VIEW_CX + c[0] * k, VIEW_CY - c[1] * k, k];
}
var FACE_N = [[0, 1, 0], [0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];
var FACE_U = [[1, 0, 0], [1, 0, 0], [0, 1, 0], [0, 1, 0], [1, 0, 0], [1, 0, 0]];
var FACE_V = [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]];
var FACE_VAL = [1, 6, 2, 5, 3, 4];
var PIPS = [[], [[0, 0]], [[-1, -1], [1, 1]], [[-1, -1], [0, 0], [1, 1]],
[[-1, -1], [1, -1], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
[[-1, -1], [-1, 0], [-1, 1], [1, -1], [1, 0], [1, 1]]];
var DICE_PAL = [
{ face: [255, 214, 226], edge: [240, 160, 186], pip: '#4b3a63' },
{ face: [200, 232, 255], edge: [140, 190, 236], pip: '#3b4a6b' },
{ face: [224, 212, 255], edge: [170, 150, 232], pip: '#46386b' },
{ face: [255, 240, 196], edge: [236, 206, 120], pip: '#5a4636' }
];
var LIGHT = unit([-0.45, 0.85, -0.35]);
function newDie(pal, xmin, xmax, zmin, zmax) {
return { p: [0, DIE_HALF, 0], v: [0, 0, 0], R: [1, 0, 0, 0, 1, 0, 0, 0, 1], w: [0, 0, 0], pal: pal, phase: PH_IDLE, t: 0, hold: 0,
bounces: 0, result: 0, top: 1, hit: 0, hx: 0, hz: 0, xmin: xmin, xmax: xmax, zmin: zmin, zmax: zmax };
}
function topIndex(d) {
var best = 0, by = -9;
for (var f = 0; f < 6; f += 1) {
var ny = d.R[3] * FACE_N[f][0] + d.R[4] * FACE_N[f][1] + d.R[5] * FACE_N[f][2];
if (ny > by) { by = ny; best = f; }
}
return best;
}
// 던지기: 시작 위치·속도·회전을 시드 수열에서 순서대로 뽑는다 (뽑는 순서가 곧 재현 규칙)
function launch(d, rnd, x, z, vx, vz, vy0, spin) {
d.p = [x, 2.4, z];
d.v = [vx + (rnd() - 0.5) * 1.6, vy0 + rnd() * 2.2, vz + (rnd() - 0.5) * 1.6];
var ax = randAxis(rnd);
var s = spin * (0.6 + rnd() * 0.8);
d.w = [ax[0] * s, ax[1] * s, ax[2] * s];
d.R = rotAxis(randAxis(rnd), rnd() * 6.283);
d.phase = PH_FLY; d.t = 0; d.hold = 0; d.bounces = 0; d.result = 0; d.hit = 0;
}
function spinStep(d, dt) {
var wl = Math.hypot(d.w[0], d.w[1], d.w[2]);
if (wl > 1e-5) d.R = matMul(rotAxis([d.w[0] / wl, d.w[1] / wl, d.w[2] / wl], wl * dt), d.R);
}
function floorStep(d) {
var mi = 0, my = 99;
var rx = 0, ry = 0, rz = 0;
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? DIE_HALF : -DIE_HALF, ly = (i & 2) ? DIE_HALF : -DIE_HALF, lz = (i & 4) ? DIE_HALF : -DIE_HALF;
var wy = d.R[3] * lx + d.R[4] * ly + d.R[5] * lz;
if (wy < my) { my = wy; mi = i; rx = d.R[0] * lx + d.R[1] * ly + d.R[2] * lz; ry = wy; rz = d.R[6] * lx + d.R[7] * ly + d.R[8] * lz; }
}
var low = d.p[1] + my;
if (low >= 0) return;
d.p[1] -= low;
var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
if (cvy >= 0) return;
var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
d.v[1] += j;
d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;
var tl = Math.hypot(cvx, cvz);
if (tl > 1e-4) {
var jt = Math.min(FLOOR_FRICTION * j, tl * 0.5);
var tx = -cvx / tl * jt, tz = -cvz / tl * jt;
d.v[0] += tx; d.v[2] += tz;
d.w[0] += ry * tz / INERTIA; d.w[1] += (rz * tx - rx * tz) / INERTIA; d.w[2] += -ry * tx / INERTIA;
}
if (cvy < -1.2) { d.bounces += 1; d.hit = -cvy; d.hx = d.p[0] + rx; d.hz = d.p[2] + rz; }
}
function wallStep(d) {
if (d.p[0] < d.xmin) { d.p[0] = d.xmin; d.v[0] = Math.abs(d.v[0]) * 0.5; }
if (d.p[0] > d.xmax) { d.p[0] = d.xmax; d.v[0] = -Math.abs(d.v[0]) * 0.5; }
if (d.p[2] < d.zmin) { d.p[2] = d.zmin; d.v[2] = Math.abs(d.v[2]) * 0.5; }
if (d.p[2] > d.zmax) { d.p[2] = d.zmax; d.v[2] = -Math.abs(d.v[2]) * 0.5; }
}
// 멈추기: 법선 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 정확히 위를 보도록 남은 각도의 SETTLE_RATE 만큼씩 돌린다
function settleStep(d) {
var k = topIndex(d);
d.top = FACE_VAL[k];
var nw = matVec(d.R, FACE_N[k]);
var ang = Math.acos(clamp(nw[1], -1, 1));
if (ang > 1e-4) {
var ax = unit(cross(nw, [0, 1, 0]));
d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
}
d.R = orthonormalize(matMul(rotAxis([0, 1, 0], d.w[1] * dtS), d.R));
d.w[1] *= 0.88;
d.v[0] *= 0.86; d.v[2] *= 0.86; d.v[1] = 0;
d.p[0] += d.v[0] * dtS; d.p[2] += d.v[2] * dtS;
d.p[1] += (DIE_HALF - d.p[1]) * 0.22;
wallStep(d);
if (ang < 0.004 && Math.abs(d.p[1] - DIE_HALF) < 0.01) {
d.p[1] = DIE_HALF;
var q = topIndex(d);
d.R = orthonormalize(matMul(rotAxis(unit(cross(matVec(d.R, FACE_N[q]), [0, 1, 0])), Math.acos(clamp(matVec(d.R, FACE_N[q])[1], -1, 1))), d.R));
d.phase = PH_HOLD; d.hold = 0; d.result = FACE_VAL[q]; d.top = d.result;
}
}
function stepDie(d) {
if (d.phase === PH_FLY) {
d.t += FIXED_DT;
d.v[1] -= GRAVITY * dtS;
d.p[0] += d.v[0] * dtS; d.p[1] += d.v[1] * dtS; d.p[2] += d.v[2] * dtS;
spinStep(d, dtS);
d.w[0] *= SPIN_DAMPING; d.w[1] *= SPIN_DAMPING; d.w[2] *= SPIN_DAMPING;
wallStep(d);
floorStep(d);
var sp = Math.hypot(d.v[0], d.v[1], d.v[2]);
if (d.t > SETTLE_AFTER_MS || (d.bounces >= 3 && sp < 1.6)) d.phase = PH_SETTLE;
} else if (d.phase === PH_SETTLE) {
d.t += FIXED_DT;
settleStep(d);
} else if (d.phase === PH_HOLD) {
d.hold += FIXED_DT;
}
}
function runUntilHold(d) {
for (var i = 0; i < 1200 && d.phase !== PH_HOLD; i += 1) stepDie(d);
return d.result;
}
// ── 그리기 ──
function rgb(c, k) {
return 'rgb(' + Math.min(255, Math.round(c[0] * k)) + ',' + Math.min(255, Math.round(c[1] * k)) + ',' + Math.min(255, Math.round(c[2] * k)) + ')';
}
function quad(c, u, v, s) {
var o = [];
var sg = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
for (var i = 0; i < 4; i += 1) {
o.push(scr([c[0] + (u[0] * sg[i][0] + v[0] * sg[i][1]) * s * DIE_HALF, c[1] + (u[1] * sg[i][0] + v[1] * sg[i][1]) * s * DIE_HALF, c[2] + (u[2] * sg[i][0] + v[2] * sg[i][1]) * s * DIE_HALF]));
}
return o;
}
function fillPoly(pts, color, edge) {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
if (edge > 0) { ctx.strokeStyle = color; ctx.lineWidth = edge; ctx.stroke(); }
}
function drawDie(d, hi, alpha) {
var pal = DICE_PAL[d.pal % DICE_PAL.length];
ctx.globalAlpha = alpha;
ctx.lineJoin = 'round';
for (var f = 0; f < 6; f += 1) {
var nw = matVec(d.R, FACE_N[f]);
var cw = [d.p[0] + nw[0] * DIE_HALF, d.p[1] + nw[1] * DIE_HALF, d.p[2] + nw[2] * DIE_HALF];
var cc = camPoint(cw), nc = camDir(nw);
if (nc[0] * cc[0] + nc[1] * cc[1] + nc[2] * cc[2] >= 0) continue;
var uw = matVec(d.R, FACE_U[f]), vw = matVec(d.R, FACE_V[f]);
var lit = 0.66 + 0.34 * Math.max(0, dot(nw, LIGHT));
var k0 = scr(cw)[2];
fillPoly(quad(cw, uw, vw, 1), rgb(pal.edge, lit), k0 * 0.1);
fillPoly(quad(cw, uw, vw, 0.84), rgb(pal.face, lit), k0 * 0.16);
if (f === hi) fillPoly(quad(cw, uw, vw, 0.84), 'rgba(255,255,255,0.4)', 0);
var val = FACE_VAL[f];
for (var pi = 0; pi < PIPS[val].length; pi += 1) {
var a = PIPS[val][pi][0] * 0.5 * DIE_HALF, b = PIPS[val][pi][1] * 0.5 * DIE_HALF;
var ring = [];
for (var j = 0; j < 10; j += 1) {
var an = j * 0.62832, rr = 0.17 * DIE_HALF;
var ca = Math.cos(an) * rr, sa = Math.sin(an) * rr;
ring.push(scr([cw[0] + uw[0] * (a + ca) + vw[0] * (b + sa) + nw[0] * 0.004, cw[1] + uw[1] * (a + ca) + vw[1] * (b + sa) + nw[1] * 0.004, cw[2] + uw[2] * (a + ca) + vw[2] * (b + sa) + nw[2] * 0.004]));
}
fillPoly(ring, pal.pip, 0);
}
}
ctx.globalAlpha = 1;
}
function drawShadow(d, alpha) {
var s = scr([d.p[0], 0, d.p[2]]);
var h = Math.max(0, d.p[1] - DIE_HALF);
var r = DIE_HALF * s[2] * (1.2 + h * 0.18);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.42);
ctx.beginPath();
ctx.arc(0, 0, r, 0, 6.2832);
ctx.fillStyle = 'rgba(30,60,60,' + (0.34 * alpha / (1 + h * 0.7)) + ')';
ctx.fill();
ctx.restore();
}
var FELT_DOTS = [];
(function () {
var fr = seedRand(99);
for (var i = 0; i < 70; i += 1) FELT_DOTS.push([(fr() * 2 - 1) * TABLE_X, TABLE_Z0 + fr() * (TABLE_Z1 - TABLE_Z0), 0.04 + fr() * 0.08]);
})();
function tablePts(m) {
return [scr([-TABLE_X - m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z1 + m]), scr([-TABLE_X - m, 0, TABLE_Z1 + m])];
}
function drawStage(lightX, lightK) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var bg = ctx.createRadialGradient(160, 80, 10, 160, 80, 210);
bg.addColorStop(0, '#3f3764');
bg.addColorStop(1, '#221d36');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
fillPoly(tablePts(0.5), '#8e80c8', 0);
var tp = tablePts(0);
var g = ctx.createLinearGradient(0, tp[0][1], 0, tp[2][1]);
g.addColorStop(0, '#9fdcc3');
g.addColorStop(1, '#78c8aa');
fillPoly(tp, g, 0);
for (var i = 0; i < FELT_DOTS.length; i += 1) {
var s = scr([FELT_DOTS[i][0], 0, FELT_DOTS[i][1]]);
ctx.beginPath();
ctx.arc(s[0], s[1], FELT_DOTS[i][2] * s[2], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,0.12)';
ctx.fill();
}
var lp = scr([lightX, 0, 1]);
var lg = ctx.createRadialGradient(lp[0], lp[1], 4, lp[0], lp[1], 120);
lg.addColorStop(0, 'rgba(255,255,255,' + (0.3 * lightK) + ')');
lg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.save();
ctx.beginPath();
ctx.moveTo(tp[0][0], tp[0][1]); ctx.lineTo(tp[1][0], tp[1][1]); ctx.lineTo(tp[2][0], tp[2][1]); ctx.lineTo(tp[3][0], tp[3][1]);
ctx.closePath();
ctx.clip();
ctx.fillStyle = lg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.restore();
}
function drawChip(x, y, s, value, pal, mark) {
var p = DICE_PAL[pal % DICE_PAL.length];
ctx.fillStyle = value > 0 ? rgb(p.face, 1) : 'rgba(255,255,255,0.18)';
ctx.strokeStyle = mark ? '#7fe3bd' : rgb(p.edge, 1);
ctx.lineWidth = mark ? 1.8 : 1;
ctx.fillRect(x, y, s, s);
ctx.strokeRect(x, y, s, s);
for (var i = 0; i < PIPS[value].length; i += 1) {
ctx.beginPath();
ctx.arc(x + s / 2 + PIPS[value][i][0] * s * 0.26, y + s / 2 + PIPS[value][i][1] * s * 0.26, s * 0.1, 0, 6.2832);
ctx.fillStyle = p.pip;
ctx.fill();
}
}
// ── 상태 줄 (aria-live) ──
var stLabel = '', stNum = '', stAt = -9999;
function setStatus(label, num, force) {
if (label === stLabel && num === stNum) return;
if (!force && label === stLabel && elapsed - stAt < 450) return;
stLabel = label; stNum = num; stAt = elapsed;
labelEl.textContent = label;
numEl.textContent = num;
}
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
// 기법: 여섯 면의 월드 법선 n 중 n.y 가 가장 큰 면이 윗면이다. 회전이 느려지면 그 면의 n 과 (0,1,0) 사이 각도를
// 매 걸음 SETTLE_RATE 비율만큼 줄이는 회전을 곱해(남은 각도의 지수 보정) 면이 정확히 위를 보게 한다.
var T_TOP = '上の面';
var T_RESULT = '結果';
var T_ROLLING = '転がり中';
var die = newDie(1, -2.2, 2.2, -1.2, 2.0);
var rng = seedRand(DICE_SEED + 3);
var normY = [0, 0, 0, 0, 0, 0];
var bestIdx = 0;
function sceneInit() { die.phase = PH_IDLE; }
function sceneRoll(manual) {
launch(die, rng, -1.4, rng() * 1.6 - 0.2, 1.8, 0.3, 2.4, 17);
setStatus(T_ROLLING, '', true);
}
function sceneDone() { return die.phase === PH_HOLD; }
function sceneFinish() { setStatus(T_RESULT, String(die.result), true); }
function readNormals() {
bestIdx = 0;
for (var f = 0; f < 6; f += 1) {
var ny = die.R[3] * FACE_N[f][0] + die.R[4] * FACE_N[f][1] + die.R[5] * FACE_N[f][2];
normY[FACE_VAL[f] - 1] = ny;
if (ny > die.R[3] * FACE_N[bestIdx][0] + die.R[4] * FACE_N[bestIdx][1] + die.R[5] * FACE_N[bestIdx][2]) bestIdx = f;
}
}
function stepWorld() {
if (die.phase === PH_IDLE) return;
stepDie(die);
readNormals();
if (die.phase === PH_HOLD) setStatus(T_RESULT, String(die.result), true);
else if (die.phase === PH_SETTLE || die.bounces > 0) setStatus(T_TOP, String(FACE_VAL[bestIdx]), false);
if (die.phase === PH_HOLD && demo && die.hold >= HOLD_MS) sceneRoll(false);
}
function drawWorld() {
drawStage(die.p[0], 0.8);
if (die.phase === PH_IDLE) return;
drawShadow(die, 1);
var showHi = die.bounces > 0 || die.phase !== PH_FLY;
drawDie(die, showHi ? bestIdx : -1, 1);
if (showHi) {
var nw = matVec(die.R, FACE_N[bestIdx]);
var a = scr([die.p[0] + nw[0] * DIE_HALF, die.p[1] + nw[1] * DIE_HALF, die.p[2] + nw[2] * DIE_HALF]);
var b = scr([die.p[0] + nw[0] * (DIE_HALF + 1.5), die.p[1] + nw[1] * (DIE_HALF + 1.5), die.p[2] + nw[2] * (DIE_HALF + 1.5)]);
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]);
ctx.strokeStyle = '#fff4b0'; ctx.lineWidth = 2.4; ctx.lineCap = 'round'; ctx.stroke();
ctx.beginPath(); ctx.arc(b[0], b[1], 3.4, 0, 6.2832); ctx.fillStyle = '#fff4b0'; ctx.fill();
}
// 여섯 면의 법선 y 막대: 가장 큰 막대가 上の面, 보정이 끝나면 1.0 에 닿는다
ctx.fillStyle = 'rgba(255,255,255,0.14)';
ctx.fillRect(84, STRIP_BASE - BAR_MAX - 3, 152, BAR_MAX + 17);
ctx.font = '8px sans-serif';
ctx.textAlign = 'center';
for (var v = 0; v < 6; v += 1) {
var h = Math.max(0, normY[v]) * BAR_MAX;
ctx.fillStyle = (v + 1) === FACE_VAL[bestIdx] ? '#ff9ebb' : '#c9b8ff';
ctx.fillRect(92 + v * 24, STRIP_BASE - h, 16, h);
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.fillText(String(v + 1), 100 + v * 24, STRIP_BASE + 9);
}
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs = -1;
var acc = 0;
function tick(ts) {
if (lastTs < 0) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
// 모션 줄이기: 애니메이션 없이 결과가 나올 때까지 같은 계산을 한 번에 돌려 최종 장면만 그린다
function runToDone() {
var i = 0;
do { elapsed += FIXED_DT; stepWorld(); i += 1; } while (i < 1500 && !sceneDone());
sceneFinish();
drawWorld();
}
function onRollClick() {
stopDemo();
sceneRoll(true);
if (reduce) runToDone();
}
sceneInit();
sceneRoll(false);
if (reduce) {
runToDone();
} else {
for (var w0 = 0; w0 < 8; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
}
btn.addEventListener('click', onRollClick);
cleaners.push(function () { btn.removeEventListener('click', onRollClick); });
fit();
window.addEventListener('resize', fit);
cleaners.push(function () { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
})();import { useEffect, useRef } from "react";
import styles from "./SpinDiceSceneSettleTopFaceRead.module.scss";
export default function SpinDiceSceneSettleTopFaceRead() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const numRef = useRef<HTMLElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current, canvasEl = canvasRef.current, l0 = labelRef.current, n0 = numRef.current, b0 = btnRef.current;
if (!rootEl || !canvasEl || !l0 || !n0 || !b0) return;
const root: HTMLDivElement = rootEl;
const canvas: HTMLCanvasElement = canvasEl;
const ctx0 = canvas.getContext("2d");
if (!ctx0) return;
const ctx: CanvasRenderingContext2D = ctx0;
const wrap: HTMLElement = canvas.parentElement ?? root;
const labelEl: HTMLElement = l0;
const numEl: HTMLElement = n0;
const btn: HTMLButtonElement = b0;
type Vec = number[];
type Mat = number[];
type RandFn = () => number;
type Fn = () => void;
type Paint = string | CanvasGradient;
type Pal = { face: number[]; edge: number[]; pip: string };
type Die = { p: Vec; v: Vec; R: Mat; w: Vec; pal: number; phase: number; t: number; hold: number; bounces: number; result: number; top: number; hit: number; hx: number; hz: number; xmin: number; xmax: number; zmin: number; zmax: number };
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var DICE_RESTITUTION = 0.45;
var SPIN_DAMPING = 0.985;
var DICE_SEED = 7;
var GRAVITY = 22;
var DIE_HALF = 1;
var INERTIA = 2 * DIE_HALF * DIE_HALF / 3;
var FLOOR_FRICTION = 0.35;
var SETTLE_AFTER_MS = 2000;
var SETTLE_RATE = 0.16;
var HOLD_MS = 700;
var CAM_Y = 5.6, CAM_BACK = 12, FOCAL = 275, VIEW_CX = 160, VIEW_CY = 82;
var TABLE_X = 4.4, TABLE_Z0 = -3, TABLE_Z1 = 5;
var PH_FLY = 0, PH_SETTLE = 1, PH_HOLD = 2, PH_IDLE = 3;
var STRIP_BASE = 128;
var BAR_MAX = 18;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed: number): RandFn {
var s = seed >>> 0;
return function (): number { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function dot(a: Vec, b: Vec): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function cross(a: Vec, b: Vec): Vec { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function unit(a: Vec): Vec { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function matMul(a: Mat, b: Mat): Mat {
var o: Mat = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (var i = 0; i < 3; i += 1) {
for (var j = 0; j < 3; j += 1) o[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
}
return o;
}
function matVec(m: Mat, v: Vec): Vec {
return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
}
// 로드리게스 공식: 단위 축 ax 둘레로 ang 만큼 도는 회전 행렬
function rotAxis(ax: Vec, ang: number): Mat {
var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c];
}
function orthonormalize(m: Mat): Mat {
var a = unit([m[0], m[1], m[2]]);
var k = a[0] * m[3] + a[1] * m[4] + a[2] * m[5];
var b = unit([m[3] - a[0] * k, m[4] - a[1] * k, m[5] - a[2] * k]);
var c = cross(a, b);
return [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]];
}
function randAxis(rnd: RandFn): Vec { return unit([rnd() * 2 - 1, rnd() * 2 - 1, rnd() * 2 - 1]); }
var PITCH = Math.atan2(CAM_Y, CAM_BACK);
var CAM_S = Math.sin(PITCH), CAM_C = Math.cos(PITCH);
function camPoint(p: Vec): Vec {
var qy = p[1] - CAM_Y, qz = p[2] + CAM_BACK;
return [p[0], qy * CAM_C + qz * CAM_S, -qy * CAM_S + qz * CAM_C];
}
function camDir(v: Vec): Vec { return [v[0], v[1] * CAM_C + v[2] * CAM_S, -v[1] * CAM_S + v[2] * CAM_C]; }
function scr(p: Vec): Vec {
var c = camPoint(p);
var k = FOCAL / c[2];
return [VIEW_CX + c[0] * k, VIEW_CY - c[1] * k, k];
}
var FACE_N: Vec[] = [[0, 1, 0], [0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];
var FACE_U: Vec[] = [[1, 0, 0], [1, 0, 0], [0, 1, 0], [0, 1, 0], [1, 0, 0], [1, 0, 0]];
var FACE_V: Vec[] = [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]];
var FACE_VAL: number[] = [1, 6, 2, 5, 3, 4];
var PIPS: number[][][] = [[], [[0, 0]], [[-1, -1], [1, 1]], [[-1, -1], [0, 0], [1, 1]],
[[-1, -1], [1, -1], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
[[-1, -1], [-1, 0], [-1, 1], [1, -1], [1, 0], [1, 1]]];
var DICE_PAL: Pal[] = [
{ face: [255, 214, 226], edge: [240, 160, 186], pip: '#4b3a63' },
{ face: [200, 232, 255], edge: [140, 190, 236], pip: '#3b4a6b' },
{ face: [224, 212, 255], edge: [170, 150, 232], pip: '#46386b' },
{ face: [255, 240, 196], edge: [236, 206, 120], pip: '#5a4636' }
];
var LIGHT: Vec = unit([-0.45, 0.85, -0.35]);
function newDie(pal: number, xmin: number, xmax: number, zmin: number, zmax: number): Die {
return { p: [0, DIE_HALF, 0], v: [0, 0, 0], R: [1, 0, 0, 0, 1, 0, 0, 0, 1], w: [0, 0, 0], pal: pal, phase: PH_IDLE, t: 0, hold: 0,
bounces: 0, result: 0, top: 1, hit: 0, hx: 0, hz: 0, xmin: xmin, xmax: xmax, zmin: zmin, zmax: zmax };
}
function topIndex(d: Die): number {
var best = 0, by = -9;
for (var f = 0; f < 6; f += 1) {
var ny = d.R[3] * FACE_N[f][0] + d.R[4] * FACE_N[f][1] + d.R[5] * FACE_N[f][2];
if (ny > by) { by = ny; best = f; }
}
return best;
}
// 던지기: 시작 위치·속도·회전을 시드 수열에서 순서대로 뽑는다 (뽑는 순서가 곧 재현 규칙)
function launch(d: Die, rnd: RandFn, x: number, z: number, vx: number, vz: number, vy0: number, spin: number): void {
d.p = [x, 2.4, z];
d.v = [vx + (rnd() - 0.5) * 1.6, vy0 + rnd() * 2.2, vz + (rnd() - 0.5) * 1.6];
var ax = randAxis(rnd);
var s = spin * (0.6 + rnd() * 0.8);
d.w = [ax[0] * s, ax[1] * s, ax[2] * s];
d.R = rotAxis(randAxis(rnd), rnd() * 6.283);
d.phase = PH_FLY; d.t = 0; d.hold = 0; d.bounces = 0; d.result = 0; d.hit = 0;
}
function spinStep(d: Die, dt: number): void {
var wl = Math.hypot(d.w[0], d.w[1], d.w[2]);
if (wl > 1e-5) d.R = matMul(rotAxis([d.w[0] / wl, d.w[1] / wl, d.w[2] / wl], wl * dt), d.R);
}
function floorStep(d: Die): void {
var mi = 0, my = 99;
var rx = 0, ry = 0, rz = 0;
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? DIE_HALF : -DIE_HALF, ly = (i & 2) ? DIE_HALF : -DIE_HALF, lz = (i & 4) ? DIE_HALF : -DIE_HALF;
var wy = d.R[3] * lx + d.R[4] * ly + d.R[5] * lz;
if (wy < my) { my = wy; mi = i; rx = d.R[0] * lx + d.R[1] * ly + d.R[2] * lz; ry = wy; rz = d.R[6] * lx + d.R[7] * ly + d.R[8] * lz; }
}
var low = d.p[1] + my;
if (low >= 0) return;
d.p[1] -= low;
var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
if (cvy >= 0) return;
var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
d.v[1] += j;
d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;
var tl = Math.hypot(cvx, cvz);
if (tl > 1e-4) {
var jt = Math.min(FLOOR_FRICTION * j, tl * 0.5);
var tx = -cvx / tl * jt, tz = -cvz / tl * jt;
d.v[0] += tx; d.v[2] += tz;
d.w[0] += ry * tz / INERTIA; d.w[1] += (rz * tx - rx * tz) / INERTIA; d.w[2] += -ry * tx / INERTIA;
}
if (cvy < -1.2) { d.bounces += 1; d.hit = -cvy; d.hx = d.p[0] + rx; d.hz = d.p[2] + rz; }
}
function wallStep(d: Die): void {
if (d.p[0] < d.xmin) { d.p[0] = d.xmin; d.v[0] = Math.abs(d.v[0]) * 0.5; }
if (d.p[0] > d.xmax) { d.p[0] = d.xmax; d.v[0] = -Math.abs(d.v[0]) * 0.5; }
if (d.p[2] < d.zmin) { d.p[2] = d.zmin; d.v[2] = Math.abs(d.v[2]) * 0.5; }
if (d.p[2] > d.zmax) { d.p[2] = d.zmax; d.v[2] = -Math.abs(d.v[2]) * 0.5; }
}
// 멈추기: 법선 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 정확히 위를 보도록 남은 각도의 SETTLE_RATE 만큼씩 돌린다
function settleStep(d: Die): void {
var k = topIndex(d);
d.top = FACE_VAL[k];
var nw = matVec(d.R, FACE_N[k]);
var ang = Math.acos(clamp(nw[1], -1, 1));
if (ang > 1e-4) {
var ax = unit(cross(nw, [0, 1, 0]));
d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
}
d.R = orthonormalize(matMul(rotAxis([0, 1, 0], d.w[1] * dtS), d.R));
d.w[1] *= 0.88;
d.v[0] *= 0.86; d.v[2] *= 0.86; d.v[1] = 0;
d.p[0] += d.v[0] * dtS; d.p[2] += d.v[2] * dtS;
d.p[1] += (DIE_HALF - d.p[1]) * 0.22;
wallStep(d);
if (ang < 0.004 && Math.abs(d.p[1] - DIE_HALF) < 0.01) {
d.p[1] = DIE_HALF;
var q = topIndex(d);
d.R = orthonormalize(matMul(rotAxis(unit(cross(matVec(d.R, FACE_N[q]), [0, 1, 0])), Math.acos(clamp(matVec(d.R, FACE_N[q])[1], -1, 1))), d.R));
d.phase = PH_HOLD; d.hold = 0; d.result = FACE_VAL[q]; d.top = d.result;
}
}
function stepDie(d: Die): void {
if (d.phase === PH_FLY) {
d.t += FIXED_DT;
d.v[1] -= GRAVITY * dtS;
d.p[0] += d.v[0] * dtS; d.p[1] += d.v[1] * dtS; d.p[2] += d.v[2] * dtS;
spinStep(d, dtS);
d.w[0] *= SPIN_DAMPING; d.w[1] *= SPIN_DAMPING; d.w[2] *= SPIN_DAMPING;
wallStep(d);
floorStep(d);
var sp = Math.hypot(d.v[0], d.v[1], d.v[2]);
if (d.t > SETTLE_AFTER_MS || (d.bounces >= 3 && sp < 1.6)) d.phase = PH_SETTLE;
} else if (d.phase === PH_SETTLE) {
d.t += FIXED_DT;
settleStep(d);
} else if (d.phase === PH_HOLD) {
d.hold += FIXED_DT;
}
}
function runUntilHold(d: Die): number {
for (var i = 0; i < 1200 && d.phase !== PH_HOLD; i += 1) stepDie(d);
return d.result;
}
// ── 그리기 ──
function rgb(c: number[], k: number): string {
return 'rgb(' + Math.min(255, Math.round(c[0] * k)) + ',' + Math.min(255, Math.round(c[1] * k)) + ',' + Math.min(255, Math.round(c[2] * k)) + ')';
}
function quad(c: Vec, u: Vec, v: Vec, s: number): number[][] {
var o: number[][] = [];
var sg = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
for (var i = 0; i < 4; i += 1) {
o.push(scr([c[0] + (u[0] * sg[i][0] + v[0] * sg[i][1]) * s * DIE_HALF, c[1] + (u[1] * sg[i][0] + v[1] * sg[i][1]) * s * DIE_HALF, c[2] + (u[2] * sg[i][0] + v[2] * sg[i][1]) * s * DIE_HALF]));
}
return o;
}
function fillPoly(pts: number[][], color: Paint, edge: number): void {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
if (edge > 0) { ctx.strokeStyle = color; ctx.lineWidth = edge; ctx.stroke(); }
}
function drawDie(d: Die, hi: number, alpha: number): void {
var pal = DICE_PAL[d.pal % DICE_PAL.length];
ctx.globalAlpha = alpha;
ctx.lineJoin = 'round';
for (var f = 0; f < 6; f += 1) {
var nw = matVec(d.R, FACE_N[f]);
var cw: Vec = [d.p[0] + nw[0] * DIE_HALF, d.p[1] + nw[1] * DIE_HALF, d.p[2] + nw[2] * DIE_HALF];
var cc = camPoint(cw), nc = camDir(nw);
if (nc[0] * cc[0] + nc[1] * cc[1] + nc[2] * cc[2] >= 0) continue;
var uw = matVec(d.R, FACE_U[f]), vw = matVec(d.R, FACE_V[f]);
var lit = 0.66 + 0.34 * Math.max(0, dot(nw, LIGHT));
var k0 = scr(cw)[2];
fillPoly(quad(cw, uw, vw, 1), rgb(pal.edge, lit), k0 * 0.1);
fillPoly(quad(cw, uw, vw, 0.84), rgb(pal.face, lit), k0 * 0.16);
if (f === hi) fillPoly(quad(cw, uw, vw, 0.84), 'rgba(255,255,255,0.4)', 0);
var val = FACE_VAL[f];
for (var pi = 0; pi < PIPS[val].length; pi += 1) {
var a = PIPS[val][pi][0] * 0.5 * DIE_HALF, b = PIPS[val][pi][1] * 0.5 * DIE_HALF;
var ring: number[][] = [];
for (var j = 0; j < 10; j += 1) {
var an = j * 0.62832, rr = 0.17 * DIE_HALF;
var ca = Math.cos(an) * rr, sa = Math.sin(an) * rr;
ring.push(scr([cw[0] + uw[0] * (a + ca) + vw[0] * (b + sa) + nw[0] * 0.004, cw[1] + uw[1] * (a + ca) + vw[1] * (b + sa) + nw[1] * 0.004, cw[2] + uw[2] * (a + ca) + vw[2] * (b + sa) + nw[2] * 0.004]));
}
fillPoly(ring, pal.pip, 0);
}
}
ctx.globalAlpha = 1;
}
function drawShadow(d: Die, alpha: number): void {
var s = scr([d.p[0], 0, d.p[2]]);
var h = Math.max(0, d.p[1] - DIE_HALF);
var r = DIE_HALF * s[2] * (1.2 + h * 0.18);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.42);
ctx.beginPath();
ctx.arc(0, 0, r, 0, 6.2832);
ctx.fillStyle = 'rgba(30,60,60,' + (0.34 * alpha / (1 + h * 0.7)) + ')';
ctx.fill();
ctx.restore();
}
var FELT_DOTS: number[][] = [];
(function (): void {
var fr = seedRand(99);
for (var i = 0; i < 70; i += 1) FELT_DOTS.push([(fr() * 2 - 1) * TABLE_X, TABLE_Z0 + fr() * (TABLE_Z1 - TABLE_Z0), 0.04 + fr() * 0.08]);
})();
function tablePts(m: number): number[][] {
return [scr([-TABLE_X - m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z1 + m]), scr([-TABLE_X - m, 0, TABLE_Z1 + m])];
}
function drawStage(lightX: number, lightK: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var bg = ctx.createRadialGradient(160, 80, 10, 160, 80, 210);
bg.addColorStop(0, '#3f3764');
bg.addColorStop(1, '#221d36');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
fillPoly(tablePts(0.5), '#8e80c8', 0);
var tp = tablePts(0);
var g = ctx.createLinearGradient(0, tp[0][1], 0, tp[2][1]);
g.addColorStop(0, '#9fdcc3');
g.addColorStop(1, '#78c8aa');
fillPoly(tp, g, 0);
for (var i = 0; i < FELT_DOTS.length; i += 1) {
var s = scr([FELT_DOTS[i][0], 0, FELT_DOTS[i][1]]);
ctx.beginPath();
ctx.arc(s[0], s[1], FELT_DOTS[i][2] * s[2], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,0.12)';
ctx.fill();
}
var lp = scr([lightX, 0, 1]);
var lg = ctx.createRadialGradient(lp[0], lp[1], 4, lp[0], lp[1], 120);
lg.addColorStop(0, 'rgba(255,255,255,' + (0.3 * lightK) + ')');
lg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.save();
ctx.beginPath();
ctx.moveTo(tp[0][0], tp[0][1]); ctx.lineTo(tp[1][0], tp[1][1]); ctx.lineTo(tp[2][0], tp[2][1]); ctx.lineTo(tp[3][0], tp[3][1]);
ctx.closePath();
ctx.clip();
ctx.fillStyle = lg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.restore();
}
function drawChip(x: number, y: number, s: number, value: number, pal: number, mark: boolean): void {
var p = DICE_PAL[pal % DICE_PAL.length];
ctx.fillStyle = value > 0 ? rgb(p.face, 1) : 'rgba(255,255,255,0.18)';
ctx.strokeStyle = mark ? '#7fe3bd' : rgb(p.edge, 1);
ctx.lineWidth = mark ? 1.8 : 1;
ctx.fillRect(x, y, s, s);
ctx.strokeRect(x, y, s, s);
for (var i = 0; i < PIPS[value].length; i += 1) {
ctx.beginPath();
ctx.arc(x + s / 2 + PIPS[value][i][0] * s * 0.26, y + s / 2 + PIPS[value][i][1] * s * 0.26, s * 0.1, 0, 6.2832);
ctx.fillStyle = p.pip;
ctx.fill();
}
}
// ── 상태 줄 (aria-live) ──
var stLabel = '', stNum = '', stAt = -9999;
function setStatus(label: string, num: string, force: boolean): void {
if (label === stLabel && num === stNum) return;
if (!force && label === stLabel && elapsed - stAt < 450) return;
stLabel = label; stNum = num; stAt = elapsed;
labelEl.textContent = label;
numEl.textContent = num;
}
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Fn[] = [];
// 기법: 여섯 면의 월드 법선 n 중 n.y 가 가장 큰 면이 윗면이다. 회전이 느려지면 그 면의 n 과 (0,1,0) 사이 각도를
// 매 걸음 SETTLE_RATE 비율만큼 줄이는 회전을 곱해(남은 각도의 지수 보정) 면이 정확히 위를 보게 한다.
var T_TOP = '윗면';
var T_RESULT = '결과';
var T_ROLLING = '굴리는 중';
var die: Die = newDie(1, -2.2, 2.2, -1.2, 2.0);
var rng: RandFn = seedRand(DICE_SEED + 3);
var normY: number[] = [0, 0, 0, 0, 0, 0];
var bestIdx = 0;
function sceneInit(): void { die.phase = PH_IDLE; }
function sceneRoll(manual: boolean): void {
launch(die, rng, -1.4, rng() * 1.6 - 0.2, 1.8, 0.3, 2.4, 17);
setStatus(T_ROLLING, '', true);
}
function sceneDone(): boolean { return die.phase === PH_HOLD; }
function sceneFinish(): void { setStatus(T_RESULT, String(die.result), true); }
function readNormals(): void {
bestIdx = 0;
for (var f = 0; f < 6; f += 1) {
var ny = die.R[3] * FACE_N[f][0] + die.R[4] * FACE_N[f][1] + die.R[5] * FACE_N[f][2];
normY[FACE_VAL[f] - 1] = ny;
if (ny > die.R[3] * FACE_N[bestIdx][0] + die.R[4] * FACE_N[bestIdx][1] + die.R[5] * FACE_N[bestIdx][2]) bestIdx = f;
}
}
function stepWorld(): void {
if (die.phase === PH_IDLE) return;
stepDie(die);
readNormals();
if (die.phase === PH_HOLD) setStatus(T_RESULT, String(die.result), true);
else if (die.phase === PH_SETTLE || die.bounces > 0) setStatus(T_TOP, String(FACE_VAL[bestIdx]), false);
if (die.phase === PH_HOLD && demo && die.hold >= HOLD_MS) sceneRoll(false);
}
function drawWorld(): void {
drawStage(die.p[0], 0.8);
if (die.phase === PH_IDLE) return;
drawShadow(die, 1);
var showHi = die.bounces > 0 || die.phase !== PH_FLY;
drawDie(die, showHi ? bestIdx : -1, 1);
if (showHi) {
var nw = matVec(die.R, FACE_N[bestIdx]);
var a = scr([die.p[0] + nw[0] * DIE_HALF, die.p[1] + nw[1] * DIE_HALF, die.p[2] + nw[2] * DIE_HALF]);
var b = scr([die.p[0] + nw[0] * (DIE_HALF + 1.5), die.p[1] + nw[1] * (DIE_HALF + 1.5), die.p[2] + nw[2] * (DIE_HALF + 1.5)]);
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]);
ctx.strokeStyle = '#fff4b0'; ctx.lineWidth = 2.4; ctx.lineCap = 'round'; ctx.stroke();
ctx.beginPath(); ctx.arc(b[0], b[1], 3.4, 0, 6.2832); ctx.fillStyle = '#fff4b0'; ctx.fill();
}
// 여섯 면의 법선 y 막대: 가장 큰 막대가 윗면, 보정이 끝나면 1.0 에 닿는다
ctx.fillStyle = 'rgba(255,255,255,0.14)';
ctx.fillRect(84, STRIP_BASE - BAR_MAX - 3, 152, BAR_MAX + 17);
ctx.font = '8px sans-serif';
ctx.textAlign = 'center';
for (var v = 0; v < 6; v += 1) {
var h = Math.max(0, normY[v]) * BAR_MAX;
ctx.fillStyle = (v + 1) === FACE_VAL[bestIdx] ? '#ff9ebb' : '#c9b8ff';
ctx.fillRect(92 + v * 24, STRIP_BASE - h, 16, h);
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.fillText(String(v + 1), 100 + v * 24, STRIP_BASE + 9);
}
}
function fit(): void {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs = -1;
var acc = 0;
function tick(ts: number): void {
if (lastTs < 0) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
// 모션 줄이기: 애니메이션 없이 결과가 나올 때까지 같은 계산을 한 번에 돌려 최종 장면만 그린다
function runToDone(): void {
var i = 0;
do { elapsed += FIXED_DT; stepWorld(); i += 1; } while (i < 1500 && !sceneDone());
sceneFinish();
drawWorld();
}
function onRollClick(): void {
stopDemo();
sceneRoll(true);
if (reduce) runToDone();
}
sceneInit();
sceneRoll(false);
if (reduce) {
runToDone();
} else {
for (var w0 = 0; w0 < 8; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
}
btn.addEventListener('click', onRollClick);
cleaners.push(function (): void { btn.removeEventListener('click', onRollClick); });
fit();
window.addEventListener('resize', fit);
cleaners.push(function (): void { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
return destroy;
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} role="img" aria-label="회전이 느려질 때 여섯 면의 법선 중 위쪽을 가장 많이 향한 면을 골라 그 눈 수를 결과로 읽고, 방향을 그 면이 정확히 위로 오도록 서서히 보정한다." />
<output className={styles.bp__status} aria-live="polite"><span ref={labelRef}>굴리는 중</span> <b ref={numRef} /></output>
<button type="button" className={styles.bp__btn} ref={btnRef}>굴리기</button>
<p className={styles.bp__caption}>회전이 느려질 때 여섯 면의 법선 중 위쪽을 가장 많이 향한 면을 골라 그 눈 수를 결과로 읽고, 방향을 그 면이 정확히 위로 오도록 서서히 보정한다.</p>
</div>
</main>
);
}03 · 止まりながら上の目を読むサイコロ — 計測 累積面積 13.378% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 止まる段階: 法線yが最大の面を上の目として読み、その面が真上を向くよう残りの角度の SETTLE_RATE ぶんずつ回す
function settleStep(d) {
var k = topIndex(d);
d.top = FACE_VAL[k];
var nw = matVec(d.R, FACE_N[k]);
var ang = Math.acos(clamp(nw[1], -1, 1));
if (ang > 1e-4) {
var ax = unit(cross(nw, [0, 1, 0]));
d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
}
04シードで同じ順番を繰り返すサイコロ
seedRand() の線形合同法の数列が、投げるたびの位置・速度・回転を決まった順番で引き出します。始める前に画面に出さず SEQ_LEN の6回を先に転がして上の列に並べておき、いま転がしている下の列と目が同じならチップの縁をミント色にして、同じシードが同じ順番を出すことを目で確かめさせます。累積面積18.2%・平均強度66.1で、19/23フレームが動きました。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" role="img" aria-label="線形合同法の数列で投げるたびの初期回転と速度を決め、同じシードなら同じ目が同じ順に出ることを、先に計算しておいた上の列と今転がしている下の列の目の履歴で見比べる。"></canvas>
<output class="bp__status" aria-live="polite"><span id="bpLabel">転がり中</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">振る</button>
<p class="bp__caption">線形合同法の数列で投げるたびの初期回転と速度を決め、同じシードなら同じ目が同じ順に出ることを、先に計算しておいた上の列と今転がしている下の列の目の履歴で見比べる。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
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: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
display: flex;
align-items: baseline;
gap: $sp-2;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: $sp-3;
top: $sp-3;
min-height: $sp-8;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-instant $ease-out;
}
.bp__btn:hover { transform: translateY(-1px); }
.bp__btn:active { transform: translateY(1px); }
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .9);
outline-offset: 2px;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__btn { font-size: 11px; padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
.bp__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: 12px;
top: 12px;
min-height: 32px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 140ms ease-out;
}
.bp__btn:hover {
transform: translateY(-1px);
}
.bp__btn:active {
transform: translateY(1px);
}
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.9);
outline-offset: 2px;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
padding: 4px 8px;
}
.bp__btn {
font-size: 11px;
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
.bp__btn {
transition: none;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var labelEl = document.getElementById('bpLabel');
var numEl = document.getElementById('bpNum');
var btn = document.getElementById('bpBtn');
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var DICE_RESTITUTION = 0.45;
var SPIN_DAMPING = 0.985;
var DICE_SEED = 7;
var GRAVITY = 22;
var DIE_HALF = 1;
var INERTIA = 2 * DIE_HALF * DIE_HALF / 3;
var FLOOR_FRICTION = 0.35;
var SETTLE_AFTER_MS = 2000;
var SETTLE_RATE = 0.16;
var HOLD_MS = 700;
var CAM_Y = 5.6, CAM_BACK = 12, FOCAL = 275, VIEW_CX = 160, VIEW_CY = 82;
var TABLE_X = 4.4, TABLE_Z0 = -3, TABLE_Z1 = 5;
var PH_FLY = 0, PH_SETTLE = 1, PH_HOLD = 2, PH_IDLE = 3;
var STRIP_BASE = 128;
var BAR_MAX = 18;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function cross(a, b) { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function unit(a) { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function matMul(a, b) {
var o = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (var i = 0; i < 3; i += 1) {
for (var j = 0; j < 3; j += 1) o[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
}
return o;
}
function matVec(m, v) {
return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
}
// 로드리게스 공식: 단위 축 ax 둘레로 ang 만큼 도는 회전 행렬
function rotAxis(ax, ang) {
var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c];
}
function orthonormalize(m) {
var a = unit([m[0], m[1], m[2]]);
var k = a[0] * m[3] + a[1] * m[4] + a[2] * m[5];
var b = unit([m[3] - a[0] * k, m[4] - a[1] * k, m[5] - a[2] * k]);
var c = cross(a, b);
return [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]];
}
function randAxis(rnd) { return unit([rnd() * 2 - 1, rnd() * 2 - 1, rnd() * 2 - 1]); }
var PITCH = Math.atan2(CAM_Y, CAM_BACK);
var CAM_S = Math.sin(PITCH), CAM_C = Math.cos(PITCH);
function camPoint(p) {
var qy = p[1] - CAM_Y, qz = p[2] + CAM_BACK;
return [p[0], qy * CAM_C + qz * CAM_S, -qy * CAM_S + qz * CAM_C];
}
function camDir(v) { return [v[0], v[1] * CAM_C + v[2] * CAM_S, -v[1] * CAM_S + v[2] * CAM_C]; }
function scr(p) {
var c = camPoint(p);
var k = FOCAL / c[2];
return [VIEW_CX + c[0] * k, VIEW_CY - c[1] * k, k];
}
var FACE_N = [[0, 1, 0], [0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];
var FACE_U = [[1, 0, 0], [1, 0, 0], [0, 1, 0], [0, 1, 0], [1, 0, 0], [1, 0, 0]];
var FACE_V = [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]];
var FACE_VAL = [1, 6, 2, 5, 3, 4];
var PIPS = [[], [[0, 0]], [[-1, -1], [1, 1]], [[-1, -1], [0, 0], [1, 1]],
[[-1, -1], [1, -1], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
[[-1, -1], [-1, 0], [-1, 1], [1, -1], [1, 0], [1, 1]]];
var DICE_PAL = [
{ face: [255, 214, 226], edge: [240, 160, 186], pip: '#4b3a63' },
{ face: [200, 232, 255], edge: [140, 190, 236], pip: '#3b4a6b' },
{ face: [224, 212, 255], edge: [170, 150, 232], pip: '#46386b' },
{ face: [255, 240, 196], edge: [236, 206, 120], pip: '#5a4636' }
];
var LIGHT = unit([-0.45, 0.85, -0.35]);
function newDie(pal, xmin, xmax, zmin, zmax) {
return { p: [0, DIE_HALF, 0], v: [0, 0, 0], R: [1, 0, 0, 0, 1, 0, 0, 0, 1], w: [0, 0, 0], pal: pal, phase: PH_IDLE, t: 0, hold: 0,
bounces: 0, result: 0, top: 1, hit: 0, hx: 0, hz: 0, xmin: xmin, xmax: xmax, zmin: zmin, zmax: zmax };
}
function topIndex(d) {
var best = 0, by = -9;
for (var f = 0; f < 6; f += 1) {
var ny = d.R[3] * FACE_N[f][0] + d.R[4] * FACE_N[f][1] + d.R[5] * FACE_N[f][2];
if (ny > by) { by = ny; best = f; }
}
return best;
}
// 던지기: 시작 위치·속도·회전을 시드 수열에서 순서대로 뽑는다 (뽑는 순서가 곧 재현 규칙)
function launch(d, rnd, x, z, vx, vz, vy0, spin) {
d.p = [x, 2.4, z];
d.v = [vx + (rnd() - 0.5) * 1.6, vy0 + rnd() * 2.2, vz + (rnd() - 0.5) * 1.6];
var ax = randAxis(rnd);
var s = spin * (0.6 + rnd() * 0.8);
d.w = [ax[0] * s, ax[1] * s, ax[2] * s];
d.R = rotAxis(randAxis(rnd), rnd() * 6.283);
d.phase = PH_FLY; d.t = 0; d.hold = 0; d.bounces = 0; d.result = 0; d.hit = 0;
}
function spinStep(d, dt) {
var wl = Math.hypot(d.w[0], d.w[1], d.w[2]);
if (wl > 1e-5) d.R = matMul(rotAxis([d.w[0] / wl, d.w[1] / wl, d.w[2] / wl], wl * dt), d.R);
}
function floorStep(d) {
var mi = 0, my = 99;
var rx = 0, ry = 0, rz = 0;
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? DIE_HALF : -DIE_HALF, ly = (i & 2) ? DIE_HALF : -DIE_HALF, lz = (i & 4) ? DIE_HALF : -DIE_HALF;
var wy = d.R[3] * lx + d.R[4] * ly + d.R[5] * lz;
if (wy < my) { my = wy; mi = i; rx = d.R[0] * lx + d.R[1] * ly + d.R[2] * lz; ry = wy; rz = d.R[6] * lx + d.R[7] * ly + d.R[8] * lz; }
}
var low = d.p[1] + my;
if (low >= 0) return;
d.p[1] -= low;
var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
if (cvy >= 0) return;
var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
d.v[1] += j;
d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;
var tl = Math.hypot(cvx, cvz);
if (tl > 1e-4) {
var jt = Math.min(FLOOR_FRICTION * j, tl * 0.5);
var tx = -cvx / tl * jt, tz = -cvz / tl * jt;
d.v[0] += tx; d.v[2] += tz;
d.w[0] += ry * tz / INERTIA; d.w[1] += (rz * tx - rx * tz) / INERTIA; d.w[2] += -ry * tx / INERTIA;
}
if (cvy < -1.2) { d.bounces += 1; d.hit = -cvy; d.hx = d.p[0] + rx; d.hz = d.p[2] + rz; }
}
function wallStep(d) {
if (d.p[0] < d.xmin) { d.p[0] = d.xmin; d.v[0] = Math.abs(d.v[0]) * 0.5; }
if (d.p[0] > d.xmax) { d.p[0] = d.xmax; d.v[0] = -Math.abs(d.v[0]) * 0.5; }
if (d.p[2] < d.zmin) { d.p[2] = d.zmin; d.v[2] = Math.abs(d.v[2]) * 0.5; }
if (d.p[2] > d.zmax) { d.p[2] = d.zmax; d.v[2] = -Math.abs(d.v[2]) * 0.5; }
}
// 멈추기: 법선 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 정확히 위를 보도록 남은 각도의 SETTLE_RATE 만큼씩 돌린다
function settleStep(d) {
var k = topIndex(d);
d.top = FACE_VAL[k];
var nw = matVec(d.R, FACE_N[k]);
var ang = Math.acos(clamp(nw[1], -1, 1));
if (ang > 1e-4) {
var ax = unit(cross(nw, [0, 1, 0]));
d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
}
d.R = orthonormalize(matMul(rotAxis([0, 1, 0], d.w[1] * dtS), d.R));
d.w[1] *= 0.88;
d.v[0] *= 0.86; d.v[2] *= 0.86; d.v[1] = 0;
d.p[0] += d.v[0] * dtS; d.p[2] += d.v[2] * dtS;
d.p[1] += (DIE_HALF - d.p[1]) * 0.22;
wallStep(d);
if (ang < 0.004 && Math.abs(d.p[1] - DIE_HALF) < 0.01) {
d.p[1] = DIE_HALF;
var q = topIndex(d);
d.R = orthonormalize(matMul(rotAxis(unit(cross(matVec(d.R, FACE_N[q]), [0, 1, 0])), Math.acos(clamp(matVec(d.R, FACE_N[q])[1], -1, 1))), d.R));
d.phase = PH_HOLD; d.hold = 0; d.result = FACE_VAL[q]; d.top = d.result;
}
}
function stepDie(d) {
if (d.phase === PH_FLY) {
d.t += FIXED_DT;
d.v[1] -= GRAVITY * dtS;
d.p[0] += d.v[0] * dtS; d.p[1] += d.v[1] * dtS; d.p[2] += d.v[2] * dtS;
spinStep(d, dtS);
d.w[0] *= SPIN_DAMPING; d.w[1] *= SPIN_DAMPING; d.w[2] *= SPIN_DAMPING;
wallStep(d);
floorStep(d);
var sp = Math.hypot(d.v[0], d.v[1], d.v[2]);
if (d.t > SETTLE_AFTER_MS || (d.bounces >= 3 && sp < 1.6)) d.phase = PH_SETTLE;
} else if (d.phase === PH_SETTLE) {
d.t += FIXED_DT;
settleStep(d);
} else if (d.phase === PH_HOLD) {
d.hold += FIXED_DT;
}
}
function runUntilHold(d) {
for (var i = 0; i < 1200 && d.phase !== PH_HOLD; i += 1) stepDie(d);
return d.result;
}
// ── 그리기 ──
function rgb(c, k) {
return 'rgb(' + Math.min(255, Math.round(c[0] * k)) + ',' + Math.min(255, Math.round(c[1] * k)) + ',' + Math.min(255, Math.round(c[2] * k)) + ')';
}
function quad(c, u, v, s) {
var o = [];
var sg = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
for (var i = 0; i < 4; i += 1) {
o.push(scr([c[0] + (u[0] * sg[i][0] + v[0] * sg[i][1]) * s * DIE_HALF, c[1] + (u[1] * sg[i][0] + v[1] * sg[i][1]) * s * DIE_HALF, c[2] + (u[2] * sg[i][0] + v[2] * sg[i][1]) * s * DIE_HALF]));
}
return o;
}
function fillPoly(pts, color, edge) {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
if (edge > 0) { ctx.strokeStyle = color; ctx.lineWidth = edge; ctx.stroke(); }
}
function drawDie(d, hi, alpha) {
var pal = DICE_PAL[d.pal % DICE_PAL.length];
ctx.globalAlpha = alpha;
ctx.lineJoin = 'round';
for (var f = 0; f < 6; f += 1) {
var nw = matVec(d.R, FACE_N[f]);
var cw = [d.p[0] + nw[0] * DIE_HALF, d.p[1] + nw[1] * DIE_HALF, d.p[2] + nw[2] * DIE_HALF];
var cc = camPoint(cw), nc = camDir(nw);
if (nc[0] * cc[0] + nc[1] * cc[1] + nc[2] * cc[2] >= 0) continue;
var uw = matVec(d.R, FACE_U[f]), vw = matVec(d.R, FACE_V[f]);
var lit = 0.66 + 0.34 * Math.max(0, dot(nw, LIGHT));
var k0 = scr(cw)[2];
fillPoly(quad(cw, uw, vw, 1), rgb(pal.edge, lit), k0 * 0.1);
fillPoly(quad(cw, uw, vw, 0.84), rgb(pal.face, lit), k0 * 0.16);
if (f === hi) fillPoly(quad(cw, uw, vw, 0.84), 'rgba(255,255,255,0.4)', 0);
var val = FACE_VAL[f];
for (var pi = 0; pi < PIPS[val].length; pi += 1) {
var a = PIPS[val][pi][0] * 0.5 * DIE_HALF, b = PIPS[val][pi][1] * 0.5 * DIE_HALF;
var ring = [];
for (var j = 0; j < 10; j += 1) {
var an = j * 0.62832, rr = 0.17 * DIE_HALF;
var ca = Math.cos(an) * rr, sa = Math.sin(an) * rr;
ring.push(scr([cw[0] + uw[0] * (a + ca) + vw[0] * (b + sa) + nw[0] * 0.004, cw[1] + uw[1] * (a + ca) + vw[1] * (b + sa) + nw[1] * 0.004, cw[2] + uw[2] * (a + ca) + vw[2] * (b + sa) + nw[2] * 0.004]));
}
fillPoly(ring, pal.pip, 0);
}
}
ctx.globalAlpha = 1;
}
function drawShadow(d, alpha) {
var s = scr([d.p[0], 0, d.p[2]]);
var h = Math.max(0, d.p[1] - DIE_HALF);
var r = DIE_HALF * s[2] * (1.2 + h * 0.18);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.42);
ctx.beginPath();
ctx.arc(0, 0, r, 0, 6.2832);
ctx.fillStyle = 'rgba(30,60,60,' + (0.34 * alpha / (1 + h * 0.7)) + ')';
ctx.fill();
ctx.restore();
}
var FELT_DOTS = [];
(function () {
var fr = seedRand(99);
for (var i = 0; i < 70; i += 1) FELT_DOTS.push([(fr() * 2 - 1) * TABLE_X, TABLE_Z0 + fr() * (TABLE_Z1 - TABLE_Z0), 0.04 + fr() * 0.08]);
})();
function tablePts(m) {
return [scr([-TABLE_X - m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z1 + m]), scr([-TABLE_X - m, 0, TABLE_Z1 + m])];
}
function drawStage(lightX, lightK) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var bg = ctx.createRadialGradient(160, 80, 10, 160, 80, 210);
bg.addColorStop(0, '#3f3764');
bg.addColorStop(1, '#221d36');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
fillPoly(tablePts(0.5), '#8e80c8', 0);
var tp = tablePts(0);
var g = ctx.createLinearGradient(0, tp[0][1], 0, tp[2][1]);
g.addColorStop(0, '#9fdcc3');
g.addColorStop(1, '#78c8aa');
fillPoly(tp, g, 0);
for (var i = 0; i < FELT_DOTS.length; i += 1) {
var s = scr([FELT_DOTS[i][0], 0, FELT_DOTS[i][1]]);
ctx.beginPath();
ctx.arc(s[0], s[1], FELT_DOTS[i][2] * s[2], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,0.12)';
ctx.fill();
}
var lp = scr([lightX, 0, 1]);
var lg = ctx.createRadialGradient(lp[0], lp[1], 4, lp[0], lp[1], 120);
lg.addColorStop(0, 'rgba(255,255,255,' + (0.3 * lightK) + ')');
lg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.save();
ctx.beginPath();
ctx.moveTo(tp[0][0], tp[0][1]); ctx.lineTo(tp[1][0], tp[1][1]); ctx.lineTo(tp[2][0], tp[2][1]); ctx.lineTo(tp[3][0], tp[3][1]);
ctx.closePath();
ctx.clip();
ctx.fillStyle = lg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.restore();
}
function drawChip(x, y, s, value, pal, mark) {
var p = DICE_PAL[pal % DICE_PAL.length];
ctx.fillStyle = value > 0 ? rgb(p.face, 1) : 'rgba(255,255,255,0.18)';
ctx.strokeStyle = mark ? '#7fe3bd' : rgb(p.edge, 1);
ctx.lineWidth = mark ? 1.8 : 1;
ctx.fillRect(x, y, s, s);
ctx.strokeRect(x, y, s, s);
for (var i = 0; i < PIPS[value].length; i += 1) {
ctx.beginPath();
ctx.arc(x + s / 2 + PIPS[value][i][0] * s * 0.26, y + s / 2 + PIPS[value][i][1] * s * 0.26, s * 0.1, 0, 6.2832);
ctx.fillStyle = p.pip;
ctx.fill();
}
}
// ── 상태 줄 (aria-live) ──
var stLabel = '', stNum = '', stAt = -9999;
function setStatus(label, num, force) {
if (label === stLabel && num === stNum) return;
if (!force && label === stLabel && elapsed - stAt < 450) return;
stLabel = label; stNum = num; stAt = elapsed;
labelEl.textContent = label;
numEl.textContent = num;
}
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
// 기법: 선형 합동 수열 s = s * 1664525 + 1013904223 (mod 2^32) 이 던질 때마다의 각도·속도·회전을 순서대로 뽑는다.
// 같은 DICE_SEED 로 다시 시작하면 같은 수열이 나오므로, 먼저 한 바퀴를 보이지 않게 계산해 위 줄에 깔아 두고 아래 줄과 견준다.
var T_RESULT = '結果';
var T_ROLLING = '転がり中';
var SEQ_LEN = 6;
var die = newDie(2, -3.0, 3.0, -1.6, 2.4);
var rng = seedRand(DICE_SEED);
var throwNo = 0;
var prevRun = [];
var curRun = [];
function seqThrow(d, rnd) {
launch(d, rnd, -2.8, rnd() * 2.4 - 0.8, 3.2, 0.1, 3.0, 14);
}
function sceneInit() {
var r2 = seedRand(DICE_SEED);
var tmp = newDie(2, -3.0, 3.0, -1.6, 2.4);
for (var k = 0; k < SEQ_LEN; k += 1) { seqThrow(tmp, r2); prevRun.push(runUntilHold(tmp)); }
die.phase = PH_IDLE;
}
function sceneRoll(manual) {
if (throwNo % SEQ_LEN === 0) { rng = seedRand(DICE_SEED); curRun = []; }
throwNo += 1;
seqThrow(die, rng);
setStatus(T_ROLLING, '', true);
}
function sceneDone() { return die.phase === PH_HOLD; }
function sceneFinish() { setStatus(T_RESULT, String(die.result), true); }
var recorded = -1;
function stepWorld() {
if (die.phase === PH_IDLE) return;
stepDie(die);
if (die.phase === PH_HOLD && recorded !== throwNo) {
recorded = throwNo;
curRun.push(die.result);
setStatus(T_RESULT, String(die.result), true);
}
if (die.phase === PH_HOLD && demo && die.hold >= HOLD_MS) sceneRoll(false);
}
function drawWorld() {
drawStage(die.p[0], 0.8);
if (die.phase !== PH_IDLE) { drawShadow(die, 1); drawDie(die, -1, 1); }
for (var k = 0; k < SEQ_LEN; k += 1) {
var x = 104 + k * 17;
drawChip(x, STRIP_BASE - 22, 12, prevRun[k] || 0, 3, false);
var got = curRun[k] || 0;
drawChip(x, STRIP_BASE - 8, 12, got, 2, got > 0 && got === prevRun[k]);
}
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs = -1;
var acc = 0;
function tick(ts) {
if (lastTs < 0) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
// 모션 줄이기: 애니메이션 없이 결과가 나올 때까지 같은 계산을 한 번에 돌려 최종 장면만 그린다
function runToDone() {
var i = 0;
do { elapsed += FIXED_DT; stepWorld(); i += 1; } while (i < 1500 && !sceneDone());
sceneFinish();
drawWorld();
}
function onRollClick() {
stopDemo();
sceneRoll(true);
if (reduce) runToDone();
}
sceneInit();
sceneRoll(false);
if (reduce) {
runToDone();
} else {
for (var w0 = 0; w0 < 8; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
}
btn.addEventListener('click', onRollClick);
cleaners.push(function () { btn.removeEventListener('click', onRollClick); });
fit();
window.addEventListener('resize', fit);
cleaners.push(function () { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
})();import { useEffect, useRef } from "react";
import styles from "./SpinDiceSceneSeededSequence.module.scss";
export default function SpinDiceSceneSeededSequence() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const numRef = useRef<HTMLElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current, canvasEl = canvasRef.current, l0 = labelRef.current, n0 = numRef.current, b0 = btnRef.current;
if (!rootEl || !canvasEl || !l0 || !n0 || !b0) return;
const root: HTMLDivElement = rootEl;
const canvas: HTMLCanvasElement = canvasEl;
const ctx0 = canvas.getContext("2d");
if (!ctx0) return;
const ctx: CanvasRenderingContext2D = ctx0;
const wrap: HTMLElement = canvas.parentElement ?? root;
const labelEl: HTMLElement = l0;
const numEl: HTMLElement = n0;
const btn: HTMLButtonElement = b0;
type Vec = number[];
type Mat = number[];
type RandFn = () => number;
type Fn = () => void;
type Paint = string | CanvasGradient;
type Pal = { face: number[]; edge: number[]; pip: string };
type Die = { p: Vec; v: Vec; R: Mat; w: Vec; pal: number; phase: number; t: number; hold: number; bounces: number; result: number; top: number; hit: number; hx: number; hz: number; xmin: number; xmax: number; zmin: number; zmax: number };
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var DICE_RESTITUTION = 0.45;
var SPIN_DAMPING = 0.985;
var DICE_SEED = 7;
var GRAVITY = 22;
var DIE_HALF = 1;
var INERTIA = 2 * DIE_HALF * DIE_HALF / 3;
var FLOOR_FRICTION = 0.35;
var SETTLE_AFTER_MS = 2000;
var SETTLE_RATE = 0.16;
var HOLD_MS = 700;
var CAM_Y = 5.6, CAM_BACK = 12, FOCAL = 275, VIEW_CX = 160, VIEW_CY = 82;
var TABLE_X = 4.4, TABLE_Z0 = -3, TABLE_Z1 = 5;
var PH_FLY = 0, PH_SETTLE = 1, PH_HOLD = 2, PH_IDLE = 3;
var STRIP_BASE = 128;
var BAR_MAX = 18;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed: number): RandFn {
var s = seed >>> 0;
return function (): number { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function dot(a: Vec, b: Vec): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function cross(a: Vec, b: Vec): Vec { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function unit(a: Vec): Vec { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function matMul(a: Mat, b: Mat): Mat {
var o: Mat = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (var i = 0; i < 3; i += 1) {
for (var j = 0; j < 3; j += 1) o[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
}
return o;
}
function matVec(m: Mat, v: Vec): Vec {
return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
}
// 로드리게스 공식: 단위 축 ax 둘레로 ang 만큼 도는 회전 행렬
function rotAxis(ax: Vec, ang: number): Mat {
var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c];
}
function orthonormalize(m: Mat): Mat {
var a = unit([m[0], m[1], m[2]]);
var k = a[0] * m[3] + a[1] * m[4] + a[2] * m[5];
var b = unit([m[3] - a[0] * k, m[4] - a[1] * k, m[5] - a[2] * k]);
var c = cross(a, b);
return [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]];
}
function randAxis(rnd: RandFn): Vec { return unit([rnd() * 2 - 1, rnd() * 2 - 1, rnd() * 2 - 1]); }
var PITCH = Math.atan2(CAM_Y, CAM_BACK);
var CAM_S = Math.sin(PITCH), CAM_C = Math.cos(PITCH);
function camPoint(p: Vec): Vec {
var qy = p[1] - CAM_Y, qz = p[2] + CAM_BACK;
return [p[0], qy * CAM_C + qz * CAM_S, -qy * CAM_S + qz * CAM_C];
}
function camDir(v: Vec): Vec { return [v[0], v[1] * CAM_C + v[2] * CAM_S, -v[1] * CAM_S + v[2] * CAM_C]; }
function scr(p: Vec): Vec {
var c = camPoint(p);
var k = FOCAL / c[2];
return [VIEW_CX + c[0] * k, VIEW_CY - c[1] * k, k];
}
var FACE_N: Vec[] = [[0, 1, 0], [0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];
var FACE_U: Vec[] = [[1, 0, 0], [1, 0, 0], [0, 1, 0], [0, 1, 0], [1, 0, 0], [1, 0, 0]];
var FACE_V: Vec[] = [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]];
var FACE_VAL: number[] = [1, 6, 2, 5, 3, 4];
var PIPS: number[][][] = [[], [[0, 0]], [[-1, -1], [1, 1]], [[-1, -1], [0, 0], [1, 1]],
[[-1, -1], [1, -1], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
[[-1, -1], [-1, 0], [-1, 1], [1, -1], [1, 0], [1, 1]]];
var DICE_PAL: Pal[] = [
{ face: [255, 214, 226], edge: [240, 160, 186], pip: '#4b3a63' },
{ face: [200, 232, 255], edge: [140, 190, 236], pip: '#3b4a6b' },
{ face: [224, 212, 255], edge: [170, 150, 232], pip: '#46386b' },
{ face: [255, 240, 196], edge: [236, 206, 120], pip: '#5a4636' }
];
var LIGHT: Vec = unit([-0.45, 0.85, -0.35]);
function newDie(pal: number, xmin: number, xmax: number, zmin: number, zmax: number): Die {
return { p: [0, DIE_HALF, 0], v: [0, 0, 0], R: [1, 0, 0, 0, 1, 0, 0, 0, 1], w: [0, 0, 0], pal: pal, phase: PH_IDLE, t: 0, hold: 0,
bounces: 0, result: 0, top: 1, hit: 0, hx: 0, hz: 0, xmin: xmin, xmax: xmax, zmin: zmin, zmax: zmax };
}
function topIndex(d: Die): number {
var best = 0, by = -9;
for (var f = 0; f < 6; f += 1) {
var ny = d.R[3] * FACE_N[f][0] + d.R[4] * FACE_N[f][1] + d.R[5] * FACE_N[f][2];
if (ny > by) { by = ny; best = f; }
}
return best;
}
// 던지기: 시작 위치·속도·회전을 시드 수열에서 순서대로 뽑는다 (뽑는 순서가 곧 재현 규칙)
function launch(d: Die, rnd: RandFn, x: number, z: number, vx: number, vz: number, vy0: number, spin: number): void {
d.p = [x, 2.4, z];
d.v = [vx + (rnd() - 0.5) * 1.6, vy0 + rnd() * 2.2, vz + (rnd() - 0.5) * 1.6];
var ax = randAxis(rnd);
var s = spin * (0.6 + rnd() * 0.8);
d.w = [ax[0] * s, ax[1] * s, ax[2] * s];
d.R = rotAxis(randAxis(rnd), rnd() * 6.283);
d.phase = PH_FLY; d.t = 0; d.hold = 0; d.bounces = 0; d.result = 0; d.hit = 0;
}
function spinStep(d: Die, dt: number): void {
var wl = Math.hypot(d.w[0], d.w[1], d.w[2]);
if (wl > 1e-5) d.R = matMul(rotAxis([d.w[0] / wl, d.w[1] / wl, d.w[2] / wl], wl * dt), d.R);
}
function floorStep(d: Die): void {
var mi = 0, my = 99;
var rx = 0, ry = 0, rz = 0;
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? DIE_HALF : -DIE_HALF, ly = (i & 2) ? DIE_HALF : -DIE_HALF, lz = (i & 4) ? DIE_HALF : -DIE_HALF;
var wy = d.R[3] * lx + d.R[4] * ly + d.R[5] * lz;
if (wy < my) { my = wy; mi = i; rx = d.R[0] * lx + d.R[1] * ly + d.R[2] * lz; ry = wy; rz = d.R[6] * lx + d.R[7] * ly + d.R[8] * lz; }
}
var low = d.p[1] + my;
if (low >= 0) return;
d.p[1] -= low;
var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
if (cvy >= 0) return;
var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
d.v[1] += j;
d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;
var tl = Math.hypot(cvx, cvz);
if (tl > 1e-4) {
var jt = Math.min(FLOOR_FRICTION * j, tl * 0.5);
var tx = -cvx / tl * jt, tz = -cvz / tl * jt;
d.v[0] += tx; d.v[2] += tz;
d.w[0] += ry * tz / INERTIA; d.w[1] += (rz * tx - rx * tz) / INERTIA; d.w[2] += -ry * tx / INERTIA;
}
if (cvy < -1.2) { d.bounces += 1; d.hit = -cvy; d.hx = d.p[0] + rx; d.hz = d.p[2] + rz; }
}
function wallStep(d: Die): void {
if (d.p[0] < d.xmin) { d.p[0] = d.xmin; d.v[0] = Math.abs(d.v[0]) * 0.5; }
if (d.p[0] > d.xmax) { d.p[0] = d.xmax; d.v[0] = -Math.abs(d.v[0]) * 0.5; }
if (d.p[2] < d.zmin) { d.p[2] = d.zmin; d.v[2] = Math.abs(d.v[2]) * 0.5; }
if (d.p[2] > d.zmax) { d.p[2] = d.zmax; d.v[2] = -Math.abs(d.v[2]) * 0.5; }
}
// 멈추기: 법선 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 정확히 위를 보도록 남은 각도의 SETTLE_RATE 만큼씩 돌린다
function settleStep(d: Die): void {
var k = topIndex(d);
d.top = FACE_VAL[k];
var nw = matVec(d.R, FACE_N[k]);
var ang = Math.acos(clamp(nw[1], -1, 1));
if (ang > 1e-4) {
var ax = unit(cross(nw, [0, 1, 0]));
d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
}
d.R = orthonormalize(matMul(rotAxis([0, 1, 0], d.w[1] * dtS), d.R));
d.w[1] *= 0.88;
d.v[0] *= 0.86; d.v[2] *= 0.86; d.v[1] = 0;
d.p[0] += d.v[0] * dtS; d.p[2] += d.v[2] * dtS;
d.p[1] += (DIE_HALF - d.p[1]) * 0.22;
wallStep(d);
if (ang < 0.004 && Math.abs(d.p[1] - DIE_HALF) < 0.01) {
d.p[1] = DIE_HALF;
var q = topIndex(d);
d.R = orthonormalize(matMul(rotAxis(unit(cross(matVec(d.R, FACE_N[q]), [0, 1, 0])), Math.acos(clamp(matVec(d.R, FACE_N[q])[1], -1, 1))), d.R));
d.phase = PH_HOLD; d.hold = 0; d.result = FACE_VAL[q]; d.top = d.result;
}
}
function stepDie(d: Die): void {
if (d.phase === PH_FLY) {
d.t += FIXED_DT;
d.v[1] -= GRAVITY * dtS;
d.p[0] += d.v[0] * dtS; d.p[1] += d.v[1] * dtS; d.p[2] += d.v[2] * dtS;
spinStep(d, dtS);
d.w[0] *= SPIN_DAMPING; d.w[1] *= SPIN_DAMPING; d.w[2] *= SPIN_DAMPING;
wallStep(d);
floorStep(d);
var sp = Math.hypot(d.v[0], d.v[1], d.v[2]);
if (d.t > SETTLE_AFTER_MS || (d.bounces >= 3 && sp < 1.6)) d.phase = PH_SETTLE;
} else if (d.phase === PH_SETTLE) {
d.t += FIXED_DT;
settleStep(d);
} else if (d.phase === PH_HOLD) {
d.hold += FIXED_DT;
}
}
function runUntilHold(d: Die): number {
for (var i = 0; i < 1200 && d.phase !== PH_HOLD; i += 1) stepDie(d);
return d.result;
}
// ── 그리기 ──
function rgb(c: number[], k: number): string {
return 'rgb(' + Math.min(255, Math.round(c[0] * k)) + ',' + Math.min(255, Math.round(c[1] * k)) + ',' + Math.min(255, Math.round(c[2] * k)) + ')';
}
function quad(c: Vec, u: Vec, v: Vec, s: number): number[][] {
var o: number[][] = [];
var sg = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
for (var i = 0; i < 4; i += 1) {
o.push(scr([c[0] + (u[0] * sg[i][0] + v[0] * sg[i][1]) * s * DIE_HALF, c[1] + (u[1] * sg[i][0] + v[1] * sg[i][1]) * s * DIE_HALF, c[2] + (u[2] * sg[i][0] + v[2] * sg[i][1]) * s * DIE_HALF]));
}
return o;
}
function fillPoly(pts: number[][], color: Paint, edge: number): void {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
if (edge > 0) { ctx.strokeStyle = color; ctx.lineWidth = edge; ctx.stroke(); }
}
function drawDie(d: Die, hi: number, alpha: number): void {
var pal = DICE_PAL[d.pal % DICE_PAL.length];
ctx.globalAlpha = alpha;
ctx.lineJoin = 'round';
for (var f = 0; f < 6; f += 1) {
var nw = matVec(d.R, FACE_N[f]);
var cw: Vec = [d.p[0] + nw[0] * DIE_HALF, d.p[1] + nw[1] * DIE_HALF, d.p[2] + nw[2] * DIE_HALF];
var cc = camPoint(cw), nc = camDir(nw);
if (nc[0] * cc[0] + nc[1] * cc[1] + nc[2] * cc[2] >= 0) continue;
var uw = matVec(d.R, FACE_U[f]), vw = matVec(d.R, FACE_V[f]);
var lit = 0.66 + 0.34 * Math.max(0, dot(nw, LIGHT));
var k0 = scr(cw)[2];
fillPoly(quad(cw, uw, vw, 1), rgb(pal.edge, lit), k0 * 0.1);
fillPoly(quad(cw, uw, vw, 0.84), rgb(pal.face, lit), k0 * 0.16);
if (f === hi) fillPoly(quad(cw, uw, vw, 0.84), 'rgba(255,255,255,0.4)', 0);
var val = FACE_VAL[f];
for (var pi = 0; pi < PIPS[val].length; pi += 1) {
var a = PIPS[val][pi][0] * 0.5 * DIE_HALF, b = PIPS[val][pi][1] * 0.5 * DIE_HALF;
var ring: number[][] = [];
for (var j = 0; j < 10; j += 1) {
var an = j * 0.62832, rr = 0.17 * DIE_HALF;
var ca = Math.cos(an) * rr, sa = Math.sin(an) * rr;
ring.push(scr([cw[0] + uw[0] * (a + ca) + vw[0] * (b + sa) + nw[0] * 0.004, cw[1] + uw[1] * (a + ca) + vw[1] * (b + sa) + nw[1] * 0.004, cw[2] + uw[2] * (a + ca) + vw[2] * (b + sa) + nw[2] * 0.004]));
}
fillPoly(ring, pal.pip, 0);
}
}
ctx.globalAlpha = 1;
}
function drawShadow(d: Die, alpha: number): void {
var s = scr([d.p[0], 0, d.p[2]]);
var h = Math.max(0, d.p[1] - DIE_HALF);
var r = DIE_HALF * s[2] * (1.2 + h * 0.18);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.42);
ctx.beginPath();
ctx.arc(0, 0, r, 0, 6.2832);
ctx.fillStyle = 'rgba(30,60,60,' + (0.34 * alpha / (1 + h * 0.7)) + ')';
ctx.fill();
ctx.restore();
}
var FELT_DOTS: number[][] = [];
(function (): void {
var fr = seedRand(99);
for (var i = 0; i < 70; i += 1) FELT_DOTS.push([(fr() * 2 - 1) * TABLE_X, TABLE_Z0 + fr() * (TABLE_Z1 - TABLE_Z0), 0.04 + fr() * 0.08]);
})();
function tablePts(m: number): number[][] {
return [scr([-TABLE_X - m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z1 + m]), scr([-TABLE_X - m, 0, TABLE_Z1 + m])];
}
function drawStage(lightX: number, lightK: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var bg = ctx.createRadialGradient(160, 80, 10, 160, 80, 210);
bg.addColorStop(0, '#3f3764');
bg.addColorStop(1, '#221d36');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
fillPoly(tablePts(0.5), '#8e80c8', 0);
var tp = tablePts(0);
var g = ctx.createLinearGradient(0, tp[0][1], 0, tp[2][1]);
g.addColorStop(0, '#9fdcc3');
g.addColorStop(1, '#78c8aa');
fillPoly(tp, g, 0);
for (var i = 0; i < FELT_DOTS.length; i += 1) {
var s = scr([FELT_DOTS[i][0], 0, FELT_DOTS[i][1]]);
ctx.beginPath();
ctx.arc(s[0], s[1], FELT_DOTS[i][2] * s[2], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,0.12)';
ctx.fill();
}
var lp = scr([lightX, 0, 1]);
var lg = ctx.createRadialGradient(lp[0], lp[1], 4, lp[0], lp[1], 120);
lg.addColorStop(0, 'rgba(255,255,255,' + (0.3 * lightK) + ')');
lg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.save();
ctx.beginPath();
ctx.moveTo(tp[0][0], tp[0][1]); ctx.lineTo(tp[1][0], tp[1][1]); ctx.lineTo(tp[2][0], tp[2][1]); ctx.lineTo(tp[3][0], tp[3][1]);
ctx.closePath();
ctx.clip();
ctx.fillStyle = lg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.restore();
}
function drawChip(x: number, y: number, s: number, value: number, pal: number, mark: boolean): void {
var p = DICE_PAL[pal % DICE_PAL.length];
ctx.fillStyle = value > 0 ? rgb(p.face, 1) : 'rgba(255,255,255,0.18)';
ctx.strokeStyle = mark ? '#7fe3bd' : rgb(p.edge, 1);
ctx.lineWidth = mark ? 1.8 : 1;
ctx.fillRect(x, y, s, s);
ctx.strokeRect(x, y, s, s);
for (var i = 0; i < PIPS[value].length; i += 1) {
ctx.beginPath();
ctx.arc(x + s / 2 + PIPS[value][i][0] * s * 0.26, y + s / 2 + PIPS[value][i][1] * s * 0.26, s * 0.1, 0, 6.2832);
ctx.fillStyle = p.pip;
ctx.fill();
}
}
// ── 상태 줄 (aria-live) ──
var stLabel = '', stNum = '', stAt = -9999;
function setStatus(label: string, num: string, force: boolean): void {
if (label === stLabel && num === stNum) return;
if (!force && label === stLabel && elapsed - stAt < 450) return;
stLabel = label; stNum = num; stAt = elapsed;
labelEl.textContent = label;
numEl.textContent = num;
}
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Fn[] = [];
// 기법: 선형 합동 수열 s = s * 1664525 + 1013904223 (mod 2^32) 이 던질 때마다의 각도·속도·회전을 순서대로 뽑는다.
// 같은 DICE_SEED 로 다시 시작하면 같은 수열이 나오므로, 먼저 한 바퀴를 보이지 않게 계산해 위 줄에 깔아 두고 아래 줄과 견준다.
var T_RESULT = '결과';
var T_ROLLING = '굴리는 중';
var SEQ_LEN = 6;
var die: Die = newDie(2, -3.0, 3.0, -1.6, 2.4);
var rng: RandFn = seedRand(DICE_SEED);
var throwNo = 0;
var prevRun: number[] = [];
var curRun: number[] = [];
function seqThrow(d: Die, rnd: RandFn): void {
launch(d, rnd, -2.8, rnd() * 2.4 - 0.8, 3.2, 0.1, 3.0, 14);
}
function sceneInit(): void {
var r2: RandFn = seedRand(DICE_SEED);
var tmp: Die = newDie(2, -3.0, 3.0, -1.6, 2.4);
for (var k = 0; k < SEQ_LEN; k += 1) { seqThrow(tmp, r2); prevRun.push(runUntilHold(tmp)); }
die.phase = PH_IDLE;
}
function sceneRoll(manual: boolean): void {
if (throwNo % SEQ_LEN === 0) { rng = seedRand(DICE_SEED); curRun = []; }
throwNo += 1;
seqThrow(die, rng);
setStatus(T_ROLLING, '', true);
}
function sceneDone(): boolean { return die.phase === PH_HOLD; }
function sceneFinish(): void { setStatus(T_RESULT, String(die.result), true); }
var recorded = -1;
function stepWorld(): void {
if (die.phase === PH_IDLE) return;
stepDie(die);
if (die.phase === PH_HOLD && recorded !== throwNo) {
recorded = throwNo;
curRun.push(die.result);
setStatus(T_RESULT, String(die.result), true);
}
if (die.phase === PH_HOLD && demo && die.hold >= HOLD_MS) sceneRoll(false);
}
function drawWorld(): void {
drawStage(die.p[0], 0.8);
if (die.phase !== PH_IDLE) { drawShadow(die, 1); drawDie(die, -1, 1); }
for (var k = 0; k < SEQ_LEN; k += 1) {
var x = 104 + k * 17;
drawChip(x, STRIP_BASE - 22, 12, prevRun[k] || 0, 3, false);
var got = curRun[k] || 0;
drawChip(x, STRIP_BASE - 8, 12, got, 2, got > 0 && got === prevRun[k]);
}
}
function fit(): void {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs = -1;
var acc = 0;
function tick(ts: number): void {
if (lastTs < 0) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
// 모션 줄이기: 애니메이션 없이 결과가 나올 때까지 같은 계산을 한 번에 돌려 최종 장면만 그린다
function runToDone(): void {
var i = 0;
do { elapsed += FIXED_DT; stepWorld(); i += 1; } while (i < 1500 && !sceneDone());
sceneFinish();
drawWorld();
}
function onRollClick(): void {
stopDemo();
sceneRoll(true);
if (reduce) runToDone();
}
sceneInit();
sceneRoll(false);
if (reduce) {
runToDone();
} else {
for (var w0 = 0; w0 < 8; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
}
btn.addEventListener('click', onRollClick);
cleaners.push(function (): void { btn.removeEventListener('click', onRollClick); });
fit();
window.addEventListener('resize', fit);
cleaners.push(function (): void { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
return destroy;
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} role="img" aria-label="선형 합동 수열로 던질 때마다의 초기 회전과 속도를 정해, 같은 시드면 같은 눈이 같은 순서로 나오는 것을 미리 계산해 둔 윗줄과 지금 굴리는 아랫줄의 눈 이력으로 견줘 보인다." />
<output className={styles.bp__status} aria-live="polite"><span ref={labelRef}>굴리는 중</span> <b ref={numRef} /></output>
<button type="button" className={styles.bp__btn} ref={btnRef}>굴리기</button>
<p className={styles.bp__caption}>선형 합동 수열로 던질 때마다의 초기 회전과 속도를 정해, 같은 시드면 같은 눈이 같은 순서로 나오는 것을 미리 계산해 둔 윗줄과 지금 굴리는 아랫줄의 눈 이력으로 견줘 보인다.</p>
</div>
</main>
);
}04 · シードで同じ順番を繰り返すサイコロ — 計測 累積面積 18.185% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function seqThrow(d, rnd) {
launch(d, rnd, -2.8, rnd() * 2.4 - 0.8, 3.2, 0.1, 3.0, 14);
}
function sceneInit() {
var r2 = seedRand(DICE_SEED);
var tmp = newDie(2, -3.0, 3.0, -1.6, 2.4);
for (var k = 0; k < SEQ_LEN; k += 1) { seqThrow(tmp, r2); prevRun.push(runUntilHold(tmp)); }
die.phase = PH_IDLE;
}
function sceneRoll(manual) {
if (throwNo % SEQ_LEN === 0) { rng = seedRand(DICE_SEED); curRun = []; }
throwNo += 1;
seqThrow(die, rng);
setStatus(T_ROLLING, '', true);
}
05二つのサイコロの合計を棒に積む場面
throwBoth() が二つのサイコロを左右から向かい合わせに投げて目の合計を counts に積み、始める前に PRELOAD_ROLLS の24回を先に転がして棒が空にならないようにします。二つのサイコロのあいだに衝突判定はないので、領域を左右に分けてすれ違わせています。累積面積20.5%・平均強度69.3で、20/23フレームが動きました。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" role="img" aria-label="二つのサイコロを左右から転がして目の合計を求め、先に24回転がして積んでおいた2から12までの棒へ一つずつ足し、真ん中が高い分布が育つ様子を見せる。"></canvas>
<output class="bp__status" aria-live="polite"><span id="bpLabel">転がり中</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">振る</button>
<p class="bp__caption">二つのサイコロを左右から転がして目の合計を求め、先に24回転がして積んでおいた2から12までの棒へ一つずつ足し、真ん中が高い分布が育つ様子を見せる。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
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: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
display: flex;
align-items: baseline;
gap: $sp-2;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: $sp-3;
top: $sp-3;
min-height: $sp-8;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-instant $ease-out;
}
.bp__btn:hover { transform: translateY(-1px); }
.bp__btn:active { transform: translateY(1px); }
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .9);
outline-offset: 2px;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__btn { font-size: 11px; padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
.bp__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: 12px;
top: 12px;
min-height: 32px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 140ms ease-out;
}
.bp__btn:hover {
transform: translateY(-1px);
}
.bp__btn:active {
transform: translateY(1px);
}
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.9);
outline-offset: 2px;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
padding: 4px 8px;
}
.bp__btn {
font-size: 11px;
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
.bp__btn {
transition: none;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var labelEl = document.getElementById('bpLabel');
var numEl = document.getElementById('bpNum');
var btn = document.getElementById('bpBtn');
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var DICE_RESTITUTION = 0.45;
var SPIN_DAMPING = 0.985;
var DICE_SEED = 7;
var GRAVITY = 22;
var DIE_HALF = 1;
var INERTIA = 2 * DIE_HALF * DIE_HALF / 3;
var FLOOR_FRICTION = 0.35;
var SETTLE_AFTER_MS = 2000;
var SETTLE_RATE = 0.16;
var HOLD_MS = 700;
var CAM_Y = 5.6, CAM_BACK = 12, FOCAL = 275, VIEW_CX = 160, VIEW_CY = 82;
var TABLE_X = 4.4, TABLE_Z0 = -3, TABLE_Z1 = 5;
var PH_FLY = 0, PH_SETTLE = 1, PH_HOLD = 2, PH_IDLE = 3;
var STRIP_BASE = 128;
var BAR_MAX = 18;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function cross(a, b) { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function unit(a) { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function matMul(a, b) {
var o = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (var i = 0; i < 3; i += 1) {
for (var j = 0; j < 3; j += 1) o[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
}
return o;
}
function matVec(m, v) {
return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
}
// 로드리게스 공식: 단위 축 ax 둘레로 ang 만큼 도는 회전 행렬
function rotAxis(ax, ang) {
var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c];
}
function orthonormalize(m) {
var a = unit([m[0], m[1], m[2]]);
var k = a[0] * m[3] + a[1] * m[4] + a[2] * m[5];
var b = unit([m[3] - a[0] * k, m[4] - a[1] * k, m[5] - a[2] * k]);
var c = cross(a, b);
return [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]];
}
function randAxis(rnd) { return unit([rnd() * 2 - 1, rnd() * 2 - 1, rnd() * 2 - 1]); }
var PITCH = Math.atan2(CAM_Y, CAM_BACK);
var CAM_S = Math.sin(PITCH), CAM_C = Math.cos(PITCH);
function camPoint(p) {
var qy = p[1] - CAM_Y, qz = p[2] + CAM_BACK;
return [p[0], qy * CAM_C + qz * CAM_S, -qy * CAM_S + qz * CAM_C];
}
function camDir(v) { return [v[0], v[1] * CAM_C + v[2] * CAM_S, -v[1] * CAM_S + v[2] * CAM_C]; }
function scr(p) {
var c = camPoint(p);
var k = FOCAL / c[2];
return [VIEW_CX + c[0] * k, VIEW_CY - c[1] * k, k];
}
var FACE_N = [[0, 1, 0], [0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];
var FACE_U = [[1, 0, 0], [1, 0, 0], [0, 1, 0], [0, 1, 0], [1, 0, 0], [1, 0, 0]];
var FACE_V = [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]];
var FACE_VAL = [1, 6, 2, 5, 3, 4];
var PIPS = [[], [[0, 0]], [[-1, -1], [1, 1]], [[-1, -1], [0, 0], [1, 1]],
[[-1, -1], [1, -1], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
[[-1, -1], [-1, 0], [-1, 1], [1, -1], [1, 0], [1, 1]]];
var DICE_PAL = [
{ face: [255, 214, 226], edge: [240, 160, 186], pip: '#4b3a63' },
{ face: [200, 232, 255], edge: [140, 190, 236], pip: '#3b4a6b' },
{ face: [224, 212, 255], edge: [170, 150, 232], pip: '#46386b' },
{ face: [255, 240, 196], edge: [236, 206, 120], pip: '#5a4636' }
];
var LIGHT = unit([-0.45, 0.85, -0.35]);
function newDie(pal, xmin, xmax, zmin, zmax) {
return { p: [0, DIE_HALF, 0], v: [0, 0, 0], R: [1, 0, 0, 0, 1, 0, 0, 0, 1], w: [0, 0, 0], pal: pal, phase: PH_IDLE, t: 0, hold: 0,
bounces: 0, result: 0, top: 1, hit: 0, hx: 0, hz: 0, xmin: xmin, xmax: xmax, zmin: zmin, zmax: zmax };
}
function topIndex(d) {
var best = 0, by = -9;
for (var f = 0; f < 6; f += 1) {
var ny = d.R[3] * FACE_N[f][0] + d.R[4] * FACE_N[f][1] + d.R[5] * FACE_N[f][2];
if (ny > by) { by = ny; best = f; }
}
return best;
}
// 던지기: 시작 위치·속도·회전을 시드 수열에서 순서대로 뽑는다 (뽑는 순서가 곧 재현 규칙)
function launch(d, rnd, x, z, vx, vz, vy0, spin) {
d.p = [x, 2.4, z];
d.v = [vx + (rnd() - 0.5) * 1.6, vy0 + rnd() * 2.2, vz + (rnd() - 0.5) * 1.6];
var ax = randAxis(rnd);
var s = spin * (0.6 + rnd() * 0.8);
d.w = [ax[0] * s, ax[1] * s, ax[2] * s];
d.R = rotAxis(randAxis(rnd), rnd() * 6.283);
d.phase = PH_FLY; d.t = 0; d.hold = 0; d.bounces = 0; d.result = 0; d.hit = 0;
}
function spinStep(d, dt) {
var wl = Math.hypot(d.w[0], d.w[1], d.w[2]);
if (wl > 1e-5) d.R = matMul(rotAxis([d.w[0] / wl, d.w[1] / wl, d.w[2] / wl], wl * dt), d.R);
}
function floorStep(d) {
var mi = 0, my = 99;
var rx = 0, ry = 0, rz = 0;
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? DIE_HALF : -DIE_HALF, ly = (i & 2) ? DIE_HALF : -DIE_HALF, lz = (i & 4) ? DIE_HALF : -DIE_HALF;
var wy = d.R[3] * lx + d.R[4] * ly + d.R[5] * lz;
if (wy < my) { my = wy; mi = i; rx = d.R[0] * lx + d.R[1] * ly + d.R[2] * lz; ry = wy; rz = d.R[6] * lx + d.R[7] * ly + d.R[8] * lz; }
}
var low = d.p[1] + my;
if (low >= 0) return;
d.p[1] -= low;
var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
if (cvy >= 0) return;
var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
d.v[1] += j;
d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;
var tl = Math.hypot(cvx, cvz);
if (tl > 1e-4) {
var jt = Math.min(FLOOR_FRICTION * j, tl * 0.5);
var tx = -cvx / tl * jt, tz = -cvz / tl * jt;
d.v[0] += tx; d.v[2] += tz;
d.w[0] += ry * tz / INERTIA; d.w[1] += (rz * tx - rx * tz) / INERTIA; d.w[2] += -ry * tx / INERTIA;
}
if (cvy < -1.2) { d.bounces += 1; d.hit = -cvy; d.hx = d.p[0] + rx; d.hz = d.p[2] + rz; }
}
function wallStep(d) {
if (d.p[0] < d.xmin) { d.p[0] = d.xmin; d.v[0] = Math.abs(d.v[0]) * 0.5; }
if (d.p[0] > d.xmax) { d.p[0] = d.xmax; d.v[0] = -Math.abs(d.v[0]) * 0.5; }
if (d.p[2] < d.zmin) { d.p[2] = d.zmin; d.v[2] = Math.abs(d.v[2]) * 0.5; }
if (d.p[2] > d.zmax) { d.p[2] = d.zmax; d.v[2] = -Math.abs(d.v[2]) * 0.5; }
}
// 멈추기: 법선 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 정확히 위를 보도록 남은 각도의 SETTLE_RATE 만큼씩 돌린다
function settleStep(d) {
var k = topIndex(d);
d.top = FACE_VAL[k];
var nw = matVec(d.R, FACE_N[k]);
var ang = Math.acos(clamp(nw[1], -1, 1));
if (ang > 1e-4) {
var ax = unit(cross(nw, [0, 1, 0]));
d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
}
d.R = orthonormalize(matMul(rotAxis([0, 1, 0], d.w[1] * dtS), d.R));
d.w[1] *= 0.88;
d.v[0] *= 0.86; d.v[2] *= 0.86; d.v[1] = 0;
d.p[0] += d.v[0] * dtS; d.p[2] += d.v[2] * dtS;
d.p[1] += (DIE_HALF - d.p[1]) * 0.22;
wallStep(d);
if (ang < 0.004 && Math.abs(d.p[1] - DIE_HALF) < 0.01) {
d.p[1] = DIE_HALF;
var q = topIndex(d);
d.R = orthonormalize(matMul(rotAxis(unit(cross(matVec(d.R, FACE_N[q]), [0, 1, 0])), Math.acos(clamp(matVec(d.R, FACE_N[q])[1], -1, 1))), d.R));
d.phase = PH_HOLD; d.hold = 0; d.result = FACE_VAL[q]; d.top = d.result;
}
}
function stepDie(d) {
if (d.phase === PH_FLY) {
d.t += FIXED_DT;
d.v[1] -= GRAVITY * dtS;
d.p[0] += d.v[0] * dtS; d.p[1] += d.v[1] * dtS; d.p[2] += d.v[2] * dtS;
spinStep(d, dtS);
d.w[0] *= SPIN_DAMPING; d.w[1] *= SPIN_DAMPING; d.w[2] *= SPIN_DAMPING;
wallStep(d);
floorStep(d);
var sp = Math.hypot(d.v[0], d.v[1], d.v[2]);
if (d.t > SETTLE_AFTER_MS || (d.bounces >= 3 && sp < 1.6)) d.phase = PH_SETTLE;
} else if (d.phase === PH_SETTLE) {
d.t += FIXED_DT;
settleStep(d);
} else if (d.phase === PH_HOLD) {
d.hold += FIXED_DT;
}
}
function runUntilHold(d) {
for (var i = 0; i < 1200 && d.phase !== PH_HOLD; i += 1) stepDie(d);
return d.result;
}
// ── 그리기 ──
function rgb(c, k) {
return 'rgb(' + Math.min(255, Math.round(c[0] * k)) + ',' + Math.min(255, Math.round(c[1] * k)) + ',' + Math.min(255, Math.round(c[2] * k)) + ')';
}
function quad(c, u, v, s) {
var o = [];
var sg = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
for (var i = 0; i < 4; i += 1) {
o.push(scr([c[0] + (u[0] * sg[i][0] + v[0] * sg[i][1]) * s * DIE_HALF, c[1] + (u[1] * sg[i][0] + v[1] * sg[i][1]) * s * DIE_HALF, c[2] + (u[2] * sg[i][0] + v[2] * sg[i][1]) * s * DIE_HALF]));
}
return o;
}
function fillPoly(pts, color, edge) {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
if (edge > 0) { ctx.strokeStyle = color; ctx.lineWidth = edge; ctx.stroke(); }
}
function drawDie(d, hi, alpha) {
var pal = DICE_PAL[d.pal % DICE_PAL.length];
ctx.globalAlpha = alpha;
ctx.lineJoin = 'round';
for (var f = 0; f < 6; f += 1) {
var nw = matVec(d.R, FACE_N[f]);
var cw = [d.p[0] + nw[0] * DIE_HALF, d.p[1] + nw[1] * DIE_HALF, d.p[2] + nw[2] * DIE_HALF];
var cc = camPoint(cw), nc = camDir(nw);
if (nc[0] * cc[0] + nc[1] * cc[1] + nc[2] * cc[2] >= 0) continue;
var uw = matVec(d.R, FACE_U[f]), vw = matVec(d.R, FACE_V[f]);
var lit = 0.66 + 0.34 * Math.max(0, dot(nw, LIGHT));
var k0 = scr(cw)[2];
fillPoly(quad(cw, uw, vw, 1), rgb(pal.edge, lit), k0 * 0.1);
fillPoly(quad(cw, uw, vw, 0.84), rgb(pal.face, lit), k0 * 0.16);
if (f === hi) fillPoly(quad(cw, uw, vw, 0.84), 'rgba(255,255,255,0.4)', 0);
var val = FACE_VAL[f];
for (var pi = 0; pi < PIPS[val].length; pi += 1) {
var a = PIPS[val][pi][0] * 0.5 * DIE_HALF, b = PIPS[val][pi][1] * 0.5 * DIE_HALF;
var ring = [];
for (var j = 0; j < 10; j += 1) {
var an = j * 0.62832, rr = 0.17 * DIE_HALF;
var ca = Math.cos(an) * rr, sa = Math.sin(an) * rr;
ring.push(scr([cw[0] + uw[0] * (a + ca) + vw[0] * (b + sa) + nw[0] * 0.004, cw[1] + uw[1] * (a + ca) + vw[1] * (b + sa) + nw[1] * 0.004, cw[2] + uw[2] * (a + ca) + vw[2] * (b + sa) + nw[2] * 0.004]));
}
fillPoly(ring, pal.pip, 0);
}
}
ctx.globalAlpha = 1;
}
function drawShadow(d, alpha) {
var s = scr([d.p[0], 0, d.p[2]]);
var h = Math.max(0, d.p[1] - DIE_HALF);
var r = DIE_HALF * s[2] * (1.2 + h * 0.18);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.42);
ctx.beginPath();
ctx.arc(0, 0, r, 0, 6.2832);
ctx.fillStyle = 'rgba(30,60,60,' + (0.34 * alpha / (1 + h * 0.7)) + ')';
ctx.fill();
ctx.restore();
}
var FELT_DOTS = [];
(function () {
var fr = seedRand(99);
for (var i = 0; i < 70; i += 1) FELT_DOTS.push([(fr() * 2 - 1) * TABLE_X, TABLE_Z0 + fr() * (TABLE_Z1 - TABLE_Z0), 0.04 + fr() * 0.08]);
})();
function tablePts(m) {
return [scr([-TABLE_X - m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z1 + m]), scr([-TABLE_X - m, 0, TABLE_Z1 + m])];
}
function drawStage(lightX, lightK) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var bg = ctx.createRadialGradient(160, 80, 10, 160, 80, 210);
bg.addColorStop(0, '#3f3764');
bg.addColorStop(1, '#221d36');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
fillPoly(tablePts(0.5), '#8e80c8', 0);
var tp = tablePts(0);
var g = ctx.createLinearGradient(0, tp[0][1], 0, tp[2][1]);
g.addColorStop(0, '#9fdcc3');
g.addColorStop(1, '#78c8aa');
fillPoly(tp, g, 0);
for (var i = 0; i < FELT_DOTS.length; i += 1) {
var s = scr([FELT_DOTS[i][0], 0, FELT_DOTS[i][1]]);
ctx.beginPath();
ctx.arc(s[0], s[1], FELT_DOTS[i][2] * s[2], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,0.12)';
ctx.fill();
}
var lp = scr([lightX, 0, 1]);
var lg = ctx.createRadialGradient(lp[0], lp[1], 4, lp[0], lp[1], 120);
lg.addColorStop(0, 'rgba(255,255,255,' + (0.3 * lightK) + ')');
lg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.save();
ctx.beginPath();
ctx.moveTo(tp[0][0], tp[0][1]); ctx.lineTo(tp[1][0], tp[1][1]); ctx.lineTo(tp[2][0], tp[2][1]); ctx.lineTo(tp[3][0], tp[3][1]);
ctx.closePath();
ctx.clip();
ctx.fillStyle = lg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.restore();
}
function drawChip(x, y, s, value, pal, mark) {
var p = DICE_PAL[pal % DICE_PAL.length];
ctx.fillStyle = value > 0 ? rgb(p.face, 1) : 'rgba(255,255,255,0.18)';
ctx.strokeStyle = mark ? '#7fe3bd' : rgb(p.edge, 1);
ctx.lineWidth = mark ? 1.8 : 1;
ctx.fillRect(x, y, s, s);
ctx.strokeRect(x, y, s, s);
for (var i = 0; i < PIPS[value].length; i += 1) {
ctx.beginPath();
ctx.arc(x + s / 2 + PIPS[value][i][0] * s * 0.26, y + s / 2 + PIPS[value][i][1] * s * 0.26, s * 0.1, 0, 6.2832);
ctx.fillStyle = p.pip;
ctx.fill();
}
}
// ── 상태 줄 (aria-live) ──
var stLabel = '', stNum = '', stAt = -9999;
function setStatus(label, num, force) {
if (label === stLabel && num === stNum) return;
if (!force && label === stLabel && elapsed - stAt < 450) return;
stLabel = label; stNum = num; stAt = elapsed;
labelEl.textContent = label;
numEl.textContent = num;
}
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
// 기법: 두 주사위가 각자 윗면을 읽어 낸 값의 합 s(2~12) 를 칸마다 세어 막대 높이로 쌓는다.
// 시작할 때 보이지 않게 같은 물리로 PRELOAD_ROLLS 번을 먼저 굴려 두므로, 가운데가 높은 분포가 처음부터 자라고 있다.
var T_SUM = '合計';
var T_ROLLING = '転がり中';
var PRELOAD_ROLLS = 24;
var dieA = newDie(0, -3.4, -0.6, -1.6, 2.4);
var dieB = newDie(3, 0.6, 3.4, -1.6, 2.4);
var rng = seedRand(DICE_SEED + 5);
var counts = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
var barH = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
var lastSum = 0;
var counted = true;
function throwBoth(a, b, rnd) {
launch(a, rnd, -3.0, rnd() * 2.2 - 0.6, 3.0, 0.2, 3.0, 14);
launch(b, rnd, 3.0, rnd() * 2.2 - 0.6, -3.0, 0.2, 3.0, 14);
}
function sceneInit() {
var ta = newDie(0, -3.4, -0.6, -1.6, 2.4);
var tb = newDie(3, 0.6, 3.4, -1.6, 2.4);
for (var k = 0; k < PRELOAD_ROLLS; k += 1) {
throwBoth(ta, tb, rng);
counts[runUntilHold(ta) + runUntilHold(tb)] += 1;
}
for (var s = 2; s <= 12; s += 1) barH[s] = counts[s];
dieA.phase = PH_IDLE; dieB.phase = PH_IDLE;
}
function sceneRoll(manual) {
throwBoth(dieA, dieB, rng);
counted = false;
setStatus(T_ROLLING, '', true);
}
function sceneDone() { return dieA.phase === PH_HOLD && dieB.phase === PH_HOLD; }
function sceneFinish() { setStatus(T_SUM, String(lastSum), true); }
function stepWorld() {
if (dieA.phase === PH_IDLE) return;
stepDie(dieA); stepDie(dieB);
if (!counted && sceneDone()) {
counted = true;
lastSum = dieA.result + dieB.result;
counts[lastSum] += 1;
setStatus(T_SUM, String(lastSum), true);
}
for (var s = 2; s <= 12; s += 1) barH[s] += (counts[s] - barH[s]) * 0.18;
if (counted && demo && dieA.hold >= HOLD_MS && dieB.hold >= HOLD_MS) sceneRoll(false);
}
function drawWorld() {
drawStage(0, 0.8);
if (dieA.phase !== PH_IDLE) {
var order = camPoint(dieA.p)[2] > camPoint(dieB.p)[2] ? [dieA, dieB] : [dieB, dieA];
drawShadow(dieA, 1); drawShadow(dieB, 1);
drawDie(order[0], -1, 1); drawDie(order[1], -1, 1);
}
var mx = 6;
for (var s = 2; s <= 12; s += 1) if (counts[s] > mx) mx = counts[s];
ctx.fillStyle = 'rgba(255,255,255,0.14)';
ctx.fillRect(70, STRIP_BASE - BAR_MAX - 3, 184, BAR_MAX + 17);
ctx.font = '8px sans-serif';
ctx.textAlign = 'center';
for (var b = 2; b <= 12; b += 1) {
var h = barH[b] / mx * BAR_MAX;
ctx.fillStyle = (counted && b === lastSum) ? '#ff9ebb' : '#c9b8ff';
ctx.fillRect(76 + (b - 2) * 16.4, STRIP_BASE - h, 12, h);
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.fillText(String(b), 82 + (b - 2) * 16.4, STRIP_BASE + 9);
}
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs = -1;
var acc = 0;
function tick(ts) {
if (lastTs < 0) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
// 모션 줄이기: 애니메이션 없이 결과가 나올 때까지 같은 계산을 한 번에 돌려 최종 장면만 그린다
function runToDone() {
var i = 0;
do { elapsed += FIXED_DT; stepWorld(); i += 1; } while (i < 1500 && !sceneDone());
sceneFinish();
drawWorld();
}
function onRollClick() {
stopDemo();
sceneRoll(true);
if (reduce) runToDone();
}
sceneInit();
sceneRoll(false);
if (reduce) {
runToDone();
} else {
for (var w0 = 0; w0 < 8; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
}
btn.addEventListener('click', onRollClick);
cleaners.push(function () { btn.removeEventListener('click', onRollClick); });
fit();
window.addEventListener('resize', fit);
cleaners.push(function () { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
})();import { useEffect, useRef } from "react";
import styles from "./SpinDiceSceneTwoDiceSumBars.module.scss";
export default function SpinDiceSceneTwoDiceSumBars() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const numRef = useRef<HTMLElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current, canvasEl = canvasRef.current, l0 = labelRef.current, n0 = numRef.current, b0 = btnRef.current;
if (!rootEl || !canvasEl || !l0 || !n0 || !b0) return;
const root: HTMLDivElement = rootEl;
const canvas: HTMLCanvasElement = canvasEl;
const ctx0 = canvas.getContext("2d");
if (!ctx0) return;
const ctx: CanvasRenderingContext2D = ctx0;
const wrap: HTMLElement = canvas.parentElement ?? root;
const labelEl: HTMLElement = l0;
const numEl: HTMLElement = n0;
const btn: HTMLButtonElement = b0;
type Vec = number[];
type Mat = number[];
type RandFn = () => number;
type Fn = () => void;
type Paint = string | CanvasGradient;
type Pal = { face: number[]; edge: number[]; pip: string };
type Die = { p: Vec; v: Vec; R: Mat; w: Vec; pal: number; phase: number; t: number; hold: number; bounces: number; result: number; top: number; hit: number; hx: number; hz: number; xmin: number; xmax: number; zmin: number; zmax: number };
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var DICE_RESTITUTION = 0.45;
var SPIN_DAMPING = 0.985;
var DICE_SEED = 7;
var GRAVITY = 22;
var DIE_HALF = 1;
var INERTIA = 2 * DIE_HALF * DIE_HALF / 3;
var FLOOR_FRICTION = 0.35;
var SETTLE_AFTER_MS = 2000;
var SETTLE_RATE = 0.16;
var HOLD_MS = 700;
var CAM_Y = 5.6, CAM_BACK = 12, FOCAL = 275, VIEW_CX = 160, VIEW_CY = 82;
var TABLE_X = 4.4, TABLE_Z0 = -3, TABLE_Z1 = 5;
var PH_FLY = 0, PH_SETTLE = 1, PH_HOLD = 2, PH_IDLE = 3;
var STRIP_BASE = 128;
var BAR_MAX = 18;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed: number): RandFn {
var s = seed >>> 0;
return function (): number { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function dot(a: Vec, b: Vec): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function cross(a: Vec, b: Vec): Vec { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function unit(a: Vec): Vec { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function matMul(a: Mat, b: Mat): Mat {
var o: Mat = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (var i = 0; i < 3; i += 1) {
for (var j = 0; j < 3; j += 1) o[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
}
return o;
}
function matVec(m: Mat, v: Vec): Vec {
return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
}
// 로드리게스 공식: 단위 축 ax 둘레로 ang 만큼 도는 회전 행렬
function rotAxis(ax: Vec, ang: number): Mat {
var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c];
}
function orthonormalize(m: Mat): Mat {
var a = unit([m[0], m[1], m[2]]);
var k = a[0] * m[3] + a[1] * m[4] + a[2] * m[5];
var b = unit([m[3] - a[0] * k, m[4] - a[1] * k, m[5] - a[2] * k]);
var c = cross(a, b);
return [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]];
}
function randAxis(rnd: RandFn): Vec { return unit([rnd() * 2 - 1, rnd() * 2 - 1, rnd() * 2 - 1]); }
var PITCH = Math.atan2(CAM_Y, CAM_BACK);
var CAM_S = Math.sin(PITCH), CAM_C = Math.cos(PITCH);
function camPoint(p: Vec): Vec {
var qy = p[1] - CAM_Y, qz = p[2] + CAM_BACK;
return [p[0], qy * CAM_C + qz * CAM_S, -qy * CAM_S + qz * CAM_C];
}
function camDir(v: Vec): Vec { return [v[0], v[1] * CAM_C + v[2] * CAM_S, -v[1] * CAM_S + v[2] * CAM_C]; }
function scr(p: Vec): Vec {
var c = camPoint(p);
var k = FOCAL / c[2];
return [VIEW_CX + c[0] * k, VIEW_CY - c[1] * k, k];
}
var FACE_N: Vec[] = [[0, 1, 0], [0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];
var FACE_U: Vec[] = [[1, 0, 0], [1, 0, 0], [0, 1, 0], [0, 1, 0], [1, 0, 0], [1, 0, 0]];
var FACE_V: Vec[] = [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]];
var FACE_VAL: number[] = [1, 6, 2, 5, 3, 4];
var PIPS: number[][][] = [[], [[0, 0]], [[-1, -1], [1, 1]], [[-1, -1], [0, 0], [1, 1]],
[[-1, -1], [1, -1], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
[[-1, -1], [-1, 0], [-1, 1], [1, -1], [1, 0], [1, 1]]];
var DICE_PAL: Pal[] = [
{ face: [255, 214, 226], edge: [240, 160, 186], pip: '#4b3a63' },
{ face: [200, 232, 255], edge: [140, 190, 236], pip: '#3b4a6b' },
{ face: [224, 212, 255], edge: [170, 150, 232], pip: '#46386b' },
{ face: [255, 240, 196], edge: [236, 206, 120], pip: '#5a4636' }
];
var LIGHT: Vec = unit([-0.45, 0.85, -0.35]);
function newDie(pal: number, xmin: number, xmax: number, zmin: number, zmax: number): Die {
return { p: [0, DIE_HALF, 0], v: [0, 0, 0], R: [1, 0, 0, 0, 1, 0, 0, 0, 1], w: [0, 0, 0], pal: pal, phase: PH_IDLE, t: 0, hold: 0,
bounces: 0, result: 0, top: 1, hit: 0, hx: 0, hz: 0, xmin: xmin, xmax: xmax, zmin: zmin, zmax: zmax };
}
function topIndex(d: Die): number {
var best = 0, by = -9;
for (var f = 0; f < 6; f += 1) {
var ny = d.R[3] * FACE_N[f][0] + d.R[4] * FACE_N[f][1] + d.R[5] * FACE_N[f][2];
if (ny > by) { by = ny; best = f; }
}
return best;
}
// 던지기: 시작 위치·속도·회전을 시드 수열에서 순서대로 뽑는다 (뽑는 순서가 곧 재현 규칙)
function launch(d: Die, rnd: RandFn, x: number, z: number, vx: number, vz: number, vy0: number, spin: number): void {
d.p = [x, 2.4, z];
d.v = [vx + (rnd() - 0.5) * 1.6, vy0 + rnd() * 2.2, vz + (rnd() - 0.5) * 1.6];
var ax = randAxis(rnd);
var s = spin * (0.6 + rnd() * 0.8);
d.w = [ax[0] * s, ax[1] * s, ax[2] * s];
d.R = rotAxis(randAxis(rnd), rnd() * 6.283);
d.phase = PH_FLY; d.t = 0; d.hold = 0; d.bounces = 0; d.result = 0; d.hit = 0;
}
function spinStep(d: Die, dt: number): void {
var wl = Math.hypot(d.w[0], d.w[1], d.w[2]);
if (wl > 1e-5) d.R = matMul(rotAxis([d.w[0] / wl, d.w[1] / wl, d.w[2] / wl], wl * dt), d.R);
}
function floorStep(d: Die): void {
var mi = 0, my = 99;
var rx = 0, ry = 0, rz = 0;
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? DIE_HALF : -DIE_HALF, ly = (i & 2) ? DIE_HALF : -DIE_HALF, lz = (i & 4) ? DIE_HALF : -DIE_HALF;
var wy = d.R[3] * lx + d.R[4] * ly + d.R[5] * lz;
if (wy < my) { my = wy; mi = i; rx = d.R[0] * lx + d.R[1] * ly + d.R[2] * lz; ry = wy; rz = d.R[6] * lx + d.R[7] * ly + d.R[8] * lz; }
}
var low = d.p[1] + my;
if (low >= 0) return;
d.p[1] -= low;
var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
if (cvy >= 0) return;
var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
d.v[1] += j;
d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;
var tl = Math.hypot(cvx, cvz);
if (tl > 1e-4) {
var jt = Math.min(FLOOR_FRICTION * j, tl * 0.5);
var tx = -cvx / tl * jt, tz = -cvz / tl * jt;
d.v[0] += tx; d.v[2] += tz;
d.w[0] += ry * tz / INERTIA; d.w[1] += (rz * tx - rx * tz) / INERTIA; d.w[2] += -ry * tx / INERTIA;
}
if (cvy < -1.2) { d.bounces += 1; d.hit = -cvy; d.hx = d.p[0] + rx; d.hz = d.p[2] + rz; }
}
function wallStep(d: Die): void {
if (d.p[0] < d.xmin) { d.p[0] = d.xmin; d.v[0] = Math.abs(d.v[0]) * 0.5; }
if (d.p[0] > d.xmax) { d.p[0] = d.xmax; d.v[0] = -Math.abs(d.v[0]) * 0.5; }
if (d.p[2] < d.zmin) { d.p[2] = d.zmin; d.v[2] = Math.abs(d.v[2]) * 0.5; }
if (d.p[2] > d.zmax) { d.p[2] = d.zmax; d.v[2] = -Math.abs(d.v[2]) * 0.5; }
}
// 멈추기: 법선 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 정확히 위를 보도록 남은 각도의 SETTLE_RATE 만큼씩 돌린다
function settleStep(d: Die): void {
var k = topIndex(d);
d.top = FACE_VAL[k];
var nw = matVec(d.R, FACE_N[k]);
var ang = Math.acos(clamp(nw[1], -1, 1));
if (ang > 1e-4) {
var ax = unit(cross(nw, [0, 1, 0]));
d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
}
d.R = orthonormalize(matMul(rotAxis([0, 1, 0], d.w[1] * dtS), d.R));
d.w[1] *= 0.88;
d.v[0] *= 0.86; d.v[2] *= 0.86; d.v[1] = 0;
d.p[0] += d.v[0] * dtS; d.p[2] += d.v[2] * dtS;
d.p[1] += (DIE_HALF - d.p[1]) * 0.22;
wallStep(d);
if (ang < 0.004 && Math.abs(d.p[1] - DIE_HALF) < 0.01) {
d.p[1] = DIE_HALF;
var q = topIndex(d);
d.R = orthonormalize(matMul(rotAxis(unit(cross(matVec(d.R, FACE_N[q]), [0, 1, 0])), Math.acos(clamp(matVec(d.R, FACE_N[q])[1], -1, 1))), d.R));
d.phase = PH_HOLD; d.hold = 0; d.result = FACE_VAL[q]; d.top = d.result;
}
}
function stepDie(d: Die): void {
if (d.phase === PH_FLY) {
d.t += FIXED_DT;
d.v[1] -= GRAVITY * dtS;
d.p[0] += d.v[0] * dtS; d.p[1] += d.v[1] * dtS; d.p[2] += d.v[2] * dtS;
spinStep(d, dtS);
d.w[0] *= SPIN_DAMPING; d.w[1] *= SPIN_DAMPING; d.w[2] *= SPIN_DAMPING;
wallStep(d);
floorStep(d);
var sp = Math.hypot(d.v[0], d.v[1], d.v[2]);
if (d.t > SETTLE_AFTER_MS || (d.bounces >= 3 && sp < 1.6)) d.phase = PH_SETTLE;
} else if (d.phase === PH_SETTLE) {
d.t += FIXED_DT;
settleStep(d);
} else if (d.phase === PH_HOLD) {
d.hold += FIXED_DT;
}
}
function runUntilHold(d: Die): number {
for (var i = 0; i < 1200 && d.phase !== PH_HOLD; i += 1) stepDie(d);
return d.result;
}
// ── 그리기 ──
function rgb(c: number[], k: number): string {
return 'rgb(' + Math.min(255, Math.round(c[0] * k)) + ',' + Math.min(255, Math.round(c[1] * k)) + ',' + Math.min(255, Math.round(c[2] * k)) + ')';
}
function quad(c: Vec, u: Vec, v: Vec, s: number): number[][] {
var o: number[][] = [];
var sg = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
for (var i = 0; i < 4; i += 1) {
o.push(scr([c[0] + (u[0] * sg[i][0] + v[0] * sg[i][1]) * s * DIE_HALF, c[1] + (u[1] * sg[i][0] + v[1] * sg[i][1]) * s * DIE_HALF, c[2] + (u[2] * sg[i][0] + v[2] * sg[i][1]) * s * DIE_HALF]));
}
return o;
}
function fillPoly(pts: number[][], color: Paint, edge: number): void {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
if (edge > 0) { ctx.strokeStyle = color; ctx.lineWidth = edge; ctx.stroke(); }
}
function drawDie(d: Die, hi: number, alpha: number): void {
var pal = DICE_PAL[d.pal % DICE_PAL.length];
ctx.globalAlpha = alpha;
ctx.lineJoin = 'round';
for (var f = 0; f < 6; f += 1) {
var nw = matVec(d.R, FACE_N[f]);
var cw: Vec = [d.p[0] + nw[0] * DIE_HALF, d.p[1] + nw[1] * DIE_HALF, d.p[2] + nw[2] * DIE_HALF];
var cc = camPoint(cw), nc = camDir(nw);
if (nc[0] * cc[0] + nc[1] * cc[1] + nc[2] * cc[2] >= 0) continue;
var uw = matVec(d.R, FACE_U[f]), vw = matVec(d.R, FACE_V[f]);
var lit = 0.66 + 0.34 * Math.max(0, dot(nw, LIGHT));
var k0 = scr(cw)[2];
fillPoly(quad(cw, uw, vw, 1), rgb(pal.edge, lit), k0 * 0.1);
fillPoly(quad(cw, uw, vw, 0.84), rgb(pal.face, lit), k0 * 0.16);
if (f === hi) fillPoly(quad(cw, uw, vw, 0.84), 'rgba(255,255,255,0.4)', 0);
var val = FACE_VAL[f];
for (var pi = 0; pi < PIPS[val].length; pi += 1) {
var a = PIPS[val][pi][0] * 0.5 * DIE_HALF, b = PIPS[val][pi][1] * 0.5 * DIE_HALF;
var ring: number[][] = [];
for (var j = 0; j < 10; j += 1) {
var an = j * 0.62832, rr = 0.17 * DIE_HALF;
var ca = Math.cos(an) * rr, sa = Math.sin(an) * rr;
ring.push(scr([cw[0] + uw[0] * (a + ca) + vw[0] * (b + sa) + nw[0] * 0.004, cw[1] + uw[1] * (a + ca) + vw[1] * (b + sa) + nw[1] * 0.004, cw[2] + uw[2] * (a + ca) + vw[2] * (b + sa) + nw[2] * 0.004]));
}
fillPoly(ring, pal.pip, 0);
}
}
ctx.globalAlpha = 1;
}
function drawShadow(d: Die, alpha: number): void {
var s = scr([d.p[0], 0, d.p[2]]);
var h = Math.max(0, d.p[1] - DIE_HALF);
var r = DIE_HALF * s[2] * (1.2 + h * 0.18);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.42);
ctx.beginPath();
ctx.arc(0, 0, r, 0, 6.2832);
ctx.fillStyle = 'rgba(30,60,60,' + (0.34 * alpha / (1 + h * 0.7)) + ')';
ctx.fill();
ctx.restore();
}
var FELT_DOTS: number[][] = [];
(function (): void {
var fr = seedRand(99);
for (var i = 0; i < 70; i += 1) FELT_DOTS.push([(fr() * 2 - 1) * TABLE_X, TABLE_Z0 + fr() * (TABLE_Z1 - TABLE_Z0), 0.04 + fr() * 0.08]);
})();
function tablePts(m: number): number[][] {
return [scr([-TABLE_X - m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z1 + m]), scr([-TABLE_X - m, 0, TABLE_Z1 + m])];
}
function drawStage(lightX: number, lightK: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var bg = ctx.createRadialGradient(160, 80, 10, 160, 80, 210);
bg.addColorStop(0, '#3f3764');
bg.addColorStop(1, '#221d36');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
fillPoly(tablePts(0.5), '#8e80c8', 0);
var tp = tablePts(0);
var g = ctx.createLinearGradient(0, tp[0][1], 0, tp[2][1]);
g.addColorStop(0, '#9fdcc3');
g.addColorStop(1, '#78c8aa');
fillPoly(tp, g, 0);
for (var i = 0; i < FELT_DOTS.length; i += 1) {
var s = scr([FELT_DOTS[i][0], 0, FELT_DOTS[i][1]]);
ctx.beginPath();
ctx.arc(s[0], s[1], FELT_DOTS[i][2] * s[2], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,0.12)';
ctx.fill();
}
var lp = scr([lightX, 0, 1]);
var lg = ctx.createRadialGradient(lp[0], lp[1], 4, lp[0], lp[1], 120);
lg.addColorStop(0, 'rgba(255,255,255,' + (0.3 * lightK) + ')');
lg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.save();
ctx.beginPath();
ctx.moveTo(tp[0][0], tp[0][1]); ctx.lineTo(tp[1][0], tp[1][1]); ctx.lineTo(tp[2][0], tp[2][1]); ctx.lineTo(tp[3][0], tp[3][1]);
ctx.closePath();
ctx.clip();
ctx.fillStyle = lg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.restore();
}
function drawChip(x: number, y: number, s: number, value: number, pal: number, mark: boolean): void {
var p = DICE_PAL[pal % DICE_PAL.length];
ctx.fillStyle = value > 0 ? rgb(p.face, 1) : 'rgba(255,255,255,0.18)';
ctx.strokeStyle = mark ? '#7fe3bd' : rgb(p.edge, 1);
ctx.lineWidth = mark ? 1.8 : 1;
ctx.fillRect(x, y, s, s);
ctx.strokeRect(x, y, s, s);
for (var i = 0; i < PIPS[value].length; i += 1) {
ctx.beginPath();
ctx.arc(x + s / 2 + PIPS[value][i][0] * s * 0.26, y + s / 2 + PIPS[value][i][1] * s * 0.26, s * 0.1, 0, 6.2832);
ctx.fillStyle = p.pip;
ctx.fill();
}
}
// ── 상태 줄 (aria-live) ──
var stLabel = '', stNum = '', stAt = -9999;
function setStatus(label: string, num: string, force: boolean): void {
if (label === stLabel && num === stNum) return;
if (!force && label === stLabel && elapsed - stAt < 450) return;
stLabel = label; stNum = num; stAt = elapsed;
labelEl.textContent = label;
numEl.textContent = num;
}
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Fn[] = [];
// 기법: 두 주사위가 각자 윗면을 읽어 낸 값의 합 s(2~12) 를 칸마다 세어 막대 높이로 쌓는다.
// 시작할 때 보이지 않게 같은 물리로 PRELOAD_ROLLS 번을 먼저 굴려 두므로, 가운데가 높은 분포가 처음부터 자라고 있다.
var T_SUM = '합계';
var T_ROLLING = '굴리는 중';
var PRELOAD_ROLLS = 24;
var dieA: Die = newDie(0, -3.4, -0.6, -1.6, 2.4);
var dieB: Die = newDie(3, 0.6, 3.4, -1.6, 2.4);
var rng: RandFn = seedRand(DICE_SEED + 5);
var counts: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
var barH: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
var lastSum = 0;
var counted = true;
function throwBoth(a: Die, b: Die, rnd: RandFn): void {
launch(a, rnd, -3.0, rnd() * 2.2 - 0.6, 3.0, 0.2, 3.0, 14);
launch(b, rnd, 3.0, rnd() * 2.2 - 0.6, -3.0, 0.2, 3.0, 14);
}
function sceneInit(): void {
var ta: Die = newDie(0, -3.4, -0.6, -1.6, 2.4);
var tb: Die = newDie(3, 0.6, 3.4, -1.6, 2.4);
for (var k = 0; k < PRELOAD_ROLLS; k += 1) {
throwBoth(ta, tb, rng);
counts[runUntilHold(ta) + runUntilHold(tb)] += 1;
}
for (var s = 2; s <= 12; s += 1) barH[s] = counts[s];
dieA.phase = PH_IDLE; dieB.phase = PH_IDLE;
}
function sceneRoll(manual: boolean): void {
throwBoth(dieA, dieB, rng);
counted = false;
setStatus(T_ROLLING, '', true);
}
function sceneDone(): boolean { return dieA.phase === PH_HOLD && dieB.phase === PH_HOLD; }
function sceneFinish(): void { setStatus(T_SUM, String(lastSum), true); }
function stepWorld(): void {
if (dieA.phase === PH_IDLE) return;
stepDie(dieA); stepDie(dieB);
if (!counted && sceneDone()) {
counted = true;
lastSum = dieA.result + dieB.result;
counts[lastSum] += 1;
setStatus(T_SUM, String(lastSum), true);
}
for (var s = 2; s <= 12; s += 1) barH[s] += (counts[s] - barH[s]) * 0.18;
if (counted && demo && dieA.hold >= HOLD_MS && dieB.hold >= HOLD_MS) sceneRoll(false);
}
function drawWorld(): void {
drawStage(0, 0.8);
if (dieA.phase !== PH_IDLE) {
var order: Die[] = camPoint(dieA.p)[2] > camPoint(dieB.p)[2] ? [dieA, dieB] : [dieB, dieA];
drawShadow(dieA, 1); drawShadow(dieB, 1);
drawDie(order[0], -1, 1); drawDie(order[1], -1, 1);
}
var mx = 6;
for (var s = 2; s <= 12; s += 1) if (counts[s] > mx) mx = counts[s];
ctx.fillStyle = 'rgba(255,255,255,0.14)';
ctx.fillRect(70, STRIP_BASE - BAR_MAX - 3, 184, BAR_MAX + 17);
ctx.font = '8px sans-serif';
ctx.textAlign = 'center';
for (var b = 2; b <= 12; b += 1) {
var h = barH[b] / mx * BAR_MAX;
ctx.fillStyle = (counted && b === lastSum) ? '#ff9ebb' : '#c9b8ff';
ctx.fillRect(76 + (b - 2) * 16.4, STRIP_BASE - h, 12, h);
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.fillText(String(b), 82 + (b - 2) * 16.4, STRIP_BASE + 9);
}
}
function fit(): void {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs = -1;
var acc = 0;
function tick(ts: number): void {
if (lastTs < 0) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
// 모션 줄이기: 애니메이션 없이 결과가 나올 때까지 같은 계산을 한 번에 돌려 최종 장면만 그린다
function runToDone(): void {
var i = 0;
do { elapsed += FIXED_DT; stepWorld(); i += 1; } while (i < 1500 && !sceneDone());
sceneFinish();
drawWorld();
}
function onRollClick(): void {
stopDemo();
sceneRoll(true);
if (reduce) runToDone();
}
sceneInit();
sceneRoll(false);
if (reduce) {
runToDone();
} else {
for (var w0 = 0; w0 < 8; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
}
btn.addEventListener('click', onRollClick);
cleaners.push(function (): void { btn.removeEventListener('click', onRollClick); });
fit();
window.addEventListener('resize', fit);
cleaners.push(function (): void { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
return destroy;
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} role="img" aria-label="두 주사위를 양쪽에서 굴려 눈의 합을 구하고, 미리 24번 굴려 쌓아 둔 2부터 12까지의 막대에 한 칸씩 더해 가운데가 높은 분포가 자라는 모습을 보인다." />
<output className={styles.bp__status} aria-live="polite"><span ref={labelRef}>굴리는 중</span> <b ref={numRef} /></output>
<button type="button" className={styles.bp__btn} ref={btnRef}>굴리기</button>
<p className={styles.bp__caption}>두 주사위를 양쪽에서 굴려 눈의 합을 구하고, 미리 24번 굴려 쌓아 둔 2부터 12까지의 막대에 한 칸씩 더해 가운데가 높은 분포가 자라는 모습을 보인다.</p>
</div>
</main>
);
}05 · 二つのサイコロの合計を棒に積む場面 — 計測 累積面積 20.543% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function throwBoth(a, b, rnd) {
launch(a, rnd, -3.0, rnd() * 2.2 - 0.6, 3.0, 0.2, 3.0, 14);
launch(b, rnd, 3.0, rnd() * 2.2 - 0.6, -3.0, 0.2, 3.0, 14);
}
function sceneInit() {
var ta = newDie(0, -3.4, -0.6, -1.6, 2.4);
var tb = newDie(3, 0.6, 3.4, -1.6, 2.4);
for (var k = 0; k < PRELOAD_ROLLS; k += 1) {
throwBoth(ta, tb, rng);
counts[runUntilHold(ta) + runUntilHold(tb)] += 1;
}
for (var s = 2; s <= 12; s += 1) barH[s] = counts[s];
dieA.phase = PH_IDLE; dieB.phase = PH_IDLE;
}
06転がった跡とほこりが床に残るサイコロ
spawnDust() が衝突の速さに比例して7個から17個のほこりの粒を散らして衝突点を床のしみとして残し、影の点が通った経路は DUST_FADE_MS の3600msをかけて薄れる線で描きます。跡は次の投げまで残り、ゆっくり消えていきます。累積面積18.8%・平均強度52.5で、23/23フレームが動きました。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" role="img" aria-label="床に当たる地点ごとに小さなほこりの粒を散らし、転がった経路を薄い線で残して、投げたあとの跡がゆっくり消える場面を描く。"></canvas>
<output class="bp__status" aria-live="polite"><span id="bpLabel">転がり中</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">振る</button>
<p class="bp__caption">床に当たる地点ごとに小さなほこりの粒を散らし、転がった経路を薄い線で残して、投げたあとの跡がゆっくり消える場面を描く。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
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: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
display: flex;
align-items: baseline;
gap: $sp-2;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: $sp-3;
top: $sp-3;
min-height: $sp-8;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-instant $ease-out;
}
.bp__btn:hover { transform: translateY(-1px); }
.bp__btn:active { transform: translateY(1px); }
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .9);
outline-offset: 2px;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__btn { font-size: 11px; padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
.bp__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: 12px;
top: 12px;
min-height: 32px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 140ms ease-out;
}
.bp__btn:hover {
transform: translateY(-1px);
}
.bp__btn:active {
transform: translateY(1px);
}
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.9);
outline-offset: 2px;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
padding: 4px 8px;
}
.bp__btn {
font-size: 11px;
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
.bp__btn {
transition: none;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var labelEl = document.getElementById('bpLabel');
var numEl = document.getElementById('bpNum');
var btn = document.getElementById('bpBtn');
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var DICE_RESTITUTION = 0.45;
var SPIN_DAMPING = 0.985;
var DICE_SEED = 7;
var GRAVITY = 22;
var DIE_HALF = 1;
var INERTIA = 2 * DIE_HALF * DIE_HALF / 3;
var FLOOR_FRICTION = 0.35;
var SETTLE_AFTER_MS = 2000;
var SETTLE_RATE = 0.16;
var HOLD_MS = 700;
var CAM_Y = 5.6, CAM_BACK = 12, FOCAL = 275, VIEW_CX = 160, VIEW_CY = 82;
var TABLE_X = 4.4, TABLE_Z0 = -3, TABLE_Z1 = 5;
var PH_FLY = 0, PH_SETTLE = 1, PH_HOLD = 2, PH_IDLE = 3;
var STRIP_BASE = 128;
var BAR_MAX = 18;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function cross(a, b) { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function unit(a) { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function matMul(a, b) {
var o = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (var i = 0; i < 3; i += 1) {
for (var j = 0; j < 3; j += 1) o[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
}
return o;
}
function matVec(m, v) {
return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
}
// 로드리게스 공식: 단위 축 ax 둘레로 ang 만큼 도는 회전 행렬
function rotAxis(ax, ang) {
var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c];
}
function orthonormalize(m) {
var a = unit([m[0], m[1], m[2]]);
var k = a[0] * m[3] + a[1] * m[4] + a[2] * m[5];
var b = unit([m[3] - a[0] * k, m[4] - a[1] * k, m[5] - a[2] * k]);
var c = cross(a, b);
return [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]];
}
function randAxis(rnd) { return unit([rnd() * 2 - 1, rnd() * 2 - 1, rnd() * 2 - 1]); }
var PITCH = Math.atan2(CAM_Y, CAM_BACK);
var CAM_S = Math.sin(PITCH), CAM_C = Math.cos(PITCH);
function camPoint(p) {
var qy = p[1] - CAM_Y, qz = p[2] + CAM_BACK;
return [p[0], qy * CAM_C + qz * CAM_S, -qy * CAM_S + qz * CAM_C];
}
function camDir(v) { return [v[0], v[1] * CAM_C + v[2] * CAM_S, -v[1] * CAM_S + v[2] * CAM_C]; }
function scr(p) {
var c = camPoint(p);
var k = FOCAL / c[2];
return [VIEW_CX + c[0] * k, VIEW_CY - c[1] * k, k];
}
var FACE_N = [[0, 1, 0], [0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];
var FACE_U = [[1, 0, 0], [1, 0, 0], [0, 1, 0], [0, 1, 0], [1, 0, 0], [1, 0, 0]];
var FACE_V = [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]];
var FACE_VAL = [1, 6, 2, 5, 3, 4];
var PIPS = [[], [[0, 0]], [[-1, -1], [1, 1]], [[-1, -1], [0, 0], [1, 1]],
[[-1, -1], [1, -1], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
[[-1, -1], [-1, 0], [-1, 1], [1, -1], [1, 0], [1, 1]]];
var DICE_PAL = [
{ face: [255, 214, 226], edge: [240, 160, 186], pip: '#4b3a63' },
{ face: [200, 232, 255], edge: [140, 190, 236], pip: '#3b4a6b' },
{ face: [224, 212, 255], edge: [170, 150, 232], pip: '#46386b' },
{ face: [255, 240, 196], edge: [236, 206, 120], pip: '#5a4636' }
];
var LIGHT = unit([-0.45, 0.85, -0.35]);
function newDie(pal, xmin, xmax, zmin, zmax) {
return { p: [0, DIE_HALF, 0], v: [0, 0, 0], R: [1, 0, 0, 0, 1, 0, 0, 0, 1], w: [0, 0, 0], pal: pal, phase: PH_IDLE, t: 0, hold: 0,
bounces: 0, result: 0, top: 1, hit: 0, hx: 0, hz: 0, xmin: xmin, xmax: xmax, zmin: zmin, zmax: zmax };
}
function topIndex(d) {
var best = 0, by = -9;
for (var f = 0; f < 6; f += 1) {
var ny = d.R[3] * FACE_N[f][0] + d.R[4] * FACE_N[f][1] + d.R[5] * FACE_N[f][2];
if (ny > by) { by = ny; best = f; }
}
return best;
}
// 던지기: 시작 위치·속도·회전을 시드 수열에서 순서대로 뽑는다 (뽑는 순서가 곧 재현 규칙)
function launch(d, rnd, x, z, vx, vz, vy0, spin) {
d.p = [x, 2.4, z];
d.v = [vx + (rnd() - 0.5) * 1.6, vy0 + rnd() * 2.2, vz + (rnd() - 0.5) * 1.6];
var ax = randAxis(rnd);
var s = spin * (0.6 + rnd() * 0.8);
d.w = [ax[0] * s, ax[1] * s, ax[2] * s];
d.R = rotAxis(randAxis(rnd), rnd() * 6.283);
d.phase = PH_FLY; d.t = 0; d.hold = 0; d.bounces = 0; d.result = 0; d.hit = 0;
}
function spinStep(d, dt) {
var wl = Math.hypot(d.w[0], d.w[1], d.w[2]);
if (wl > 1e-5) d.R = matMul(rotAxis([d.w[0] / wl, d.w[1] / wl, d.w[2] / wl], wl * dt), d.R);
}
function floorStep(d) {
var mi = 0, my = 99;
var rx = 0, ry = 0, rz = 0;
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? DIE_HALF : -DIE_HALF, ly = (i & 2) ? DIE_HALF : -DIE_HALF, lz = (i & 4) ? DIE_HALF : -DIE_HALF;
var wy = d.R[3] * lx + d.R[4] * ly + d.R[5] * lz;
if (wy < my) { my = wy; mi = i; rx = d.R[0] * lx + d.R[1] * ly + d.R[2] * lz; ry = wy; rz = d.R[6] * lx + d.R[7] * ly + d.R[8] * lz; }
}
var low = d.p[1] + my;
if (low >= 0) return;
d.p[1] -= low;
var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
if (cvy >= 0) return;
var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
d.v[1] += j;
d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;
var tl = Math.hypot(cvx, cvz);
if (tl > 1e-4) {
var jt = Math.min(FLOOR_FRICTION * j, tl * 0.5);
var tx = -cvx / tl * jt, tz = -cvz / tl * jt;
d.v[0] += tx; d.v[2] += tz;
d.w[0] += ry * tz / INERTIA; d.w[1] += (rz * tx - rx * tz) / INERTIA; d.w[2] += -ry * tx / INERTIA;
}
if (cvy < -1.2) { d.bounces += 1; d.hit = -cvy; d.hx = d.p[0] + rx; d.hz = d.p[2] + rz; }
}
function wallStep(d) {
if (d.p[0] < d.xmin) { d.p[0] = d.xmin; d.v[0] = Math.abs(d.v[0]) * 0.5; }
if (d.p[0] > d.xmax) { d.p[0] = d.xmax; d.v[0] = -Math.abs(d.v[0]) * 0.5; }
if (d.p[2] < d.zmin) { d.p[2] = d.zmin; d.v[2] = Math.abs(d.v[2]) * 0.5; }
if (d.p[2] > d.zmax) { d.p[2] = d.zmax; d.v[2] = -Math.abs(d.v[2]) * 0.5; }
}
// 멈추기: 법선 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 정확히 위를 보도록 남은 각도의 SETTLE_RATE 만큼씩 돌린다
function settleStep(d) {
var k = topIndex(d);
d.top = FACE_VAL[k];
var nw = matVec(d.R, FACE_N[k]);
var ang = Math.acos(clamp(nw[1], -1, 1));
if (ang > 1e-4) {
var ax = unit(cross(nw, [0, 1, 0]));
d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
}
d.R = orthonormalize(matMul(rotAxis([0, 1, 0], d.w[1] * dtS), d.R));
d.w[1] *= 0.88;
d.v[0] *= 0.86; d.v[2] *= 0.86; d.v[1] = 0;
d.p[0] += d.v[0] * dtS; d.p[2] += d.v[2] * dtS;
d.p[1] += (DIE_HALF - d.p[1]) * 0.22;
wallStep(d);
if (ang < 0.004 && Math.abs(d.p[1] - DIE_HALF) < 0.01) {
d.p[1] = DIE_HALF;
var q = topIndex(d);
d.R = orthonormalize(matMul(rotAxis(unit(cross(matVec(d.R, FACE_N[q]), [0, 1, 0])), Math.acos(clamp(matVec(d.R, FACE_N[q])[1], -1, 1))), d.R));
d.phase = PH_HOLD; d.hold = 0; d.result = FACE_VAL[q]; d.top = d.result;
}
}
function stepDie(d) {
if (d.phase === PH_FLY) {
d.t += FIXED_DT;
d.v[1] -= GRAVITY * dtS;
d.p[0] += d.v[0] * dtS; d.p[1] += d.v[1] * dtS; d.p[2] += d.v[2] * dtS;
spinStep(d, dtS);
d.w[0] *= SPIN_DAMPING; d.w[1] *= SPIN_DAMPING; d.w[2] *= SPIN_DAMPING;
wallStep(d);
floorStep(d);
var sp = Math.hypot(d.v[0], d.v[1], d.v[2]);
if (d.t > SETTLE_AFTER_MS || (d.bounces >= 3 && sp < 1.6)) d.phase = PH_SETTLE;
} else if (d.phase === PH_SETTLE) {
d.t += FIXED_DT;
settleStep(d);
} else if (d.phase === PH_HOLD) {
d.hold += FIXED_DT;
}
}
function runUntilHold(d) {
for (var i = 0; i < 1200 && d.phase !== PH_HOLD; i += 1) stepDie(d);
return d.result;
}
// ── 그리기 ──
function rgb(c, k) {
return 'rgb(' + Math.min(255, Math.round(c[0] * k)) + ',' + Math.min(255, Math.round(c[1] * k)) + ',' + Math.min(255, Math.round(c[2] * k)) + ')';
}
function quad(c, u, v, s) {
var o = [];
var sg = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
for (var i = 0; i < 4; i += 1) {
o.push(scr([c[0] + (u[0] * sg[i][0] + v[0] * sg[i][1]) * s * DIE_HALF, c[1] + (u[1] * sg[i][0] + v[1] * sg[i][1]) * s * DIE_HALF, c[2] + (u[2] * sg[i][0] + v[2] * sg[i][1]) * s * DIE_HALF]));
}
return o;
}
function fillPoly(pts, color, edge) {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
if (edge > 0) { ctx.strokeStyle = color; ctx.lineWidth = edge; ctx.stroke(); }
}
function drawDie(d, hi, alpha) {
var pal = DICE_PAL[d.pal % DICE_PAL.length];
ctx.globalAlpha = alpha;
ctx.lineJoin = 'round';
for (var f = 0; f < 6; f += 1) {
var nw = matVec(d.R, FACE_N[f]);
var cw = [d.p[0] + nw[0] * DIE_HALF, d.p[1] + nw[1] * DIE_HALF, d.p[2] + nw[2] * DIE_HALF];
var cc = camPoint(cw), nc = camDir(nw);
if (nc[0] * cc[0] + nc[1] * cc[1] + nc[2] * cc[2] >= 0) continue;
var uw = matVec(d.R, FACE_U[f]), vw = matVec(d.R, FACE_V[f]);
var lit = 0.66 + 0.34 * Math.max(0, dot(nw, LIGHT));
var k0 = scr(cw)[2];
fillPoly(quad(cw, uw, vw, 1), rgb(pal.edge, lit), k0 * 0.1);
fillPoly(quad(cw, uw, vw, 0.84), rgb(pal.face, lit), k0 * 0.16);
if (f === hi) fillPoly(quad(cw, uw, vw, 0.84), 'rgba(255,255,255,0.4)', 0);
var val = FACE_VAL[f];
for (var pi = 0; pi < PIPS[val].length; pi += 1) {
var a = PIPS[val][pi][0] * 0.5 * DIE_HALF, b = PIPS[val][pi][1] * 0.5 * DIE_HALF;
var ring = [];
for (var j = 0; j < 10; j += 1) {
var an = j * 0.62832, rr = 0.17 * DIE_HALF;
var ca = Math.cos(an) * rr, sa = Math.sin(an) * rr;
ring.push(scr([cw[0] + uw[0] * (a + ca) + vw[0] * (b + sa) + nw[0] * 0.004, cw[1] + uw[1] * (a + ca) + vw[1] * (b + sa) + nw[1] * 0.004, cw[2] + uw[2] * (a + ca) + vw[2] * (b + sa) + nw[2] * 0.004]));
}
fillPoly(ring, pal.pip, 0);
}
}
ctx.globalAlpha = 1;
}
function drawShadow(d, alpha) {
var s = scr([d.p[0], 0, d.p[2]]);
var h = Math.max(0, d.p[1] - DIE_HALF);
var r = DIE_HALF * s[2] * (1.2 + h * 0.18);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.42);
ctx.beginPath();
ctx.arc(0, 0, r, 0, 6.2832);
ctx.fillStyle = 'rgba(30,60,60,' + (0.34 * alpha / (1 + h * 0.7)) + ')';
ctx.fill();
ctx.restore();
}
var FELT_DOTS = [];
(function () {
var fr = seedRand(99);
for (var i = 0; i < 70; i += 1) FELT_DOTS.push([(fr() * 2 - 1) * TABLE_X, TABLE_Z0 + fr() * (TABLE_Z1 - TABLE_Z0), 0.04 + fr() * 0.08]);
})();
function tablePts(m) {
return [scr([-TABLE_X - m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z1 + m]), scr([-TABLE_X - m, 0, TABLE_Z1 + m])];
}
function drawStage(lightX, lightK) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var bg = ctx.createRadialGradient(160, 80, 10, 160, 80, 210);
bg.addColorStop(0, '#3f3764');
bg.addColorStop(1, '#221d36');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
fillPoly(tablePts(0.5), '#8e80c8', 0);
var tp = tablePts(0);
var g = ctx.createLinearGradient(0, tp[0][1], 0, tp[2][1]);
g.addColorStop(0, '#9fdcc3');
g.addColorStop(1, '#78c8aa');
fillPoly(tp, g, 0);
for (var i = 0; i < FELT_DOTS.length; i += 1) {
var s = scr([FELT_DOTS[i][0], 0, FELT_DOTS[i][1]]);
ctx.beginPath();
ctx.arc(s[0], s[1], FELT_DOTS[i][2] * s[2], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,0.12)';
ctx.fill();
}
var lp = scr([lightX, 0, 1]);
var lg = ctx.createRadialGradient(lp[0], lp[1], 4, lp[0], lp[1], 120);
lg.addColorStop(0, 'rgba(255,255,255,' + (0.3 * lightK) + ')');
lg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.save();
ctx.beginPath();
ctx.moveTo(tp[0][0], tp[0][1]); ctx.lineTo(tp[1][0], tp[1][1]); ctx.lineTo(tp[2][0], tp[2][1]); ctx.lineTo(tp[3][0], tp[3][1]);
ctx.closePath();
ctx.clip();
ctx.fillStyle = lg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.restore();
}
function drawChip(x, y, s, value, pal, mark) {
var p = DICE_PAL[pal % DICE_PAL.length];
ctx.fillStyle = value > 0 ? rgb(p.face, 1) : 'rgba(255,255,255,0.18)';
ctx.strokeStyle = mark ? '#7fe3bd' : rgb(p.edge, 1);
ctx.lineWidth = mark ? 1.8 : 1;
ctx.fillRect(x, y, s, s);
ctx.strokeRect(x, y, s, s);
for (var i = 0; i < PIPS[value].length; i += 1) {
ctx.beginPath();
ctx.arc(x + s / 2 + PIPS[value][i][0] * s * 0.26, y + s / 2 + PIPS[value][i][1] * s * 0.26, s * 0.1, 0, 6.2832);
ctx.fillStyle = p.pip;
ctx.fill();
}
}
// ── 상태 줄 (aria-live) ──
var stLabel = '', stNum = '', stAt = -9999;
function setStatus(label, num, force) {
if (label === stLabel && num === stNum) return;
if (!force && label === stLabel && elapsed - stAt < 450) return;
stLabel = label; stNum = num; stAt = elapsed;
labelEl.textContent = label;
numEl.textContent = num;
}
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
// 기법: 바닥에 닿는 걸음마다 충격 속도에 비례한 개수의 먼지 입자를 시드 난수로 흩뿌리고(수평 퍼짐·위로 솟음·중력·마찰),
// 그림자 점의 경로를 DUST_FADE_MS 동안 옅어지는 선으로 바닥에 남긴다. 입자·자국 모두 수명(age)으로만 투명도를 정한다.
var T_RESULT = '結果';
var T_ROLLING = '転がり中';
var DUST_FADE_MS = 3600;
var die = newDie(1, -3.4, 3.4, -1.8, 2.6);
var rng = seedRand(DICE_SEED + 6);
var dustRng = seedRand(DICE_SEED + 60);
var dust = [];
var path = [];
var marks = [];
function sceneInit() { die.phase = PH_IDLE; }
function sceneRoll(manual) {
launch(die, rng, -3.2, rng() * 2.4 - 0.8, 3.8, 0.3, 3.2, 15);
setStatus(T_ROLLING, '', true);
}
function sceneDone() { return die.phase === PH_HOLD; }
function sceneFinish() { setStatus(T_RESULT, String(die.result), true); }
function spawnDust(x, z, speed) {
var n = 7 + Math.min(10, Math.round(speed * 1.1));
for (var i = 0; i < n; i += 1) {
var an = dustRng() * 6.2832, sp = 0.6 + dustRng() * 1.8;
dust.push({ x: x, y: 0.05, z: z, vx: Math.cos(an) * sp, vy: 0.8 + dustRng() * 1.8, vz: Math.sin(an) * sp, born: elapsed, life: 900 + dustRng() * 700, size: 0.07 + dustRng() * 0.11 });
}
marks.push([x, z, elapsed, 0.3 + Math.min(0.5, speed * 0.04)]);
if (marks.length > 24) marks.shift();
}
function stepWorld() {
if (die.phase === PH_IDLE) return;
stepDie(die);
if (die.phase !== PH_HOLD && Math.round(die.t / FIXED_DT) % 3 === 0) { path.push([die.p[0], die.p[2], elapsed]); if (path.length > 90) path.shift(); }
if (die.hit > 0) { spawnDust(die.hx, die.hz, die.hit); die.hit = 0; }
var live = [];
for (var i = 0; i < dust.length; i += 1) {
var p = dust[i];
if (elapsed - p.born > p.life) continue;
p.vy -= 3.2 * dtS; p.vx *= 0.97; p.vz *= 0.97;
p.x += p.vx * dtS; p.y = Math.max(0, p.y + p.vy * dtS); p.z += p.vz * dtS;
live.push(p);
}
dust = live;
if (die.phase === PH_HOLD) setStatus(T_RESULT, String(die.result), true);
if (die.phase === PH_HOLD && demo && die.hold >= HOLD_MS) sceneRoll(false);
}
function drawWorld() {
drawStage(die.p[0], 0.8);
ctx.lineCap = 'round';
for (var i = 1; i < path.length; i += 1) {
var age = (elapsed - path[i][2]) / DUST_FADE_MS;
if (age >= 1) continue;
var a = scr([path[i - 1][0], 0, path[i - 1][1]]), b = scr([path[i][0], 0, path[i][1]]);
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]);
ctx.strokeStyle = 'rgba(95,70,150,' + (0.5 * (1 - age)) + ')'; ctx.lineWidth = b[2] * 0.3 * (1 - age * 0.6); ctx.stroke();
}
for (var m = 0; m < marks.length; m += 1) {
var f = (elapsed - marks[m][2]) / (DUST_FADE_MS + 800);
if (f >= 1) continue;
var s = scr([marks[m][0], 0, marks[m][1]]);
ctx.save(); ctx.translate(s[0], s[1]); ctx.scale(1, 0.4);
ctx.beginPath(); ctx.arc(0, 0, s[2] * marks[m][3], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,' + (0.34 * (1 - f)) + ')'; ctx.fill();
ctx.restore();
}
if (die.phase !== PH_IDLE) { drawShadow(die, 1); drawDie(die, -1, 1); }
for (var d = 0; d < dust.length; d += 1) {
var q = dust[d];
var t = (elapsed - q.born) / q.life;
var sp = scr([q.x, q.y, q.z]);
ctx.beginPath(); ctx.arc(sp[0], sp[1], sp[2] * q.size * (1 + t * 1.6), 0, 6.2832);
ctx.fillStyle = 'rgba(255,248,230,' + (0.85 * (1 - t)) + ')'; ctx.fill();
}
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs = -1;
var acc = 0;
function tick(ts) {
if (lastTs < 0) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
// 모션 줄이기: 애니메이션 없이 결과가 나올 때까지 같은 계산을 한 번에 돌려 최종 장면만 그린다
function runToDone() {
var i = 0;
do { elapsed += FIXED_DT; stepWorld(); i += 1; } while (i < 1500 && !sceneDone());
sceneFinish();
drawWorld();
}
function onRollClick() {
stopDemo();
sceneRoll(true);
if (reduce) runToDone();
}
sceneInit();
sceneRoll(false);
if (reduce) {
runToDone();
} else {
for (var w0 = 0; w0 < 8; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
}
btn.addEventListener('click', onRollClick);
cleaners.push(function () { btn.removeEventListener('click', onRollClick); });
fit();
window.addEventListener('resize', fit);
cleaners.push(function () { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
})();import { useEffect, useRef } from "react";
import styles from "./SpinDiceSceneDustTrailFloor.module.scss";
export default function SpinDiceSceneDustTrailFloor() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const numRef = useRef<HTMLElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current, canvasEl = canvasRef.current, l0 = labelRef.current, n0 = numRef.current, b0 = btnRef.current;
if (!rootEl || !canvasEl || !l0 || !n0 || !b0) return;
const root: HTMLDivElement = rootEl;
const canvas: HTMLCanvasElement = canvasEl;
const ctx0 = canvas.getContext("2d");
if (!ctx0) return;
const ctx: CanvasRenderingContext2D = ctx0;
const wrap: HTMLElement = canvas.parentElement ?? root;
const labelEl: HTMLElement = l0;
const numEl: HTMLElement = n0;
const btn: HTMLButtonElement = b0;
type Vec = number[];
type Mat = number[];
type RandFn = () => number;
type Fn = () => void;
type Paint = string | CanvasGradient;
type Pal = { face: number[]; edge: number[]; pip: string };
type Die = { p: Vec; v: Vec; R: Mat; w: Vec; pal: number; phase: number; t: number; hold: number; bounces: number; result: number; top: number; hit: number; hx: number; hz: number; xmin: number; xmax: number; zmin: number; zmax: number };
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var DICE_RESTITUTION = 0.45;
var SPIN_DAMPING = 0.985;
var DICE_SEED = 7;
var GRAVITY = 22;
var DIE_HALF = 1;
var INERTIA = 2 * DIE_HALF * DIE_HALF / 3;
var FLOOR_FRICTION = 0.35;
var SETTLE_AFTER_MS = 2000;
var SETTLE_RATE = 0.16;
var HOLD_MS = 700;
var CAM_Y = 5.6, CAM_BACK = 12, FOCAL = 275, VIEW_CX = 160, VIEW_CY = 82;
var TABLE_X = 4.4, TABLE_Z0 = -3, TABLE_Z1 = 5;
var PH_FLY = 0, PH_SETTLE = 1, PH_HOLD = 2, PH_IDLE = 3;
var STRIP_BASE = 128;
var BAR_MAX = 18;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed: number): RandFn {
var s = seed >>> 0;
return function (): number { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function dot(a: Vec, b: Vec): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function cross(a: Vec, b: Vec): Vec { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function unit(a: Vec): Vec { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function matMul(a: Mat, b: Mat): Mat {
var o: Mat = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (var i = 0; i < 3; i += 1) {
for (var j = 0; j < 3; j += 1) o[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
}
return o;
}
function matVec(m: Mat, v: Vec): Vec {
return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
}
// 로드리게스 공식: 단위 축 ax 둘레로 ang 만큼 도는 회전 행렬
function rotAxis(ax: Vec, ang: number): Mat {
var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c];
}
function orthonormalize(m: Mat): Mat {
var a = unit([m[0], m[1], m[2]]);
var k = a[0] * m[3] + a[1] * m[4] + a[2] * m[5];
var b = unit([m[3] - a[0] * k, m[4] - a[1] * k, m[5] - a[2] * k]);
var c = cross(a, b);
return [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]];
}
function randAxis(rnd: RandFn): Vec { return unit([rnd() * 2 - 1, rnd() * 2 - 1, rnd() * 2 - 1]); }
var PITCH = Math.atan2(CAM_Y, CAM_BACK);
var CAM_S = Math.sin(PITCH), CAM_C = Math.cos(PITCH);
function camPoint(p: Vec): Vec {
var qy = p[1] - CAM_Y, qz = p[2] + CAM_BACK;
return [p[0], qy * CAM_C + qz * CAM_S, -qy * CAM_S + qz * CAM_C];
}
function camDir(v: Vec): Vec { return [v[0], v[1] * CAM_C + v[2] * CAM_S, -v[1] * CAM_S + v[2] * CAM_C]; }
function scr(p: Vec): Vec {
var c = camPoint(p);
var k = FOCAL / c[2];
return [VIEW_CX + c[0] * k, VIEW_CY - c[1] * k, k];
}
var FACE_N: Vec[] = [[0, 1, 0], [0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];
var FACE_U: Vec[] = [[1, 0, 0], [1, 0, 0], [0, 1, 0], [0, 1, 0], [1, 0, 0], [1, 0, 0]];
var FACE_V: Vec[] = [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]];
var FACE_VAL: number[] = [1, 6, 2, 5, 3, 4];
var PIPS: number[][][] = [[], [[0, 0]], [[-1, -1], [1, 1]], [[-1, -1], [0, 0], [1, 1]],
[[-1, -1], [1, -1], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
[[-1, -1], [-1, 0], [-1, 1], [1, -1], [1, 0], [1, 1]]];
var DICE_PAL: Pal[] = [
{ face: [255, 214, 226], edge: [240, 160, 186], pip: '#4b3a63' },
{ face: [200, 232, 255], edge: [140, 190, 236], pip: '#3b4a6b' },
{ face: [224, 212, 255], edge: [170, 150, 232], pip: '#46386b' },
{ face: [255, 240, 196], edge: [236, 206, 120], pip: '#5a4636' }
];
var LIGHT: Vec = unit([-0.45, 0.85, -0.35]);
function newDie(pal: number, xmin: number, xmax: number, zmin: number, zmax: number): Die {
return { p: [0, DIE_HALF, 0], v: [0, 0, 0], R: [1, 0, 0, 0, 1, 0, 0, 0, 1], w: [0, 0, 0], pal: pal, phase: PH_IDLE, t: 0, hold: 0,
bounces: 0, result: 0, top: 1, hit: 0, hx: 0, hz: 0, xmin: xmin, xmax: xmax, zmin: zmin, zmax: zmax };
}
function topIndex(d: Die): number {
var best = 0, by = -9;
for (var f = 0; f < 6; f += 1) {
var ny = d.R[3] * FACE_N[f][0] + d.R[4] * FACE_N[f][1] + d.R[5] * FACE_N[f][2];
if (ny > by) { by = ny; best = f; }
}
return best;
}
// 던지기: 시작 위치·속도·회전을 시드 수열에서 순서대로 뽑는다 (뽑는 순서가 곧 재현 규칙)
function launch(d: Die, rnd: RandFn, x: number, z: number, vx: number, vz: number, vy0: number, spin: number): void {
d.p = [x, 2.4, z];
d.v = [vx + (rnd() - 0.5) * 1.6, vy0 + rnd() * 2.2, vz + (rnd() - 0.5) * 1.6];
var ax = randAxis(rnd);
var s = spin * (0.6 + rnd() * 0.8);
d.w = [ax[0] * s, ax[1] * s, ax[2] * s];
d.R = rotAxis(randAxis(rnd), rnd() * 6.283);
d.phase = PH_FLY; d.t = 0; d.hold = 0; d.bounces = 0; d.result = 0; d.hit = 0;
}
function spinStep(d: Die, dt: number): void {
var wl = Math.hypot(d.w[0], d.w[1], d.w[2]);
if (wl > 1e-5) d.R = matMul(rotAxis([d.w[0] / wl, d.w[1] / wl, d.w[2] / wl], wl * dt), d.R);
}
function floorStep(d: Die): void {
var mi = 0, my = 99;
var rx = 0, ry = 0, rz = 0;
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? DIE_HALF : -DIE_HALF, ly = (i & 2) ? DIE_HALF : -DIE_HALF, lz = (i & 4) ? DIE_HALF : -DIE_HALF;
var wy = d.R[3] * lx + d.R[4] * ly + d.R[5] * lz;
if (wy < my) { my = wy; mi = i; rx = d.R[0] * lx + d.R[1] * ly + d.R[2] * lz; ry = wy; rz = d.R[6] * lx + d.R[7] * ly + d.R[8] * lz; }
}
var low = d.p[1] + my;
if (low >= 0) return;
d.p[1] -= low;
var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
if (cvy >= 0) return;
var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
d.v[1] += j;
d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;
var tl = Math.hypot(cvx, cvz);
if (tl > 1e-4) {
var jt = Math.min(FLOOR_FRICTION * j, tl * 0.5);
var tx = -cvx / tl * jt, tz = -cvz / tl * jt;
d.v[0] += tx; d.v[2] += tz;
d.w[0] += ry * tz / INERTIA; d.w[1] += (rz * tx - rx * tz) / INERTIA; d.w[2] += -ry * tx / INERTIA;
}
if (cvy < -1.2) { d.bounces += 1; d.hit = -cvy; d.hx = d.p[0] + rx; d.hz = d.p[2] + rz; }
}
function wallStep(d: Die): void {
if (d.p[0] < d.xmin) { d.p[0] = d.xmin; d.v[0] = Math.abs(d.v[0]) * 0.5; }
if (d.p[0] > d.xmax) { d.p[0] = d.xmax; d.v[0] = -Math.abs(d.v[0]) * 0.5; }
if (d.p[2] < d.zmin) { d.p[2] = d.zmin; d.v[2] = Math.abs(d.v[2]) * 0.5; }
if (d.p[2] > d.zmax) { d.p[2] = d.zmax; d.v[2] = -Math.abs(d.v[2]) * 0.5; }
}
// 멈추기: 법선 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 정확히 위를 보도록 남은 각도의 SETTLE_RATE 만큼씩 돌린다
function settleStep(d: Die): void {
var k = topIndex(d);
d.top = FACE_VAL[k];
var nw = matVec(d.R, FACE_N[k]);
var ang = Math.acos(clamp(nw[1], -1, 1));
if (ang > 1e-4) {
var ax = unit(cross(nw, [0, 1, 0]));
d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
}
d.R = orthonormalize(matMul(rotAxis([0, 1, 0], d.w[1] * dtS), d.R));
d.w[1] *= 0.88;
d.v[0] *= 0.86; d.v[2] *= 0.86; d.v[1] = 0;
d.p[0] += d.v[0] * dtS; d.p[2] += d.v[2] * dtS;
d.p[1] += (DIE_HALF - d.p[1]) * 0.22;
wallStep(d);
if (ang < 0.004 && Math.abs(d.p[1] - DIE_HALF) < 0.01) {
d.p[1] = DIE_HALF;
var q = topIndex(d);
d.R = orthonormalize(matMul(rotAxis(unit(cross(matVec(d.R, FACE_N[q]), [0, 1, 0])), Math.acos(clamp(matVec(d.R, FACE_N[q])[1], -1, 1))), d.R));
d.phase = PH_HOLD; d.hold = 0; d.result = FACE_VAL[q]; d.top = d.result;
}
}
function stepDie(d: Die): void {
if (d.phase === PH_FLY) {
d.t += FIXED_DT;
d.v[1] -= GRAVITY * dtS;
d.p[0] += d.v[0] * dtS; d.p[1] += d.v[1] * dtS; d.p[2] += d.v[2] * dtS;
spinStep(d, dtS);
d.w[0] *= SPIN_DAMPING; d.w[1] *= SPIN_DAMPING; d.w[2] *= SPIN_DAMPING;
wallStep(d);
floorStep(d);
var sp = Math.hypot(d.v[0], d.v[1], d.v[2]);
if (d.t > SETTLE_AFTER_MS || (d.bounces >= 3 && sp < 1.6)) d.phase = PH_SETTLE;
} else if (d.phase === PH_SETTLE) {
d.t += FIXED_DT;
settleStep(d);
} else if (d.phase === PH_HOLD) {
d.hold += FIXED_DT;
}
}
function runUntilHold(d: Die): number {
for (var i = 0; i < 1200 && d.phase !== PH_HOLD; i += 1) stepDie(d);
return d.result;
}
// ── 그리기 ──
function rgb(c: number[], k: number): string {
return 'rgb(' + Math.min(255, Math.round(c[0] * k)) + ',' + Math.min(255, Math.round(c[1] * k)) + ',' + Math.min(255, Math.round(c[2] * k)) + ')';
}
function quad(c: Vec, u: Vec, v: Vec, s: number): number[][] {
var o: number[][] = [];
var sg = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
for (var i = 0; i < 4; i += 1) {
o.push(scr([c[0] + (u[0] * sg[i][0] + v[0] * sg[i][1]) * s * DIE_HALF, c[1] + (u[1] * sg[i][0] + v[1] * sg[i][1]) * s * DIE_HALF, c[2] + (u[2] * sg[i][0] + v[2] * sg[i][1]) * s * DIE_HALF]));
}
return o;
}
function fillPoly(pts: number[][], color: Paint, edge: number): void {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
if (edge > 0) { ctx.strokeStyle = color; ctx.lineWidth = edge; ctx.stroke(); }
}
function drawDie(d: Die, hi: number, alpha: number): void {
var pal = DICE_PAL[d.pal % DICE_PAL.length];
ctx.globalAlpha = alpha;
ctx.lineJoin = 'round';
for (var f = 0; f < 6; f += 1) {
var nw = matVec(d.R, FACE_N[f]);
var cw: Vec = [d.p[0] + nw[0] * DIE_HALF, d.p[1] + nw[1] * DIE_HALF, d.p[2] + nw[2] * DIE_HALF];
var cc = camPoint(cw), nc = camDir(nw);
if (nc[0] * cc[0] + nc[1] * cc[1] + nc[2] * cc[2] >= 0) continue;
var uw = matVec(d.R, FACE_U[f]), vw = matVec(d.R, FACE_V[f]);
var lit = 0.66 + 0.34 * Math.max(0, dot(nw, LIGHT));
var k0 = scr(cw)[2];
fillPoly(quad(cw, uw, vw, 1), rgb(pal.edge, lit), k0 * 0.1);
fillPoly(quad(cw, uw, vw, 0.84), rgb(pal.face, lit), k0 * 0.16);
if (f === hi) fillPoly(quad(cw, uw, vw, 0.84), 'rgba(255,255,255,0.4)', 0);
var val = FACE_VAL[f];
for (var pi = 0; pi < PIPS[val].length; pi += 1) {
var a = PIPS[val][pi][0] * 0.5 * DIE_HALF, b = PIPS[val][pi][1] * 0.5 * DIE_HALF;
var ring: number[][] = [];
for (var j = 0; j < 10; j += 1) {
var an = j * 0.62832, rr = 0.17 * DIE_HALF;
var ca = Math.cos(an) * rr, sa = Math.sin(an) * rr;
ring.push(scr([cw[0] + uw[0] * (a + ca) + vw[0] * (b + sa) + nw[0] * 0.004, cw[1] + uw[1] * (a + ca) + vw[1] * (b + sa) + nw[1] * 0.004, cw[2] + uw[2] * (a + ca) + vw[2] * (b + sa) + nw[2] * 0.004]));
}
fillPoly(ring, pal.pip, 0);
}
}
ctx.globalAlpha = 1;
}
function drawShadow(d: Die, alpha: number): void {
var s = scr([d.p[0], 0, d.p[2]]);
var h = Math.max(0, d.p[1] - DIE_HALF);
var r = DIE_HALF * s[2] * (1.2 + h * 0.18);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.42);
ctx.beginPath();
ctx.arc(0, 0, r, 0, 6.2832);
ctx.fillStyle = 'rgba(30,60,60,' + (0.34 * alpha / (1 + h * 0.7)) + ')';
ctx.fill();
ctx.restore();
}
var FELT_DOTS: number[][] = [];
(function (): void {
var fr = seedRand(99);
for (var i = 0; i < 70; i += 1) FELT_DOTS.push([(fr() * 2 - 1) * TABLE_X, TABLE_Z0 + fr() * (TABLE_Z1 - TABLE_Z0), 0.04 + fr() * 0.08]);
})();
function tablePts(m: number): number[][] {
return [scr([-TABLE_X - m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z1 + m]), scr([-TABLE_X - m, 0, TABLE_Z1 + m])];
}
function drawStage(lightX: number, lightK: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var bg = ctx.createRadialGradient(160, 80, 10, 160, 80, 210);
bg.addColorStop(0, '#3f3764');
bg.addColorStop(1, '#221d36');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
fillPoly(tablePts(0.5), '#8e80c8', 0);
var tp = tablePts(0);
var g = ctx.createLinearGradient(0, tp[0][1], 0, tp[2][1]);
g.addColorStop(0, '#9fdcc3');
g.addColorStop(1, '#78c8aa');
fillPoly(tp, g, 0);
for (var i = 0; i < FELT_DOTS.length; i += 1) {
var s = scr([FELT_DOTS[i][0], 0, FELT_DOTS[i][1]]);
ctx.beginPath();
ctx.arc(s[0], s[1], FELT_DOTS[i][2] * s[2], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,0.12)';
ctx.fill();
}
var lp = scr([lightX, 0, 1]);
var lg = ctx.createRadialGradient(lp[0], lp[1], 4, lp[0], lp[1], 120);
lg.addColorStop(0, 'rgba(255,255,255,' + (0.3 * lightK) + ')');
lg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.save();
ctx.beginPath();
ctx.moveTo(tp[0][0], tp[0][1]); ctx.lineTo(tp[1][0], tp[1][1]); ctx.lineTo(tp[2][0], tp[2][1]); ctx.lineTo(tp[3][0], tp[3][1]);
ctx.closePath();
ctx.clip();
ctx.fillStyle = lg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.restore();
}
function drawChip(x: number, y: number, s: number, value: number, pal: number, mark: boolean): void {
var p = DICE_PAL[pal % DICE_PAL.length];
ctx.fillStyle = value > 0 ? rgb(p.face, 1) : 'rgba(255,255,255,0.18)';
ctx.strokeStyle = mark ? '#7fe3bd' : rgb(p.edge, 1);
ctx.lineWidth = mark ? 1.8 : 1;
ctx.fillRect(x, y, s, s);
ctx.strokeRect(x, y, s, s);
for (var i = 0; i < PIPS[value].length; i += 1) {
ctx.beginPath();
ctx.arc(x + s / 2 + PIPS[value][i][0] * s * 0.26, y + s / 2 + PIPS[value][i][1] * s * 0.26, s * 0.1, 0, 6.2832);
ctx.fillStyle = p.pip;
ctx.fill();
}
}
// ── 상태 줄 (aria-live) ──
var stLabel = '', stNum = '', stAt = -9999;
function setStatus(label: string, num: string, force: boolean): void {
if (label === stLabel && num === stNum) return;
if (!force && label === stLabel && elapsed - stAt < 450) return;
stLabel = label; stNum = num; stAt = elapsed;
labelEl.textContent = label;
numEl.textContent = num;
}
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Fn[] = [];
// 기법: 바닥에 닿는 걸음마다 충격 속도에 비례한 개수의 먼지 입자를 시드 난수로 흩뿌리고(수평 퍼짐·위로 솟음·중력·마찰),
// 그림자 점의 경로를 DUST_FADE_MS 동안 옅어지는 선으로 바닥에 남긴다. 입자·자국 모두 수명(age)으로만 투명도를 정한다.
var T_RESULT = '결과';
var T_ROLLING = '굴리는 중';
var DUST_FADE_MS = 3600;
type Dust = { x: number; y: number; z: number; vx: number; vy: number; vz: number; born: number; life: number; size: number };
var die: Die = newDie(1, -3.4, 3.4, -1.8, 2.6);
var rng: RandFn = seedRand(DICE_SEED + 6);
var dustRng: RandFn = seedRand(DICE_SEED + 60);
var dust: Dust[] = [];
var path: number[][] = [];
var marks: number[][] = [];
function sceneInit(): void { die.phase = PH_IDLE; }
function sceneRoll(manual: boolean): void {
launch(die, rng, -3.2, rng() * 2.4 - 0.8, 3.8, 0.3, 3.2, 15);
setStatus(T_ROLLING, '', true);
}
function sceneDone(): boolean { return die.phase === PH_HOLD; }
function sceneFinish(): void { setStatus(T_RESULT, String(die.result), true); }
function spawnDust(x: number, z: number, speed: number): void {
var n = 7 + Math.min(10, Math.round(speed * 1.1));
for (var i = 0; i < n; i += 1) {
var an = dustRng() * 6.2832, sp = 0.6 + dustRng() * 1.8;
dust.push({ x: x, y: 0.05, z: z, vx: Math.cos(an) * sp, vy: 0.8 + dustRng() * 1.8, vz: Math.sin(an) * sp, born: elapsed, life: 900 + dustRng() * 700, size: 0.07 + dustRng() * 0.11 });
}
marks.push([x, z, elapsed, 0.3 + Math.min(0.5, speed * 0.04)]);
if (marks.length > 24) marks.shift();
}
function stepWorld(): void {
if (die.phase === PH_IDLE) return;
stepDie(die);
if (die.phase !== PH_HOLD && Math.round(die.t / FIXED_DT) % 3 === 0) { path.push([die.p[0], die.p[2], elapsed]); if (path.length > 90) path.shift(); }
if (die.hit > 0) { spawnDust(die.hx, die.hz, die.hit); die.hit = 0; }
var live: Dust[] = [];
for (var i = 0; i < dust.length; i += 1) {
var p = dust[i];
if (elapsed - p.born > p.life) continue;
p.vy -= 3.2 * dtS; p.vx *= 0.97; p.vz *= 0.97;
p.x += p.vx * dtS; p.y = Math.max(0, p.y + p.vy * dtS); p.z += p.vz * dtS;
live.push(p);
}
dust = live;
if (die.phase === PH_HOLD) setStatus(T_RESULT, String(die.result), true);
if (die.phase === PH_HOLD && demo && die.hold >= HOLD_MS) sceneRoll(false);
}
function drawWorld(): void {
drawStage(die.p[0], 0.8);
ctx.lineCap = 'round';
for (var i = 1; i < path.length; i += 1) {
var age = (elapsed - path[i][2]) / DUST_FADE_MS;
if (age >= 1) continue;
var a = scr([path[i - 1][0], 0, path[i - 1][1]]), b = scr([path[i][0], 0, path[i][1]]);
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]);
ctx.strokeStyle = 'rgba(95,70,150,' + (0.5 * (1 - age)) + ')'; ctx.lineWidth = b[2] * 0.3 * (1 - age * 0.6); ctx.stroke();
}
for (var m = 0; m < marks.length; m += 1) {
var f = (elapsed - marks[m][2]) / (DUST_FADE_MS + 800);
if (f >= 1) continue;
var s = scr([marks[m][0], 0, marks[m][1]]);
ctx.save(); ctx.translate(s[0], s[1]); ctx.scale(1, 0.4);
ctx.beginPath(); ctx.arc(0, 0, s[2] * marks[m][3], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,' + (0.34 * (1 - f)) + ')'; ctx.fill();
ctx.restore();
}
if (die.phase !== PH_IDLE) { drawShadow(die, 1); drawDie(die, -1, 1); }
for (var d = 0; d < dust.length; d += 1) {
var q = dust[d];
var t = (elapsed - q.born) / q.life;
var sp = scr([q.x, q.y, q.z]);
ctx.beginPath(); ctx.arc(sp[0], sp[1], sp[2] * q.size * (1 + t * 1.6), 0, 6.2832);
ctx.fillStyle = 'rgba(255,248,230,' + (0.85 * (1 - t)) + ')'; ctx.fill();
}
}
function fit(): void {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs = -1;
var acc = 0;
function tick(ts: number): void {
if (lastTs < 0) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
// 모션 줄이기: 애니메이션 없이 결과가 나올 때까지 같은 계산을 한 번에 돌려 최종 장면만 그린다
function runToDone(): void {
var i = 0;
do { elapsed += FIXED_DT; stepWorld(); i += 1; } while (i < 1500 && !sceneDone());
sceneFinish();
drawWorld();
}
function onRollClick(): void {
stopDemo();
sceneRoll(true);
if (reduce) runToDone();
}
sceneInit();
sceneRoll(false);
if (reduce) {
runToDone();
} else {
for (var w0 = 0; w0 < 8; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
}
btn.addEventListener('click', onRollClick);
cleaners.push(function (): void { btn.removeEventListener('click', onRollClick); });
fit();
window.addEventListener('resize', fit);
cleaners.push(function (): void { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
return destroy;
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} role="img" aria-label="바닥에 닿는 지점마다 작은 먼지 입자를 흩뿌리고 구른 경로를 옅은 선으로 남겨, 던진 뒤의 흔적이 천천히 사라지는 장면을 그린다." />
<output className={styles.bp__status} aria-live="polite"><span ref={labelRef}>굴리는 중</span> <b ref={numRef} /></output>
<button type="button" className={styles.bp__btn} ref={btnRef}>굴리기</button>
<p className={styles.bp__caption}>바닥에 닿는 지점마다 작은 먼지 입자를 흩뿌리고 구른 경로를 옅은 선으로 남겨, 던진 뒤의 흔적이 천천히 사라지는 장면을 그린다.</p>
</div>
</main>
);
}06 · 転がった跡とほこりが床に残るサイコロ — 計測 累積面積 18.76% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function spawnDust(x, z, speed) {
var n = 7 + Math.min(10, Math.round(speed * 1.1));
for (var i = 0; i < n; i += 1) {
var an = dustRng() * 6.2832, sp = 0.6 + dustRng() * 1.8;
dust.push({ x: x, y: 0.05, z: z, vx: Math.cos(an) * sp, vy: 0.8 + dustRng() * 1.8, vz: Math.sin(an) * sp, born: elapsed, life: 900 + dustRng() * 700, size: 0.07 + dustRng() * 0.11 });
}
marks.push([x, z, elapsed, 0.3 + Math.min(0.5, speed * 0.04)]);
if (marks.length > 24) marks.shift();
}
07カップの中で振られるサイコロ
noise1() は種つき乱数256個の表をなめらかにつないで読む値ノイズで、shakePose() がカップとサイコロの位置・高さ・傾きにそれぞれ別のずれを足します。振幅は SHAKE_MS の1300msのあいだ、振った時間の割合の二乗で大きくなり、サイコロをカップの外へ打ち上げたあとは02番と同じ物理が引き継ぎます。累積面積24.3%・平均強度68.4で、23/23フレームが動きました。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" role="img" aria-label="カップの中でサイコロの位置と角度にノイズのずれを足してがたがたさせ、揺れの強さを少しずつ上げて、カップの外へ飛び出す場面を描く。"></canvas>
<output class="bp__status" aria-live="polite"><span id="bpLabel">振っている</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">振る</button>
<p class="bp__caption">カップの中でサイコロの位置と角度にノイズのずれを足してがたがたさせ、揺れの強さを少しずつ上げて、カップの外へ飛び出す場面を描く。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
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: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
display: flex;
align-items: baseline;
gap: $sp-2;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: $sp-3;
top: $sp-3;
min-height: $sp-8;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-instant $ease-out;
}
.bp__btn:hover { transform: translateY(-1px); }
.bp__btn:active { transform: translateY(1px); }
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .9);
outline-offset: 2px;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__btn { font-size: 11px; padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
.bp__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: 12px;
top: 12px;
min-height: 32px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 140ms ease-out;
}
.bp__btn:hover {
transform: translateY(-1px);
}
.bp__btn:active {
transform: translateY(1px);
}
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.9);
outline-offset: 2px;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
padding: 4px 8px;
}
.bp__btn {
font-size: 11px;
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
.bp__btn {
transition: none;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var labelEl = document.getElementById('bpLabel');
var numEl = document.getElementById('bpNum');
var btn = document.getElementById('bpBtn');
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var DICE_RESTITUTION = 0.45;
var SPIN_DAMPING = 0.985;
var DICE_SEED = 7;
var GRAVITY = 22;
var DIE_HALF = 1;
var INERTIA = 2 * DIE_HALF * DIE_HALF / 3;
var FLOOR_FRICTION = 0.35;
var SETTLE_AFTER_MS = 2000;
var SETTLE_RATE = 0.16;
var HOLD_MS = 700;
var CAM_Y = 5.6, CAM_BACK = 12, FOCAL = 275, VIEW_CX = 160, VIEW_CY = 82;
var TABLE_X = 4.4, TABLE_Z0 = -3, TABLE_Z1 = 5;
var PH_FLY = 0, PH_SETTLE = 1, PH_HOLD = 2, PH_IDLE = 3;
var STRIP_BASE = 128;
var BAR_MAX = 18;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function cross(a, b) { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function unit(a) { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function matMul(a, b) {
var o = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (var i = 0; i < 3; i += 1) {
for (var j = 0; j < 3; j += 1) o[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
}
return o;
}
function matVec(m, v) {
return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
}
// 로드리게스 공식: 단위 축 ax 둘레로 ang 만큼 도는 회전 행렬
function rotAxis(ax, ang) {
var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c];
}
function orthonormalize(m) {
var a = unit([m[0], m[1], m[2]]);
var k = a[0] * m[3] + a[1] * m[4] + a[2] * m[5];
var b = unit([m[3] - a[0] * k, m[4] - a[1] * k, m[5] - a[2] * k]);
var c = cross(a, b);
return [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]];
}
function randAxis(rnd) { return unit([rnd() * 2 - 1, rnd() * 2 - 1, rnd() * 2 - 1]); }
var PITCH = Math.atan2(CAM_Y, CAM_BACK);
var CAM_S = Math.sin(PITCH), CAM_C = Math.cos(PITCH);
function camPoint(p) {
var qy = p[1] - CAM_Y, qz = p[2] + CAM_BACK;
return [p[0], qy * CAM_C + qz * CAM_S, -qy * CAM_S + qz * CAM_C];
}
function camDir(v) { return [v[0], v[1] * CAM_C + v[2] * CAM_S, -v[1] * CAM_S + v[2] * CAM_C]; }
function scr(p) {
var c = camPoint(p);
var k = FOCAL / c[2];
return [VIEW_CX + c[0] * k, VIEW_CY - c[1] * k, k];
}
var FACE_N = [[0, 1, 0], [0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];
var FACE_U = [[1, 0, 0], [1, 0, 0], [0, 1, 0], [0, 1, 0], [1, 0, 0], [1, 0, 0]];
var FACE_V = [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]];
var FACE_VAL = [1, 6, 2, 5, 3, 4];
var PIPS = [[], [[0, 0]], [[-1, -1], [1, 1]], [[-1, -1], [0, 0], [1, 1]],
[[-1, -1], [1, -1], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
[[-1, -1], [-1, 0], [-1, 1], [1, -1], [1, 0], [1, 1]]];
var DICE_PAL = [
{ face: [255, 214, 226], edge: [240, 160, 186], pip: '#4b3a63' },
{ face: [200, 232, 255], edge: [140, 190, 236], pip: '#3b4a6b' },
{ face: [224, 212, 255], edge: [170, 150, 232], pip: '#46386b' },
{ face: [255, 240, 196], edge: [236, 206, 120], pip: '#5a4636' }
];
var LIGHT = unit([-0.45, 0.85, -0.35]);
function newDie(pal, xmin, xmax, zmin, zmax) {
return { p: [0, DIE_HALF, 0], v: [0, 0, 0], R: [1, 0, 0, 0, 1, 0, 0, 0, 1], w: [0, 0, 0], pal: pal, phase: PH_IDLE, t: 0, hold: 0,
bounces: 0, result: 0, top: 1, hit: 0, hx: 0, hz: 0, xmin: xmin, xmax: xmax, zmin: zmin, zmax: zmax };
}
function topIndex(d) {
var best = 0, by = -9;
for (var f = 0; f < 6; f += 1) {
var ny = d.R[3] * FACE_N[f][0] + d.R[4] * FACE_N[f][1] + d.R[5] * FACE_N[f][2];
if (ny > by) { by = ny; best = f; }
}
return best;
}
// 던지기: 시작 위치·속도·회전을 시드 수열에서 순서대로 뽑는다 (뽑는 순서가 곧 재현 규칙)
function launch(d, rnd, x, z, vx, vz, vy0, spin) {
d.p = [x, 2.4, z];
d.v = [vx + (rnd() - 0.5) * 1.6, vy0 + rnd() * 2.2, vz + (rnd() - 0.5) * 1.6];
var ax = randAxis(rnd);
var s = spin * (0.6 + rnd() * 0.8);
d.w = [ax[0] * s, ax[1] * s, ax[2] * s];
d.R = rotAxis(randAxis(rnd), rnd() * 6.283);
d.phase = PH_FLY; d.t = 0; d.hold = 0; d.bounces = 0; d.result = 0; d.hit = 0;
}
function spinStep(d, dt) {
var wl = Math.hypot(d.w[0], d.w[1], d.w[2]);
if (wl > 1e-5) d.R = matMul(rotAxis([d.w[0] / wl, d.w[1] / wl, d.w[2] / wl], wl * dt), d.R);
}
function floorStep(d) {
var mi = 0, my = 99;
var rx = 0, ry = 0, rz = 0;
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? DIE_HALF : -DIE_HALF, ly = (i & 2) ? DIE_HALF : -DIE_HALF, lz = (i & 4) ? DIE_HALF : -DIE_HALF;
var wy = d.R[3] * lx + d.R[4] * ly + d.R[5] * lz;
if (wy < my) { my = wy; mi = i; rx = d.R[0] * lx + d.R[1] * ly + d.R[2] * lz; ry = wy; rz = d.R[6] * lx + d.R[7] * ly + d.R[8] * lz; }
}
var low = d.p[1] + my;
if (low >= 0) return;
d.p[1] -= low;
var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
if (cvy >= 0) return;
var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
d.v[1] += j;
d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;
var tl = Math.hypot(cvx, cvz);
if (tl > 1e-4) {
var jt = Math.min(FLOOR_FRICTION * j, tl * 0.5);
var tx = -cvx / tl * jt, tz = -cvz / tl * jt;
d.v[0] += tx; d.v[2] += tz;
d.w[0] += ry * tz / INERTIA; d.w[1] += (rz * tx - rx * tz) / INERTIA; d.w[2] += -ry * tx / INERTIA;
}
if (cvy < -1.2) { d.bounces += 1; d.hit = -cvy; d.hx = d.p[0] + rx; d.hz = d.p[2] + rz; }
}
function wallStep(d) {
if (d.p[0] < d.xmin) { d.p[0] = d.xmin; d.v[0] = Math.abs(d.v[0]) * 0.5; }
if (d.p[0] > d.xmax) { d.p[0] = d.xmax; d.v[0] = -Math.abs(d.v[0]) * 0.5; }
if (d.p[2] < d.zmin) { d.p[2] = d.zmin; d.v[2] = Math.abs(d.v[2]) * 0.5; }
if (d.p[2] > d.zmax) { d.p[2] = d.zmax; d.v[2] = -Math.abs(d.v[2]) * 0.5; }
}
// 멈추기: 법선 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 정확히 위를 보도록 남은 각도의 SETTLE_RATE 만큼씩 돌린다
function settleStep(d) {
var k = topIndex(d);
d.top = FACE_VAL[k];
var nw = matVec(d.R, FACE_N[k]);
var ang = Math.acos(clamp(nw[1], -1, 1));
if (ang > 1e-4) {
var ax = unit(cross(nw, [0, 1, 0]));
d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
}
d.R = orthonormalize(matMul(rotAxis([0, 1, 0], d.w[1] * dtS), d.R));
d.w[1] *= 0.88;
d.v[0] *= 0.86; d.v[2] *= 0.86; d.v[1] = 0;
d.p[0] += d.v[0] * dtS; d.p[2] += d.v[2] * dtS;
d.p[1] += (DIE_HALF - d.p[1]) * 0.22;
wallStep(d);
if (ang < 0.004 && Math.abs(d.p[1] - DIE_HALF) < 0.01) {
d.p[1] = DIE_HALF;
var q = topIndex(d);
d.R = orthonormalize(matMul(rotAxis(unit(cross(matVec(d.R, FACE_N[q]), [0, 1, 0])), Math.acos(clamp(matVec(d.R, FACE_N[q])[1], -1, 1))), d.R));
d.phase = PH_HOLD; d.hold = 0; d.result = FACE_VAL[q]; d.top = d.result;
}
}
function stepDie(d) {
if (d.phase === PH_FLY) {
d.t += FIXED_DT;
d.v[1] -= GRAVITY * dtS;
d.p[0] += d.v[0] * dtS; d.p[1] += d.v[1] * dtS; d.p[2] += d.v[2] * dtS;
spinStep(d, dtS);
d.w[0] *= SPIN_DAMPING; d.w[1] *= SPIN_DAMPING; d.w[2] *= SPIN_DAMPING;
wallStep(d);
floorStep(d);
var sp = Math.hypot(d.v[0], d.v[1], d.v[2]);
if (d.t > SETTLE_AFTER_MS || (d.bounces >= 3 && sp < 1.6)) d.phase = PH_SETTLE;
} else if (d.phase === PH_SETTLE) {
d.t += FIXED_DT;
settleStep(d);
} else if (d.phase === PH_HOLD) {
d.hold += FIXED_DT;
}
}
function runUntilHold(d) {
for (var i = 0; i < 1200 && d.phase !== PH_HOLD; i += 1) stepDie(d);
return d.result;
}
// ── 그리기 ──
function rgb(c, k) {
return 'rgb(' + Math.min(255, Math.round(c[0] * k)) + ',' + Math.min(255, Math.round(c[1] * k)) + ',' + Math.min(255, Math.round(c[2] * k)) + ')';
}
function quad(c, u, v, s) {
var o = [];
var sg = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
for (var i = 0; i < 4; i += 1) {
o.push(scr([c[0] + (u[0] * sg[i][0] + v[0] * sg[i][1]) * s * DIE_HALF, c[1] + (u[1] * sg[i][0] + v[1] * sg[i][1]) * s * DIE_HALF, c[2] + (u[2] * sg[i][0] + v[2] * sg[i][1]) * s * DIE_HALF]));
}
return o;
}
function fillPoly(pts, color, edge) {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
if (edge > 0) { ctx.strokeStyle = color; ctx.lineWidth = edge; ctx.stroke(); }
}
function drawDie(d, hi, alpha) {
var pal = DICE_PAL[d.pal % DICE_PAL.length];
ctx.globalAlpha = alpha;
ctx.lineJoin = 'round';
for (var f = 0; f < 6; f += 1) {
var nw = matVec(d.R, FACE_N[f]);
var cw = [d.p[0] + nw[0] * DIE_HALF, d.p[1] + nw[1] * DIE_HALF, d.p[2] + nw[2] * DIE_HALF];
var cc = camPoint(cw), nc = camDir(nw);
if (nc[0] * cc[0] + nc[1] * cc[1] + nc[2] * cc[2] >= 0) continue;
var uw = matVec(d.R, FACE_U[f]), vw = matVec(d.R, FACE_V[f]);
var lit = 0.66 + 0.34 * Math.max(0, dot(nw, LIGHT));
var k0 = scr(cw)[2];
fillPoly(quad(cw, uw, vw, 1), rgb(pal.edge, lit), k0 * 0.1);
fillPoly(quad(cw, uw, vw, 0.84), rgb(pal.face, lit), k0 * 0.16);
if (f === hi) fillPoly(quad(cw, uw, vw, 0.84), 'rgba(255,255,255,0.4)', 0);
var val = FACE_VAL[f];
for (var pi = 0; pi < PIPS[val].length; pi += 1) {
var a = PIPS[val][pi][0] * 0.5 * DIE_HALF, b = PIPS[val][pi][1] * 0.5 * DIE_HALF;
var ring = [];
for (var j = 0; j < 10; j += 1) {
var an = j * 0.62832, rr = 0.17 * DIE_HALF;
var ca = Math.cos(an) * rr, sa = Math.sin(an) * rr;
ring.push(scr([cw[0] + uw[0] * (a + ca) + vw[0] * (b + sa) + nw[0] * 0.004, cw[1] + uw[1] * (a + ca) + vw[1] * (b + sa) + nw[1] * 0.004, cw[2] + uw[2] * (a + ca) + vw[2] * (b + sa) + nw[2] * 0.004]));
}
fillPoly(ring, pal.pip, 0);
}
}
ctx.globalAlpha = 1;
}
function drawShadow(d, alpha) {
var s = scr([d.p[0], 0, d.p[2]]);
var h = Math.max(0, d.p[1] - DIE_HALF);
var r = DIE_HALF * s[2] * (1.2 + h * 0.18);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.42);
ctx.beginPath();
ctx.arc(0, 0, r, 0, 6.2832);
ctx.fillStyle = 'rgba(30,60,60,' + (0.34 * alpha / (1 + h * 0.7)) + ')';
ctx.fill();
ctx.restore();
}
var FELT_DOTS = [];
(function () {
var fr = seedRand(99);
for (var i = 0; i < 70; i += 1) FELT_DOTS.push([(fr() * 2 - 1) * TABLE_X, TABLE_Z0 + fr() * (TABLE_Z1 - TABLE_Z0), 0.04 + fr() * 0.08]);
})();
function tablePts(m) {
return [scr([-TABLE_X - m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z1 + m]), scr([-TABLE_X - m, 0, TABLE_Z1 + m])];
}
function drawStage(lightX, lightK) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var bg = ctx.createRadialGradient(160, 80, 10, 160, 80, 210);
bg.addColorStop(0, '#3f3764');
bg.addColorStop(1, '#221d36');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
fillPoly(tablePts(0.5), '#8e80c8', 0);
var tp = tablePts(0);
var g = ctx.createLinearGradient(0, tp[0][1], 0, tp[2][1]);
g.addColorStop(0, '#9fdcc3');
g.addColorStop(1, '#78c8aa');
fillPoly(tp, g, 0);
for (var i = 0; i < FELT_DOTS.length; i += 1) {
var s = scr([FELT_DOTS[i][0], 0, FELT_DOTS[i][1]]);
ctx.beginPath();
ctx.arc(s[0], s[1], FELT_DOTS[i][2] * s[2], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,0.12)';
ctx.fill();
}
var lp = scr([lightX, 0, 1]);
var lg = ctx.createRadialGradient(lp[0], lp[1], 4, lp[0], lp[1], 120);
lg.addColorStop(0, 'rgba(255,255,255,' + (0.3 * lightK) + ')');
lg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.save();
ctx.beginPath();
ctx.moveTo(tp[0][0], tp[0][1]); ctx.lineTo(tp[1][0], tp[1][1]); ctx.lineTo(tp[2][0], tp[2][1]); ctx.lineTo(tp[3][0], tp[3][1]);
ctx.closePath();
ctx.clip();
ctx.fillStyle = lg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.restore();
}
function drawChip(x, y, s, value, pal, mark) {
var p = DICE_PAL[pal % DICE_PAL.length];
ctx.fillStyle = value > 0 ? rgb(p.face, 1) : 'rgba(255,255,255,0.18)';
ctx.strokeStyle = mark ? '#7fe3bd' : rgb(p.edge, 1);
ctx.lineWidth = mark ? 1.8 : 1;
ctx.fillRect(x, y, s, s);
ctx.strokeRect(x, y, s, s);
for (var i = 0; i < PIPS[value].length; i += 1) {
ctx.beginPath();
ctx.arc(x + s / 2 + PIPS[value][i][0] * s * 0.26, y + s / 2 + PIPS[value][i][1] * s * 0.26, s * 0.1, 0, 6.2832);
ctx.fillStyle = p.pip;
ctx.fill();
}
}
// ── 상태 줄 (aria-live) ──
var stLabel = '', stNum = '', stAt = -9999;
function setStatus(label, num, force) {
if (label === stLabel && num === stNum) return;
if (!force && label === stLabel && elapsed - stAt < 450) return;
stLabel = label; stNum = num; stAt = elapsed;
labelEl.textContent = label;
numEl.textContent = num;
}
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
// 기법: 컵 안 주사위의 위치·높이·기울기에 1차원 값 노이즈 오프셋(noise1)을 더한다. 진폭 amp 가 SHAKE_MS 동안 제곱 곡선으로 커지고,
// 끝나면 같은 주사위가 위로 솟구쳐 일반 물리(포물선·튕김·上の面 읽기)로 넘어간다. 컵은 앞벽이 주사위 아래쪽을 가리도록 뒤→주사위→앞 순서로 그린다.
var T_SHAKE = '振っている';
var T_ROLLING = '転がり中';
var T_RESULT = '結果';
var SHAKE_MS = 1300;
var NOISE_FREQ = 9;
var CUP_X = -1.8, CUP_Z = -0.6, CUP_RIM_Y = 2.0;
var die = newDie(3, -3.4, 3.4, -1.8, 2.6);
var rng = seedRand(DICE_SEED + 7);
var perm = [];
(function () {
var nr = seedRand(DICE_SEED + 70);
for (var i = 0; i < 256; i += 1) perm.push(nr() * 2 - 1);
})();
function noise1(x) {
var i = Math.floor(x);
var f = x - i;
var s = f * f * (3 - 2 * f);
return lerp(perm[i & 255], perm[(i + 1) & 255], s);
}
var mode = 0;
var shakeT = 0;
var baseR = [1, 0, 0, 0, 1, 0, 0, 0, 1];
var introAt = 0, ejectAt = 0;
var cupDX = 0, cupLift = 0, ampNow = 0.1;
function sceneInit() { die.phase = PH_IDLE; }
function sceneRoll(manual) {
mode = 0; shakeT = 0; introAt = elapsed; ejectAt = 0;
baseR = rotAxis(randAxis(rng), rng() * 6.283);
die.phase = PH_IDLE; die.result = 0;
setStatus(T_SHAKE, '', true);
}
function sceneDone() { return mode === 1 && die.phase === PH_HOLD; }
function sceneFinish() { setStatus(T_RESULT, String(die.result), true); }
function shakePose() {
var t = elapsed / 1000 * NOISE_FREQ;
var I = clamp(shakeT / SHAKE_MS, 0, 1);
ampNow = 0.1 + 0.9 * I * I;
cupDX = noise1(t + 31) * ampNow * 0.5;
cupLift = Math.abs(noise1(t * 1.2 + 53)) * ampNow * 0.35;
var cx = CUP_X + cupDX;
die.p = [cx + noise1(t * 1.3 + 70) * ampNow * 0.7, 1.3 + cupLift + Math.abs(noise1(t * 1.1 + 110)) * ampNow * 0.9, CUP_Z + noise1(t * 1.2 + 150) * ampNow * 0.4];
die.R = matMul(rotAxis([0, 1, 0], noise1(t * 1.4 + 190) * ampNow), matMul(rotAxis([1, 0, 0], noise1(t * 1.5 + 230) * ampNow * 0.6), baseR));
}
function stepWorld() {
if (mode === 0) {
shakeT += FIXED_DT;
shakePose();
if (shakeT >= SHAKE_MS) {
mode = 1; ejectAt = elapsed;
var r0 = die.R, x0 = die.p[0], z0 = die.p[2];
launch(die, rng, x0, z0, 3.2, 0.2, 3.6, 16);
die.R = r0;
setStatus(T_ROLLING, '', true);
}
return;
}
stepDie(die);
if (die.phase === PH_HOLD) setStatus(T_RESULT, String(die.result), true);
if (die.phase === PH_HOLD && demo && die.hold >= HOLD_MS) sceneRoll(false);
}
function drawCup(alpha, slide) {
var cx = CUP_X + cupDX - slide * 3.2, cz = CUP_Z;
var rc = scr([cx, CUP_RIM_Y + cupLift, cz]);
var rr = 1.6 * rc[2];
ctx.globalAlpha = alpha;
ctx.beginPath(); ctx.ellipse(rc[0], rc[1], rr, rr * 0.42, 0, 0, 6.2832);
ctx.fillStyle = '#6a5ba3'; ctx.fill();
ctx.globalAlpha = 1;
}
function drawCupFront(alpha, slide) {
var cx = CUP_X + cupDX - slide * 3.2, cz = CUP_Z;
var rc = scr([cx, CUP_RIM_Y + cupLift, cz]), bc = scr([cx, cupLift, cz]);
var rr = 1.6 * rc[2], rb = 1.15 * bc[2];
ctx.globalAlpha = alpha;
var g = ctx.createLinearGradient(rc[0] - rr, 0, rc[0] + rr, 0);
g.addColorStop(0, '#f29bb7');
g.addColorStop(0.45, '#ffc9d8');
g.addColorStop(1, '#e38aa9');
ctx.beginPath();
ctx.moveTo(rc[0] - rr, rc[1]);
ctx.lineTo(bc[0] - rb, bc[1]);
ctx.ellipse(bc[0], bc[1], rb, rb * 0.42, 0, Math.PI, 0, true);
ctx.lineTo(rc[0] + rr, rc[1]);
ctx.ellipse(rc[0], rc[1], rr, rr * 0.42, 0, 0, Math.PI, false);
ctx.closePath();
ctx.fillStyle = g;
ctx.fill();
ctx.beginPath(); ctx.ellipse(rc[0], rc[1], rr, rr * 0.42, 0, 0, Math.PI, false);
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 2; ctx.stroke();
ctx.globalAlpha = 1;
}
function drawWorld() {
drawStage(die.p[0], 0.8);
var slide = mode === 1 ? clamp((elapsed - ejectAt) / 500, 0, 1) : 0;
var intro = clamp((elapsed - introAt) / 220, 0, 1);
var cupA = (mode === 0 ? 1 : 1 - slide) * intro;
var dieA = intro;
if (mode === 1 && die.phase === PH_HOLD) dieA = clamp((HOLD_MS - die.hold) / 180, 0, 1);
if (cupA > 0.01) drawCup(cupA, slide);
if (mode === 0 || die.phase !== PH_IDLE) {
drawShadow(die, dieA);
drawDie(die, -1, dieA);
}
if (cupA > 0.01) drawCupFront(cupA, slide);
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs = -1;
var acc = 0;
function tick(ts) {
if (lastTs < 0) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
// 모션 줄이기: 애니메이션 없이 결과가 나올 때까지 같은 계산을 한 번에 돌려 최종 장면만 그린다
function runToDone() {
var i = 0;
do { elapsed += FIXED_DT; stepWorld(); i += 1; } while (i < 1500 && !sceneDone());
sceneFinish();
drawWorld();
}
function onRollClick() {
stopDemo();
sceneRoll(true);
if (reduce) runToDone();
}
sceneInit();
sceneRoll(false);
if (reduce) {
runToDone();
} else {
for (var w0 = 0; w0 < 8; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
}
btn.addEventListener('click', onRollClick);
cleaners.push(function () { btn.removeEventListener('click', onRollClick); });
fit();
window.addEventListener('resize', fit);
cleaners.push(function () { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
})();import { useEffect, useRef } from "react";
import styles from "./SpinDiceSceneCupShakeNoise.module.scss";
export default function SpinDiceSceneCupShakeNoise() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const numRef = useRef<HTMLElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current, canvasEl = canvasRef.current, l0 = labelRef.current, n0 = numRef.current, b0 = btnRef.current;
if (!rootEl || !canvasEl || !l0 || !n0 || !b0) return;
const root: HTMLDivElement = rootEl;
const canvas: HTMLCanvasElement = canvasEl;
const ctx0 = canvas.getContext("2d");
if (!ctx0) return;
const ctx: CanvasRenderingContext2D = ctx0;
const wrap: HTMLElement = canvas.parentElement ?? root;
const labelEl: HTMLElement = l0;
const numEl: HTMLElement = n0;
const btn: HTMLButtonElement = b0;
type Vec = number[];
type Mat = number[];
type RandFn = () => number;
type Fn = () => void;
type Paint = string | CanvasGradient;
type Pal = { face: number[]; edge: number[]; pip: string };
type Die = { p: Vec; v: Vec; R: Mat; w: Vec; pal: number; phase: number; t: number; hold: number; bounces: number; result: number; top: number; hit: number; hx: number; hz: number; xmin: number; xmax: number; zmin: number; zmax: number };
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var DICE_RESTITUTION = 0.45;
var SPIN_DAMPING = 0.985;
var DICE_SEED = 7;
var GRAVITY = 22;
var DIE_HALF = 1;
var INERTIA = 2 * DIE_HALF * DIE_HALF / 3;
var FLOOR_FRICTION = 0.35;
var SETTLE_AFTER_MS = 2000;
var SETTLE_RATE = 0.16;
var HOLD_MS = 700;
var CAM_Y = 5.6, CAM_BACK = 12, FOCAL = 275, VIEW_CX = 160, VIEW_CY = 82;
var TABLE_X = 4.4, TABLE_Z0 = -3, TABLE_Z1 = 5;
var PH_FLY = 0, PH_SETTLE = 1, PH_HOLD = 2, PH_IDLE = 3;
var STRIP_BASE = 128;
var BAR_MAX = 18;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed: number): RandFn {
var s = seed >>> 0;
return function (): number { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function dot(a: Vec, b: Vec): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function cross(a: Vec, b: Vec): Vec { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function unit(a: Vec): Vec { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function matMul(a: Mat, b: Mat): Mat {
var o: Mat = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (var i = 0; i < 3; i += 1) {
for (var j = 0; j < 3; j += 1) o[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
}
return o;
}
function matVec(m: Mat, v: Vec): Vec {
return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
}
// 로드리게스 공식: 단위 축 ax 둘레로 ang 만큼 도는 회전 행렬
function rotAxis(ax: Vec, ang: number): Mat {
var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c];
}
function orthonormalize(m: Mat): Mat {
var a = unit([m[0], m[1], m[2]]);
var k = a[0] * m[3] + a[1] * m[4] + a[2] * m[5];
var b = unit([m[3] - a[0] * k, m[4] - a[1] * k, m[5] - a[2] * k]);
var c = cross(a, b);
return [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]];
}
function randAxis(rnd: RandFn): Vec { return unit([rnd() * 2 - 1, rnd() * 2 - 1, rnd() * 2 - 1]); }
var PITCH = Math.atan2(CAM_Y, CAM_BACK);
var CAM_S = Math.sin(PITCH), CAM_C = Math.cos(PITCH);
function camPoint(p: Vec): Vec {
var qy = p[1] - CAM_Y, qz = p[2] + CAM_BACK;
return [p[0], qy * CAM_C + qz * CAM_S, -qy * CAM_S + qz * CAM_C];
}
function camDir(v: Vec): Vec { return [v[0], v[1] * CAM_C + v[2] * CAM_S, -v[1] * CAM_S + v[2] * CAM_C]; }
function scr(p: Vec): Vec {
var c = camPoint(p);
var k = FOCAL / c[2];
return [VIEW_CX + c[0] * k, VIEW_CY - c[1] * k, k];
}
var FACE_N: Vec[] = [[0, 1, 0], [0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];
var FACE_U: Vec[] = [[1, 0, 0], [1, 0, 0], [0, 1, 0], [0, 1, 0], [1, 0, 0], [1, 0, 0]];
var FACE_V: Vec[] = [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]];
var FACE_VAL: number[] = [1, 6, 2, 5, 3, 4];
var PIPS: number[][][] = [[], [[0, 0]], [[-1, -1], [1, 1]], [[-1, -1], [0, 0], [1, 1]],
[[-1, -1], [1, -1], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
[[-1, -1], [-1, 0], [-1, 1], [1, -1], [1, 0], [1, 1]]];
var DICE_PAL: Pal[] = [
{ face: [255, 214, 226], edge: [240, 160, 186], pip: '#4b3a63' },
{ face: [200, 232, 255], edge: [140, 190, 236], pip: '#3b4a6b' },
{ face: [224, 212, 255], edge: [170, 150, 232], pip: '#46386b' },
{ face: [255, 240, 196], edge: [236, 206, 120], pip: '#5a4636' }
];
var LIGHT: Vec = unit([-0.45, 0.85, -0.35]);
function newDie(pal: number, xmin: number, xmax: number, zmin: number, zmax: number): Die {
return { p: [0, DIE_HALF, 0], v: [0, 0, 0], R: [1, 0, 0, 0, 1, 0, 0, 0, 1], w: [0, 0, 0], pal: pal, phase: PH_IDLE, t: 0, hold: 0,
bounces: 0, result: 0, top: 1, hit: 0, hx: 0, hz: 0, xmin: xmin, xmax: xmax, zmin: zmin, zmax: zmax };
}
function topIndex(d: Die): number {
var best = 0, by = -9;
for (var f = 0; f < 6; f += 1) {
var ny = d.R[3] * FACE_N[f][0] + d.R[4] * FACE_N[f][1] + d.R[5] * FACE_N[f][2];
if (ny > by) { by = ny; best = f; }
}
return best;
}
// 던지기: 시작 위치·속도·회전을 시드 수열에서 순서대로 뽑는다 (뽑는 순서가 곧 재현 규칙)
function launch(d: Die, rnd: RandFn, x: number, z: number, vx: number, vz: number, vy0: number, spin: number): void {
d.p = [x, 2.4, z];
d.v = [vx + (rnd() - 0.5) * 1.6, vy0 + rnd() * 2.2, vz + (rnd() - 0.5) * 1.6];
var ax = randAxis(rnd);
var s = spin * (0.6 + rnd() * 0.8);
d.w = [ax[0] * s, ax[1] * s, ax[2] * s];
d.R = rotAxis(randAxis(rnd), rnd() * 6.283);
d.phase = PH_FLY; d.t = 0; d.hold = 0; d.bounces = 0; d.result = 0; d.hit = 0;
}
function spinStep(d: Die, dt: number): void {
var wl = Math.hypot(d.w[0], d.w[1], d.w[2]);
if (wl > 1e-5) d.R = matMul(rotAxis([d.w[0] / wl, d.w[1] / wl, d.w[2] / wl], wl * dt), d.R);
}
function floorStep(d: Die): void {
var mi = 0, my = 99;
var rx = 0, ry = 0, rz = 0;
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? DIE_HALF : -DIE_HALF, ly = (i & 2) ? DIE_HALF : -DIE_HALF, lz = (i & 4) ? DIE_HALF : -DIE_HALF;
var wy = d.R[3] * lx + d.R[4] * ly + d.R[5] * lz;
if (wy < my) { my = wy; mi = i; rx = d.R[0] * lx + d.R[1] * ly + d.R[2] * lz; ry = wy; rz = d.R[6] * lx + d.R[7] * ly + d.R[8] * lz; }
}
var low = d.p[1] + my;
if (low >= 0) return;
d.p[1] -= low;
var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
if (cvy >= 0) return;
var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
d.v[1] += j;
d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;
var tl = Math.hypot(cvx, cvz);
if (tl > 1e-4) {
var jt = Math.min(FLOOR_FRICTION * j, tl * 0.5);
var tx = -cvx / tl * jt, tz = -cvz / tl * jt;
d.v[0] += tx; d.v[2] += tz;
d.w[0] += ry * tz / INERTIA; d.w[1] += (rz * tx - rx * tz) / INERTIA; d.w[2] += -ry * tx / INERTIA;
}
if (cvy < -1.2) { d.bounces += 1; d.hit = -cvy; d.hx = d.p[0] + rx; d.hz = d.p[2] + rz; }
}
function wallStep(d: Die): void {
if (d.p[0] < d.xmin) { d.p[0] = d.xmin; d.v[0] = Math.abs(d.v[0]) * 0.5; }
if (d.p[0] > d.xmax) { d.p[0] = d.xmax; d.v[0] = -Math.abs(d.v[0]) * 0.5; }
if (d.p[2] < d.zmin) { d.p[2] = d.zmin; d.v[2] = Math.abs(d.v[2]) * 0.5; }
if (d.p[2] > d.zmax) { d.p[2] = d.zmax; d.v[2] = -Math.abs(d.v[2]) * 0.5; }
}
// 멈추기: 법선 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 정확히 위를 보도록 남은 각도의 SETTLE_RATE 만큼씩 돌린다
function settleStep(d: Die): void {
var k = topIndex(d);
d.top = FACE_VAL[k];
var nw = matVec(d.R, FACE_N[k]);
var ang = Math.acos(clamp(nw[1], -1, 1));
if (ang > 1e-4) {
var ax = unit(cross(nw, [0, 1, 0]));
d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
}
d.R = orthonormalize(matMul(rotAxis([0, 1, 0], d.w[1] * dtS), d.R));
d.w[1] *= 0.88;
d.v[0] *= 0.86; d.v[2] *= 0.86; d.v[1] = 0;
d.p[0] += d.v[0] * dtS; d.p[2] += d.v[2] * dtS;
d.p[1] += (DIE_HALF - d.p[1]) * 0.22;
wallStep(d);
if (ang < 0.004 && Math.abs(d.p[1] - DIE_HALF) < 0.01) {
d.p[1] = DIE_HALF;
var q = topIndex(d);
d.R = orthonormalize(matMul(rotAxis(unit(cross(matVec(d.R, FACE_N[q]), [0, 1, 0])), Math.acos(clamp(matVec(d.R, FACE_N[q])[1], -1, 1))), d.R));
d.phase = PH_HOLD; d.hold = 0; d.result = FACE_VAL[q]; d.top = d.result;
}
}
function stepDie(d: Die): void {
if (d.phase === PH_FLY) {
d.t += FIXED_DT;
d.v[1] -= GRAVITY * dtS;
d.p[0] += d.v[0] * dtS; d.p[1] += d.v[1] * dtS; d.p[2] += d.v[2] * dtS;
spinStep(d, dtS);
d.w[0] *= SPIN_DAMPING; d.w[1] *= SPIN_DAMPING; d.w[2] *= SPIN_DAMPING;
wallStep(d);
floorStep(d);
var sp = Math.hypot(d.v[0], d.v[1], d.v[2]);
if (d.t > SETTLE_AFTER_MS || (d.bounces >= 3 && sp < 1.6)) d.phase = PH_SETTLE;
} else if (d.phase === PH_SETTLE) {
d.t += FIXED_DT;
settleStep(d);
} else if (d.phase === PH_HOLD) {
d.hold += FIXED_DT;
}
}
function runUntilHold(d: Die): number {
for (var i = 0; i < 1200 && d.phase !== PH_HOLD; i += 1) stepDie(d);
return d.result;
}
// ── 그리기 ──
function rgb(c: number[], k: number): string {
return 'rgb(' + Math.min(255, Math.round(c[0] * k)) + ',' + Math.min(255, Math.round(c[1] * k)) + ',' + Math.min(255, Math.round(c[2] * k)) + ')';
}
function quad(c: Vec, u: Vec, v: Vec, s: number): number[][] {
var o: number[][] = [];
var sg = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
for (var i = 0; i < 4; i += 1) {
o.push(scr([c[0] + (u[0] * sg[i][0] + v[0] * sg[i][1]) * s * DIE_HALF, c[1] + (u[1] * sg[i][0] + v[1] * sg[i][1]) * s * DIE_HALF, c[2] + (u[2] * sg[i][0] + v[2] * sg[i][1]) * s * DIE_HALF]));
}
return o;
}
function fillPoly(pts: number[][], color: Paint, edge: number): void {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
if (edge > 0) { ctx.strokeStyle = color; ctx.lineWidth = edge; ctx.stroke(); }
}
function drawDie(d: Die, hi: number, alpha: number): void {
var pal = DICE_PAL[d.pal % DICE_PAL.length];
ctx.globalAlpha = alpha;
ctx.lineJoin = 'round';
for (var f = 0; f < 6; f += 1) {
var nw = matVec(d.R, FACE_N[f]);
var cw: Vec = [d.p[0] + nw[0] * DIE_HALF, d.p[1] + nw[1] * DIE_HALF, d.p[2] + nw[2] * DIE_HALF];
var cc = camPoint(cw), nc = camDir(nw);
if (nc[0] * cc[0] + nc[1] * cc[1] + nc[2] * cc[2] >= 0) continue;
var uw = matVec(d.R, FACE_U[f]), vw = matVec(d.R, FACE_V[f]);
var lit = 0.66 + 0.34 * Math.max(0, dot(nw, LIGHT));
var k0 = scr(cw)[2];
fillPoly(quad(cw, uw, vw, 1), rgb(pal.edge, lit), k0 * 0.1);
fillPoly(quad(cw, uw, vw, 0.84), rgb(pal.face, lit), k0 * 0.16);
if (f === hi) fillPoly(quad(cw, uw, vw, 0.84), 'rgba(255,255,255,0.4)', 0);
var val = FACE_VAL[f];
for (var pi = 0; pi < PIPS[val].length; pi += 1) {
var a = PIPS[val][pi][0] * 0.5 * DIE_HALF, b = PIPS[val][pi][1] * 0.5 * DIE_HALF;
var ring: number[][] = [];
for (var j = 0; j < 10; j += 1) {
var an = j * 0.62832, rr = 0.17 * DIE_HALF;
var ca = Math.cos(an) * rr, sa = Math.sin(an) * rr;
ring.push(scr([cw[0] + uw[0] * (a + ca) + vw[0] * (b + sa) + nw[0] * 0.004, cw[1] + uw[1] * (a + ca) + vw[1] * (b + sa) + nw[1] * 0.004, cw[2] + uw[2] * (a + ca) + vw[2] * (b + sa) + nw[2] * 0.004]));
}
fillPoly(ring, pal.pip, 0);
}
}
ctx.globalAlpha = 1;
}
function drawShadow(d: Die, alpha: number): void {
var s = scr([d.p[0], 0, d.p[2]]);
var h = Math.max(0, d.p[1] - DIE_HALF);
var r = DIE_HALF * s[2] * (1.2 + h * 0.18);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.42);
ctx.beginPath();
ctx.arc(0, 0, r, 0, 6.2832);
ctx.fillStyle = 'rgba(30,60,60,' + (0.34 * alpha / (1 + h * 0.7)) + ')';
ctx.fill();
ctx.restore();
}
var FELT_DOTS: number[][] = [];
(function (): void {
var fr = seedRand(99);
for (var i = 0; i < 70; i += 1) FELT_DOTS.push([(fr() * 2 - 1) * TABLE_X, TABLE_Z0 + fr() * (TABLE_Z1 - TABLE_Z0), 0.04 + fr() * 0.08]);
})();
function tablePts(m: number): number[][] {
return [scr([-TABLE_X - m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z1 + m]), scr([-TABLE_X - m, 0, TABLE_Z1 + m])];
}
function drawStage(lightX: number, lightK: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var bg = ctx.createRadialGradient(160, 80, 10, 160, 80, 210);
bg.addColorStop(0, '#3f3764');
bg.addColorStop(1, '#221d36');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
fillPoly(tablePts(0.5), '#8e80c8', 0);
var tp = tablePts(0);
var g = ctx.createLinearGradient(0, tp[0][1], 0, tp[2][1]);
g.addColorStop(0, '#9fdcc3');
g.addColorStop(1, '#78c8aa');
fillPoly(tp, g, 0);
for (var i = 0; i < FELT_DOTS.length; i += 1) {
var s = scr([FELT_DOTS[i][0], 0, FELT_DOTS[i][1]]);
ctx.beginPath();
ctx.arc(s[0], s[1], FELT_DOTS[i][2] * s[2], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,0.12)';
ctx.fill();
}
var lp = scr([lightX, 0, 1]);
var lg = ctx.createRadialGradient(lp[0], lp[1], 4, lp[0], lp[1], 120);
lg.addColorStop(0, 'rgba(255,255,255,' + (0.3 * lightK) + ')');
lg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.save();
ctx.beginPath();
ctx.moveTo(tp[0][0], tp[0][1]); ctx.lineTo(tp[1][0], tp[1][1]); ctx.lineTo(tp[2][0], tp[2][1]); ctx.lineTo(tp[3][0], tp[3][1]);
ctx.closePath();
ctx.clip();
ctx.fillStyle = lg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.restore();
}
function drawChip(x: number, y: number, s: number, value: number, pal: number, mark: boolean): void {
var p = DICE_PAL[pal % DICE_PAL.length];
ctx.fillStyle = value > 0 ? rgb(p.face, 1) : 'rgba(255,255,255,0.18)';
ctx.strokeStyle = mark ? '#7fe3bd' : rgb(p.edge, 1);
ctx.lineWidth = mark ? 1.8 : 1;
ctx.fillRect(x, y, s, s);
ctx.strokeRect(x, y, s, s);
for (var i = 0; i < PIPS[value].length; i += 1) {
ctx.beginPath();
ctx.arc(x + s / 2 + PIPS[value][i][0] * s * 0.26, y + s / 2 + PIPS[value][i][1] * s * 0.26, s * 0.1, 0, 6.2832);
ctx.fillStyle = p.pip;
ctx.fill();
}
}
// ── 상태 줄 (aria-live) ──
var stLabel = '', stNum = '', stAt = -9999;
function setStatus(label: string, num: string, force: boolean): void {
if (label === stLabel && num === stNum) return;
if (!force && label === stLabel && elapsed - stAt < 450) return;
stLabel = label; stNum = num; stAt = elapsed;
labelEl.textContent = label;
numEl.textContent = num;
}
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Fn[] = [];
// 기법: 컵 안 주사위의 위치·높이·기울기에 1차원 값 노이즈 오프셋(noise1)을 더한다. 진폭 amp 가 SHAKE_MS 동안 제곱 곡선으로 커지고,
// 끝나면 같은 주사위가 위로 솟구쳐 일반 물리(포물선·튕김·윗면 읽기)로 넘어간다. 컵은 앞벽이 주사위 아래쪽을 가리도록 뒤→주사위→앞 순서로 그린다.
var T_SHAKE = '흔드는 중';
var T_ROLLING = '굴리는 중';
var T_RESULT = '결과';
var SHAKE_MS = 1300;
var NOISE_FREQ = 9;
var CUP_X = -1.8, CUP_Z = -0.6, CUP_RIM_Y = 2.0;
var die: Die = newDie(3, -3.4, 3.4, -1.8, 2.6);
var rng: RandFn = seedRand(DICE_SEED + 7);
var perm: number[] = [];
(function (): void {
var nr = seedRand(DICE_SEED + 70);
for (var i = 0; i < 256; i += 1) perm.push(nr() * 2 - 1);
})();
function noise1(x: number): number {
var i = Math.floor(x);
var f = x - i;
var s = f * f * (3 - 2 * f);
return lerp(perm[i & 255], perm[(i + 1) & 255], s);
}
var mode = 0;
var shakeT = 0;
var baseR: Mat = [1, 0, 0, 0, 1, 0, 0, 0, 1];
var introAt = 0, ejectAt = 0;
var cupDX = 0, cupLift = 0, ampNow = 0.1;
function sceneInit(): void { die.phase = PH_IDLE; }
function sceneRoll(manual: boolean): void {
mode = 0; shakeT = 0; introAt = elapsed; ejectAt = 0;
baseR = rotAxis(randAxis(rng), rng() * 6.283);
die.phase = PH_IDLE; die.result = 0;
setStatus(T_SHAKE, '', true);
}
function sceneDone(): boolean { return mode === 1 && die.phase === PH_HOLD; }
function sceneFinish(): void { setStatus(T_RESULT, String(die.result), true); }
function shakePose(): void {
var t = elapsed / 1000 * NOISE_FREQ;
var I = clamp(shakeT / SHAKE_MS, 0, 1);
ampNow = 0.1 + 0.9 * I * I;
cupDX = noise1(t + 31) * ampNow * 0.5;
cupLift = Math.abs(noise1(t * 1.2 + 53)) * ampNow * 0.35;
var cx = CUP_X + cupDX;
die.p = [cx + noise1(t * 1.3 + 70) * ampNow * 0.7, 1.3 + cupLift + Math.abs(noise1(t * 1.1 + 110)) * ampNow * 0.9, CUP_Z + noise1(t * 1.2 + 150) * ampNow * 0.4];
die.R = matMul(rotAxis([0, 1, 0], noise1(t * 1.4 + 190) * ampNow), matMul(rotAxis([1, 0, 0], noise1(t * 1.5 + 230) * ampNow * 0.6), baseR));
}
function stepWorld(): void {
if (mode === 0) {
shakeT += FIXED_DT;
shakePose();
if (shakeT >= SHAKE_MS) {
mode = 1; ejectAt = elapsed;
var r0 = die.R, x0 = die.p[0], z0 = die.p[2];
launch(die, rng, x0, z0, 3.2, 0.2, 3.6, 16);
die.R = r0;
setStatus(T_ROLLING, '', true);
}
return;
}
stepDie(die);
if (die.phase === PH_HOLD) setStatus(T_RESULT, String(die.result), true);
if (die.phase === PH_HOLD && demo && die.hold >= HOLD_MS) sceneRoll(false);
}
function drawCup(alpha: number, slide: number): void {
var cx = CUP_X + cupDX - slide * 3.2, cz = CUP_Z;
var rc = scr([cx, CUP_RIM_Y + cupLift, cz]);
var rr = 1.6 * rc[2];
ctx.globalAlpha = alpha;
ctx.beginPath(); ctx.ellipse(rc[0], rc[1], rr, rr * 0.42, 0, 0, 6.2832);
ctx.fillStyle = '#6a5ba3'; ctx.fill();
ctx.globalAlpha = 1;
}
function drawCupFront(alpha: number, slide: number): void {
var cx = CUP_X + cupDX - slide * 3.2, cz = CUP_Z;
var rc = scr([cx, CUP_RIM_Y + cupLift, cz]), bc = scr([cx, cupLift, cz]);
var rr = 1.6 * rc[2], rb = 1.15 * bc[2];
ctx.globalAlpha = alpha;
var g = ctx.createLinearGradient(rc[0] - rr, 0, rc[0] + rr, 0);
g.addColorStop(0, '#f29bb7');
g.addColorStop(0.45, '#ffc9d8');
g.addColorStop(1, '#e38aa9');
ctx.beginPath();
ctx.moveTo(rc[0] - rr, rc[1]);
ctx.lineTo(bc[0] - rb, bc[1]);
ctx.ellipse(bc[0], bc[1], rb, rb * 0.42, 0, Math.PI, 0, true);
ctx.lineTo(rc[0] + rr, rc[1]);
ctx.ellipse(rc[0], rc[1], rr, rr * 0.42, 0, 0, Math.PI, false);
ctx.closePath();
ctx.fillStyle = g;
ctx.fill();
ctx.beginPath(); ctx.ellipse(rc[0], rc[1], rr, rr * 0.42, 0, 0, Math.PI, false);
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 2; ctx.stroke();
ctx.globalAlpha = 1;
}
function drawWorld(): void {
drawStage(die.p[0], 0.8);
var slide = mode === 1 ? clamp((elapsed - ejectAt) / 500, 0, 1) : 0;
var intro = clamp((elapsed - introAt) / 220, 0, 1);
var cupA = (mode === 0 ? 1 : 1 - slide) * intro;
var dieA = intro;
if (mode === 1 && die.phase === PH_HOLD) dieA = clamp((HOLD_MS - die.hold) / 180, 0, 1);
if (cupA > 0.01) drawCup(cupA, slide);
if (mode === 0 || die.phase !== PH_IDLE) {
drawShadow(die, dieA);
drawDie(die, -1, dieA);
}
if (cupA > 0.01) drawCupFront(cupA, slide);
}
function fit(): void {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs = -1;
var acc = 0;
function tick(ts: number): void {
if (lastTs < 0) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
// 모션 줄이기: 애니메이션 없이 결과가 나올 때까지 같은 계산을 한 번에 돌려 최종 장면만 그린다
function runToDone(): void {
var i = 0;
do { elapsed += FIXED_DT; stepWorld(); i += 1; } while (i < 1500 && !sceneDone());
sceneFinish();
drawWorld();
}
function onRollClick(): void {
stopDemo();
sceneRoll(true);
if (reduce) runToDone();
}
sceneInit();
sceneRoll(false);
if (reduce) {
runToDone();
} else {
for (var w0 = 0; w0 < 8; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
}
btn.addEventListener('click', onRollClick);
cleaners.push(function (): void { btn.removeEventListener('click', onRollClick); });
fit();
window.addEventListener('resize', fit);
cleaners.push(function (): void { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
return destroy;
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} role="img" aria-label="컵 안에서 주사위 위치와 각도에 노이즈 오프셋을 더해 덜그럭거리게 하다가, 흔들림 세기를 서서히 키워 컵 밖으로 튀어나가는 장면을 그린다." />
<output className={styles.bp__status} aria-live="polite"><span ref={labelRef}>흔드는 중</span> <b ref={numRef} /></output>
<button type="button" className={styles.bp__btn} ref={btnRef}>굴리기</button>
<p className={styles.bp__caption}>컵 안에서 주사위 위치와 각도에 노이즈 오프셋을 더해 덜그럭거리게 하다가, 흔들림 세기를 서서히 키워 컵 밖으로 튀어나가는 장면을 그린다.</p>
</div>
</main>
);
}07 · カップの中で振られるサイコロ — 計測 累積面積 24.347% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function shakePose() {
var t = elapsed / 1000 * NOISE_FREQ;
var I = clamp(shakeT / SHAKE_MS, 0, 1);
ampNow = 0.1 + 0.9 * I * I;
cupDX = noise1(t + 31) * ampNow * 0.5;
cupLift = Math.abs(noise1(t * 1.2 + 53)) * ampNow * 0.35;
var cx = CUP_X + cupDX;
die.p = [cx + noise1(t * 1.3 + 70) * ampNow * 0.7, 1.3 + cupLift + Math.abs(noise1(t * 1.1 + 110)) * ampNow * 0.9, CUP_Z + noise1(t * 1.2 + 150) * ampNow * 0.4];
die.R = matMul(rotAxis([0, 1, 0], noise1(t * 1.4 + 190) * ampNow), matMul(rotAxis([1, 0, 0], noise1(t * 1.5 + 230) * ampNow * 0.6), baseR));
}
08平らな面の切り替えで転がる2Dサイコロ
この場面だけキャンバスではなくDOMとCSSで、自動の見せ方はキーフレームで床の縞を128pxずつ押しながら、サイコロを scaleX で平らに潰して目のレイヤーを差し替えます。ボタンを押すと pickFaces() が直前の面と重ならない面を HOP_COUNT の4ホップぶん選び、Web Animationsで再生します。累積面積15.7%・平均強度48.1で、23/23フレームが動きました。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<div class="fd" aria-hidden="true">
<div class="fd__floor" id="fdFloor"></div>
<div class="fd__shadow" id="fdShadow"></div>
<div class="fd__hop" id="fdHop">
<div class="fd__squeeze" id="fdSqueeze">
<div class="fd__die" id="fdDie" data-face="2">
<div class="fd__face fd__face--1"><i class="pip r2 c2"></i></div>
<div class="fd__face fd__face--2"><i class="pip r1 c1"></i><i class="pip r3 c3"></i></div>
<div class="fd__face fd__face--3"><i class="pip r1 c1"></i><i class="pip r2 c2"></i><i class="pip r3 c3"></i></div>
<div class="fd__face fd__face--4"><i class="pip r1 c1"></i><i class="pip r1 c3"></i><i class="pip r3 c1"></i><i class="pip r3 c3"></i></div>
<div class="fd__face fd__face--5"><i class="pip r1 c1"></i><i class="pip r1 c3"></i><i class="pip r2 c2"></i><i class="pip r3 c1"></i><i class="pip r3 c3"></i></div>
<div class="fd__face fd__face--6"><i class="pip r1 c1"></i><i class="pip r2 c1"></i><i class="pip r3 c1"></i><i class="pip r1 c3"></i><i class="pip r2 c3"></i><i class="pip r3 c3"></i></div>
</div>
</div>
</div>
</div>
<output class="bp__status" aria-live="polite"><span id="bpLabel">転がり中</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">振る</button>
<p class="bp__caption">3D計算なしで面一枚の横幅を縮めては伸ばしながら目の数を差し替え、床の縞を一マスずつ押して、平面のサイコロが転がる様子をキーフレームのスタイル規則で作る。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
$tile: 128px;
$hop-height: 28px;
$die-size: 80px;
$hop-dur: .4s;
$felt-a: #a8e0c8;
$felt-b: #84cbae;
$die-face: #fff1f5;
$die-edge: #f2b4c8;
$pip-ink: #4b3a63;
$sky-top: #cdbfff;
$sky-bottom: #fbe3ec;
.bp {
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: $stage-ink;
}
.fd {
position: absolute;
inset: 0;
overflow: hidden;
background: linear-gradient($sky-top, $sky-bottom);
}
.fd__floor {
position: absolute;
left: 0;
bottom: 0;
width: calc(100% + 5 * #{$tile});
height: 34%;
background: repeating-linear-gradient(90deg, $felt-a 0 ($tile * .5), $felt-b ($tile * .5) $tile);
box-shadow: inset 0 4px 0 rgba(255, 255, 255, .5);
}
.fd__shadow {
position: absolute;
left: 50%;
bottom: calc(22% - 8px);
width: $die-size;
height: 16px;
margin-left: -($die-size * .5);
border-radius: 50%;
background: rgba(30, 60, 60, .5);
opacity: .5;
}
.fd__hop {
position: absolute;
left: 50%;
bottom: 22%;
width: $die-size;
height: $die-size;
margin-left: -($die-size * .5);
}
.fd__squeeze {
width: 100%;
height: 100%;
}
.fd__die {
position: relative;
width: 100%;
height: 100%;
border-radius: $r-card;
background: $die-face;
box-shadow: inset 0 -6px 0 rgba(200, 120, 160, .28), inset 0 0 0 3px $die-edge;
}
.fd__face {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
padding: $sp-3;
opacity: 0;
}
.pip {
width: $sp-4;
height: $sp-4;
border-radius: $r-pill;
background: $pip-ink;
place-self: center;
}
.r1 { grid-row: 1; }
.r2 { grid-row: 2; }
.r3 { grid-row: 3; }
.c1 { grid-column: 1; }
.c2 { grid-column: 2; }
.c3 { grid-column: 3; }
[data-face="1"] .fd__face--1,
[data-face="2"] .fd__face--2,
[data-face="3"] .fd__face--3,
[data-face="4"] .fd__face--4,
[data-face="5"] .fd__face--5,
[data-face="6"] .fd__face--6 { opacity: 1; }
@keyframes fd-floor {
0% { transform: translateX(0); animation-timing-function: cubic-bezier(.45, 0, .2, 1); }
20% { transform: translateX(-$tile); animation-timing-function: cubic-bezier(.45, 0, .2, 1); }
40% { transform: translateX(-2 * $tile); animation-timing-function: cubic-bezier(.45, 0, .2, 1); }
60% { transform: translateX(-3 * $tile); animation-timing-function: cubic-bezier(.45, 0, .2, 1); }
80% { transform: translateX(-4 * $tile); animation-timing-function: cubic-bezier(.45, 0, .2, 1); }
100% { transform: translateX(-5 * $tile); }
}
@keyframes fd-hop {
0% { transform: translateY(0); animation-timing-function: ease-out; }
50% { transform: translateY(-$hop-height); animation-timing-function: ease-in; }
100% { transform: translateY(0); }
}
@keyframes fd-squeeze {
0% { transform: scaleX(1); animation-timing-function: ease-in; }
50% { transform: scaleX(.18); animation-timing-function: ease-out; }
100% { transform: scaleX(1); }
}
@keyframes fd-shadow {
0% { transform: scale(1); opacity: .5; animation-timing-function: ease-out; }
50% { transform: scale(.6); opacity: .25; animation-timing-function: ease-in; }
100% { transform: scale(1); opacity: .5; }
}
@keyframes fd-face-1 {
0% { opacity: 0; }
50% { opacity: 1; }
70% { opacity: 0; }
}
@keyframes fd-face-2 {
0% { opacity: 0; }
10% { opacity: 1; }
30% { opacity: 0; }
}
@keyframes fd-face-4 {
0% { opacity: 0; }
70% { opacity: 1; }
90% { opacity: 0; }
}
@keyframes fd-face-5 {
0% { opacity: 0; }
30% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes fd-face-6 {
0% { opacity: 1; }
10% { opacity: 0; }
90% { opacity: 1; }
}
.is-demo .fd__floor { animation: fd-floor $dur-loop linear infinite; }
.is-demo .fd__hop { animation: fd-hop $hop-dur linear infinite; }
.is-demo .fd__squeeze { animation: fd-squeeze $hop-dur linear infinite; }
.is-demo .fd__shadow { animation: fd-shadow $hop-dur linear infinite; }
.is-demo .fd__face--2 { animation: fd-face-2 $dur-loop steps(1, end) infinite; }
.is-demo .fd__face--5 { animation: fd-face-5 $dur-loop steps(1, end) infinite; }
.is-demo .fd__face--1 { animation: fd-face-1 $dur-loop steps(1, end) infinite; }
.is-demo .fd__face--4 { animation: fd-face-4 $dur-loop steps(1, end) infinite; }
.is-demo .fd__face--6 { animation: fd-face-6 $dur-loop steps(1, end) infinite; }
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
display: flex;
align-items: baseline;
gap: $sp-2;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: $sp-3;
top: $sp-3;
min-height: $sp-8;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-instant $ease-out;
}
.bp__btn:hover { transform: translateY(-1px); }
.bp__btn:active { transform: translateY(1px); }
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .9);
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__btn { font-size: 11px; padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
.bp__btn { transition: none; }
.fd__floor, .fd__hop, .fd__squeeze, .fd__shadow, .fd__face { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.fd {
position: absolute;
inset: 0;
overflow: hidden;
background: linear-gradient(#cdbfff, #fbe3ec);
}
.fd__floor {
position: absolute;
left: 0;
bottom: 0;
width: calc(100% + 5 * 128px);
height: 34%;
background: repeating-linear-gradient(90deg, #a8e0c8 0 64px, #84cbae 64px 128px);
box-shadow: inset 0 4px 0 rgba(255, 255, 255, 0.5);
}
.fd__shadow {
position: absolute;
left: 50%;
bottom: calc(22% - 8px);
width: 80px;
height: 16px;
margin-left: -40px;
border-radius: 50%;
background: rgba(30, 60, 60, 0.5);
opacity: 0.5;
}
.fd__hop {
position: absolute;
left: 50%;
bottom: 22%;
width: 80px;
height: 80px;
margin-left: -40px;
}
.fd__squeeze {
width: 100%;
height: 100%;
}
.fd__die {
position: relative;
width: 100%;
height: 100%;
border-radius: 16px;
background: #fff1f5;
box-shadow: inset 0 -6px 0 rgba(200, 120, 160, 0.28), inset 0 0 0 3px #f2b4c8;
}
.fd__face {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
padding: 12px;
opacity: 0;
}
.pip {
width: 16px;
height: 16px;
border-radius: 999px;
background: #4b3a63;
place-self: center;
}
.r1 {
grid-row: 1;
}
.r2 {
grid-row: 2;
}
.r3 {
grid-row: 3;
}
.c1 {
grid-column: 1;
}
.c2 {
grid-column: 2;
}
.c3 {
grid-column: 3;
}
[data-face="1"] .fd__face--1,
[data-face="2"] .fd__face--2,
[data-face="3"] .fd__face--3,
[data-face="4"] .fd__face--4,
[data-face="5"] .fd__face--5,
[data-face="6"] .fd__face--6 {
opacity: 1;
}
@keyframes fd-floor {
0% {
transform: translateX(0);
animation-timing-function: cubic-bezier(0.45, 0, 0.2, 1);
}
20% {
transform: translateX(-128px);
animation-timing-function: cubic-bezier(0.45, 0, 0.2, 1);
}
40% {
transform: translateX(-256px);
animation-timing-function: cubic-bezier(0.45, 0, 0.2, 1);
}
60% {
transform: translateX(-384px);
animation-timing-function: cubic-bezier(0.45, 0, 0.2, 1);
}
80% {
transform: translateX(-512px);
animation-timing-function: cubic-bezier(0.45, 0, 0.2, 1);
}
100% {
transform: translateX(-640px);
}
}
@keyframes fd-hop {
0% {
transform: translateY(0);
animation-timing-function: ease-out;
}
50% {
transform: translateY(-28px);
animation-timing-function: ease-in;
}
100% {
transform: translateY(0);
}
}
@keyframes fd-squeeze {
0% {
transform: scaleX(1);
animation-timing-function: ease-in;
}
50% {
transform: scaleX(0.18);
animation-timing-function: ease-out;
}
100% {
transform: scaleX(1);
}
}
@keyframes fd-shadow {
0% {
transform: scale(1);
opacity: 0.5;
animation-timing-function: ease-out;
}
50% {
transform: scale(0.6);
opacity: 0.25;
animation-timing-function: ease-in;
}
100% {
transform: scale(1);
opacity: 0.5;
}
}
@keyframes fd-face-1 {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
70% {
opacity: 0;
}
}
@keyframes fd-face-2 {
0% {
opacity: 0;
}
10% {
opacity: 1;
}
30% {
opacity: 0;
}
}
@keyframes fd-face-4 {
0% {
opacity: 0;
}
70% {
opacity: 1;
}
90% {
opacity: 0;
}
}
@keyframes fd-face-5 {
0% {
opacity: 0;
}
30% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@keyframes fd-face-6 {
0% {
opacity: 1;
}
10% {
opacity: 0;
}
90% {
opacity: 1;
}
}
.is-demo .fd__floor {
animation: fd-floor 2s linear infinite;
}
.is-demo .fd__hop {
animation: fd-hop 0.4s linear infinite;
}
.is-demo .fd__squeeze {
animation: fd-squeeze 0.4s linear infinite;
}
.is-demo .fd__shadow {
animation: fd-shadow 0.4s linear infinite;
}
.is-demo .fd__face--2 {
animation: fd-face-2 2s steps(1, end) infinite;
}
.is-demo .fd__face--5 {
animation: fd-face-5 2s steps(1, end) infinite;
}
.is-demo .fd__face--1 {
animation: fd-face-1 2s steps(1, end) infinite;
}
.is-demo .fd__face--4 {
animation: fd-face-4 2s steps(1, end) infinite;
}
.is-demo .fd__face--6 {
animation: fd-face-6 2s steps(1, end) infinite;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: 12px;
top: 12px;
min-height: 32px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 140ms ease-out;
}
.bp__btn:hover {
transform: translateY(-1px);
}
.bp__btn:active {
transform: translateY(1px);
}
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.9);
outline-offset: 2px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
padding: 4px 8px;
}
.bp__btn {
font-size: 11px;
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
.bp__btn {
transition: none;
}
.fd__floor, .fd__hop, .fd__squeeze, .fd__shadow, .fd__face {
animation: none !important;
}
}(function () {
var root = document.getElementById('bpRoot');
var labelEl = document.getElementById('bpLabel');
var numEl = document.getElementById('bpNum');
var btn = document.getElementById('bpBtn');
var dieEl = document.getElementById('fdDie');
var floorEl = document.getElementById('fdFloor');
var hopEl = document.getElementById('fdHop');
var squeezeEl = document.getElementById('fdSqueeze');
var shadowEl = document.getElementById('fdShadow');
var DICE_SEED = 7;
var HOP_MS = 360;
var HOP_COUNT = 4;
var TILE_PX = 128;
var T_RESULT = '結果';
var T_ROLLING = '転がり中';
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rng = seedRand(DICE_SEED + 8);
var timers = [];
var cleaners = [];
var stLabel = '', stNum = '';
function setStatus(label, num) {
if (label === stLabel && num === stNum) return;
stLabel = label; stNum = num;
labelEl.textContent = label;
numEl.textContent = num;
}
// 기법: 면 한 장의 가로 폭을 scaleX 로 줄였다 늘리는 동안(납작해지는 순간) 눈 수 레이어를 바꿔 끼운다.
// 바닥 줄무늬를 한 칸(TILE_PX)씩 밀면 주사위는 제자리에서 튀기고 바닥이 굴러 가는 것처럼 보인다.
function clearTimers() {
for (var i = 0; i < timers.length; i += 1) window.clearTimeout(timers[i]);
timers = [];
}
function pickFaces() {
var seq = [];
var prev = Number(dieEl.getAttribute('data-face')) || 2;
for (var k = 0; k < HOP_COUNT; k += 1) {
var f = 1 + Math.floor(rng() * 6);
if (f === prev) f = (f % 6) + 1;
seq.push(f);
prev = f;
}
return seq;
}
function onRoll() {
stopDemo();
clearTimers();
var running = [floorEl, hopEl, squeezeEl, shadowEl];
for (var r = 0; r < running.length; r += 1) {
var an = running[r].getAnimations();
for (var q = 0; q < an.length; q += 1) an[q].cancel();
}
var seq = pickFaces();
var last = seq[seq.length - 1];
if (reduce) {
dieEl.setAttribute('data-face', String(last));
setStatus(T_RESULT, String(last));
return;
}
setStatus(T_ROLLING, '');
var span = HOP_MS * HOP_COUNT;
floorEl.animate([{ transform: 'translateX(0px)' }, { transform: 'translateX(-' + (TILE_PX * HOP_COUNT) + 'px)' }], { duration: span, easing: 'ease-in-out' });
hopEl.animate([{ transform: 'translateY(0px)' }, { transform: 'translateY(-28px)', offset: 0.5 }, { transform: 'translateY(0px)' }], { duration: HOP_MS, iterations: HOP_COUNT, easing: 'ease-out' });
squeezeEl.animate([{ transform: 'scaleX(1)' }, { transform: 'scaleX(0.18)', offset: 0.5 }, { transform: 'scaleX(1)' }], { duration: HOP_MS, iterations: HOP_COUNT, easing: 'ease-in-out' });
shadowEl.animate([{ transform: 'scale(1)', opacity: 0.5 }, { transform: 'scale(0.6)', opacity: 0.25, offset: 0.5 }, { transform: 'scale(1)', opacity: 0.5 }], { duration: HOP_MS, iterations: HOP_COUNT, easing: 'ease-out' });
for (var k = 0; k < HOP_COUNT; k += 1) {
(function (face, at) {
timers.push(window.setTimeout(function () { dieEl.setAttribute('data-face', String(face)); }, at));
})(seq[k], Math.round((k + 0.5) * HOP_MS));
}
timers.push(window.setTimeout(function () { setStatus(T_RESULT, String(last)); }, span + 20));
}
if (reduce) setStatus(T_RESULT, String(Number(dieEl.getAttribute('data-face')) || 2));
btn.addEventListener('click', onRoll);
cleaners.push(function () { btn.removeEventListener('click', onRoll); });
function destroy() {
clearTimers();
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
})();import { useEffect, useRef } from "react";
import styles from "./SpinDiceSceneFlatFaceTumble.module.scss";
export default function SpinDiceSceneFlatFaceTumble() {
const rootRef = useRef<HTMLDivElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const numRef = useRef<HTMLElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const dieRef = useRef<HTMLDivElement | null>(null);
const floorRef = useRef<HTMLDivElement | null>(null);
const hopRef = useRef<HTMLDivElement | null>(null);
const squeezeRef = useRef<HTMLDivElement | null>(null);
const shadowRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const r0 = rootRef.current, l0 = labelRef.current, n0 = numRef.current, b0 = btnRef.current;
const d0 = dieRef.current, f0 = floorRef.current, h0 = hopRef.current, s0 = squeezeRef.current, sh0 = shadowRef.current;
if (!r0 || !l0 || !n0 || !b0 || !d0 || !f0 || !h0 || !s0 || !sh0) return;
const root: HTMLDivElement = r0;
const labelEl: HTMLElement = l0;
const numEl: HTMLElement = n0;
const btn: HTMLButtonElement = b0;
const dieEl: HTMLDivElement = d0;
const floorEl: HTMLDivElement = f0;
const hopEl: HTMLDivElement = h0;
const squeezeEl: HTMLDivElement = s0;
const shadowEl: HTMLDivElement = sh0;
type Fn = () => void;
type RandFn = () => number;
var DICE_SEED = 7;
var HOP_MS = 360;
var HOP_COUNT = 4;
var TILE_PX = 128;
var T_RESULT = '결과';
var T_ROLLING = '굴리는 중';
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed: number): RandFn {
var s = seed >>> 0;
return function (): number { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rng = seedRand(DICE_SEED + 8);
var timers: number[] = [];
var cleaners: Fn[] = [];
var stLabel = '', stNum = '';
function setStatus(label: string, num: string): void {
if (label === stLabel && num === stNum) return;
stLabel = label; stNum = num;
labelEl.textContent = label;
numEl.textContent = num;
}
// 기법: 면 한 장의 가로 폭을 scaleX 로 줄였다 늘리는 동안(납작해지는 순간) 눈 수 레이어를 바꿔 끼운다.
// 바닥 줄무늬를 한 칸(TILE_PX)씩 밀면 주사위는 제자리에서 튀기고 바닥이 굴러 가는 것처럼 보인다.
function clearTimers(): void {
for (var i = 0; i < timers.length; i += 1) window.clearTimeout(timers[i]);
timers = [];
}
function pickFaces(): number[] {
var seq: number[] = [];
var prev = Number(dieEl.getAttribute('data-face')) || 2;
for (var k = 0; k < HOP_COUNT; k += 1) {
var f = 1 + Math.floor(rng() * 6);
if (f === prev) f = (f % 6) + 1;
seq.push(f);
prev = f;
}
return seq;
}
function onRoll(): void {
stopDemo();
clearTimers();
var running = [floorEl, hopEl, squeezeEl, shadowEl];
for (var r = 0; r < running.length; r += 1) {
var an = running[r].getAnimations();
for (var q = 0; q < an.length; q += 1) an[q].cancel();
}
var seq = pickFaces();
var last = seq[seq.length - 1];
if (reduce) {
dieEl.setAttribute('data-face', String(last));
setStatus(T_RESULT, String(last));
return;
}
setStatus(T_ROLLING, '');
var span = HOP_MS * HOP_COUNT;
floorEl.animate([{ transform: 'translateX(0px)' }, { transform: 'translateX(-' + (TILE_PX * HOP_COUNT) + 'px)' }], { duration: span, easing: 'ease-in-out' });
hopEl.animate([{ transform: 'translateY(0px)' }, { transform: 'translateY(-28px)', offset: 0.5 }, { transform: 'translateY(0px)' }], { duration: HOP_MS, iterations: HOP_COUNT, easing: 'ease-out' });
squeezeEl.animate([{ transform: 'scaleX(1)' }, { transform: 'scaleX(0.18)', offset: 0.5 }, { transform: 'scaleX(1)' }], { duration: HOP_MS, iterations: HOP_COUNT, easing: 'ease-in-out' });
shadowEl.animate([{ transform: 'scale(1)', opacity: 0.5 }, { transform: 'scale(0.6)', opacity: 0.25, offset: 0.5 }, { transform: 'scale(1)', opacity: 0.5 }], { duration: HOP_MS, iterations: HOP_COUNT, easing: 'ease-out' });
for (var k = 0; k < HOP_COUNT; k += 1) {
(function (face: number, at: number): void {
timers.push(window.setTimeout(function (): void { dieEl.setAttribute('data-face', String(face)); }, at));
})(seq[k], Math.round((k + 0.5) * HOP_MS));
}
timers.push(window.setTimeout(function (): void { setStatus(T_RESULT, String(last)); }, span + 20));
}
if (reduce) setStatus(T_RESULT, String(Number(dieEl.getAttribute('data-face')) || 2));
btn.addEventListener('click', onRoll);
cleaners.push(function (): void { btn.removeEventListener('click', onRoll); });
function destroy(): void {
clearTimers();
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
return destroy;
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<div className={styles.fd} aria-hidden="true">
<div className={styles.fd__floor} ref={floorRef} />
<div className={styles.fd__shadow} ref={shadowRef} />
<div className={styles.fd__hop} ref={hopRef}>
<div className={styles.fd__squeeze} ref={squeezeRef}>
<div className={styles.fd__die} ref={dieRef} data-face="2">
<div className={`${styles.fd__face} ${styles["fd__face--1"]}`}><i className={`${styles.pip} ${styles.r2} ${styles.c2}`} /></div>
<div className={`${styles.fd__face} ${styles["fd__face--2"]}`}><i className={`${styles.pip} ${styles.r1} ${styles.c1}`} /><i className={`${styles.pip} ${styles.r3} ${styles.c3}`} /></div>
<div className={`${styles.fd__face} ${styles["fd__face--3"]}`}><i className={`${styles.pip} ${styles.r1} ${styles.c1}`} /><i className={`${styles.pip} ${styles.r2} ${styles.c2}`} /><i className={`${styles.pip} ${styles.r3} ${styles.c3}`} /></div>
<div className={`${styles.fd__face} ${styles["fd__face--4"]}`}><i className={`${styles.pip} ${styles.r1} ${styles.c1}`} /><i className={`${styles.pip} ${styles.r1} ${styles.c3}`} /><i className={`${styles.pip} ${styles.r3} ${styles.c1}`} /><i className={`${styles.pip} ${styles.r3} ${styles.c3}`} /></div>
<div className={`${styles.fd__face} ${styles["fd__face--5"]}`}><i className={`${styles.pip} ${styles.r1} ${styles.c1}`} /><i className={`${styles.pip} ${styles.r1} ${styles.c3}`} /><i className={`${styles.pip} ${styles.r2} ${styles.c2}`} /><i className={`${styles.pip} ${styles.r3} ${styles.c1}`} /><i className={`${styles.pip} ${styles.r3} ${styles.c3}`} /></div>
<div className={`${styles.fd__face} ${styles["fd__face--6"]}`}><i className={`${styles.pip} ${styles.r1} ${styles.c1}`} /><i className={`${styles.pip} ${styles.r2} ${styles.c1}`} /><i className={`${styles.pip} ${styles.r3} ${styles.c1}`} /><i className={`${styles.pip} ${styles.r1} ${styles.c3}`} /><i className={`${styles.pip} ${styles.r2} ${styles.c3}`} /><i className={`${styles.pip} ${styles.r3} ${styles.c3}`} /></div>
</div>
</div>
</div>
</div>
<output className={styles.bp__status} aria-live="polite"><span ref={labelRef}>굴리는 중</span> <b ref={numRef} /></output>
<button type="button" className={styles.bp__btn} ref={btnRef}>굴리기</button>
<p className={styles.bp__caption}>3D 계산 없이 면 한 장의 가로 폭을 줄였다 늘리며 눈 수를 바꿔 끼우고 바닥 줄무늬를 한 칸씩 밀어, 평면 주사위가 굴러가는 모습을 키프레임 스타일 규칙으로 만든다.</p>
</div>
</main>
);
}08 · 平らな面の切り替えで転がる2Dサイコロ — 計測 累積面積 15.748% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function pickFaces() {
var seq = [];
var prev = Number(dieEl.getAttribute('data-face')) || 2;
for (var k = 0; k < HOP_COUNT; k += 1) {
var f = 1 + Math.floor(rng() * 6);
if (f === prev) f = (f % 6) + 1;
seq.push(f);
prev = f;
}
return seq;
}
09ヒーロー背景のサイコロテーブル
三つのサイコロは LANES で分けた横長のレーンの中で、それぞれ別のシードにより時間差をつけて投げられ、止まると結果が盤の下のチップの列へ一つずつ埋まります。チップの列は最初は空のままで、衝突計算の代わりにレーンで避け合い、カメラから最も遠いサイコロから順に描くだけです。累積面積23.0%・平均強度69.4で、23/23フレームが動きました。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" role="img" aria-label="フェルトの台の上でサイコロ三つが別々のシードで順に投げられては止まることを繰り返し、結果の目が台の下のチップ列へ順に記される、ヒーロー場面。"></canvas>
<output class="bp__status" aria-live="polite"><span id="bpLabel">転がり中</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">振る</button>
<p class="bp__caption">フェルトの台の上でサイコロ三つが別々のシードで順に投げられては止まることを繰り返し、結果の目が台の下のチップ列へ順に記される、ヒーロー場面。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
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: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__status {
position: absolute;
left: $sp-3;
top: $sp-3;
display: flex;
align-items: baseline;
gap: $sp-2;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: $sp-3;
top: $sp-3;
min-height: $sp-8;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-instant $ease-out;
}
.bp__btn:hover { transform: translateY(-1px); }
.bp__btn:active { transform: translateY(1px); }
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .9);
outline-offset: 2px;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__status { font-size: 10px; padding: $sp-1 $sp-2; }
.bp__btn { font-size: 11px; padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
.bp__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__status {
position: absolute;
left: 12px;
top: 12px;
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.bp__status b {
min-width: 1ch;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bp__btn {
position: absolute;
right: 12px;
top: 12px;
min-height: 32px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 140ms ease-out;
}
.bp__btn:hover {
transform: translateY(-1px);
}
.bp__btn:active {
transform: translateY(1px);
}
.bp__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.9);
outline-offset: 2px;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
.bp__status {
font-size: 10px;
padding: 4px 8px;
}
.bp__btn {
font-size: 11px;
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
.bp__btn {
transition: none;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var labelEl = document.getElementById('bpLabel');
var numEl = document.getElementById('bpNum');
var btn = document.getElementById('bpBtn');
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var DICE_RESTITUTION = 0.45;
var SPIN_DAMPING = 0.985;
var DICE_SEED = 7;
var GRAVITY = 22;
var DIE_HALF = 1;
var INERTIA = 2 * DIE_HALF * DIE_HALF / 3;
var FLOOR_FRICTION = 0.35;
var SETTLE_AFTER_MS = 2000;
var SETTLE_RATE = 0.16;
var HOLD_MS = 700;
var CAM_Y = 5.6, CAM_BACK = 12, FOCAL = 275, VIEW_CX = 160, VIEW_CY = 82;
var TABLE_X = 4.4, TABLE_Z0 = -3, TABLE_Z1 = 5;
var PH_FLY = 0, PH_SETTLE = 1, PH_HOLD = 2, PH_IDLE = 3;
var STRIP_BASE = 128;
var BAR_MAX = 18;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
function dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function cross(a, b) { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function unit(a) { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function matMul(a, b) {
var o = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (var i = 0; i < 3; i += 1) {
for (var j = 0; j < 3; j += 1) o[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
}
return o;
}
function matVec(m, v) {
return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
}
// 로드리게스 공식: 단위 축 ax 둘레로 ang 만큼 도는 회전 행렬
function rotAxis(ax, ang) {
var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c];
}
function orthonormalize(m) {
var a = unit([m[0], m[1], m[2]]);
var k = a[0] * m[3] + a[1] * m[4] + a[2] * m[5];
var b = unit([m[3] - a[0] * k, m[4] - a[1] * k, m[5] - a[2] * k]);
var c = cross(a, b);
return [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]];
}
function randAxis(rnd) { return unit([rnd() * 2 - 1, rnd() * 2 - 1, rnd() * 2 - 1]); }
var PITCH = Math.atan2(CAM_Y, CAM_BACK);
var CAM_S = Math.sin(PITCH), CAM_C = Math.cos(PITCH);
function camPoint(p) {
var qy = p[1] - CAM_Y, qz = p[2] + CAM_BACK;
return [p[0], qy * CAM_C + qz * CAM_S, -qy * CAM_S + qz * CAM_C];
}
function camDir(v) { return [v[0], v[1] * CAM_C + v[2] * CAM_S, -v[1] * CAM_S + v[2] * CAM_C]; }
function scr(p) {
var c = camPoint(p);
var k = FOCAL / c[2];
return [VIEW_CX + c[0] * k, VIEW_CY - c[1] * k, k];
}
var FACE_N = [[0, 1, 0], [0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];
var FACE_U = [[1, 0, 0], [1, 0, 0], [0, 1, 0], [0, 1, 0], [1, 0, 0], [1, 0, 0]];
var FACE_V = [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]];
var FACE_VAL = [1, 6, 2, 5, 3, 4];
var PIPS = [[], [[0, 0]], [[-1, -1], [1, 1]], [[-1, -1], [0, 0], [1, 1]],
[[-1, -1], [1, -1], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
[[-1, -1], [-1, 0], [-1, 1], [1, -1], [1, 0], [1, 1]]];
var DICE_PAL = [
{ face: [255, 214, 226], edge: [240, 160, 186], pip: '#4b3a63' },
{ face: [200, 232, 255], edge: [140, 190, 236], pip: '#3b4a6b' },
{ face: [224, 212, 255], edge: [170, 150, 232], pip: '#46386b' },
{ face: [255, 240, 196], edge: [236, 206, 120], pip: '#5a4636' }
];
var LIGHT = unit([-0.45, 0.85, -0.35]);
function newDie(pal, xmin, xmax, zmin, zmax) {
return { p: [0, DIE_HALF, 0], v: [0, 0, 0], R: [1, 0, 0, 0, 1, 0, 0, 0, 1], w: [0, 0, 0], pal: pal, phase: PH_IDLE, t: 0, hold: 0,
bounces: 0, result: 0, top: 1, hit: 0, hx: 0, hz: 0, xmin: xmin, xmax: xmax, zmin: zmin, zmax: zmax };
}
function topIndex(d) {
var best = 0, by = -9;
for (var f = 0; f < 6; f += 1) {
var ny = d.R[3] * FACE_N[f][0] + d.R[4] * FACE_N[f][1] + d.R[5] * FACE_N[f][2];
if (ny > by) { by = ny; best = f; }
}
return best;
}
// 던지기: 시작 위치·속도·회전을 시드 수열에서 순서대로 뽑는다 (뽑는 순서가 곧 재현 규칙)
function launch(d, rnd, x, z, vx, vz, vy0, spin) {
d.p = [x, 2.4, z];
d.v = [vx + (rnd() - 0.5) * 1.6, vy0 + rnd() * 2.2, vz + (rnd() - 0.5) * 1.6];
var ax = randAxis(rnd);
var s = spin * (0.6 + rnd() * 0.8);
d.w = [ax[0] * s, ax[1] * s, ax[2] * s];
d.R = rotAxis(randAxis(rnd), rnd() * 6.283);
d.phase = PH_FLY; d.t = 0; d.hold = 0; d.bounces = 0; d.result = 0; d.hit = 0;
}
function spinStep(d, dt) {
var wl = Math.hypot(d.w[0], d.w[1], d.w[2]);
if (wl > 1e-5) d.R = matMul(rotAxis([d.w[0] / wl, d.w[1] / wl, d.w[2] / wl], wl * dt), d.R);
}
function floorStep(d) {
var mi = 0, my = 99;
var rx = 0, ry = 0, rz = 0;
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? DIE_HALF : -DIE_HALF, ly = (i & 2) ? DIE_HALF : -DIE_HALF, lz = (i & 4) ? DIE_HALF : -DIE_HALF;
var wy = d.R[3] * lx + d.R[4] * ly + d.R[5] * lz;
if (wy < my) { my = wy; mi = i; rx = d.R[0] * lx + d.R[1] * ly + d.R[2] * lz; ry = wy; rz = d.R[6] * lx + d.R[7] * ly + d.R[8] * lz; }
}
var low = d.p[1] + my;
if (low >= 0) return;
d.p[1] -= low;
var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
if (cvy >= 0) return;
var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
d.v[1] += j;
d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;
var tl = Math.hypot(cvx, cvz);
if (tl > 1e-4) {
var jt = Math.min(FLOOR_FRICTION * j, tl * 0.5);
var tx = -cvx / tl * jt, tz = -cvz / tl * jt;
d.v[0] += tx; d.v[2] += tz;
d.w[0] += ry * tz / INERTIA; d.w[1] += (rz * tx - rx * tz) / INERTIA; d.w[2] += -ry * tx / INERTIA;
}
if (cvy < -1.2) { d.bounces += 1; d.hit = -cvy; d.hx = d.p[0] + rx; d.hz = d.p[2] + rz; }
}
function wallStep(d) {
if (d.p[0] < d.xmin) { d.p[0] = d.xmin; d.v[0] = Math.abs(d.v[0]) * 0.5; }
if (d.p[0] > d.xmax) { d.p[0] = d.xmax; d.v[0] = -Math.abs(d.v[0]) * 0.5; }
if (d.p[2] < d.zmin) { d.p[2] = d.zmin; d.v[2] = Math.abs(d.v[2]) * 0.5; }
if (d.p[2] > d.zmax) { d.p[2] = d.zmax; d.v[2] = -Math.abs(d.v[2]) * 0.5; }
}
// 멈추기: 법선 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 정확히 위를 보도록 남은 각도의 SETTLE_RATE 만큼씩 돌린다
function settleStep(d) {
var k = topIndex(d);
d.top = FACE_VAL[k];
var nw = matVec(d.R, FACE_N[k]);
var ang = Math.acos(clamp(nw[1], -1, 1));
if (ang > 1e-4) {
var ax = unit(cross(nw, [0, 1, 0]));
d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
}
d.R = orthonormalize(matMul(rotAxis([0, 1, 0], d.w[1] * dtS), d.R));
d.w[1] *= 0.88;
d.v[0] *= 0.86; d.v[2] *= 0.86; d.v[1] = 0;
d.p[0] += d.v[0] * dtS; d.p[2] += d.v[2] * dtS;
d.p[1] += (DIE_HALF - d.p[1]) * 0.22;
wallStep(d);
if (ang < 0.004 && Math.abs(d.p[1] - DIE_HALF) < 0.01) {
d.p[1] = DIE_HALF;
var q = topIndex(d);
d.R = orthonormalize(matMul(rotAxis(unit(cross(matVec(d.R, FACE_N[q]), [0, 1, 0])), Math.acos(clamp(matVec(d.R, FACE_N[q])[1], -1, 1))), d.R));
d.phase = PH_HOLD; d.hold = 0; d.result = FACE_VAL[q]; d.top = d.result;
}
}
function stepDie(d) {
if (d.phase === PH_FLY) {
d.t += FIXED_DT;
d.v[1] -= GRAVITY * dtS;
d.p[0] += d.v[0] * dtS; d.p[1] += d.v[1] * dtS; d.p[2] += d.v[2] * dtS;
spinStep(d, dtS);
d.w[0] *= SPIN_DAMPING; d.w[1] *= SPIN_DAMPING; d.w[2] *= SPIN_DAMPING;
wallStep(d);
floorStep(d);
var sp = Math.hypot(d.v[0], d.v[1], d.v[2]);
if (d.t > SETTLE_AFTER_MS || (d.bounces >= 3 && sp < 1.6)) d.phase = PH_SETTLE;
} else if (d.phase === PH_SETTLE) {
d.t += FIXED_DT;
settleStep(d);
} else if (d.phase === PH_HOLD) {
d.hold += FIXED_DT;
}
}
function runUntilHold(d) {
for (var i = 0; i < 1200 && d.phase !== PH_HOLD; i += 1) stepDie(d);
return d.result;
}
// ── 그리기 ──
function rgb(c, k) {
return 'rgb(' + Math.min(255, Math.round(c[0] * k)) + ',' + Math.min(255, Math.round(c[1] * k)) + ',' + Math.min(255, Math.round(c[2] * k)) + ')';
}
function quad(c, u, v, s) {
var o = [];
var sg = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
for (var i = 0; i < 4; i += 1) {
o.push(scr([c[0] + (u[0] * sg[i][0] + v[0] * sg[i][1]) * s * DIE_HALF, c[1] + (u[1] * sg[i][0] + v[1] * sg[i][1]) * s * DIE_HALF, c[2] + (u[2] * sg[i][0] + v[2] * sg[i][1]) * s * DIE_HALF]));
}
return o;
}
function fillPoly(pts, color, edge) {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
if (edge > 0) { ctx.strokeStyle = color; ctx.lineWidth = edge; ctx.stroke(); }
}
function drawDie(d, hi, alpha) {
var pal = DICE_PAL[d.pal % DICE_PAL.length];
ctx.globalAlpha = alpha;
ctx.lineJoin = 'round';
for (var f = 0; f < 6; f += 1) {
var nw = matVec(d.R, FACE_N[f]);
var cw = [d.p[0] + nw[0] * DIE_HALF, d.p[1] + nw[1] * DIE_HALF, d.p[2] + nw[2] * DIE_HALF];
var cc = camPoint(cw), nc = camDir(nw);
if (nc[0] * cc[0] + nc[1] * cc[1] + nc[2] * cc[2] >= 0) continue;
var uw = matVec(d.R, FACE_U[f]), vw = matVec(d.R, FACE_V[f]);
var lit = 0.66 + 0.34 * Math.max(0, dot(nw, LIGHT));
var k0 = scr(cw)[2];
fillPoly(quad(cw, uw, vw, 1), rgb(pal.edge, lit), k0 * 0.1);
fillPoly(quad(cw, uw, vw, 0.84), rgb(pal.face, lit), k0 * 0.16);
if (f === hi) fillPoly(quad(cw, uw, vw, 0.84), 'rgba(255,255,255,0.4)', 0);
var val = FACE_VAL[f];
for (var pi = 0; pi < PIPS[val].length; pi += 1) {
var a = PIPS[val][pi][0] * 0.5 * DIE_HALF, b = PIPS[val][pi][1] * 0.5 * DIE_HALF;
var ring = [];
for (var j = 0; j < 10; j += 1) {
var an = j * 0.62832, rr = 0.17 * DIE_HALF;
var ca = Math.cos(an) * rr, sa = Math.sin(an) * rr;
ring.push(scr([cw[0] + uw[0] * (a + ca) + vw[0] * (b + sa) + nw[0] * 0.004, cw[1] + uw[1] * (a + ca) + vw[1] * (b + sa) + nw[1] * 0.004, cw[2] + uw[2] * (a + ca) + vw[2] * (b + sa) + nw[2] * 0.004]));
}
fillPoly(ring, pal.pip, 0);
}
}
ctx.globalAlpha = 1;
}
function drawShadow(d, alpha) {
var s = scr([d.p[0], 0, d.p[2]]);
var h = Math.max(0, d.p[1] - DIE_HALF);
var r = DIE_HALF * s[2] * (1.2 + h * 0.18);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.42);
ctx.beginPath();
ctx.arc(0, 0, r, 0, 6.2832);
ctx.fillStyle = 'rgba(30,60,60,' + (0.34 * alpha / (1 + h * 0.7)) + ')';
ctx.fill();
ctx.restore();
}
var FELT_DOTS = [];
(function () {
var fr = seedRand(99);
for (var i = 0; i < 70; i += 1) FELT_DOTS.push([(fr() * 2 - 1) * TABLE_X, TABLE_Z0 + fr() * (TABLE_Z1 - TABLE_Z0), 0.04 + fr() * 0.08]);
})();
function tablePts(m) {
return [scr([-TABLE_X - m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z1 + m]), scr([-TABLE_X - m, 0, TABLE_Z1 + m])];
}
function drawStage(lightX, lightK) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var bg = ctx.createRadialGradient(160, 80, 10, 160, 80, 210);
bg.addColorStop(0, '#3f3764');
bg.addColorStop(1, '#221d36');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
fillPoly(tablePts(0.5), '#8e80c8', 0);
var tp = tablePts(0);
var g = ctx.createLinearGradient(0, tp[0][1], 0, tp[2][1]);
g.addColorStop(0, '#9fdcc3');
g.addColorStop(1, '#78c8aa');
fillPoly(tp, g, 0);
for (var i = 0; i < FELT_DOTS.length; i += 1) {
var s = scr([FELT_DOTS[i][0], 0, FELT_DOTS[i][1]]);
ctx.beginPath();
ctx.arc(s[0], s[1], FELT_DOTS[i][2] * s[2], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,0.12)';
ctx.fill();
}
var lp = scr([lightX, 0, 1]);
var lg = ctx.createRadialGradient(lp[0], lp[1], 4, lp[0], lp[1], 120);
lg.addColorStop(0, 'rgba(255,255,255,' + (0.3 * lightK) + ')');
lg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.save();
ctx.beginPath();
ctx.moveTo(tp[0][0], tp[0][1]); ctx.lineTo(tp[1][0], tp[1][1]); ctx.lineTo(tp[2][0], tp[2][1]); ctx.lineTo(tp[3][0], tp[3][1]);
ctx.closePath();
ctx.clip();
ctx.fillStyle = lg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.restore();
}
function drawChip(x, y, s, value, pal, mark) {
var p = DICE_PAL[pal % DICE_PAL.length];
ctx.fillStyle = value > 0 ? rgb(p.face, 1) : 'rgba(255,255,255,0.18)';
ctx.strokeStyle = mark ? '#7fe3bd' : rgb(p.edge, 1);
ctx.lineWidth = mark ? 1.8 : 1;
ctx.fillRect(x, y, s, s);
ctx.strokeRect(x, y, s, s);
for (var i = 0; i < PIPS[value].length; i += 1) {
ctx.beginPath();
ctx.arc(x + s / 2 + PIPS[value][i][0] * s * 0.26, y + s / 2 + PIPS[value][i][1] * s * 0.26, s * 0.1, 0, 6.2832);
ctx.fillStyle = p.pip;
ctx.fill();
}
}
// ── 상태 줄 (aria-live) ──
var stLabel = '', stNum = '', stAt = -9999;
function setStatus(label, num, force) {
if (label === stLabel && num === stNum) return;
if (!force && label === stLabel && elapsed - stAt < 450) return;
stLabel = label; stNum = num; stAt = elapsed;
labelEl.textContent = label;
numEl.textContent = num;
}
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
// 기법: 주사위 세 개가 서로 다른 시드의 난수열(DICE_SEED + 100 * 번호)로 차례로 던져지고, 깊이(카메라 z)가 먼 것부터 그린다.
// 멈출 때마다 읽은 윗면이 판 가장자리 칩 줄에 한 칸씩 적히고, 펠트 위 조명 웅덩이가 천천히 쓸고 지나간다.
var T_RESULT = '結果';
var T_ROLLING = '転がり中';
var DIE_COUNT = 3;
var LANES = [[-3.5, -1.2], [-1.1, 1.1], [1.2, 3.5]];
var dice = [];
var rngs = [];
var startAt = [0, 520, 1040];
var board = [];
var boardPal = [];
var recordedHold = [-1, -1, -1];
var tossCount = [0, 0, 0];
var firstAt = 0;
function throwOne(k) {
var d = dice[k];
var lane = LANES[k];
launch(d, rngs[k], (lane[0] + lane[1]) / 2 - 0.4, rngs[k]() * 2.2 - 1.2, 0.6, 0.2, 2.8, 14);
tossCount[k] += 1;
}
function sceneInit() {
for (var k = 0; k < DIE_COUNT; k += 1) {
dice.push(newDie(k, LANES[k][0], LANES[k][1], -1.6, 2.2));
rngs.push(seedRand(DICE_SEED + 100 * (k + 1)));
}
}
function sceneRoll(manual) {
firstAt = elapsed;
for (var k = 0; k < DIE_COUNT; k += 1) {
startAt[k] = elapsed + (reduce ? 0 : k * 520);
if (manual || dice[k].phase === PH_IDLE) { dice[k].phase = PH_IDLE; }
}
setStatus(T_ROLLING, '', true);
}
function statusText() {
var n = board.length;
var out = [];
for (var i = Math.max(0, n - DIE_COUNT); i < n; i += 1) out.push(String(board[i]));
return out.join(' · ');
}
function sceneDone() {
for (var k = 0; k < DIE_COUNT; k += 1) if (dice[k].phase !== PH_HOLD) return false;
return true;
}
function sceneFinish() { setStatus(T_RESULT, statusText(), true); }
function stepWorld() {
for (var k = 0; k < DIE_COUNT; k += 1) {
var d = dice[k];
if (d.phase === PH_IDLE && elapsed >= startAt[k]) throwOne(k);
stepDie(d);
if (d.phase === PH_HOLD && recordedHold[k] !== tossCount[k]) {
recordedHold[k] = tossCount[k];
board.push(d.result); boardPal.push(k);
if (board.length > 12) { board.shift(); boardPal.shift(); }
setStatus(T_RESULT, statusText(), true);
}
if (d.phase === PH_HOLD && demo && d.hold >= HOLD_MS + k * 160) { d.phase = PH_IDLE; startAt[k] = elapsed; }
}
}
function drawWorld() {
drawStage(Math.sin(elapsed / 1700) * 3, 1);
var live = [];
for (var k = 0; k < DIE_COUNT; k += 1) if (dice[k].phase !== PH_IDLE) live.push(dice[k]);
live.sort(function (a, b) { return camPoint(b.p)[2] - camPoint(a.p)[2]; });
for (var i = 0; i < live.length; i += 1) drawShadow(live[i], 1);
for (var j = 0; j < live.length; j += 1) drawDie(live[j], -1, 1);
var n = board.length;
for (var c = 0; c < 6; c += 1) {
var idx = n - 6 + c;
var v = idx >= 0 ? board[idx] : 0;
drawChip(100 + c * 19, STRIP_BASE - 12, 14, v, idx >= 0 ? boardPal[idx] : 0, false);
}
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs = -1;
var acc = 0;
function tick(ts) {
if (lastTs < 0) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
// 모션 줄이기: 애니메이션 없이 결과가 나올 때까지 같은 계산을 한 번에 돌려 최종 장면만 그린다
function runToDone() {
var i = 0;
do { elapsed += FIXED_DT; stepWorld(); i += 1; } while (i < 1500 && !sceneDone());
sceneFinish();
drawWorld();
}
function onRollClick() {
stopDemo();
sceneRoll(true);
if (reduce) runToDone();
}
sceneInit();
sceneRoll(false);
if (reduce) {
runToDone();
} else {
for (var w0 = 0; w0 < 8; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
}
btn.addEventListener('click', onRollClick);
cleaners.push(function () { btn.removeEventListener('click', onRollClick); });
fit();
window.addEventListener('resize', fit);
cleaners.push(function () { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
})();import { useEffect, useRef } from "react";
import styles from "./SpinDiceSceneHeroTable.module.scss";
export default function SpinDiceSceneHeroTable() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const numRef = useRef<HTMLElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current, canvasEl = canvasRef.current, l0 = labelRef.current, n0 = numRef.current, b0 = btnRef.current;
if (!rootEl || !canvasEl || !l0 || !n0 || !b0) return;
const root: HTMLDivElement = rootEl;
const canvas: HTMLCanvasElement = canvasEl;
const ctx0 = canvas.getContext("2d");
if (!ctx0) return;
const ctx: CanvasRenderingContext2D = ctx0;
const wrap: HTMLElement = canvas.parentElement ?? root;
const labelEl: HTMLElement = l0;
const numEl: HTMLElement = n0;
const btn: HTMLButtonElement = b0;
type Vec = number[];
type Mat = number[];
type RandFn = () => number;
type Fn = () => void;
type Paint = string | CanvasGradient;
type Pal = { face: number[]; edge: number[]; pip: string };
type Die = { p: Vec; v: Vec; R: Mat; w: Vec; pal: number; phase: number; t: number; hold: number; bounces: number; result: number; top: number; hit: number; hx: number; hz: number; xmin: number; xmax: number; zmin: number; zmax: number };
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var DICE_RESTITUTION = 0.45;
var SPIN_DAMPING = 0.985;
var DICE_SEED = 7;
var GRAVITY = 22;
var DIE_HALF = 1;
var INERTIA = 2 * DIE_HALF * DIE_HALF / 3;
var FLOOR_FRICTION = 0.35;
var SETTLE_AFTER_MS = 2000;
var SETTLE_RATE = 0.16;
var HOLD_MS = 700;
var CAM_Y = 5.6, CAM_BACK = 12, FOCAL = 275, VIEW_CX = 160, VIEW_CY = 82;
var TABLE_X = 4.4, TABLE_Z0 = -3, TABLE_Z1 = 5;
var PH_FLY = 0, PH_SETTLE = 1, PH_HOLD = 2, PH_IDLE = 3;
var STRIP_BASE = 128;
var BAR_MAX = 18;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo(): void { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed: number): RandFn {
var s = seed >>> 0;
return function (): number { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; }
function dot(a: Vec, b: Vec): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function cross(a: Vec, b: Vec): Vec { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function unit(a: Vec): Vec { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
function matMul(a: Mat, b: Mat): Mat {
var o: Mat = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (var i = 0; i < 3; i += 1) {
for (var j = 0; j < 3; j += 1) o[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
}
return o;
}
function matVec(m: Mat, v: Vec): Vec {
return [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]];
}
// 로드리게스 공식: 단위 축 ax 둘레로 ang 만큼 도는 회전 행렬
function rotAxis(ax: Vec, ang: number): Mat {
var c = Math.cos(ang), s = Math.sin(ang), t = 1 - c, x = ax[0], y = ax[1], z = ax[2];
return [t * x * x + c, t * x * y - s * z, t * x * z + s * y,
t * x * y + s * z, t * y * y + c, t * y * z - s * x,
t * x * z - s * y, t * y * z + s * x, t * z * z + c];
}
function orthonormalize(m: Mat): Mat {
var a = unit([m[0], m[1], m[2]]);
var k = a[0] * m[3] + a[1] * m[4] + a[2] * m[5];
var b = unit([m[3] - a[0] * k, m[4] - a[1] * k, m[5] - a[2] * k]);
var c = cross(a, b);
return [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]];
}
function randAxis(rnd: RandFn): Vec { return unit([rnd() * 2 - 1, rnd() * 2 - 1, rnd() * 2 - 1]); }
var PITCH = Math.atan2(CAM_Y, CAM_BACK);
var CAM_S = Math.sin(PITCH), CAM_C = Math.cos(PITCH);
function camPoint(p: Vec): Vec {
var qy = p[1] - CAM_Y, qz = p[2] + CAM_BACK;
return [p[0], qy * CAM_C + qz * CAM_S, -qy * CAM_S + qz * CAM_C];
}
function camDir(v: Vec): Vec { return [v[0], v[1] * CAM_C + v[2] * CAM_S, -v[1] * CAM_S + v[2] * CAM_C]; }
function scr(p: Vec): Vec {
var c = camPoint(p);
var k = FOCAL / c[2];
return [VIEW_CX + c[0] * k, VIEW_CY - c[1] * k, k];
}
var FACE_N: Vec[] = [[0, 1, 0], [0, -1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1]];
var FACE_U: Vec[] = [[1, 0, 0], [1, 0, 0], [0, 1, 0], [0, 1, 0], [1, 0, 0], [1, 0, 0]];
var FACE_V: Vec[] = [[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]];
var FACE_VAL: number[] = [1, 6, 2, 5, 3, 4];
var PIPS: number[][][] = [[], [[0, 0]], [[-1, -1], [1, 1]], [[-1, -1], [0, 0], [1, 1]],
[[-1, -1], [1, -1], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
[[-1, -1], [-1, 0], [-1, 1], [1, -1], [1, 0], [1, 1]]];
var DICE_PAL: Pal[] = [
{ face: [255, 214, 226], edge: [240, 160, 186], pip: '#4b3a63' },
{ face: [200, 232, 255], edge: [140, 190, 236], pip: '#3b4a6b' },
{ face: [224, 212, 255], edge: [170, 150, 232], pip: '#46386b' },
{ face: [255, 240, 196], edge: [236, 206, 120], pip: '#5a4636' }
];
var LIGHT: Vec = unit([-0.45, 0.85, -0.35]);
function newDie(pal: number, xmin: number, xmax: number, zmin: number, zmax: number): Die {
return { p: [0, DIE_HALF, 0], v: [0, 0, 0], R: [1, 0, 0, 0, 1, 0, 0, 0, 1], w: [0, 0, 0], pal: pal, phase: PH_IDLE, t: 0, hold: 0,
bounces: 0, result: 0, top: 1, hit: 0, hx: 0, hz: 0, xmin: xmin, xmax: xmax, zmin: zmin, zmax: zmax };
}
function topIndex(d: Die): number {
var best = 0, by = -9;
for (var f = 0; f < 6; f += 1) {
var ny = d.R[3] * FACE_N[f][0] + d.R[4] * FACE_N[f][1] + d.R[5] * FACE_N[f][2];
if (ny > by) { by = ny; best = f; }
}
return best;
}
// 던지기: 시작 위치·속도·회전을 시드 수열에서 순서대로 뽑는다 (뽑는 순서가 곧 재현 규칙)
function launch(d: Die, rnd: RandFn, x: number, z: number, vx: number, vz: number, vy0: number, spin: number): void {
d.p = [x, 2.4, z];
d.v = [vx + (rnd() - 0.5) * 1.6, vy0 + rnd() * 2.2, vz + (rnd() - 0.5) * 1.6];
var ax = randAxis(rnd);
var s = spin * (0.6 + rnd() * 0.8);
d.w = [ax[0] * s, ax[1] * s, ax[2] * s];
d.R = rotAxis(randAxis(rnd), rnd() * 6.283);
d.phase = PH_FLY; d.t = 0; d.hold = 0; d.bounces = 0; d.result = 0; d.hit = 0;
}
function spinStep(d: Die, dt: number): void {
var wl = Math.hypot(d.w[0], d.w[1], d.w[2]);
if (wl > 1e-5) d.R = matMul(rotAxis([d.w[0] / wl, d.w[1] / wl, d.w[2] / wl], wl * dt), d.R);
}
function floorStep(d: Die): void {
var mi = 0, my = 99;
var rx = 0, ry = 0, rz = 0;
for (var i = 0; i < 8; i += 1) {
var lx = (i & 1) ? DIE_HALF : -DIE_HALF, ly = (i & 2) ? DIE_HALF : -DIE_HALF, lz = (i & 4) ? DIE_HALF : -DIE_HALF;
var wy = d.R[3] * lx + d.R[4] * ly + d.R[5] * lz;
if (wy < my) { my = wy; mi = i; rx = d.R[0] * lx + d.R[1] * ly + d.R[2] * lz; ry = wy; rz = d.R[6] * lx + d.R[7] * ly + d.R[8] * lz; }
}
var low = d.p[1] + my;
if (low >= 0) return;
d.p[1] -= low;
var cvx = d.v[0] + d.w[1] * rz - d.w[2] * ry;
var cvy = d.v[1] + d.w[2] * rx - d.w[0] * rz;
var cvz = d.v[2] + d.w[0] * ry - d.w[1] * rx;
if (cvy >= 0) return;
var j = -(1 + DICE_RESTITUTION) * cvy / (1 + (rx * rx + rz * rz) / INERTIA);
d.v[1] += j;
d.w[0] += -rz * j / INERTIA; d.w[2] += rx * j / INERTIA;
var tl = Math.hypot(cvx, cvz);
if (tl > 1e-4) {
var jt = Math.min(FLOOR_FRICTION * j, tl * 0.5);
var tx = -cvx / tl * jt, tz = -cvz / tl * jt;
d.v[0] += tx; d.v[2] += tz;
d.w[0] += ry * tz / INERTIA; d.w[1] += (rz * tx - rx * tz) / INERTIA; d.w[2] += -ry * tx / INERTIA;
}
if (cvy < -1.2) { d.bounces += 1; d.hit = -cvy; d.hx = d.p[0] + rx; d.hz = d.p[2] + rz; }
}
function wallStep(d: Die): void {
if (d.p[0] < d.xmin) { d.p[0] = d.xmin; d.v[0] = Math.abs(d.v[0]) * 0.5; }
if (d.p[0] > d.xmax) { d.p[0] = d.xmax; d.v[0] = -Math.abs(d.v[0]) * 0.5; }
if (d.p[2] < d.zmin) { d.p[2] = d.zmin; d.v[2] = Math.abs(d.v[2]) * 0.5; }
if (d.p[2] > d.zmax) { d.p[2] = d.zmax; d.v[2] = -Math.abs(d.v[2]) * 0.5; }
}
// 멈추기: 법선 y 가 가장 큰 면을 윗면으로 읽고, 그 면이 정확히 위를 보도록 남은 각도의 SETTLE_RATE 만큼씩 돌린다
function settleStep(d: Die): void {
var k = topIndex(d);
d.top = FACE_VAL[k];
var nw = matVec(d.R, FACE_N[k]);
var ang = Math.acos(clamp(nw[1], -1, 1));
if (ang > 1e-4) {
var ax = unit(cross(nw, [0, 1, 0]));
d.R = orthonormalize(matMul(rotAxis(ax, ang * SETTLE_RATE), d.R));
}
d.R = orthonormalize(matMul(rotAxis([0, 1, 0], d.w[1] * dtS), d.R));
d.w[1] *= 0.88;
d.v[0] *= 0.86; d.v[2] *= 0.86; d.v[1] = 0;
d.p[0] += d.v[0] * dtS; d.p[2] += d.v[2] * dtS;
d.p[1] += (DIE_HALF - d.p[1]) * 0.22;
wallStep(d);
if (ang < 0.004 && Math.abs(d.p[1] - DIE_HALF) < 0.01) {
d.p[1] = DIE_HALF;
var q = topIndex(d);
d.R = orthonormalize(matMul(rotAxis(unit(cross(matVec(d.R, FACE_N[q]), [0, 1, 0])), Math.acos(clamp(matVec(d.R, FACE_N[q])[1], -1, 1))), d.R));
d.phase = PH_HOLD; d.hold = 0; d.result = FACE_VAL[q]; d.top = d.result;
}
}
function stepDie(d: Die): void {
if (d.phase === PH_FLY) {
d.t += FIXED_DT;
d.v[1] -= GRAVITY * dtS;
d.p[0] += d.v[0] * dtS; d.p[1] += d.v[1] * dtS; d.p[2] += d.v[2] * dtS;
spinStep(d, dtS);
d.w[0] *= SPIN_DAMPING; d.w[1] *= SPIN_DAMPING; d.w[2] *= SPIN_DAMPING;
wallStep(d);
floorStep(d);
var sp = Math.hypot(d.v[0], d.v[1], d.v[2]);
if (d.t > SETTLE_AFTER_MS || (d.bounces >= 3 && sp < 1.6)) d.phase = PH_SETTLE;
} else if (d.phase === PH_SETTLE) {
d.t += FIXED_DT;
settleStep(d);
} else if (d.phase === PH_HOLD) {
d.hold += FIXED_DT;
}
}
function runUntilHold(d: Die): number {
for (var i = 0; i < 1200 && d.phase !== PH_HOLD; i += 1) stepDie(d);
return d.result;
}
// ── 그리기 ──
function rgb(c: number[], k: number): string {
return 'rgb(' + Math.min(255, Math.round(c[0] * k)) + ',' + Math.min(255, Math.round(c[1] * k)) + ',' + Math.min(255, Math.round(c[2] * k)) + ')';
}
function quad(c: Vec, u: Vec, v: Vec, s: number): number[][] {
var o: number[][] = [];
var sg = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
for (var i = 0; i < 4; i += 1) {
o.push(scr([c[0] + (u[0] * sg[i][0] + v[0] * sg[i][1]) * s * DIE_HALF, c[1] + (u[1] * sg[i][0] + v[1] * sg[i][1]) * s * DIE_HALF, c[2] + (u[2] * sg[i][0] + v[2] * sg[i][1]) * s * DIE_HALF]));
}
return o;
}
function fillPoly(pts: number[][], color: Paint, edge: number): void {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
if (edge > 0) { ctx.strokeStyle = color; ctx.lineWidth = edge; ctx.stroke(); }
}
function drawDie(d: Die, hi: number, alpha: number): void {
var pal = DICE_PAL[d.pal % DICE_PAL.length];
ctx.globalAlpha = alpha;
ctx.lineJoin = 'round';
for (var f = 0; f < 6; f += 1) {
var nw = matVec(d.R, FACE_N[f]);
var cw: Vec = [d.p[0] + nw[0] * DIE_HALF, d.p[1] + nw[1] * DIE_HALF, d.p[2] + nw[2] * DIE_HALF];
var cc = camPoint(cw), nc = camDir(nw);
if (nc[0] * cc[0] + nc[1] * cc[1] + nc[2] * cc[2] >= 0) continue;
var uw = matVec(d.R, FACE_U[f]), vw = matVec(d.R, FACE_V[f]);
var lit = 0.66 + 0.34 * Math.max(0, dot(nw, LIGHT));
var k0 = scr(cw)[2];
fillPoly(quad(cw, uw, vw, 1), rgb(pal.edge, lit), k0 * 0.1);
fillPoly(quad(cw, uw, vw, 0.84), rgb(pal.face, lit), k0 * 0.16);
if (f === hi) fillPoly(quad(cw, uw, vw, 0.84), 'rgba(255,255,255,0.4)', 0);
var val = FACE_VAL[f];
for (var pi = 0; pi < PIPS[val].length; pi += 1) {
var a = PIPS[val][pi][0] * 0.5 * DIE_HALF, b = PIPS[val][pi][1] * 0.5 * DIE_HALF;
var ring: number[][] = [];
for (var j = 0; j < 10; j += 1) {
var an = j * 0.62832, rr = 0.17 * DIE_HALF;
var ca = Math.cos(an) * rr, sa = Math.sin(an) * rr;
ring.push(scr([cw[0] + uw[0] * (a + ca) + vw[0] * (b + sa) + nw[0] * 0.004, cw[1] + uw[1] * (a + ca) + vw[1] * (b + sa) + nw[1] * 0.004, cw[2] + uw[2] * (a + ca) + vw[2] * (b + sa) + nw[2] * 0.004]));
}
fillPoly(ring, pal.pip, 0);
}
}
ctx.globalAlpha = 1;
}
function drawShadow(d: Die, alpha: number): void {
var s = scr([d.p[0], 0, d.p[2]]);
var h = Math.max(0, d.p[1] - DIE_HALF);
var r = DIE_HALF * s[2] * (1.2 + h * 0.18);
ctx.save();
ctx.translate(s[0], s[1]);
ctx.scale(1, 0.42);
ctx.beginPath();
ctx.arc(0, 0, r, 0, 6.2832);
ctx.fillStyle = 'rgba(30,60,60,' + (0.34 * alpha / (1 + h * 0.7)) + ')';
ctx.fill();
ctx.restore();
}
var FELT_DOTS: number[][] = [];
(function (): void {
var fr = seedRand(99);
for (var i = 0; i < 70; i += 1) FELT_DOTS.push([(fr() * 2 - 1) * TABLE_X, TABLE_Z0 + fr() * (TABLE_Z1 - TABLE_Z0), 0.04 + fr() * 0.08]);
})();
function tablePts(m: number): number[][] {
return [scr([-TABLE_X - m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z0 - m]), scr([TABLE_X + m, 0, TABLE_Z1 + m]), scr([-TABLE_X - m, 0, TABLE_Z1 + m])];
}
function drawStage(lightX: number, lightK: number): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var bg = ctx.createRadialGradient(160, 80, 10, 160, 80, 210);
bg.addColorStop(0, '#3f3764');
bg.addColorStop(1, '#221d36');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
fillPoly(tablePts(0.5), '#8e80c8', 0);
var tp = tablePts(0);
var g = ctx.createLinearGradient(0, tp[0][1], 0, tp[2][1]);
g.addColorStop(0, '#9fdcc3');
g.addColorStop(1, '#78c8aa');
fillPoly(tp, g, 0);
for (var i = 0; i < FELT_DOTS.length; i += 1) {
var s = scr([FELT_DOTS[i][0], 0, FELT_DOTS[i][1]]);
ctx.beginPath();
ctx.arc(s[0], s[1], FELT_DOTS[i][2] * s[2], 0, 6.2832);
ctx.fillStyle = 'rgba(40,110,90,0.12)';
ctx.fill();
}
var lp = scr([lightX, 0, 1]);
var lg = ctx.createRadialGradient(lp[0], lp[1], 4, lp[0], lp[1], 120);
lg.addColorStop(0, 'rgba(255,255,255,' + (0.3 * lightK) + ')');
lg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.save();
ctx.beginPath();
ctx.moveTo(tp[0][0], tp[0][1]); ctx.lineTo(tp[1][0], tp[1][1]); ctx.lineTo(tp[2][0], tp[2][1]); ctx.lineTo(tp[3][0], tp[3][1]);
ctx.closePath();
ctx.clip();
ctx.fillStyle = lg;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.restore();
}
function drawChip(x: number, y: number, s: number, value: number, pal: number, mark: boolean): void {
var p = DICE_PAL[pal % DICE_PAL.length];
ctx.fillStyle = value > 0 ? rgb(p.face, 1) : 'rgba(255,255,255,0.18)';
ctx.strokeStyle = mark ? '#7fe3bd' : rgb(p.edge, 1);
ctx.lineWidth = mark ? 1.8 : 1;
ctx.fillRect(x, y, s, s);
ctx.strokeRect(x, y, s, s);
for (var i = 0; i < PIPS[value].length; i += 1) {
ctx.beginPath();
ctx.arc(x + s / 2 + PIPS[value][i][0] * s * 0.26, y + s / 2 + PIPS[value][i][1] * s * 0.26, s * 0.1, 0, 6.2832);
ctx.fillStyle = p.pip;
ctx.fill();
}
}
// ── 상태 줄 (aria-live) ──
var stLabel = '', stNum = '', stAt = -9999;
function setStatus(label: string, num: string, force: boolean): void {
if (label === stLabel && num === stNum) return;
if (!force && label === stLabel && elapsed - stAt < 450) return;
stLabel = label; stNum = num; stAt = elapsed;
labelEl.textContent = label;
numEl.textContent = num;
}
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Fn[] = [];
// 기법: 주사위 세 개가 서로 다른 시드의 난수열(DICE_SEED + 100 * 번호)로 차례로 던져지고, 깊이(카메라 z)가 먼 것부터 그린다.
// 멈출 때마다 읽은 윗면이 판 가장자리 칩 줄에 한 칸씩 적히고, 펠트 위 조명 웅덩이가 천천히 쓸고 지나간다.
var T_RESULT = '결과';
var T_ROLLING = '굴리는 중';
var DIE_COUNT = 3;
var LANES: number[][] = [[-3.5, -1.2], [-1.1, 1.1], [1.2, 3.5]];
var dice: Die[] = [];
var rngs: RandFn[] = [];
var startAt: number[] = [0, 520, 1040];
var board: number[] = [];
var boardPal: number[] = [];
var recordedHold: number[] = [-1, -1, -1];
var tossCount: number[] = [0, 0, 0];
var firstAt = 0;
function throwOne(k: number): void {
var d = dice[k];
var lane = LANES[k];
launch(d, rngs[k], (lane[0] + lane[1]) / 2 - 0.4, rngs[k]() * 2.2 - 1.2, 0.6, 0.2, 2.8, 14);
tossCount[k] += 1;
}
function sceneInit(): void {
for (var k = 0; k < DIE_COUNT; k += 1) {
dice.push(newDie(k, LANES[k][0], LANES[k][1], -1.6, 2.2));
rngs.push(seedRand(DICE_SEED + 100 * (k + 1)));
}
}
function sceneRoll(manual: boolean): void {
firstAt = elapsed;
for (var k = 0; k < DIE_COUNT; k += 1) {
startAt[k] = elapsed + (reduce ? 0 : k * 520);
if (manual || dice[k].phase === PH_IDLE) { dice[k].phase = PH_IDLE; }
}
setStatus(T_ROLLING, '', true);
}
function statusText(): string {
var n = board.length;
var out: string[] = [];
for (var i = Math.max(0, n - DIE_COUNT); i < n; i += 1) out.push(String(board[i]));
return out.join(' · ');
}
function sceneDone(): boolean {
for (var k = 0; k < DIE_COUNT; k += 1) if (dice[k].phase !== PH_HOLD) return false;
return true;
}
function sceneFinish(): void { setStatus(T_RESULT, statusText(), true); }
function stepWorld(): void {
for (var k = 0; k < DIE_COUNT; k += 1) {
var d = dice[k];
if (d.phase === PH_IDLE && elapsed >= startAt[k]) throwOne(k);
stepDie(d);
if (d.phase === PH_HOLD && recordedHold[k] !== tossCount[k]) {
recordedHold[k] = tossCount[k];
board.push(d.result); boardPal.push(k);
if (board.length > 12) { board.shift(); boardPal.shift(); }
setStatus(T_RESULT, statusText(), true);
}
if (d.phase === PH_HOLD && demo && d.hold >= HOLD_MS + k * 160) { d.phase = PH_IDLE; startAt[k] = elapsed; }
}
}
function drawWorld(): void {
drawStage(Math.sin(elapsed / 1700) * 3, 1);
var live: Die[] = [];
for (var k = 0; k < DIE_COUNT; k += 1) if (dice[k].phase !== PH_IDLE) live.push(dice[k]);
live.sort(function (a: Die, b: Die): number { return camPoint(b.p)[2] - camPoint(a.p)[2]; });
for (var i = 0; i < live.length; i += 1) drawShadow(live[i], 1);
for (var j = 0; j < live.length; j += 1) drawDie(live[j], -1, 1);
var n = board.length;
for (var c = 0; c < 6; c += 1) {
var idx = n - 6 + c;
var v = idx >= 0 ? board[idx] : 0;
drawChip(100 + c * 19, STRIP_BASE - 12, 14, v, idx >= 0 ? boardPal[idx] : 0, false);
}
}
function fit(): void {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs = -1;
var acc = 0;
function tick(ts: number): void {
if (lastTs < 0) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
// 모션 줄이기: 애니메이션 없이 결과가 나올 때까지 같은 계산을 한 번에 돌려 최종 장면만 그린다
function runToDone(): void {
var i = 0;
do { elapsed += FIXED_DT; stepWorld(); i += 1; } while (i < 1500 && !sceneDone());
sceneFinish();
drawWorld();
}
function onRollClick(): void {
stopDemo();
sceneRoll(true);
if (reduce) runToDone();
}
sceneInit();
sceneRoll(false);
if (reduce) {
runToDone();
} else {
for (var w0 = 0; w0 < 8; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
}
btn.addEventListener('click', onRollClick);
cleaners.push(function (): void { btn.removeEventListener('click', onRollClick); });
fit();
window.addEventListener('resize', fit);
cleaners.push(function (): void { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy(): void {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
return destroy;
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} role="img" aria-label="펠트 바닥 위로 주사위 세 개가 서로 다른 시드로 번갈아 던져지고 멈추기를 되풀이하며, 결과 눈이 판 아래 칩 줄에 차례로 적히는 히어로 장면." />
<output className={styles.bp__status} aria-live="polite"><span ref={labelRef}>굴리는 중</span> <b ref={numRef} /></output>
<button type="button" className={styles.bp__btn} ref={btnRef}>굴리기</button>
<p className={styles.bp__caption}>펠트 바닥 위로 주사위 세 개가 서로 다른 시드로 번갈아 던져지고 멈추기를 되풀이하며, 결과 눈이 판 아래 칩 줄에 차례로 적히는 히어로 장면.</p>
</div>
</main>
);
}09 · ヒーロー背景のサイコロテーブル — 計測 累積面積 23.037% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function throwOne(k) {
var d = dice[k];
var lane = LANES[k];
launch(d, rngs[k], (lane[0] + lane[1]) / 2 - 0.4, rngs[k]() * 2.2 - 1.2, 0.6, 0.2, 2.8, 14);
tossCount[k] += 1;
}
function sceneInit() {
for (var k = 0; k < DIE_COUNT; k += 1) {
dice.push(newDie(k, LANES[k][0], LANES[k][1], -1.6, 2.2));
rngs.push(seedRand(DICE_SEED + 100 * (k + 1)));
}
}
値が場面を変える場所 — 反発係数・回転の減衰・シード
見た目を大きく変える値は三つに絞れます。02番の DICE_RESTITUTION は床で速度がどれだけ残るかを、03番の SPIN_DAMPING は一歩ごとに回転がどれだけ削られるかを、04番の DICE_SEED は投げる順番の全体を決めます。反発係数を上げると高さは長く残りますが、2秒のあいだに数えられる強い跳ねは13回から7回へむしろ減ることがあるので、この値は回数ではなく高さで読んでください。
九つの計測値を一つの表にまとめました。07番はカップとサイコロが一緒に大きく揺れるため面積が最も広く、03番は一つのサイコロが弱く投げられるので最も狭くなりますが、10%を下回る場面はありません。
| 項目 | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|
| 01 回転行列の3Dサイコロ | 13.8% | 72.4 | 23/23 |
| 02 放物線の跳ね | 23.3% | 61.8 | 21/23 |
| 03 上の目の読み取り | 13.4% | 65.7 | 18/23 |
| 04 シードの数列 | 18.2% | 66.1 | 19/23 |
| 05 二つの合計の棒 | 20.5% | 69.3 | 20/23 |
| 06 ほこりと跡 | 18.8% | 52.5 | 23/23 |
| 07 カップを振るノイズ | 24.3% | 68.4 | 23/23 |
| 08 平らなサイコロ | 15.7% | 48.1 | 23/23 |
| 09 ヒーローテーブル | 23.0% | 69.4 | 23/23 |
どこで壊れるか — 辺で立つ瞬間・狭い画面・二つのサイコロが重なるとき
いちばん危ないのは辺で立つ瞬間です。上の目の読み取りは法線yの最大値なので、二つの面のyがほぼ等しい斜めの姿勢では選ばれる面が一歩のあいだにも入れ替わり、コードは SETTLE_AFTER_MS か跳ねた回数の条件で止まる段階へ入るため、補正が向かう面が歩みの間で入れ替わり、倒れる向きが揺れて見えることがあります。投げた200回のうち止まらなかったものはなく、平均は110歩、いちばん長くかかったのは157歩でした。
狭い画面ではキャンバスが幅に比例して縮み、340px以下では状態行・ボタン・キャプションの文字だけが小さくなります。二つのサイコロが重なる場面では、05番と09番は衝突を解かずにレーンで避けさせているので境界で目の模様が互いに隠れることがあり、カメラから遠いサイコロから順に描くのが対策のすべてです。目の出方は偏っていて、シード7で200回投げると1から6が51・25・31・29・26・38回になるため、抽選や賭けの根拠には使わず演出にとどめてください。値を自分で変えながら確かめたいときは、zipをパスワード ky895umc で開いて使ってください。
変形3つとアクセシビリティ
| 変形 | 値 | 感じ方 |
|---|---|---|
| 反発係数を調整する | 02 DICE_RESTITUTION 0.45 → 0.7 | 値を上げると床に当たるたび速度が削られにくく、跳ねる高さが長く残る(同じ投げで一つ目と二つ目の跳ねの頂点が0.82・0.15から1.17・0.89へ、サイコロ半辺の単位)。下げると一、二度跳ねてすぐ寝る。 |
| 回転の減衰を調整する | 03 SPIN_DAMPING 0.985 → 0.96 | 値を下げると一歩ごとに回転がより削られ、同じ20回の平均で上の目の補正が始まる歩数が78から61へ早まり、1秒時点の角速度も3.8から2.7 rad/sへ下がる。上げると長く回る。 |
| シードを変える | 04 DICE_SEED 7 → 19 | シードを変えると投げる角度・速度・回転の順番が変わり、最初の六回の目が2・6・4・4・3・1から5・6・2・1・5・1へ変わる。同じ値なら常に同じ順番になる。 |
prefers-reduced-motionが有効なとき、キャンバスの場面は requestAnimationFrame のループを作りません。始めるときとボタンを押すたびに同じ計算を一度にまとめて回し、結果が確定した場面だけを描いて状態行に結果を書きます。08番はすべてのアニメーションを切ってボタンが面を即座に替え、01番は押すたびに回転行列へ新しい回転をすぐ掛けます。転がすボタンは標準の <button> なのでTabでフォーカスでき、EnterとSpaceがそのままクリックになり、結果は <output aria-live="polite"> の中に書かれて画面読み上げが読みます。
コードはサイコロの上の目を何で割り出しているのか?
上の目は、面の法線をワールド座標へ回したときにy成分がいちばん大きい面です。topIndex() は六つの法線に回転行列のy行を掛けて最大の値を探し、目は上+yが1・下-yが6・+xが2・-xが5・+zが3・-zが4と、向かい合う面の合計が7になるように並べてあります。同じ計算が01番では実時間の表示に、03番では止まる前の補正の向きに、02番以降では結果の確定に使われます。
キャンバス描画の根拠はCanvas APIのドキュメントとrequestAnimationFrameのドキュメントにあります。3DをキャンバスなしでCSSだけで作る道は【一行でコピペOK】CSS 3D アニメーション9選に、ピクセルまで計算するキャンバスの場面はhtml5 canvas アニメーション9種 — 水滴とブラックホールに、奥行きの層を重ねる背景はcss パララックス 背景 9選 — 奥行き3Dシーンにあります。
サイコロの場面を使う前によくある質問
サイコロを振るゲームにするには、何を足せばいいですか?
各場面は、止まったら目の数を result に確定して状態行に書くところまでしか入っていません。得点・順番・勝ち負けのルールは自分で足す必要があり、05番の合計計算と09番の結果チップの列が、そのルールをのせるのにいちばん向いた場所です。
サイコロ アニメーション 素材として画像や外部ライブラリは必要ですか?
必要ありません。08番はDOMとCSS、残りの八つはキャンバス2Dと一つのファイルのスクリプトだけで動き、外部のアドレスを呼ぶ行は一つもないので、zipを開けばそのまま動きます。
サイコロが長く跳ねすぎたり、すぐ寝てしまったりするときはどこを直しますか?
跳ね方は02番の DICE_RESTITUTION、回転が減る速さは03番の SPIN_DAMPING で決まり、止まる段階へ移る時点は SETTLE_AFTER_MS が決めます。同じ投げをもう一度見たいときは04番の DICE_SEED を固定すれば、毎回同じ並びが出ます。









