Add notion of multiple connected players to lockstep protocol

This commit is contained in:
Tangent Wantwight 2020-05-01 21:45:31 -04:00
parent 39018635cd
commit 743905cad4
6 changed files with 91 additions and 40 deletions

View file

@ -1,10 +1,12 @@
Open: Open:
- Refactor input messages for more than one player - Refactor input generics to distinct local/full types
- Multiplayer loopback server
- Insecured websocket server implementation - Insecured websocket server implementation
- Cloneable RNG that goes in state (use MurmurHash3 finalizer in counter mode?) - Cloneable RNG that goes in state (use MurmurHash3 finalizer in counter mode?)
- remove all random() calls - remove all random() calls
Done: Done:
- Refactor input messages for more than one player
- Rework State implementation for easier cloning/deserialization - Rework State implementation for easier cloning/deserialization
- Test Lockstep/rollback - Test Lockstep/rollback
- Smarter typings for Join/Lookup functions - Smarter typings for Join/Lookup functions

View file

@ -2,15 +2,16 @@
export const INPUT_FREQUENCY = 33; // roughly 30fps export const INPUT_FREQUENCY = 33; // roughly 30fps
export interface LockstepProcessor<Input, State> { export interface LockstepProcessor<Input, State> {
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; cloneState(source: State): State;
advanceState(state: State, input: Input): void; advanceState(state: State, input: Input[]): void;
} }
export class LockstepState<Input, State> { export class LockstepState<Input, State> {
private inputIndex = -1; private inputIndex = -1;
private inputLog: Input[] = []; private inputLog: Input[][] = [];
private canonIndex = -1; private canonIndex = -1;
private canonState: State; private canonState: State;
private renderIndex = -1; private renderIndex = -1;
@ -21,7 +22,7 @@ export class LockstepState<Input, State> {
this.renderState = engine.cloneState(initialState); this.renderState = engine.cloneState(initialState);
} }
public addCanonInput(input: Input): void { public addCanonInput(input: Input[]): void {
this.canonIndex++; this.canonIndex++;
// advance canonical game state // advance canonical game state
@ -37,13 +38,14 @@ export class LockstepState<Input, State> {
this.inputLog[this.canonIndex] = input; 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++; this.inputIndex++;
// ensure that we don't overwrite the canon input with local input somehow // 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.) // (probably only possible in situations where game is unplayable anyways? but still for sanity.)
if(this.inputIndex > this.canonIndex) { 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<Input, State> {
public constructor( public constructor(
private state: State, private state: State,
private inputLog: Input[], private inputLog: Input[][],
private engine: LockstepProcessor<Input, State>, private engine: LockstepProcessor<Input, State>,
) {} ) {}

View file

@ -23,7 +23,7 @@ export class World {
phase = GamePhase.TITLE; phase = GamePhase.TITLE;
score = 0; score = 0;
constructor() {} constructor() { }
/* /*
* Drawing Layers * Drawing Layers
@ -50,9 +50,9 @@ export class World {
const score = `Score: ${this.score}`; const score = `Score: ${this.score}`;
cx.fillStyle = "#000"; 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.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<GameSchema> {
hp: Store<Hp>; hp: Store<Hp>;
lifetime: Store<Lifetime>; lifetime: Store<Lifetime>;
message: Store<Message>; message: Store<Message>;
playerControl: Store<PlayerControl>;
// globals // globals
debugLayer = new Layer(2); debugLayer = new Layer(2);
@ -82,6 +83,7 @@ export class Data extends EcsData implements StateForSchema<GameSchema> {
this.hp = copySparse(from.hp); this.hp = copySparse(from.hp);
this.lifetime = copySparse(from.lifetime); this.lifetime = copySparse(from.lifetime);
this.message = copySparse(from.message); this.message = copySparse(from.message);
this.playerControl = copySparse(from.playerControl);
} }
clone() { clone() {
@ -124,7 +126,7 @@ export class Hp extends Component<Hp> {
export class Lifetime extends Component<Lifetime> { export class Lifetime extends Component<Lifetime> {
time: number; time: number;
constructor(from: Partial<Lifetime> & {time: number}) { constructor(from: Partial<Lifetime> & { time: number }) {
super(from); super(from);
this.time = from.time; this.time = from.time;
} }
@ -163,35 +165,55 @@ export class Message extends Component<Message> {
} }
} }
export class PlayerControl extends Component<PlayerControl> {
playerNumber: number;
constructor(from: Partial<PlayerControl> & {playerNumber: number}) {
super(from);
this.playerNumber = from.playerNumber;
}
clone(): PlayerControl {
return new PlayerControl(this);
}
}
export class Engine implements LockstepProcessor<KeyName[], Data> { export class Engine implements LockstepProcessor<KeyName[], Data> {
cloneState(old: Data) { cloneState(old: Data) {
return new Data(old); return new Data(old);
} }
compareInput(a: KeyName[], b: KeyName[]): boolean { predictInput(prev: KeyName[][] | null, localPlayer: number, localInput: KeyName[]): KeyName[][] {
if (a.length != b.length) return false; return prev?.map((prevInput, player) => (player == localPlayer) ? localInput : prevInput) ?? [];
let matches = true;
a.forEach((keyA, i) => {
if (keyA != b[i]) {
matches = false;
}
});
return matches;
} }
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); DumbMotion(state, INPUT_FREQUENCY);
Join(state, "location").forEach(([location]) => { Join(state, "playerControl", "location").forEach(([player, location]) => {
let dir = 0; const playerInput = input[player.playerNumber];
if(input.indexOf("left") != -1) { if(playerInput) {
dir -= 1; 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;
}); });
} }
} }

View file

@ -8,7 +8,7 @@ import { Create } from "../Ecs/Data";
import { RunRenderBounds } from "../Ecs/Renderers"; import { RunRenderBounds } from "../Ecs/Renderers";
import { LockstepClient } from "../Net/LockstepClient"; import { LockstepClient } from "../Net/LockstepClient";
import { Loopback } from "../Net/LoopbackServer"; import { Loopback } from "../Net/LoopbackServer";
import { Data, Engine } from "./GameComponents"; import { Data, Engine, PlayerControl } from "./GameComponents";
import { Buttons } from "./Input"; import { Buttons } from "./Input";
export class Main extends LockstepClient<KeyName[], Data> { export class Main extends LockstepClient<KeyName[], Data> {
@ -43,8 +43,11 @@ export class Main extends LockstepClient<KeyName[], Data> {
const newState = new Data(patch); const newState = new Data(patch);
Create(newState, { Create(newState, {
playerControl: new PlayerControl({
playerNumber: 0
}),
location: new Location({ location: new Location({
X: 200, X: 100,
Y: 200, Y: 200,
}), }),
bounds: new PolygonComponent({points: [-30, 0, 30, 0, 0, 40]}), bounds: new PolygonComponent({points: [-30, 0, 30, 0, 0, 40]}),
@ -54,6 +57,21 @@ export class Main extends LockstepClient<KeyName[], Data> {
}), }),
}); });
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; return newState;
} }

View file

@ -43,8 +43,8 @@ export type ClientMessage<Input, State> =
; ;
export type ServerMessage<Input, State> = export type ServerMessage<Input, State> =
| Packet<MessageTypes.SET_STATE, { s: Partial<State> }> | Packet<MessageTypes.SET_STATE, { u: number, s: Partial<State> }>
| Packet<MessageTypes.INPUT, { i: Input }> | Packet<MessageTypes.INPUT, { i: Input[] }>
| Packet<MessageTypes.GET_STATE, { c: number }> | Packet<MessageTypes.GET_STATE, { c: number }>
| Packet<MessageTypes.PING, { z: number }> | Packet<MessageTypes.PING, { z: number }>
; ;
@ -53,6 +53,7 @@ export type Server<Input, State> = Callbag<ClientMessage<Input, State>, ServerMe
export abstract class LockstepClient<Input, State> { export abstract class LockstepClient<Input, State> {
private playerNumber = -1;
private state: LockstepState<Input, State>; private state: LockstepState<Input, State>;
public constructor( public constructor(
@ -75,8 +76,10 @@ export abstract class LockstepClient<Input, State> {
const sampleInput = () => { const sampleInput = () => {
if (serverTalkback) { if (serverTalkback) {
const input = this.gatherInput(); const input = this.gatherInput();
this.state.addLocalInput(input); if(this.playerNumber >= 0) {
serverTalkback(1, { t: MessageTypes.INPUT, i: input }); this.state.addLocalInput(this.playerNumber, input);
serverTalkback(1, { t: MessageTypes.INPUT, i: input });
}
setTimeout(sampleInput, INPUT_FREQUENCY); setTimeout(sampleInput, INPUT_FREQUENCY);
} }
}; };
@ -95,6 +98,7 @@ export abstract class LockstepClient<Input, State> {
case MessageTypes.SET_STATE: case MessageTypes.SET_STATE:
const resetState = this.initState(message.s); const resetState = this.initState(message.s);
this.state = new LockstepState(resetState, this.engine); this.state = new LockstepState(resetState, this.engine);
this.playerNumber = message.u;
break; break;
case MessageTypes.INPUT: case MessageTypes.INPUT:
this.state.addCanonInput(message.i); this.state.addCanonInput(message.i);

View file

@ -15,10 +15,13 @@ export function Loopback<Input, State>(start: number, data?: Client<Input, State
const message = data as ClientMessage<Input, State>; const message = data as ClientMessage<Input, State>;
switch(message.t) { switch(message.t) {
case MessageTypes.INPUT: case MessageTypes.INPUT:
sink(1, message); sink(1, {
t: MessageTypes.INPUT,
i: [message.i],
});
break; break;
} }
} }
}); });
sink(1, {t: MessageTypes.SET_STATE, s: {}}); sink(1, {t: MessageTypes.SET_STATE, u: 0, s: {}});
}; };