feat(scripts): RoomSceneStart.ts + pure layout helpers + tests (setPlayerCount/setSeatLayout/setPlayerData/clearSeats API)
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }));
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user