/** * @author tint */ /** * @template Input * @typedef {{ * added?: number[], * removed?: number[], * players: { * [conn: number]: Input * }, * }} GameInput */ /** * @template Data, Input * @param {import("./game.js").Game>} game * @param {string} code * @param {( * startFrame: number, * latency: number, * connection: number, * update: (input: Input, frame: number) => void, * ping: () => Promise, * desyncCheck: () => Promise, * ) => void} onStart * Called when the game is in a ready state with the current frame * (or -1 if you're starting the room), an estimate of how many milliseconds have elapsed since that frame was sent, * and your connection ID */ export function multiplayer(game, code, onStart) { const url = new URL("api/join/" + encodeURIComponent(code), window.location); url.protocol = url.protocol.replace("http", "ws"); const socket = new WebSocket(url); let requestStateTime; let hasState = false; let connectionId; let cachedInputs = []; let connections = []; let pingPromise; function send(obj) { socket.send(JSON.stringify(obj)); } function applyInput(input) { let prev = game.getHistory(input.frame); let newInput = prev && prev.input ? {...prev.input} : { players: {} }; if(input.type === "input") { if(input.connection === undefined) { // local input if(input.data) { // send it to the server send(input); // then apply it newInput.players[connectionId] = input.data; } } else { newInput.players[input.connection] = input.data; } } else if(input.type === "connections") { if(input.added !== null) { newInput.added = (newInput.added || []).concat([input.added]); } if(input.removed !== null) { if(newInput.added) { newInput.added = newInput.added.filter(n => n !== input.removed); } newInput.removed = (newInput.removed || []).concat([input.removed]); } } game.setInput(input.frame, newInput); } function flushCachedInputs(latency = 0) { for(const input of cachedInputs) { // only care about inputs after the new state if(input.frame <= game.historyStart) { continue; } applyInput(input); } cachedInputs = []; return onStart(game.getFrame(), latency, connectionId, update, ping, desyncCheck); } function update(input, frame) { if(input === undefined) { // used to update the game locally if(hasState) { applyInput({ frame, }); } return; } const data = { type: "input", data: input, frame: frame, }; if(!hasState) { cachedInputs.push(data); } else { applyInput(data); } } async function ping() { send({ type: "ping", frame: Math.max(0, game.currentFrame), }); const frame = await new Promise(r => pingPromise = r); return game.currentFrame - frame; } async function desyncCheck(frame) { const history = game.getHistory(frame); if(!history) { console.error("tried to check for desyncs on a frame not in memory", frame); return true; } const localstate = history.data; const proms = connections .filter(n => n !== connectionId) .map(connection => { send({ type: "requeststate", frame, connection, }); return new Promise(r => { stateRequests[frame + "," + connection] = state => { r({ state, connection, }); } }); }); if(!proms.length) { return false; // this is the only connection, no check necessary } const states = await Promise.all(proms); if(!states.every(({ state }) => objeq(localstate, state))) { console.error("desync! remote states:", states, "local state:", localstate); return true; } return false; } let stateRequests = {}; socket.onmessage = message => { const data = JSON.parse(message.data.toString()); switch(data.type) { case "error": console.error(data); break; case "framerequest": send({ type: "frame", frame: Math.max(game.currentFrame, 1), }); break; case "state": if(data.frame + "," + data.connection in stateRequests) { stateRequests[data.frame + "," + data.connection](data.state); } if(!hasState) { game.startHistory(data.frame, data.state); hasState = true; // this state is from the past // i want to find out exactly how far in the past // the sequence of requests looks like: // client -[staterequest]-> server -[staterequest]-> client2 // client2 -[state]-> server -[state]-> client // and the time i'm concerned with is the second half, // how long it takes the state to come from client2 let delta = 0; if(requestStateTime !== undefined) { delta = performance.now() - requestStateTime; } if (!flushCachedInputs(delta / 2)) { socket.close() document.getElementById("lobby").style.display = "none" document.getElementById("join").style.display = "" return } } break; case "requeststate": // wait until there's some state to send const startTime = performance.now(); function check() { if(performance.now() - startTime > 5000) { return; // give up after 5s } const state = game.getHistory(data.frame); if(!state) { return; } send({ type: "state", frame: data.frame, state: state.data, }); clearInterval(interval); } const interval = setInterval(check, 100); check(); break; case "connections": connections = data.connections; if(connectionId === undefined) { console.log("setting connection id", data.id); connectionId = data.id; if(data.connections.length === 1) { // no need to request state hasState = true; applyInput(data); flushCachedInputs(); // just in case, also it calls onStart break; } // grab the state from another client console.log("requesting state"); // measure the time it takes for state to be delivered requestStateTime = performance.now(); send({ type: "requeststate", frame: data.frame, }); } if(!hasState) { cachedInputs.push(data); } else { applyInput(data); } break; case "input": if(!hasState) { cachedInputs.push(data); } else { applyInput(data); } break; case "pong": if(pingPromise) { pingPromise(data.frame); pingPromise = undefined; } break; default: console.warn("unknown server message", data); break; } } } // compare two plain objects (things that can be JSON.stringified) function objeq(a, b) { if(typeof(a) !== typeof(b)) { return false; } // array diff if(Array.isArray(a) && Array.isArray(b)) { if(a.length !== b.length) { return false; } for(let i = 0; i < a.length; i++) { if(!objeq(a[i], b[i])) { return false; } } return true; } switch(typeof(a)) { // primitives can be compared directly case "number": case "boolean": case "string": case "undefined": return a === b; case "object": // typeof(null) = "object" but null can be compared directly if(a === null || b === null) { return a === b; } // object diff for(let k in a) { if(!(k in b) || !objeq(a[k], b[k])) { return false; } } for(let k in b) { if(!(k in a)) { return false; } } return true; default: // incomparable things return false; } }