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 = []; 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, ): 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): 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) { 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): 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 { return this.objectContainer.findIntersecting( BoundingBox.ofCircle(this.center, radius), ); } private enemiesByDistance(nearObjects: Array): Array { const enemies: Array = []; 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, ): Array { return planetsIn(nearObjects) .filter((p) => p.team !== this.team) .sort( (a, b) => vec2.distance(this.center, a.center) - vec2.distance(this.center, b.center), ); } }