Add notion of multiple connected players to lockstep protocol
This commit is contained in:
parent
39018635cd
commit
743905cad4
6 changed files with 91 additions and 40 deletions
4
plan.txt
4
plan.txt
|
@ -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
|
||||||
|
|
|
@ -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>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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: {}});
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue