decla-red/frontend/src/scripts/objects/game-object-container.ts
2026-08-23 10:27:55 +01:00

186 lines
5.7 KiB
TypeScript

import { Renderer } from 'sdf-2d';
import {
Circle,
CommandExecutors,
CommandReceiver,
CreateObjectsCommand,
CreatePlayerCommand,
DeleteObjectsCommand,
Id,
PropertyUpdatesForObjects,
RemoteCallsForObjects,
settings,
UpdatePropertyCommand,
} from 'shared';
import { FeedbackHud } from '../feedback-hud';
import { Game } from '../game';
import { serverTimeline } from '../helper/server-timeline';
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
import { Camera } from './types/camera';
import { CharacterView } from './types/character-view';
import { PlanetView } from './types/planet-view';
import { View } from './view';
export class GameObjectContainer extends CommandReceiver {
public player?: CharacterView;
public readonly camera: Camera;
public planets: Array<PlanetView> = [];
private objects: Map<Id, View> = new Map();
private wasLocalPlayerAlive = false;
// Creates and deletes take effect on the render timeline, not on arrival.
private visibleFrom = new Map<Id, number>();
private deleteAt = new Map<Id, number>();
private awaitingCreateStamp: Array<Id> = [];
private awaitingDeleteStamp: Array<Id> = [];
protected commandExecutors: CommandExecutors = {
[CreatePlayerCommand.type]: (c: CreatePlayerCommand) => {
this.player = c.character as CharacterView;
this.player.isMainCharacter = true;
this.addObject(this.player);
localCharacterPredictor.reset();
FeedbackHud.hideElimination();
this.wasLocalPlayerAlive = true;
},
[CreateObjectsCommand.type]: (c: CreateObjectsCommand) =>
c.objects.forEach((o) => {
this.addObject(o as View);
this.awaitingCreateStamp.push(o.id);
}),
[RemoteCallsForObjects.type]: (c: RemoteCallsForObjects) =>
c.callsForObjects.forEach((c) =>
this.objects.get(c.id)?.processRemoteCalls(c.calls),
),
[PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => {
serverTimeline.onSnapshot(c.timestamp);
this.stampPending(c.timestamp);
c.updates.forEach((u) => {
const object = this.objects.get(u.id);
u.updates.forEach((au) => object?.updateProperty?.(au));
if (object && object === this.player) {
this.feedPredictor(u.updates);
}
});
},
[DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) =>
c.ids.forEach((id: Id) => this.awaitingDeleteStamp.push(id)),
};
constructor(game: Game) {
super();
this.camera = new Camera(game);
}
public reset() {
this.objects.forEach((o) => o.beforeDestroy?.());
this.objects.clear();
this.planets = [];
this.visibleFrom.clear();
this.deleteAt.clear();
this.awaitingCreateStamp = [];
this.awaitingDeleteStamp = [];
this.wasLocalPlayerAlive = false;
}
public get localPlayer(): CharacterView | undefined {
return this.player && this.objects.has(this.player.id) ? this.player : undefined;
}
public step(deltaTimeInSeconds: number) {
this.stampPending(serverTimeline.snapshotTime);
this.retireDueObjects();
this.objects.forEach((o) => o.step(deltaTimeInSeconds));
const player = this.localPlayer;
const alive = !!player && player.health > 0;
if (this.wasLocalPlayerAlive && !alive) {
FeedbackHud.showElimination();
}
this.wasLocalPlayerAlive = alive;
if (player) {
localCharacterPredictor.setAlive(alive);
localCharacterPredictor.setStrength(
player.strengthFraction * settings.playerMaxStrength,
);
if (localCharacterPredictor.update(this.planets, deltaTimeInSeconds)) {
player.head = localCharacterPredictor.head;
player.leftFoot = localCharacterPredictor.leftFoot;
player.rightFoot = localCharacterPredictor.rightFoot;
}
this.camera.follow(player.position, deltaTimeInSeconds);
}
}
public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) {
this.objects.forEach((o) => {
if (this.isVisible(o.id)) {
o.render(renderer, overlay, shouldChangeLayout);
}
});
this.camera.draw(renderer);
}
private isVisible(id: Id): boolean {
const from = this.visibleFrom.get(id);
return from === undefined || serverTimeline.renderTime >= from;
}
private stampPending(timestamp: number) {
for (const id of this.awaitingCreateStamp) {
this.visibleFrom.set(id, timestamp);
}
this.awaitingCreateStamp = [];
for (const id of this.awaitingDeleteStamp) {
this.deleteAt.set(id, timestamp);
}
this.awaitingDeleteStamp = [];
}
private retireDueObjects() {
const renderTime = serverTimeline.renderTime;
for (const [id, at] of [...this.deleteAt]) {
if (renderTime >= at) {
this.deleteObject(id);
}
}
}
private feedPredictor(updates: Array<UpdatePropertyCommand>) {
const pose: Partial<Record<'head' | 'leftFoot' | 'rightFoot', Circle>> = {};
for (const u of updates) {
if (
u.propertyKey === 'head' ||
u.propertyKey === 'leftFoot' ||
u.propertyKey === 'rightFoot'
) {
pose[u.propertyKey] = u.propertyValue;
}
}
if (pose.head && pose.leftFoot && pose.rightFoot) {
localCharacterPredictor.setAuthoritative(pose.head, pose.leftFoot, pose.rightFoot);
}
}
private addObject(object: View) {
this.objects.set(object.id, object);
if (object instanceof PlanetView) {
this.planets.push(object);
}
}
private deleteObject(id: Id) {
this.objects.get(id)?.beforeDestroy?.();
this.objects.delete(id);
this.planets = this.planets.filter((p) => p.id !== id);
this.visibleFrom.delete(id);
this.deleteAt.delete(id);
}
}