docs(spec): Room.scene 设计 (可配置玩家数 + 4 种 seat layout)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,256 @@
|
|||||||
|
# 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('2x2') 后位置正确。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
### 覆盖
|
||||||
|
- ✅ 新建 `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 是展示层,不参与路由)
|
||||||
|
|
||||||
|
## 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 报错
|
||||||
|
- 4 种 seat layout 函数返回正确位置
|
||||||
|
- setPlayerCount(1-8) 全部通过
|
||||||
|
- 现有 framework tests 217/217 仍 PASS(不回归)
|
||||||
|
- 新增 RoomSceneStart tests X/X PASS(target ≥ 6 test cases)
|
||||||
|
</content>
|
||||||
|
</invoke>
|
||||||
Reference in New Issue
Block a user