186 lines
4.7 KiB
TypeScript
186 lines
4.7 KiB
TypeScript
import { NetworkClock } from "./network-clock.js";
|
|
import type {
|
|
ClientStateReport,
|
|
GameDefinition,
|
|
InputContext,
|
|
InputPacket,
|
|
PlayerId,
|
|
StateSnapshot,
|
|
} from "./types.js";
|
|
|
|
interface PredictedInput<Input> {
|
|
packet: InputPacket<Input>;
|
|
applied: boolean;
|
|
}
|
|
|
|
export class PredictedEngine<State, Input> {
|
|
readonly game: GameDefinition<State, Input>;
|
|
readonly networkClock = new NetworkClock();
|
|
private state: State;
|
|
private playerId: PlayerId | null = null;
|
|
private currentTick = 0;
|
|
private nextInputSequence = 1;
|
|
private acknowledgedSequence = 0;
|
|
private pendingInputs: PredictedInput<Input>[] = [];
|
|
|
|
constructor(game: GameDefinition<State, Input>) {
|
|
this.game = game;
|
|
this.state = game.createInitialState();
|
|
}
|
|
|
|
get tick(): number {
|
|
return this.currentTick;
|
|
}
|
|
|
|
get localPlayerId(): PlayerId | null {
|
|
return this.playerId;
|
|
}
|
|
|
|
get currentState(): Readonly<State> {
|
|
return this.state;
|
|
}
|
|
|
|
get initialized(): boolean {
|
|
return this.playerId !== null;
|
|
}
|
|
|
|
initialize(playerId: PlayerId, snapshot: StateSnapshot<State>): void {
|
|
this.playerId = playerId;
|
|
this.currentTick = snapshot.tick;
|
|
this.state = this.game.cloneState(snapshot.state);
|
|
this.pendingInputs = [];
|
|
this.acknowledgedSequence = 0;
|
|
}
|
|
|
|
createInput(input: Input, leadTicks?: number): InputPacket<Input> {
|
|
if (this.playerId === null) {
|
|
throw new Error("Client engine has not received a welcome snapshot");
|
|
}
|
|
|
|
const targetTick =
|
|
this.currentTick +
|
|
(leadTicks ??
|
|
this.networkClock.recommendedInputLeadTicks(this.game.tickRateHz));
|
|
const packet: InputPacket<Input> = {
|
|
sequence: this.nextInputSequence,
|
|
targetTick,
|
|
observedTick: this.currentTick,
|
|
input,
|
|
};
|
|
const context = this.inputContext(packet);
|
|
|
|
if (!this.game.validateInput(input, context)) {
|
|
throw new Error("Refusing to send invalid local input");
|
|
}
|
|
|
|
this.nextInputSequence = (this.nextInputSequence + 1) >>> 0;
|
|
this.pendingInputs.push({ packet, applied: false });
|
|
return packet;
|
|
}
|
|
|
|
acknowledge(sequence: number): void {
|
|
this.acknowledgedSequence = Math.max(this.acknowledgedSequence, sequence);
|
|
this.pendingInputs = this.pendingInputs.filter(
|
|
(pending) => pending.packet.sequence > this.acknowledgedSequence,
|
|
);
|
|
}
|
|
|
|
reject(sequence: number): void {
|
|
this.pendingInputs = this.pendingInputs.filter(
|
|
(pending) => pending.packet.sequence !== sequence,
|
|
);
|
|
}
|
|
|
|
step(): void {
|
|
if (this.playerId === null) {
|
|
return;
|
|
}
|
|
|
|
this.currentTick += 1;
|
|
this.applyDueInputs();
|
|
this.game.step(this.state, {
|
|
tick: this.currentTick,
|
|
deltaSeconds: 1 / this.game.tickRateHz,
|
|
});
|
|
this.assertValidState();
|
|
}
|
|
|
|
reconcile(snapshot: StateSnapshot<State>): void {
|
|
if (
|
|
this.playerId === null ||
|
|
snapshot.tick < this.currentTick - this.game.tickRateHz * 2
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const predictedTick = Math.max(this.currentTick, snapshot.tick);
|
|
this.state = this.game.cloneState(snapshot.state);
|
|
this.currentTick = snapshot.tick;
|
|
|
|
for (const pending of this.pendingInputs) {
|
|
pending.applied = false;
|
|
}
|
|
|
|
this.applyDueInputs();
|
|
while (this.currentTick < predictedTick) {
|
|
this.currentTick += 1;
|
|
this.applyDueInputs();
|
|
this.game.step(this.state, {
|
|
tick: this.currentTick,
|
|
deltaSeconds: 1 / this.game.tickRateHz,
|
|
});
|
|
}
|
|
|
|
this.assertValidState();
|
|
}
|
|
|
|
createStateReport(): ClientStateReport<State> {
|
|
return {
|
|
tick: this.currentTick,
|
|
state: this.game.cloneState(this.state),
|
|
};
|
|
}
|
|
|
|
private applyDueInputs(): void {
|
|
for (const pending of this.pendingInputs) {
|
|
if (!pending.applied && pending.packet.targetTick <= this.currentTick) {
|
|
this.game.applyInput(
|
|
this.state,
|
|
pending.packet.input,
|
|
this.inputContext(pending.packet),
|
|
);
|
|
pending.applied = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
private inputContext(packet: InputPacket<Input>): InputContext {
|
|
if (this.playerId === null) {
|
|
throw new Error("Client engine has not been initialized");
|
|
}
|
|
|
|
return {
|
|
playerId: this.playerId,
|
|
sequence: packet.sequence,
|
|
targetTick: packet.targetTick,
|
|
observedTick: packet.observedTick ?? packet.targetTick,
|
|
tick: this.currentTick,
|
|
deltaSeconds: 1 / this.game.tickRateHz,
|
|
};
|
|
}
|
|
|
|
private assertValidState(): void {
|
|
if (
|
|
this.game.validateState &&
|
|
!this.game.validateState(this.state, {
|
|
tick: this.currentTick,
|
|
deltaSeconds: 1 / this.game.tickRateHz,
|
|
})
|
|
) {
|
|
throw new Error(
|
|
`Client prediction produced invalid state at tick ${this.currentTick}`,
|
|
);
|
|
}
|
|
}
|
|
}
|