Dice Roll Animation: 9 Scenes with Top-Face Math
Throw, bounce, settle — one roll is just three verbs, and scene 03 reads the top-face number out of the last one by calculation.
See All Nine Dice Scenes Roll Right on This Page
Each canvas in the grid rolls by itself, and every one has a roll button for throwing again. Eight scenes draw on a canvas with no library, and one, scene 08, is plain DOM and CSS.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 3D Dice Turned by a Rotation Matrix
- 02 Dice Thrown on an Arc That Bounce on the Floor
- 03 Dice That Read Their Top Face as They Settle
- 04 Dice That Repeat the Same Sequence from a Seed
- 05 Two Dice Whose Sum Stacks into Bars
- 06 Dice That Leave Roll Marks and Dust on the Floor
- 07 Dice Shaken Inside a Cup
- 08 Flat 2D Dice Rolling by Face Swaps
- 09 Hero Background Dice Table
Dice Roll Animation: 9 Scenes Where One Rotation Matrix Decides the Top Face
A die's orientation is one 3×3 rotation matrix R, and every scene except 08 runs on top of it. Scene 01 keeps turning the matrix in mid air and hides the faces turned away from the camera, 02 adds gravity and floor impacts, and 03 slowly stands the matrix up so the winning face points straight up. Scene 04 draws each throw from a seeded sequence, and 05 adds up two dice.
Scene 06 leaves dust and marks where the die lands, 07 adds a noise offset to the matrix so the dice rattle in a cup, 08 fakes the whole thing with one squeezed flat face, and 09 puts three dice on a felt table. The browser has no built-in 3D die, so this order goes from the bare projection to a scene you could put behind a hero headline.
If you want the same idea as a dice roll animation gif, remember that a gif plays one fixed loop forever, while here the top face is calculated again for every throw. For the neighbours of this set, see the bouncing ball patterns with physics and the spinning top canvas scenes.
Code for Each Dice Roll Technique
Every snippet below is an unbroken run of lines copied from that scene's index.html, chosen where the scene does something the others do not. The complete files are in the zip.
013D Dice Turned by a Rotation Matrix
The die floats on a sine and cosine orbit while its eight corners are multiplied by R and projected with a focal length of 275, and any face whose normal points away from the camera is skipped. DEMO_KICK_MS gives it a new axis and speed every 1.5 seconds, which makes this a calm opening frame for a dice scene.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" role="img" aria-label="Multiplies the cube's eight corners by a rotation matrix, projects them with perspective, and drops the faces turned away from the camera by the dot product of normal and view ray, so a pipped die spins in mid air."></canvas>
<output class="bp__status" aria-live="polite"><span id="bpLabel">Top face</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">Roll</button>
<p class="bp__caption">Multiplies the cube's eight corners by a rotation matrix, projects them with perspective, and drops the faces turned away from the camera by the dot product of normal and view ray, so a pipped die spins in mid air.</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 = 'Top face';
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 Dice Turned by a Rotation Matrix — Measured area 13.776% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
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]];
}
// Rodrigues formula: rotation matrix for turning by ang around the unit axis ax
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];
}
Measured: 13.8% accumulated area, intensity 72.4, moved in 23/23 frames.
02Dice Thrown on an Arc That Bounce on the Floor
Gravity bends the throw into an arc, and at each floor hit the speed of the lowest corner is multiplied by DICE_RESTITUTION, so the bounce apex shrinks with a shadow and a dotted trail to show it. It suits the moment of throwing, where the feel of the hit matters more than the result.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" role="img" aria-label="Adds gravity to a thrown die so it follows an arc, then multiplies the contact speed by a restitution factor at each floor hit so the bounce apex shrinks, drawn with a shadow and a dotted trail."></canvas>
<output class="bp__status" aria-live="polite"><span id="bpLabel">Rolling</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">Roll</button>
<p class="bp__caption">Adds gravity to a thrown die so it follows an arc, then multiplies the contact speed by a restitution factor at each floor hit so the bounce apex shrinks, drawn with a shadow and a dotted trail.</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 = 'Bounces';
var T_RESULT = 'Result';
var T_ROLLING = '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 · Dice Thrown on an Arc That Bounce on the Floor — Measured area 23.341% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
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;
Measured: 23.3% area, intensity 61.8, moved in 21/23 frames.
03Dice That Read Their Top Face as They Settle
When the spin slows, the die picks the face whose normal has the largest y value and treats its pip count as the result, then turns a fixed share of the remaining angle each step until that face is exactly on top. Use it when the result has to lock in with a visible click, and the six bars under the die show every face's y value while it happens.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" role="img" aria-label="As the spin slows, picks the face whose normal points most upward, reads its pip count as the result, and eases the orientation until that face sits exactly on top."></canvas>
<output class="bp__status" aria-live="polite"><span id="bpLabel">Rolling</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">Roll</button>
<p class="bp__caption">As the spin slows, picks the face whose normal points most upward, reads its pip count as the result, and eases the orientation until that face sits exactly on top.</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 = 'Top face';
var T_RESULT = 'Result';
var T_ROLLING = '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 막대: 가장 큰 막대가 Top face, 보정이 끝나면 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 · Dice That Read Their Top Face as They Settle — Measured area 13.378% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
// Settle: read the face with the largest normal y as the top, then turn by SETTLE_RATE of the remaining angle each step until that face points straight up
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));
}
Measured: 13.4% area, intensity 65.7, moved in 18/23 frames.
04Dice That Repeat the Same Sequence from a Seed
A linear congruential generator decides each throw's start spin and speed, so one seed always gives the same faces in the same order. The scene hides six throws before it starts and lays them in a top row, then compares them with the row you are rolling now, and a chip with a matching face gets a mint border.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" role="img" aria-label="Derives each throw's starting spin and speed from a linear congruential sequence, so the same seed yields the same faces in the same order, shown by comparing a precomputed top row of faces with the row being rolled now."></canvas>
<output class="bp__status" aria-live="polite"><span id="bpLabel">Rolling</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">Roll</button>
<p class="bp__caption">Derives each throw's starting spin and speed from a linear congruential sequence, so the same seed yields the same faces in the same order, shown by comparing a precomputed top row of faces with the row being rolled now.</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 = 'Result';
var T_ROLLING = '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 · Dice That Repeat the Same Sequence from a Seed — Measured area 18.185% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
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);
}
Measured: 18.2% area, intensity 66.1, moved in 19/23 frames.
05Two Dice Whose Sum Stacks into Bars
Two dice are thrown from opposite sides, their top faces are added, and one count is stacked onto the bars from 2 to 12 that 24 hidden rolls already built. Because the middle sums can be reached in more ways than 2 or 12, the middle bars grow tallest, which suits a page that explains sums.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" role="img" aria-label="Rolls two dice from opposite sides, adds their top faces, and stacks one more count onto the 2-to-12 bars already built from 24 hidden rolls, so a distribution tall in the middle keeps growing."></canvas>
<output class="bp__status" aria-live="polite"><span id="bpLabel">Rolling</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">Roll</button>
<p class="bp__caption">Rolls two dice from opposite sides, adds their top faces, and stacks one more count onto the 2-to-12 bars already built from 24 hidden rolls, so a distribution tall in the middle keeps growing.</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 = 'Sum';
var T_ROLLING = '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 · Two Dice Whose Sum Stacks into Bars — Measured area 20.543% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
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;
}
Measured: 20.5% area, intensity 69.3, moved in 20/23 frames.
06Dice That Leave Roll Marks and Dust on the Floor
Every floor contact scatters between 7 and 17 dust specks, and the path of the shadow point stays on the floor as a pale violet line for 3.6 seconds. The marks are kept until the next throw and fade slowly, so a roll leaves a trace you can still read after the die has stopped.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" role="img" aria-label="Scatters small dust particles at every floor contact and leaves the rolled path as a faint line that slowly fades after the throw."></canvas>
<output class="bp__status" aria-live="polite"><span id="bpLabel">Rolling</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">Roll</button>
<p class="bp__caption">Scatters small dust particles at every floor contact and leaves the rolled path as a faint line that slowly fades after the throw.</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 = 'Result';
var T_ROLLING = '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 · Dice That Leave Roll Marks and Dust on the Floor — Measured area 18.76% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
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();
}
Measured: 18.8% area, intensity 52.5, moved in 23/23 frames.
07Dice Shaken Inside a Cup
A one-dimensional value noise reads a table of 256 seeded numbers with smooth interpolation, and the result is added to the cup and to the dice position and angle, so the rattle never repeats a simple loop. Once the shake has grown for 1.3 seconds the dice are shot out of the cup, and the cup's front wall is drawn after the dice so it hides their lower half.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" role="img" aria-label="Adds noise offsets to the dice position and angle so they rattle inside a cup, then ramps up the shake strength until they fly out of the cup."></canvas>
<output class="bp__status" aria-live="polite"><span id="bpLabel">Shaking</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">Roll</button>
<p class="bp__caption">Adds noise offsets to the dice position and angle so they rattle inside a cup, then ramps up the shake strength until they fly out of the cup.</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 동안 제곱 곡선으로 커지고,
// 끝나면 같은 주사위가 위로 솟구쳐 일반 물리(포물선·튕김·Top face 읽기)로 넘어간다. 컵은 앞벽이 주사위 아래쪽을 가리도록 뒤→주사위→앞 순서로 그린다.
var T_SHAKE = 'Shaking';
var T_ROLLING = 'Rolling';
var T_RESULT = '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 · Dice Shaken Inside a Cup — Measured area 24.347% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
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));
}
Measured: 24.3% area, intensity 68.4, moved in 23/23 frames.
08Flat 2D Dice Rolling by Face Swaps
This is the dice roll animation 2d version: no matrix at all, only a face squeezed to 0.18 of its width and widened again while the pip layer is swapped at the flattest moment. The floor stripes slide one tile each hop, and the button plays four hops of 360 ms with the Web Animations API, so it works as a light flourish on a button.

