decla-red/backend/src/players/npc.ts
Andras Schmelczer 93a7fe01ab
All checks were successful
Build & deploy / Build & deploy website (push) Successful in 32s
Build & deploy / Build & publish server image (push) Successful in 1m2s
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
2026-09-15 07:57:57 +01:00

373 lines
11 KiB
TypeScript

import { vec2 } from 'gl-matrix';
import {
PlayerInformation,
settings,
Random,
CharacterTeam,
Id,
evaluateSdf,
} from 'shared';
import { PhysicalContainer } from '../physics/physical-container';
import { BoundingBox } from '../physics/bounding-box';
import { PlayerContainer } from './player-container';
import { PlayerBase } from './player-base';
import { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical, planetsIn } from '../objects/planet-physical';
import { ProjectilePhysical } from '../objects/projectile-physical';
import { Physical } from '../physics/physical';
const tuning = {
planIntervalSeconds: 1,
shootIntervalSeconds: 1.5,
reactionIntervalSeconds: 1 / 10,
reactionObserveRadius: 1400,
planScanRadius: 3500,
aggressionMin: 0.25,
aggressionMax: 1,
wanderReconsiderSeconds: 5,
wanderProbability: 0.4,
wanderTurn: 0.3,
fleeBaseRange: 240,
fleeAggressionFalloff: 1.4,
chaseBaseRange: 700,
chaseAggressionRange: 1600,
captureHoldEnemyDistance: 500,
dodgeThreatRange: 450,
dodgeApproachDot: 0.6,
dodgeBaseChance: 0.25,
dodgeAggressionChance: 0.35,
dodgeCommitSeconds: 0.35,
dodgeCooldownSeconds: 0.5,
lineOfSightClearance: 20,
lineOfSightStartOffset: 100,
fireBaseChance: 0.45,
fireAggressionChance: 0.45,
chargeRangeThreshold: 500,
chargeBaseChance: 0.3,
chargeAggressionChance: 0.4,
chargeMin: 0.6,
spreadBase: 60,
spreadPerDistance: 0.08,
spreadAggressionFalloff: 1.3,
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 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(
tuning.aggressionMin,
tuning.aggressionMax,
);
private aimTargetId: Id | null = null;
private readonly aimTargetLastPosition = vec2.create();
private readonly dodgeDirection = vec2.create();
private dodgeCommitRemaining = 0;
private dodgeCooldownRemaining = 0;
private nearObjects: Array<Physical> = [];
constructor(
playerInfo: PlayerInformation,
playerContainer: PlayerContainer,
objectContainer: PhysicalContainer,
team: CharacterTeam,
) {
super(playerInfo, playerContainer, objectContainer, team);
this.createCharacter();
}
public step(deltaTimeInSeconds: number) {
const character = this.stepLifecycle(deltaTimeInSeconds);
if (!character) {
return;
}
if (
(this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) >
tuning.wanderReconsiderSeconds
) {
this.timeSinceLastWanderingConsideration = 0;
this.isWandering = Random.getRandom() < tuning.wanderProbability;
}
if ((this.timeSinceLastPlan += deltaTimeInSeconds) > tuning.planIntervalSeconds) {
this.timeSinceLastPlan = 0;
this.plan();
}
this.dodgeCommitRemaining -= deltaTimeInSeconds;
this.dodgeCooldownRemaining -= deltaTimeInSeconds;
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 &&
character.groundPlanet &&
vec2.length(this.movement) > 0 &&
Random.getRandom() <
tuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds
) {
character.leap();
}
if ((this.timeSinceLastShoot += deltaTimeInSeconds) > tuning.shootIntervalSeconds) {
this.timeSinceLastShoot = 0;
this.tryShoot(character, this.nearObjects);
}
}
private plan() {
const distanceFromCentre = vec2.length(this.center);
if (
(!this.isComingBack && distanceFromCentre > settings.worldRadius) ||
(this.isComingBack && distanceFromCentre > settings.worldRadius / 2)
) {
this.isComingBack = true;
vec2.negate(this.direction, this.center);
return;
}
this.isComingBack = false;
const nearObjects = this.observe(tuning.planScanRadius);
const nearest = this.enemiesByDistance(nearObjects)[0];
if (nearest) {
const fleeRange =
tuning.fleeBaseRange * (tuning.fleeAggressionFalloff - this.aggression);
if (nearest.distance < fleeRange) {
vec2.subtract(this.direction, this.center, nearest.character.center);
return;
}
const chaseRange =
tuning.chaseBaseRange + tuning.chaseAggressionRange * this.aggression;
if (nearest.distance < chaseRange) {
vec2.subtract(this.direction, nearest.character.center, this.center);
return;
}
}
if (!this.isWandering) {
const planet = this.capturablePlanetsByDistance(nearObjects)[0];
if (planet) {
vec2.subtract(this.direction, planet.center, this.center);
return;
}
}
vec2.rotate(
this.direction,
this.direction,
vec2.create(),
Random.getRandomInRange(-tuning.wanderTurn, tuning.wanderTurn),
);
}
private decideMovement(
character: CharacterPhysical,
nearObjects: Array<Physical>,
): vec2 {
if (this.dodgeCommitRemaining > 0) {
return vec2.clone(this.dodgeDirection);
}
if (this.dodgeCooldownRemaining <= 0) {
const dodge = this.dodgeVector(nearObjects);
if (dodge) {
if (
Random.getRandom() <
tuning.dodgeBaseChance + tuning.dodgeAggressionChance * this.aggression
) {
vec2.copy(this.dodgeDirection, dodge);
this.dodgeCommitRemaining = tuning.dodgeCommitSeconds;
return vec2.clone(this.dodgeDirection);
}
this.dodgeCooldownRemaining = tuning.dodgeCooldownSeconds;
}
}
const planet = character.groundPlanet;
if (planet && planet.team !== this.team) {
const enemies = this.enemiesByDistance(nearObjects);
if (enemies.length === 0 || enemies[0].distance > tuning.captureHoldEnemyDistance) {
return vec2.create();
}
}
return vec2.normalize(vec2.create(), this.direction);
}
private dodgeVector(nearObjects: Array<Physical>): vec2 | undefined {
let threat: ProjectilePhysical | undefined;
let threatDistance = Infinity;
for (const o of nearObjects) {
const p = o.gameObject;
if (!(p instanceof ProjectilePhysical) || p.team === this.team || !p.isAlive) {
continue;
}
const toMe = vec2.subtract(vec2.create(), this.center, p.center);
const distance = vec2.length(toMe);
if (distance > tuning.dodgeThreatRange || distance === 0) {
continue;
}
vec2.normalize(toMe, toMe);
if (
vec2.dot(p.direction, toMe) > tuning.dodgeApproachDot &&
distance < threatDistance
) {
threatDistance = distance;
threat = p;
}
}
if (!threat) {
return undefined;
}
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);
}
return vec2.normalize(perpendicular, perpendicular);
}
private tryShoot(character: CharacterPhysical, nearObjects: Array<Physical>) {
const visible = this.enemiesByDistance(nearObjects).find((e) =>
this.hasLineOfSightTo(e.character.center, nearObjects),
);
if (!visible) {
this.aimTargetId = null;
return;
}
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 / tuning.shootIntervalSeconds);
}
this.aimTargetId = target.id;
vec2.copy(this.aimTargetLastPosition, target.center);
if (
Random.getRandom() >
tuning.fireBaseChance + tuning.fireAggressionChance * this.aggression
) {
return;
}
const charge =
distance > tuning.chargeRangeThreshold &&
Random.getRandom() <
tuning.chargeBaseChance + tuning.chargeAggressionChance * this.aggression
? Random.getRandomInRange(tuning.chargeMin, 1)
: 0;
const projectileSpeed =
charge > 0 ? settings.chargeShotSpeedMax : settings.chargeShotSpeedMin;
const aim = vec2.scaleAndAdd(
vec2.create(),
target.center,
velocity,
distance / projectileSpeed,
);
const spread =
(tuning.spreadBase + distance * tuning.spreadPerDistance) *
(tuning.spreadAggressionFalloff - this.aggression);
aim[0] += Random.getRandomInRange(-spread, spread);
aim[1] += Random.getRandomInRange(-spread, spread);
character.shootTowards(aim, charge);
}
private hasLineOfSightTo(target: vec2, nearObjects: Array<Physical>): boolean {
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 <= tuning.lineOfSightStartOffset) {
return true;
}
vec2.normalize(direction, direction);
let traveled = tuning.lineOfSightStartOffset;
const position = vec2.scaleAndAdd(vec2.create(), this.center, direction, traveled);
while (traveled < totalDistance) {
const sdf = evaluateSdf(position, planets);
if (sdf < tuning.lineOfSightClearance) {
return false;
}
traveled += sdf;
vec2.scaleAndAdd(position, position, direction, sdf);
}
return true;
}
private observe(radius: number): Array<Physical> {
return this.objectContainer.findIntersecting(
BoundingBox.ofCircle(this.center, radius),
);
}
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
) {
enemies.push({ character: c, distance: vec2.distance(this.center, c.center) });
}
}
return enemies.sort((a, b) => a.distance - b.distance);
}
private capturablePlanetsByDistance(
nearObjects: Array<Physical>,
): 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),
);
}
}