Skip to content

Commit 84675b1

Browse files
committed
feat(labs): add Easingの魔導書 interactive tome at /labs/easing
Claude Design からエクスポートされた HTML プロトタイプ (Tome of Easing) を Next.js 15 / React 19 / TypeScript に 完コピで移植。RPG世界観で Easing 関数を6章立てで解説する。 styles は .easing-tome クラスに scoped。 3点の仕様変更: - RotationArena: translate(-50%,-100%) rotate() の合成で武器が 回転に合わせてドリフトしていたのを、外側 anchor + 内側 rotation の二層構造に分離。斧の startAngle を -160°→-90° に調整して 画面外スタートを解消。 - ReferenceTome: 曲線グラフ・投擲デモ・数式の列を撤去し、 Easing 関数名のグリッドのみに簡略化。Noto Serif JP ベースで 可読性重視のフォント指定に変更。 - 各関数カードクリックで Radix Dialog を開き、毎フレーム更新 ベースの擬似コード (velocity += a*dt; position += v*dt) を 表示。着弾点を事前計算しないゲームロジックでも書ける形に 言い換え、各式の物理モデル名 (constant acceleration / linear drag / critically-damped spring / under-damped spring / low-damping spring / gravity + restitution / constant jerk) を 併記。
1 parent 7196433 commit 84675b1

11 files changed

