diff options
author | Tyler Murphy <tylerm@tylerm.dev> | 2023-06-16 20:38:55 -0400 |
---|---|---|
committer | Tyler Murphy <tylerm@tylerm.dev> | 2023-06-16 20:38:55 -0400 |
commit | 44334fc3852eb832280a335f72e6416c93a9f19f (patch) | |
tree | 4a97b6064a97c4ad58c07d89050ad8a11e7a4f70 /client/src/net | |
parent | better map bg renderer (diff) | |
download | tuxman-44334fc3852eb832280a335f72e6416c93a9f19f.tar.gz tuxman-44334fc3852eb832280a335f72e6416c93a9f19f.tar.bz2 tuxman-44334fc3852eb832280a335f72e6416c93a9f19f.zip |
ts
Diffstat (limited to '')
-rw-r--r-- | client/src/net/game.ts | 186 | ||||
-rw-r--r-- | client/src/net/input.ts | 70 | ||||
-rw-r--r-- | client/src/net/multiplayer.ts (renamed from client/js/multiplayer.js) | 85 |
3 files changed, 291 insertions, 50 deletions
diff --git a/client/src/net/game.ts b/client/src/net/game.ts new file mode 100644 index 0000000..c8e5991 --- /dev/null +++ b/client/src/net/game.ts @@ -0,0 +1,186 @@ +import { Frame, GameState, Input, Key, KeyMap, PlayerInput } from "../types.js"; +import { startInputListener } from "./input.js"; +import { multiplayer } from "./multiplayer.js"; + +/** + * @author tint + * @template Data, Input + */ +export class Game { + + historysize: number + history: Frame[] + historyStart: number + currentFrame: number + advance: (pastData: GameState, input: Input, frame: number) => GameState + + constructor(history: number) { + this.historysize = history; + + this.history = []; + this.historyStart = 0; + + // the game may have inputs from the "future" + // (local input delay to make sure inputs play at the same time on all machines) + // so the "present" isn't always the latest frame + // the game loop should set this every frame + this.currentFrame = 0; + } + + startHistory(frame: number, data: GameState) { + this.historyStart = frame; + this.history = [{ data, input: { players: {} }}]; + this.currentFrame = frame; + } + + getHistory(frame: number): Frame { + return this.history[frame - this.historyStart]; + } + + getFrame(): number { + return this.historyStart + this.history.length - 1; + } + + getCurrentData(): GameState { + const entry = this.history[this.history.length - 1]; + return entry && entry.data; + } + + /** + * Sets the input at a specific frame. If that frame is in history, + * the game will be rewound, the input applied, and then fast-forwarded to the current head. + * If the frame is ahead of the current latest frame, the game will be run until that frame. + */ + setInput(frame: number, input: Input) { + console.log('input', frame, input) + this.editFrame(frame, (index: number): void => { + let past = this.history[index - 1]; + if(index === 0) { + past = { data: undefined, input: undefined }; + } + this.history[index] = { + input, + data: this.advance(past ? past.data : undefined, input, frame), + }; + }); + } + + setData(frame: number, data: GameState) { + console.log('data', frame, data) + this.editFrame(frame, (index: number): void => { + this.history[index] = { + data, + input: this.history[index] && this.history[index].input, + } + }); + } + + editFrame(frame: number, edit: (index: number) => void) { + const head = this.historyStart + this.history.length; + if(frame < head) { + if(frame < this.historyStart) { + throw new Error("Tried to edit a past frame not in history: " + frame); + } + + edit(frame - this.historyStart); + // fast forward back to the present with the new data + for(let i = frame + 1; i < head; i++) { + const past = this.history[i - this.historyStart - 1]; + this.history[i - this.historyStart].data = this.advance( + past ? past.data : undefined, + this.history[i - this.historyStart].input, + i + ); + } + } else { + // fast forward the inbetween frames with no input + for(let i = head; i < frame; i++) { + const entry = this.history[i - this.historyStart - 1]; + this.history[i - this.historyStart] = { + input: undefined, + data: this.advance(entry ? entry.data : undefined, undefined, i), + }; + } + edit(frame - this.historyStart); + } + + while(this.history.length > this.historysize) { + this.history.shift(); + this.historyStart++; + } + } + + start ( + code: string, + keymap: KeyMap, + onLoad: (startFrame: Frame) => boolean, + onFrame: (data: Frame, frame: number) => void, + onLogic: (pastData: GameState, input: Input, frame: number) => GameState, + data: PlayerInput = { start: false, key: Key.NOTHING } + ): void { + + const fps = 60; + let delay = 3; + + this.advance = onLogic + + const onStart = ( + startFrame: number, + latency: number, + _connection: number, + update: (input: PlayerInput, frame: number) => void, + _ping: () => Promise<number>, + _desyncCheck: (frame: number) => Promise<boolean>, + ) => { + console.log("started game at frame", startFrame); + // window.desyncCheck = () => desyncCheck(this.currentFrame - 5); + + let startTs = performance.now() - latency; + let lastFrame = startFrame; + update(data, startFrame + 1); + + let getInput = startInputListener(keymap) + + const startData = this.getHistory(startFrame) + + if (!onLoad(startData)) return false + + let lastTs = performance.now(); + + let loop = (ts: number) => { + + const frame = Math.floor((ts - startTs) / 1000 * fps) + startFrame; + + if(frame !== lastFrame) { // update input once per frame, regardless of the display refresh rate + lastFrame = frame; + + // gather input + const input: PlayerInput = getInput(); + + // apply input + update(input, frame + delay); + } + + this.currentFrame = frame + const data = this.getHistory(frame) + + onFrame(data, frame) + + lastTs = ts + + requestAnimationFrame(loop) + } + + requestAnimationFrame(loop) + + if(startFrame === -1) { + update(data, 0); + } + + return true + } + + multiplayer(this, code, onStart) + + } +} diff --git a/client/src/net/input.ts b/client/src/net/input.ts new file mode 100644 index 0000000..75be3e6 --- /dev/null +++ b/client/src/net/input.ts @@ -0,0 +1,70 @@ +import { Key, KeyMap, PlayerInput } from "../types.js" + +let pressed = {} + +const updateRecent = (keymap: KeyMap) => { + let max = -1 + let key = undefined + for (let code in pressed) { + let weight = pressed[code] + if (weight < max) continue + max = weight + key = keymap[code] + } + + return key +} + +export const startInputListener = (keymap: KeyMap): () => PlayerInput => { + let key: Key = Key.NOTHING; + let start = false; + + document.getElementById("start").onclick = function() { + start = true + } + + window.addEventListener("keydown", ev => { + if(ev.repeat) { + return; + } + if(!(ev.code in keymap)) { + return; + } + pressed[ev.code] = Object.keys(pressed).length + key = updateRecent(keymap) + }); + + window.addEventListener("keyup", ev => { + if (ev.repeat) { + return; + } + if (!(ev.code in keymap)) { + return + } + delete pressed[ev.code] + key = updateRecent(keymap) + }) + + let last = { + key: Key.NOTHING, + } + + return (): PlayerInput => { + + if(key === last.key && !start) { + return; + } + + last = { + key, + }; + + let s = start; + start = false; + + return { + key, + start: s, + } + } +} diff --git a/client/js/multiplayer.js b/client/src/net/multiplayer.ts index eec1c7d..e9f3057 100644 --- a/client/js/multiplayer.js +++ b/client/src/net/multiplayer.ts @@ -2,52 +2,39 @@ * @author tint */ -/** - * @template Input - * @typedef {{ - * added?: number[], - * removed?: number[], - * players: { - * [conn: number]: Input - * }, - * }} GameInput - */ +import { GameState, Message, PlayerInput } from "../types.js"; +import { Game } from "./game"; -/** - * @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); +export function multiplayer( + game: Game, + code: string, + onStart: ( + startFrame: number, + latency: number, + connection: number, + update: (input: PlayerInput, frame: number) => void, + ping: () => Promise<number>, + desyncCheck: (frame: number) => Promise<boolean>, + ) => boolean +) { + const url = new URL("api/join/" + encodeURIComponent(code), window.location.toString()); url.protocol = url.protocol.replace("http", "ws"); const socket = new WebSocket(url); - let requestStateTime; + let requestStateTime: number; let hasState = false; - let connectionId; + let connectionId: number; let cachedInputs = []; let connections = []; - let pingPromise; + let pingPromise: (type: Promise<number>) => void; - function send(obj) { + function send(obj: any) { socket.send(JSON.stringify(obj)); } - function applyInput(input) { + function applyInput(input: Message) { let prev = game.getHistory(input.frame); let newInput = prev && prev.input ? {...prev.input} : { players: {} }; @@ -74,11 +61,10 @@ export function multiplayer(game, code, onStart) { newInput.removed = (newInput.removed || []).concat([input.removed]); } } - game.setInput(input.frame, newInput); } - function flushCachedInputs(latency = 0) { + function flushCachedInputs(latency = 0): boolean { for(const input of cachedInputs) { // only care about inputs after the new state if(input.frame <= game.historyStart) { @@ -91,15 +77,14 @@ export function multiplayer(game, code, onStart) { return onStart(game.getFrame(), latency, connectionId, update, ping, desyncCheck); } - function update(input, frame) { + function update(input: PlayerInput, frame: number) { if(input === undefined) { // used to update the game locally if(hasState) { - applyInput({ - frame, - }); + applyInput({}) } return; } + const data = { type: "input", data: input, @@ -118,17 +103,17 @@ export function multiplayer(game, code, onStart) { type: "ping", frame: Math.max(0, game.currentFrame), }); - const frame = await new Promise(r => pingPromise = r); + const frame: number = await new Promise(r => pingPromise = r); return game.currentFrame - frame; } - async function desyncCheck(frame) { + async function desyncCheck(frame: number): Promise<boolean> { 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 localstate = history.data; const proms = connections .filter(n => n !== connectionId) .map(connection => { @@ -138,7 +123,7 @@ export function multiplayer(game, code, onStart) { connection, }); return new Promise(r => { - stateRequests[frame + "," + connection] = state => { + stateRequests[frame + "," + connection] = (state: GameState) => { r({ state, connection, @@ -150,11 +135,11 @@ export function multiplayer(game, code, onStart) { 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; - } + // 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; } @@ -203,7 +188,7 @@ export function multiplayer(game, code, onStart) { case "requeststate": // wait until there's some state to send const startTime = performance.now(); - function check() { + const check = () => { if(performance.now() - startTime > 5000) { return; // give up after 5s } @@ -272,7 +257,7 @@ export function multiplayer(game, code, onStart) { } // compare two plain objects (things that can be JSON.stringified) -function objeq(a, b) { +function objeq(a: any, b: any) { if(typeof(a) !== typeof(b)) { return false; } |