コマ 回転 9パターン — 角速度と歳差で描く回転シーン
回る、傾く、ふらつく、倒れる — コマの一生は四つの動詞で終わり、03番はその二つ目を数字で計算する。コマ 回転の九つの場面を、このページで動かしながら確かめます。
九つのコマの場面をこのページで回して確かめる
下のグリッドの九つのキャンバスは、スクロールして見えた時点ですでに動いています。どれも角速度・傾き・軸の向きを持つ同じ小さなコマから始まり、クリックを待つ場面はなく、ポインターが回転を生む06番だけが例外です。
01 spin-top-scene-angular-decay
02 spin-top-scene-precession-wobble
03 spin-top-scene-tilt-amplitude
04 spin-top-scene-floor-friction-trace
05 spin-top-scene-two-top-collision
06 spin-top-scene-pointer-spin-impart
07 spin-top-scene-afterimage-stripes
08 spin-top-scene-tumble-fall-judge
09 spin-top-scene-hero-arena
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 摩擦で角速度が落ちていく基本のコマ
- 02 傾いた軸が円を描く歳差のコマ
- 03 遅くなるほど傾きの振れ幅が増えるコマ
- 04 床に摩擦の軌跡を残してさまようコマ
- 05 二つのコマがぶつかって弾き合う衝突
- 06 ポインターでなでると回転が付くコマ
- 07 速いと縞がにじんで見える残像のコマ
- 08 傾きが限界を超えると倒れる転倒判定
- 09 角速度・歳差・衝突を一枚にまとめたヒーローアリーナ
コマ 回転 9パターン — 角速度ひとつが傾きと揺れを一緒に決める
この九つは、角速度という一つの数が16msのステップごとに少しずつ落ち、傾き・軸の円・床の上の移動・転倒がすべてその数を読む、というつくりのコマです。コマ 回転運動を触って確かめられる形にしたもので、GIFのように同じループを繰り返すのではなく、毎フレーム計算し直すので、各項目の冒頭の定数を変えれば次のフレームから見え方が変わります。
九つはコマの一生の順に並べてあります。減衰、傾く軸、広がる揺れ、床をさまよう動き、二つのコマの衝突、ポインターで回すコマ、にじむ縞、転倒、そして全部を混ぜたヒーローアリーナです。どの項目も makeTop() のオブジェクトと drawTop() の描画関数を共有しているので、以下で読むコードはその項目が足した部分だけです。
01摩擦で角速度が落ちていく基本のコマ
decayAngularSpeed() が16msのステップごとに ANGULAR_DECAY(0.55)から作った指数の係数を角速度に掛け、縞の角度は残った速度のぶんだけ進むので、最初は速く回って徐々に落ち着きます。角速度が4を下回ると OMEGA_START(38)に戻って波紋が広がるため、回転シーンの最初の画面に向いています。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<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__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; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}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__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;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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 rand = seedRand(435001);
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
function makeTop(x, y, palIndex, R, omega) {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses = [];
function addPulse(x, y, rgb) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr, x, y, max) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr, rgb, w) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R, n, base, alpha, color) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t, rest, margin) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a, b, rest, spinMix, kickAmt) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 프레임마다 회전각에 각속도를 더하고, 각속도에는 지수 감쇠 비율을 곱한다.
// 각속도가 낮아질수록 기울기 진폭과 세차 속도를 키워 곧게 서 있던 팽이가 서서히 비틀거리게 한다.
var ANGULAR_DECAY = 0.55;
var OMEGA_START = 38;
var spinTop = makeTop(160, 158, 0, 58, OMEGA_START);
function decayAngularSpeed(t) { t.omega *= Math.exp(-ANGULAR_DECAY * dtS); }
function stepWorld() {
decayAngularSpeed(spinTop);
spinTop.spin += spinTop.omega * dtS;
var slow = 1 - clamp(spinTop.omega / OMEGA_START, 0, 1);
spinTop.tilt = 0.06 + 0.5 * slow * slow;
spinTop.az += (2 + slow * 5) * dtS;
if (spinTop.omega < 4) {
spinTop.omega = OMEGA_START;
addPulse(spinTop.x, spinTop.y + 4, '255,214,230');
}
stepPulses();
}
function drawOmegaGauge(t) {
var frac = clamp(t.omega / OMEGA_START, 0, 1);
ctx.save();
ctx.translate(t.x, t.y + 4);
ctx.scale(1, 0.34);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(0, 0, 84, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 12;
ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, 84, -1.5708, -1.5708 + 6.2832 * frac);
ctx.strokeStyle = 'rgba(255,179,199,0.9)';
ctx.lineWidth = 12;
ctx.stroke();
ctx.restore();
}
function drawWorld() {
drawArena();
drawOmegaGauge(spinTop);
drawPulses();
drawTop(spinTop);
}
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 = null;
var acc = 0;
function tick(ts) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 30; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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 "./SpinTopSceneAngularDecay.module.scss";
type Pal = { a: string; b: string; mid: string; body: string };
type Top = { x: number; y: number; vx: number; vy: number; omega: number; tilt: number; az: number; spin: number; R: number; pal: Pal; kick: number; tipX: number; tipY: number };
type Pulse = { x: number; y: number; born: number; rgb: string };
type Pt = { x: number; y: number };
export default function SpinTopSceneAngularDecay() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
const canvasEl = canvasRef.current;
if (!rootEl || !canvasEl) 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;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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: number) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(435001);
function clamp(v: number, lo: number, hi: number) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Array<() => void> = [];
function makeTop(x: number, y: number, palIndex: number, R: number, omega: number): Top {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses: Pulse[] = [];
function addPulse(x: number, y: number, rgb?: string) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live: Pulse[] = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr: Pt[], rgb: string, w: number) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R: number, n: number, base: number, alpha: number, color: string) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t: Top) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t: Top, rest: number, margin: number) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a: Top, b: Top, rest: number, spinMix: number, kickAmt: number) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 프레임마다 회전각에 각속도를 더하고, 각속도에는 지수 감쇠 비율을 곱한다.
// 각속도가 낮아질수록 기울기 진폭과 세차 속도를 키워 곧게 서 있던 팽이가 서서히 비틀거리게 한다.
var ANGULAR_DECAY = 0.55;
var OMEGA_START = 38;
var spinTop = makeTop(160, 158, 0, 58, OMEGA_START);
function decayAngularSpeed(t: Top) { t.omega *= Math.exp(-ANGULAR_DECAY * dtS); }
function stepWorld() {
decayAngularSpeed(spinTop);
spinTop.spin += spinTop.omega * dtS;
var slow = 1 - clamp(spinTop.omega / OMEGA_START, 0, 1);
spinTop.tilt = 0.06 + 0.5 * slow * slow;
spinTop.az += (2 + slow * 5) * dtS;
if (spinTop.omega < 4) {
spinTop.omega = OMEGA_START;
addPulse(spinTop.x, spinTop.y + 4, '255,214,230');
}
stepPulses();
}
function drawOmegaGauge(t: Top) {
var frac = clamp(t.omega / OMEGA_START, 0, 1);
ctx.save();
ctx.translate(t.x, t.y + 4);
ctx.scale(1, 0.34);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(0, 0, 84, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 12;
ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, 84, -1.5708, -1.5708 + 6.2832 * frac);
ctx.strokeStyle = 'rgba(255,179,199,0.9)';
ctx.lineWidth = 12;
ctx.stroke();
ctx.restore();
}
function drawWorld() {
drawArena();
drawOmegaGauge(spinTop);
drawPulses();
drawTop(spinTop);
}
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: number | null = null;
var acc = 0;
function tick(ts: number) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 30; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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]();
}
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} />
<p className={styles.bp__caption}>팽이 몸통의 회전 각도를 프레임마다 각속도만큼 더하고 각속도에 감쇠 비율을 곱해, 처음엔 줄무늬가 빠르게 돌다 점점 느려져 멈추는 회전을 그린다.</p>
</div>
</main>
);
}01 · 摩擦で角速度が落ちていく基本のコマ — 計測 累積面積 22.972% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var ANGULAR_DECAY = 0.55;
var OMEGA_START = 38;
var spinTop = makeTop(160, 158, 0, 58, OMEGA_START);
function decayAngularSpeed(t) { t.omega *= Math.exp(-ANGULAR_DECAY * dtS); }
function stepWorld() {
decayAngularSpeed(spinTop);
spinTop.spin += spinTop.omega * dtS;
var slow = 1 - clamp(spinTop.omega / OMEGA_START, 0, 1);
spinTop.tilt = 0.06 + 0.5 * slow * slow;
spinTop.az += (2 + slow * 5) * dtS;
if (spinTop.omega < 4) {
spinTop.omega = OMEGA_START;
addPulse(spinTop.x, spinTop.y + 4, '255,214,230');
}
stepPulses();
}
項目01はフレームの23.0%を平均強度76.2で塗り替え、23/23フレームで動きが測定されました。
02傾いた軸が円を描く歳差のコマ
PRECESSION_GAIN(0.6)は軸がどれだけ傾くかだけを決め、TILT_PER_GAIN(0.7)にその係数を掛けたものが傾きになり、軸が回る速さは precessAxis() の中で PRECESSION_K(200)を角速度で割って決まります。軸の先端を白い点と110点の軌跡で追うので、傾いた軸が回り続ける理由を見せる教育シーンに向いています。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<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__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; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}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__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;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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 rand = seedRand(435002);
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
function makeTop(x, y, palIndex, R, omega) {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses = [];
function addPulse(x, y, rgb) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr, x, y, max) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr, rgb, w) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R, n, base, alpha, color) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t, rest, margin) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a, b, rest, spinMix, kickAmt) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 기울기 각도의 사인 값을 각속도로 나눠 세차 각속도를 구하고, 그 각도로 축 끝점을 원 위에 놓는다.
// 세차 계수(PRECESSION_GAIN)가 기울기 크기를 정해 축 끝이 바닥 위에서 그리는 원의 크기가 달라진다.
var PRECESSION_GAIN = 0.6;
var TILT_PER_GAIN = 0.7;
var PRECESSION_K = 200;
var AXIS_DECAY = 0.14;
var OMEGA_FULL = 34;
var spinTop = makeTop(160, 160, 2, 54, OMEGA_FULL);
var tipTrail = [];
function precessAxis(t) {
var rate = PRECESSION_K * Math.sin(t.tilt) / Math.max(t.omega, 8);
t.az += rate * dtS;
}
function stepWorld() {
spinTop.omega *= Math.exp(-AXIS_DECAY * dtS);
spinTop.spin += spinTop.omega * dtS;
var slow = 1 - clamp(spinTop.omega / OMEGA_FULL, 0, 1);
spinTop.tilt = TILT_PER_GAIN * PRECESSION_GAIN * (1 + 0.5 * slow);
precessAxis(spinTop);
pushTrail(tipTrail, spinTop.tipX, spinTop.tipY, 110);
if (spinTop.omega < 12) {
spinTop.omega = OMEGA_FULL;
addPulse(spinTop.x, spinTop.y + 4, '201,184,255');
}
stepPulses();
}
function drawWorld() {
drawArena();
drawPulses();
ctx.save();
ctx.translate(spinTop.x, spinTop.y + 3);
ctx.scale(1, 0.32);
ctx.beginPath();
ctx.arc(0, 0, spinTop.R * 1.25 * Math.sin(spinTop.tilt) * 1.15 + 6, 0, 6.2832);
ctx.strokeStyle = 'rgba(241,236,255,0.7)';
ctx.lineWidth = 7;
ctx.stroke();
ctx.restore();
drawTrail(tipTrail, '241,236,255', 6);
drawTop(spinTop);
ctx.beginPath();
ctx.arc(spinTop.tipX, spinTop.tipY, 4, 0, 6.2832);
ctx.fillStyle = '#ffffff';
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 = null;
var acc = 0;
function tick(ts) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 40; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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 "./SpinTopScenePrecessionWobble.module.scss";
type Pal = { a: string; b: string; mid: string; body: string };
type Top = { x: number; y: number; vx: number; vy: number; omega: number; tilt: number; az: number; spin: number; R: number; pal: Pal; kick: number; tipX: number; tipY: number };
type Pulse = { x: number; y: number; born: number; rgb: string };
type Pt = { x: number; y: number };
export default function SpinTopScenePrecessionWobble() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
const canvasEl = canvasRef.current;
if (!rootEl || !canvasEl) 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;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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: number) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(435002);
function clamp(v: number, lo: number, hi: number) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Array<() => void> = [];
function makeTop(x: number, y: number, palIndex: number, R: number, omega: number): Top {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses: Pulse[] = [];
function addPulse(x: number, y: number, rgb?: string) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live: Pulse[] = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr: Pt[], rgb: string, w: number) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R: number, n: number, base: number, alpha: number, color: string) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t: Top) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t: Top, rest: number, margin: number) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a: Top, b: Top, rest: number, spinMix: number, kickAmt: number) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 기울기 각도의 사인 값을 각속도로 나눠 세차 각속도를 구하고, 그 각도로 축 끝점을 원 위에 놓는다.
// 세차 계수(PRECESSION_GAIN)가 기울기 크기를 정해 축 끝이 바닥 위에서 그리는 원의 크기가 달라진다.
var PRECESSION_GAIN = 0.6;
var TILT_PER_GAIN = 0.7;
var PRECESSION_K = 200;
var AXIS_DECAY = 0.14;
var OMEGA_FULL = 34;
var spinTop = makeTop(160, 160, 2, 54, OMEGA_FULL);
var tipTrail: Pt[] = [];
function precessAxis(t: Top) {
var rate = PRECESSION_K * Math.sin(t.tilt) / Math.max(t.omega, 8);
t.az += rate * dtS;
}
function stepWorld() {
spinTop.omega *= Math.exp(-AXIS_DECAY * dtS);
spinTop.spin += spinTop.omega * dtS;
var slow = 1 - clamp(spinTop.omega / OMEGA_FULL, 0, 1);
spinTop.tilt = TILT_PER_GAIN * PRECESSION_GAIN * (1 + 0.5 * slow);
precessAxis(spinTop);
pushTrail(tipTrail, spinTop.tipX, spinTop.tipY, 110);
if (spinTop.omega < 12) {
spinTop.omega = OMEGA_FULL;
addPulse(spinTop.x, spinTop.y + 4, '201,184,255');
}
stepPulses();
}
function drawWorld() {
drawArena();
drawPulses();
ctx.save();
ctx.translate(spinTop.x, spinTop.y + 3);
ctx.scale(1, 0.32);
ctx.beginPath();
ctx.arc(0, 0, spinTop.R * 1.25 * Math.sin(spinTop.tilt) * 1.15 + 6, 0, 6.2832);
ctx.strokeStyle = 'rgba(241,236,255,0.7)';
ctx.lineWidth = 7;
ctx.stroke();
ctx.restore();
drawTrail(tipTrail, '241,236,255', 6);
drawTop(spinTop);
ctx.beginPath();
ctx.arc(spinTop.tipX, spinTop.tipY, 4, 0, 6.2832);
ctx.fillStyle = '#ffffff';
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: number | null = null;
var acc = 0;
function tick(ts: number) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 40; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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]();
}
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} />
<p className={styles.bp__caption}>팽이 축의 기울기 각도에 따라 위쪽 끝점이 바닥 위에서 작은 원을 그리도록 위치를 사인·코사인으로 계산해, 몸통이 돌면서 축 자체도 천천히 한 바퀴씩 돈다.</p>
</div>
</main>
);
}02 · 傾いた軸が円を描く歳差のコマ — 計測 累積面積 23.096% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function precessAxis(t) {
var rate = PRECESSION_K * Math.sin(t.tilt) / Math.max(t.omega, 8);
t.az += rate * dtS;
}
function stepWorld() {
spinTop.omega *= Math.exp(-AXIS_DECAY * dtS);
spinTop.spin += spinTop.omega * dtS;
var slow = 1 - clamp(spinTop.omega / OMEGA_FULL, 0, 1);
spinTop.tilt = TILT_PER_GAIN * PRECESSION_GAIN * (1 + 0.5 * slow);
precessAxis(spinTop);
pushTrail(tipTrail, spinTop.tipX, spinTop.tipY, 110);
if (spinTop.omega < 12) {
spinTop.omega = OMEGA_FULL;
addPulse(spinTop.x, spinTop.y + 4, '201,184,255');
}
stepPulses();
}
項目02はフレームの23.1%を平均強度64.6で塗り替え、23/23フレームで動きが測定されました。
03遅くなるほど傾きの振れ幅が増えるコマ
tiltAmplitude() は TILT_AMP_MAX(0.88)に、失われた速度の割合の1.6乗を掛けた振れ幅を返し、遅くなるほど速まる正弦の位相が、その範囲で傾きを左右に振ります。キャンバス上の半透明の扇が揺れ幅の拡大を見せるので、止まる直前の緊張感を出したい場面に使えます。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<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__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; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}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__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;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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 rand = seedRand(435003);
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
function makeTop(x, y, palIndex, R, omega) {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses = [];
function addPulse(x, y, rgb) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr, x, y, max) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr, rgb, w) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R, n, base, alpha, color) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t, rest, margin) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a, b, rest, spinMix, kickAmt) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 각속도가 최대값에서 얼마나 줄었는지(slow)를 거듭제곱해 기울기 진폭을 구하고,
// 진폭에 사인 위상을 곱해 기울기를 좌우로 흔든다. 느릴수록 위상도 빨라져 흔들림이 점점 커진다.
var TILT_AMP_MAX = 0.88;
var OMEGA_TOP = 36;
var OMEGA_FADE = 0.45;
var spinTop = makeTop(160, 164, 3, 44, OMEGA_TOP);
var wobblePhase = 0;
var currentAmp = 0;
function tiltAmplitude(omega) {
var slow = 1 - clamp(omega / OMEGA_TOP, 0, 1);
return TILT_AMP_MAX * Math.pow(slow, 1.6);
}
function stepWorld() {
spinTop.omega *= Math.exp(-OMEGA_FADE * dtS);
spinTop.spin += spinTop.omega * dtS;
currentAmp = tiltAmplitude(spinTop.omega);
var slow = 1 - clamp(spinTop.omega / OMEGA_TOP, 0, 1);
wobblePhase += (5 + 9 * slow) * dtS;
spinTop.tilt = 0.05 + currentAmp * (0.55 + 0.45 * Math.sin(wobblePhase));
spinTop.az += (3 + slow * 5) * dtS;
if (spinTop.omega < 7) {
spinTop.omega = OMEGA_TOP;
addPulse(spinTop.x, spinTop.y + 4, '159,216,255');
}
stepPulses();
}
function drawAmplitudeFan(t) {
var reach = 150;
var a = 0.05 + currentAmp;
ctx.beginPath();
ctx.moveTo(t.x, t.y);
ctx.lineTo(t.x - Math.sin(a) * reach, t.y - Math.cos(a) * reach * 0.8);
ctx.lineTo(t.x + Math.sin(a) * reach, t.y - Math.cos(a) * reach * 0.8);
ctx.closePath();
ctx.fillStyle = 'rgba(159,216,255,0.16)';
ctx.fill();
ctx.strokeStyle = 'rgba(159,216,255,0.5)';
ctx.lineWidth = 1.5;
ctx.stroke();
}
function drawWorld() {
drawArena();
drawAmplitudeFan(spinTop);
drawPulses();
drawTop(spinTop);
}
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 = null;
var acc = 0;
function tick(ts) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 40; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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 "./SpinTopSceneTiltAmplitude.module.scss";
type Pal = { a: string; b: string; mid: string; body: string };
type Top = { x: number; y: number; vx: number; vy: number; omega: number; tilt: number; az: number; spin: number; R: number; pal: Pal; kick: number; tipX: number; tipY: number };
type Pulse = { x: number; y: number; born: number; rgb: string };
type Pt = { x: number; y: number };
export default function SpinTopSceneTiltAmplitude() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
const canvasEl = canvasRef.current;
if (!rootEl || !canvasEl) 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;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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: number) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(435003);
function clamp(v: number, lo: number, hi: number) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Array<() => void> = [];
function makeTop(x: number, y: number, palIndex: number, R: number, omega: number): Top {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses: Pulse[] = [];
function addPulse(x: number, y: number, rgb?: string) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live: Pulse[] = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr: Pt[], rgb: string, w: number) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R: number, n: number, base: number, alpha: number, color: string) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t: Top) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t: Top, rest: number, margin: number) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a: Top, b: Top, rest: number, spinMix: number, kickAmt: number) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 각속도가 최대값에서 얼마나 줄었는지(slow)를 거듭제곱해 기울기 진폭을 구하고,
// 진폭에 사인 위상을 곱해 기울기를 좌우로 흔든다. 느릴수록 위상도 빨라져 흔들림이 점점 커진다.
var TILT_AMP_MAX = 0.88;
var OMEGA_TOP = 36;
var OMEGA_FADE = 0.45;
var spinTop = makeTop(160, 164, 3, 44, OMEGA_TOP);
var wobblePhase = 0;
var currentAmp = 0;
function tiltAmplitude(omega: number) {
var slow = 1 - clamp(omega / OMEGA_TOP, 0, 1);
return TILT_AMP_MAX * Math.pow(slow, 1.6);
}
function stepWorld() {
spinTop.omega *= Math.exp(-OMEGA_FADE * dtS);
spinTop.spin += spinTop.omega * dtS;
currentAmp = tiltAmplitude(spinTop.omega);
var slow = 1 - clamp(spinTop.omega / OMEGA_TOP, 0, 1);
wobblePhase += (5 + 9 * slow) * dtS;
spinTop.tilt = 0.05 + currentAmp * (0.55 + 0.45 * Math.sin(wobblePhase));
spinTop.az += (3 + slow * 5) * dtS;
if (spinTop.omega < 7) {
spinTop.omega = OMEGA_TOP;
addPulse(spinTop.x, spinTop.y + 4, '159,216,255');
}
stepPulses();
}
function drawAmplitudeFan(t: Top) {
var reach = 150;
var a = 0.05 + currentAmp;
ctx.beginPath();
ctx.moveTo(t.x, t.y);
ctx.lineTo(t.x - Math.sin(a) * reach, t.y - Math.cos(a) * reach * 0.8);
ctx.lineTo(t.x + Math.sin(a) * reach, t.y - Math.cos(a) * reach * 0.8);
ctx.closePath();
ctx.fillStyle = 'rgba(159,216,255,0.16)';
ctx.fill();
ctx.strokeStyle = 'rgba(159,216,255,0.5)';
ctx.lineWidth = 1.5;
ctx.stroke();
}
function drawWorld() {
drawArena();
drawAmplitudeFan(spinTop);
drawPulses();
drawTop(spinTop);
}
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: number | null = null;
var acc = 0;
function tick(ts: number) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 40; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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]();
}
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} />
<p className={styles.bp__caption}>각속도가 낮아질수록 기울기 각도에 더하는 진폭 값을 키워, 빠를 땐 곧게 서 있던 팽이가 느려지면서 좌우로 점점 크게 흔들리는 모습을 계산한다.</p>
</div>
</main>
);
}03 · 遅くなるほど傾きの振れ幅が増えるコマ — 計測 累積面積 20.204% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function tiltAmplitude(omega) {
var slow = 1 - clamp(omega / OMEGA_TOP, 0, 1);
return TILT_AMP_MAX * Math.pow(slow, 1.6);
}
function stepWorld() {
spinTop.omega *= Math.exp(-OMEGA_FADE * dtS);
spinTop.spin += spinTop.omega * dtS;
currentAmp = tiltAmplitude(spinTop.omega);
var slow = 1 - clamp(spinTop.omega / OMEGA_TOP, 0, 1);
wobblePhase += (5 + 9 * slow) * dtS;
spinTop.tilt = 0.05 + currentAmp * (0.55 + 0.45 * Math.sin(wobblePhase));
spinTop.az += (3 + slow * 5) * dtS;
if (spinTop.omega < 7) {
spinTop.omega = OMEGA_TOP;
addPulse(spinTop.x, spinTop.y + 4, '159,216,255');
}
stepPulses();
}
項目03はフレームの20.2%を平均強度68.2で塗り替え、23/23フレームで動きが測定されました。
04床に摩擦の軌跡を残してさまようコマ
毎ステップ、回り続ける進行方向へ DRIFT_ACCEL(70)の力を足し、速度に FLOOR_FRICTION(0.992)を掛けてから接点を動かします。32msごとに位置を floorTrace(最大420点)へ記録して床に淡い線を残すので、動きが跡として読み取れる床のシーンになります。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<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__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; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}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__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;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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 rand = seedRand(435004);
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
function makeTop(x, y, palIndex, R, omega) {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses = [];
function addPulse(x, y, rgb) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr, x, y, max) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr, rgb, w) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R, n, base, alpha, color) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t, rest, margin) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a, b, rest, spinMix, kickAmt) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 팽이가 각속도에 비례해 밀려가는 힘을 받고, 그 방향이 세차처럼 계속 돌아 나선으로 떠돈다.
// 매 스텝 속도에 바닥 마찰 비율(FLOOR_FRICTION)을 곱해 깎고, 지나간 자리를 바닥 자취로 남긴다.
var FLOOR_FRICTION = 0.992;
var DRIFT_ACCEL = 70;
var HEADING_RATE = 1.6;
var SPIN_FADE = 0.18;
var OMEGA_BASE = 36;
var spinTop = makeTop(160, 130, 1, 30, OMEGA_BASE);
var floorTrace = [];
var heading = 0.4;
spinTop.vx = 60; spinTop.vy = 20;
function stepWorld() {
spinTop.omega *= Math.exp(-SPIN_FADE * dtS);
spinTop.spin += spinTop.omega * dtS;
heading += HEADING_RATE * dtS;
var drive = DRIFT_ACCEL * (spinTop.omega / OMEGA_BASE) * dtS;
spinTop.vx += Math.cos(heading) * drive;
spinTop.vy += Math.sin(heading) * drive * 0.7;
spinTop.vx *= FLOOR_FRICTION;
spinTop.vy *= FLOOR_FRICTION;
spinTop.x += spinTop.vx * dtS;
spinTop.y += spinTop.vy * dtS;
wallBounce(spinTop, 0.6, 6);
var slow = 1 - clamp(spinTop.omega / OMEGA_BASE, 0, 1);
spinTop.tilt = 0.1 + 0.4 * slow * slow + 0.1 * Math.min(1, Math.hypot(spinTop.vx, spinTop.vy) / 120);
spinTop.az = heading;
if (elapsed % 32 === 0) pushTrail(floorTrace, spinTop.x, spinTop.y + 3, 420);
if (spinTop.omega < 9) {
spinTop.omega = OMEGA_BASE;
spinTop.vx += 50; spinTop.vy -= 30;
addPulse(spinTop.x, spinTop.y + 4, '168,230,207');
}
stepPulses();
}
function drawWorld() {
drawArena();
drawTrail(floorTrace, '168,230,207', 20);
drawPulses();
drawTop(spinTop);
}
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 = null;
var acc = 0;
function tick(ts) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 30; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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 "./SpinTopSceneFloorFrictionTrace.module.scss";
type Pal = { a: string; b: string; mid: string; body: string };
type Top = { x: number; y: number; vx: number; vy: number; omega: number; tilt: number; az: number; spin: number; R: number; pal: Pal; kick: number; tipX: number; tipY: number };
type Pulse = { x: number; y: number; born: number; rgb: string };
type Pt = { x: number; y: number };
export default function SpinTopSceneFloorFrictionTrace() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
const canvasEl = canvasRef.current;
if (!rootEl || !canvasEl) 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;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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: number) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(435004);
function clamp(v: number, lo: number, hi: number) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Array<() => void> = [];
function makeTop(x: number, y: number, palIndex: number, R: number, omega: number): Top {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses: Pulse[] = [];
function addPulse(x: number, y: number, rgb?: string) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live: Pulse[] = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr: Pt[], rgb: string, w: number) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R: number, n: number, base: number, alpha: number, color: string) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t: Top) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t: Top, rest: number, margin: number) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a: Top, b: Top, rest: number, spinMix: number, kickAmt: number) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 팽이가 각속도에 비례해 밀려가는 힘을 받고, 그 방향이 세차처럼 계속 돌아 나선으로 떠돈다.
// 매 스텝 속도에 바닥 마찰 비율(FLOOR_FRICTION)을 곱해 깎고, 지나간 자리를 바닥 자취로 남긴다.
var FLOOR_FRICTION = 0.992;
var DRIFT_ACCEL = 70;
var HEADING_RATE = 1.6;
var SPIN_FADE = 0.18;
var OMEGA_BASE = 36;
var spinTop = makeTop(160, 130, 1, 30, OMEGA_BASE);
var floorTrace: Pt[] = [];
var heading = 0.4;
spinTop.vx = 60; spinTop.vy = 20;
function stepWorld() {
spinTop.omega *= Math.exp(-SPIN_FADE * dtS);
spinTop.spin += spinTop.omega * dtS;
heading += HEADING_RATE * dtS;
var drive = DRIFT_ACCEL * (spinTop.omega / OMEGA_BASE) * dtS;
spinTop.vx += Math.cos(heading) * drive;
spinTop.vy += Math.sin(heading) * drive * 0.7;
spinTop.vx *= FLOOR_FRICTION;
spinTop.vy *= FLOOR_FRICTION;
spinTop.x += spinTop.vx * dtS;
spinTop.y += spinTop.vy * dtS;
wallBounce(spinTop, 0.6, 6);
var slow = 1 - clamp(spinTop.omega / OMEGA_BASE, 0, 1);
spinTop.tilt = 0.1 + 0.4 * slow * slow + 0.1 * Math.min(1, Math.hypot(spinTop.vx, spinTop.vy) / 120);
spinTop.az = heading;
if (elapsed % 32 === 0) pushTrail(floorTrace, spinTop.x, spinTop.y + 3, 420);
if (spinTop.omega < 9) {
spinTop.omega = OMEGA_BASE;
spinTop.vx += 50; spinTop.vy -= 30;
addPulse(spinTop.x, spinTop.y + 4, '168,230,207');
}
stepPulses();
}
function drawWorld() {
drawArena();
drawTrail(floorTrace, '168,230,207', 20);
drawPulses();
drawTop(spinTop);
}
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: number | null = null;
var acc = 0;
function tick(ts: number) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 30; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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]();
}
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} />
<p className={styles.bp__caption}>팽이 접점의 위치를 속도 벡터로 옮기고 바닥 마찰 계수를 곱해 속도를 줄이면서, 지나간 자리를 얇은 선으로 남겨 점점 좁아지는 소용돌이 궤적을 그린다.</p>
</div>
</main>
);
}04 · 床に摩擦の軌跡を残してさまようコマ — 計測 累積面積 25.688% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function stepWorld() {
spinTop.omega *= Math.exp(-SPIN_FADE * dtS);
spinTop.spin += spinTop.omega * dtS;
heading += HEADING_RATE * dtS;
var drive = DRIFT_ACCEL * (spinTop.omega / OMEGA_BASE) * dtS;
spinTop.vx += Math.cos(heading) * drive;
spinTop.vy += Math.sin(heading) * drive * 0.7;
spinTop.vx *= FLOOR_FRICTION;
spinTop.vy *= FLOOR_FRICTION;
spinTop.x += spinTop.vx * dtS;
spinTop.y += spinTop.vy * dtS;
wallBounce(spinTop, 0.6, 6);
var slow = 1 - clamp(spinTop.omega / OMEGA_BASE, 0, 1);
spinTop.tilt = 0.1 + 0.4 * slow * slow + 0.1 * Math.min(1, Math.hypot(spinTop.vx, spinTop.vy) / 120);
spinTop.az = heading;
if (elapsed % 32 === 0) pushTrail(floorTrace, spinTop.x, spinTop.y + 3, 420);
if (spinTop.omega < 9) {
spinTop.omega = OMEGA_BASE;
spinTop.vx += 50; spinTop.vy -= 30;
addPulse(spinTop.x, spinTop.y + 4, '168,230,207');
}
stepPulses();
}
項目04はフレームの25.7%を平均強度60.8で塗り替え、23/23フレームで動きが測定されました。
05二つのコマがぶつかって弾き合う衝突
pairCollide() は縦の距離を1.6倍して遠近を反映し、その距離が半径の和の0.92倍より小さくなったときに衝突とみなして二つを押し離し、COLLISION_RESTITUTION(0.9)を掛けた衝撃を接触の法線方向に与えます。さらに角速度の差の30%(SPIN_MIX)を交換して合計は変わらず、遅い方が速くなり速い方は遅くなるので、二つの力がぶつかる対決シーンに向いています。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<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__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; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}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__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;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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 rand = seedRand(435005);
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
function makeTop(x, y, palIndex, R, omega) {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses = [];
function addPulse(x, y, rgb) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr, x, y, max) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr, rgb, w) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R, n, base, alpha, color) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t, rest, margin) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a, b, rest, spinMix, kickAmt) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 두 팽이를 원으로 보고 겹치면 법선 방향 충격량으로 튕겨 내고(COLLISION_RESTITUTION),
// 각속도 차이의 일부를 서로 맞바꿔 각운동량을 주고받는다. 한쪽이 얻은 만큼 다른 쪽이 잃고, 접선 방향으로도 밀린다.
var COLLISION_RESTITUTION = 0.9;
var SPIN_MIX = 0.3;
var ATTRACT = 34;
var topA = makeTop(80, 120, 0, 30, 36);
var topB = makeTop(240, 150, 3, 30, -24);
topA.vx = 70; topA.vy = 8;
topB.vx = -70; topB.vy = -6;
var tops = [topA, topB];
function resolveTopPair(a, b) {
return pairCollide(a, b, COLLISION_RESTITUTION, SPIN_MIX, 0.5);
}
function exchangeSpin(t) {
var floorKeep = Math.exp(-0.06 * dtS);
t.omega *= floorKeep;
if (Math.abs(t.omega) < 14) t.omega += (t.omega < 0 ? -1 : 1) * 10 * dtS;
}
function stepWorld() {
var dx = topB.x - topA.x, dy = topB.y - topA.y;
var d = Math.hypot(dx, dy) || 1;
topA.vx += dx / d * ATTRACT * dtS; topA.vy += dy / d * ATTRACT * dtS;
topB.vx -= dx / d * ATTRACT * dtS; topB.vy -= dy / d * ATTRACT * dtS;
for (var i = 0; i < tops.length; i += 1) {
var t = tops[i];
exchangeSpin(t);
t.spin += t.omega * dtS;
t.vx *= 0.999; t.vy *= 0.999;
t.x += t.vx * dtS; t.y += t.vy * dtS;
wallBounce(t, 0.9, 6);
t.kick = Math.max(0, t.kick - dtS * 0.9);
t.az += (3 + t.kick * 10) * dtS;
t.tilt = 0.08 + t.kick * 0.7;
}
resolveTopPair(topA, topB);
stepPulses();
}
function drawWorld() {
drawArena();
drawPulses();
var order = topA.y < topB.y ? [topA, topB] : [topB, topA];
drawTop(order[0]);
drawTop(order[1]);
}
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 = null;
var acc = 0;
function tick(ts) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 30; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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 "./SpinTopSceneTwoTopCollision.module.scss";
type Pal = { a: string; b: string; mid: string; body: string };
type Top = { x: number; y: number; vx: number; vy: number; omega: number; tilt: number; az: number; spin: number; R: number; pal: Pal; kick: number; tipX: number; tipY: number };
type Pulse = { x: number; y: number; born: number; rgb: string };
type Pt = { x: number; y: number };
export default function SpinTopSceneTwoTopCollision() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
const canvasEl = canvasRef.current;
if (!rootEl || !canvasEl) 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;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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: number) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(435005);
function clamp(v: number, lo: number, hi: number) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Array<() => void> = [];
function makeTop(x: number, y: number, palIndex: number, R: number, omega: number): Top {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses: Pulse[] = [];
function addPulse(x: number, y: number, rgb?: string) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live: Pulse[] = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr: Pt[], rgb: string, w: number) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R: number, n: number, base: number, alpha: number, color: string) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t: Top) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t: Top, rest: number, margin: number) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a: Top, b: Top, rest: number, spinMix: number, kickAmt: number) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 두 팽이를 원으로 보고 겹치면 법선 방향 충격량으로 튕겨 내고(COLLISION_RESTITUTION),
// 각속도 차이의 일부를 서로 맞바꿔 각운동량을 주고받는다. 한쪽이 얻은 만큼 다른 쪽이 잃고, 접선 방향으로도 밀린다.
var COLLISION_RESTITUTION = 0.9;
var SPIN_MIX = 0.3;
var ATTRACT = 34;
var topA = makeTop(80, 120, 0, 30, 36);
var topB = makeTop(240, 150, 3, 30, -24);
topA.vx = 70; topA.vy = 8;
topB.vx = -70; topB.vy = -6;
var tops = [topA, topB];
function resolveTopPair(a: Top, b: Top) {
return pairCollide(a, b, COLLISION_RESTITUTION, SPIN_MIX, 0.5);
}
function exchangeSpin(t: Top) {
var floorKeep = Math.exp(-0.06 * dtS);
t.omega *= floorKeep;
if (Math.abs(t.omega) < 14) t.omega += (t.omega < 0 ? -1 : 1) * 10 * dtS;
}
function stepWorld() {
var dx = topB.x - topA.x, dy = topB.y - topA.y;
var d = Math.hypot(dx, dy) || 1;
topA.vx += dx / d * ATTRACT * dtS; topA.vy += dy / d * ATTRACT * dtS;
topB.vx -= dx / d * ATTRACT * dtS; topB.vy -= dy / d * ATTRACT * dtS;
for (var i = 0; i < tops.length; i += 1) {
var t = tops[i];
exchangeSpin(t);
t.spin += t.omega * dtS;
t.vx *= 0.999; t.vy *= 0.999;
t.x += t.vx * dtS; t.y += t.vy * dtS;
wallBounce(t, 0.9, 6);
t.kick = Math.max(0, t.kick - dtS * 0.9);
t.az += (3 + t.kick * 10) * dtS;
t.tilt = 0.08 + t.kick * 0.7;
}
resolveTopPair(topA, topB);
stepPulses();
}
function drawWorld() {
drawArena();
drawPulses();
var order = topA.y < topB.y ? [topA, topB] : [topB, topA];
drawTop(order[0]);
drawTop(order[1]);
}
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: number | null = null;
var acc = 0;
function tick(ts: number) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 30; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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]();
}
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} />
<p className={styles.bp__caption}>두 팽이 중심 사이 거리가 반지름 합보다 짧아지는 프레임에서 충돌 방향을 계산해 서로의 속도를 바꾸고, 부딪친 쪽의 각속도를 깎아 한쪽이 더 빨리 멈추게 한다.</p>
</div>
</main>
);
}05 · 二つのコマがぶつかって弾き合う衝突 — 計測 累積面積 40.629% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function pairCollide(a, b, rest, spinMix, kickAmt) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
項目05はフレームの40.6%を平均強度72.1で塗り替え、23/23フレームで動きが測定されました。
06ポインターでなでると回転が付くコマ
impartSpin() はコマの中心からポインターまでのベクトルと、ポインターが今動いたベクトルの外積を回転力にし、SPIN_PER_PX(0.0045)を掛けて角速度に足し、OMEGA_MAX(44)の範囲に収めます。中心の上をなでるか下をなでるかで回る向きが逆になり、速くなでるほど強く回り、触れるまでは台本どおりの四回のスワイプが再生されます。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300" tabindex="0" aria-label="ポインターがコマの上を通った速度の横成分を読んで角速度に足し、向きは符号で決めて、速くなでるほど強く回り、逆になでれば逆向きに回る。"></canvas>
<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__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; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}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__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;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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 rand = seedRand(435006);
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
function makeTop(x, y, palIndex, R, omega) {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses = [];
function addPulse(x, y, rgb) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr, x, y, max) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr, rgb, w) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R, n, base, alpha, color) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t, rest, margin) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a, b, rest, spinMix, kickAmt) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 포인터가 팽이를 쓸고 지나간 이동 벡터와 팽이 중심에서의 위치 벡터를 외적해 돌림힘을 구하고,
// 그 값에 SPIN_PER_PX 를 곱해 각속도에 더한다. 위를 쓸면 한쪽, 아래를 쓸면 반대쪽으로 돈다.
var SPIN_PER_PX = 0.0045;
var OMEGA_MAX = 44;
var SPIN_FADE = 0.4;
var spinTop = makeTop(160, 160, 2, 42, 7);
var pointerOn = false, pointerX = 0, pointerY = 0, lastPX = 0, lastPY = 0;
var swipeAlpha = 0, trailX = 0;
var charging = false;
var swipes = [
{ off: -34, dir: 1 }, { off: 36, dir: 1 }, { off: 30, dir: -1 }, { off: -38, dir: -1 }
];
var swipeSpan = 1300, swipeLen = 520, demoClock = 0;
function impartSpin(px, py, dx, dy) {
var cx = spinTop.x, cy = spinTop.y - 34;
var rx = px - cx, ry = (py - cy) * 1.4;
if (Math.hypot(rx, ry) > spinTop.R * 1.7) return;
var torque = rx * dy - ry * dx;
spinTop.omega = clamp(spinTop.omega + torque * SPIN_PER_PX, -OMEGA_MAX, OMEGA_MAX);
spinTop.kick = Math.min(0.6, spinTop.kick + Math.abs(torque) * 0.004);
}
function virtualSwipe() {
var k = Math.floor(demoClock / swipeSpan) % swipes.length;
var local = demoClock % swipeSpan;
if (local >= swipeLen || demoClock < 250) { swipeAlpha = Math.max(0, swipeAlpha - 0.05); return; }
var sw = swipes[k];
var f = local / swipeLen;
var nx = spinTop.x + sw.dir * (-76 + 152 * f);
var ny = spinTop.y - 34 + sw.off;
var nx0 = spinTop.x + sw.dir * (-76 + 152 * clamp((local - FIXED_DT) / swipeLen, 0, 1));
impartSpin(nx, ny, nx - nx0, 0);
pointerX = nx; pointerY = ny; trailX = nx - sw.dir * 34;
swipeAlpha = 1;
}
function stepWorld() {
if (demo) { demoClock += FIXED_DT; virtualSwipe(); }
if (charging) {
var sgn = spinTop.omega < 0 ? -1 : 1;
spinTop.omega = clamp(spinTop.omega + sgn * 45 * dtS, -OMEGA_MAX, OMEGA_MAX);
}
spinTop.omega *= Math.exp(-SPIN_FADE * dtS);
spinTop.spin += spinTop.omega * dtS;
spinTop.kick = Math.max(0, spinTop.kick - dtS * 0.8);
var calm = 1 - clamp(Math.abs(spinTop.omega) / OMEGA_MAX, 0, 1);
spinTop.tilt = 0.05 + 0.5 * calm * calm + spinTop.kick * 0.3;
spinTop.az += (2 + calm * 5) * dtS;
stepPulses();
}
function drawSpinRing() {
var frac = clamp(Math.abs(spinTop.omega) / OMEGA_MAX, 0, 1);
var col = spinTop.omega < 0 ? '159,216,255' : '255,179,199';
ctx.save();
ctx.translate(spinTop.x, spinTop.y + 4);
ctx.scale(1, 0.34);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(0, 0, 84, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 12;
ctx.stroke();
var dirSign = spinTop.omega < 0 ? -1 : 1;
ctx.beginPath();
ctx.arc(0, 0, 84, -1.5708, -1.5708 + dirSign * 6.2832 * frac, dirSign < 0);
ctx.strokeStyle = 'rgba(' + col + ',0.9)';
ctx.lineWidth = 12;
ctx.stroke();
ctx.restore();
}
function drawPointer() {
var live = demo ? swipeAlpha : (pointerOn ? 1 : 0);
if (live <= 0) return;
ctx.beginPath();
ctx.arc(pointerX, pointerY, 10, 0, 6.2832);
ctx.fillStyle = 'rgba(255,255,255,' + (0.28 * live) + ')';
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,' + (0.9 * live) + ')';
ctx.lineWidth = 2;
ctx.stroke();
if (demo) {
ctx.beginPath();
ctx.moveTo(trailX, pointerY);
ctx.lineTo(pointerX, pointerY);
ctx.strokeStyle = 'rgba(255,255,255,' + (0.5 * live) + ')';
ctx.lineWidth = 4;
ctx.stroke();
}
}
function drawWorld() {
drawArena();
drawSpinRing();
drawPulses();
drawTop(spinTop);
drawPointer();
}
function toCalc(e) {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
function onPointerDown(e) {
stopDemo();
var p = toCalc(e);
pointerOn = true; pointerX = lastPX = p.x; pointerY = lastPY = p.y;
if (canvas.setPointerCapture) canvas.setPointerCapture(e.pointerId);
if (reduce) drawWorld();
}
function onPointerMove(e) {
if (!pointerOn) return;
var p = toCalc(e);
impartSpin(p.x, p.y, p.x - lastPX, p.y - lastPY);
lastPX = pointerX = p.x; lastPY = pointerY = p.y;
if (reduce) drawWorld();
}
function onPointerUp() { pointerOn = false; if (reduce) drawWorld(); }
function onKeyDown(e) {
var k = e.key;
if (k === ' ' || k === 'Spacebar') {
e.preventDefault(); stopDemo();
if (reduce) { spinTop.omega = clamp(spinTop.omega + 10, -OMEGA_MAX, OMEGA_MAX); drawWorld(); } else { charging = true; }
} else if (k === 'ArrowRight' || k === 'ArrowLeft') {
e.preventDefault(); stopDemo();
spinTop.omega = clamp(spinTop.omega + (k === 'ArrowRight' ? 12 : -12), -OMEGA_MAX, OMEGA_MAX);
spinTop.kick = 0.4;
if (reduce) drawWorld();
}
}
function onKeyUp(e) { if (e.key === ' ' || e.key === 'Spacebar') charging = false; }
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerUp);
canvas.addEventListener('keydown', onKeyDown);
canvas.addEventListener('keyup', onKeyUp);
cleaners.push(function () {
canvas.removeEventListener('pointerdown', onPointerDown);
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerup', onPointerUp);
canvas.removeEventListener('pointercancel', onPointerUp);
canvas.removeEventListener('keydown', onKeyDown);
canvas.removeEventListener('keyup', onKeyUp);
});
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 = null;
var acc = 0;
function tick(ts) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 20; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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 "./SpinTopScenePointerSpinImpart.module.scss";
type Pal = { a: string; b: string; mid: string; body: string };
type Top = { x: number; y: number; vx: number; vy: number; omega: number; tilt: number; az: number; spin: number; R: number; pal: Pal; kick: number; tipX: number; tipY: number };
type Pulse = { x: number; y: number; born: number; rgb: string };
type Pt = { x: number; y: number };
export default function SpinTopScenePointerSpinImpart() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
const canvasEl = canvasRef.current;
if (!rootEl || !canvasEl) 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;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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: number) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(435006);
function clamp(v: number, lo: number, hi: number) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Array<() => void> = [];
function makeTop(x: number, y: number, palIndex: number, R: number, omega: number): Top {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses: Pulse[] = [];
function addPulse(x: number, y: number, rgb?: string) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live: Pulse[] = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr: Pt[], rgb: string, w: number) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R: number, n: number, base: number, alpha: number, color: string) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t: Top) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t: Top, rest: number, margin: number) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a: Top, b: Top, rest: number, spinMix: number, kickAmt: number) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 포인터가 팽이를 쓸고 지나간 이동 벡터와 팽이 중심에서의 위치 벡터를 외적해 돌림힘을 구하고,
// 그 값에 SPIN_PER_PX 를 곱해 각속도에 더한다. 위를 쓸면 한쪽, 아래를 쓸면 반대쪽으로 돈다.
var SPIN_PER_PX = 0.0045;
var OMEGA_MAX = 44;
var SPIN_FADE = 0.4;
var spinTop = makeTop(160, 160, 2, 42, 7);
var pointerOn = false, pointerX = 0, pointerY = 0, lastPX = 0, lastPY = 0;
var swipeAlpha = 0, trailX = 0;
var charging = false;
var swipes = [
{ off: -34, dir: 1 }, { off: 36, dir: 1 }, { off: 30, dir: -1 }, { off: -38, dir: -1 }
];
var swipeSpan = 1300, swipeLen = 520, demoClock = 0;
function impartSpin(px: number, py: number, dx: number, dy: number) {
var cx = spinTop.x, cy = spinTop.y - 34;
var rx = px - cx, ry = (py - cy) * 1.4;
if (Math.hypot(rx, ry) > spinTop.R * 1.7) return;
var torque = rx * dy - ry * dx;
spinTop.omega = clamp(spinTop.omega + torque * SPIN_PER_PX, -OMEGA_MAX, OMEGA_MAX);
spinTop.kick = Math.min(0.6, spinTop.kick + Math.abs(torque) * 0.004);
}
function virtualSwipe() {
var k = Math.floor(demoClock / swipeSpan) % swipes.length;
var local = demoClock % swipeSpan;
if (local >= swipeLen || demoClock < 250) { swipeAlpha = Math.max(0, swipeAlpha - 0.05); return; }
var sw = swipes[k];
var f = local / swipeLen;
var nx = spinTop.x + sw.dir * (-76 + 152 * f);
var ny = spinTop.y - 34 + sw.off;
var nx0 = spinTop.x + sw.dir * (-76 + 152 * clamp((local - FIXED_DT) / swipeLen, 0, 1));
impartSpin(nx, ny, nx - nx0, 0);
pointerX = nx; pointerY = ny; trailX = nx - sw.dir * 34;
swipeAlpha = 1;
}
function stepWorld() {
if (demo) { demoClock += FIXED_DT; virtualSwipe(); }
if (charging) {
var sgn = spinTop.omega < 0 ? -1 : 1;
spinTop.omega = clamp(spinTop.omega + sgn * 45 * dtS, -OMEGA_MAX, OMEGA_MAX);
}
spinTop.omega *= Math.exp(-SPIN_FADE * dtS);
spinTop.spin += spinTop.omega * dtS;
spinTop.kick = Math.max(0, spinTop.kick - dtS * 0.8);
var calm = 1 - clamp(Math.abs(spinTop.omega) / OMEGA_MAX, 0, 1);
spinTop.tilt = 0.05 + 0.5 * calm * calm + spinTop.kick * 0.3;
spinTop.az += (2 + calm * 5) * dtS;
stepPulses();
}
function drawSpinRing() {
var frac = clamp(Math.abs(spinTop.omega) / OMEGA_MAX, 0, 1);
var col = spinTop.omega < 0 ? '159,216,255' : '255,179,199';
ctx.save();
ctx.translate(spinTop.x, spinTop.y + 4);
ctx.scale(1, 0.34);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(0, 0, 84, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 12;
ctx.stroke();
var dirSign = spinTop.omega < 0 ? -1 : 1;
ctx.beginPath();
ctx.arc(0, 0, 84, -1.5708, -1.5708 + dirSign * 6.2832 * frac, dirSign < 0);
ctx.strokeStyle = 'rgba(' + col + ',0.9)';
ctx.lineWidth = 12;
ctx.stroke();
ctx.restore();
}
function drawPointer() {
var live = demo ? swipeAlpha : (pointerOn ? 1 : 0);
if (live <= 0) return;
ctx.beginPath();
ctx.arc(pointerX, pointerY, 10, 0, 6.2832);
ctx.fillStyle = 'rgba(255,255,255,' + (0.28 * live) + ')';
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,' + (0.9 * live) + ')';
ctx.lineWidth = 2;
ctx.stroke();
if (demo) {
ctx.beginPath();
ctx.moveTo(trailX, pointerY);
ctx.lineTo(pointerX, pointerY);
ctx.strokeStyle = 'rgba(255,255,255,' + (0.5 * live) + ')';
ctx.lineWidth = 4;
ctx.stroke();
}
}
function drawWorld() {
drawArena();
drawSpinRing();
drawPulses();
drawTop(spinTop);
drawPointer();
}
function toCalc(e: PointerEvent) {
var r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * CALC_W, y: (e.clientY - r.top) / r.height * CALC_H };
}
function onPointerDown(e: PointerEvent) {
stopDemo();
var p = toCalc(e);
pointerOn = true; pointerX = lastPX = p.x; pointerY = lastPY = p.y;
if (canvas.setPointerCapture) canvas.setPointerCapture(e.pointerId);
if (reduce) drawWorld();
}
function onPointerMove(e: PointerEvent) {
if (!pointerOn) return;
var p = toCalc(e);
impartSpin(p.x, p.y, p.x - lastPX, p.y - lastPY);
lastPX = pointerX = p.x; lastPY = pointerY = p.y;
if (reduce) drawWorld();
}
function onPointerUp() { pointerOn = false; if (reduce) drawWorld(); }
function onKeyDown(e: KeyboardEvent) {
var k = e.key;
if (k === ' ' || k === 'Spacebar') {
e.preventDefault(); stopDemo();
if (reduce) { spinTop.omega = clamp(spinTop.omega + 10, -OMEGA_MAX, OMEGA_MAX); drawWorld(); } else { charging = true; }
} else if (k === 'ArrowRight' || k === 'ArrowLeft') {
e.preventDefault(); stopDemo();
spinTop.omega = clamp(spinTop.omega + (k === 'ArrowRight' ? 12 : -12), -OMEGA_MAX, OMEGA_MAX);
spinTop.kick = 0.4;
if (reduce) drawWorld();
}
}
function onKeyUp(e: KeyboardEvent) { if (e.key === ' ' || e.key === 'Spacebar') charging = false; }
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerUp);
canvas.addEventListener('keydown', onKeyDown);
canvas.addEventListener('keyup', onKeyUp);
cleaners.push(function () {
canvas.removeEventListener('pointerdown', onPointerDown);
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerup', onPointerUp);
canvas.removeEventListener('pointercancel', onPointerUp);
canvas.removeEventListener('keydown', onKeyDown);
canvas.removeEventListener('keyup', onKeyUp);
});
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: number | null = null;
var acc = 0;
function tick(ts: number) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 20; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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]();
}
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} tabIndex={0} aria-label="포인터가 팽이 위를 지나간 속도의 가로 성분을 읽어 각속도에 더하고 방향도 부호로 정해, 빠르게 쓸수록 세게 돌고 반대로 쓸면 반대 방향으로 돈다." />
<p className={styles.bp__caption}>포인터가 팽이 위를 지나간 속도의 가로 성분을 읽어 각속도에 더하고 방향도 부호로 정해, 빠르게 쓸수록 세게 돌고 반대로 쓸면 반대 방향으로 돈다.</p>
</div>
</main>
);
}06 · ポインターでなでると回転が付くコマ — 計測 累積面積 15.637% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function impartSpin(px, py, dx, dy) {
var cx = spinTop.x, cy = spinTop.y - 34;
var rx = px - cx, ry = (py - cy) * 1.4;
if (Math.hypot(rx, ry) > spinTop.R * 1.7) return;
var torque = rx * dy - ry * dx;
spinTop.omega = clamp(spinTop.omega + torque * SPIN_PER_PX, -OMEGA_MAX, OMEGA_MAX);
spinTop.kick = Math.min(0.6, spinTop.kick + Math.abs(torque) * 0.004);
}
項目06はフレームの15.6%を平均強度73.1で塗り替え、23/23フレームで動きが測定されました。
07速いと縞がにじんで見える残像のコマ
縞が通過する頻度(角速度 × STRIPE_COUNT(6)÷ 2π)を STRIPE_FUSE_HZ(14)と比べ、超えると縞の上に中間色の円盤を重ね、さらに過去の三つの角度の縞を薄く重ねて残像にします。角速度は46まで上がってから指数で落ち、6まで下がるとまた上がるので、縞が色の帯ににじんではまたはっきりする様子が毎ループ見られます。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<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__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; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}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__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;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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 rand = seedRand(435007);
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
function makeTop(x, y, palIndex, R, omega) {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses = [];
function addPulse(x, y, rgb) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr, x, y, max) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr, rgb, w) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R, n, base, alpha, color) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t, rest, margin) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a, b, rest, spinMix, kickAmt) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 초당 지나가는 줄무늬 수(각속도 x STRIPE_COUNT)가 STRIPE_FUSE_HZ 를 넘으면 줄무늬 위에 평균색 원반을 덧칠하고,
// 지난 각도의 줄무늬를 옅게 겹쳐 잔상을 만든다. 팽이는 가속했다가 감속해 줄무늬가 번졌다 다시 또렷해진다.
var REV_ACCEL = 34;
var OMEGA_PEAK = 46;
var OMEGA_FLOOR = 6;
var SLOW_DECAY = 0.7;
var spinTop = makeTop(160, 164, 0, 62, 5);
var revUp = true;
function stepWorld() {
if (revUp) {
spinTop.omega += REV_ACCEL * dtS;
if (spinTop.omega >= OMEGA_PEAK) revUp = false;
} else {
spinTop.omega *= Math.exp(-SLOW_DECAY * dtS);
if (spinTop.omega <= OMEGA_FLOOR) {
revUp = true;
addPulse(spinTop.x, spinTop.y + 4, '255,241,201');
}
}
spinTop.spin += spinTop.omega * dtS;
var calm = 1 - clamp(spinTop.omega / OMEGA_PEAK, 0, 1);
spinTop.tilt = 0.06 + 0.3 * calm * calm;
spinTop.az += (2 + calm * 4) * dtS;
stepPulses();
}
function drawFuseBar() {
var hz = Math.abs(spinTop.omega) * STRIPE_COUNT / 6.2832;
var frac = clamp(hz / (STRIPE_FUSE_HZ + 26), 0, 1);
ctx.save();
ctx.translate(spinTop.x, spinTop.y + 4);
ctx.scale(1, 0.34);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(0, 0, 96, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 12;
ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, 96, -1.5708, -1.5708 + 6.2832 * frac);
ctx.strokeStyle = frac > STRIPE_FUSE_HZ / (STRIPE_FUSE_HZ + 26) ? 'rgba(255,241,201,0.95)' : 'rgba(255,179,199,0.9)';
ctx.lineWidth = 12;
ctx.stroke();
ctx.restore();
}
function drawWorld() {
drawArena();
drawFuseBar();
drawPulses();
drawTop(spinTop);
}
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 = null;
var acc = 0;
function tick(ts) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 40; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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 "./SpinTopSceneAfterimageStripes.module.scss";
type Pal = { a: string; b: string; mid: string; body: string };
type Top = { x: number; y: number; vx: number; vy: number; omega: number; tilt: number; az: number; spin: number; R: number; pal: Pal; kick: number; tipX: number; tipY: number };
type Pulse = { x: number; y: number; born: number; rgb: string };
type Pt = { x: number; y: number };
export default function SpinTopSceneAfterimageStripes() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
const canvasEl = canvasRef.current;
if (!rootEl || !canvasEl) 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;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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: number) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(435007);
function clamp(v: number, lo: number, hi: number) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Array<() => void> = [];
function makeTop(x: number, y: number, palIndex: number, R: number, omega: number): Top {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses: Pulse[] = [];
function addPulse(x: number, y: number, rgb?: string) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live: Pulse[] = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr: Pt[], rgb: string, w: number) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R: number, n: number, base: number, alpha: number, color: string) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t: Top) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t: Top, rest: number, margin: number) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a: Top, b: Top, rest: number, spinMix: number, kickAmt: number) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 초당 지나가는 줄무늬 수(각속도 x STRIPE_COUNT)가 STRIPE_FUSE_HZ 를 넘으면 줄무늬 위에 평균색 원반을 덧칠하고,
// 지난 각도의 줄무늬를 옅게 겹쳐 잔상을 만든다. 팽이는 가속했다가 감속해 줄무늬가 번졌다 다시 또렷해진다.
var REV_ACCEL = 34;
var OMEGA_PEAK = 46;
var OMEGA_FLOOR = 6;
var SLOW_DECAY = 0.7;
var spinTop = makeTop(160, 164, 0, 62, 5);
var revUp = true;
function stepWorld() {
if (revUp) {
spinTop.omega += REV_ACCEL * dtS;
if (spinTop.omega >= OMEGA_PEAK) revUp = false;
} else {
spinTop.omega *= Math.exp(-SLOW_DECAY * dtS);
if (spinTop.omega <= OMEGA_FLOOR) {
revUp = true;
addPulse(spinTop.x, spinTop.y + 4, '255,241,201');
}
}
spinTop.spin += spinTop.omega * dtS;
var calm = 1 - clamp(spinTop.omega / OMEGA_PEAK, 0, 1);
spinTop.tilt = 0.06 + 0.3 * calm * calm;
spinTop.az += (2 + calm * 4) * dtS;
stepPulses();
}
function drawFuseBar() {
var hz = Math.abs(spinTop.omega) * STRIPE_COUNT / 6.2832;
var frac = clamp(hz / (STRIPE_FUSE_HZ + 26), 0, 1);
ctx.save();
ctx.translate(spinTop.x, spinTop.y + 4);
ctx.scale(1, 0.34);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(0, 0, 96, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 12;
ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, 96, -1.5708, -1.5708 + 6.2832 * frac);
ctx.strokeStyle = frac > STRIPE_FUSE_HZ / (STRIPE_FUSE_HZ + 26) ? 'rgba(255,241,201,0.95)' : 'rgba(255,179,199,0.9)';
ctx.lineWidth = 12;
ctx.stroke();
ctx.restore();
}
function drawWorld() {
drawArena();
drawFuseBar();
drawPulses();
drawTop(spinTop);
}
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: number | null = null;
var acc = 0;
function tick(ts: number) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 40; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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]();
}
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} />
<p className={styles.bp__caption}>각속도가 클수록 줄무늬 한 칸의 투명도를 낮추고 이전 각도 여러 개를 겹쳐 그려, 빠를 땐 무늬가 하나의 색 띠로 번지다가 느려지면 줄무늬가 다시 또렷해진다.</p>
</div>
</main>
);
}07 · 速いと縞がにじんで見える残像のコマ — 計測 累積面積 20.401% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
項目07はフレームの20.4%を平均強度60.4で塗り替え、23/23フレームで動きが測定されました。
08傾きが限界を超えると倒れる転倒判定
judgeTumble() は失われた速度の割合から傾きを決め、角速度が FALL_OMEGA(9)未満で、傾きが TILT_LIMIT(0.62)を超えたときだけ真を返します。すると状態が回転から転倒に変わり、重力が傾きの速さに足され、勢いが十分なら28%の速さで跳ね返り、1100ms横たわってから再び回り始めて、点線が限界の角度を示します。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<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__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; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}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__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;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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 rand = seedRand(435008);
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
function makeTop(x, y, palIndex, R, omega) {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses = [];
function addPulse(x, y, rgb) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr, x, y, max) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr, rgb, w) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R, n, base, alpha, color) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t, rest, margin) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a, b, rest, spinMix, kickAmt) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 각속도가 낮아질수록 기울기가 커지다가, 각속도가 FALL_OMEGA 밑이고 기울기가 TILT_LIMIT 를 넘는 순간
// 쓰러짐 상태로 바꾼다. 쓰러지는 동안은 중력 가속을 기울기 속도에 더하고 바닥에 닿으면 반발로 한 번 튕긴 뒤 눕는다.
var TILT_LIMIT = 0.62;
var FALL_OMEGA = 9;
var OMEGA_START = 30;
var OMEGA_FADE = 0.7;
var tops = [makeTop(96, 150, 1, 32, 30), makeTop(224, 150, 3, 32, 14)];
tops.forEach(function (t) { t.state = 'spin'; t.tv = 0; t.rest = 0; });
function judgeTumble(t) {
var slow = 1 - clamp(t.omega / OMEGA_START, 0, 1);
t.tilt = 0.08 + 0.95 * Math.pow(slow, 2.2);
return t.omega < FALL_OMEGA && t.tilt > TILT_LIMIT;
}
function stepTop(t) {
if (t.state === 'spin') {
t.omega *= Math.exp(-OMEGA_FADE * dtS);
t.spin += t.omega * dtS;
t.az += (3 + (1 - t.omega / OMEGA_START) * 4) * dtS;
if (judgeTumble(t)) { t.state = 'fall'; t.tv = 0.5; t.az = 0.35 + (t.x < 160 ? 0 : 2.4); }
} else if (t.state === 'fall') {
t.spin += t.omega * dtS;
t.tv += 18 * Math.sin(t.tilt) * dtS;
t.tilt += t.tv * dtS;
if (t.tilt >= 1.5) {
t.tilt = 1.5;
if (t.tv > 0.9) { t.tv = -t.tv * 0.28; addPulse(t.x, t.y + 4, '255,255,255'); }
else { t.tv = 0; t.state = 'down'; t.rest = 0; }
}
} else {
t.omega *= 0.97;
t.spin += t.omega * dtS;
t.rest += FIXED_DT;
if (t.rest > 1100) {
t.state = 'spin'; t.omega = OMEGA_START; t.tilt = 0.08;
addPulse(t.x, t.y + 4, '168,230,207');
}
}
}
function stepWorld() {
for (var i = 0; i < tops.length; i += 1) stepTop(tops[i]);
stepPulses();
}
function drawLimitLines(t) {
if (t.state !== 'spin') return;
ctx.setLineDash([5, 5]);
ctx.strokeStyle = 'rgba(255,255,255,0.28)';
ctx.lineWidth = 1.5;
for (var sgn = -1; sgn <= 1; sgn += 2) {
ctx.beginPath();
ctx.moveTo(t.x, t.y);
ctx.lineTo(t.x + sgn * Math.sin(TILT_LIMIT) * 120, t.y - Math.cos(TILT_LIMIT) * 96);
ctx.stroke();
}
ctx.setLineDash([]);
}
function drawWorld() {
drawArena();
for (var i = 0; i < tops.length; i += 1) drawLimitLines(tops[i]);
drawPulses();
for (var j = 0; j < tops.length; j += 1) drawTop(tops[j]);
}
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 = null;
var acc = 0;
function tick(ts) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 20; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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 "./SpinTopSceneTumbleFallJudge.module.scss";
type Pal = { a: string; b: string; mid: string; body: string };
type Top = { x: number; y: number; vx: number; vy: number; omega: number; tilt: number; az: number; spin: number; R: number; pal: Pal; kick: number; tipX: number; tipY: number; state: string; tv: number; rest: number };
type Pulse = { x: number; y: number; born: number; rgb: string };
type Pt = { x: number; y: number };
export default function SpinTopSceneTumbleFallJudge() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
const canvasEl = canvasRef.current;
if (!rootEl || !canvasEl) 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;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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: number) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(435008);
function clamp(v: number, lo: number, hi: number) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Array<() => void> = [];
function makeTop(x: number, y: number, palIndex: number, R: number, omega: number): Top {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y, state: 'spin', tv: 0, rest: 0 };
}
var pulses: Pulse[] = [];
function addPulse(x: number, y: number, rgb?: string) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live: Pulse[] = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr: Pt[], rgb: string, w: number) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R: number, n: number, base: number, alpha: number, color: string) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t: Top) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t: Top, rest: number, margin: number) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a: Top, b: Top, rest: number, spinMix: number, kickAmt: number) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 각속도가 낮아질수록 기울기가 커지다가, 각속도가 FALL_OMEGA 밑이고 기울기가 TILT_LIMIT 를 넘는 순간
// 쓰러짐 상태로 바꾼다. 쓰러지는 동안은 중력 가속을 기울기 속도에 더하고 바닥에 닿으면 반발로 한 번 튕긴 뒤 눕는다.
var TILT_LIMIT = 0.62;
var FALL_OMEGA = 9;
var OMEGA_START = 30;
var OMEGA_FADE = 0.7;
var tops = [makeTop(96, 150, 1, 32, 30), makeTop(224, 150, 3, 32, 14)];
tops.forEach(function (t) { t.state = 'spin'; t.tv = 0; t.rest = 0; });
function judgeTumble(t: Top) {
var slow = 1 - clamp(t.omega / OMEGA_START, 0, 1);
t.tilt = 0.08 + 0.95 * Math.pow(slow, 2.2);
return t.omega < FALL_OMEGA && t.tilt > TILT_LIMIT;
}
function stepTop(t: Top) {
if (t.state === 'spin') {
t.omega *= Math.exp(-OMEGA_FADE * dtS);
t.spin += t.omega * dtS;
t.az += (3 + (1 - t.omega / OMEGA_START) * 4) * dtS;
if (judgeTumble(t)) { t.state = 'fall'; t.tv = 0.5; t.az = 0.35 + (t.x < 160 ? 0 : 2.4); }
} else if (t.state === 'fall') {
t.spin += t.omega * dtS;
t.tv += 18 * Math.sin(t.tilt) * dtS;
t.tilt += t.tv * dtS;
if (t.tilt >= 1.5) {
t.tilt = 1.5;
if (t.tv > 0.9) { t.tv = -t.tv * 0.28; addPulse(t.x, t.y + 4, '255,255,255'); }
else { t.tv = 0; t.state = 'down'; t.rest = 0; }
}
} else {
t.omega *= 0.97;
t.spin += t.omega * dtS;
t.rest += FIXED_DT;
if (t.rest > 1100) {
t.state = 'spin'; t.omega = OMEGA_START; t.tilt = 0.08;
addPulse(t.x, t.y + 4, '168,230,207');
}
}
}
function stepWorld() {
for (var i = 0; i < tops.length; i += 1) stepTop(tops[i]);
stepPulses();
}
function drawLimitLines(t: Top) {
if (t.state !== 'spin') return;
ctx.setLineDash([5, 5]);
ctx.strokeStyle = 'rgba(255,255,255,0.28)';
ctx.lineWidth = 1.5;
for (var sgn = -1; sgn <= 1; sgn += 2) {
ctx.beginPath();
ctx.moveTo(t.x, t.y);
ctx.lineTo(t.x + sgn * Math.sin(TILT_LIMIT) * 120, t.y - Math.cos(TILT_LIMIT) * 96);
ctx.stroke();
}
ctx.setLineDash([]);
}
function drawWorld() {
drawArena();
for (var i = 0; i < tops.length; i += 1) drawLimitLines(tops[i]);
drawPulses();
for (var j = 0; j < tops.length; j += 1) drawTop(tops[j]);
}
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: number | null = null;
var acc = 0;
function tick(ts: number) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 20; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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]();
}
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} />
<p className={styles.bp__caption}>기울기 각도가 한계 값을 넘는 프레임에서 팽이를 쓰러짐 상태로 바꾸고, 몸통을 옆으로 눕히며 바닥을 따라 짧게 구르다 그림자까지 납작해지게 한다.</p>
</div>
</main>
);
}08 · 傾きが限界を超えると倒れる転倒判定 — 計測 累積面積 22.438% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function stepTop(t) {
if (t.state === 'spin') {
t.omega *= Math.exp(-OMEGA_FADE * dtS);
t.spin += t.omega * dtS;
t.az += (3 + (1 - t.omega / OMEGA_START) * 4) * dtS;
if (judgeTumble(t)) { t.state = 'fall'; t.tv = 0.5; t.az = 0.35 + (t.x < 160 ? 0 : 2.4); }
} else if (t.state === 'fall') {
t.spin += t.omega * dtS;
t.tv += 18 * Math.sin(t.tilt) * dtS;
t.tilt += t.tv * dtS;
if (t.tilt >= 1.5) {
t.tilt = 1.5;
if (t.tv > 0.9) { t.tv = -t.tv * 0.28; addPulse(t.x, t.y + 4, '255,255,255'); }
else { t.tv = 0; t.state = 'down'; t.rest = 0; }
}
} else {
t.omega *= 0.97;
t.spin += t.omega * dtS;
t.rest += FIXED_DT;
if (t.rest > 1100) {
t.state = 'spin'; t.omega = OMEGA_START; t.tilt = 0.08;
addPulse(t.x, t.y + 4, '168,230,207');
}
}
}
項目08はフレームの22.4%を平均強度68.4で塗り替え、23/23フレームで動きが測定されました。
09角速度・歳差・衝突を一枚にまとめたヒーローアリーナ
heroStep() は半径24のコマ三つを一つのループで回し、角速度の減衰(HERO_DECAY 0.22)・歳差(HERO_PRECESSION_GAIN 0.9)・中心への引力・床の摩擦(HERO_FRICTION 0.995)と、全ての組への pairCollide() を同時に計算します。ぶつかり合って遅くなり、角速度が9を下回ったコマは全速に戻されるのでヒーロー背景として続きますが、勝ち負けのコードはなく、決着のルールは自分で足す必要があります。

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<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__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; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}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__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;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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 rand = seedRand(435009);
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
function makeTop(x, y, palIndex, R, omega) {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses = [];
function addPulse(x, y, rgb) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr, x, y, max) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr, rgb, w) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R, n, base, alpha, color) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t, rest, margin) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a, b, rest, spinMix, kickAmt) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 각속도 감쇠(01)와 세차(02)와 두 팽이 충돌(05)을 한 경기장에서 같이 돌린다.
// 각속도가 줄면 기울기가 커지고 세차가 빨라지며, 세 팽이는 서로 부딪혀 각속도를 맞바꾸다 바닥 마찰로 천천히 떠돈다.
var HERO_OMEGA_START = 36;
var HERO_DECAY = 0.22;
var HERO_PRECESSION_GAIN = 0.9;
var HERO_RESTITUTION = 0.9;
var HERO_FRICTION = 0.995;
var HERO_DRIFT = 50;
var heroTops = [
makeTop(70, 110, 0, 24, 36), makeTop(250, 120, 3, 24, -30), makeTop(160, 175, 1, 24, 26)
];
heroTops[0].vx = 50; heroTops[1].vx = -50; heroTops[2].vy = -40;
var heroTrails = [[], [], []];
function heroStep() {
var i, j, t;
for (i = 0; i < heroTops.length; i += 1) {
t = heroTops[i];
t.omega *= Math.exp(-HERO_DECAY * dtS);
t.spin += t.omega * dtS;
var slow = 1 - clamp(Math.abs(t.omega) / HERO_OMEGA_START, 0, 1);
t.kick = Math.max(0, t.kick - dtS * 0.9);
t.tilt = HERO_PRECESSION_GAIN * 0.3 * (1 + slow) + t.kick * 0.5;
t.az += 150 * Math.sin(t.tilt) / Math.max(Math.abs(t.omega), 8) * dtS;
var cxs = 160 - t.x, cys = 120 - t.y, d = Math.hypot(cxs, cys) || 1;
t.vx += (cxs / d * 14 + Math.cos(t.az) * HERO_DRIFT * 0.4) * dtS;
t.vy += (cys / d * 14 + Math.sin(t.az) * HERO_DRIFT * 0.3) * dtS;
t.vx *= HERO_FRICTION; t.vy *= HERO_FRICTION;
t.x += t.vx * dtS; t.y += t.vy * dtS;
wallBounce(t, 0.85, 6);
if (elapsed % 48 === 0) pushTrail(heroTrails[i], t.x, t.y + 3, 70);
if (Math.abs(t.omega) < 9) {
t.omega = (i === 1 ? -1 : 1) * HERO_OMEGA_START;
addPulse(t.x, t.y + 4, '255,241,201');
}
}
for (i = 0; i < heroTops.length; i += 1) {
for (j = i + 1; j < heroTops.length; j += 1) pairCollide(heroTops[i], heroTops[j], HERO_RESTITUTION, 0.3, 0.5);
}
}
function stepWorld() { heroStep(); stepPulses(); }
function drawWorld() {
drawArena();
var rgbs = ['255,179,199', '159,216,255', '168,230,207'];
for (var i = 0; i < heroTops.length; i += 1) drawTrail(heroTrails[i], rgbs[i], 7);
drawPulses();
var order = heroTops.slice().sort(function (a, b) { return a.y - b.y; });
for (var j = 0; j < order.length; j += 1) drawTop(order[j]);
}
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 = null;
var acc = 0;
function tick(ts) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 60; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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 "./SpinTopSceneHeroArena.module.scss";
type Pal = { a: string; b: string; mid: string; body: string };
type Top = { x: number; y: number; vx: number; vy: number; omega: number; tilt: number; az: number; spin: number; R: number; pal: Pal; kick: number; tipX: number; tipY: number };
type Pulse = { x: number; y: number; born: number; rgb: string };
type Pt = { x: number; y: number };
export default function SpinTopSceneHeroArena() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
const canvasEl = canvasRef.current;
if (!rootEl || !canvasEl) 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;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
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: number) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(435009);
function clamp(v: number, lo: number, hi: number) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Array<() => void> = [];
function makeTop(x: number, y: number, palIndex: number, R: number, omega: number): Top {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses: Pulse[] = [];
function addPulse(x: number, y: number, rgb?: string) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live: Pulse[] = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr: Pt[], rgb: string, w: number) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
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 g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R: number, n: number, base: number, alpha: number, color: string) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t: Top) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t: Top, rest: number, margin: number) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a: Top, b: Top, rest: number, spinMix: number, kickAmt: number) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 각속도 감쇠(01)와 세차(02)와 두 팽이 충돌(05)을 한 경기장에서 같이 돌린다.
// 각속도가 줄면 기울기가 커지고 세차가 빨라지며, 세 팽이는 서로 부딪혀 각속도를 맞바꾸다 바닥 마찰로 천천히 떠돈다.
var HERO_OMEGA_START = 36;
var HERO_DECAY = 0.22;
var HERO_PRECESSION_GAIN = 0.9;
var HERO_RESTITUTION = 0.9;
var HERO_FRICTION = 0.995;
var HERO_DRIFT = 50;
var heroTops = [
makeTop(70, 110, 0, 24, 36), makeTop(250, 120, 3, 24, -30), makeTop(160, 175, 1, 24, 26)
];
heroTops[0].vx = 50; heroTops[1].vx = -50; heroTops[2].vy = -40;
var heroTrails = [[], [], []];
function heroStep() {
var i, j, t;
for (i = 0; i < heroTops.length; i += 1) {
t = heroTops[i];
t.omega *= Math.exp(-HERO_DECAY * dtS);
t.spin += t.omega * dtS;
var slow = 1 - clamp(Math.abs(t.omega) / HERO_OMEGA_START, 0, 1);
t.kick = Math.max(0, t.kick - dtS * 0.9);
t.tilt = HERO_PRECESSION_GAIN * 0.3 * (1 + slow) + t.kick * 0.5;
t.az += 150 * Math.sin(t.tilt) / Math.max(Math.abs(t.omega), 8) * dtS;
var cxs = 160 - t.x, cys = 120 - t.y, d = Math.hypot(cxs, cys) || 1;
t.vx += (cxs / d * 14 + Math.cos(t.az) * HERO_DRIFT * 0.4) * dtS;
t.vy += (cys / d * 14 + Math.sin(t.az) * HERO_DRIFT * 0.3) * dtS;
t.vx *= HERO_FRICTION; t.vy *= HERO_FRICTION;
t.x += t.vx * dtS; t.y += t.vy * dtS;
wallBounce(t, 0.85, 6);
if (elapsed % 48 === 0) pushTrail(heroTrails[i], t.x, t.y + 3, 70);
if (Math.abs(t.omega) < 9) {
t.omega = (i === 1 ? -1 : 1) * HERO_OMEGA_START;
addPulse(t.x, t.y + 4, '255,241,201');
}
}
for (i = 0; i < heroTops.length; i += 1) {
for (j = i + 1; j < heroTops.length; j += 1) pairCollide(heroTops[i], heroTops[j], HERO_RESTITUTION, 0.3, 0.5);
}
}
function stepWorld() { heroStep(); stepPulses(); }
function drawWorld() {
drawArena();
var rgbs = ['255,179,199', '159,216,255', '168,230,207'];
for (var i = 0; i < heroTops.length; i += 1) drawTrail(heroTrails[i], rgbs[i], 7);
drawPulses();
var order = heroTops.slice().sort(function (a, b) { return a.y - b.y; });
for (var j = 0; j < order.length; j += 1) drawTop(order[j]);
}
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: number | null = null;
var acc = 0;
function tick(ts: number) {
if (lastTs === null) 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);
}
for (var w0 = 0; w0 < 60; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
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]();
}
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} />
<p className={styles.bp__caption}>원형 경기장 안에서 여러 팽이를 서로 다른 각속도로 시작시켜 감쇠·세차·충돌을 한 화면에서 동시에 돌리고, 마지막까지 서 있는 팽이를 가리는 히어로 장면을 만든다.</p>
</div>
</main>
);
}09 · 角速度・歳差・衝突を一枚にまとめたヒーローアリーナ — 計測 累積面積 28.945% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
for (i = 0; i < heroTops.length; i += 1) {
t = heroTops[i];
t.omega *= Math.exp(-HERO_DECAY * dtS);
t.spin += t.omega * dtS;
var slow = 1 - clamp(Math.abs(t.omega) / HERO_OMEGA_START, 0, 1);
t.kick = Math.max(0, t.kick - dtS * 0.9);
t.tilt = HERO_PRECESSION_GAIN * 0.3 * (1 + slow) + t.kick * 0.5;
t.az += 150 * Math.sin(t.tilt) / Math.max(Math.abs(t.omega), 8) * dtS;
var cxs = 160 - t.x, cys = 120 - t.y, d = Math.hypot(cxs, cys) || 1;
t.vx += (cxs / d * 14 + Math.cos(t.az) * HERO_DRIFT * 0.4) * dtS;
t.vy += (cys / d * 14 + Math.sin(t.az) * HERO_DRIFT * 0.3) * dtS;
t.vx *= HERO_FRICTION; t.vy *= HERO_FRICTION;
t.x += t.vx * dtS; t.y += t.vy * dtS;
wallBounce(t, 0.85, 6);
if (elapsed % 48 === 0) pushTrail(heroTrails[i], t.x, t.y + 3, 70);
if (Math.abs(t.omega) < 9) {
t.omega = (i === 1 ? -1 : 1) * HERO_OMEGA_START;
addPulse(t.x, t.y + 4, '255,241,201');
}
}
項目09はフレームの28.9%を平均強度64.0で塗り替え、23/23フレームで動きが測定されました。
技法別に見るコマ回転のコード
九つの項目を並べると、再描画面積が最も大きいのは05(40.6%)で、二つのコマがキャンバスを横切り、衝突のたびに波紋が加わります。最も小さいのは06(15.6%)で、コマは一か所に留まりポインターのリングだけが動きます。項目04は例外で、1フレームの最大は3.9%しかありませんが、床の軌跡が積み重なってループ全体では25.7%になります。平均強度は07の60.4から01の76.2までで、最も低いのは縞が帯に融合する07です。
| 項目 | 技法 | 再描画面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|---|
| 01 | 角速度の減衰 | 23.0% | 76.2 | 23/23 フレーム |
| 02 | 歳差の軸の円 | 23.1% | 64.6 | 23/23 フレーム |
| 03 | 増えていく傾きの振れ幅 | 20.2% | 68.2 | 23/23 フレーム |
| 04 | 床の摩擦の軌跡 | 25.7% | 60.8 | 23/23 フレーム |
| 05 | 二つのコマの衝突 | 40.6% | 72.1 | 23/23 フレーム |
| 06 | ポインターの回転力 | 15.6% | 73.1 | 23/23 フレーム |
| 07 | 残像の縞 | 20.4% | 60.4 | 23/23 フレーム |
| 08 | 転倒の判定 | 22.4% | 68.4 | 23/23 フレーム |
| 09 | ヒーローアリーナ | 28.9% | 64.0 | 23/23 フレーム |
値が場面を変える場所 — 角速度・摩擦・歳差係数
場面の見え方はほとんど三組の定数で決まります。減衰の割合(ANGULAR_DECAY・AXIS_DECAY・OMEGA_FADE・HERO_DECAY)は回転がどれだけ続くかを決め、摩擦と移動の定数(FLOOR_FRICTION・DRIFT_ACCEL・HERO_FRICTION)はコマがどこまでさまようかを決め、係数と振れ幅の定数(PRECESSION_GAIN・TILT_AMP_MAX・TILT_LIMIT)はどこまで傾くかを決めます。どれもループの構造には触れず、ステップ関数がすでに読んでいる数を拡大縮小するだけで、これは 跳ねるボール アニメーション 9選 — 衝突と重力の物理計算 の反発係数の扱いと同じ考え方です。コマ 回転速度の変わり方を一つずつ見たいときは、すぐ下の変形の表が一行につき値を一つだけ変えています。
ただし、コマ 回転 模様や動きを剛体の力学で厳密に導く式はコードにありません。どの場面も手で調整した近似で、角速度は割合で落ち、傾きは残った速度の関数で、力を積分する計算は一つもありません。だからスマートフォンでも動き、定数を変えても式が壊れません。回る盤に同じ指数の減衰を使う例は web ルーレット — 弾いて回し摩擦で止まる回転盤キャンバス9種 にもあります。
どこで壊れるか — 止まる直前・狭い画面・衝突が重なるとき
止まる直前はコマのアニメーションが最も壊れやすい場所で、このコードは二か所で守っています。precessAxis() は Math.max(t.omega, 8) で割るので、角速度がゼロに近づいても軸の速さが発散せず、ループする場面は項目01の4や項目09の9のような下限を割ると速度を戻します。項目08の転倒判定にも細かい点があり、傾きの式 0.08 + 0.95 × slow^2.2 では傾きの限界0.62を超えるのは角速度がおよそ6.8を下回ったときなので、コマが倒れる時点では FALL_OMEGA の9の条件はすでに満たされています。狭い画面では fit() がキャンバスを枠の幅に合わせ、変わるのは描画の倍率 sc だけで、物理は幅320・高さ200の固定の空間で動き続けるので、場面の中身は変わらずコマが小さく見えます。三つ目の弱点は衝突で、項目09のループは組を一組ずつ順に処理するため、最初の組に押されたコマが自分の番の前に次の組と重なることがあり、pairCollide() はすでに離れつつある組には衝撃を与えません。九つの index.html はパスワード tqsqe3wp の zip にまとめてあるので、定数を大きくして自分の場面が壊れるところまで試せます。
変形3つとアクセシビリティ
| 変形 | 値 | 感じ方 |
|---|---|---|
| 歳差係数を調整する | 02 PRECESSION_GAIN 0.6 → 1.4 |
全速のときの傾きは係数の0.7倍なので0.42から0.98に増え、軸の先端が床の上で大きな円を描いて胴が大きくふらつき、値を小さくするとコマはほぼまっすぐ立って回ります。 |
| 床の摩擦率を調整する | 04 FLOOR_FRICTION 0.992 → 0.97 |
値を下げるのは摩擦を強めることで、移動の力が保てる速さがおよそ4分の1に落ちてコマはあまり動き回らず、値を上げると長く滑って床に長い軌跡を残します。 |
| 残像の縞の本数を調整する | 07 STRIPE_COUNT 6 → 12 |
本数を増やすと縞が細かくなり、にじみ始める角速度がおよそ14.7からおよそ7.3に下がり、減らすと太い縞が遅くなるまではっきり残ります。 |
九つの Canvas API の場面はすべて、requestAnimationFrame のループを始める前に matchMedia で prefers-reduced-motion を読みます。動きを減らす設定のときは九つとも固定ステップの短い予熱(項目01〜09で順に30・40・40・30・30・20・40・20・60)を回し、落ち着いた一枚を描いてループを始めません。tabindex="0" を持ち入力を受けるのは項目06だけで、ポインターのドラッグ、左右の矢印キー、スペースキーがアニメーションの停止中も回転を変え、台本のスワイプは無効になります。
コードはコマの傾きを何で計算しているのか?
どの場面でも傾きは失われた角速度の関数で、slow = 1 - omega / start と書けます。項目01は 0.06 + 0.5 × slow²、項目03は振れ幅に正弦を掛け、項目08は slow の2.2乗を使い、項目02は係数から傾きを決めます。drawTop() はその傾きと軸の向き az の正弦・余弦から軸の上端を置き、胴は傾きが大きいほど縦に潰れます。遠近の床のグリッド、コマの下の光だまり、半透明の回転の残像の円盤は表示層で、速度・傾き・位置・衝突の衝撃が計算の本体です。計算して描くという同じ考え方は html5 canvas アニメーション9種 — 水滴とブラックホール と 物理シミュレーション 作り方 9選 — バネから慣性スクラブまで にも通じます。
コマの場面を使う前によくある質問
コマ 回転 時間を長くするには、どの値を変えればいいですか?
項目01なら ANGULAR_DECAY を小さくします。割合が小さいほど角速度が落ちる速さが緩やかになり、OMEGA_START に戻るまでの時間が延びます。摩擦の FLOOR_FRICTION は逆に、1に近いほど長く滑ります。
項目06は紐を引くコマのように動きますか?
いいえ、紐はコードにありません。代わりに、なでた動きとコマの中心からのベクトルの外積が回転力になり、スペースキーを押し続けるとコマがすでに回っている向きに速度が上がり続けます。
この九つのうち一つだけを自分のページに入れられますか?
はい。各場面は、自分のキャンバス・定数・ステップ関数を持つ一つの index.html に収まっているので、一つのコマだけをヒーロー部分に取り出しても、残り八つのコードを持ち込む必要はありません。









