- 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
373 lines
11 KiB
TypeScript
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),
|
|
);
|
|
}
|
|
}
|