import { vec2 } from 'gl-matrix'; import { PlayerInformation, settings, Circle, Random, MoveActionCommand, CharacterTeam, Id, } from 'shared'; import { PhysicalContainer } from '../physics/containers/physical-container'; 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 { 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, // 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, }; 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 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 = []; constructor( playerInfo: PlayerInformation, playerContainer: PlayerContainer, objectContainer: PhysicalContainer, team: CharacterTeam, ) { 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; } return; } if ( (this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) > npcTuning.wanderReconsiderSeconds ) { this.timeSinceLastWanderingConsideration = 0; this.isWandering = Random.getRandom() < npcTuning.wanderProbability; } if ((this.timeSinceLastPlan += deltaTimeInSeconds) > npcTuning.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.isComingBack && this.character.groundPlanet && vec2.length(movement) > 0 && Random.getRandom() < npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds ) { this.character.leap(); } if ( (this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds ) { this.timeSinceLastShoot = 0; this.tryShoot(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(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(), Random.getRandomInRange(-npcTuning.wanderTurn, npcTuning.wanderTurn), ); } private decideMovement(nearObjects: Array): 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): 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) { 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); const spread = (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): boolean { const planets: Array = []; 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 { return this.objectContainer.findIntersecting( getBoundingBoxOfCircle(new Circle(this.center, radius)), ); } private enemiesByDistance( nearObjects: Array, ): Array<{ character: CharacterPhysical; distance: number }> { const seen = new Set(); 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, ): 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; } }