BlogRoom 的 3D 场景由程序化几何组合,而不是导入一个整体室内模型。这样家具的尺寸、线条和交互状态都能在代码中独立调整。场景层只负责空间对象,业务面板和数据内容仍由 React DOM 管理。
房间场景组合
src/scene/room-scene.tsx 是对象装配入口。它依次挂载 CameraRig、SceneTicker、RoomShell、桌面、显示器、电脑、键盘、门、窗户、书架、留声机、床和其他家具,再挂载字形层、后处理和线稿揭示动画。对象组件放在 src/scene/objects/,每个组件负责几何、材质、局部动画和 pointer 事件。
{/* 所有家具共享这一局部坐标原点,单个组件只维护自身偏移。 */}<group position={[0, -0.85, 0]}> <RoomShell /> <Desk /> <Monitor /> <Laptop /> <Keyboard /> <Bookshelf /> <Door /> ...</group>所有家具共享场景的坐标基准和主题颜色。新增物件必须先确定它属于哪个空间区域、是否需要碰撞体、是否需要 pointer cursor,以及是否在移动端可见。不要在单个家具内部创建全局弹窗或直接修改另一个家具的状态。
相机区域和焦点
camera-rig.tsx 定义三种区域:lounge、overview、workspace,每个区域包含相机位置、lookAt 目标和 zoom;还定义 bookshelf、door、keyboard、laptop、portrait 等焦点。Canvas 使用正交相机,因此 zoom 改变的是可视范围,不是透视距离。
export const cameraZones = { // lounge 是默认阅读区,镜头同时覆盖书架。 lounge: { position: [20, 14.5, 23], target: [0.8, 1.8, 1.8], zoom: 70 }, // overview 保留完整房间,用于总览和状态恢复。 overview: { position: [20, 14.5, 22], target: [0, 2.85, 0.18], zoom: 58 }, workspace: { position: [18.5, 13.2, 21], target: [-0.8, 2.7, -5.8], zoom: 77 },};桌面端切换焦点时,CameraRig 使用 GSAP 对 position、lookAt target 和 zoom 做 1.18 秒的贝塞尔插值;动画开始点读取相机当前值,所以连续点击不同物件时不会跳回区域初始点。用户开启 reduced motion 时直接设置终点,不创建动画。移动端禁用自动焦点,只显示 MapControls 的平移,避免小屏幕上相机把用户带离上下文。
Zustand 状态模型
room-store.ts 将状态分成四组:相机 (cameraZone、cameraFocus)、面板 (panel 和 selected ID)、物件状态 (objectState) 以及主题/声音/门出口等全局状态。状态更新通过动作函数完成,例如 openBook() 会清除其他选中 ID、关闭门提示并把 panel 设为 book。
openBook: (selectedBookId) => set((state) => ({ // 书籍弹窗会取代临时门提示和其他内容选中状态。 isDoorExitPromptOpen: false, objectState: { ...state.objectState, doorOpen: false }, panel: 'book', selectedBookId, selectedFeedId: null, selectedLinkId: null, })),场景对象只订阅自己需要的字段。台灯读取 objectState.deskLampOn,门读取 doorOpen,显示器读取 monitorOn;面板只订阅 panel 和对应 selected ID。这样切换书籍不会让所有家具重新渲染。
命令分发
room-commands.ts 把物件 ID 转换为业务动作。activateRoomObject('bookshelf') 随机选择一个已同步的书籍 ID 后调用 openBook();laptop 打开 GitHub 面板;mouse 切换显示器状态;wall-switch 切换主题并播放开关声音。家具组件只传入自己的 ID,不需要知道业务面板组件的实现。
新增物件时要同时处理对象组件、room-scene.tsx 的挂载、roomObjects 定义和 activateRoomObject() 的分支。如果它打开面板,还要增加 PanelId 类型、app.tsx 的 dialog 挂载和对应 feature。漏掉其中一层,常见结果是物件可点击但没有动作,或面板打开后相机没有焦点。
动画状态和渲染循环
房间 Canvas 使用 frameloop="demand",需要连续变化的对象由 SceneTicker 或组件中的 useFrame 请求 invalidate。台灯、窗帘、门、风扇等动画读取 store 状态,再用 GSAP 或 useFrame 更新局部 transform;动画结束后应停止无意义的 invalidate,避免静态页面保持高帧率。
主题和声音状态写入 localStorage。ThemeSync 监听 store 的 theme 并同步 Canvas 背景色、CSS class 和 DOM 变量;声音开关只影响 playRoomSound 和 playObjectSound,不应该阻止数据请求或相机交互。
无障碍和降级
Canvas 本身标记为 aria-hidden,所以所有核心入口应有 DOM 面板、键盘可达按钮或静态 fallback。RoomFallback 使用 /assets/fallback/room.svg,不能依赖 Three.js 才能访问个人资料、书籍、链接和退出入口。新增家具前要决定它在不支持 WebGL 时是否需要额外的 DOM 导航项。
修改检查
# 检查场景组件、store、命令和 feature 的类型关系。pnpm typecheck# 检查 React/TSX、CSS 和无障碍相关 lint 规则。pnpm lint# 验证同步数据、TypeScript 编译和生产场景 bundle。pnpm build修改相机时检查桌面、移动和 reduced motion;修改命令时检查状态互斥关系;修改对象几何时检查线稿后处理和点击区域;修改面板时检查 Query 加载、错误、关闭后相机恢复以及键盘焦点。3D 画面能显示不代表状态链路完整,至少要测试从物件点击到状态、相机、面板和关闭恢复的整条路径。
程序化几何的组织方式
家具组件通常由基础几何、线框边缘和少量局部材质组成,尺寸通过常量或 props 控制。公共的线稿材质、阴影和 hover 反馈应放在可复用工具中,不能每个家具重新创建一套颜色和后处理参数。这样调整主题或线宽时只改一层,避免场景出现不同黑色、不同发光强度和不同点击反馈。
场景坐标以房间中心和 group position={[0, -0.85, 0]} 为基准,家具内部再使用局部坐标。新增物件时先在 overview 区域确认全局位置,再为 lounge/workspace 焦点定义 target;不要通过改变整个房间 group 来修正一个家具,否则所有 camera target 和碰撞体都会偏移。
面板关闭和相机恢复
打开面板时 store 记录 focusReturnZone,app.tsx 根据 panel 映射 camera focus;面板关闭后调用 restoreCameraFocus() 返回原区域。门出口提示属于特殊状态,它优先级高于普通 panel,会将相机聚焦到 door 并关闭其他选中项。新增一个高优先级提示时,应明确它与 panel、cameraFocus 和 objectState 的优先级,不能只新增一个 boolean。