Improve netcode and simplify (#4)
All checks were successful
Build & deploy / Build & deploy website (push) Successful in 32s
Build & deploy / Build & publish server image (push) Successful in 1m2s

- Simplify the logic wherever possible to remove overengineering and simplify the netcode
- Actually improve the netcode
- The new lines are mainly the package locks and tests

Reviewed-on: https://home.schmelczer.dev/git/andras/decla-red/pulls/4
This commit is contained in:
Andras Schmelczer 2026-09-15 07:57:57 +01:00
commit 93a7fe01ab

View file

@ -1,56 +1,101 @@
import { vec2 } from 'gl-matrix';
import {
CommandReceiver,
Circle,
PlayerInformation,
CharacterTeam,
Random,
settings,
sanitizeName,
boundRadius,
evaluateSdf,
} from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { isCircleIntersecting } from '../physics/functions/is-circle-intersecting';
import { PhysicalContainer } from '../physics/physical-container';
import { BoundingBox } from '../physics/bounding-box';
import { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical } from '../objects/planet-physical';
import { PlanetPhysical, planetsNear } from '../objects/planet-physical';
import { PlayerContainer } from './player-container';
const maximumNameLength = 40;
export interface Score {
kills: number;
deaths: number;
}
export abstract class PlayerBase extends CommandReceiver {
public character?: CharacterPhysical | null;
public character: CharacterPhysical | null = null;
public center: vec2 = vec2.create();
protected sumKills = 0;
protected sumDeaths = 0;
protected lastCharacter?: CharacterPhysical;
protected timeUntilRespawn = 0;
private kills: number;
private deaths: number;
constructor(
protected readonly playerInfo: PlayerInformation,
protected readonly playerContainer: PlayerContainer,
protected readonly objectContainer: PhysicalContainer,
public readonly team: CharacterTeam,
score: Score = { kills: 0, deaths: 0 },
) {
super();
this.kills = score.kills;
this.deaths = score.deaths;
}
protected createCharacter() {
public get score(): Score {
return {
kills: (this.character ?? this.lastCharacter)?.killCount ?? this.kills,
deaths: this.deaths,
};
}
protected createCharacter(): CharacterPhysical {
this.kills = this.lastCharacter?.killCount ?? this.kills;
this.character = new CharacterPhysical(
this.playerInfo.name.slice(0, 20),
this.sumKills,
this.sumDeaths,
sanitizeName(this.playerInfo.name, maximumNameLength),
this.kills,
this.deaths,
this.team,
this.objectContainer,
this.findEmptyPositionForPlayer(this.findSpawnCenter()),
);
this.objectContainer.addObject(this.character);
this.center = this.character.center;
return this.character;
}
public abstract step(deltaTimeInSeconds: number): void;
public step(deltaTimeInSeconds: number) {
this.stepLifecycle(deltaTimeInSeconds);
}
protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined {
if (this.character) {
this.center = this.character.center;
if (this.character.isAlive) {
return this.character;
}
this.deaths++;
this.lastCharacter = this.character;
this.character = null;
this.timeUntilRespawn = settings.playerDiedTimeout;
return undefined;
}
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
this.createCharacter();
}
return undefined;
}
private findSpawnCenter(): vec2 {
const planets = this.objectContainer
.findIntersecting(
getBoundingBoxOfCircle(new Circle(vec2.create(), settings.worldRadius * 2)),
)
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
const planets = planetsNear(
this.objectContainer,
vec2.create(),
settings.worldRadius * 2,
);
const friendly = planets.filter((p) => p.team === this.team);
const neutral = planets.filter((p) => p.team === CharacterTeam.neutral);
@ -68,34 +113,39 @@ export abstract class PlayerBase extends CommandReceiver {
);
const safe = candidates.filter((p) => !isContested(p));
// candidates is non-empty here, so choose() always returns a planet.
return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center);
}
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
private findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
let rotation = 0;
let radius = 0;
for (;;) {
const playerPosition = vec2.fromValues(
radius * Math.cos(rotation) + preferredCenter.x,
radius * Math.sin(rotation) + preferredCenter.y,
let roomiestPosition = vec2.clone(preferredCenter);
let roomiestClearance = -Infinity;
for (let attempt = 0; attempt < 512; attempt++) {
const position = vec2.fromValues(
radius * Math.cos(rotation) + preferredCenter[0],
radius * Math.sin(rotation) + preferredCenter[1],
);
const playerBoundingCircle = new Circle(
playerPosition,
CharacterPhysical.boundRadius,
const clearance = evaluateSdf(
position,
this.objectContainer.findIntersecting(
BoundingBox.ofCircle(position, boundRadius),
),
);
const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle);
const possibleIntersectors =
this.objectContainer.findIntersecting(playerBoundingBox);
if (!isCircleIntersecting(playerBoundingCircle, possibleIntersectors)) {
return playerPosition;
if (clearance >= boundRadius) {
return position;
}
if (clearance > roomiestClearance) {
roomiestClearance = clearance;
roomiestPosition = position;
}
rotation += Math.PI / 8;
radius += 30;
}
return roomiestPosition;
}
public destroy() {