Lines changed: 2507 additions & 0 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
'use client';
2+
3+
import { useEffect, useMemo, useRef, useState } from 'react';
4+
import { EASINGS, EASING_KEYS, SIGILS, type EasingKey } from '../easings';
5+
6+
const DURATION = 1800;
7+
const REST = 600;
8+
9+
export default function CurveVisualizer() {
10+
const [selected, setSelected] = useState<EasingKey>('easeOutBack');
11+
const [t, setT] = useState(0);
12+
const rafRef = useRef<number | null>(null);
13+
const startRef = useRef<number | null>(null);
14+
15+
useEffect(() => {
16+
startRef.current = null;
17+
const tick = (now: number) => {
18+
if (startRef.current === null) startRef.current = now;
19+
const elapsed = (now - startRef.current) % (DURATION + REST);
20+
if (elapsed > DURATION) setT(1);
21+
else setT(elapsed / DURATION);
22+
rafRef.current = requestAnimationFrame(tick);
23+
};
24+
rafRef.current = requestAnimationFrame(tick);
25+
return () => {
26+
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
27+
};
28+
}, [selected]);
29+
30+
const easing = EASINGS[selected];
31+
32+
const W = 500;
33+
const H = 380;
34+
const PAD = 30;
35+
36+
const path = useMemo(() => {
37+
const pts: string[] = [];
38+
for (let i = 0; i <= 100; i++) {
39+
const x = i / 100;
40+
const y = easing.fn(x);
41+
const px = PAD + x * (W - PAD * 2);
42+
const py = H - PAD - y * (H - PAD * 2);
43+
pts.push(`${px.toFixed(1)},${py.toFixed(1)}`);
44+
}
45+
return 'M ' + pts.join(' L ');
46+
}, [easing]);
47+
48+
const curT = Math.min(1, t);
49+
const curY = easing.fn(curT);
50+
const dotX = PAD + curT * (W - PAD * 2);
51+
const dotY = H - PAD - curY * (H - PAD * 2);
52+
const orbPct = curY * 100;
53+
54+
return (
55+
<div className="chapter">
56+
<div className="chapter-mark">
57+
<span className="num">I</span>
58+
<span>CHAPTER ONE — 魔導書の基礎</span>
59+
</div>
60+
<h2 className="chapter-title">Easingとは、動きに「生命」を吹き込む魔法</h2>
61+
<p className="chapter-desc">
62+
ゲーム内のあらゆる動き — キャラクターの歩み、放たれた矢の軌跡、宝箱の開く瞬間 — これらが単調に感じるかワクワクするかは、「時間に対して値がどう変化するか」で決まる。Easing関数はその変化の形を司る古の術である。
63+
</p>
64+
65+
<div className="graph-panel">
66+
<div>
67+
<div className="graph-stage">
68+
<svg className="graph-svg" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet">
69+
<text x={PAD} y={H - 8} fontFamily="Cinzel" fontSize="11" fill="#c89b3c" opacity="0.7">TIME →</text>
70+
<text x={PAD - 22} y={PAD + 4} fontFamily="Cinzel" fontSize="11" fill="#c89b3c" opacity="0.7" transform={`rotate(-90 ${PAD - 22} ${PAD + 4})`}>VALUE →</text>
71+
72+
<line x1={PAD} y1={H - PAD} x2={W - PAD} y2={PAD} stroke="rgba(200,150,42,0.25)" strokeWidth="1.5" strokeDasharray="4 4" />
73+
<text x={W - PAD - 90} y={PAD + 18} fontFamily="Cinzel" fontSize="10" fill="rgba(200,150,42,0.5)" letterSpacing="2">LINEAR REF</text>
74+
75+
<line x1={PAD} y1={H - PAD} x2={W - PAD} y2={H - PAD} stroke="#c89b3c" strokeWidth="1.5" opacity="0.6" />
76+
<line x1={PAD} y1={H - PAD} x2={PAD} y2={PAD} stroke="#c89b3c" strokeWidth="1.5" opacity="0.6" />
77+
78+
<path d={path} fill="none" stroke={easing.color} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" style={{ filter: `drop-shadow(0 0 6px ${easing.color})` }} />
79+
80+
<line x1={dotX} y1={H - PAD} x2={dotX} y2={dotY} stroke={easing.color} strokeWidth="1" strokeDasharray="3 3" opacity="0.6" />
81+
<line x1={PAD} y1={dotY} x2={dotX} y2={dotY} stroke={easing.color} strokeWidth="1" strokeDasharray="3 3" opacity="0.6" />
82+
83+
<circle cx={dotX} cy={dotY} r="10" fill={easing.color} style={{ filter: `drop-shadow(0 0 12px ${easing.color})` }} />
84+
<circle cx={dotX} cy={dotY} r="4" fill="#fff" />
85+
86+
<text x={PAD - 5} y={H - PAD + 14} fontFamily="VT323" fontSize="14" fill="#8a8578" textAnchor="end">0</text>
87+
<text x={W - PAD + 5} y={H - PAD + 14} fontFamily="VT323" fontSize="14" fill="#8a8578">1</text>
88+
<text x={PAD - 8} y={PAD + 4} fontFamily="VT323" fontSize="14" fill="#8a8578" textAnchor="end">1</text>
89+
</svg>
90+
</div>
91+
92+
<div
93+
style={{
94+
marginTop: 16,
95+
background: '#1a1208',
96+
border: '2px solid var(--border-dark)',
97+
borderRadius: 4,
98+
padding: '20px 20px',
99+
position: 'relative',
100+
overflow: 'hidden',
101+
}}
102+
>
103+
<div style={{ position: 'absolute', top: 10, left: 20, fontFamily: 'Cinzel, serif', fontSize: 10, letterSpacing: '0.3em', color: 'var(--parchment-dark)', opacity: 0.7 }}>
104+
REAL-TIME DEMONSTRATION
105+
</div>
106+
<div
107+
style={{
108+
position: 'relative',
109+
height: 40,
110+
marginTop: 18,
111+
background: 'repeating-linear-gradient(90deg, rgba(200,150,42,0.15) 0 4px, transparent 4px 12px)',
112+
borderTop: '1px solid rgba(200,150,42,0.3)',
113+
borderBottom: '1px solid rgba(200,150,42,0.3)',
114+
}}
115+
>
116+
<div
117+
style={{
118+
position: 'absolute',
119+
top: '50%',
120+
left: `calc(${orbPct}% - 16px)`,
121+
transform: 'translateY(-50%)',
122+
width: 32,
123+
height: 32,
124+
borderRadius: '50%',
125+
background: `radial-gradient(circle at 30% 30%, #fff, ${easing.color} 70%)`,
126+
boxShadow: `0 0 24px ${easing.color}, 0 0 8px ${easing.color}`,
127+
border: '1px solid rgba(255,255,255,0.4)',
128+
}}
129+
/>
130+
</div>
131+
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8, fontFamily: 'VT323, monospace', fontSize: 14, color: '#8a8578' }}>
132+
<span>◆ START</span>
133+
<span>t = {curT.toFixed(2)} value = {curY.toFixed(2)}</span>
134+
<span>GOAL ◆</span>
135+
</div>
136+
</div>
137+
</div>
138+
139+
<div className="spell-list">
140+
<div style={{ fontFamily: 'Cinzel, serif', fontSize: 10, letterSpacing: '0.3em', color: 'var(--parchment-dark)', padding: '4px 4px 8px', opacity: 0.7 }}>
141+
✦ SELECT A TECHNIQUE
142+
</div>
143+
{EASING_KEYS.map((k) => {
144+
const e = EASINGS[k];
145+
const active = selected === k;
146+
return (
147+
<button key={k} className={`spell-option ${active ? 'active' : ''}`} onClick={() => setSelected(k)} type="button">
148+
<span className="spell-sigil" style={{ color: e.color, background: `${e.color}22` }}>
149+
{SIGILS[k]}
150+
</span>
151+
<span className="spell-names">
152+
<div className="spell-rpg">{e.rpg}</div>
153+
<div className="spell-tech">{e.en}</div>
154+
</span>
155+
</button>
156+
);
157+
})}
158+
</div>
159+
</div>
160+
161+
<div className="lore-plaque">
162+
<span className="badge">◈ LORE</span>
163+
<span className="text">
164+
<strong style={{ color: 'var(--gold-bright)' }}>{easing.rpg}</strong>
165+
({easing.rpgEn}) ──── {easing.desc}
166+
</span>
167+
</div>
168+
</div>
169+
);
170+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
'use client';
2+
3+
import { useEffect, useRef, useState } from 'react';
4+
import { EASINGS, type EasingKey } from '../easings';
5+
6+
const DUR = 1800;
7+
const PAUSE = 400;
8+
9+
function DuelTrack({ easingKey, glyph, token, color }: { easingKey: EasingKey; glyph: string; token: number; color: string }) {
10+
const [pos, setPos] = useState(0);
11+
const rafRef = useRef<number | null>(null);
12+
const startRef = useRef<number | null>(null);
13+
14+
useEffect(() => {
15+
startRef.current = null;
16+
const ez = EASINGS[easingKey].fn;
17+
const tick = (now: number) => {
18+
if (startRef.current === null) startRef.current = now;
19+
const elapsed = (now - startRef.current) % (DUR + PAUSE);
20+
if (elapsed > DUR) setPos(1);
21+
else setPos(ez(elapsed / DUR));
22+
rafRef.current = requestAnimationFrame(tick);
23+
};
24+
rafRef.current = requestAnimationFrame(tick);
25+
return () => {
26+
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
27+
};
28+
}, [easingKey, token]);
29+
30+
return (
31+
<div className="duel-track">
32+
<div
33+
className="duel-mover"
34+
style={{
35+
left: `calc(12px + ${pos * 88}%)`,
36+
transform: 'translateY(-50%) translateX(-50%)',
37+
background: `linear-gradient(180deg, ${color}, ${color}88)`,
38+
boxShadow: `0 0 14px ${color}`,
39+
}}
40+
>
41+
{glyph}
42+
</div>
43+
</div>
44+
);
45+
}
46+
47+
interface Scenario {
48+
title: string;
49+
tech: string;
50+
glyph: string;
51+
linear: EasingKey;
52+
eased: EasingKey;
53+
note: string;
54+
}
55+
56+
const SCENARIOS: Scenario[] = [
57+
{ title: 'メニュー出現', tech: 'MENU POP-IN', glyph: '📜', linear: 'linear', eased: 'easeOutBack', note: 'Linearだと冷たい。Ease Out Backで一度行き過ぎて戻ると「カチッ」と決まる。' },
58+
{ title: '剣の振り下ろし', tech: 'SWORD SWING', glyph: '⚔', linear: 'linear', eased: 'easeInCubic', note: 'Ease Inで終端に向けて加速。振り下ろしの重みが出る。' },
59+
{ title: '宝箱オープン', tech: 'TREASURE OPEN', glyph: '💎', linear: 'linear', eased: 'easeOutElastic', note: 'Elasticでバネのように震える。驚きと報酬感が倍増。' },
60+
{ title: 'コイン収集', tech: 'COIN COLLECT', glyph: '◉', linear: 'linear', eased: 'easeOutBounce', note: 'Bounceで床に落ちて跳ねる。物理的な「質量」を感じる。' },
61+
{ title: '魔法チャージ', tech: 'MANA CHARGE', glyph: '✦', linear: 'linear', eased: 'easeInQuad', note: 'Ease Inで徐々に加速。溜めの緊張感が生まれる。' },
62+
{ title: 'カメラパン', tech: 'CAMERA PAN', glyph: '◈', linear: 'linear', eased: 'easeInOutCubic', note: '両端で減速。酔わず、かつ劇的。映画的カメラワークの基本。' },
63+
];
64+
65+
export default function DuelPanel() {
66+
const [token, setToken] = useState(0);
67+
68+
return (
69+
<div className="chapter">
70+
<div className="chapter-mark">
71+
<span className="num">V</span>
72+
<span>CHAPTER FIVE — 術の決闘</span>
73+
</div>
74+
<h2 className="chapter-title">Linear vs Easing ── 一対一の勝負</h2>
75+
<p className="chapter-desc">
76+
同じゴール、同じ時間。違うのは「曲線」だけ。見比べれば一目瞭然である。
77+
</p>
78+
79+
<div style={{ textAlign: 'right', marginBottom: 12 }}>
80+
<button className="rune-button" onClick={() => setToken((x) => x + 1)} type="button">
81+
⚔ REMATCH
82+
</button>
83+
</div>
84+
85+
<div className="compare-panel">
86+
<div
87+
style={{
88+
display: 'grid',
89+
gridTemplateColumns: '140px 1fr 1fr',
90+
gap: 16,
91+
padding: '0 0 12px',
92+
borderBottom: '2px solid var(--gold-deep)',
93+
fontFamily: 'Cinzel, serif',
94+
fontSize: 10,
95+
letterSpacing: '0.3em',
96+
color: 'var(--gold-bright)',
97+
}}
98+
>
99+
<span>SCENE</span>
100+
<span style={{ textAlign: 'center' }}>◇ LINEAR ◇</span>
101+
<span style={{ textAlign: 'center' }}>◆ EASED ◆</span>
102+
</div>
103+
{SCENARIOS.map((s, i) => (
104+
<div
105+
key={i}
106+
style={{
107+
display: 'grid',
108+
gridTemplateColumns: '140px 1fr 1fr',
109+
gap: 16,
110+
alignItems: 'center',
111+
padding: '14px 0',
112+
borderBottom: i === SCENARIOS.length - 1 ? 'none' : '1px dashed rgba(200,150,42,0.2)',
113+
}}
114+
>
115+
<div className="duel-label">
116+
<div className="jp">{s.title}</div>
117+
<div className="tech">{s.tech}</div>
118+
</div>
119+
<DuelTrack easingKey={s.linear} glyph={s.glyph} token={token} color="#8a8578" />
120+
<DuelTrack easingKey={s.eased} glyph={s.glyph} token={token} color={EASINGS[s.eased].color} />
121+
<div
122+
style={{
123+
gridColumn: '2 / 4',
124+
marginTop: 6,
125+
fontSize: 12,
126+
color: 'var(--parchment-dark)',
127+
opacity: 0.75,
128+
fontStyle: 'italic',
129+
paddingLeft: 4,
130+
}}
131+
>
132+
{s.note}
133+
</div>
134+
</div>
135+
))}
136+
</div>
137+
</div>
138+
);
139+
}

0 commit comments

Comments
 (0)