import { Callbag } from "callbag"; import animationFrames from "callbag-animation-frames"; import map from "callbag-map"; import pipe from "callbag-pipe"; import { INPUT_FREQUENCY, LockstepProcessor, LockstepState } from "../Ecs/Lockstep"; export const enum MessageTypes { /** * Reserved, likely for transient messages that wouldn't make sense appearing in a replay */ META = 0, /** * 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 = 1, /** * 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 = 2, /** * 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 = 3, /** * For liveness checks / latency estimate. Echos a cookie. * May be refactored into one of the above messages. */ PING = 4 } export type Packet = { t: TypeId } & Payload; export type ClientMessage = | Packet }> | Packet | Packet | Packet ; export type ServerMessage = | Packet }> | Packet | Packet | Packet ; export type Server = Callbag, ServerMessage>; export abstract class LockstepClient { private state: LockstepState; public constructor( public readonly engine: LockstepProcessor, ) { const initialState = this.initState({}); this.state = new LockstepState(initialState, engine); } public abstract initState(init: Partial): State; public abstract gatherInput(): Input; /** * Connect to a [perhaps emulated] server and return a disconnect callback */ public connect(server: Server): () => void { let serverTalkback: Server | null = null; const sampleInput = () => { if (serverTalkback) { const input = this.gatherInput(); this.state.addLocalInput(input); serverTalkback(1, { t: MessageTypes.INPUT, i: input }); setTimeout(sampleInput, INPUT_FREQUENCY); } }; // connect to server server(0, (mode: number, data: Server | ServerMessage) => { if (mode == 0) { serverTalkback = data as Server; // kickoff input sender setTimeout(sampleInput, INPUT_FREQUENCY); } else if (mode == 1) { // server message const message = data as ServerMessage; switch (message.t) { case MessageTypes.SET_STATE: const resetState = this.initState(message.s); this.state = new LockstepState(resetState, this.engine); break; case MessageTypes.INPUT: this.state.addCanonInput(message.i); break; } } else if (mode == 2) { // disconnected console.log("Disconnected from server", data); serverTalkback = null; } }); // disposal return () => { serverTalkback?.(2); serverTalkback = null; }; } public renderFrames = pipe( animationFrames, map(_ms => this.state.getStateToRender()) ); }