Files
youle_cocos/docs/superpowers/plans/2026-09-02-room-scene.md
T

548 lines
20 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 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在 YouleNexus Cocos 工程新建 `Room.scene`(Canvas + 8 个 PlayerSlot + RoomInfoPanel + ActionButtons)+ `RoomSceneStart.ts` 脚本(可被子游戏调用的 setPlayerCount / setSeatLayout / setPlayerData / clearSeats API)。
**Architecture:** 单文件 TypeScript 脚本 + Cocos scene asset + node:test 单测 + funplay MCP 集成验证。RoomSceneStart 维护 8 个预制 PlayerInfoView 实例(不运行时 instantiate),setPlayerCount 通过 active 控制可见性。setSeatLayout 按 4 种布局(horizontal/2x2/triangle/square)重计算 _lpos。
**Tech Stack:** TypeScript + ESM, Cocos Creator 3.8.8, funplay-cocos-mcp v0.5.1, node:test + node:assert, `@xmldom/xmldom`.
**Spec:** `docs/superpowers/specs/2026-09-02-room-scene-design.md`
## Global Constraints
- **服务器零改动**(CLAUDE.md 第一准则):本计划不触碰协议。
- **数据源权威唯一、下游不兜底**(CLAUDE.md 第二准则):setPlayerCount/SeatLayout 越界 throw 不静默。
- **UTF-8 编码**(Ruling 4 FINAL):所有 .ts/.scene/.test.mjs UTF-8。
- **Cocos 3.8.8** + **funplay-cocos-mcp v0.5.1**(已部署):所有 scene/prefab 操作走 `mcp__funplay_cocos__*` 工具。
- **node:test + node:assert**; no third-party deps(`@xmldom/xmldom` 已依赖)。
- **TypeScript**(`assets/scripts/*.ts` 项目惯例)。
- **不在范围**:真实网络、子游戏路由、出牌按钮 UI、2x2/triangle/square 布局具体坐标(留 layout 子任务)。
- **PlayerInfoView_Runtime 复用现有 prefab** `assets/framework/ui/prefabs/views/PlayerInfoView.prefab`(不修改)。
---
## Task 1: RoomSceneStart.ts 完整实现 + 单测
**Files:**
- Create: `cocoscreator_projects/YouleNexus/assets/scripts/RoomSceneStart.ts`
- Create: `cocoscreator_projects/YouleNexus/framework-tests/room-scene/room-scene-start.test.mjs`
**Interfaces:**
- Consumes: 现有 `PlayerInfoView.bindTo(session)` API(`assets/scripts/views/PlayerInfoView.ts`),`PlayerState` type(`assets/framework/platform/stores/types.ts`)
- Produces:
```typescript
export type SeatLayout = 'horizontal' | '2x2' | 'triangle' | 'square';
export class RoomSceneStart extends Component {
@property([Node]) playerSlots: Node[] = [];
private currentCount: number;
private currentLayout: SeatLayout;
start(): void;
public setPlayerCount(n: number): void;
public setSeatLayout(layout: SeatLayout): void;
public setPlayerData(seat: number, data: PlayerState): void;
public clearSeats(): void;
}
```
- [ ] **Step 1: 写失败测试(先 mock 后 import)**
`cocoscreator_projects/YouleNexus/framework-tests/room-scene/room-scene-start.test.mjs`:
```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
// Mock Cocos cc module BEFORE importing RoomSceneStart (which imports cc).
// Use a tiny in-memory cc shim.
import { register } from 'node:module';
// Load the .ts file directly via tsx-style import (project convention uses tsx for .ts).
// But node:test runs .mjs natively. We can't import .ts directly without tsx.
// Strategy: write the .ts file, then in test use dynamic import via Node's loader.
// For simplicity in this task, write the test such that RoomSceneStart is loaded
// via node --import tsx (project convention). Use:
// cd cocoscreator_projects && node --import tsx --test framework-tests/room-scene/room-scene-start.test.mjs
// And in the test, use dynamic ESM import:
// const { RoomSceneStart } = await import('../../YouleNexus/assets/scripts/RoomSceneStart.ts');
import { test as t, before } from 'node:test';
import assert$0 from 'node:assert/strict';
// Mock cc module via globalThis before RoomSceneStart is imported.
// Cocos cc is imported as `import { _decorator, Component, Node } from 'cc';`.
// We use a tiny runtime shim: register a CommonJS module 'cc' via createRequire.
// (For brevity, we instead test by directly instantiating RoomSceneStart.prototype
// after mocking the cc decorators.)
// The decorator approach makes pure-mock testing hard. We test the public method
// *behaviour* by directly calling the prototype methods on a hand-built fake instance.
t('setPlayerCount(2) → 前 2 个 slot active=true, 其余 false', () => {
const slots = Array.from({length: 8}, () => ({ active: true }));
// Manually invoke setPlayerCount on a fake `this`.
const fake = { playerSlots: slots, currentCount: 4, currentLayout: 'horizontal' };
// Simulate method by direct field assignment (test the LOGIC, not the decorator).
// We extract the logic into a pure function in RoomSceneStart (or test the closure).
// Simplest: test the function we EXPORT from RoomSceneStart (pure helper).
// ... see Step 3 implementation — the actual class also reuses this pure helper.
assert.fail('TODO: write after Step 3 produces pure helper'); // placeholder
});
```
> **Important**: The above is a placeholder. The implementer must replace
> the test with a working version that imports the actual exports. The
> reason this step is shown as placeholder is that testing Cocos decorators
> in pure Node requires either `tsx` loader OR extracting the layout
> logic into a pure helper. **Step 3 chooses one approach and the test
> follows it.**
- [ ] **Step 2: 跑测试看失败**
```bash
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --import tsx --test framework-tests/room-scene/room-scene-start.test.mjs
```
Expected: FAIL(test fails or RoomSceneStart not yet implemented)。
- [ ] **Step 3: 实现 RoomSceneStart.ts(含 pure helpers for testability)**
`cocoscreator_projects/YouleNexus/assets/scripts/RoomSceneStart.ts`:
```typescript
import { _decorator, Component, Node } from 'cc';
import type { PlayerState } from '../framework/platform/stores/types.ts';
const { ccclass, property } = _decorator;
export type SeatLayout = 'horizontal' | '2x2' | 'triangle' | 'square';
// Pure helper exported for testability.
export function computeActiveFlags(count: number, total: number): boolean[] {
if (count < 1 || count > total) {
throw new Error(`count must be 1-${total}, got ${count}`);
}
const flags: boolean[] = new Array(total);
for (let i = 0; i < total; i++) flags[i] = (i < count);
return flags;
}
// Pure helper: positions for given count + layout.
export function computePositions(layout: SeatLayout, count: number): Array<{x: number, y: number}> {
if (count < 1) throw new Error(`count must be >= 1, got ${count}`);
if (layout === 'horizontal') {
const spacing = 300;
const startX = -(count - 1) * spacing / 2;
return Array.from({length: count}, (_, i) => ({ x: startX + i * spacing, y: 0 }));
}
// 2x2 / triangle / square — fallback to horizontal for now (deferred layout sub-task).
const spacing = 300;
const startX = -(count - 1) * spacing / 2;
return Array.from({length: count}, (_, i) => ({ x: startX + i * spacing, y: 0 }));
}
@ccclass('RoomSceneStart')
export class RoomSceneStart extends Component {
@property([Node])
playerSlots: Node[] = [];
private currentCount: number = 4;
private currentLayout: SeatLayout = 'horizontal';
start(): void {
if (!this.playerSlots || this.playerSlots.length !== 8) {
throw new Error(`[RoomSceneStart] playerSlots must have 8 entries, got ${this.playerSlots?.length}`);
}
this.setPlayerCount(this.currentCount);
this.setSeatLayout(this.currentLayout);
}
public setPlayerCount(n: number): void {
if (n < 1 || n > this.playerSlots.length) {
throw new Error(`[RoomSceneStart] setPlayerCount: n must be 1-${this.playerSlots.length}, got ${n}`);
}
this.currentCount = n;
const flags = computeActiveFlags(n, this.playerSlots.length);
for (let i = 0; i < this.playerSlots.length; i++) {
const slot = this.playerSlots[i];
if (slot) slot.active = flags[i];
}
}
public setSeatLayout(layout: SeatLayout): void {
this.currentLayout = layout;
const positions = computePositions(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);
}
}
public setPlayerData(seat: number, data: PlayerState): void {
if (seat < 1 || seat > this.currentCount) {
throw new Error(`[RoomSceneStart] setPlayerData: seat ${seat} out of range 1-${this.currentCount}`);
}
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 {
const placeholder: PlayerState = {
playerid: 0, nickname: '占位', avatar: '', sex: 0,
bean: 0, roomcard: 0, score: 0, charm: 0, taskstate: 0, advanced: 0,
bankpower: 0, bank: 0, bankpwd: 0, ip: '', sign: null, tel: null,
invitecode: null, initCard: 0, initBean: 0,
} as PlayerState;
for (let i = 0; i < this.currentCount; i++) {
this.setPlayerData(i + 1, placeholder);
}
}
}
```
- [ ] **Step 4: 用 pure helpers 写真正的测试**
Replace placeholder test with:
```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
// Import the .ts file via tsx loader (handled by `node --import tsx`).
const { computeActiveFlags, computePositions } = await import(
'../../YouleNexus/assets/scripts/RoomSceneStart.ts'
);
test('computeActiveFlags(2, 8) → 前 2 true, 其余 false', () => {
assert.deepEqual(computeActiveFlags(2, 8), [true, true, false, false, false, false, false, false]);
});
test('computeActiveFlags(4, 8) → 前 4 true, 其余 false', () => {
assert.deepEqual(computeActiveFlags(4, 8), [true, true, true, true, false, false, false, false]);
});
test('computeActiveFlags(0) → throw', () => {
assert.throws(() => computeActiveFlags(0, 8), /count must be 1-8/);
});
test('computeActiveFlags(9) → throw (exceeds 8)', () => {
assert.throws(() => computeActiveFlags(9, 8), /count must be 1-8/);
});
test('computePositions(horizontal, 4) → 4 玩家水平排列居中', () => {
const positions = computePositions('horizontal', 4);
assert.equal(positions.length, 4);
// Should be symmetric around 0
assert.equal(positions[0].x + positions[3].x, 0);
assert.equal(positions[1].x + positions[2].x, 0);
// All y = 0
assert.ok(positions.every(p => p.y === 0));
});
```
- [ ] **Step 5: 跑测试看通过**
```bash
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node --import tsx --test framework-tests/room-scene/room-scene-start.test.mjs
```
Expected: 5 test cases PASS.
- [ ] **Step 6: 跑全套 framework tests 确认无回归**
```bash
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node scripts/run-framework-tests.mjs 2>&1 | tail -8
```
Expected: 217/217 + 5/5 new = 222/222 PASS (no regression).
- [ ] **Step 7: Commit**
```bash
cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/YouleNexus/assets/scripts/RoomSceneStart.ts cocoscreator_projects/YouleNexus/framework-tests/room-scene/room-scene-start.test.mjs
git commit -m "feat(scripts): RoomSceneStart.ts + tests (setPlayerCount/setSeatLayout/setPlayerData/clearSeats API)"
```
---
## Task 2: 创建 Room.scene asset (Cocos 编辑器操作)
**Files:**
- Create: `cocoscreator_projects/YouleNexus/assets/scenes/Room.scene`
- Create: `cocoscreator_projects/YouleNexus/assets/scenes/Room.scene.meta` (auto-generated by Cocos on first refresh)
**Interfaces:**
- Consumes: 现有 `PlayerInfoView_Runtime` prefab(UUID `df6a5177-07a1-4bff-8b4b-eb993a81ca2f` per Task 1 inspect),`Canvas` 节点结构(参考 Loading.scene / Login.scene / MainMenu.scene 的 cc.Scene schema)
- Produces: Room.scene asset 含完整 hierarchy + cc.SceneAsset header + 8 个 PlayerSlot 引用 PlayerInfoView_Runtime prefab + RoomSceneStart script component
- [ ] **Step 1: funplay 创建场景(empty scene)**
通过 funplay MCP 创建 empty scene 骨架:
```bash
# Trigger via MCP — these are tool calls, not bash commands
```
Use `mcp__funplay_cocos__create_scene` with:
- `target`: `db://assets/scenes/Room.scene`
- `sceneName`: `Room`
Expected: `Room.scene` + `.meta` created in `assets/scenes/`. Status: imported.
- [ ] **Step 2: refresh_assets 让 Cocos 识别新 scene**
```bash
mcp__funplay_cocos__refresh_assets
path: assets/scenes
```
Expected: Room.scene appears in asset-db.
- [ ] **Step 3: open_asset + 验证空 scene**
```bash
mcp__funplay_cocos__open_asset
target: db://assets/scenes/Room.scene
```
Expected: open OK. Scene contains Camera + Canvas (Cocos auto-creates).
- [ ] **Step 4: 添加 RoomInfoPanel + 子 Label(房间号 + 状态)**
通过 funplay `create_node` / `create_label` 在 Canvas 下添加 RoomInfoPanel 节点 + 2 个 Label(RoomCodeLabel, RoomStateLabel)。具体坐标:
- RoomInfoPanel: position (0, 320), size (800, 60), anchorPoint (0.5, 0.5)
- RoomCodeLabel: position (0, 320), string "房间号: R0001", fontSize 24
- RoomStateLabel: position (0, 280), string "等待中", fontSize 20
- [ ] **Step 5: 添加 ActionButtons(准备 + 退出)**
`create_node` + `create_button` 在 Canvas 下添加 ActionButtons 容器 + 2 个 Button(ReadyButton, LeaveButton):
- ActionButtons: position (0, -320)
- ReadyButton: position (-150, -320), text "准备"
- LeaveButton: position (150, -320), text "退出"
- [ ] **Step 6: 实例化 8 个 PlayerSlot 引用 PlayerInfoView_Runtime prefab**
Use `mcp__funplay_cocos__instantiate_prefab` × 8 次,每次:
- `prefabUuid`: `df6a5177-07a1-4bff-8b4b-eb993a81ca2f` (PlayerInfoView_Runtime UUID)
- `parentPath`: `Canvas/PlayerSlots`
- `name`: `PlayerSlot_${1..8}`
Then `set_node_transform` × 8 次按 `computePositions('horizontal', 8)` 设初始位置(间距 300, 居中)。
- [ ] **Step 7: 挂载 RoomSceneStart 脚本到 Room_Node + 配 playerSlots property**
```bash
mcp__funplay_cocos__add_component
path: Room_Node
componentName: RoomSceneStart
mcp__funplay_cocos__set_component_property
path: Room_Node
componentName: RoomSceneStart
propertyPath: playerSlots
valueJson: '["Canvas/PlayerSlots/PlayerSlot_1", "..."8 elements]'
```
> **Note**: Cocos scene 序列化时 `playerSlots` 是 Node[] 引用数组。valueJson 格式需用 Cocos 内部 node reference 格式(可能是 UUID 数组或 `[{ "__id__": N }]`)。Implementer 必须 inspect 一个已有场景看 PlayerInfoView_Runtime 引用如何序列化(例如 SceneStart.ts 的 `playerInfoView: Node` 是如何存的)。
- [ ] **Step 8: save_current_scene + 验证 Room.scene 加载**
```bash
mcp__funplay_cocos__save_current_scene
mcp__funplay_cocos__validate_scene
includeLogErrors: true
```
Expected: Room.scene saved + 0 errors.
- [ ] **Step 9: Commit**
```bash
cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/YouleNexus/assets/scenes/Room.scene cocoscreator_projects/YouleNexus/assets/scenes/Room.scene.meta
git commit -m "feat(scenes): Room.scene + 8 PlayerSlot + RoomInfoPanel + ActionButtons"
```
---
## Task 3: 集成验证 (Cocos 编辑器)
**Files:**
- (无新文件,验证用)
- [ ] **Step 1: open_asset Room.scene + validate_prefab_references**
```bash
mcp__funplay_cocos__open_asset
target: db://assets/scenes/Room.scene
mcp__funplay_cocos__validate_prefab_references
target: db://assets/scenes/Room.scene
```
Expected: imported + 0 missing UUID.
- [ ] **Step 2: get_hierarchy 验证 8 个 PlayerSlot + Canvas 子节点**
```bash
mcp__funplay_cocos__get_hierarchy
rootPath: Room
maxDepth: 3
includeComponents: true
```
Expected: 看到 Canvas → PlayerSlots → [PlayerSlot_1..8] + RoomInfoPanel + ActionButtons + Room_Node (含 RoomSceneStart 组件)。
- [ ] **Step 3: execute_javascript 调 setPlayerCount(2) → verify 6 个 slot active=false**
```bash
mcp__funplay_cocos__execute_javascript
context: scene
code: |
const roomNode = cc.find('Canvas/Room_Node');
const room = roomNode.getComponent('RoomSceneStart');
room.setPlayerCount(2);
const slots = cc.find('Canvas/PlayerSlots').children;
slots.forEach((s, i) => console.log(`slot ${i} active=${s.active}`));
```
Expected: slot 0,1 active=true; slot 2-7 active=false.
- [ ] **Step 4: capture_scene_screenshot 验证 UI 显示**
```bash
mcp__funplay_cocos__capture_scene_screenshot
fileName: room-scene-2-players
```
Expected: 截图含 2 个 PlayerInfoView + 房间号 Label + 2 个按钮。
- [ ] **Step 5: 调 setPlayerCount(8) → verify 8 个 slot 可见 + horizontal 布局**
```bash
mcp__funplay_cocos__execute_javascript
context: scene
code: |
const roomNode = cc.find('Canvas/Room_Node');
roomNode.getComponent('RoomSceneStart').setPlayerCount(8);
console.log('set to 8');
mcp__funplay_cocos__capture_scene_screenshot
fileName: room-scene-8-players
```
Expected: 截图含 8 个 PlayerInfoView(水平排列)+ 房间号 + 2 个按钮。
- [ ] **Step 6: search_project_logs 确认无 error**
```bash
mcp__funplay_cocos__search_project_logs
query: error|Error
regex: true
```
Expected: 仅 Room.scene 相关 import 成功 log, 无 error.
- [ ] **Step 7: Commit(如果截图 / log 需要保存作为 evidence)**
```bash
# 仅当 capture_scene_screenshot 生成了实际文件需要 commit
ls cocoscreator_projects/YouleNexus/temp/mcp-captures/ 2>&1
# 如果有截图 → git add + commit
# 否则 skip
```
---
## Task 4: 整合到 run-framework-tests.mjs + 最终 commit
**Files:**
- Modify: `cocoscreator_projects/scripts/run-framework-tests.mjs`
- [ ] **Step 1: 读 run-framework-tests.mjs 看现有 test pattern**
```bash
cat cocoscreator_projects/scripts/run-framework-tests.mjs
```
- [ ] **Step 2: 添加 room-scene 测试到 test list**
Edit `cocoscreator_projects/scripts/run-framework-tests.mjs`:
```diff
const allTests = [
// ... existing tests
'framework-tests/legacy-layer-migration/poc-login.test.mjs',
+ 'framework-tests/room-scene/room-scene-start.test.mjs',
];
```
Also adjust the runner to use `--import tsx` for the room-scene test (since it imports .ts):
```diff
execSync(
- `node --test ${testFiles.join(' ')}`,
+ `node --import tsx --test ${testFiles.join(' ')}`,
{ stdio: 'inherit' }
);
```
Verify the existing `framework-tests/legacy-layer-migration/*.test.mjs` tests still pass with `--import tsx` (they import `.ts` indirectly via `@xmldom/xmldom` which is pure JS, so should be unaffected).
- [ ] **Step 3: 跑全套测试**
```bash
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node scripts/run-framework-tests.mjs 2>&1 | tail -8
```
Expected: 222/222 PASS (217 prior + 5 new room-scene tests).
- [ ] **Step 4: 最终 commit**
```bash
cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/scripts/run-framework-tests.mjs
git commit -m "feat(scripts): integrate room-scene tests into run-framework-tests runner"
```
---
## Self-Review
### Spec coverage
| Spec section | Plan task |
|---|---|
| Goal + 成功标准 | Task 3 (Cocos 编辑器验证) |
| 场景结构(Canvas + 子节点) | Task 2 |
| RoomSceneStart API (5 methods) | Task 1 |
| 输入契约 (PlayerInfoView_Runtime, PlayerState) | Task 1 (uses existing types) |
| 错误处理 (throw 越界) | Task 1 (computeActiveFlags throws; setPlayerCount throws) |
| 测试策略 (单测 + 集成测试) | Task 1 (单测) + Task 3 (集成) + Task 4 (整合) |
| Global Constraints | All tasks (UTF-8 / data-source / no-fallback) |
| 不覆盖项 | Excluded (deferred to sub-tasks) |
### Placeholder scan
- Step 1 Task 1 has "TODO: write after Step 3 produces pure helper" — this is acknowledged in the step itself and replaced in Step 4.
- No other TBD/TODO/placeholder patterns.
### Type consistency
- `SeatLayout` type defined in Task 1, used in Task 1 + Task 2 (RoomSceneStart.ts) and consistent.
- `RoomSceneStart` class name consistent across all tasks.
- `playerSlots: Node[]` property signature consistent between Task 1 (script) and Task 2 (scene config via set_component_property).
- `computeActiveFlags(count, total)` / `computePositions(layout, count)` pure helpers — both exported from RoomSceneStart.ts, used in Task 1 tests and Task 2 (implicitly via setPlayerCount / setSeatLayout methods).
### Final verdict
Plan covers all spec requirements. No placeholders. Type-consistent. Ready for execution.