UNPKG

js13k-online

Version:

A simple client SDK for the JS13K Online Challenge Series that abstracts away the WebSocket connection and state management

329 lines (325 loc) 10.4 kB
//#region src/safeWebSocket.ts function safeWebSocket(url, onConnect = () => {}, timeout = 3e3) { const connect = () => { const ws = new WebSocket(url); const close = () => setTimeout(connect, timeout); ws.addEventListener("close", close); onConnect(ws); }; connect(); } //#endregion //#region src/util.ts function deepClone(obj) { return JSON.parse(JSON.stringify(obj)); } function mergeState(target, source, shouldDeleteOnNull = true) { if (source === null) return null; if (typeof source !== "object") return source; if (typeof target !== "object" || target === null) target = {}; for (const key of Object.keys(source)) if (source[key] === null) if (shouldDeleteOnNull) delete target[key]; else target[key] = null; else target[key] = mergeState(target[key], source[key], shouldDeleteOnNull); return target; } function deepEqual(a, b) { if (a === b) return true; if (Number.isNaN(a) && Number.isNaN(b)) return true; if (typeof a !== typeof b) return false; if (a && b && typeof a === "object") { const aIsArray = Array.isArray(a); const bIsArray = Array.isArray(b); if (aIsArray || bIsArray) { if (!aIsArray || !bIsArray) return false; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false; return true; } const aKeys = Object.keys(a); const bKeys = Object.keys(b); if (aKeys.length !== bKeys.length) return false; for (const key of aKeys) { if (!Object.prototype.hasOwnProperty.call(b, key)) return false; if (!deepEqual(a[key], b[key])) return false; } return true; } return false; } function filterDeltaAgainstBase(delta, base) { if (delta === null) return base === void 0 ? void 0 : null; if (typeof delta !== "object" || delta === null) return deepEqual(delta, base) ? void 0 : delta; if (Array.isArray(delta)) return deepEqual(delta, base) ? void 0 : delta; const result = {}; let hasAny = false; for (const key of Object.keys(delta)) { const filteredChild = filterDeltaAgainstBase(delta[key], base ? base[key] : void 0); if (filteredChild !== void 0) { result[key] = filteredChild; hasAny = true; } } return hasAny ? result : void 0; } function generateUUID() { return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)); } function filterDeltaWithTombstones(delta, tombstones) { const isPlainObject = (v) => v && typeof v === "object" && !Array.isArray(v); const processCollection = (collectionObj) => { const out = {}; for (const entityGuid of Object.keys(collectionObj)) { const entityDelta = collectionObj[entityGuid]; if (tombstones.has(entityGuid)) continue; if (entityDelta === null) { tombstones.add(entityGuid); out[entityGuid] = null; continue; } out[entityGuid] = walk(entityDelta); } return out; }; const walk = (node) => { if (Array.isArray(node)) return node.map((el) => walk(el)); if (!isPlainObject(node)) return node; const out = {}; for (const key of Object.keys(node)) { const value = node[key]; if (key.startsWith("_") && isPlainObject(value)) { const filteredCollection = processCollection(value); if (Object.keys(filteredCollection).length === 0) continue; out[key] = filteredCollection; continue; } out[key] = walk(value); } return out; }; return walk(delta); } //#endregion //#region src/Js13kClient.ts var Js13kClient = class { room; options; socket; myId; localState; eventListeners; connected; remoteState; pendingUberDelta; throttleTimer; tombstones; constructor(room, options = {}) { this.room = room; this.options = { host: location.origin, party: "js13k", deltaEvaluator: (delta, remoteState, playerId) => true, deltaNormalizer: (delta) => delta, throttleMs: 50, debug: false, ...options }; this.socket = null; this.myId = null; this.localState = {}; this.eventListeners = {}; this.connected = false; this.remoteState = {}; this.pendingUberDelta = {}; this.throttleTimer = null; this.tombstones = /* @__PURE__ */ new Set(); this.connect(); } log(...args) { if (this.options.debug) console.log(`[Js13kClient][${this.myId?.slice(-4) || "null"}]`, ...args); } shouldSendDelta(delta) { if (!this.options.deltaEvaluator) return true; return this.options.deltaEvaluator(delta, this.remoteState, this.myId || void 0); } connect() { safeWebSocket(`${this.options.host}/parties/${this.options.party}/${this.room}`, (socket) => { this.socket = socket; socket.addEventListener("open", () => { this.connected = true; this.emit("open"); }); this.socket.addEventListener("close", () => { this.connected = false; this.emit("close"); }); this.socket.addEventListener("error", (event) => { this.emit("error", event); }); this.socket.addEventListener("message", (event) => { try { const data = JSON.parse(event.data); this.handleMessage(data); } catch (error) { console.error("Error parsing message:", error); } }); }); } handleMessage(data) { if (data.id) { this.myId = data.id; this.log(`client id`, data.id); this.emit("id", this.myId); } else if (data.connect) { this.log(`client connected`, data.connect); this.emit("connect", data.connect); } else if (data.disconnect) { this.log(`client disconnected`, data.disconnect); if (this.localState._players && this.localState._players[data.disconnect]) { this.log(`removing client from state`, data.disconnect); delete this.localState._players[data.disconnect]; } this.emit("disconnect", data.disconnect); } else if (data.state) { this.log(`initial state received`, JSON.stringify(data.state, null, 2)); this.localState = data.state; this.remoteState = this.options.deltaNormalizer(deepClone(data.state)); this.log(`initial remote state`, JSON.stringify(this.remoteState, null, 2)); this.emit("state", this.localState); } else if (data.delta) { const filtered = filterDeltaWithTombstones(data.delta, this.tombstones); if (Object.keys(filtered).length === 0) return; this.log(`delta received`, JSON.stringify(filtered, null, 2)); this.localState = mergeState(this.localState, filtered); this.remoteState = mergeState(this.remoteState, this.options.deltaNormalizer(filtered)); this.emit("delta", filtered); } Object.keys(data).filter((key) => ![ "id", "connect", "disconnect", "state", "delta" ].includes(key)).forEach((key) => { this.emit(key, data[key]); }); } on(event, callback) { if (!this.eventListeners[event]) this.eventListeners[event] = []; this.eventListeners[event].push(callback); } off(event, callback) { if (this.eventListeners[event]) { const index = this.eventListeners[event].indexOf(callback); if (index > -1) this.eventListeners[event].splice(index, 1); } } emit(event, data) { if (this.eventListeners[event]) this.eventListeners[event].forEach((callback) => { try { callback(data); } catch (error) { console.error("Error in event listener:", error); } }); } getState() { return this.localState; } getMyId() { return this.myId; } getMyState(copy = false) { if (!this.myId || !this.localState._players) return null; const state = this.localState._players[this.myId]; return copy ? deepClone(state) : state; } getPlayerState(playerId, copy = false) { if (!this.localState._players || !this.localState._players[playerId]) return null; const state = this.localState._players[playerId]; return copy ? deepClone(state) : state; } isConnected() { return this.connected; } sendDelta(delta) { if (!this.socket || !this.connected) { console.warn("Not connected to server, skipping delta"); return; } const deltaString = JSON.stringify({ delta }); this.socket.send(deltaString); } updateState(delta) { this.addLocalTombstonesFromDelta(delta); this.addToPendingDelta(delta); } addToPendingDelta(delta) { this.localState = mergeState(this.localState, delta, true); this.pendingUberDelta = mergeState(this.pendingUberDelta, delta, false); if (!this.throttleTimer) { this.processPendingDelta(); this.throttleTimer = setTimeout(() => { this.throttleTimer = null; this.processPendingDelta(); }, this.options.throttleMs); } } addLocalTombstonesFromDelta(delta) { const isPlainObject = (v) => v && typeof v === "object" && !Array.isArray(v); const walk = (node) => { if (Array.isArray(node)) { for (const el of node) walk(el); return; } if (!isPlainObject(node)) return; for (const key of Object.keys(node)) { const value = node[key]; if (key.startsWith("_") && isPlainObject(value)) { for (const entityGuid of Object.keys(value)) { const entityDelta = value[entityGuid]; if (entityDelta === null) { this.tombstones.add(entityGuid); continue; } walk(entityDelta); } continue; } walk(value); } }; walk(delta); } processPendingDelta() { if (Object.keys(this.pendingUberDelta).length === 0) return; const filteredUberDelta = filterDeltaAgainstBase(this.options.deltaNormalizer(this.pendingUberDelta), this.remoteState); if (!filteredUberDelta || Object.keys(filteredUberDelta).length === 0) {} else if (this.shouldSendDelta(filteredUberDelta)) { this.log(`sending pending delta`, JSON.stringify({ pendingDelta: filteredUberDelta }, null, 2)); this.sendDelta(filteredUberDelta); this.remoteState = this.options.deltaNormalizer(deepClone(this.localState)); } this.pendingUberDelta = {}; } updateMyState(delta) { if (this.myId) this.updateState({ _players: { [this.myId]: delta } }); else console.warn("No myId yet, waiting for server..."); } disconnect() { if (this.throttleTimer) { clearTimeout(this.throttleTimer); this.throttleTimer = null; } this.pendingUberDelta = {}; this.remoteState = {}; this.tombstones.clear(); if (this.socket) this.socket.close(); } }; //#endregion //#region src/index.ts var src_default = Js13kClient; if (typeof window !== "undefined") window.Js13kClient = Js13kClient; //#endregion export { Js13kClient, deepClone, src_default as default, filterDeltaWithTombstones, generateUUID, mergeState }; //# sourceMappingURL=index.js.map