319 lines
7.4 KiB
JavaScript
319 lines
7.4 KiB
JavaScript
|
/**
|
||
|
* @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;
|
||
|
}
|
||
|
}
|