More improvements
This commit is contained in:
parent
3848e460cd
commit
e3c44f775b
11 changed files with 908 additions and 233 deletions
|
|
@ -6,6 +6,7 @@ import {
|
|||
Random,
|
||||
MoveActionCommand,
|
||||
CharacterTeam,
|
||||
Id,
|
||||
} from 'shared';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { PlayerContainer } from './player-container';
|
||||
|
|
@ -13,16 +14,77 @@ 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 { ProjectilePhysical } from '../objects/projectile-physical';
|
||||
import { Physical } from '../physics/physicals/physical';
|
||||
|
||||
const npcTuning = {
|
||||
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,
|
||||
};
|
||||
|
||||
export class NPC extends PlayerBase {
|
||||
private direction: vec2 = vec2.fromValues(Random.getRandom(), Random.getRandom());
|
||||
private timeSinceLastFindTarget = 10000;
|
||||
private timeSinceLastFindShootTarget = 10000;
|
||||
private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5);
|
||||
private timeSinceLastPlan = 10000;
|
||||
private timeSinceLastShoot = 10000;
|
||||
private isWandering = false;
|
||||
private timeSinceLastWanderingConsideration = 0;
|
||||
private isComingBack = false;
|
||||
|
||||
private readonly aggression = Random.getRandomInRange(
|
||||
npcTuning.aggressionMin,
|
||||
npcTuning.aggressionMax,
|
||||
);
|
||||
|
||||
private aimTargetId: Id = 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(
|
||||
playerInfo: PlayerInformation,
|
||||
playerContainer: PlayerContainer,
|
||||
|
|
@ -34,172 +96,304 @@ export class NPC extends PlayerBase {
|
|||
this.step(0);
|
||||
}
|
||||
|
||||
private findTarget() {
|
||||
if (
|
||||
(!this.isComingBack && vec2.length(this.center) > settings.worldRadius) ||
|
||||
(this.isComingBack && vec2.length(this.center) > settings.worldRadius / 2)
|
||||
) {
|
||||
this.isComingBack = true;
|
||||
vec2.subtract(this.direction, vec2.fromValues(0, 0), this.center);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isComingBack = false;
|
||||
|
||||
const observableArea = getBoundingBoxOfCircle(new Circle(this.center, 2000));
|
||||
const nearObjects = this.objectContainer.findIntersecting(observableArea);
|
||||
const characters = this.findNearCharactersSorted(nearObjects);
|
||||
|
||||
if (characters.length > 0) {
|
||||
const nearest = characters[0];
|
||||
if (nearest.distance < 200) {
|
||||
vec2.subtract(this.direction, this.center, nearest.character.center);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const enemies = characters.filter((o) => o.character.team !== this.team);
|
||||
|
||||
if (enemies.length > 0) {
|
||||
const nearest = enemies[0];
|
||||
if (nearest.distance < 500) {
|
||||
vec2.subtract(this.direction, this.center, nearest.character.center);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (enemies.length > 0) {
|
||||
const nearest = enemies[0];
|
||||
if (nearest.distance > 1000) {
|
||||
vec2.subtract(this.direction, nearest.character.center, this.center);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isWandering) {
|
||||
vec2.rotate(
|
||||
this.direction,
|
||||
this.direction,
|
||||
vec2.create(),
|
||||
Random.getRandomInRange(-0.2, 0.2),
|
||||
);
|
||||
} else {
|
||||
const planets = this.findNearPlanetsSorted(nearObjects).filter(
|
||||
(p) => p.planet.team !== this.team,
|
||||
);
|
||||
|
||||
if (planets.length > 0) {
|
||||
vec2.subtract(this.direction, planets[0].planet.center, this.center);
|
||||
} else {
|
||||
this.isWandering = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findNearCharactersSorted(
|
||||
nearObjects: Array<Physical>,
|
||||
): Array<{ character: CharacterPhysical; distance: number }> {
|
||||
const characters = Array.from(
|
||||
new Set(
|
||||
nearObjects.filter(
|
||||
(o) =>
|
||||
o.gameObject instanceof CharacterPhysical && o.gameObject !== this.character,
|
||||
),
|
||||
),
|
||||
).map((c) => ({
|
||||
character: c.gameObject,
|
||||
distance: vec2.distance(this.center, (c.gameObject as CharacterPhysical).center),
|
||||
})) as Array<{ character: CharacterPhysical; distance: number }>;
|
||||
|
||||
characters.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
return characters;
|
||||
}
|
||||
|
||||
private findNearPlanetsSorted(
|
||||
nearObjects: Array<Physical>,
|
||||
): Array<{ planet: PlanetPhysical; distance: number }> {
|
||||
const planets = nearObjects
|
||||
.filter((o) => o.gameObject instanceof PlanetPhysical)
|
||||
.map((c) => ({
|
||||
planet: c.gameObject,
|
||||
distance: vec2.distance(this.center, (c.gameObject as PlanetPhysical).center),
|
||||
})) as Array<{ planet: PlanetPhysical; distance: number }>;
|
||||
|
||||
planets.sort((a, b) => a.distance - b.distance);
|
||||
return planets;
|
||||
}
|
||||
|
||||
private findShootTarget(): vec2 | undefined {
|
||||
const observableArea = getBoundingBoxOfCircle(new Circle(this.center, 1000));
|
||||
const nearObjects = this.objectContainer.findIntersecting(observableArea);
|
||||
|
||||
const enemies = nearObjects.filter(
|
||||
(o) => o.gameObject instanceof CharacterPhysical && o.gameObject.team !== this.team,
|
||||
);
|
||||
if (enemies.length > 0) {
|
||||
return (enemies[0].gameObject as CharacterPhysical).center;
|
||||
}
|
||||
}
|
||||
|
||||
private timeUntilRespawn = 0;
|
||||
public step(deltaTimeInSeconds: number) {
|
||||
if (this.character) {
|
||||
this.center = this.character?.center;
|
||||
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;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) > 3) {
|
||||
if (
|
||||
(this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) >
|
||||
npcTuning.wanderReconsiderSeconds
|
||||
) {
|
||||
this.timeSinceLastWanderingConsideration = 0;
|
||||
this.isWandering = Random.getRandom() > 0.5;
|
||||
this.isWandering = Random.getRandom() < npcTuning.wanderProbability;
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastFindTarget += deltaTimeInSeconds) > 1) {
|
||||
this.timeSinceLastFindTarget = 0;
|
||||
this.findTarget();
|
||||
if ((this.timeSinceLastPlan += deltaTimeInSeconds) > npcTuning.planIntervalSeconds) {
|
||||
this.timeSinceLastPlan = 0;
|
||||
this.plan();
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastFindShootTarget += deltaTimeInSeconds) > 0.5) {
|
||||
if (Random.getRandom() > 0.5) {
|
||||
const shootTarget = this.findShootTarget();
|
||||
if (shootTarget) {
|
||||
vec2.add(
|
||||
shootTarget,
|
||||
shootTarget,
|
||||
vec2.fromValues(
|
||||
Random.getRandomInRange(-200, 200),
|
||||
Random.getRandomInRange(-200, 200),
|
||||
),
|
||||
);
|
||||
this.character?.shootTowards(shootTarget);
|
||||
}
|
||||
}
|
||||
|
||||
this.timeSinceLastFindShootTarget = 0;
|
||||
if (
|
||||
(this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds
|
||||
) {
|
||||
this.timeSinceObserve = 0;
|
||||
this.nearObjects = this.observe(npcTuning.reactionObserveRadius);
|
||||
}
|
||||
|
||||
this.character?.handleMovementAction(new MoveActionCommand(this.direction));
|
||||
this.dodgeCommitRemaining -= deltaTimeInSeconds;
|
||||
this.dodgeCooldownRemaining -= deltaTimeInSeconds;
|
||||
const movement = this.decideMovement(this.nearObjects);
|
||||
this.character.handleMovementAction(new MoveActionCommand(movement));
|
||||
|
||||
if (
|
||||
(this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds
|
||||
) {
|
||||
this.timeSinceLastShoot = 0;
|
||||
this.tryShoot(this.nearObjects);
|
||||
}
|
||||
}
|
||||
|
||||
protected createCharacter() {
|
||||
const randomPoint = vec2.rotate(
|
||||
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(npcTuning.planScanRadius);
|
||||
const enemies = this.enemiesByDistance(nearObjects);
|
||||
|
||||
if (enemies.length > 0) {
|
||||
const nearest = enemies[0];
|
||||
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) {
|
||||
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.planet.center, this.center);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
vec2.rotate(
|
||||
this.direction,
|
||||
this.direction,
|
||||
vec2.create(),
|
||||
vec2.fromValues(Random.getRandomInRange(0, settings.worldRadius), 0),
|
||||
vec2.create(),
|
||||
Random.getRandomInRange(0, Math.PI * 2),
|
||||
Random.getRandomInRange(-npcTuning.wanderTurn, npcTuning.wanderTurn),
|
||||
);
|
||||
super.createCharacter(randomPoint);
|
||||
}
|
||||
|
||||
private decideMovement(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() <
|
||||
npcTuning.dodgeBaseChance + npcTuning.dodgeAggressionChance * this.aggression
|
||||
) {
|
||||
vec2.copy(this.dodgeDirection, dodge);
|
||||
this.dodgeCommitRemaining = npcTuning.dodgeCommitSeconds;
|
||||
return vec2.clone(this.dodgeDirection);
|
||||
}
|
||||
this.dodgeCooldownRemaining = npcTuning.dodgeCooldownSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
const planet = this.character!.groundPlanet;
|
||||
if (planet && planet.team !== this.team) {
|
||||
const enemies = this.enemiesByDistance(nearObjects);
|
||||
if (
|
||||
enemies.length === 0 ||
|
||||
enemies[0].distance > npcTuning.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 > npcTuning.dodgeThreatRange || distance === 0) {
|
||||
continue;
|
||||
}
|
||||
vec2.normalize(toMe, toMe);
|
||||
if (
|
||||
vec2.dot(p.direction, toMe) > npcTuning.dodgeApproachDot &&
|
||||
distance < threatDistance
|
||||
) {
|
||||
threatDistance = distance;
|
||||
threat = p;
|
||||
}
|
||||
}
|
||||
|
||||
if (!threat) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const perpendicular = vec2.fromValues(-threat.direction.y, threat.direction.x);
|
||||
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;
|
||||
}
|
||||
|
||||
private tryShoot(nearObjects: Array<Physical>) {
|
||||
const enemies = this.enemiesByDistance(nearObjects);
|
||||
const visible = enemies.find((e) =>
|
||||
this.hasLineOfSightTo(e.character.center, nearObjects),
|
||||
);
|
||||
if (!visible) {
|
||||
this.aimTargetId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const target = visible.character;
|
||||
const distance = visible.distance;
|
||||
|
||||
const velocity = vec2.create();
|
||||
if (this.aimTargetId === target.id) {
|
||||
vec2.subtract(velocity, target.center, this.aimTargetLastPosition);
|
||||
vec2.scale(velocity, velocity, 1 / npcTuning.shootIntervalSeconds);
|
||||
}
|
||||
this.aimTargetId = target.id;
|
||||
vec2.copy(this.aimTargetLastPosition, target.center);
|
||||
|
||||
if (
|
||||
Random.getRandom() >
|
||||
npcTuning.fireBaseChance + npcTuning.fireAggressionChance * this.aggression
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const charge =
|
||||
distance > npcTuning.chargeRangeThreshold &&
|
||||
Random.getRandom() <
|
||||
npcTuning.chargeBaseChance + npcTuning.chargeAggressionChance * this.aggression
|
||||
? Random.getRandomInRange(npcTuning.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);
|
||||
|
||||
(npcTuning.spreadBase + distance * npcTuning.spreadPerDistance) *
|
||||
(npcTuning.spreadAggressionFalloff - this.aggression);
|
||||
aim.x += Random.getRandomInRange(-spread, spread);
|
||||
aim.y += Random.getRandomInRange(-spread, spread);
|
||||
|
||||
this.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);
|
||||
}
|
||||
}
|
||||
if (planets.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const direction = vec2.subtract(vec2.create(), target, this.center);
|
||||
const totalDistance = vec2.length(direction);
|
||||
if (totalDistance <= npcTuning.lineOfSightStartOffset) {
|
||||
return true;
|
||||
}
|
||||
vec2.normalize(direction, direction);
|
||||
|
||||
let traveled = npcTuning.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) {
|
||||
return false;
|
||||
}
|
||||
traveled += sdf;
|
||||
vec2.scaleAndAdd(position, position, direction, sdf);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private observe(radius: number): Array<Physical> {
|
||||
return this.objectContainer.findIntersecting(
|
||||
getBoundingBoxOfCircle(new Circle(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 }> = [];
|
||||
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)
|
||||
) {
|
||||
seen.add(c);
|
||||
enemies.push({ character: c, distance: vec2.distance(this.center, c.center) });
|
||||
}
|
||||
}
|
||||
enemies.sort((a, b) => a.distance - b.distance);
|
||||
return enemies;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue