feat: add OverlayManager state machine module
Made-with: Cursor
This commit is contained in:
368
js/state-manager.js
Normal file
368
js/state-manager.js
Normal 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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user