94 lines
3 KiB
TypeScript
94 lines
3 KiB
TypeScript
|
|
||
|
export interface DeepCopy<T> {
|
||
|
deepCopy(patch: Partial<T>): T;
|
||
|
}
|
||
|
|
||
|
export interface Equals {
|
||
|
equals(other: this): boolean;
|
||
|
}
|
||
|
|
||
|
function equals<T extends Equals | string | number>(a: T, b: T): boolean {
|
||
|
if(typeof a === "string"){
|
||
|
return a == b;
|
||
|
}
|
||
|
if(typeof a === "number"){
|
||
|
return a == b;
|
||
|
}
|
||
|
return (a as Equals).equals(b as Equals);
|
||
|
}
|
||
|
|
||
|
// TODO: probably an incoherent idea. Instead consider having two state objects,
|
||
|
// a synchronized state object that can be rolled back / predicted, and
|
||
|
// a cosmetic state object that glosses the synchronized state and never rolls back
|
||
|
export const enum TickType {
|
||
|
/// a "canonical" update that will not be rolled back;
|
||
|
/// this must be fully deterministic based on the state and input.
|
||
|
CANON,
|
||
|
/// a "predicted" update that may or may not be rolled back;
|
||
|
/// if possible, avoid changes that could be distracting if rolled back.
|
||
|
PREDICTED,
|
||
|
}
|
||
|
|
||
|
export type Advancer<Input, State> = (state: State, input: Input) => void;
|
||
|
|
||
|
export class LockstepState<Input extends Equals, State extends DeepCopy<State>> {
|
||
|
|
||
|
private inputIndex = -1;
|
||
|
private inputLog: Input[] = [];
|
||
|
private canonIndex = -1;
|
||
|
private canonState: State;
|
||
|
private renderIndex = -1;
|
||
|
private renderState: State;
|
||
|
|
||
|
constructor(private initialState: State, private advancer: Advancer<Input, State>) {
|
||
|
this.canonState = initialState.deepCopy({});
|
||
|
this.renderState = initialState.deepCopy({});
|
||
|
}
|
||
|
|
||
|
public addCanonInput(input: Input): void {
|
||
|
this.canonIndex++;
|
||
|
|
||
|
// advance canonical game state
|
||
|
this.advancer(this.canonState, input);
|
||
|
|
||
|
if(this.canonIndex <= this.renderIndex) {
|
||
|
// we're rendering predicted states, so if the input changes we need to invalidate the rendered state
|
||
|
if(!equals(this.inputLog[this.canonIndex], input)) {
|
||
|
this.renderState = this.canonState.deepCopy({});
|
||
|
this.renderIndex = this.canonIndex;
|
||
|
}
|
||
|
}
|
||
|
this.inputLog[this.canonIndex] = input;
|
||
|
}
|
||
|
|
||
|
public addLocalInput(input: Input): void {
|
||
|
this.inputIndex++;
|
||
|
|
||
|
// 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.)
|
||
|
if(this.inputIndex > this.canonIndex) {
|
||
|
this.inputLog[this.inputIndex] = input;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Do any necessary simulation to catchup the predicted game state to the frame
|
||
|
* that should be rendered, then return it.
|
||
|
*/
|
||
|
public getStateToRender(): State {
|
||
|
// TODO: input lag by X frames
|
||
|
const targetIndex = this.inputLog.length - 1;
|
||
|
|
||
|
while(this.renderIndex < targetIndex) {
|
||
|
this.renderIndex++;
|
||
|
this.advancer(this.renderState, this.inputLog[this.renderIndex]);
|
||
|
}
|
||
|
|
||
|
return this.renderState;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export class LockstepLoop<Input, State extends DeepCopy<State>> {
|
||
|
|
||
|
}
|