diff --git a/js/state-manager.js b/js/state-manager.js new file mode 100644 index 0000000..790802b --- /dev/null +++ b/js/state-manager.js @@ -0,0 +1,368 @@ +/** + * 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', '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.#notify(); + } + + /** + * @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.#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() { + const ctx = this.getContext(); + for (const component of this.#components.values()) { + 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.get(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 component of this.#components.values()) { + if (typeof component.deactivate === 'function') { + try { + component.deactivate(); + } catch (_) { + /* ignore */ + } + } + } + } + + this.#state = next; + + if (enteringLobby) { + const ctx = this.getContext(); + for (const component of this.#components.values()) { + if (typeof component.activate === 'function') { + try { + component.activate(ctx); + } catch (_) { + /* ignore */ + } + } + } + } + + if (next !== 'ended') { + this.#clearEndedToIdleTimer(); + } + + this.#notify(); + } + + #fullLobbyReset() { + for (const component of this.#components.values()) { + if (typeof component.deactivate === 'function') { + try { + component.deactivate(); + } catch (_) { + /* ignore */ + } + } + } + const ctx = this.getContext(); + for (const component of this.#components.values()) { + if (typeof component.activate === 'function') { + try { + component.activate(ctx); + } catch (_) { + /* ignore */ + } + } + } + } +}