<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">Rolling</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">Roll</button>
<p class="bp__caption">Without any 3D math, squeezes one flat face's width and widens it again while swapping the pip layer and sliding the floor stripes one tile at a time, so a flat die rolls using keyframe style rules.</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 = 'Result';
var T_ROLLING = '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 · Flat 2D Dice Rolling by Face Swaps — Measured area 15.748% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
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;
}
Measured: 15.7% area, intensity 48.1, moved in 23/23 frames.
09Hero Background Dice Table
Three dice, each with its own seed, take turns being thrown over a felt table and stay inside three separate lanes, then each result face is written into a chip row under the table. Because the scene never loops visibly, it works as a landing page background that keeps changing.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" role="img" aria-label="Three dice with different seeds are thrown in turn over a felt table and come to rest again and again, while each result face is written in order onto a chip row under the table, in a hero scene."></canvas>
<output class="bp__status" aria-live="polite"><span id="bpLabel">Rolling</span> <b id="bpNum"></b></output>
<button type="button" class="bp__btn" id="bpBtn">Roll</button>
<p class="bp__caption">Three dice with different seeds are thrown in turn over a felt table and come to rest again and again, while each result face is written in order onto a chip row under the table, in a hero scene.</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 = 'Result';
var T_ROLLING = '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 · Hero Background Dice Table — Measured area 23.037% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
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)));
}
}
Measured: 23.0% area, intensity 69.4, moved in 23/23 frames.
Where the Values Change the Scene — Restitution, Spin Damping, Seed
Three constants change the character of the scene the most. DICE_RESTITUTION in 02 sets how much speed survives a floor hit, SPIN_DAMPING in 03 sets how much spin each step removes, and DICE_SEED in 04 reorders the whole sequence of throws. A higher restitution keeps the height up longer, but within the two-second cap the count of strong bounces can even drop from 13 to 7, so read this value as height and not as a bounce count.
The table collects all nine measurements. Scene 07 covers the most area because the cup and both dice shake together, scene 03 covers the least because one die is thrown gently, and no scene falls under ten percent.
| Item | Cumulative area | Avg. intensity | Moved frames |
|---|---|---|---|
| 01 Rotation-matrix 3D die | 13.8% | 72.4 | 23/23 |
| 02 Arc throw and bounce | 23.3% | 61.8 | 21/23 |
| 03 Top-face reading | 13.4% | 65.7 | 18/23 |
| 04 Seeded sequence | 18.2% | 66.1 | 19/23 |
| 05 Two-dice sum bars | 20.5% | 69.3 | 20/23 |
| 06 Dust and roll marks | 18.8% | 52.5 | 23/23 |
| 07 Cup shake noise | 24.3% | 68.4 | 23/23 |
| 08 Flat 2D die | 15.7% | 48.1 | 23/23 |
| 09 Hero table | 23.0% | 69.4 | 23/23 |
Where It Breaks — Resting on an Edge, Narrow Screens, Overlapping Dice
The riskiest moment is a die balanced on an edge. Reading the top face is a maximum over normal y, so in a diagonal pose where two faces have almost the same y the choice can flip within one step, and the code forces the settle with SETTLE_AFTER_MS or a bounce count. Each correction step turns only a share (SETTLE_RATE, 0.16) of the remaining angle, but the face it aims at can change between steps, so the direction the die lies down in may look unsteady. In 200 throws none failed to stop, with an average of 110 steps (about 1.76 seconds), a longest run of 157 steps, and 8 warm-up steps before the first frame.
On a narrow screen the canvas shrinks with its width and only the status line, button and caption text get smaller below 340px. Scenes 05 and 09 do not test two dice against each other at all, they keep each die in its own lane and draw the die farthest from the camera first, so pips can hide each other at the border and nothing more is done about it. The faces are also not evenly spread: 200 throws with seed 7 gave the faces 1 to 6 counts of 51, 25, 31, 29, 26 and 38, so never use these scenes to draw a winner or settle a bet, and keep them as visual effects. To change the constants yourself, open the zip with the password ky895umc and edit the files.
Three Variants and Accessibility
| Variant | Value | Effect |
|---|---|---|
| Adjust the restitution | 02 DICE_RESTITUTION 0.45 → 0.7 |
Raising it keeps more speed at each floor hit, so the bounces stay higher for longer (in the same throw the first and second bounce apex rise from 0.82 and 0.15 to 1.17 and 0.89, in half-die units). Lowering it gives one or two bounces before the die lies flat. |
| Adjust the spin damping | 03 SPIN_DAMPING 0.985 → 0.96 |
Lowering it takes more spin off every step: averaged over the same 20 throws the top-face correction starts at step 61 instead of 78, and the angular speed at one second drops from 3.8 to 2.7 rad/s. Raising it makes the die spin longer before the correction starts. |
| Change the seed | 04 DICE_SEED 7 → 19 |
Changing the seed reorders the throw angles, speeds and spins so the first six faces change from 2, 6, 4, 4, 3, 1 to 5, 6, 2, 1, 5, 1; the same value always gives the same order. |
With prefers-reduced-motion turned on, a canvas scene never creates a requestAnimationFrame loop. At the start and on every button press it runs the same calculation in one go, draws only the final settled scene and writes the result into the status line. Scene 08 switches all animation off and the button swaps the face at once, while scene 01 multiplies a fresh rotation into the matrix on each press. The roll control is a native <button>, so Tab focuses it and Enter or Space clicks it, and the result sits inside an <output aria-live="polite"> that a screen reader announces.
What Does the Code Use to Find the Top Face of a Die?
The top face is the face whose normal has the largest y value after it is rotated into world space. topIndex() multiplies the six normals by the y row of the rotation matrix and keeps the biggest, and the pips are laid out as +y is 1, -y is 6, +x is 2, -x is 5, +z is 3 and -z is 4, so opposite faces always add up to 7. The same calculation drives the live label in 01, the correction target in 03 and the final result in the other scenes.
Drawing on a canvas is documented in the Canvas API reference. For 3D without a canvas see the CSS 3D cube effects, for scenes computed down to the pixel see the HTML5 canvas droplet and warp effects, and for layered backgrounds see the depth-layer 3D backgrounds.
Frequently Asked Questions Before You Use These Dice Scenes
How do I turn a dice roll animation js demo into a game?
Each scene stops at storing the pip count in result and writing it to the status line. Score, turns and win rules are yours to add, and the sum in 05 and the result chip row in 09 are the easiest places to attach them.
Do I need an external library or image?
No. Scene 08 runs on DOM and CSS, the other eight on a 2D canvas and one script, and nothing calls an outside address, so a copied scene runs as it is.
What do I change if the die bounces too long or lies down too soon?
The bounce comes from DICE_RESTITUTION in 02 and the speed at which spin fades from SPIN_DAMPING in 03, while SETTLE_AFTER_MS decides when settling begins. To replay the same throw, fix DICE_SEED in 04 and you get the same list of faces each time.









