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
JavaScript
//#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