even more

This commit is contained in:
Andras Schmelczer 2026-08-23 10:27:55 +01:00
commit 8f50ebdb97

View file

@ -1,38 +1,35 @@
import { vec2 } from 'gl-matrix';
import { Renderer } from 'sdf-2d';
import {
Circle,
Command,
CommandExecutors,
CommandReceiver,
CreateObjectsCommand,
CreatePlayerCommand,
DeleteObjectsCommand,
GameObject,
Id,
PropertyUpdatesForObjects,
RemoteCallsForObjects,
settings,
UpdatePropertyCommand,
} from 'shared';
import { BeforeDestroyCommand } from '../commands/types/before-destroy';
import { RenderCommand } from '../commands/types/render';
import { StepCommand } from '../commands/types/step';
import { FeedbackHud } from '../feedback-hud';
import { Game } from '../game';
import { serverTimeline } from '../helper/server-timeline';
import { PredictablePlanet } from '../helper/prediction/client-character-world';
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 {
protected objects: Map<Id, GameObject> = new Map();
public player!: CharacterView;
public camera: Camera = new Camera(this.game);
public player?: CharacterView;
public readonly camera: Camera;
public planets: Array<PlanetView> = [];
private objects: Map<Id, View> = new Map();
private wasLocalPlayerAlive = false;
// Create/delete are admitted/retired on the render timeline (not the wire) so interpolated objects don't pop at the muzzle.
// 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> = [];
@ -50,42 +47,10 @@ export class GameObjectContainer extends CommandReceiver {
[CreateObjectsCommand.type]: (c: CreateObjectsCommand) =>
c.objects.forEach((o) => {
this.addObject(o as GameObject);
this.awaitingCreateStamp.push((o as GameObject).id);
this.addObject(o as View);
this.awaitingCreateStamp.push(o.id);
}),
[StepCommand.type]: (c: StepCommand) => {
// Fall back to newest snapshot if a batch had no property updates, so nothing is stranded.
this.stampPending(serverTimeline.snapshotTime);
this.retireDueObjects();
this.defaultCommandExecutor(c);
// Alive = object exists AND health > 0 (player pointer outlives the body).
const bodyPresent = !!this.player && this.objects.has(this.player.id);
const alive = bodyPresent && this.player.health > 0;
if (this.wasLocalPlayerAlive && !alive) {
FeedbackHud.showElimination();
}
this.wasLocalPlayerAlive = alive;
if (bodyPresent) {
// Override the local player's interpolated pose with the predicted one; suppressed while dead (server ignores dead input).
localCharacterPredictor.setAlive(alive);
localCharacterPredictor.setStrength(
this.player.strengthFraction * settings.playerMaxStrength,
);
if (
localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds)
) {
this.player.head = localCharacterPredictor.head;
this.player.leftFoot = localCharacterPredictor.leftFoot;
this.player.rightFoot = localCharacterPredictor.rightFoot;
}
this.camera.follow(this.player.position, c.deltaTimeInSeconds);
}
},
[RemoteCallsForObjects.type]: (c: RemoteCallsForObjects) =>
c.callsForObjects.forEach((c) =>
this.objects.get(c.id)?.processRemoteCalls(c.calls),
@ -93,11 +58,11 @@ export class GameObjectContainer extends CommandReceiver {
[PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => {
serverTimeline.onSnapshot(c.timestamp);
// Create/delete precede property updates in a batch — timestamp is known here.
this.stampPending(c.timestamp);
c.updates.forEach((u) => {
u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au));
if (this.player && u.id === this.player.id) {
const object = this.objects.get(u.id);
u.updates.forEach((au) => object?.updateProperty?.(au));
if (object && object === this.player) {
this.feedPredictor(u.updates);
}
});
@ -107,19 +72,15 @@ export class GameObjectContainer extends CommandReceiver {
c.ids.forEach((id: Id) => this.awaitingDeleteStamp.push(id)),
};
constructor(private game: Game) {
constructor(game: Game) {
super();
this.camera = new Camera(game);
}
/**
* A reconnect is a new server-side player that never sends deletes for the
* old session's objects — clear them or they stay as frozen ghosts. `player`
* is left stale: reads are gated on `objects`, and the next CreatePlayerCommand
* replaces it.
*/
public reset() {
this.objects.forEach((o) => o.handleCommand(new BeforeDestroyCommand()));
this.objects.forEach((o) => o.beforeDestroy?.());
this.objects.clear();
this.planets = [];
this.visibleFrom.clear();
this.deleteAt.clear();
this.awaitingCreateStamp = [];
@ -127,32 +88,43 @@ export class GameObjectContainer extends CommandReceiver {
this.wasLocalPlayerAlive = false;
}
public get localPlayerPosition(): vec2 | undefined {
return this.player && this.objects.has(this.player.id)
? this.player.position
: undefined;
public get localPlayer(): CharacterView | undefined {
return this.player && this.objects.has(this.player.id) ? this.player : undefined;
}
public get planets(): Array<PlanetView> {
const planets: Array<PlanetView> = [];
this.objects.forEach((o) => {
if (o instanceof PlanetView) {
planets.push(o);
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;
}
});
return planets;
this.camera.follow(player.position, deltaTimeInSeconds);
}
}
protected defaultCommandExecutor(c: Command) {
const isRender = c.type === RenderCommand.type;
public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) {
this.objects.forEach((o) => {
// Stepping runs even for not-yet-visible objects so they enter the scene already moving.
if (isRender && !this.isVisible(o.id)) {
return;
if (this.isVisible(o.id)) {
o.render(renderer, overlay, shouldChangeLayout);
}
o.handleCommand(c);
});
this.camera.handleCommand(c);
this.camera.draw(renderer);
}
private isVisible(id: Id): boolean {
@ -173,52 +145,41 @@ export class GameObjectContainer extends CommandReceiver {
}
private retireDueObjects() {
if (this.deleteAt.size === 0) {
return;
}
const renderTime = serverTimeline.renderTime;
for (const [id, at] of [...this.deleteAt]) {
if (renderTime >= at) {
this.deleteAt.delete(id);
this.deleteObject(id);
}
}
}
// Feed the predictor the raw (not interpolated) authoritative pose.
private feedPredictor(updates: Array<UpdatePropertyCommand>) {
let head: Circle | undefined;
let leftFoot: Circle | undefined;
let rightFoot: Circle | undefined;
const pose: Partial<Record<'head' | 'leftFoot' | 'rightFoot', Circle>> = {};
for (const u of updates) {
if (u.propertyKey === 'head') head = u.propertyValue as Circle;
else if (u.propertyKey === 'leftFoot') leftFoot = u.propertyValue as Circle;
else if (u.propertyKey === 'rightFoot') rightFoot = u.propertyValue as Circle;
if (
u.propertyKey === 'head' ||
u.propertyKey === 'leftFoot' ||
u.propertyKey === 'rightFoot'
) {
pose[u.propertyKey] = u.propertyValue;
}
}
if (head && leftFoot && rightFoot) {
localCharacterPredictor.setAuthoritative(head, leftFoot, rightFoot);
if (pose.head && pose.leftFoot && pose.rightFoot) {
localCharacterPredictor.setAuthoritative(pose.head, pose.leftFoot, pose.rightFoot);
}
}
private predictablePlanets(): Array<PredictablePlanet> {
return this.planets.map((p) => ({
id: p.id,
vertices: p.vertices,
center: p.center,
radius: p.radius,
rotation: p.predictionRotation,
rotationSpeed: p.predictionRotationSpeed,
}));
}
private addObject(object: GameObject) {
private addObject(object: View) {
this.objects.set(object.id, object);
if (object instanceof PlanetView) {
this.planets.push(object);
}
}
private deleteObject(id: Id) {
const object = this.objects.get(id);
object?.handleCommand(new BeforeDestroyCommand());
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);
}