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

9.9 KiB
Raw Blame History

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

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

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

# 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 + 所有非法输入)