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>
199 lines
7.0 KiB
TypeScript
199 lines
7.0 KiB
TypeScript
/**
|
|
* Room.scene 控制器的纯函数核心。
|
|
*
|
|
* 设计意图:RoomSceneStart.ts 依赖 Cocos(cc 模块),在 node + tsx 下不可加载。
|
|
* 本文件抽出与 cc 无关的:
|
|
* - 输入校验(count / layout / seat)
|
|
* - 槽位操作(active flag / position / player data)
|
|
* - PlayerInfoView 交互(applyPlayer 一致调用)
|
|
* 让单元测试可直接 import,而 RoomSceneStart 只做 cc 包装 + 转发。
|
|
*
|
|
* CLAUDE.md 第二准则:不兜底。非法输入一律 throw,不静默 fallback / ?? 兜底。
|
|
*/
|
|
import {
|
|
computeActiveFlags,
|
|
computePositions,
|
|
MAX_SEATS,
|
|
type SeatLayout,
|
|
} from './room-scene-layout.ts';
|
|
|
|
export type { SeatLayout };
|
|
export { computeActiveFlags, computePositions, MAX_SEATS };
|
|
|
|
/** PlayerState 子集,PlayerInfoView.applyPlayer 实际消费(其它字段由视图内部兜底)。 */
|
|
export interface PlayerViewData {
|
|
nickname: string;
|
|
bean: number;
|
|
avatar: string;
|
|
}
|
|
|
|
/** Mock 槽位契约(由 RoomSceneStart 持有真 Node,测试用普通对象)。 */
|
|
export interface PlayerSlotLike {
|
|
active: boolean;
|
|
setPosition(x: number, y: number, z: number): void;
|
|
getComponent(name: string): unknown;
|
|
}
|
|
|
|
/** PlayerInfoView 公共契约(测试可提供 fake,真实现见 assets/scripts/views/PlayerInfoView.ts)。 */
|
|
export interface PlayerInfoViewLike {
|
|
applyPlayer?(p: PlayerViewData): void;
|
|
bindTo?(session: unknown): void;
|
|
}
|
|
|
|
/** 占位 PlayerViewData(clearSeats 使用)。 */
|
|
export const PLACEHOLDER_VIEW_DATA: PlayerViewData = Object.freeze({
|
|
nickname: '占位',
|
|
bean: 0,
|
|
avatar: '',
|
|
});
|
|
|
|
/** 取槽位上的 PlayerInfoView 组件,缺则 null(供测试 spy / 真实组件共用)。 */
|
|
export function extractPlayerView(slot: PlayerSlotLike | null): PlayerInfoViewLike | null {
|
|
if (!slot) return null;
|
|
const view = slot.getComponent('PlayerInfoView') as PlayerInfoViewLike | null;
|
|
return view ?? null;
|
|
}
|
|
|
|
/** 校验 count ∈ [1, MAX_SEATS] 且为整数;非法 throw。 */
|
|
export function validateCount(n: number): void {
|
|
if (typeof n !== 'number' || Number.isNaN(n) || !Number.isInteger(n)) {
|
|
throw new Error(`[RoomSceneStart] count must be an integer, got ${String(n)}`);
|
|
}
|
|
if (n < 1 || n > MAX_SEATS) {
|
|
throw new Error(`[RoomSceneStart] count must be 1-${MAX_SEATS}, got ${n}`);
|
|
}
|
|
}
|
|
|
|
/** 校验 layout ∈ VALID_LAYOUTS;非法 throw。 */
|
|
const VALID_LAYOUTS: readonly SeatLayout[] = ['horizontal', '2x2', 'triangle', 'square'];
|
|
export function validateLayout(layout: SeatLayout): void {
|
|
if (!VALID_LAYOUTS.includes(layout)) {
|
|
throw new Error(
|
|
`[RoomSceneStart] layout must be one of ${VALID_LAYOUTS.join('|')}, got ${String(layout)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** 校验 seat ∈ [1, currentCount] 且为整数;非法 throw。 */
|
|
export function validateSeat(seat: number, currentCount: number): void {
|
|
if (typeof seat !== 'number' || Number.isNaN(seat) || !Number.isInteger(seat)) {
|
|
throw new Error(`[RoomSceneStart] seat must be an integer, got ${String(seat)}`);
|
|
}
|
|
if (seat < 1 || seat > currentCount) {
|
|
throw new Error(
|
|
`[RoomSceneStart] seat ${seat} out of range 1-${currentCount}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 设置每个槽位的 active 标志(前 count 个 true,其余 false)。
|
|
* 委托 computeActiveFlags + 校验(已在 computeActiveFlags 内)。
|
|
*/
|
|
export function applyPlayerCount(slots: PlayerSlotLike[], n: number): void {
|
|
validateCount(n);
|
|
const flags = computeActiveFlags(n, slots.length);
|
|
for (let i = 0; i < slots.length; i++) {
|
|
const slot = slots[i];
|
|
if (slot) slot.active = flags[i];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 按 layout + count 计算位置,设置到前 count 个槽位。
|
|
* 校验(layout / count 在 computePositions 内)。
|
|
*/
|
|
export function applySeatLayoutPositions(
|
|
slots: PlayerSlotLike[],
|
|
layout: SeatLayout,
|
|
count: number,
|
|
): void {
|
|
validateCount(count);
|
|
validateLayout(layout);
|
|
const positions = computePositions(layout, count);
|
|
for (let i = 0; i < count && i < positions.length; i++) {
|
|
const slot = slots[i];
|
|
if (slot) slot.setPosition(positions[i].x, positions[i].y, 0);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 从完整 PlayerState 中抽取 PlayerInfoView.applyPlayer 实际需要的 3 个字段。
|
|
* 缺字段一律显式 throw(不 ?? 兜底,CLAUDE.md 第二准则)。
|
|
*/
|
|
export function extractPlayerViewData(data: { nickname?: unknown; bean?: unknown; avatar?: unknown }): PlayerViewData {
|
|
if (!data || typeof data !== 'object') {
|
|
throw new Error(`[RoomSceneStart] setPlayerData: data must be an object, got ${String(data)}`);
|
|
}
|
|
if (typeof data.nickname !== 'string') {
|
|
throw new Error(`[RoomSceneStart] setPlayerData: data.nickname must be string, got ${String(data.nickname)}`);
|
|
}
|
|
if (typeof data.bean !== 'number' || Number.isNaN(data.bean)) {
|
|
throw new Error(`[RoomSceneStart] setPlayerData: data.bean must be number, got ${String(data.bean)}`);
|
|
}
|
|
if (typeof data.avatar !== 'string') {
|
|
throw new Error(`[RoomSceneStart] setPlayerData: data.avatar must be string, got ${String(data.avatar)}`);
|
|
}
|
|
return { nickname: data.nickname, bean: data.bean, avatar: data.avatar };
|
|
}
|
|
|
|
/**
|
|
* 把玩家数据推入指定槽位的 PlayerInfoView。
|
|
* 优先用 applyPlayer(一次设置);缺 applyPlayer 才用 bindTo + proper reactive store。
|
|
* 若两者皆缺,throw(明确暴露组件挂载错误,不静默跳过)。
|
|
*
|
|
* 校验:seat 必须为整数 ∈ [1, currentCount];data 必须含 3 个合法字段。
|
|
*/
|
|
export function applyPlayerData(
|
|
slots: PlayerSlotLike[],
|
|
seat: number,
|
|
currentCount: number,
|
|
data: { nickname?: unknown; bean?: unknown; avatar?: unknown },
|
|
): void {
|
|
validateSeat(seat, currentCount);
|
|
const viewData = extractPlayerViewData(data);
|
|
const slot = slots[seat - 1];
|
|
if (!slot) {
|
|
throw new Error(`[RoomSceneStart] setPlayerData: slot ${seat} is null/undefined`);
|
|
}
|
|
const view = extractPlayerView(slot);
|
|
if (!view) {
|
|
throw new Error(
|
|
`[RoomSceneStart] setPlayerData: slot ${seat} has no PlayerInfoView component`,
|
|
);
|
|
}
|
|
if (typeof view.applyPlayer === 'function') {
|
|
view.applyPlayer(viewData);
|
|
return;
|
|
}
|
|
if (typeof view.bindTo === 'function') {
|
|
// 退化路径:为 view.bindTo 构造一个 minimal reactive store
|
|
// (满足 ViewStore 契约:有 subscribe + value),保证不抛 TypeError。
|
|
view.bindTo(buildOneShotSession(viewData));
|
|
return;
|
|
}
|
|
throw new Error(
|
|
`[RoomSceneStart] setPlayerData: slot ${seat} view has neither applyPlayer nor bindTo`,
|
|
);
|
|
}
|
|
|
|
/** 占位用的 minimal reactive store(只触发一次订阅,值不再变化)。 */
|
|
function buildOneShotSession(viewData: PlayerViewData): { player: { state: { value: PlayerViewData; subscribe(cb: () => void): () => void } } } {
|
|
const state = {
|
|
value: viewData,
|
|
subscribe(_cb: () => void): () => void {
|
|
return () => { /* no-op unsubscribe */ };
|
|
},
|
|
};
|
|
return { player: { state } };
|
|
}
|
|
|
|
/**
|
|
* 把当前可见槽位刷成占位数据(clearSeats)。
|
|
* 数据非法时抛错,不部分清空。
|
|
*/
|
|
export function clearSlots(slots: PlayerSlotLike[], currentCount: number): void {
|
|
for (let i = 0; i < currentCount; i++) {
|
|
applyPlayerData(slots, i + 1, currentCount, PLACEHOLDER_VIEW_DATA);
|
|
}
|
|
} |