Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
20 KiB
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),PlayerStatetype(assets/framework/platform/stores/types.ts) -
Produces:
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:
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
tsxloader OR extracting the layout logic into a pure helper. Step 3 chooses one approach and the test follows it.
- Step 2: 跑测试看失败
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:
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:
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: 跑测试看通过
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 确认无回归
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
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_Runtimeprefab(UUIDdf6a5177-07a1-4bff-8b4b-eb993a81ca2fper 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 骨架:
# Trigger via MCP — these are tool calls, not bash commands
Use mcp__funplay_cocos__create_scene with:
target:db://assets/scenes/Room.scenesceneName:Room
Expected: Room.scene + .meta created in assets/scenes/. Status: imported.
- Step 2: refresh_assets 让 Cocos 识别新 scene
mcp__funplay_cocos__refresh_assets
path: assets/scenes
Expected: Room.scene appears in asset-db.
- Step 3: open_asset + 验证空 scene
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/PlayerSlotsname:PlayerSlot_${1..8}
Then set_node_transform × 8 次按 computePositions('horizontal', 8) 设初始位置(间距 300, 居中)。
- Step 7: 挂载 RoomSceneStart 脚本到 Room_Node + 配 playerSlots property
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 加载
mcp__funplay_cocos__save_current_scene
mcp__funplay_cocos__validate_scene
includeLogErrors: true
Expected: Room.scene saved + 0 errors.
- Step 9: Commit
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
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 子节点
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
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 显示
mcp__funplay_cocos__capture_scene_screenshot
fileName: room-scene-2-players
Expected: 截图含 2 个 PlayerInfoView + 房间号 Label + 2 个按钮。
- Step 5: 调 setPlayerCount(8) → verify 8 个 slot 可见 + horizontal 布局
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
mcp__funplay_cocos__search_project_logs
query: error|Error
regex: true
Expected: 仅 Room.scene 相关 import 成功 log, 无 error.
- Step 7: Commit(如果截图 / log 需要保存作为 evidence)
# 仅当 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
cat cocoscreator_projects/scripts/run-framework-tests.mjs
- Step 2: 添加 room-scene 测试到 test list
Edit cocoscreator_projects/scripts/run-framework-tests.mjs:
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):
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: 跑全套测试
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
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
SeatLayouttype defined in Task 1, used in Task 1 + Task 2 (RoomSceneStart.ts) and consistent.RoomSceneStartclass 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.