diff --git a/plan.txt b/plan.txt index eebd8b1..0c202c7 100644 --- a/plan.txt +++ b/plan.txt @@ -1,10 +1,12 @@ Open: -- Refactor input messages for more than one player +- Refactor input generics to distinct local/full types +- Multiplayer loopback server - Insecured websocket server implementation - Cloneable RNG that goes in state (use MurmurHash3 finalizer in counter mode?) - remove all random() calls Done: +- Refactor input messages for more than one player - Rework State implementation for easier cloning/deserialization - Test Lockstep/rollback - Smarter typings for Join/Lookup functions diff --git a/src/Ecs/Lockstep.ts b/src/Ecs/Lockstep.ts index cb959d6..711b76b 100644 --- a/src/Ecs/Lockstep.ts +++ b/src/Ecs/Lockstep.ts @@ -2,15 +2,16 @@ export const INPUT_FREQUENCY = 33; // roughly 30fps export interface LockstepProcessor { - compareInput(a: Input, b: Input): boolean; + compareInput(a: Input[], b: Input[]): boolean; + predictInput(prev: Input[] | null, localPlayer: number, localInput: Input): Input[]; cloneState(source: State): State; - advanceState(state: State, input: Input): void; + advanceState(state: State, input: Input[]): void; } export class LockstepState { private inputIndex = -1; - private inputLog: Input[] = []; + private inputLog: Input[][] = []; private canonIndex = -1; private canonState: State; private renderIndex = -1; @@ -21,7 +22,7 @@ export class LockstepState { this.renderState = engine.cloneState(initialState); } - public addCanonInput(input: Input): void { + public addCanonInput(input: Input[]): void { this.canonIndex++; // advance canonical game state @@ -37,13 +38,14 @@ export class LockstepState { this.inputLog[this.canonIndex] = input; } - public addLocalInput(input: Input): void { + /** Warning: this only supports one player input per frame. There is no support for two players using the same lockstep simulation instance. */ + public addLocalInput(player: number, input: Input): void { this.inputIndex++; // ensure that we don't overwrite the canon input with local input somehow // (probably only possible in situations where game is unplayable anyways? but still for sanity.) if(this.inputIndex > this.canonIndex) { - this.inputLog[this.inputIndex] = input; + this.inputLog[this.inputIndex] = this.engine.predictInput(this.inputLog[this.inputIndex - 1] ?? null, player, input); } } @@ -69,7 +71,7 @@ export class Playback { public constructor( private state: State, - private inputLog: Input[], + private inputLog: Input[][], private engine: LockstepProcessor, ) {} diff --git a/src/Game/GameComponents.ts b/src/Game/GameComponents.ts index b6460ad..a289019 100644 --- a/src/Game/GameComponents.ts +++ b/src/Game/GameComponents.ts @@ -23,7 +23,7 @@ export class World { phase = GamePhase.TITLE; score = 0; - constructor() {} + constructor() { } /* * Drawing Layers @@ -50,9 +50,9 @@ export class World { const score = `Score: ${this.score}`; cx.fillStyle = "#000"; - cx.fillText(score, this.width/3 + 1, this.height - 18 + 1, this.width/4); + cx.fillText(score, this.width / 3 + 1, this.height - 18 + 1, this.width / 4); cx.fillStyle = "#0ff"; - cx.fillText(score, this.width/3, this.height - 18, this.width/4); + cx.fillText(score, this.width / 3, this.height - 18, this.width / 4); })); } } @@ -71,6 +71,7 @@ export class Data extends EcsData implements StateForSchema { hp: Store; lifetime: Store; message: Store; + playerControl: Store; // globals debugLayer = new Layer(2); @@ -82,6 +83,7 @@ export class Data extends EcsData implements StateForSchema { this.hp = copySparse(from.hp); this.lifetime = copySparse(from.lifetime); this.message = copySparse(from.message); + this.playerControl = copySparse(from.playerControl); } clone() { @@ -124,7 +126,7 @@ export class Hp extends Component { export class Lifetime extends Component { time: number; - constructor(from: Partial & {time: number}) { + constructor(from: Partial & { time: number }) { super(from); this.time = from.time; } @@ -163,35 +165,55 @@ export class Message extends Component { } } +export class PlayerControl extends Component { + playerNumber: number; + constructor(from: Partial & {playerNumber: number}) { + super(from); + this.playerNumber = from.playerNumber; + } + clone(): PlayerControl { + return new PlayerControl(this); + } +} + export class Engine implements LockstepProcessor { cloneState(old: Data) { return new Data(old); } - compareInput(a: KeyName[], b: KeyName[]): boolean { - if (a.length != b.length) return false; - - let matches = true; - a.forEach((keyA, i) => { - if (keyA != b[i]) { - matches = false; - } - }); - - return matches; + predictInput(prev: KeyName[][] | null, localPlayer: number, localInput: KeyName[]): KeyName[][] { + return prev?.map((prevInput, player) => (player == localPlayer) ? localInput : prevInput) ?? []; } - advanceState(state: Data, input: KeyName[]) { + compareInput(a: KeyName[][], b: KeyName[][]): boolean { + if (a.length != b.length) return false; + + for (let i = 0; i < a.length; i++) { + if (a[i].length != b[i].length) return false; + for (let j = 0; j < a[i].length; j++) { + if (a[i][j] != b[i][j]) { + return false; + } + } + } + + return true; + } + + advanceState(state: Data, input: KeyName[][]) { DumbMotion(state, INPUT_FREQUENCY); - Join(state, "location").forEach(([location]) => { - let dir = 0; - if(input.indexOf("left") != -1) { - dir -= 1; + Join(state, "playerControl", "location").forEach(([player, location]) => { + const playerInput = input[player.playerNumber]; + if(playerInput) { + let dir = 0; + if (playerInput.indexOf("left") != -1) { + dir -= 1; + } + if (playerInput.indexOf("right") != -1) { + dir += 1; + } + location.VAngle = dir * 0.01; } - if(input.indexOf("right") != -1) { - dir += 1; - } - location.VAngle = dir * 0.01; }); } } diff --git a/src/Game/Main.ts b/src/Game/Main.ts index 7e477af..28fb918 100644 --- a/src/Game/Main.ts +++ b/src/Game/Main.ts @@ -8,7 +8,7 @@ import { Create } from "../Ecs/Data"; import { RunRenderBounds } from "../Ecs/Renderers"; import { LockstepClient } from "../Net/LockstepClient"; import { Loopback } from "../Net/LoopbackServer"; -import { Data, Engine } from "./GameComponents"; +import { Data, Engine, PlayerControl } from "./GameComponents"; import { Buttons } from "./Input"; export class Main extends LockstepClient { @@ -43,8 +43,11 @@ export class Main extends LockstepClient { const newState = new Data(patch); Create(newState, { + playerControl: new PlayerControl({ + playerNumber: 0 + }), location: new Location({ - X: 200, + X: 100, Y: 200, }), bounds: new PolygonComponent({points: [-30, 0, 30, 0, 0, 40]}), @@ -54,6 +57,21 @@ export class Main extends LockstepClient { }), }); + Create(newState, { + playerControl: new PlayerControl({ + playerNumber: 1 + }), + location: new Location({ + X: 400, + Y: 200, + }), + bounds: new PolygonComponent({points: [-30, 0, 30, 0, 0, 40]}), + renderBounds: new RenderBounds({ + color: "#f0a", + layer: 0 + }), + }); + return newState; } diff --git a/src/Net/LockstepClient.ts b/src/Net/LockstepClient.ts index ad22e97..43ea9df 100644 --- a/src/Net/LockstepClient.ts +++ b/src/Net/LockstepClient.ts @@ -43,8 +43,8 @@ export type ClientMessage = ; export type ServerMessage = - | Packet }> - | Packet + | Packet }> + | Packet | Packet | Packet ; @@ -53,6 +53,7 @@ export type Server = Callbag, ServerMe export abstract class LockstepClient { + private playerNumber = -1; private state: LockstepState; public constructor( @@ -75,8 +76,10 @@ export abstract class LockstepClient { const sampleInput = () => { if (serverTalkback) { const input = this.gatherInput(); - this.state.addLocalInput(input); - serverTalkback(1, { t: MessageTypes.INPUT, i: input }); + if(this.playerNumber >= 0) { + this.state.addLocalInput(this.playerNumber, input); + serverTalkback(1, { t: MessageTypes.INPUT, i: input }); + } setTimeout(sampleInput, INPUT_FREQUENCY); } }; @@ -95,6 +98,7 @@ export abstract class LockstepClient { case MessageTypes.SET_STATE: const resetState = this.initState(message.s); this.state = new LockstepState(resetState, this.engine); + this.playerNumber = message.u; break; case MessageTypes.INPUT: this.state.addCanonInput(message.i); diff --git a/src/Net/LoopbackServer.ts b/src/Net/LoopbackServer.ts index 9e1deb1..323b858 100644 --- a/src/Net/LoopbackServer.ts +++ b/src/Net/LoopbackServer.ts @@ -15,10 +15,13 @@ export function Loopback(start: number, data?: Client; switch(message.t) { case MessageTypes.INPUT: - sink(1, message); + sink(1, { + t: MessageTypes.INPUT, + i: [message.i], + }); break; } } }); - sink(1, {t: MessageTypes.SET_STATE, s: {}}); + sink(1, {t: MessageTypes.SET_STATE, u: 0, s: {}}); };