77 * - entering 模式:useCameraFlight 独占相机,CameraController 暂停
88 * - docked 模式:CameraController 接管(渲染已暂停,实际不执行 lerp)
99 *
10+ * 相机飞行统一由相位迁移响应式驱动(覆盖 3D 点击/数字键/路由同步等全部入口);
11+ * 点击入口额外记录行星实时坐标供飞行使用(行星在公转,固定坐标会飞向错误位置)。
12+ *
1013 * @ai -context: 3D 空间导航组件——键盘/手势驱动的模块间轨道跳转,与 OrbitalStore 状态联动;渲染于 R3F Canvas 内,禁止使用 DOM API。
1114 */
12- import { useCallback } from 'react' ;
15+ import { useCallback , useEffect , useRef } from 'react' ;
1316import { useNavigate } from 'react-router-dom' ;
1417import { useFrame } from '@react-three/fiber' ;
1518import { OrbitControls } from '@react-three/drei' ;
@@ -60,6 +63,9 @@ const AURORA_ORBIT_CONFIG: Record<ModuleId, {
6063/** 相机飞入模块时的偏移(从模块位置向相机方向偏移) */
6164const CAMERA_OFFSET : [ number , number , number ] = [ 0 , 0 , 4 ] ;
6265
66+ /** 概览态相机全景位(退出模块时相机飞回此位置) */
67+ const OVERVIEW_CAMERA_POS : [ number , number , number ] = [ 0 , 0 , 10 ] ;
68+
6369function addVectors ( a : [ number , number , number ] , b : [ number , number , number ] ) : [ number , number , number ] {
6470 return [ a [ 0 ] + b [ 0 ] , a [ 1 ] + b [ 1 ] , a [ 2 ] + b [ 2 ] ] ;
6571}
@@ -71,46 +77,76 @@ export function SpatialNav() {
7177 const { flyTo, update } = useCameraFlight ( ) ;
7278 const { shouldDegrade3D } = useRuntimeEnv ( ) ;
7379
80+ // 点击入口记录的行星实时世界坐标(飞行 effect 优先消费),点击后才有效
81+ const clickFlightPosRef = useRef < Partial < Record < ModuleId , [ number , number , number ] > > > ( { } ) ;
82+ // 上一次已处理的飞行键(entering:模块 / overview / docked),防止重复飞行
83+ const flightKeyRef = useRef < string | null > ( null ) ;
84+
7485 // 每帧更新相机飞行
7586 useFrame ( ( _ , delta ) => {
7687 // 防止浏览器节流导致的帧时间尖峰
7788 const safeDelta = Math . min ( delta , 0.1 ) ;
7889 update ( safeDelta ) ;
7990 } ) ;
8091
81- // 点击模块实体(深海模式使用固定坐标)
92+ // 响应式相机飞行:统一覆盖全部入口(3D 点击/数字键/返回首页/路由同步)。
93+ // 此前 flyTo 仅在点击回调中命令式调用,数字键等入口进入时无镜头移动;
94+ // 退出(Esc)时相机也需从模块停靠位飞回全景位。
95+ useEffect ( ( ) => {
96+ const flightKey = phase === 'entering' ? `entering:${ currentModule } ` : phase ;
97+ if ( flightKeyRef . current === flightKey ) return ;
98+ const prevKey = flightKeyRef . current ;
99+ flightKeyRef . current = flightKey ;
100+
101+ if ( phase === 'entering' && currentModule ) {
102+ const module = MODULE_POSITIONS . find ( ( m ) => m . id === currentModule ) ;
103+ if ( ! module ) return ;
104+ // 优先使用点击时记录的实时坐标,fallback 到固定坐标
105+ const basePos = clickFlightPosRef . current [ currentModule ] ?? module . position ;
106+ delete clickFlightPosRef . current [ currentModule ] ;
107+ // 注视点传入模块实时坐标:飞行期间朝向同步转向模块,
108+ // 否则相机抵达后仍保持概览态旧朝向,叠加 docked 渲染冻结导致视角错位
109+ flyTo ( addVectors ( basePos , CAMERA_OFFSET ) , basePos ) ;
110+ } else if ( phase === 'overview' && prevKey && prevKey !== 'overview' ) {
111+ // 退出模块:返回全景位并转向场景中心(初始挂载即 overview 时 prevKey 为 null,不飞行)
112+ flyTo ( OVERVIEW_CAMERA_POS , [ 0 , 0 , 0 ] ) ;
113+ }
114+ } , [ phase , currentModule , flyTo ] ) ;
115+
116+ // 点击模块实体:飞行由相位迁移 effect 统一触发(深海模式使用固定坐标)
82117 const handleModuleClick = useCallback ( ( id : ModuleId ) => {
83118 const module = MODULE_POSITIONS . find ( m => m . id === id ) ;
84119 if ( ! module ) return ;
85120
86121 soundPlayer . play ( 'ui_module_enter' ) ;
87122 enterModule ( id ) ;
88- // 相机飞向模块位置(加偏移)
89- flyTo ( addVectors ( module . position , CAMERA_OFFSET ) ) ;
90123 navigate ( module . route ) ;
91- } , [ enterModule , flyTo , navigate ] ) ;
124+ } , [ enterModule , navigate ] ) ;
92125
93- // 点击 Aurora 模式行星:使用行星实时世界坐标作为 flyTo 目标,而非 MODULE_POSITIONS 固定坐标
126+ // 点击 Aurora 模式行星:记录实时世界坐标供飞行 effect 消费,而非使用 MODULE_POSITIONS 固定坐标
94127 // 因为行星在轨道上持续公转,固定坐标会导致相机飞向错误位置(漂移根因之一)
95128 const handleAuroraClick = useCallback ( ( id : ModuleId , currentPosition ?: [ number , number , number ] ) => {
96129 const module = MODULE_POSITIONS . find ( m => m . id === id ) ;
97130 if ( ! module ) return ;
98131
99132 soundPlayer . play ( 'ui_module_enter' ) ;
133+ // enterModule 在"同模块已停靠且覆盖层可见"时为无操作(不产生飞行),此时不记录,避免陈旧坐标被后续进入误用
134+ const s = useOrbitalStore . getState ( ) ;
135+ const willNoop = s . currentModule === id && s . phase === 'docked' && s . overlayVisible ;
136+ if ( ! willNoop && currentPosition ) {
137+ clickFlightPosRef . current [ id ] = currentPosition ;
138+ }
100139 enterModule ( id ) ;
101- // 优先使用行星当前实时世界坐标,fallback 到固定坐标
102- const basePos = currentPosition ?? module . position ;
103- flyTo ( addVectors ( basePos , CAMERA_OFFSET ) ) ;
104140 navigate ( module . route ) ;
105- } , [ enterModule , flyTo , navigate ] ) ;
141+ } , [ enterModule , navigate ] ) ;
106142
107143 // 悬停音效(移动端降级时不播放,仅在指针事件回调中触发)
108144 const handleHover = useCallback ( ( id : ModuleId | null ) => {
109145 if ( id && ! shouldDegrade3D ) soundPlayer . play ( 'ui_hover_3d' , { throttleMs : 200 } ) ;
110146 setHovered ( id ) ;
111147 } , [ setHovered , shouldDegrade3D ] ) ;
112148
113- // 计算相机目标位置与注视点:非概览相位(entering/docked)时飞向并注视当前模块 ,否则全景位/场景中心
149+ // 计算相机目标位置与注视点:非概览相位(entering/docked)时注视当前模块 ,否则全景位/场景中心
114150 const inModuleView = phase !== 'overview' && currentModule ;
115151 const activeModulePos = inModuleView
116152 ? MODULE_POSITIONS . find ( m => m . id === currentModule ) ?. position
@@ -165,7 +201,8 @@ export function SpatialNav() {
165201 color = { config . color }
166202 emissiveColor = { config . emissiveColor }
167203 isHovered = { hoveredModule === module . id || highlightAll }
168- isActive = { currentModule === module . id }
204+ // 选中态需结合相位:Esc 退出后 currentModule 保留(页面状态存活),但视觉选中必须解除
205+ isActive = { phase !== 'overview' && currentModule === module . id }
169206 showLabel = { highlightAll || ! overlayVisible }
170207 onClick = { ( ) => handleModuleClick ( module . id ) }
171208 onPointerOver = { ( ) => handleHover ( module . id ) }
@@ -211,7 +248,9 @@ export function SpatialNav() {
211248 orbitRadius = { config . orbitRadius }
212249 orbitSpeed = { config . orbitSpeed }
213250 initialAngle = { config . initialAngle }
214- isActive = { currentModule === module . id }
251+ // 选中态需结合相位:Esc 退出后 currentModule 保留(页面状态存活),
252+ // 若不解除,行星将持续保持放大/光环/停止公转的"选中"表现
253+ isActive = { phase !== 'overview' && currentModule === module . id }
215254 showLabel = { highlightAll || ! overlayVisible }
216255 onClick = { handleAuroraClick }
217256 onHover = { handleHover }
0 commit comments