summaryrefslogtreecommitdiff
path: root/client/src/net
diff options
context:
space:
mode:
authorTyler Murphy <tylerm@tylerm.dev>2023-06-16 20:38:55 -0400
committerTyler Murphy <tylerm@tylerm.dev>2023-06-16 20:38:55 -0400
commit44334fc3852eb832280a335f72e6416c93a9f19f (patch)
tree4a97b6064a97c4ad58c07d89050ad8a11e7a4f70 /client/src/net
parentbetter map bg renderer (diff)
downloadtuxman-44334fc3852eb832280a335f72e6416c93a9f19f.tar.gz
tuxman-44334fc3852eb832280a335f72e6416c93a9f19f.tar.bz2
tuxman-44334fc3852eb832280a335f72e6416c93a9f19f.zip
ts
Diffstat (limited to '')
-rw-r--r--client/src/net/game.ts186
-rw-r--r--client/src/net/input.ts70
-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;
}