03멈추며 윗면 숫자를 읽어 내는 주사위
회전이 느려질 때 여섯 면의 법선 중 위쪽을 가장 많이 향한 면을 골라 그 눈 수를 결과로 읽고, 방향을 그 면이 정확히 위로 오도록 서서히 보정한다.
쓰임결과가 확정되는 순간 장면
settleStepSETTLE_RATESETTLE_AFTER_MSreadNormalstopIndex

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


