/** * Central overlay state machine: coordinates room/game lifecycle and registered UI components. */ export const OVERRIDE_MODES = Object.freeze({ AUTO: 'auto', FORCE_SHOW: 'force_show', FORCE_HIDE: 'force_hide', }); const OVERRIDE_VALUES = new Set(Object.values(OVERRIDE_MODES)); /** @typedef {'idle'|'lobby'|'playing'|'ended'|'disconnected'} OverlayState */ const VALID_TRANSITIONS = Object.freeze({ idle: new Set(['lobby', 'playing', 'ended', 'disconnected']), lobby: new Set(['lobby', 'playing', 'ended', 'idle', 'disconnected']), playing: new Set(['ended', 'lobby', 'idle', 'disconnected']), ended: new Set(['idle', 'lobby', 'disconnected']), disconnected: new Set(['idle', 'lobby']), }); const EVENT_LOG_MAX = 50; function shallowClone(obj) { return { ...obj }; } /** * @typedef {object} OverlayComponent * @property {(context: object) => void} activate * @property {() => void} deactivate * @property {(context: object) => void} update * @property {() => object} getStatus */ export class OverlayManager { /** @type {OverlayState} */ #state = 'idle'; /** @type {Map} */ #components = new Map(); /** @type {Map} */ #overrides = new Map(); /** @type {{ roomCode?: string, game?: object, maxPlayers?: number, players?: unknown[], lobbyState?: unknown, playerCount?: number, sessionId?: string, lastJoinedPlayer?: unknown, [key: string]: unknown }} */ #context = {}; /** @type {Array<{ type: string, data: unknown, at: number }>} */ #eventLog = []; /** @type {Set<(info: { state: OverlayState, context: object }) => void>} */ #listeners = new Set(); /** @type {ReturnType | null} */ #endedToIdleTimer = null; /** * @param {string} name * @param {OverlayComponent} component */ registerComponent(name, component) { this.#components.set(name, component); } /** * @param {(info: { state: OverlayState, context: object }) => void} listener * @returns {() => void} */ onChange(listener) { this.#listeners.add(listener); return () => this.#listeners.delete(listener); } /** * @param {string} type * @param {unknown} [data] */ logEvent(type, data) { this.#eventLog.push({ type, data, at: Date.now() }); if (this.#eventLog.length > EVENT_LOG_MAX) { this.#eventLog.splice(0, this.#eventLog.length - EVENT_LOG_MAX); } } getEventLog() { return this.#eventLog.map((e) => ({ ...e, data: e.data })); } /** @returns {OverlayState} */ getState() { return this.#state; } getContext() { return shallowClone(this.#context); } /** * @param {string} name * @param {string} mode */ setOverride(name, mode) { if (!OVERRIDE_VALUES.has(mode)) { throw new Error(`Invalid override mode: ${mode}`); } this.#overrides.set(name, mode); this.#applyOverride(name); this.#notify(); } #applyOverride(name) { const component = this.#components.get(name); if (!component) return; const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO; const ctx = this.getContext(); if (mode === OVERRIDE_MODES.FORCE_SHOW) { if (typeof component.activate === 'function') { try { component.activate(ctx); } catch (_) { /* ignore */ } } } else if (mode === OVERRIDE_MODES.FORCE_HIDE) { if (typeof component.deactivate === 'function') { try { component.deactivate(); } catch (_) { /* ignore */ } } } else if (mode === OVERRIDE_MODES.AUTO) { if (this.#state === 'lobby') { if (typeof component.activate === 'function') { try { component.activate(ctx); } catch (_) { /* ignore */ } } } else { if (typeof component.deactivate === 'function') { try { component.deactivate(); } catch (_) { /* ignore */ } } } } } /** * @param {string} name * @returns {string} */ getOverride(name) { return this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO; } getComponentStatuses() { const out = {}; for (const [name, component] of this.#components) { out[name] = { status: typeof component.getStatus === 'function' ? component.getStatus() : null, override: this.getOverride(name), }; } return out; } /** * @param {string} eventType * @param {unknown} [data] */ handleEvent(eventType, data) { this.logEvent(eventType, data); const d = data && typeof data === 'object' ? /** @type {Record} */ (data) : {}; switch (eventType) { case 'game.added': this.#applyGameAdded(d); this.#transitionTo('lobby'); break; case 'room.connected': this.#applyRoomConnected(d); if (this.#state === 'idle' || this.#state === 'ended') { this.#transitionTo('lobby'); } else { this.#broadcastUpdate(); } break; case 'lobby.player-joined': this.#applyLobbyPlayerJoined(d); this.#broadcastUpdate(); break; case 'lobby.updated': this.#applyLobbyUpdated(d); this.#broadcastUpdate(); break; case 'game.started': this.#transitionTo('playing'); break; case 'game.ended': this.#transitionTo('ended'); this.#scheduleEndedToIdle(); break; case 'room.disconnected': this.#context.roomCode = null; this.#context.players = []; this.#context.playerCount = 0; this.#context.lobbyState = null; this.#transitionTo('idle'); break; case 'session.started': if (d.sessionId != null) this.#context.sessionId = /** @type {string} */ (d.sessionId); this.#notify(); break; case 'session.ended': this.#clearEndedToIdleTimer(); this.#clearContext(); this.#transitionTo('idle'); break; case 'player-count.updated': this.#applyPlayerCountUpdated(d); this.#broadcastUpdate(); break; default: break; } } // --- internal --- #notify() { const snapshot = { state: this.#state, context: this.getContext() }; for (const fn of this.#listeners) { try { fn(snapshot); } catch (_) { /* ignore listener errors */ } } } #broadcastUpdate() { if (this.#state !== 'lobby') { this.#notify(); return; } const ctx = this.getContext(); for (const [name, component] of this.#components) { const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO; if (mode === OVERRIDE_MODES.FORCE_HIDE) continue; if (typeof component.update === 'function') { try { component.update(ctx); } catch (_) { /* ignore */ } } } this.#notify(); } #clearContext() { this.#context = {}; } #clearEndedToIdleTimer() { if (this.#endedToIdleTimer != null) { clearTimeout(this.#endedToIdleTimer); this.#endedToIdleTimer = null; } } #scheduleEndedToIdle() { this.#clearEndedToIdleTimer(); this.#endedToIdleTimer = setTimeout(() => { this.#endedToIdleTimer = null; if (this.#state === 'ended') { this.#transitionTo('idle'); } }, 2000); } /** * @param {Record} d */ #applyGameAdded(d) { const game = /** @type {Record | undefined} */ (d.game); if (!game || typeof game !== 'object') return; const code = game.room_code ?? game.roomCode ?? game.code; if (code != null) this.#context.roomCode = String(code); this.#context.game = { ...game }; } /** * @param {Record} d */ #applyRoomConnected(d) { if (d.maxPlayers != null) this.#context.maxPlayers = Number(d.maxPlayers); if (Array.isArray(d.players)) this.#context.players = [...d.players]; if (d.lobbyState !== undefined) this.#context.lobbyState = d.lobbyState; if (d.playerCount != null) this.#context.playerCount = Number(d.playerCount); } /** * @param {Record} d */ #applyLobbyPlayerJoined(d) { if (d.maxPlayers != null) this.#context.maxPlayers = Number(d.maxPlayers); if (d.playerCount != null) this.#context.playerCount = Number(d.playerCount); if (Array.isArray(d.players)) this.#context.players = [...d.players]; if (d.player !== undefined) this.#context.lastJoinedPlayer = d.player; if (d.lastJoinedPlayer !== undefined) this.#context.lastJoinedPlayer = d.lastJoinedPlayer; } /** * @param {Record} d */ #applyLobbyUpdated(d) { if (d.lobbyState !== undefined) this.#context.lobbyState = d.lobbyState; if (d.playerCount != null) this.#context.playerCount = Number(d.playerCount); } /** * @param {Record} d */ #applyPlayerCountUpdated(d) { const n = d.playerCount ?? d.count; if (n != null) this.#context.playerCount = Number(n); } /** * @param {OverlayState} next */ #transitionTo(next) { const current = this.#state; if (!VALID_TRANSITIONS[current]?.has(next)) { return; } if (current === 'lobby' && next === 'lobby') { this.#fullLobbyReset(); this.#notify(); return; } const leavingLobby = current === 'lobby' && next !== 'lobby'; const enteringLobby = next === 'lobby' && current !== 'lobby'; if (leavingLobby) { for (const [name, component] of this.#components) { const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO; if (mode !== OVERRIDE_MODES.AUTO) continue; if (typeof component.deactivate === 'function') { try { component.deactivate(); } catch (_) { /* ignore */ } } } } this.#state = next; if (enteringLobby) { const ctx = this.getContext(); for (const [name, component] of this.#components) { const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO; if (mode !== OVERRIDE_MODES.AUTO) continue; if (typeof component.activate === 'function') { try { component.activate(ctx); } catch (_) { /* ignore */ } } } } if (next !== 'ended') { this.#clearEndedToIdleTimer(); } this.#notify(); } #fullLobbyReset() { for (const [name, component] of this.#components) { const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO; if (mode !== OVERRIDE_MODES.AUTO) continue; if (typeof component.deactivate === 'function') { try { component.deactivate(); } catch (_) { /* ignore */ } } } const ctx = this.getContext(); for (const [name, component] of this.#components) { const mode = this.#overrides.get(name) ?? OVERRIDE_MODES.AUTO; if (mode !== OVERRIDE_MODES.AUTO) continue; if (typeof component.activate === 'function') { try { component.activate(ctx); } catch (_) { /* ignore */ } } } } }