11/**
2- * ChronosCanvas — 时间生物容器
2+ * ChronosCanvas — 时间生物容器(R3F 3D 渲染版)
33 *
4- * R3F Canvas 包裹 ChronosParticleField(粒子主体)+ ChronosCreature(辅助形态)。
5- * compact 尺寸上限提升至 480px(需求 1:普通视图 3D 足够大)。
6- * 中央 overlay 仅保留时间数字(需求 4:提示语移出球体,由父组件状态行承担 )。
4+ * R3F + WebGPU 渲染的 3D 粒子球体(ChronosSphere),替代 2D Canvas 版:
5+ * 保留 3D 球面坐标 + 透视投影的立体观感,渲染走 THREE.Points(GPU 高效),
6+ * 1500 粒子轻松 60fps,位置算法与 2D 版一致(静态分布 + 增量角度 )。
77 *
8- * 3D 空间:camera z=5.5 fov=50(视锥半高 ≈2.57),比原 z=5.0 fov=45 增大约 24%,
9- * 粒子场全态(asleep canopy≤2.0, radius=1.8)完整落在视锥内 。
8+ * compact 尺寸上限 480px;中央 overlay 仅保留时间数字(提示语移出球体)。
9+ * 时间显示:阶段开始 5s + hover 显示(父组件 showTime prop) 。
1010 *
11- * 时间显示:使用 CSS 动画实现呼吸级辉光脉冲(每周期 3s),与 60bpm 心跳同频反射 。
11+ * 降级策略:系统 reduced-motion / 性能 low 档 → degraded(减粒子),动画保留不静态化 。
1212 *
13- * 降级策略:系统 reduced-motion / 性能 low 档 → degraded(减粒子+跳帧+降 dpr),
14- * 但保留粒子形态与动画(永不静态化);仅 WebGL 不可用时回退 2D 静态。
15- *
16- * @ai -context: Chronos 渲染容器;描述符 particleMorphs,色板 chronosStyles。
13+ * @ai -context: Chronos 渲染容器;3D 粒子球 ChronosSphere,WebGPU 渲染后端。
1714 */
18- import { useMemo , useRef , memo } from 'react' ;
1915import { Canvas } from '@react-three/fiber' ;
16+ import { useRef , useState , memo } from 'react' ;
17+ import * as THREE from 'three' ;
18+ import WebGPURenderer from 'three/src/renderers/webgpu/WebGPURenderer.js' ;
2019import { useSceneTheme } from '@/lib/3d/hooks/useSceneTheme' ;
2120import { useReducedMotion } from '@/hooks/useReducedMotion' ;
2221import { usePerformanceModeStore } from '@/lib/performance/usePerformanceMode' ;
2322import { toChronosState } from './chronosState' ;
2423import { CHRONOS_PALETTES } from './chronosStyles' ;
2524import type { Mood } from './particleMorphs' ;
26- import { ChronosCreature } from './ChronosCreature' ;
27- import { ChronosParticleField } from './ChronosParticleField' ;
25+ import { ChronosSphere } from './ChronosSphere' ;
2826
2927/** 长按判定阈值(ms):长按=中止/放弃(回沉睡) */
3028const LONG_PRESS_MS = 800 ;
@@ -48,50 +46,12 @@ interface ChronosCanvasProps {
4846 onLongPress ?: ( ) => void ;
4947 /** 右键点击生物(沉浸模式入口,父组件处理) */
5048 onContextMenu ?: ( e : React . MouseEvent ) => void ;
49+ /** 阶段开始 5s 显示标记(父组件检测运行起始沿传入;hover 时同样显示) */
50+ showTime ?: boolean ;
5151 /** 中央显示内容(时间字符串) */
5252 timeStr : string ;
5353}
5454
55- /** WebGL 可用性检测(不可用时回退 2D 静态) */
56- function isWebGLAvailable ( ) : boolean {
57- try {
58- const c = document . createElement ( 'canvas' ) ;
59- return ! ! ( c . getContext ( 'webgl' ) || c . getContext ( 'experimental-webgl' ) ) ;
60- } catch {
61- return false ;
62- }
63- }
64-
65- /** 2D 静态兜底(仅 WebGL 不可用):显式宽高,只显示时间(状态行由父组件渲染) */
66- function ChronosStatic ( { mode, timeStr } : { mode : 'full' | 'compact' ; timeStr : string } ) {
67- return (
68- < div
69- className = { mode === 'full'
70- ? 'absolute inset-0 flex items-center justify-center'
71- : 'relative flex items-center justify-center w-[clamp(240px,52vmin,480px)] h-[clamp(240px,52vmin,480px)]' }
72- aria-label = { timeStr }
73- role = "timer"
74- >
75- < div
76- className = "rounded-full shrink-0"
77- style = { {
78- width : 'min(70%, 320px)' ,
79- minWidth : '160px' ,
80- aspectRatio : '1' ,
81- background : 'radial-gradient(circle at 35% 30%, rgba(34,211,238,0.25) 0%, #0C152433 55%, transparent 75%)' ,
82- border : '2px solid rgba(34,211,238,0.35)' ,
83- boxShadow : '0 0 40px rgba(34,211,238,0.2)' ,
84- } }
85- />
86- < div className = "absolute inset-0 flex flex-col items-center justify-center pointer-events-none" >
87- < span className = "font-timer font-light tracking-tight leading-none text-text-primary" style = { { fontSize : mode === 'full' ? 'clamp(3rem, 9vmin, 6rem)' : 'clamp(2rem, 8vmin, 4.5rem)' , fontVariantNumeric : 'tabular-nums' } } >
88- { timeStr }
89- </ span >
90- </ div >
91- </ div >
92- ) ;
93- }
94-
9555// P0-2 React.memo:TimerFace 因 completedCount/currentGoal 等非 tick 字段
9656// 重渲染时,粒子容器跳过 reconcile(timeStr 变化时 props 不等,正常重渲染)
9757export const ChronosCanvas = memo ( function ChronosCanvas ( {
@@ -107,22 +67,24 @@ export const ChronosCanvas = memo(function ChronosCanvas({
10767 onTap,
10868 onLongPress,
10969 onContextMenu,
70+ showTime = false ,
11071 timeStr,
11172} : ChronosCanvasProps ) {
11273 const theme = useSceneTheme ( ) ;
113- // 降级(不静态化):系统 reduced-motion 或性能 low 档 → 减粒子/跳帧/降 dpr
74+ // 降级(不静态化):系统 reduced-motion 或性能 low 档 → 减粒子
11475 const reduced = useReducedMotion ( ) ;
11576 const perfMode = usePerformanceModeStore ( ( s ) => s . mode ) ;
11677 const degraded = reduced || perfMode === 'low' ;
117- const webgl = useMemo ( ( ) => isWebGLAvailable ( ) , [ ] ) ;
11878
11979 const chronosState = toChronosState ( { isArmed, isRunning, isPaused, phase, isStepDive } ) ;
12080 const stateColor = CHRONOS_PALETTES [ theme ] [ chronosState ] . glow ;
12181 const progress = totalSeconds > 0 ? remainingSeconds / totalSeconds : 1 ;
12282
123- // P0-9:秒数变化心跳脉冲改 CSS animation 重启(key={remainingSeconds} 触发),
124- // 不再每秒 2 次 setState(脉冲开/关)造成额外 React 渲染
125- // 容器层统一手势(3D 与静态回退交互一致)
83+ // ── 时间显示隐藏化:父组件传入 showTime(阶段开始 5s),hover 粒子团时显示 ──
84+ const [ hovering , setHovering ] = useState ( false ) ;
85+ const timeVisible = showTime || hovering ;
86+
87+ // 容器层统一手势
12688 const pointerDownRef = useRef ( 0 ) ;
12789 const longPressTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
12890 const longPressFiredRef = useRef ( false ) ;
@@ -153,51 +115,45 @@ export const ChronosCanvas = memo(function ChronosCanvas({
153115 ? 'absolute inset-0 flex items-center justify-center'
154116 : 'relative w-[clamp(240px,52vmin,480px)] h-[clamp(240px,52vmin,480px)] overflow-hidden' ;
155117
156- // WebGL 不可用 → 2D 静态兜底(唯一静态化路径)
157- if ( ! webgl ) {
158- return < ChronosStatic mode = { mode } timeStr = { timeStr } /> ;
159- }
160-
161118 return (
162119 < div
163120 className = { `${ containerClass } cursor-pointer` }
164121 onPointerDown = { handlePointerDown }
165122 onPointerUp = { handlePointerUp }
166123 onPointerLeave = { cancelLongPress }
167124 onContextMenu = { onContextMenu }
125+ onMouseEnter = { ( ) => setHovering ( true ) }
126+ onMouseLeave = { ( ) => setHovering ( false ) }
168127 role = "timer"
169128 aria-label = { timeStr }
170129 >
130+ { /* 3D 粒子球(R3F + WebGPU;THREE.Points 高效渲染,canvas 填满容器) */ }
171131 < Canvas
172- /* camera z=5.5 fov=50:视锥半高 ≈2.57,比原 z=5.0 fov=45 增大约 24%,
173- asleep 半径 1.8 + 漂移余量 ≈2.0 完整落在视锥内;
174- 扩大空间让粒子聚集动画有更大的运动范围 */
175- camera = { { position : [ 0 , 0 , 5.5 ] , fov : 50 } }
176- dpr = { degraded ? [ 1 , 1 ] : [ 1 , 1.5 ] }
177- gl = { { antialias : true , alpha : true } }
178- className = "!absolute inset-0"
179- style = { { pointerEvents : 'none' } }
132+ gl = { ( c ) => new WebGPURenderer ( { canvas : c , antialias : true , alpha : true , stencil : false } ) }
133+ dpr = { [ 1 , degraded ? 1 : 1.5 ] }
134+ camera = { { fov : 50 , near : 0.1 , far : 20 , position : [ 0 , 0 , 4.5 ] } }
135+ style = { { position : 'absolute' , inset : 0 , width : '100%' , height : '100%' , background : 'transparent' } }
136+ onCreated = { ( { gl } ) => {
137+ gl . toneMapping = THREE . ACESFilmicToneMapping ;
138+ gl . toneMappingExposure = 1.0 ;
139+ } }
180140 >
181- < ambientLight intensity = { 0.6 } />
182- < pointLight position = { [ 3 , 3 , 4 ] } intensity = { 1.0 } color = { stateColor } />
183- < pointLight position = { [ - 3 , - 2 , - 3 ] } intensity = { 0.4 } color = { stateColor } />
184- < ChronosParticleField
141+ < ChronosSphere
185142 state = { chronosState }
186- theme = { theme }
187143 mood = { mood }
188144 progress = { progress }
189145 degraded = { degraded }
190146 />
191- < ChronosCreature state = { chronosState } theme = { theme } />
192147 </ Canvas >
193148 { /* 进度条:容器顶部边缘细条(剩余时间比例,图形化时间感知) */ }
194149 < div className = "absolute top-0 left-0 right-0 pointer-events-none" aria-hidden = "true" >
195150 < div className = "h-[2px] rounded-full" style = { { width : `${ progress * 100 } %` , background : stateColor , opacity : 0.45 , transition : 'width 1s linear' } } />
196151 </ div >
197- { /* 中央状态区:仅时间数字(需求 4:状态名/引导语移出球体) 。
198- 时间与粒子球同源和谐:状态辉光色 + 同色 textShadow,半透明胶囊背景提升可读性
152+ { /* 中央状态区:仅时间数字。
153+ 时间显示隐藏化:阶段开始显示 5s + 鼠标悬停粒子团时显示,否则隐藏
199154 辉光脉冲动画:3s 周期呼吸级脉冲,与 60bpm 心跳同频反射
200155 秒数心跳脉冲:key 随秒变化重置 chronos-second-pulse 动画(scale 1.05 → 1) */ }
156+ { timeVisible && (
201157 < div className = "absolute inset-0 flex items-center justify-center pointer-events-none" >
202158 < span
203159 key = { remainingSeconds }
@@ -213,6 +169,7 @@ export const ChronosCanvas = memo(function ChronosCanvas({
213169 { timeStr }
214170 </ span >
215171 </ div >
172+ ) }
216173 { /* 时间辉光脉冲动画 keyframes */ }
217174 < style > { `
218175 @keyframes chronos-glow-pulse {
0 commit comments