This commit is contained in:
Andras Schmelczer 2026-08-22 15:29:33 +01:00
commit b9274a42c7
4 changed files with 480 additions and 457 deletions

View file

@ -7,6 +7,7 @@ import {
MoveActionCommand,
CharacterTeam,
Id,
rotate90Deg,
} from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { PlayerContainer } from './player-container';
@ -100,23 +101,9 @@ export class NPC extends PlayerBase {
this.step(0);
}
private timeUntilRespawn = 0;
public step(deltaTimeInSeconds: number) {
if (this.character) {
this.center = this.character.center;
if (!this.character.isAlive) {
this.sumDeaths++;
this.sumKills = this.character.killCount;
this.character = null;
this.timeUntilRespawn = settings.playerDiedTimeout;
return;
}
} else {
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
this.createCharacter();
this.center = this.character!.center;
}
const character = this.stepLifecycle(deltaTimeInSeconds);
if (!character) {
return;
}
@ -143,16 +130,16 @@ export class NPC extends PlayerBase {
this.dodgeCommitRemaining -= deltaTimeInSeconds;
this.dodgeCooldownRemaining -= deltaTimeInSeconds;
const movement = this.decideMovement(this.nearObjects);
this.character.handleMovementAction(new MoveActionCommand(movement));
character.handleMovementAction(new MoveActionCommand(movement));
if (
!this.isComingBack &&
this.character.groundPlanet &&
character.groundPlanet &&
vec2.length(movement) > 0 &&
Random.getRandom() <
npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds
) {
this.character.leap();
character.leap();
}
if (
@ -178,18 +165,15 @@ export class NPC extends PlayerBase {
const nearObjects = this.observe(npcTuning.planScanRadius);
const enemies = this.enemiesByDistance(nearObjects);
if (enemies.length > 0) {
const nearest = enemies[0];
const nearest = enemies[0];
if (nearest) {
const fleeRange =
npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression);
if (nearest.distance < fleeRange) {
vec2.subtract(this.direction, this.center, nearest.character.center);
return;
}
}
if (enemies.length > 0) {
const nearest = enemies[0];
const chaseRange =
npcTuning.chaseBaseRange + npcTuning.chaseAggressionRange * this.aggression;
if (nearest.distance < chaseRange) {
@ -275,7 +259,7 @@ export class NPC extends PlayerBase {
return undefined;
}
const perpendicular = vec2.fromValues(-threat.direction.y, threat.direction.x);
const perpendicular = rotate90Deg(threat.direction);
const toMe = vec2.subtract(vec2.create(), this.center, threat.center);
if (vec2.dot(perpendicular, toMe) < 0) {
vec2.negate(perpendicular, perpendicular);

View file

@ -7,6 +7,7 @@ import {
Random,
settings,
sanitizeName,
boundRadius,
} from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
@ -23,6 +24,7 @@ export abstract class PlayerBase extends CommandReceiver {
protected sumKills = 0;
protected sumDeaths = 0;
protected timeUntilRespawn = 0;
constructor(
protected readonly playerInfo: PlayerInformation,
@ -53,6 +55,44 @@ export abstract class PlayerBase extends CommandReceiver {
public abstract step(deltaTimeInSeconds: number): void;
/**
* The death/respawn lifecycle both a connected player and an NPC go through:
* bank the score off a body that just died, hold the corpse for the respawn
* timeout, then build a new one. Returns whether there is a living character
* to act with this tick — returned as the character itself, so a subclass can
* `const character = this.stepLifecycle(dt); if (!character) return;` and go
* on to use it without re-narrowing.
*/
protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined {
if (this.character) {
this.center = this.character.center;
if (this.character.isAlive) {
return this.character;
}
this.sumDeaths++;
this.sumKills = this.character.killCount;
this.onCharacterDied(this.character);
this.character = null;
this.timeUntilRespawn = settings.playerDiedTimeout;
return undefined;
}
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
this.onBeforeRespawn();
this.createCharacter();
this.center = this.character!.center;
}
return undefined;
}
// Hooks for what a subclass wants to do with the body it just lost, and just
// before a new one is built. Both are no-ops by default.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected onCharacterDied(character: CharacterPhysical) {}
protected onBeforeRespawn() {}
private findSpawnCenter(): vec2 {
const planets = this.objectContainer
.findIntersecting(
@ -96,10 +136,7 @@ export abstract class PlayerBase extends CommandReceiver {
radius * Math.sin(rotation) + preferredCenter.y,
);
const playerBoundingCircle = new Circle(
playerPosition,
CharacterPhysical.boundRadius,
);
const playerBoundingCircle = new Circle(playerPosition, boundRadius);
const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle);
const possibleIntersectors =

View file

@ -135,7 +135,11 @@ export class PlayerContainer {
}
public step(deltaTimeInSeconds: number) {
this.players.forEach((p) => p.step(deltaTimeInSeconds));
// Iterates the two backing arrays rather than the merged `players` getter:
// this runs once per 200 Hz physics substep, and the getter builds a new
// array every call.
this._players.forEach((p) => p.step(deltaTimeInSeconds));
this._npcs.forEach((p) => p.step(deltaTimeInSeconds));
}
public stepCommunication(deltaTimeInSeconds: number) {

View file

@ -52,9 +52,11 @@ const maximumBufferedBytes = settings.maxBufferedBytesPerClient;
export class Player extends PlayerBase {
// default, until the clients sends its real value
private aspectRatio: number = 16 / 9;
private timeUntilRespawn = 0;
private timeSinceLastMessage = 0;
private objectsPreviouslyInViewArea: Array<GameObject> = [];
// What this client has already been told about, in the order it was told.
// A Set because the per-snapshot diff below is membership-heavy; iteration
// order is insertion order, so the streamed object order is unchanged.
private objectsInViewArea = new Set<GameObject>();
private lastInputClientTimeMs = 0;
private lastInputReceiptMs = 0;
private lastLeapClientTimeMs = 0;
@ -190,7 +192,7 @@ export class Player extends PlayerBase {
protected createCharacter() {
super.createCharacter();
this.objectsPreviouslyInViewArea.push(this.character!);
this.objectsInViewArea.add(this.character!);
this.queueCommandSend(new CreatePlayerCommand(this.character!));
}
@ -205,26 +207,17 @@ export class Player extends PlayerBase {
private dyingCharacter?: CharacterPhysical | null;
public step(deltaTimeInSeconds: number) {
if (this.character) {
this.center = this.character?.center;
this.stepLifecycle(deltaTimeInSeconds);
}
if (!this.character.isAlive) {
this.sumDeaths++;
this.sumKills = this.character.killCount;
protected onCharacterDied(character: CharacterPhysical) {
this.dyingCharacter = character;
}
this.dyingCharacter = this.character;
this.character = null;
this.timeUntilRespawn = settings.playerDiedTimeout;
}
} else {
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
if (this.dyingCharacter) {
this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount);
this.dyingCharacter = null;
}
this.createCharacter();
this.center = this.character!.center;
}
protected onBeforeRespawn() {
if (this.dyingCharacter) {
this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount);
this.dyingCharacter = null;
}
}
@ -234,26 +227,30 @@ export class Player extends PlayerBase {
bb.topLeft = viewArea.topLeft;
bb.size = viewArea.size;
const objectsInViewArea = Array.from(
new Set(this.objectContainer.findIntersecting(bb).map((o) => o.gameObject)),
const inViewArea = new Set(
this.objectContainer.findIntersecting(bb).map((o) => o.gameObject),
);
// The owning character must always be in its own snapshot, regardless of the
// view-area query, so the client predictor never loses its authoritative
// anchor (the body can ride a fast spinner to the very edge of the box).
if (this.character && !objectsInViewArea.includes(this.character)) {
objectsInViewArea.push(this.character);
if (this.character) {
inViewArea.add(this.character);
}
const newlyIntersecting = objectsInViewArea.filter(
(o) => !this.objectsPreviouslyInViewArea.includes(o),
// Set membership rather than Array.includes: this diff runs per player per
// snapshot over everything on their screen, and was quadratic in that.
// Set membership rather than Array.includes: this diff runs per player per
// snapshot over everything on their screen, and was quadratic in that.
const newlyIntersecting = [...inViewArea].filter(
(o) => !this.objectsInViewArea.has(o),
);
const noLongerIntersecting = this.objectsPreviouslyInViewArea.filter(
(o) => !objectsInViewArea.includes(o),
const noLongerIntersecting = [...this.objectsInViewArea].filter(
(o) => !inViewArea.has(o),
);
this.objectsPreviouslyInViewArea = objectsInViewArea;
this.objectsInViewArea = inViewArea;
if (noLongerIntersecting.length > 0) {
this.queueCommandSend(
@ -267,13 +264,15 @@ export class Player extends PlayerBase {
this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers()));
const propertyUpdates: Array<PropertyUpdatesForObject> = [];
for (const object of this.objectsInViewArea) {
const update = object.getPropertyUpdatesForFrame();
if (update) {
propertyUpdates.push(update);
}
}
this.queueCommandSend(
new PropertyUpdatesForObjects(
this.objectsPreviouslyInViewArea
.map((o) => o.getPropertyUpdatesForFrame())
.filter((u) => u) as Array<PropertyUpdatesForObject>,
performance.now() / 1000,
),
new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 1000),
);
// Tell the client how much of its own input is reflected in the snapshot it
@ -310,11 +309,17 @@ export class Player extends PlayerBase {
}
public stepCommunications(deltaTime: number) {
const remoteCalls = this.objectsPreviouslyInViewArea
.map((g) => new RemoteCallsForObject(g.id, g.getRemoteCalls()))
.filter((c) => c.calls.length > 0);
// Runs at the physics rate, and on most ticks nothing has fired — so test
// first and only build the command when there is something to send.
let remoteCalls: Array<RemoteCallsForObject> | undefined;
for (const object of this.objectsInViewArea) {
const calls = object.getRemoteCalls();
if (calls.length > 0) {
(remoteCalls ??= []).push(new RemoteCallsForObject(object.id, calls));
}
}
if (remoteCalls.length > 0) {
if (remoteCalls) {
this.queueCommandSend(new RemoteCallsForObjects(remoteCalls));
}