From 71ed67abff61891ef11095424cacec91f3adc2fe Mon Sep 17 00:00:00 2001 From: Joywayer Date: Wed, 2 Sep 2026 20:59:31 +0800 Subject: [PATCH] feat(scripts): RoomSceneStart.ts + pure layout helpers + tests (setPlayerCount/setSeatLayout/setPlayerData/clearSeats API) --- .../assets/scripts/RoomSceneStart.ts | 106 ++++++++++++++++++ .../assets/scripts/room-scene-layout.ts | 40 +++++++ .../room-scene/room-scene-start.test.mjs | 38 +++++++ 3 files changed, 184 insertions(+) create mode 100644 cocoscreator_projects/YouleNexus/assets/scripts/RoomSceneStart.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/scripts/room-scene-layout.ts create mode 100644 cocoscreator_projects/framework-tests/room-scene/room-scene-start.test.mjs diff --git a/cocoscreator_projects/YouleNexus/assets/scripts/RoomSceneStart.ts b/cocoscreator_projects/YouleNexus/assets/scripts/RoomSceneStart.ts new file mode 100644 index 0000000..04613f4 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/scripts/RoomSceneStart.ts @@ -0,0 +1,106 @@ +import { _decorator, Component, Node } from 'cc'; +import type { PlayerState } from '../framework/platform/stores/types.ts'; +import { computeActiveFlags, computePositions, type SeatLayout } from './room-scene-layout.ts'; + +export { computeActiveFlags, computePositions }; +export type { SeatLayout }; + +const { ccclass, property } = _decorator; + +/** + * RoomSceneStart: 房间场景控制脚本。 + * + * 工作流: + * 1. 子游戏初始化 → setPlayerCount(n) + setSeatLayout(layout) 调整可见槽位与位置 + * 2. 子游戏收到玩家状态变更 → setPlayerData(seat, data) 注入到对应槽位的 PlayerInfoView + * 3. 子游戏重置房间 → clearSeats() 把当前可见槽位刷成占位数据 + * + * 8 个 playerSlots 在场景里预先挂好(Plan Task 2 负责),setPlayerCount 只切 active, + * 不运行时 instantiate 节点(避免预制重复实例化开销)。 + * + * 纯函数算法(computeActiveFlags / computePositions)放在 room-scene-layout.ts, + * 本文件只 re-export — 这样单元测试可以直接 import 布局函数而无需 mock cc 模块 + * (CLAUDE.md 第二准则:RoomSceneStart 的下游消费者不应被 cc 依赖拖累)。 + */ +@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, + }; + for (let i = 0; i < this.currentCount; i++) { + this.setPlayerData(i + 1, placeholder); + } + } +} \ No newline at end of file diff --git a/cocoscreator_projects/YouleNexus/assets/scripts/room-scene-layout.ts b/cocoscreator_projects/YouleNexus/assets/scripts/room-scene-layout.ts new file mode 100644 index 0000000..0a06eba --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/scripts/room-scene-layout.ts @@ -0,0 +1,40 @@ +/** + * Room.scene 的纯函数布局/可见性算法。 + * + * 设计意图:RoomSceneStart.ts 依赖 Cocos(cc 模块),在 node + tsx 下不可加载; + * 把不依赖 cc 的纯逻辑抽到本文件,让单元测试可直接 import 而无需 mock cc。 + * RoomSceneStart 在内部 import 并 re-export 这两个函数,作为公共 API 的一部分。 + */ + +/** 房间布局方案。horizontal 已落地;其余 3 种留 layout 子任务(plan 范围外)。 */ +export type SeatLayout = 'horizontal' | '2x2' | 'triangle' | 'square'; + +/** 槽位之间的固定像素间距(layout 子任务会参数化)。 */ +const SLOT_SPACING = 300; + +/** + * 给定显示槽位数 + 槽位总数,返回 active 标志数组。 + * 越界 throw(CLAUDE.md 第二准则:不兜底)。 + */ +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; +} + +/** + * 给定 layout + 玩家数,返回每个槽位的局部坐标。 + * horizontal 已落地;2x2 / triangle / square 暂 fallback 到 horizontal + * (留作后续 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}`); + const startX = -(count - 1) * SLOT_SPACING / 2; + return Array.from({ length: count }, (_, i) => ({ x: startX + i * SLOT_SPACING, y: 0 })); +} \ No newline at end of file diff --git a/cocoscreator_projects/framework-tests/room-scene/room-scene-start.test.mjs b/cocoscreator_projects/framework-tests/room-scene/room-scene-start.test.mjs new file mode 100644 index 0000000..b95adfe --- /dev/null +++ b/cocoscreator_projects/framework-tests/room-scene/room-scene-start.test.mjs @@ -0,0 +1,38 @@ +// 测试纯函数布局/可见性算法。 +// 直接 import room-scene-layout.ts,不依赖 RoomSceneStart.ts(后者引入 cc 模块,需 Cocos 运行时)。 +// 这与 CLAUDE.md 第二准则一致:纯逻辑放纯文件,被测方无需任何 cc mock。 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { computeActiveFlags, computePositions } from '../../YouleNexus/assets/scripts/room-scene-layout.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, 8) → throw', () => { + assert.throws(() => computeActiveFlags(0, 8), /count must be 1-8/); +}); + +test('computeActiveFlags(9, 8) → 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); + // 对称居中:首末 + 中间两项 sum = 0 + assert.equal(positions[0].x + positions[3].x, 0); + assert.equal(positions[1].x + positions[2].x, 0); + // 全员 y=0 + assert.ok(positions.every((p) => p.y === 0)); +}); \ No newline at end of file