# Room.scene 设计 spec > **Status:** Draft v1 (brainstorming in progress, awaiting user review) > **Date:** 2026-09-02 > **Spec for:** `assets/scenes/Room.scene` + `assets/scripts/RoomSceneStart.ts` ## Goal 为 YouleNexus Cocos 工程新建 `Room.scene`,含 4 个默认玩家槽位(可配置 1-8) + 房间号 Label + 准备/退出按钮。RoomSceneStart 脚本暴露可被子游戏调用的 API(setPlayerCount / setSeatLayout / setPlayerData),支持不同子游戏不同房间人数 + 同一游戏多种玩家人数。 **成功标准**:Room.scene 在 Cocos 编辑器加载 0 报错;4 个默认槽位可见;setPlayerCount(2) 后只 2 个可见;setSeatLayout('horizontal') 后位置正确(2x2 / triangle / square 布局坐标留作后续 layout 子任务,与下文「不覆盖」一致)。 ## 范围 ### 覆盖 - ✅ 新建 `assets/scenes/Room.scene` - ✅ 新建 `assets/scripts/RoomSceneStart.ts` 脚本 + 公共 API - ✅ 4-8 个 PlayerInfoView_Runtime 槽位(预制 + active 控制) - ✅ 房间号 Label + 状态 Label - ✅ 准备 + 退出按钮 - ✅ 4 种 seat layout:horizontal / 2x2 / triangle / square - ✅ 测试:RoomSceneStart 单测 + Room.scene 集成测试 ### 不覆盖 - ❌ 真实网络(不连真服务器,mock session) - ❌ 出牌按钮(业务 UI 子游戏各自定义) - ❌ 房间生命周期(other_join_room / free_room 等事件探针在 RoomEventProbe.ts 中,RoomSceneStart 只展示层) - ❌ 跨子游戏路由(Room.scene 是展示层,不参与路由) - ❌ 2x2 / triangle / square layout 的具体坐标实现(仅在 `SeatLayout` 类型与 `setSeatLayout` 校验中列出,后续 layout 子任务补齐;与上方「成功标准」保持一致) ## Architecture ### 场景结构 ``` Room_Node (cc.Scene) ├── Camera (cc.Camera) ├── Canvas (cc.Node + cc.UITransform 1600×720 + cc.Widget alignFlags=45 全屏) │ ├── RoomInfoPanel (cc.Node + UITransform) │ │ ├── RoomCodeLabel (cc.Label, "房间号: R0001") │ │ └── RoomStateLabel (cc.Label, "等待中 / 战斗中") │ ├── PlayerSlots (cc.Node, 锚定 bottom center) │ │ ├── PlayerSlot_1 (PlayerInfoView_Runtime prefab instance, active=visible) │ │ ├── PlayerSlot_2 (...) │ │ ├── ... (8 个预制, 默认 1-4 visible, 5-8 hidden) │ └── ActionButtons (cc.Node + UITransform) │ ├── ReadyButton (cc.Button) │ └── LeaveButton (cc.Button) └── Room_Node (cc.Node + RoomSceneStart ccclass script) └── RoomSceneStart component ``` ### 数据流 ``` [Game 子游戏初始化] ↓ roomScene.setPlayerCount(2) + setSeatLayout('triangle') [RoomSceneStart] subscribe session.room.players ↓ when player state changes [PlayerInfoView_Runtime] bindTo(playerData) ↓ [UI 自动刷新 Label.string / Sprite.spriteFrame] ``` ### 模块划分 | 模块 | 职责 | |---|---| | `RoomSceneStart.setPlayerCount(n)` | 设置 1-8 显示槽位数(不重建节点,只切 active) | | `RoomSceneStart.setSeatLayout(layout)` | 按 4 种布局重新计算槽位 _lpos | | `RoomSceneStart.setPlayerData(seat, data)` | 注入玩家数据到指定座位,触发对应 PlayerInfoView.bindTo | | `RoomSceneStart.clearSeats()` | 重置所有槽位为占位(清空 Label + 隐藏 Sprite) | | `PlayerInfoView_Runtime` (现有) | 单玩家 UI 展示(Label.string / Sprite.spriteFrame) | ## 输入契约 ### `assets/scenes/Room.scene` - UTF-8 JSON - 标准 Cocos 3.8.8 scene schema(含 cc.SceneAsset / cc.Scene / cc.Node / 组件 / CompPrefabInfo / PrefabInfo) ### `PlayerInfoView_Runtime prefab` - 已存在 `assets/framework/ui/prefabs/views/PlayerInfoView.prefab` - 含 3 个子节点:NicknameLabel / BeanLabel / AvatarSprite - 提供 `bindTo(session)` API ### `PlayerState` (复用现有 type) - `assets/framework/platform/stores/types.ts` - 字段:playerid, nickname, avatar, bean, roomcard, score, charm, sex, ... ## 输出契约 ### `RoomSceneStart` API ```typescript import { _decorator, Component, Node } from 'cc'; const { ccclass, property } = _decorator; export type SeatLayout = 'horizontal' | '2x2' | 'triangle' | 'square'; @ccclass('RoomSceneStart') export class RoomSceneStart extends Component { @property([Node]) playerSlots: Node[] = []; // 8 个预制 PlayerInfoView_Runtime 实例(场景里挂载) private currentCount: number = 4; private currentLayout: SeatLayout = 'horizontal'; start(): void { this.setPlayerCount(this.currentCount); this.setSeatLayout(this.currentLayout); } public setPlayerCount(n: number): void { if (n < 1 || n > 8) { console.error(`[RoomSceneStart] setPlayerCount: n must be 1-8, got ${n}`); return; } this.currentCount = n; for (let i = 0; i < this.playerSlots.length; i++) { const slot = this.playerSlots[i]; if (slot) slot.active = (i < n); } } public setSeatLayout(layout: SeatLayout): void { this.currentLayout = layout; // 按布局计算每个 slot 的 _lpos const positions = this.computeLayoutPositions(layout, this.currentCount); for (let i = 0; i < this.currentCount && i < positions.length; i++) { const slot = this.playerSlots[i]; if (slot) slot.setPosition(positions[i].x, positions[i].y, 0); } } private computeLayoutPositions(layout: SeatLayout, count: number): Array<{x: number, y: number}> { // 4 种布局的位置表(具体坐标按 RoomInfoPanel + ActionButtons 不重叠设计) if (layout === 'horizontal') { // 水平排列:-spacing*i const spacing = 300; const startX = -(count - 1) * spacing / 2; return Array.from({length: count}, (_, i) => ({ x: startX + i * spacing, y: 0 })); } // TODO: 2x2 / triangle / square return []; } public setPlayerData(seat: number, data: any): void { if (seat < 1 || seat > this.currentCount) { console.error(`[RoomSceneStart] setPlayerData: seat ${seat} out of range 1-${this.currentCount}`); return; } const slot = this.playerSlots[seat - 1]; if (!slot) return; const view = slot.getComponent('PlayerInfoView') as any; if (view && typeof view.bindTo === 'function') { view.bindTo({ player: { state: { value: data } } }); } } public clearSeats(): void { for (let i = 0; i < this.currentCount; i++) { const slot = this.playerSlots[i]; if (slot) { const view = slot.getComponent('PlayerInfoView') as any; if (view && typeof view.bindTo === 'function') { view.bindTo({ player: { state: { value: { nickname: '占位', bean: 0, avatar: '' } } } }); } } } } } ``` ## 错误处理(CLAUDE.md 第二准则) - `setPlayerCount(n)` n ∉ [1, 8] → throw or log + no-op(throw 更明确) - `setPlayerData(seat, data)` seat ∉ [1, currentCount] → throw - `playerSlots` 字段未挂载或长度 !== 8 → throw at start() - 玩家数据缺失字段 → bindTo 已有容错,不需重复处理 ## 测试策略(TDD) ### 单测:`framework-tests/ui/room-scene-start.test.mjs` ```javascript import { test } from 'node:test'; import assert from 'node:assert/strict'; import { setPlayerCount, setSeatLayout, setPlayerData, clearSeats } from '../../assets/scripts/RoomSceneStart.ts'; // Mock Node + PlayerInfoView function mockSlot() { return { active: true, setPosition: (x, y, z) => {}, getComponent: (name) => name === 'PlayerInfoView' ? { bindTo: (s) => {} } : null, }; } test('setPlayerCount(2) → 前 2 个 slot active=true, 其余 false', () => { const slots = Array.from({length: 8}, mockSlot); setPlayerCount(slots, 2); assert.equal(slots[0].active, true); assert.equal(slots[3].active, false); }); test('setPlayerCount(0) → throw', () => { const slots = Array.from({length: 8}, mockSlot); assert.throws(() => setPlayerCount(slots, 0)); }); test('setSeatLayout(horizontal) → 8 玩家水平排列居中', () => { // verify positions computed symmetric around 0 }); ``` ### 集成测试:用 funplay_cocos_mcp 加载 Room.scene ```bash # 1. 创建 Room.scene 资产 + 挂载脚本 # 2. refresh_assets # 3. open_asset Room.scene # 4. validate_prefab_references # 5. capture_scene_screenshot 看 4 玩家 slot 可见 # 6. 调用 setPlayerCount(2) via execute_javascript → 验证只 2 个 active ``` ## Global Constraints - **服务器零改动** (CLAUDE.md 第一准则) - **数据源权威唯一、下游不兜底** (第二准则) - **UTF-8 编码** (Ruling 4 FINAL) - **node:test + node:assert**; no third-party deps - **Cocos 3.8.8** + **funplay-cocos-mcp v0.5.1** - **TypeScript + ESM**(assets/scripts/*.ts 项目惯例) ## PoC 验证路径(实施完成后) 1. funplay_cocos_mcp `open_asset Room.scene` → imported, invalid=false 2. `validate_prefab_references` → 0 missing 3. `capture_scene_screenshot` → 4 个 PlayerInfoView 槽位可见 + RoomInfoPanel 显示"房间号: 占位" + ActionButtons 可见 4. `execute_javascript` 调用 `RoomSceneStart.setPlayerCount(2)` → capture 第二次 → 只 2 个 slot visible ## 残余 follow-up(park) - ❌ 真实网络(不连真服务器)— 子游戏路由任务 - ❌ 出牌按钮 UI — 各子游戏自行实现 - ❌ 2x2 / triangle / square 布局具体坐标 — 留作 layout 子任务 ## Success Metrics - Room.scene 在 Cocos 编辑器 0 报错 - `setSeatLayout('horizontal')` 函数返回正确位置(对称居中) - `setSeatLayout('2x2'|'triangle'|'square')` 在 `computePositions` 层校验通过且 throw 行为正确;具体坐标留 layout 子任务 - setPlayerCount(1-8) 全部通过(含非法值 throw:0 / 9 / NaN / 1.5 / 非 number) - setSeatLayout 仅接受合法 layout 字符串;非法值 throw - setPlayerData(seat, data) 校验 seat 整数 ∈ [1, currentCount]、data 三字段合法;非法值 throw - 现有 framework tests 仍 PASS(不回归) - 新增 RoomSceneStart controller tests 全部 PASS(target ≥ 8 test cases,含 happy path + 所有非法输入)