AI/EnemyBrain.cs
using System;
using System.Linq;
namespace Prototype;
public sealed class EnemyBrain : Component
{
[Sync] public EnemyRole NetworkRole { get; private set; }
[Sync] public float NetworkScale { get; private set; } = 1f;
[Sync] public float SpawnHealth { get; private set; } = 50f;
[Sync] public float MoveSpeed { get; private set; }
[Sync] public float AttackDamage { get; private set; }
[Sync] public float AttackRange { get; private set; }
[Sync] public float AttackInterval { get; private set; }
[Sync] public Color EnemyColor { get; private set; }
public EnemySpec Spec => new(NetworkRole, SpawnHealth, MoveSpeed, AttackDamage, AttackRange, AttackInterval, EnemyColor, NetworkScale);
public HealthComponent Vitality { get; private set; }
private SkinnedModelRenderer _healthBarModel;
public Vector3 HealthBarPosition
{
get
{
if ( !_healthBarModel.IsValid() )
foreach ( var child in GameObject.Children )
{
var renderer = child.Components.Get<SkinnedModelRenderer>();
if ( renderer.IsValid() ) { _healthBarModel = renderer; break; }
}
if ( _healthBarModel.IsValid() && _healthBarModel.TryGetBoneTransform( "head", out var head ) )
{
var model = _healthBarModel.Model;
var gap = Math.Clamp( model.Bounds.Maxs.z - model.GetBoneTransform( "head" ).Position.z + 8f, 12f, 40f );
return head.Position + Vector3.Up * gap;
}
return WorldPosition + Vector3.Up * 60f;
}
}
// Use the same bone-attached hitboxes as weapon shots.
public bool TraceHead( Vector3 origin, Vector3 direction, float range, out float distance )
{
var hit = EnemyShotTrace.Run( Scene, origin, origin + direction.Normal * range );
distance = hit.Hit ? origin.Distance( hit.HitPosition ) : 0f;
return hit.Hit && hit.GameObject == GameObject && hit.Hitbox?.Tags.Contains( "head" ) == true;
}
[Sync] public float LastDamage { get; private set; }
[Sync] public int DamageSequence { get; private set; }
[Sync] public float LastDamageAt { get; private set; } = -100f;
[Sync] private float _attackCooldown { get; set; }
[Sync] public int ChargePhase { get; private set; }
[Sync] private float _chargeTimer { get; set; }
[Sync] private Vector3 _chargeHeading { get; set; }
[Sync] private float _removeAt { get; set; }
[Sync] public float LastAttackAt { get; private set; } = -100f;
[Sync] private float _bossCastLeft { get; set; }
[Sync] private Vector3 _bossCastDirection { get; set; }
private float _repathTimer;
private float _progressTimer;
private float _stuckSeconds;
private float _strafeSign = 1f;
private bool _needsWideDetour;
private FpsPlayer _target;
private Vector3 _waypoint;
private Vector3 _progressSamplePosition;
public void Configure( EnemySpec spec )
{
SpawnHealth = spec.Health;
MoveSpeed = spec.Speed;
AttackDamage = spec.Damage;
AttackRange = spec.AttackRange;
AttackInterval = spec.AttackCooldown;
EnemyColor = spec.Color;
NetworkRole = spec.Role;
NetworkScale = spec.Scale;
}
protected override void OnStart()
{
Vitality = Components.GetOrCreate<HealthComponent>();
if ( !IsProxy ) Vitality.ResetTo( Spec.Health <= 0 ? 50f : Spec.Health );
Vitality.Damaged += OnDamaged;
Vitality.Died += OnDied;
_progressSamplePosition = WorldPosition;
}
private void OnDamaged( float amount, GameObject source )
{
LastDamage = amount;
DamageSequence++;
LastDamageAt = Time.Now;
// The shooter plays the body/head confirmation locally through ReportHit.
}
protected override void OnFixedUpdate()
{
if ( !IsProxy && _removeAt > 0 && Time.Now >= _removeAt ) { GameObject.Destroy(); return; }
if ( IsProxy || Vitality.IsDead || PrototypeGame.Instance?.Phase != RunPhase.Combat ) return;
_attackCooldown = MathF.Max( 0f, _attackCooldown - Time.Delta );
if ( _bossCastLeft > 0f )
{
_bossCastLeft -= Time.Delta;
if ( _bossCastLeft <= 0f ) FireRangedProjectile( _bossCastDirection );
return;
}
_repathTimer -= Time.Delta;
if ( _target is null || !_target.IsValid || _target.Vitality.IsDead ) AcquireTarget();
if ( _target is null ) return;
var targetDelta = (_target.WorldPosition - WorldPosition).WithZ( 0f );
var distance = targetDelta.Length;
if ( distance < 1f ) return;
var direction = targetDelta / distance;
if ( Spec.Role == EnemyRole.Charger && UpdateCharge( direction, distance ) ) return;
var melee = Spec.Role is EnemyRole.Chaser or EnemyRole.Charger;
var contactDistance = GetContactDistance();
var surfaceDistance = MathF.Max( 0f, distance - contactDistance );
// A player standing flush against a wall has an unreachable origin. Melee
// enemies instead path to a point on the near edge of the two capsules.
var navigationTarget = melee
? _target.WorldPosition - direction * (contactDistance + 5f)
: _target.WorldPosition;
if ( _repathTimer <= 0f )
{
var detourClearance = _needsWideDetour ? 96f : 0f;
_waypoint = PrototypeGame.Instance?.Dungeon?.GetWaypoint( WorldPosition, navigationTarget, 25f * Spec.Scale, detourClearance ) ?? navigationTarget;
_repathTimer = .22f;
}
var pathDelta = (_waypoint - WorldPosition).WithZ( 0f );
var desired = pathDelta.IsNearZeroLength ? Vector3.Zero : pathDelta.Normal;
if ( Spec.Role == EnemyRole.Gunner || Spec.Role == EnemyRole.Boss )
{
var strafe = new Vector3( -direction.y, direction.x, 0 ) * _strafeSign;
if ( distance < Spec.AttackRange * .55f ) desired = -direction;
else if ( distance <= Spec.AttackRange && HasLineOfSight() ) desired = strafe;
}
// Melee enemies hold striking distance instead of walking into the camera.
if ( melee && surfaceDistance <= Spec.AttackRange * .65f && HasMeleeLineOfSight() ) desired = Vector3.Zero;
AvoidAndMove( desired );
UpdateProgress( melee ? surfaceDistance : distance );
WorldRotation = Rotation.LookAt( direction, Vector3.Up );
var inAttackRange = melee ? surfaceDistance <= Spec.AttackRange : distance <= Spec.AttackRange;
var canHitTarget = melee ? HasMeleeLineOfSight() : HasLineOfSight();
if ( inAttackRange && _attackCooldown <= 0f && canHitTarget ) Attack( direction );
}
private float GetContactDistance()
{
const float playerRadius = 18f;
var enemyRadius = 20f * Spec.Scale;
return playerRadius + enemyRadius;
}
private void UpdateProgress( float targetDistance )
{
_progressTimer += Time.Delta;
if ( _progressTimer < .35f ) return;
var moved = (WorldPosition - _progressSamplePosition).WithZ( 0f ).Length;
var shouldBeMoving = targetDistance > MathF.Max( 100f, Spec.AttackRange * .8f );
if ( shouldBeMoving && moved < 14f )
{
_stuckSeconds += _progressTimer;
_repathTimer = 0f;
_strafeSign *= -1f;
if ( _stuckSeconds >= .7f ) _needsWideDetour = true;
}
else if ( moved >= 28f )
{
_stuckSeconds = 0f;
_needsWideDetour = false;
}
_progressSamplePosition = WorldPosition;
_progressTimer = 0f;
}
private void AcquireTarget()
{
_target = PrototypeGame.Instance.RunPlayers.Where( p => p.Vitality is not null && !p.Vitality.IsDead )
.OrderBy( p => p.WorldPosition.DistanceSquared( WorldPosition ) ).FirstOrDefault();
_repathTimer = 0f;
if ( Game.Random.Int( 0, 3 ) == 0 ) _strafeSign *= -1f;
}
private bool HasLineOfSight()
{
if ( _target is null ) return false;
var trace = Scene.Trace.Ray( WorldPosition + Vector3.Up * 42f, _target.EyePosition )
.WithoutTags( "enemy", "projectile" ).Run();
return !trace.Hit || trace.GameObject == _target.GameObject || trace.GameObject?.Root == _target.GameObject;
}
private bool HasMeleeLineOfSight()
{
if ( _target is null ) return false;
// Aim at the middle of the player's capsule. Eye-level rays can graze the
// wall behind a player who is pressed against it and incorrectly reject a hit.
var start = WorldPosition + Vector3.Up * 36f;
var end = _target.WorldPosition + Vector3.Up * 36f;
var trace = Scene.Trace.Sphere( 4f, start, end )
.WithoutTags( "enemy", "projectile" ).Run();
return !trace.Hit || trace.GameObject == _target.GameObject || trace.GameObject?.Root == _target.GameObject;
}
private void AvoidAndMove( Vector3 direction )
{
var separation = Vector3.Zero;
var neighbours = Scene.GetAll<EnemyBrain>().Where( e => e != this && e.IsValid() && e.Vitality?.IsDead == false ).ToArray();
foreach ( var other in neighbours )
{
var offset = (WorldPosition - other.WorldPosition).WithZ( 0 );
var spacing = 30f * (Spec.Scale + other.Spec.Scale);
if ( offset.Length >= spacing + 28f ) continue;
var away = offset.Length > .01f ? offset.Normal : (GameObject.Id.CompareTo( other.GameObject.Id ) < 0 ? Vector3.Left : Vector3.Right);
separation += away * (1f - offset.Length / (spacing + 28f));
}
var heading = direction + separation * 2.5f;
if ( heading.IsNearZeroLength ) return;
var step = heading.Normal * Spec.Speed * Time.Delta;
foreach ( var other in neighbours )
{
var before = (WorldPosition - other.WorldPosition).WithZ( 0 ).Length;
var after = (WorldPosition + step - other.WorldPosition).WithZ( 0 ).Length;
if ( after < 30f * (Spec.Scale + other.Spec.Scale) && after < before ) return;
}
var center = WorldPosition + Vector3.Up * 24f;
var trace = Scene.Trace.Sphere( 25f * Spec.Scale, center, center + step )
.WithoutTags( "enemy", "projectile" ).Run();
if ( !trace.Hit )
{
WorldPosition += step;
return;
}
// Move only up to the obstacle, then spend the remaining movement sliding
// along its surface. A second sweep prevents the slide from clipping a corner.
var safeFraction = MathF.Max( 0f, trace.Fraction - 1f / MathF.Max( step.Length, 1f ) );
WorldPosition += step * safeFraction;
var remaining = step * (1f - safeFraction);
var normal = trace.Normal.WithZ( 0f ).Normal;
var slide = remaining - normal * Vector3.Dot( remaining, normal );
if ( slide.IsNearZeroLength )
slide = new Vector3( -direction.y, direction.x, 0f ) * remaining.Length * _strafeSign;
center = WorldPosition + Vector3.Up * 24f;
var slideTrace = Scene.Trace.Sphere( 25f * Spec.Scale, center, center + slide )
.WithoutTags( "enemy", "projectile" ).Run();
WorldPosition += slide * MathF.Max( 0f, slideTrace.Fraction - .03f );
}
private bool UpdateCharge( Vector3 direction, float distance )
{
if ( ChargePhase == 0 )
{
if ( _attackCooldown > 0 || distance < 90 || distance > 500 || !HasLineOfSight() ) return false;
ChargePhase = 1; _chargeTimer = .65f; _chargeHeading = direction;
WorldRotation = Rotation.LookAt( direction, Vector3.Up );
}
_chargeTimer -= Time.Delta;
if ( ChargePhase == 1 && _chargeTimer <= 0 ) { ChargePhase = 2; _chargeTimer = .65f; }
if ( ChargePhase == 2 )
{
var step = _chargeHeading * 650f * Time.Delta;
var start = WorldPosition + Vector3.Up * 24f;
var hit = Scene.Trace.Sphere( 25f * Spec.Scale, start, start + step ).WithoutTags( "enemy", "projectile" ).Run();
WorldPosition += step * MathF.Max( 0, hit.Fraction - 1f / MathF.Max( step.Length, 1f ) );
var player = hit.GameObject?.Components.Get<FpsPlayer>() ?? hit.GameObject?.Root.Components.Get<FpsPlayer>();
if ( player is not null ) player.ReceiveDamage( Spec.Damage, GameObject );
if ( hit.Hit || _chargeTimer <= 0 ) { ChargePhase = 3; _chargeTimer = .8f; }
}
if ( ChargePhase == 3 && _chargeTimer <= 0 ) { ChargePhase = 0; _attackCooldown = 2.5f; }
return true;
}
private void Attack( Vector3 direction )
{
_attackCooldown = Spec.AttackCooldown;
LastAttackAt = Time.Now;
if ( Spec.Role is EnemyRole.Gunner or EnemyRole.Boss )
{
var origin = Spec.Role == EnemyRole.Gunner
? (Components.Get<GunnerGameplayPreview>()?.MuzzlePosition ?? WorldPosition + Vector3.Up * 42f)
: (Components.Get<BossGameplayPreview>()?.SpellOrigin ?? WorldPosition + Vector3.Up * 42f);
var aimDirection = _target is null ? direction : (_target.EyePosition - origin).Normal;
if ( Spec.Role == EnemyRole.Boss )
{
_bossCastLeft = .5f; _bossCastDirection = aimDirection;
_attackCooldown = MathF.Max( Spec.AttackCooldown, 1.4f );
}
else FireRangedProjectile( aimDirection );
}
else if ( _target is not null )
{
var centerDistance = _target.WorldPosition.Distance( WorldPosition );
var surfaceDistance = MathF.Max( 0f, centerDistance - GetContactDistance() );
if ( surfaceDistance <= Spec.AttackRange + 4f )
_target.ReceiveDamage( Spec.Damage, GameObject );
}
}
private void FireRangedProjectile( Vector3 direction )
{
var origin = Spec.Role == EnemyRole.Boss
? (Components.Get<BossGameplayPreview>()?.SpellOrigin ?? WorldPosition + Vector3.Up * 42f) + direction * 12f
: (Components.Get<GunnerGameplayPreview>()?.MuzzlePosition ?? WorldPosition + Vector3.Up * 42f + direction * 35f);
var projectile = PrototypeFactory.Disc( "Enemy Projectile", origin, 9f, 9f, Spec.Color );
projectile.Tags.Add( "projectile" );
var logic = projectile.Components.Create<Projectile>();
logic.Velocity = direction * (Spec.Role == EnemyRole.Boss ? 900f : 680f);
logic.Damage = Spec.Damage;
logic.Instigator = GameObject;
// Spawn after configuring the host simulation and its visible model.
if ( Networking.IsActive ) projectile.NetworkSpawn();
}
private void OnDied( GameObject killer )
{
if ( IsProxy || _removeAt > 0f ) return;
// Enemy death is intentionally silent.
// Team coins are awarded once by PrototypeGame.NotifyEnemyDeath.
PrototypeGame.Instance?.NotifyEnemyDeath( this );
ChargePhase = 0;
var collider = Components.Get<CapsuleCollider>();
if ( collider.IsValid() ) collider.Enabled = false;
foreach ( var hitboxes in Components.GetAll<ModelHitboxes>( FindMode.EverythingInSelfAndDescendants ) ) hitboxes.Enabled = false;
_removeAt = Time.Now + 2.5f;
}
}