feat: add OverlayManager state machine module

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-20 12:50:48 -04:00
parent 41773d0fef
commit 284830a24b

368
js/state-manager.js Normal file
View File

@@ -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<string, OverlayComponent>} */
#components = new Map();
/** @type {Map<string, string>} */
#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<typeof setTimeout> | 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<string, unknown>} */ (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<string, unknown>} d
*/
#applyGameAdded(d) {
const game = /** @type {Record<string, unknown> | 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} d
*/
#applyLobbyUpdated(d) {
if (d.lobbyState !== undefined) this.#context.lobbyState = d.lobbyState;
if (d.playerCount != null) this.#context.playerCount = Number(d.playerCount);
}
/**
* @param {Record<string, unknown>} 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 */
}
}
}
}
}