/** * 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); } }