02포물선으로 던져 바닥에서 튕기는 주사위
던진 주사위에 중력을 더해 포물선을 그리게 하고, 바닥에 닿을 때마다 접촉 속도에 반발 계수를 곱해 튕김 정점 높이가 줄어드는 모습을 그림자와 궤적 점과 함께 그린다.
쓰임던지는 순간의 손맛 연출
GRAVITYDICE_RESTITUTIONfloorStepwallStepINERTIA

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


