tuxman/client/js/multiplayer.js

319 lines
7.4 KiB
JavaScript
Raw Normal View History

2023-06-14 01:18:01 +00:00
/**
* @author tint
*/
/**
* @template Input
* @typedef {{
* added?: number[],
* removed?: number[],
* players: {
* [conn: number]: Input
* },
* }} GameInput
*/
/**
* @template Data, Input
* @param {import("./game.js").Game<Data, GameInput<Input>>} game
* @param {string} code
* @param {(
* startFrame: number,
* latency: number,
* connection: number,
* update: (input: Input, frame: number) => void,
* ping: () => Promise<number>,
* desyncCheck: () => Promise<boolean>,
* ) => 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;
}
}