Skip to content

Commit 7e63acf

Browse files
committed
feat(website): add playable chronos canvas demo with six-state machine
1 parent 2337f8e commit 7e63acf

1 file changed

Lines changed: 170 additions & 0 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// @ai-context
2+
// Chronos 可玩演示:自研轻量 Canvas 2D 粒子引擎,六态参数化状态机,
3+
// 与产品内两步式交互同构(沉睡→呼吸→专注)。不依赖 three.js,保首屏。
4+
// Chronos playable demo: self-built Canvas 2D particle engine.
5+
// Why: 宣传视觉资产需零依赖、60fps、reduced-motion 可降级;参数复用 lib/features/chronos.ts。
6+
"use client";
7+
8+
import { useEffect, useRef, useState } from "react";
9+
import { CHRONOS_STATES, STATE_STYLE, type ChronosState } from "@/lib/features/chronos";
10+
11+
const PARTICLE_COUNT = 300;
12+
/** 专注态粒子消散循环周期(秒):模拟倒计时沙漏 */
13+
const FOCUS_CYCLE_S = 24;
14+
/** 透视投影焦距 */
15+
const FOCAL = 3.2;
16+
17+
interface Particle {
18+
theta: number;
19+
phi: number;
20+
r: number;
21+
speed: number;
22+
phase: number;
23+
}
24+
25+
interface ChronosDemoProps {
26+
/** 受控形态(六态卡片点击传入);null = 自由交互(点击推进) */
27+
controlledState?: ChronosState | null;
28+
onStateChange?: (s: ChronosState) => void;
29+
}
30+
31+
export function ChronosDemo({ controlledState = null, onStateChange }: ChronosDemoProps) {
32+
const canvasRef = useRef<HTMLCanvasElement>(null);
33+
const [freeState, setFreeState] = useState<ChronosState>("asleep");
34+
const state = controlledState ?? freeState;
35+
const stateRef = useRef(state);
36+
useEffect(() => { stateRef.current = state; }, [state]);
37+
38+
// 自由交互:沉睡→呼吸→专注→沉睡
39+
const handleTap = () => {
40+
if (controlledState) return;
41+
const next: ChronosState =
42+
state === "asleep" ? "breathing" : state === "breathing" ? "focus" : "asleep";
43+
setFreeState(next);
44+
onStateChange?.(next);
45+
};
46+
47+
useEffect(() => {
48+
const canvas = canvasRef.current;
49+
if (!canvas) return;
50+
const ctx = canvas.getContext("2d");
51+
if (!ctx) return;
52+
53+
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
54+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
55+
const size = Math.min(canvas.clientWidth, canvas.clientHeight);
56+
canvas.width = size * dpr;
57+
canvas.height = size * dpr;
58+
ctx.scale(dpr, dpr);
59+
60+
// 球面分布(带厚度)+ 增量角度漂移
61+
const particles: Particle[] = Array.from({ length: PARTICLE_COUNT }, () => ({
62+
theta: Math.random() * Math.PI * 2,
63+
phi: Math.acos(2 * Math.random() - 1),
64+
r: 0.55 + Math.random() * 0.45,
65+
speed: 0.05 + Math.random() * 0.12,
66+
phase: Math.random() * Math.PI * 2,
67+
}));
68+
69+
const cx = size / 2;
70+
const cy = size / 2;
71+
let raf = 0;
72+
const start = performance.now();
73+
74+
const render = (now: number) => {
75+
const elapsed = (now - start) / 1000;
76+
const style = STATE_STYLE[stateRef.current];
77+
const [cr, cg, cb] = style.color;
78+
79+
ctx.clearRect(0, 0, size, size);
80+
ctx.globalCompositeOperation = "lighter";
81+
82+
// 心跳脉动:breathing 为 1Hz(60bpm),其余弱脉动
83+
const pulse = style.pulse * (0.5 + 0.5 * Math.sin(elapsed * Math.PI * 2));
84+
const focusProgress = (elapsed % FOCUS_CYCLE_S) / FOCUS_CYCLE_S;
85+
86+
// 中心光晕
87+
const glowR = size * (0.32 + pulse * 0.06);
88+
const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, glowR);
89+
glow.addColorStop(0, `rgba(${cr},${cg},${cb},${style.glow})`);
90+
glow.addColorStop(1, "rgba(0,0,0,0)");
91+
ctx.fillStyle = glow;
92+
ctx.fillRect(0, 0, size, size);
93+
94+
// 全局自转 + 微摆
95+
const rotY = elapsed * 0.12;
96+
const rotX = Math.sin(elapsed * 0.08) * 0.25;
97+
98+
particles.forEach((p, i) => {
99+
// 专注态:粒子按索引顺序随进度消散(沙漏隐喻)
100+
if (style.dissipate && i / PARTICLE_COUNT < focusProgress) return;
101+
102+
const theta = p.theta + elapsed * p.speed * style.drift + rotY;
103+
const phi = p.phi + rotX;
104+
const radius = p.r * style.scale * (1 + pulse * 0.08);
105+
const x = radius * Math.sin(phi) * Math.cos(theta);
106+
const y = radius * Math.sin(phi) * Math.sin(theta);
107+
const z = radius * Math.cos(phi);
108+
109+
// 透视投影:近大远小 + 深度亮度
110+
const proj = FOCAL / (FOCAL - z);
111+
const px = cx + x * (size / 2) * proj;
112+
const py = cy + y * (size / 2) * proj;
113+
const depth = (z + 1) / 2;
114+
const alpha =
115+
(0.25 + 0.55 * depth) * (style.dissipate ? 1 - focusProgress * 0.6 : 1);
116+
const pr = Math.max((0.7 + 1.3 * depth) * proj * style.scale, 0.3);
117+
118+
ctx.beginPath();
119+
ctx.arc(px, py, pr, 0, Math.PI * 2);
120+
ctx.fillStyle = `rgba(${cr},${cg},${cb},${alpha})`;
121+
ctx.fill();
122+
});
123+
124+
// 划时代点:金色涟漪扩散
125+
if (stateRef.current === "milestone") {
126+
const ringT = (elapsed % 3) / 3;
127+
ctx.beginPath();
128+
ctx.arc(cx, cy, size * (0.2 + ringT * 0.35), 0, Math.PI * 2);
129+
ctx.strokeStyle = `rgba(251,191,36,${0.5 * (1 - ringT)})`;
130+
ctx.lineWidth = 2;
131+
ctx.stroke();
132+
}
133+
134+
raf = requestAnimationFrame(render);
135+
};
136+
137+
if (reduced) {
138+
// 无障碍降级:静态一帧渐变球
139+
const [cr, cg, cb] = STATE_STYLE.asleep.color;
140+
const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, size * 0.4);
141+
glow.addColorStop(0, `rgba(${cr},${cg},${cb},0.5)`);
142+
glow.addColorStop(1, "rgba(0,0,0,0)");
143+
ctx.fillStyle = glow;
144+
ctx.fillRect(0, 0, size, size);
145+
} else {
146+
raf = requestAnimationFrame(render);
147+
}
148+
149+
return () => cancelAnimationFrame(raf);
150+
}, []);
151+
152+
const meta = CHRONOS_STATES.find((s) => s.key === state);
153+
154+
return (
155+
<div
156+
className="relative w-[clamp(240px,52vmin,420px)] h-[clamp(240px,52vmin,420px)] mx-auto cursor-pointer select-none"
157+
onClick={handleTap}
158+
role="button"
159+
aria-label="Chronos 时间生物演示,点击切换形态"
160+
>
161+
<canvas ref={canvasRef} className="w-full h-full" />
162+
<div
163+
className="absolute bottom-3 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full text-xs text-kb-text2 whitespace-nowrap"
164+
style={{ background: "var(--kb-bg-tertiary)" }}
165+
>
166+
{meta?.icon} {meta?.name}
167+
</div>
168+
</div>
169+
);
170+
}

0 commit comments

Comments
 (0)