Improve netcode and simplify (#4)
- 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:
parent
5d854440ec
commit
93a7fe01ab
4 changed files with 15073 additions and 4754 deletions
|
|
@ -2,22 +2,21 @@ import { vec2 } from 'gl-matrix';
|
|||
import {
|
||||
PlayerInformation,
|
||||
settings,
|
||||
Circle,
|
||||
Random,
|
||||
MoveActionCommand,
|
||||
CharacterTeam,
|
||||
Id,
|
||||
evaluateSdf,
|
||||
} from 'shared';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { PhysicalContainer } from '../physics/physical-container';
|
||||
import { BoundingBox } from '../physics/bounding-box';
|
||||
import { PlayerContainer } from './player-container';
|
||||
import { PlayerBase } from './player-base';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { CharacterPhysical } from '../objects/character-physical';
|
||||
import { PlanetPhysical } from '../objects/planet-physical';
|
||||
import { PlanetPhysical, planetsIn } from '../objects/planet-physical';
|
||||
import { ProjectilePhysical } from '../objects/projectile-physical';
|
||||
import { Physical } from '../physics/physicals/physical';
|
||||
import { Physical } from '../physics/physical';
|
||||
|
||||
const npcTuning = {
|
||||
const tuning = {
|
||||
planIntervalSeconds: 1,
|
||||
shootIntervalSeconds: 1.5,
|
||||
reactionIntervalSeconds: 1 / 10,
|
||||
|
|
@ -40,7 +39,6 @@ const npcTuning = {
|
|||
|
||||
dodgeThreatRange: 450,
|
||||
dodgeApproachDot: 0.6,
|
||||
|
||||
dodgeBaseChance: 0.25,
|
||||
dodgeAggressionChance: 0.35,
|
||||
dodgeCommitSeconds: 0.35,
|
||||
|
|
@ -61,32 +59,36 @@ const npcTuning = {
|
|||
spreadPerDistance: 0.08,
|
||||
spreadAggressionFalloff: 1.3,
|
||||
|
||||
// Per-second chance to hop while grounded and on the move, so bots use the
|
||||
// leap verb too instead of being grounded targets.
|
||||
leapChancePerSecond: 0.35,
|
||||
};
|
||||
|
||||
interface Enemy {
|
||||
character: CharacterPhysical;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export class NPC extends PlayerBase {
|
||||
private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5);
|
||||
private timeSinceLastPlan = 10000;
|
||||
private timeSinceLastShoot = 10000;
|
||||
private isWandering = false;
|
||||
private movement = vec2.create();
|
||||
private timeSinceLastPlan = Infinity;
|
||||
private timeSinceLastShoot = Infinity;
|
||||
private timeSinceObserve = Infinity;
|
||||
private timeSinceLastWanderingConsideration = 0;
|
||||
private isWandering = false;
|
||||
private isComingBack = false;
|
||||
|
||||
private readonly aggression = Random.getRandomInRange(
|
||||
npcTuning.aggressionMin,
|
||||
npcTuning.aggressionMax,
|
||||
tuning.aggressionMin,
|
||||
tuning.aggressionMax,
|
||||
);
|
||||
|
||||
private aimTargetId: Id = null;
|
||||
private aimTargetId: Id | null = null;
|
||||
private readonly aimTargetLastPosition = vec2.create();
|
||||
|
||||
private readonly dodgeDirection = vec2.create();
|
||||
private dodgeCommitRemaining = 0;
|
||||
private dodgeCooldownRemaining = 0;
|
||||
|
||||
private timeSinceObserve = npcTuning.reactionIntervalSeconds;
|
||||
private nearObjects: Array<Physical> = [];
|
||||
|
||||
constructor(
|
||||
|
|
@ -97,69 +99,50 @@ export class NPC extends PlayerBase {
|
|||
) {
|
||||
super(playerInfo, playerContainer, objectContainer, team);
|
||||
this.createCharacter();
|
||||
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;
|
||||
}
|
||||
|
||||
if (
|
||||
(this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) >
|
||||
npcTuning.wanderReconsiderSeconds
|
||||
tuning.wanderReconsiderSeconds
|
||||
) {
|
||||
this.timeSinceLastWanderingConsideration = 0;
|
||||
this.isWandering = Random.getRandom() < npcTuning.wanderProbability;
|
||||
this.isWandering = Random.getRandom() < tuning.wanderProbability;
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastPlan += deltaTimeInSeconds) > npcTuning.planIntervalSeconds) {
|
||||
if ((this.timeSinceLastPlan += deltaTimeInSeconds) > tuning.planIntervalSeconds) {
|
||||
this.timeSinceLastPlan = 0;
|
||||
this.plan();
|
||||
}
|
||||
|
||||
if (
|
||||
(this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds
|
||||
) {
|
||||
this.timeSinceObserve = 0;
|
||||
this.nearObjects = this.observe(npcTuning.reactionObserveRadius);
|
||||
}
|
||||
|
||||
this.dodgeCommitRemaining -= deltaTimeInSeconds;
|
||||
this.dodgeCooldownRemaining -= deltaTimeInSeconds;
|
||||
const movement = this.decideMovement(this.nearObjects);
|
||||
this.character.handleMovementAction(new MoveActionCommand(movement));
|
||||
|
||||
if ((this.timeSinceObserve += deltaTimeInSeconds) > tuning.reactionIntervalSeconds) {
|
||||
this.timeSinceObserve = 0;
|
||||
this.nearObjects = this.observe(tuning.reactionObserveRadius);
|
||||
this.movement = this.decideMovement(character, this.nearObjects);
|
||||
character.setMoveDirection(this.movement);
|
||||
}
|
||||
|
||||
if (
|
||||
!this.isComingBack &&
|
||||
this.character.groundPlanet &&
|
||||
vec2.length(movement) > 0 &&
|
||||
character.groundPlanet &&
|
||||
vec2.length(this.movement) > 0 &&
|
||||
Random.getRandom() <
|
||||
npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds
|
||||
tuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds
|
||||
) {
|
||||
this.character.leap();
|
||||
character.leap();
|
||||
}
|
||||
|
||||
if (
|
||||
(this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds
|
||||
) {
|
||||
if ((this.timeSinceLastShoot += deltaTimeInSeconds) > tuning.shootIntervalSeconds) {
|
||||
this.timeSinceLastShoot = 0;
|
||||
this.tryShoot(this.nearObjects);
|
||||
this.tryShoot(character, this.nearObjects);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -175,23 +158,18 @@ export class NPC extends PlayerBase {
|
|||
}
|
||||
this.isComingBack = false;
|
||||
|
||||
const nearObjects = this.observe(npcTuning.planScanRadius);
|
||||
const enemies = this.enemiesByDistance(nearObjects);
|
||||
|
||||
if (enemies.length > 0) {
|
||||
const nearest = enemies[0];
|
||||
const nearObjects = this.observe(tuning.planScanRadius);
|
||||
const nearest = this.enemiesByDistance(nearObjects)[0];
|
||||
if (nearest) {
|
||||
const fleeRange =
|
||||
npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression);
|
||||
tuning.fleeBaseRange * (tuning.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;
|
||||
tuning.chaseBaseRange + tuning.chaseAggressionRange * this.aggression;
|
||||
if (nearest.distance < chaseRange) {
|
||||
vec2.subtract(this.direction, nearest.character.center, this.center);
|
||||
return;
|
||||
|
|
@ -201,7 +179,7 @@ export class NPC extends PlayerBase {
|
|||
if (!this.isWandering) {
|
||||
const planet = this.capturablePlanetsByDistance(nearObjects)[0];
|
||||
if (planet) {
|
||||
vec2.subtract(this.direction, planet.planet.center, this.center);
|
||||
vec2.subtract(this.direction, planet.center, this.center);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -210,11 +188,14 @@ export class NPC extends PlayerBase {
|
|||
this.direction,
|
||||
this.direction,
|
||||
vec2.create(),
|
||||
Random.getRandomInRange(-npcTuning.wanderTurn, npcTuning.wanderTurn),
|
||||
Random.getRandomInRange(-tuning.wanderTurn, tuning.wanderTurn),
|
||||
);
|
||||
}
|
||||
|
||||
private decideMovement(nearObjects: Array<Physical>): vec2 {
|
||||
private decideMovement(
|
||||
character: CharacterPhysical,
|
||||
nearObjects: Array<Physical>,
|
||||
): vec2 {
|
||||
if (this.dodgeCommitRemaining > 0) {
|
||||
return vec2.clone(this.dodgeDirection);
|
||||
}
|
||||
|
|
@ -223,23 +204,20 @@ export class NPC extends PlayerBase {
|
|||
if (dodge) {
|
||||
if (
|
||||
Random.getRandom() <
|
||||
npcTuning.dodgeBaseChance + npcTuning.dodgeAggressionChance * this.aggression
|
||||
tuning.dodgeBaseChance + tuning.dodgeAggressionChance * this.aggression
|
||||
) {
|
||||
vec2.copy(this.dodgeDirection, dodge);
|
||||
this.dodgeCommitRemaining = npcTuning.dodgeCommitSeconds;
|
||||
this.dodgeCommitRemaining = tuning.dodgeCommitSeconds;
|
||||
return vec2.clone(this.dodgeDirection);
|
||||
}
|
||||
this.dodgeCooldownRemaining = npcTuning.dodgeCooldownSeconds;
|
||||
this.dodgeCooldownRemaining = tuning.dodgeCooldownSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
const planet = this.character!.groundPlanet;
|
||||
const planet = character.groundPlanet;
|
||||
if (planet && planet.team !== this.team) {
|
||||
const enemies = this.enemiesByDistance(nearObjects);
|
||||
if (
|
||||
enemies.length === 0 ||
|
||||
enemies[0].distance > npcTuning.captureHoldEnemyDistance
|
||||
) {
|
||||
if (enemies.length === 0 || enemies[0].distance > tuning.captureHoldEnemyDistance) {
|
||||
return vec2.create();
|
||||
}
|
||||
}
|
||||
|
|
@ -258,12 +236,12 @@ export class NPC extends PlayerBase {
|
|||
}
|
||||
const toMe = vec2.subtract(vec2.create(), this.center, p.center);
|
||||
const distance = vec2.length(toMe);
|
||||
if (distance > npcTuning.dodgeThreatRange || distance === 0) {
|
||||
if (distance > tuning.dodgeThreatRange || distance === 0) {
|
||||
continue;
|
||||
}
|
||||
vec2.normalize(toMe, toMe);
|
||||
if (
|
||||
vec2.dot(p.direction, toMe) > npcTuning.dodgeApproachDot &&
|
||||
vec2.dot(p.direction, toMe) > tuning.dodgeApproachDot &&
|
||||
distance < threatDistance
|
||||
) {
|
||||
threatDistance = distance;
|
||||
|
|
@ -275,19 +253,16 @@ export class NPC extends PlayerBase {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const perpendicular = vec2.fromValues(-threat.direction.y, threat.direction.x);
|
||||
const perpendicular = vec2.fromValues(-threat.direction[1], threat.direction[0]);
|
||||
const toMe = vec2.subtract(vec2.create(), this.center, threat.center);
|
||||
if (vec2.dot(perpendicular, toMe) < 0) {
|
||||
vec2.negate(perpendicular, perpendicular);
|
||||
}
|
||||
vec2.normalize(perpendicular, perpendicular);
|
||||
|
||||
return perpendicular;
|
||||
return vec2.normalize(perpendicular, perpendicular);
|
||||
}
|
||||
|
||||
private tryShoot(nearObjects: Array<Physical>) {
|
||||
const enemies = this.enemiesByDistance(nearObjects);
|
||||
const visible = enemies.find((e) =>
|
||||
private tryShoot(character: CharacterPhysical, nearObjects: Array<Physical>) {
|
||||
const visible = this.enemiesByDistance(nearObjects).find((e) =>
|
||||
this.hasLineOfSightTo(e.character.center, nearObjects),
|
||||
);
|
||||
if (!visible) {
|
||||
|
|
@ -295,71 +270,66 @@ export class NPC extends PlayerBase {
|
|||
return;
|
||||
}
|
||||
|
||||
const target = visible.character;
|
||||
const distance = visible.distance;
|
||||
const { character: target, distance } = visible;
|
||||
|
||||
const velocity = vec2.create();
|
||||
if (this.aimTargetId === target.id) {
|
||||
vec2.subtract(velocity, target.center, this.aimTargetLastPosition);
|
||||
vec2.scale(velocity, velocity, 1 / npcTuning.shootIntervalSeconds);
|
||||
vec2.scale(velocity, velocity, 1 / tuning.shootIntervalSeconds);
|
||||
}
|
||||
this.aimTargetId = target.id;
|
||||
vec2.copy(this.aimTargetLastPosition, target.center);
|
||||
|
||||
if (
|
||||
Random.getRandom() >
|
||||
npcTuning.fireBaseChance + npcTuning.fireAggressionChance * this.aggression
|
||||
tuning.fireBaseChance + tuning.fireAggressionChance * this.aggression
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const charge =
|
||||
distance > npcTuning.chargeRangeThreshold &&
|
||||
distance > tuning.chargeRangeThreshold &&
|
||||
Random.getRandom() <
|
||||
npcTuning.chargeBaseChance + npcTuning.chargeAggressionChance * this.aggression
|
||||
? Random.getRandomInRange(npcTuning.chargeMin, 1)
|
||||
tuning.chargeBaseChance + tuning.chargeAggressionChance * this.aggression
|
||||
? Random.getRandomInRange(tuning.chargeMin, 1)
|
||||
: 0;
|
||||
|
||||
const projectileSpeed =
|
||||
charge > 0 ? settings.chargeShotSpeedMax : settings.chargeShotSpeedMin;
|
||||
const leadTime = distance / projectileSpeed;
|
||||
const aim = vec2.scaleAndAdd(vec2.create(), target.center, velocity, leadTime);
|
||||
const aim = vec2.scaleAndAdd(
|
||||
vec2.create(),
|
||||
target.center,
|
||||
velocity,
|
||||
distance / projectileSpeed,
|
||||
);
|
||||
|
||||
const spread =
|
||||
(npcTuning.spreadBase + distance * npcTuning.spreadPerDistance) *
|
||||
(npcTuning.spreadAggressionFalloff - this.aggression);
|
||||
aim.x += Random.getRandomInRange(-spread, spread);
|
||||
aim.y += Random.getRandomInRange(-spread, spread);
|
||||
(tuning.spreadBase + distance * tuning.spreadPerDistance) *
|
||||
(tuning.spreadAggressionFalloff - this.aggression);
|
||||
aim[0] += Random.getRandomInRange(-spread, spread);
|
||||
aim[1] += Random.getRandomInRange(-spread, spread);
|
||||
|
||||
this.character!.shootTowards(aim, charge);
|
||||
character.shootTowards(aim, charge);
|
||||
}
|
||||
|
||||
private hasLineOfSightTo(target: vec2, nearObjects: Array<Physical>): boolean {
|
||||
const planets: Array<PlanetPhysical> = [];
|
||||
for (const o of nearObjects) {
|
||||
if (o.gameObject instanceof PlanetPhysical) {
|
||||
planets.push(o.gameObject);
|
||||
}
|
||||
}
|
||||
const planets = planetsIn(nearObjects);
|
||||
if (planets.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const direction = vec2.subtract(vec2.create(), target, this.center);
|
||||
const totalDistance = vec2.length(direction);
|
||||
if (totalDistance <= npcTuning.lineOfSightStartOffset) {
|
||||
if (totalDistance <= tuning.lineOfSightStartOffset) {
|
||||
return true;
|
||||
}
|
||||
vec2.normalize(direction, direction);
|
||||
|
||||
let traveled = npcTuning.lineOfSightStartOffset;
|
||||
let traveled = tuning.lineOfSightStartOffset;
|
||||
const position = vec2.scaleAndAdd(vec2.create(), this.center, direction, traveled);
|
||||
while (traveled < totalDistance) {
|
||||
let sdf = Infinity;
|
||||
for (const planet of planets) {
|
||||
sdf = Math.min(sdf, planet.distance(position));
|
||||
}
|
||||
if (sdf < npcTuning.lineOfSightClearance) {
|
||||
const sdf = evaluateSdf(position, planets);
|
||||
if (sdf < tuning.lineOfSightClearance) {
|
||||
return false;
|
||||
}
|
||||
traveled += sdf;
|
||||
|
|
@ -370,45 +340,34 @@ export class NPC extends PlayerBase {
|
|||
|
||||
private observe(radius: number): Array<Physical> {
|
||||
return this.objectContainer.findIntersecting(
|
||||
getBoundingBoxOfCircle(new Circle(this.center, radius)),
|
||||
BoundingBox.ofCircle(this.center, radius),
|
||||
);
|
||||
}
|
||||
|
||||
private enemiesByDistance(
|
||||
nearObjects: Array<Physical>,
|
||||
): Array<{ character: CharacterPhysical; distance: number }> {
|
||||
const seen = new Set<CharacterPhysical>();
|
||||
const enemies: Array<{ character: CharacterPhysical; distance: number }> = [];
|
||||
private enemiesByDistance(nearObjects: Array<Physical>): Array<Enemy> {
|
||||
const enemies: Array<Enemy> = [];
|
||||
for (const o of nearObjects) {
|
||||
const c = o.gameObject;
|
||||
if (
|
||||
c instanceof CharacterPhysical &&
|
||||
c !== this.character &&
|
||||
c.isAlive &&
|
||||
c.team !== this.team &&
|
||||
!seen.has(c)
|
||||
c.team !== this.team
|
||||
) {
|
||||
seen.add(c);
|
||||
enemies.push({ character: c, distance: vec2.distance(this.center, c.center) });
|
||||
}
|
||||
}
|
||||
enemies.sort((a, b) => a.distance - b.distance);
|
||||
return enemies;
|
||||
return enemies.sort((a, b) => a.distance - b.distance);
|
||||
}
|
||||
|
||||
private capturablePlanetsByDistance(
|
||||
nearObjects: Array<Physical>,
|
||||
): Array<{ planet: PlanetPhysical; distance: number }> {
|
||||
const planets = nearObjects
|
||||
.filter(
|
||||
(o): o is Physical =>
|
||||
o.gameObject instanceof PlanetPhysical && o.gameObject.team !== this.team,
|
||||
)
|
||||
.map((o) => ({
|
||||
planet: o.gameObject as PlanetPhysical,
|
||||
distance: vec2.distance(this.center, (o.gameObject as PlanetPhysical).center),
|
||||
}));
|
||||
planets.sort((a, b) => a.distance - b.distance);
|
||||
return planets;
|
||||
): Array<PlanetPhysical> {
|
||||
return planetsIn(nearObjects)
|
||||
.filter((p) => p.team !== this.team)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
vec2.distance(this.center, a.center) - vec2.distance(this.center, b.center),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,25 @@
|
|||
import { CharacterTeam, PlayerInformation, Random, settings, Command } from 'shared';
|
||||
import {
|
||||
CharacterTeam,
|
||||
PlayerInformation,
|
||||
Random,
|
||||
settings,
|
||||
Command,
|
||||
GameObject,
|
||||
PropertyUpdatesForObject,
|
||||
} from 'shared';
|
||||
import { Socket } from 'socket.io';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { PhysicalContainer } from '../physics/physical-container';
|
||||
import { NPC } from './npc';
|
||||
import { Player } from './player';
|
||||
import { PlayerBase } from './player-base';
|
||||
import { PlayerBase, Score } from './player-base';
|
||||
|
||||
export interface CarriedScore extends Score {
|
||||
team: CharacterTeam;
|
||||
}
|
||||
|
||||
export class PlayerContainer {
|
||||
private _players: Array<Player> = [];
|
||||
private _npcs: Array<NPC> = [];
|
||||
private npcs: Array<NPC> = [];
|
||||
|
||||
constructor(
|
||||
private readonly objects: PhysicalContainer,
|
||||
|
|
@ -17,58 +29,65 @@ export class PlayerContainer {
|
|||
this.createNPCs();
|
||||
}
|
||||
|
||||
public createNPCs() {
|
||||
const newNpcCount = Math.min(
|
||||
this.playerMaxCount - this._players.length - this._npcs.length,
|
||||
this.npcMaxCount - this._npcs.length,
|
||||
);
|
||||
private createNPCs() {
|
||||
const newNpcCount =
|
||||
Math.min(this.playerMaxCount, this.npcMaxCount) -
|
||||
this._players.length -
|
||||
this.npcs.length;
|
||||
for (let i = 0; i < newNpcCount; i++) {
|
||||
const name = `🤖 ${Random.choose(settings.npcNames)}`;
|
||||
this._npcs.push(
|
||||
new NPC({ name }, this, this.objects, this.getTeamOfNextPlayer(true)),
|
||||
this.npcs.push(
|
||||
new NPC({ name }, this, this.objects, this.teamOfNext(this.players)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public createPlayer(playerInfo: PlayerInformation, socket: Socket): Player {
|
||||
if (this._players.length === this.playerMaxCount) {
|
||||
throw new Error('Too many players');
|
||||
}
|
||||
public createPlayer(
|
||||
playerInfo: PlayerInformation,
|
||||
socket: Socket,
|
||||
carried?: CarriedScore,
|
||||
): Player {
|
||||
const team = carried ? carried.team : this.teamOfNext(this._players);
|
||||
|
||||
const team = this.getTeamOfNextPlayer();
|
||||
let npcToReplace = this._npcs.find((n) => n.team === team);
|
||||
if (!npcToReplace) {
|
||||
npcToReplace = this._npcs.find((n) => n.team !== team);
|
||||
}
|
||||
const npcToReplace =
|
||||
this.npcs.find((n) => n.team === team) ?? this.npcs.find((n) => n.team !== team);
|
||||
npcToReplace?.destroy();
|
||||
this._npcs = this._npcs.filter((n) => n !== npcToReplace);
|
||||
this.npcs = this.npcs.filter((n) => n !== npcToReplace);
|
||||
|
||||
const player = new Player(playerInfo, this, this.objects, team, socket);
|
||||
const player = new Player(playerInfo, this, this.objects, team, socket, carried);
|
||||
this._players.push(player);
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
public get players(): Array<PlayerBase> {
|
||||
return [...this._players, ...this._npcs];
|
||||
return [...this._players, ...this.npcs];
|
||||
}
|
||||
|
||||
public get count(): number {
|
||||
return this._players.length;
|
||||
}
|
||||
|
||||
// Measured round-trip times (ms) of the real connected players, for
|
||||
// server-side latency stats. NPCs have no socket and are excluded.
|
||||
public get isFull(): boolean {
|
||||
return this._players.length >= this.playerMaxCount;
|
||||
}
|
||||
|
||||
public get connectedPlayerRttsMs(): Array<number> {
|
||||
return this._players.map((p) => p.rttMs);
|
||||
}
|
||||
|
||||
public step(deltaTimeInSeconds: number) {
|
||||
this.players.forEach((p) => p.step(deltaTimeInSeconds));
|
||||
this._players.forEach((p) => p.step(deltaTimeInSeconds));
|
||||
this.npcs.forEach((p) => p.step(deltaTimeInSeconds));
|
||||
}
|
||||
|
||||
public stepCommunication(deltaTimeInSeconds: number) {
|
||||
this._players.forEach((p) => p.stepCommunications(deltaTimeInSeconds));
|
||||
public stepCommunication(
|
||||
deltaTimeInSeconds: number,
|
||||
simulatedThroughMs: number,
|
||||
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
|
||||
) {
|
||||
this._players.forEach((p) =>
|
||||
p.stepCommunications(deltaTimeInSeconds, simulatedThroughMs, propertyUpdatesOf),
|
||||
);
|
||||
}
|
||||
|
||||
public endGame(winner: CharacterTeam) {
|
||||
|
|
@ -83,8 +102,7 @@ export class PlayerContainer {
|
|||
this._players.forEach((p) => p.sendQueuedCommandsToClient());
|
||||
}
|
||||
|
||||
private getTeamOfNextPlayer(isNpc = false): CharacterTeam {
|
||||
const players = isNpc ? this.players : this._players;
|
||||
private teamOfNext(players: Array<PlayerBase>): CharacterTeam {
|
||||
const blueCount = players.filter((p) => p.team === CharacterTeam.blue).length;
|
||||
const redCount = players.filter((p) => p.team === CharacterTeam.red).length;
|
||||
|
||||
|
|
@ -96,7 +114,9 @@ export class PlayerContainer {
|
|||
}
|
||||
|
||||
public deletePlayer(player: Player) {
|
||||
this._players = this._players.filter((p) => p !== player);
|
||||
this.createNPCs();
|
||||
if (this._players.includes(player)) {
|
||||
this._players = this._players.filter((p) => p !== player);
|
||||
this.createNPCs();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,49 +25,85 @@ import {
|
|||
PrimaryActionCommand,
|
||||
LeapActionCommand,
|
||||
InputAcknowledgement,
|
||||
ClientHeartbeatCommand,
|
||||
finiteInRange,
|
||||
finiteVec2,
|
||||
isFiniteNumber,
|
||||
boundRadius,
|
||||
} from 'shared';
|
||||
import { Socket } from 'socket.io';
|
||||
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { BoundingBox } from '../physics/bounding-box';
|
||||
import { PhysicalContainer } from '../physics/physical-container';
|
||||
import { PlayerContainer } from './player-container';
|
||||
import { PlayerBase } from './player-base';
|
||||
import { PlayerBase, Score } from './player-base';
|
||||
import { CharacterPhysical } from '../objects/character-physical';
|
||||
import { planetsNear } from '../objects/planet-physical';
|
||||
|
||||
// How often the server pings each client to measure round-trip time.
|
||||
const pingIntervalSeconds = 1;
|
||||
const minimumAspectRatio = 0.2;
|
||||
const maximumAspectRatio = 8;
|
||||
|
||||
const sheddableWhenBackedUp: ReadonlyArray<string> = [
|
||||
PropertyUpdatesForObjects.name,
|
||||
UpdateMinimap.name,
|
||||
InputAcknowledgement.name,
|
||||
];
|
||||
|
||||
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> = [];
|
||||
private lastInputClientTimeMs = 0;
|
||||
private lastLeapClientTimeMs = 0;
|
||||
|
||||
// Measured round-trip time to this client (ms) — the latency primitive that
|
||||
// lag compensation, a latency HUD, and adaptive interpolation build on.
|
||||
public reconnectToken = '';
|
||||
public rttMs = 0;
|
||||
|
||||
private aspectRatio = 16 / 9;
|
||||
private timeSinceLastMessage = 0;
|
||||
private objectsInViewArea = new Set<GameObject>();
|
||||
private commandsToBeSent: Array<Command> = [];
|
||||
private winnerTeam?: CharacterTeam;
|
||||
|
||||
private lastInputClientTimeMs = 0;
|
||||
private lastInputReceiptMs = 0;
|
||||
private lastLeapClientTimeMs = -1;
|
||||
|
||||
private timeSinceLastPing = 0;
|
||||
private lastPingSentMs = 0;
|
||||
private pendingPingNonce = 0;
|
||||
private nextPingNonce = 1;
|
||||
|
||||
// Input is untrusted: one non-finite value reaching the simulation leaves a character that never dies.
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) =>
|
||||
(this.aspectRatio = v.aspectRatio),
|
||||
[MoveActionCommand.type]: (c: MoveActionCommand) => {
|
||||
// Remember how far into this client's input timeline we've consumed, to
|
||||
// echo back for client-side prediction reconciliation.
|
||||
this.lastInputClientTimeMs = c.clientTimeMs;
|
||||
this.character?.handleMovementAction(c);
|
||||
[SetAspectRatioActionCommand.name]: (v: SetAspectRatioActionCommand) => {
|
||||
this.aspectRatio = finiteInRange(
|
||||
v.aspectRatio,
|
||||
minimumAspectRatio,
|
||||
maximumAspectRatio,
|
||||
this.aspectRatio,
|
||||
);
|
||||
},
|
||||
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) =>
|
||||
this.character?.shootTowards(c.position, c.charge),
|
||||
[LeapActionCommand.type]: (c: LeapActionCommand) => {
|
||||
// Record receipt (whether or not leap() accepts it): either way its effect
|
||||
// on bodyVelocity is now reflected in the streamed launch momentum, so the
|
||||
// predictor must stop replaying this leap.
|
||||
[MoveActionCommand.name]: (c: MoveActionCommand) => {
|
||||
const direction = finiteVec2(c.direction, 1);
|
||||
if (!direction) {
|
||||
return;
|
||||
}
|
||||
this.observeClientTime(c.clientTimeMs);
|
||||
this.character?.setMoveDirection(direction);
|
||||
},
|
||||
[PrimaryActionCommand.name]: (c: PrimaryActionCommand) => {
|
||||
const position = finiteVec2(c.position, settings.maxClientPositionMagnitude);
|
||||
if (!position) {
|
||||
return;
|
||||
}
|
||||
this.observeClientTime(c.clientTimeMs);
|
||||
this.character?.shootTowards(position, finiteInRange(c.charge, 0, 1, 0));
|
||||
},
|
||||
[LeapActionCommand.name]: (c: LeapActionCommand) => {
|
||||
if (!isFiniteNumber(c.clientTimeMs)) {
|
||||
return;
|
||||
}
|
||||
this.lastLeapClientTimeMs = c.clientTimeMs;
|
||||
this.observeClientTime(c.clientTimeMs);
|
||||
this.character?.leap();
|
||||
},
|
||||
[ClientHeartbeatCommand.name]: (c: ClientHeartbeatCommand) =>
|
||||
this.observeClientTime(c.clientTimeMs),
|
||||
};
|
||||
|
||||
constructor(
|
||||
|
|
@ -76,116 +112,152 @@ export class Player extends PlayerBase {
|
|||
objectContainer: PhysicalContainer,
|
||||
team: CharacterTeam,
|
||||
private readonly socket: Socket,
|
||||
score?: Score,
|
||||
) {
|
||||
super(playerInfo, playerContainer, objectContainer, team);
|
||||
super(playerInfo, playerContainer, objectContainer, team, score);
|
||||
this.createCharacter();
|
||||
this.step(0);
|
||||
|
||||
// The client already echoes a Pong for every Ping (see game.ts). Only one
|
||||
// ping is ever in flight, so RTT is simply now − send-time; no payload
|
||||
// needed and no client change required.
|
||||
this.socket.on(TransportEvents.Pong, () => {
|
||||
if (this.lastPingSentMs > 0) {
|
||||
this.rttMs = performance.now() - this.lastPingSentMs;
|
||||
}
|
||||
});
|
||||
this.socket.on(TransportEvents.Pong, this.onPong);
|
||||
}
|
||||
|
||||
protected createCharacter() {
|
||||
super.createCharacter();
|
||||
private observeClientTime(clientTimeMs: number) {
|
||||
if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) {
|
||||
this.lastInputClientTimeMs = clientTimeMs;
|
||||
this.lastInputReceiptMs = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
this.objectsPreviouslyInViewArea.push(this.character!);
|
||||
this.queueCommandSend(new CreatePlayerCommand(this.character!));
|
||||
private readonly onPong = (nonce: unknown) => {
|
||||
if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) {
|
||||
return;
|
||||
}
|
||||
this.pendingPingNonce = 0;
|
||||
this.rttMs = Math.min(
|
||||
performance.now() - this.lastPingSentMs,
|
||||
settings.maxMeasuredRttMs,
|
||||
);
|
||||
};
|
||||
|
||||
public detachFromSocket() {
|
||||
this.socket.off(TransportEvents.Pong, this.onPong);
|
||||
}
|
||||
|
||||
protected createCharacter(): CharacterPhysical {
|
||||
const character = super.createCharacter();
|
||||
this.objectsInViewArea.add(character);
|
||||
this.queueCommandSend(new CreatePlayerCommand(character));
|
||||
return character;
|
||||
}
|
||||
|
||||
private winnerTeam?: CharacterTeam;
|
||||
public onGameEnded(winnerTeam: CharacterTeam) {
|
||||
this.winnerTeam = winnerTeam;
|
||||
}
|
||||
|
||||
public step(deltaTimeInSeconds: number) {
|
||||
if (this.character) {
|
||||
this.center = this.character?.center;
|
||||
public queueCommandSend(command: Command) {
|
||||
this.commandsToBeSent.push(command);
|
||||
}
|
||||
|
||||
if (!this.character.isAlive) {
|
||||
this.sumDeaths++;
|
||||
this.sumKills = this.character.killCount;
|
||||
public stepCommunications(
|
||||
deltaTime: number,
|
||||
simulatedThroughMs: number,
|
||||
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
|
||||
) {
|
||||
const remoteCalls: Array<RemoteCallsForObject> = [];
|
||||
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) {
|
||||
this.queueCommandSend(new RemoteCallsForObjects(remoteCalls));
|
||||
}
|
||||
|
||||
this.character = null;
|
||||
this.timeUntilRespawn = settings.playerDiedTimeout;
|
||||
}
|
||||
} else {
|
||||
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
|
||||
this.createCharacter();
|
||||
this.center = this.character!.center;
|
||||
}
|
||||
if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) {
|
||||
this.timeSinceLastPing = 0;
|
||||
this.lastPingSentMs = performance.now();
|
||||
this.pendingPingNonce = this.nextPingNonce++;
|
||||
this.socket.emit(TransportEvents.Ping, this.pendingPingNonce);
|
||||
}
|
||||
|
||||
// Subtracted, not zeroed: zeroing would stretch every interval to the next physics frame.
|
||||
if ((this.timeSinceLastMessage += deltaTime) >= settings.updateMessageInterval) {
|
||||
this.timeSinceLastMessage = Math.min(
|
||||
this.timeSinceLastMessage - settings.updateMessageInterval,
|
||||
settings.updateMessageInterval,
|
||||
);
|
||||
this.queueAnnouncement();
|
||||
this.queueSnapshot(simulatedThroughMs, propertyUpdatesOf);
|
||||
this.sendQueuedCommandsToClient();
|
||||
}
|
||||
}
|
||||
|
||||
private handleViewAreaUpdate() {
|
||||
const viewArea = calculateViewArea(this.center, this.aspectRatio, 1.2);
|
||||
const bb = new BoundingBox();
|
||||
bb.topLeft = viewArea.topLeft;
|
||||
bb.size = viewArea.size;
|
||||
|
||||
const objectsInViewArea = Array.from(
|
||||
new Set(this.objectContainer.findIntersecting(bb).map((o) => o.gameObject)),
|
||||
private queueSnapshot(
|
||||
simulatedThroughMs: number,
|
||||
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
|
||||
) {
|
||||
const { topLeft, size } = calculateViewArea(this.center, this.aspectRatio, 1.2);
|
||||
const viewBox = new BoundingBox(
|
||||
topLeft[0],
|
||||
topLeft[0] + size[0],
|
||||
topLeft[1] - size[1],
|
||||
topLeft[1],
|
||||
);
|
||||
|
||||
// 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);
|
||||
const inViewArea = new Set(
|
||||
this.objectContainer.findIntersecting(viewBox).map((o) => o.gameObject),
|
||||
);
|
||||
if (this.character) {
|
||||
inViewArea.add(this.character);
|
||||
// Prediction needs every gravity source, including planets outside a narrow viewport.
|
||||
for (const planet of planetsNear(
|
||||
this.objectContainer,
|
||||
this.character.center,
|
||||
boundRadius + settings.maxGravityDistance,
|
||||
)) {
|
||||
inViewArea.add(planet);
|
||||
}
|
||||
}
|
||||
|
||||
const newlyIntersecting = objectsInViewArea.filter(
|
||||
(o) => !this.objectsPreviouslyInViewArea.includes(o),
|
||||
);
|
||||
const created = [...inViewArea].filter((o) => !this.objectsInViewArea.has(o));
|
||||
const deleted = [...this.objectsInViewArea].filter((o) => !inViewArea.has(o));
|
||||
this.objectsInViewArea = inViewArea;
|
||||
|
||||
const noLongerIntersecting = this.objectsPreviouslyInViewArea.filter(
|
||||
(o) => !objectsInViewArea.includes(o),
|
||||
);
|
||||
|
||||
this.objectsPreviouslyInViewArea = objectsInViewArea;
|
||||
|
||||
if (noLongerIntersecting.length > 0) {
|
||||
this.queueCommandSend(
|
||||
new DeleteObjectsCommand(noLongerIntersecting.map((g) => g.id)),
|
||||
);
|
||||
if (deleted.length > 0) {
|
||||
this.queueCommandSend(new DeleteObjectsCommand(deleted.map((g) => g.id)));
|
||||
}
|
||||
|
||||
if (newlyIntersecting.length > 0) {
|
||||
this.queueCommandSend(new CreateObjectsCommand(newlyIntersecting));
|
||||
if (created.length > 0) {
|
||||
this.queueCommandSend(new CreateObjectsCommand(created));
|
||||
}
|
||||
|
||||
this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers()));
|
||||
|
||||
const propertyUpdates: Array<PropertyUpdatesForObject> = [];
|
||||
for (const object of this.objectsInViewArea) {
|
||||
const update = propertyUpdatesOf(object);
|
||||
if (update) {
|
||||
propertyUpdates.push(update);
|
||||
}
|
||||
}
|
||||
this.queueCommandSend(
|
||||
new PropertyUpdatesForObjects(
|
||||
this.objectsPreviouslyInViewArea
|
||||
.map((o) => o.getPropertyUpdates())
|
||||
.filter((u) => u) as Array<PropertyUpdatesForObject>,
|
||||
performance.now() / 1000,
|
||||
),
|
||||
new PropertyUpdatesForObjects(propertyUpdates, simulatedThroughMs / 1000),
|
||||
);
|
||||
|
||||
// Tell the client how much of its own input is reflected in the snapshot it
|
||||
// just received, so its predictor can replay the rest. Only while alive —
|
||||
// a dead player isn't predicting.
|
||||
if (this.character) {
|
||||
this.queueCommandSend(
|
||||
new InputAcknowledgement(
|
||||
this.lastInputClientTimeMs,
|
||||
this.character.launchMomentum,
|
||||
this.character.movementSnapshot,
|
||||
this.lastLeapClientTimeMs,
|
||||
// Aged against the instant the pose is from, not the send time: the difference is
|
||||
// the un-simulated remainder, and it would land straight in the client's replay.
|
||||
this.lastInputReceiptMs > 0
|
||||
? Math.max(0, simulatedThroughMs - this.lastInputReceiptMs)
|
||||
: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Every living player except this one, reported by absolute world position so
|
||||
// the client can plot the whole circular arena on its minimap.
|
||||
private getMinimapPlayers(): Array<MinimapPlayer> {
|
||||
return this.playerContainer.players
|
||||
.filter((p) => p !== this && p.character?.isAlive)
|
||||
|
|
@ -195,49 +267,66 @@ export class Player extends PlayerBase {
|
|||
);
|
||||
}
|
||||
|
||||
private commandsToBeSent: Array<Command> = [];
|
||||
public queueCommandSend(command: Command) {
|
||||
this.commandsToBeSent.push(command);
|
||||
}
|
||||
|
||||
public stepCommunications(deltaTime: number) {
|
||||
const remoteCalls = this.objectsPreviouslyInViewArea
|
||||
.map((g) => new RemoteCallsForObject(g.id, g.getRemoteCalls()))
|
||||
.filter((c) => c.calls.length > 0);
|
||||
|
||||
if (remoteCalls.length > 0) {
|
||||
this.queueCommandSend(new RemoteCallsForObjects(remoteCalls));
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) {
|
||||
this.timeSinceLastPing = 0;
|
||||
this.lastPingSentMs = performance.now();
|
||||
this.socket.emit(TransportEvents.Ping);
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) {
|
||||
this.handleAnnouncements();
|
||||
this.handleViewAreaUpdate();
|
||||
this.sendQueuedCommandsToClient();
|
||||
this.timeSinceLastMessage = 0;
|
||||
private get bufferedBytes(): number {
|
||||
const conn = this.socket.conn as unknown as
|
||||
| {
|
||||
transport?: { socket?: { bufferedAmount?: unknown } };
|
||||
writeBuffer?: Array<{ data?: unknown }>;
|
||||
}
|
||||
| undefined;
|
||||
const buffered = conn?.transport?.socket?.bufferedAmount;
|
||||
let bytes = typeof buffered === 'number' ? buffered : 0;
|
||||
// Engine.IO queues packets here while the transport is waiting for its write callback.
|
||||
for (const { data } of conn?.writeBuffer ?? []) {
|
||||
if (typeof data === 'string') {
|
||||
bytes += Buffer.byteLength(data);
|
||||
} else if (ArrayBuffer.isView(data) || data instanceof ArrayBuffer) {
|
||||
bytes += data.byteLength;
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public sendQueuedCommandsToClient() {
|
||||
// Only state the next snapshot regenerates may be dropped; create/delete are one-shot.
|
||||
if (this.bufferedBytes > settings.maxBufferedBytesPerClient) {
|
||||
const hasReliableCommands = this.commandsToBeSent.some(
|
||||
(c) => !sheddableWhenBackedUp.includes(c.constructor.name),
|
||||
);
|
||||
this.commandsToBeSent = hasReliableCommands
|
||||
? this.commandsToBeSent.flatMap((c): Array<Command> => {
|
||||
// Lifecycle events still need their snapshot time to enter the render timeline.
|
||||
if (c.constructor.name === PropertyUpdatesForObjects.name) {
|
||||
return [
|
||||
new PropertyUpdatesForObjects(
|
||||
[],
|
||||
(c as PropertyUpdatesForObjects).timestamp,
|
||||
),
|
||||
];
|
||||
}
|
||||
return sheddableWhenBackedUp.includes(c.constructor.name) ? [] : [c];
|
||||
})
|
||||
: [];
|
||||
}
|
||||
if (this.commandsToBeSent.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit(TransportEvents.ServerToPlayer, serialize(this.commandsToBeSent));
|
||||
this.commandsToBeSent = [];
|
||||
}
|
||||
|
||||
private handleAnnouncements() {
|
||||
let announcement = '';
|
||||
private queueAnnouncement() {
|
||||
if (this.winnerTeam) {
|
||||
announcement = `Team <span class="${this.winnerTeam}">${this.winnerTeam}</span> won 🎉`;
|
||||
this.queueCommandSend(
|
||||
new ServerAnnouncement(
|
||||
`Team <span class="${this.winnerTeam}">${this.winnerTeam}</span> won 🎉`,
|
||||
),
|
||||
);
|
||||
} else if (!this.character) {
|
||||
announcement = `Reviving in ${Math.round(this.timeUntilRespawn)}…`;
|
||||
}
|
||||
|
||||
if (announcement) {
|
||||
this.queueCommandSend(new ServerAnnouncement(announcement));
|
||||
this.queueCommandSend(
|
||||
new ServerAnnouncement(`Reviving in ${Math.round(this.timeUntilRespawn)}…`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue