Files
youle_cocos/docs/superpowers/specs/2026-09-02-room-scene-design.md
T
joywayerandClaude Code ec550ba257 fix(scripts): RoomSceneStart setPlayerData bindTo contract + runtime validation
Critical fix:
- setPlayerData fake state lacked subscribe() -> PlayerInfoView.bindTo threw
  TypeError at runtime. Switched to PlayerInfoView.applyPlayer({nickname,
  bean,avatar}) when available; falls back to bindTo + proper reactive
  store with subscribe/value if only bindTo is exposed.

Runtime validation (per CLAUDE.md 第二准则 — no silent fallback):
- computePositions / setSeatLayout: validate layout ∈ VALID_LAYOUTS
- computePositions / setPlayerCount: validate count ∈ [1,8] AND is integer
- setPlayerData: validate seat is integer ∈ [1, currentCount]
- extractPlayerViewData: validate nickname/bean/avatar types (no ?? 兜底)
- start(): validate no null/undefined entries in playerSlots

Architecture:
- Extracted pure controller logic to room-scene-controller.ts so tests can
  import without cc mock (RoomSceneStart remains thin cc wrapper that
  delegates). Matches existing pattern of room-scene-layout.ts.

Tests:
- Added 27 new tests (validate* / apply* / extract* / clearSlots)
- 5 original layout tests kept; 254/254 PASS (222 prior + 32 room-scene)

Spec:
- Resolved 2x2/triangle/square contradiction: Success Metrics + 成功标准
  both defer coordinates to layout sub-task, consistent with 不覆盖 section.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-02 21:22:04 +08:00

260 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 + 所有非法输入)
</content>
</invoke>