Bot/BotController.cs
/// <summary>
/// Host-only AI brain. Attach alongside PlayerPawn to make it a bot.
/// Runs a simple state machine: Patrol → Chase → Attack → Evade.
/// Drives the pawn by writing InputDirection, ViewAngles, BotFirePrimary and BotWantsBoost.
/// </summary>
[Title( "Bot Controller" ), Icon( "smart_toy" )]
public sealed class BotController : Component
{
public enum BotState { Patrol, Chase, Attack, Evade }
// ── Tuning ────────────────────────────────────────────────────────────────
/// <summary>Distance at which the bot starts attacking.</summary>
[Property] public float AttackRange { get; set; } = 2200f;
/// <summary>Preferred engagement distance — bot coasts and fires from here.</summary>
[Property] public float OptimalAttackDistance { get; set; } = 1200f;
/// <summary>Distance at which the bot starts chasing a spotted target.</summary>
[Property] public float ChaseRange { get; set; } = 5000f;
/// <summary>Half-angle (degrees) within which the bot will fire.</summary>
[Property] public float FireCone { get; set; } = 22f;
/// <summary>Max distance from arena centre before the bot is forced to turn back.</summary>
[Property] public float MaxRoamDistance { get; set; } = 8000f;
/// <summary>How aggressively the bot turns toward its target (0-1 per second).</summary>
[Property] public float TurnSpeed { get; set; } = 5f;
/// <summary>Health fraction below which the bot enters evade state.</summary>
[Property] public float EvadeHealthFraction { get; set; } = 0.25f;
/// <summary>Seconds spent in evade before returning to chase.</summary>
[Property] public float EvadeDuration { get; set; } = 2.5f;
/// <summary>Whether the bot uses boost during attack runs.</summary>
[Property] public bool UseBoostOnAttack { get; set; } = true;
/// <summary>Random aim offset in degrees — higher = less accurate.</summary>
[Property, Range( 0f, 45f )] public float AimError { get; set; } = 4f;
/// <summary>How far ahead to look for walls.</summary>
[Property] public float WallAvoidDistance { get; set; } = 1200f;
/// <summary>How strongly wall avoidance deflects the steering direction.</summary>
[Property] public float WallAvoidStrength { get; set; } = 3f;
/// <summary>Delay before bots react to a newly seen/lost target.</summary>
[Property] public float ReactionTime { get; set; } = 0.3f;
/// <summary>Chance that a bot counter-attacks (instead of evading) when recently hit.</summary>
[Property, Range( 0f, 1f )] public float CounterAttackChanceOnHit { get; set; } = 0.35f;
// ── State ─────────────────────────────────────────────────────────────────
public BotState State { get; private set; } = BotState.Patrol;
private PlayerPawn _pawn;
private PlayerPawn _target;
private PlayerPawn _candidateTarget;
private Vector3 _patrolPoint;
private TimeSince _timeSincePatrolUpdate;
private TimeSince _timeSinceTargetSearch;
private TimeSince _timeSinceEvadeStart;
private Vector3 _evadeDirection;
private Vector3 _evadeBaseDirection; // away-from-attacker baseline
private TimeSince _timeSinceWeaveFlip; // how long since we last flipped the weave
private float _weaveSign = 1f; // current weave lateral sign
private float _weaveFreq; // randomised flip interval per evade
// Anti-circling: detect when distance to target stops closing
private float _lastTargetDist;
private TimeSince _timeSinceDistChanged;
private Vector3 _breakOffset;
// Stuck detection
private Vector3 _lastPosition;
private TimeSince _timeSincePositionChanged;
private Vector3 _escapeDirection;
private bool _isEscaping;
// Aim scatter — updated periodically so it doesn't jitter every frame
private Vector3 _aimScatterOffset;
private TimeSince _timeSinceAimScatterUpdate;
// Evade cooldown — prevents spam-evading on every hit
private TimeSince _timeSinceLastEvade;
private TimeSince _timeSinceLastHitReactionRoll;
// How long since we last had a live target in chase/attack range
private TimeSince _timeSinceHadTarget;
private TimeSince _timeSinceCandidateChanged;
private TimeSince _timeSinceTargetAcquired;
// How long since we last successfully fired at a target
private TimeSince _timeSinceLastFired;
private PlayerPawn _fireCandidate;
private TimeSince _timeSinceFireCandidateSeen;
// Weapon cycling
private TimeSince _timeSinceWeaponSwitch;
private float _weaponSwitchInterval;
// Strafing oscillator
private float _strafeSign = 1f;
private TimeSince _timeSinceStrafFlip;
private float _strafeFlipInterval;
// Attack sub-mode: alternate between strafing runs and hold-and-fire
private enum AttackMode { StrafeRun, HoldFire }
private AttackMode _attackMode = AttackMode.StrafeRun;
private TimeSince _timeSinceAttackModeSwitch;
private float _attackModeDuration;
// Arena centre — average of all spawn points, computed once
private Vector3 _arenaCenter;
// ── Lifecycle ─────────────────────────────────────────────────────────────
protected override void OnStart()
{
_pawn = Components.Get<PlayerPawn>( FindMode.InSelf );
PickNewPatrolPoint();
_weaponSwitchInterval = Game.Random.Float( 8f, 15f );
_strafeSign = Game.Random.Int( 0, 1 ) == 0 ? 1f : -1f;
_strafeFlipInterval = Game.Random.Float( 0.8f, 2.0f );
// Compute arena centre from spawn points so bots know where "home" is
var spawns = Game.ActiveScene?.GetAllComponents<SpawnPoint>().ToList();
if ( spawns != null && spawns.Count > 0 )
{
var sum = Vector3.Zero;
foreach ( var s in spawns ) sum += s.WorldPosition;
_arenaCenter = sum / spawns.Count;
}
}
protected override void OnFixedUpdate()
{
if ( !Networking.IsHost ) return;
if ( _pawn == null || !_pawn.IsAlive ) return;
ResolvePenetration();
DetectStuck();
CycleWeapon();
RefreshTarget();
UpdateState();
ApplySteering();
}
/// <summary>
/// Cycles through available weapons. Switches immediately when the current weapon is
/// out of ammo; otherwise rotates on a randomised interval.
/// </summary>
private void CycleWeapon()
{
if ( _pawn.Weapons.Count <= 1 ) return;
// Switch immediately if current weapon has no ammo
var currentAmmo = _pawn.ActiveWeapon?.Components.Get<AmmoComponent>();
bool outOfAmmo = currentAmmo != null && !currentAmmo.HasEnoughAmmo();
if ( outOfAmmo || _timeSinceWeaponSwitch > _weaponSwitchInterval )
{
_timeSinceWeaponSwitch = 0f;
_weaponSwitchInterval = Game.Random.Float( 8f, 15f );
// Cycle to next slot, wrapping around
var currentIndex = _pawn.Weapons.IndexOf( _pawn.ActiveWeapon );
var nextIndex = (currentIndex + 1) % _pawn.Weapons.Count;
_pawn.SetActiveWeapon( _pawn.GetSlot( nextIndex ) );
}
}
private void DetectStuck()
{
var pos = _pawn.WorldPosition;
if ( pos.Distance( _lastPosition ) > 50f )
{
_lastPosition = pos;
_timeSincePositionChanged = 0f;
_isEscaping = false;
}
else if ( _timeSincePositionChanged > 1.8f )
{
// Haven't moved — pick an escape direction away from all nearby walls
_isEscaping = true;
_timeSincePositionChanged = 0f;
_escapeDirection = FindEscapeDirection();
PickNewPatrolPoint(); // reset patrol so we don't immediately try to re-enter the stuck area
}
}
private Vector3 FindEscapeDirection()
{
var pos = _pawn.WorldPosition;
var best = _pawn.WorldRotation.Forward;
float bestClear = 0f;
// Sample 12 directions in a hemisphere and pick the one with most clearance
for ( int i = 0; i < 12; i++ )
{
var angle = i * (360f / 12f);
var yaw = MathF.Sin( angle.DegreeToRadian() );
var pitch = (i % 3 == 0) ? Game.Random.Float( -0.4f, 0.4f ) : 0f;
var dir = Rotation.FromYaw( angle ).Forward.WithZ( pitch ).Normal;
var tr = Scene.Trace.Ray( pos, pos + dir * WallAvoidDistance )
.IgnoreGameObject( _pawn.GameObject )
.WithoutTags( "player" )
.Run();
var clearance = tr.Hit ? tr.Distance : WallAvoidDistance;
if ( clearance > bestClear )
{
bestClear = clearance;
best = dir;
}
}
return best;
}
// ── Target acquisition ────────────────────────────────────────────────────
private void RefreshTarget()
{
if ( _timeSinceTargetSearch < 0.4f ) return;
_timeSinceTargetSearch = 0f;
PlayerPawn best = null;
float bestDist = float.MaxValue;
foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
{
if ( pawn == _pawn ) continue;
if ( !pawn.IsAlive ) continue;
var dist = _pawn.WorldPosition.Distance( pawn.WorldPosition );
if ( dist < bestDist )
{
bestDist = dist;
best = pawn;
}
}
// Track the latest observed candidate and only commit after ReactionTime.
// This makes bot awareness feel less instant.
if ( best != _candidateTarget )
{
_candidateTarget = best;
_timeSinceCandidateChanged = 0f;
}
if ( _timeSinceCandidateChanged >= ReactionTime && _target != _candidateTarget )
{
_target = _candidateTarget;
_timeSinceTargetAcquired = 0f;
}
// Reset the "no target" timer whenever we have someone in chase range
if ( _target != null && bestDist < ChaseRange )
_timeSinceHadTarget = 0f;
}
// ── State machine ─────────────────────────────────────────────────────────
private void UpdateState()
{
var healthFraction = _pawn.MaxHealth > 0f ? _pawn.Health / _pawn.MaxHealth : 1f;
bool recentlyHit = _pawn.TimeSinceLastDamage < 1.0f;
bool evadeReady = _timeSinceLastEvade > 4f;
bool counterOnHit = ShouldCounterAttackOnHit( recentlyHit );
switch ( State )
{
case BotState.Patrol:
if ( _target != null && DistToTarget() < ChaseRange )
SetState( BotState.Chase );
else if ( _target != null && _timeSinceHadTarget > 6f )
{
// Haven't found anyone in range for a while — go hunt them down
_timeSinceHadTarget = 0f;
SetState( BotState.Chase );
}
break;
case BotState.Chase:
if ( _target == null || DistToTarget() > ChaseRange * 1.2f )
SetState( BotState.Patrol );
else if ( counterOnHit && _target != null )
SetState( BotState.Attack );
else if ( recentlyHit && evadeReady )
SetState( BotState.Evade );
else if ( _timeSinceLastFired > 12f && evadeReady )
SetState( BotState.Evade );
else if ( DistToTarget() < AttackRange )
SetState( BotState.Attack );
break;
case BotState.Attack:
if ( _target == null || !_target.IsAlive )
{
SetState( BotState.Patrol );
break;
}
if ( counterOnHit && _target != null )
{
SetState( BotState.Attack );
break;
}
if ( recentlyHit && evadeReady )
{
SetState( BotState.Evade );
break;
}
if ( healthFraction < EvadeHealthFraction )
{
SetState( BotState.Evade );
break;
}
if ( _timeSinceLastFired > 10f && evadeReady )
{
SetState( BotState.Evade );
break;
}
if ( DistToTarget() > AttackRange * 1.5f )
SetState( BotState.Chase );
break;
case BotState.Evade:
if ( _timeSinceEvadeStart > EvadeDuration )
SetState( _target != null ? BotState.Chase : BotState.Patrol );
break;
}
}
private bool ShouldCounterAttackOnHit( bool recentlyHit )
{
if ( !recentlyHit ) return false;
// Roll once per hit window to avoid random flipping every frame.
if ( _timeSinceLastHitReactionRoll < 1.0f ) return false;
_timeSinceLastHitReactionRoll = 0f;
return Game.Random.Float() < CounterAttackChanceOnHit;
}
private void SetState( BotState newState )
{
State = newState;
if ( newState == BotState.Chase || newState == BotState.Attack )
{
_timeSinceHadTarget = 0f;
_timeSinceLastFired = 0f;
}
if ( newState == BotState.Attack )
PickAttackMode();
if ( newState == BotState.Evade )
{
_timeSinceEvadeStart = 0f;
_timeSinceLastEvade = 0f;
_timeSinceWeaveFlip = 0f;
_weaveSign = Game.Random.Int( 0, 1 ) == 0 ? 1f : -1f;
_weaveFreq = Game.Random.Float( 0.25f, 0.55f ); // flip every 0.25–0.55s
// Baseline: fly away from attacker with a random side kick
var away = _target != null
? (_pawn.WorldPosition - _target.WorldPosition).Normal
: _pawn.WorldRotation.Forward;
var perp = away.Cross( Vector3.Up ).Normal;
_evadeBaseDirection = (away + perp * Game.Random.Float( -0.4f, 0.4f )).Normal;
_evadeDirection = _evadeBaseDirection;
}
if ( newState == BotState.Patrol )
PickNewPatrolPoint();
}
// ── Steering ─────────────────────────────────────────────────────────────
private void ApplySteering()
{
Vector3 desiredForward;
Vector3 aimForward; // where the ship LOOKS (weapons aim)
float inputX = 1f; // 1 = throttle forward, 0 = coast
// Escape overrides all other steering when stuck
if ( _isEscaping )
{
desiredForward = ApplyWallAvoidance( _escapeDirection );
_pawn.BotWantsBoost = true;
_pawn.BotFirePrimary = false;
_pawn.ViewAngles = Rotation.LookAt( desiredForward, Vector3.Up ).Angles();
_pawn.InputDirection = Vector3.Forward;
return;
}
switch ( State )
{
case BotState.Patrol:
if ( _pawn.WorldPosition.Distance( _patrolPoint ) < 400f || _timeSincePatrolUpdate > 10f )
PickNewPatrolPoint();
desiredForward = (_patrolPoint - _pawn.WorldPosition).Normal;
aimForward = desiredForward;
_pawn.BotWantsBoost = false;
_pawn.BotWantsBrake = false;
_pawn.BotFirePrimary = ShouldFire();
_breakOffset = Vector3.Zero;
inputX = 1f;
break;
case BotState.Chase:
desiredForward = _target != null
? InterceptTarget( _target )
: _pawn.WorldRotation.Forward;
desiredForward = ApplyBreakOffset( desiredForward );
aimForward = desiredForward;
_pawn.BotWantsBoost = false;
_pawn.BotWantsBrake = false;
_pawn.BotFirePrimary = ShouldFire();
inputX = 1f;
break;
case BotState.Attack:
{
var dist = DistToTarget();
// Always aim at predicted target position for firing
aimForward = _target != null
? (PredictAimPosition( _target ) - _pawn.WorldPosition).Normal
: _pawn.WorldRotation.Forward;
// Switch attack mode on timer or when a strafe run passes through
if ( _timeSinceAttackModeSwitch > _attackModeDuration )
PickAttackMode();
else if ( _attackMode == AttackMode.StrafeRun && dist < 350f )
PickAttackMode(); // passed through — switch after close pass
if ( _attackMode == AttackMode.StrafeRun )
{
// Full commitment run: fly straight at target, boost, no strafe
desiredForward = _target != null ? AttackPassDirection( _target ) : aimForward;
_pawn.BotWantsBoost = UseBoostOnAttack;
_pawn.BotWantsBrake = false;
inputX = 1f;
}
else
{
// Hold-and-fire: coast at optimal distance, aim locked on target
if ( dist > OptimalAttackDistance * 1.15f )
{
desiredForward = _target != null ? AttackPassDirection( _target ) : aimForward;
_pawn.BotWantsBoost = false;
_pawn.BotWantsBrake = false;
inputX = 1f;
}
else if ( dist < OptimalAttackDistance * 0.65f )
{
desiredForward = _target != null
? (_pawn.WorldPosition - _target.WorldPosition).Normal
: aimForward;
_pawn.BotWantsBoost = false;
_pawn.BotWantsBrake = true;
inputX = 0f;
}
else
{
desiredForward = aimForward;
_pawn.BotWantsBoost = false;
_pawn.BotWantsBrake = false;
inputX = 0f;
}
}
_pawn.BotFirePrimary = ShouldFire();
_breakOffset = Vector3.Zero;
break;
}
case BotState.Evade:
if ( _timeSinceWeaveFlip > _weaveFreq )
{
_timeSinceWeaveFlip = 0f;
_weaveSign = -_weaveSign;
_weaveFreq = Game.Random.Float( 0.25f, 0.55f );
var right = _evadeBaseDirection.Cross( Vector3.Up ).Normal;
var up = right.Cross( _evadeBaseDirection ).Normal;
var lateral = right * _weaveSign * Game.Random.Float( 0.6f, 1.0f );
var vertical = up * Game.Random.Float( -0.4f, 0.4f );
_evadeDirection = (_evadeBaseDirection + lateral + vertical).Normal;
}
desiredForward = _evadeDirection;
aimForward = desiredForward;
_pawn.BotWantsBoost = true;
_pawn.BotWantsBrake = false;
_pawn.BotFirePrimary = ShouldFire();
_breakOffset = Vector3.Zero;
inputX = 1f;
break;
default:
desiredForward = _pawn.WorldRotation.Forward;
aimForward = desiredForward;
_pawn.BotFirePrimary = false;
_pawn.BotWantsBoost = false;
inputX = 1f;
break;
}
// Movement direction passes through avoidance / bias / boundary / separation
desiredForward = ApplyWallAvoidance( desiredForward );
desiredForward = ApplyEnemyBias( desiredForward );
desiredForward = ApplyBoundaryPull( desiredForward );
desiredForward = ApplySeparation( desiredForward );
// ViewAngles drives the ship's look direction (and therefore weapon aim).
// In Attack we use the raw aim direction — not the movement direction.
var lookDir = (State == BotState.Attack) ? aimForward : desiredForward;
var viewAngles = Rotation.LookAt( lookDir, Vector3.Up ).Angles();
if ( State == BotState.Attack && _target != null )
viewAngles = ApplyAimScatter( viewAngles );
_pawn.ViewAngles = viewAngles;
_pawn.InputDirection = new Vector3( inputX, ComputeStrafe(), 0f );
}
/// <summary>
/// Returns a -1..1 strafe value that periodically flips direction.
/// Only active during Chase — Attack flies straight for a clean gun run.
/// </summary>
private float ComputeStrafe()
{
if ( State != BotState.Chase ) return 0f;
if ( _timeSinceStrafFlip > _strafeFlipInterval )
{
_timeSinceStrafFlip = 0f;
_strafeSign = -_strafeSign;
_strafeFlipInterval = Game.Random.Float( 0.8f, 2.0f );
}
return _strafeSign;
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// <summary>
/// Directly corrects position when two ships overlap. Since ships pass through each
/// other in the movement trace, this is the only hard guarantee they stay separated.
/// </summary>
private void ResolvePenetration()
{
const float MinDist = 200f;
var pos = _pawn.WorldPosition;
foreach ( var other in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
{
if ( other == _pawn || !other.IsAlive ) continue;
var toOther = other.WorldPosition - pos;
var dist = toOther.Length;
if ( dist < MinDist && dist > 0.1f )
{
// Push both ships apart equally so neither teleports too far
var correction = toOther.Normal * (MinDist - dist) * 0.5f;
_pawn.WorldPosition -= correction;
other.WorldPosition += correction;
// Kill relative velocity toward each other to stop the spin
var relVel = _pawn.Velocity - other.Velocity;
var approaching = Vector3.Dot( relVel, toOther.Normal );
if ( approaching > 0f )
{
var impulse = toOther.Normal * approaching * 0.5f;
_pawn.Velocity -= impulse;
other.Velocity += impulse;
}
}
}
}
/// <summary>
/// Pushes the desired direction away from any other ship within MinSeparationDistance.
/// Prevents bots from steering into each other.
/// </summary>
private Vector3 ApplySeparation( Vector3 desired )
{
const float MinSep = 300f;
const float Strength = 4f;
var pos = _pawn.WorldPosition;
var separation = Vector3.Zero;
foreach ( var other in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
{
if ( other == _pawn || !other.IsAlive ) continue;
var toOther = other.WorldPosition - pos;
var dist = toOther.Length;
if ( dist < MinSep && dist > 0.1f )
{
var t = 1f - (dist / MinSep);
separation -= toOther.Normal * t;
}
}
if ( separation.IsNearZeroLength ) return desired;
return (desired + separation.Normal * Strength).Normal;
}
private float DistToTarget() =>
_target != null ? _pawn.WorldPosition.Distance( _target.WorldPosition ) : float.MaxValue;
/// <summary>
/// Returns the world position the bot should aim at, accounting for projectile travel time.
/// For hitscan weapons, returns the current target position (instant hit — no lead needed).
/// For projectile weapons, leads the target based on InitialForceForward speed.
/// </summary>
private Vector3 PredictAimPosition( PlayerPawn target )
{
var shoot = _pawn.ActiveWeapon?.Components.Get<ShootComponent>( FindMode.EverythingInSelfAndDescendants );
if ( shoot?.Data != null && shoot.Data.Mode == WeaponData.FiringMode.Projectile )
{
var projectileSpeed = MathF.Max( shoot.Data.InitialForceForward, 1f );
var toTarget = target.WorldPosition - _pawn.WorldPosition;
var travelTime = toTarget.Length / projectileSpeed;
return target.WorldPosition + target.Velocity * travelTime;
}
// Hitscan — aim directly at current position
return target.WorldPosition;
}
/// <summary>
/// Randomly picks the next attack sub-mode and how long to stay in it.
/// Strafe runs are shorter; hold-fire phases are longer.
/// </summary>
private void PickAttackMode()
{
_timeSinceAttackModeSwitch = 0f;
// 60 % chance of a strafe run, 40 % hold-and-fire
_attackMode = Game.Random.Float() < 0.6f ? AttackMode.StrafeRun : AttackMode.HoldFire;
_attackModeDuration = _attackMode == AttackMode.StrafeRun
? Game.Random.Float( 2.5f, 5f )
: Game.Random.Float( 3f, 7f );
}
/// <summary>
/// Intercept-pursuit for Chase: aims where the target will be.
/// No scatter — used for steering only.
/// </summary>
private Vector3 InterceptTarget( PlayerPawn target )
{
var toTarget = target.WorldPosition - _pawn.WorldPosition;
var mySpeed = MathF.Max( _pawn.Speed, 1f );
var travelTime = toTarget.Length / mySpeed;
var predicted = target.WorldPosition + target.Velocity * travelTime;
return (predicted - _pawn.WorldPosition).Normal;
}
/// <summary>
/// Attack steering: pure intercept toward predicted target position.
/// No scatter here — scatter is applied separately to ViewAngles so it only
/// affects weapon accuracy, not where the ship physically flies.
/// </summary>
private Vector3 AttackPassDirection( PlayerPawn target )
{
var toTarget = target.WorldPosition - _pawn.WorldPosition;
var mySpeed = MathF.Max( _pawn.Speed, 1f );
var travelTime = toTarget.Length / mySpeed;
var predicted = target.WorldPosition + target.Velocity * travelTime;
return (predicted - _pawn.WorldPosition).Normal;
}
/// <summary>
/// Returns a ViewAngles offset that adds persistent aim scatter.
/// Refreshed every 0.3 s so it drifts rather than jittering every frame.
/// </summary>
private Angles ApplyAimScatter( Angles baseAngles )
{
if ( _timeSinceAimScatterUpdate > 0.3f )
{
_timeSinceAimScatterUpdate = 0f;
_aimScatterOffset = AimError > 0f
? new Vector3( Game.Random.Float( -AimError, AimError ), Game.Random.Float( -AimError, AimError ), 0f )
: Vector3.Zero;
}
return new Angles(
baseAngles.pitch + _aimScatterOffset.x,
baseAngles.yaw + _aimScatterOffset.y,
baseAngles.roll );
}
/// <summary>
/// Detects if we've been at the same distance for too long (circling) and
/// injects a lateral+vertical juke offset to break out of the orbit.
/// </summary>
private Vector3 ApplyBreakOffset( Vector3 forward )
{
if ( _target == null ) return forward;
var dist = DistToTarget();
if ( MathF.Abs( dist - _lastTargetDist ) > 80f )
{
_lastTargetDist = dist;
_timeSinceDistChanged = 0f;
}
// If we've been at the same range for >1.5s, generate a juke
if ( _timeSinceDistChanged > 1.5f )
{
_timeSinceDistChanged = 0f;
var right = forward.Cross( Vector3.Up ).Normal;
var sign = Game.Random.Int( 0, 1 ) == 0 ? 1f : -1f;
_breakOffset = right * sign * 0.8f + Vector3.Up * Game.Random.Float( -0.3f, 0.3f );
}
// Decay the offset over time
_breakOffset = _breakOffset.LerpTo( Vector3.Zero, Time.Delta * 0.6f );
return (forward + _breakOffset).Normal;
}
private bool ShouldFire()
{
// Reaction delay before engaging after target acquisition/change.
if ( _target != null && _timeSinceTargetAcquired < ReactionTime )
return false;
var forward = _pawn.WorldRotation.Forward;
var pos = _pawn.WorldPosition;
PlayerPawn bestCandidate = null;
float bestAngle = float.MaxValue;
foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
{
if ( pawn == _pawn || !pawn.IsAlive ) continue;
var aimPos = (pawn == _target) ? PredictAimPosition( pawn ) : pawn.WorldPosition;
var toEnemy = aimPos - pos;
if ( toEnemy.Length > AttackRange ) continue;
var angle = MathF.Acos( toEnemy.Normal.Dot( forward ).Clamp( -1f, 1f ) ).RadianToDegree();
if ( angle < FireCone )
{
// Prefer shooting the active target; otherwise pick the closest-to-center candidate.
if ( pawn == _target )
{
bestCandidate = pawn;
break;
}
if ( angle < bestAngle )
{
bestAngle = angle;
bestCandidate = pawn;
}
}
}
if ( bestCandidate == null )
{
_fireCandidate = null;
return false;
}
// Per-shot reaction: when a candidate first enters the cone, wait ReactionTime before firing.
if ( bestCandidate != _fireCandidate )
{
_fireCandidate = bestCandidate;
_timeSinceFireCandidateSeen = 0f;
return false;
}
if ( _timeSinceFireCandidateSeen < ReactionTime )
return false;
_timeSinceLastFired = 0f;
return true;
}
/// <summary>
/// Casts rays forward and to the sides. Any wall hit within WallAvoidDistance
/// pushes the desired direction away proportional to proximity.
/// </summary>
/// <summary>
/// Blends a small pull toward the nearest enemy into any steering direction.
/// Stronger in Patrol (nothing else to do) and fades out in Attack (already aiming).
/// </summary>
private Vector3 ApplyEnemyBias( Vector3 forward )
{
if ( _target == null ) return forward;
// No bias when already directly targeting (Attack/Chase handle aim themselves)
float bias = State switch
{
BotState.Patrol => 0.35f,
BotState.Chase => 0.15f,
BotState.Evade => 0f,
_ => 0f,
};
if ( bias <= 0f ) return forward;
var toEnemy = (_target.WorldPosition - _pawn.WorldPosition).Normal;
return (forward + toEnemy * bias).Normal;
}
private Vector3 ApplyWallAvoidance( Vector3 forward )
{
var pos = _pawn.WorldPosition;
var right = forward.Cross( Vector3.Up ).Normal;
var up = right.Cross( forward ).Normal;
Span<Vector3> probes = stackalloc Vector3[]
{
forward,
(forward + right * 0.7f).Normal,
(forward - right * 0.7f).Normal,
(forward + up * 0.7f).Normal,
(forward - up * 0.7f).Normal,
};
var avoidance = Vector3.Zero;
int hitCount = 0;
foreach ( var dir in probes )
{
var tr = Scene.Trace.Ray( pos, pos + dir * WallAvoidDistance )
.IgnoreGameObject( _pawn.GameObject )
.WithoutTags( "player" )
.Run();
if ( tr.Hit )
{
var proximity = 1f - (tr.Distance / WallAvoidDistance);
avoidance += tr.Normal * proximity;
hitCount++;
}
}
if ( avoidance.LengthSquared < 0.001f )
return forward;
// If most probes hit walls we're in a corner — escape wins over desired direction
var strength = hitCount >= 4 ? WallAvoidStrength * 3f : WallAvoidStrength;
return (forward + avoidance * strength).Normal;
}
/// <summary>
/// Pulls the bot back toward the arena centre when it wanders too far.
/// At the soft edge (80 % of MaxRoamDistance) a gentle blend begins;
/// beyond MaxRoamDistance steering is overridden completely.
/// </summary>
private Vector3 ApplyBoundaryPull( Vector3 forward )
{
var dist = _pawn.WorldPosition.Distance( _arenaCenter );
if ( dist < MaxRoamDistance * 0.8f ) return forward;
var home = (_arenaCenter - _pawn.WorldPosition).Normal;
var t = ((dist - MaxRoamDistance * 0.8f) / (MaxRoamDistance * 0.2f)).Clamp( 0f, 1f );
// Past the hard limit — ignore all other steering and head straight home
if ( dist >= MaxRoamDistance )
{
_pawn.BotFirePrimary = false;
_pawn.BotWantsBoost = true;
if ( State != BotState.Patrol ) SetState( BotState.Patrol );
return home;
}
return (forward + home * t * 2f).Normal;
}
private void PickNewPatrolPoint()
{
_timeSincePatrolUpdate = 0f;
var offset = new Vector3(
Game.Random.Float( -3000f, 3000f ),
Game.Random.Float( -3000f, 3000f ),
Game.Random.Float( -600f, 600f )
);
var candidate = _pawn.WorldPosition + offset;
// Keep patrol points inside the roam boundary
var toCandidate = candidate - _arenaCenter;
if ( toCandidate.Length > MaxRoamDistance * 0.75f )
candidate = _arenaCenter + toCandidate.Normal * MaxRoamDistance * 0.75f;
_patrolPoint = candidate;
}
}