This commit is contained in:
Andras Schmelczer 2026-08-22 18:35:53 +01:00
commit 5017ff168a

View file

@ -32,14 +32,7 @@ export class GameObjectContainer extends CommandReceiver {
public camera: Camera = new Camera(this.game);
private wasLocalPlayerAlive = false;
// Create and delete arrive on the wire, but positions are drawn
// interpolationDelaySeconds in the past — so applying them the instant a
// packet lands made every projectile pop into existence and hang at its muzzle
// for 100 ms, then disappear a few hundred units before its rendered impact.
// Objects are therefore admitted and retired on the render timeline instead:
// they exist (and accumulate interpolation frames) from the moment they
// arrive, but only draw once the cursor reaches the snapshot that introduced
// them, and keep drawing until it reaches the one that removed them.
// Create/delete are admitted/retired on the render timeline (not the wire) so interpolated objects don't pop at the muzzle.
private visibleFrom = new Map<Id, number>();
private deleteAt = new Map<Id, number>();
private awaitingCreateStamp: Array<Id> = [];
@ -50,10 +43,7 @@ export class GameObjectContainer extends CommandReceiver {
this.player = c.character as CharacterView;
this.player.isMainCharacter = true;
this.addObject(this.player);
// Fresh character (first spawn or respawn at a far planet): drop any
// prediction state so it snaps to the new body instead of gliding across.
localCharacterPredictor.reset();
// Respawned — clear the elimination overlay.
FeedbackHud.hideElimination();
this.wasLocalPlayerAlive = true;
},
@ -65,32 +55,22 @@ export class GameObjectContainer extends CommandReceiver {
}),
[StepCommand.type]: (c: StepCommand) => {
// A batch carrying no property updates (a bare announcement, say) leaves
// entries unstamped; fall back to the newest known snapshot so nothing is
// stranded invisible.
// 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);
// The local body is alive only while its object still exists (the server
// deletes it on death) and its health is above zero — `player` keeps
// pointing at the now-stale view after death, so both checks are needed.
// 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;
// Show the elimination overlay on the alive→dead edge; CreatePlayerCommand
// clears it on respawn.
if (this.wasLocalPlayerAlive && !alive) {
FeedbackHud.showElimination();
}
this.wasLocalPlayerAlive = alive;
if (bodyPresent) {
// Override the interpolated pose of the local player with the predicted
// one so it responds to input immediately. Suppressed while dead so the
// corpse can't be walked around (the server ignores a dead player's
// input — a moving predicted body would be a pure client-side desync).
// A large correction (respawn / death) snaps inside the predictor.
// 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,
@ -113,8 +93,7 @@ export class GameObjectContainer extends CommandReceiver {
[PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => {
serverTimeline.onSnapshot(c.timestamp);
// Create/delete precede the property updates inside a batch, so this is
// where the batch's server timestamp becomes known to them.
// 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));
@ -133,17 +112,10 @@ export class GameObjectContainer extends CommandReceiver {
}
/**
* Forget everything the previous connection was told about.
*
* A reconnect is a brand-new server-side player whose view-area bookkeeping
* starts empty, so it re-announces what is in view NOW and never sends a
* delete for anything the dropped session held. Without this, every object
* that has since moved out of view — the old character included — stays in the
* map and on screen forever, as a frozen ghost.
*
* `player` is deliberately left pointing at the stale view: every read of it
* is gated on the object still being in `objects`, so clearing the map is what
* makes it inert, and the next CreatePlayerCommand replaces it.
* 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()));
@ -174,8 +146,7 @@ export class GameObjectContainer extends CommandReceiver {
protected defaultCommandExecutor(c: Command) {
const isRender = c.type === RenderCommand.type;
this.objects.forEach((o) => {
// Stepping always runs, so an object that has not appeared yet is still
// accumulating interpolation frames and enters the scene already moving.
// Stepping runs even for not-yet-visible objects so they enter the scene already moving.
if (isRender && !this.isVisible(o.id)) {
return;
}
@ -214,9 +185,7 @@ export class GameObjectContainer extends CommandReceiver {
}
}
// Hand the local player's raw authoritative pose to the predictor (the
// interpolated pose would already be ~100 ms stale). The three body parts
// arrive together in one snapshot.
// Feed the predictor the raw (not interpolated) authoritative pose.
private feedPredictor(updates: Array<UpdatePropertyCommand>) {
let head: Circle | undefined;
let leftFoot: Circle | undefined;