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;
    }
}