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