base2020/src/net/LockstepClient.ts

137 lines
4.7 KiB
TypeScript

import { Callbag } from "callbag";
import animationFrames from "callbag-animation-frames";
import map from "callbag-map";
import pipe from "callbag-pipe";
import { LockstepProcessor, LockstepState, TICK_LENGTH } from "../ecs/Lockstep";
export const enum MessageTypes {
/**
* Reserved, likely for transient messages that wouldn't make sense appearing in a replay
*/
META = "m",
/**
* From server: initialize client state / load new level, plus informing client of their "controller number"
* From client: (op only) force a new game state, likely loading a level
*/
SET_STATE = "s",
/**
* From server: array of canonical inputs for a frame
* From client: input for their controller for the next frame (TBD: strategy for handling late inputs)
*/
INPUT = "i",
/**
* From server: issue request for the current game state, with a cookie to identify the response
* From client: serialized game state, with the cookie of the request
*/
GET_STATE = "g",
}
export type Packet<TypeId, Payload> = { t: TypeId } & Payload;
export type Meta = {
helo?: string;
};
export type ClientMessage<LocalInput, State> =
| Packet<MessageTypes.SET_STATE, { s: Partial<State> }>
| Packet<MessageTypes.INPUT, { i: LocalInput }>
| Packet<MessageTypes.GET_STATE, { c: number, s: State }>
;
export type ServerMessage<GlobalInput, State> =
| Packet<MessageTypes.META, Meta>
| Packet<MessageTypes.SET_STATE, { u: number | null, s: Partial<State> }>
| Packet<MessageTypes.INPUT, { i: GlobalInput }>
| Packet<MessageTypes.GET_STATE, { c: number }>
;
export type Server<LocalInput, GlobalInput, State> = Callbag<ClientMessage<LocalInput, State>, ServerMessage<GlobalInput, State>>;
export abstract class LockstepClient<LocalInput, GlobalInput, State> {
private playerNumber: number | null = null;
private state: LockstepState<LocalInput, GlobalInput, State>;
private serverTalkback: Server<LocalInput, GlobalInput, State> | null = null;
public constructor(
public readonly engine: LockstepProcessor<LocalInput, GlobalInput, State>,
) {
const initialState = this.initState({});
this.state = new LockstepState(initialState, engine);
}
public abstract initState(init: Partial<State>): State;
public abstract gatherInput(): LocalInput;
/**
* Connect to a [perhaps emulated] server and return a disconnect callback
*
* Only call this once or things will break.
*/
public connect(server: Server<LocalInput, GlobalInput, State>): () => void {
// connect to server
server(0, (mode: number, data: Server<LocalInput, GlobalInput, State> | ServerMessage<GlobalInput, State>) => {
if (mode == 0) {
this.serverTalkback = data as Server<LocalInput, GlobalInput, State>;
// kickoff input sender
setTimeout(this.sampleInput, TICK_LENGTH);
} else if (mode == 1) {
// server message
const message = data as ServerMessage<GlobalInput, State>;
this.processMessage(message);
} else if (mode == 2) {
// disconnected
console.log("Disconnected from server", data);
this.serverTalkback = null;
}
});
// disposal
return () => {
this.serverTalkback?.(2);
this.serverTalkback = null;
};
}
private sampleInput = () => {
if (this.serverTalkback) {
const input = this.gatherInput();
if(this.playerNumber !== null) {
this.state.addLocalInput(this.playerNumber, input);
this.serverTalkback(1, { t: MessageTypes.INPUT, i: input });
}
setTimeout(this.sampleInput, TICK_LENGTH);
}
};
private processMessage(message: ServerMessage<GlobalInput, State>) {
switch (message.t) {
case MessageTypes.META:
if(message.helo) {
console.log(`Connected to ${message.helo}`);
// Connection established, reset state for now
this.serverTalkback?.(1, {t: MessageTypes.SET_STATE, s: {}});
}
break;
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);
break;
}
}
public renderFrames = pipe(
animationFrames,
map(_ms => this.state.getStateToRender())
);
}