This commit is contained in:
185
packages/engine/src/client.ts
Normal file
185
packages/engine/src/client.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user