OMRBotController.CombatContinuity.cs
using Sandbox;
using System.Collections.Generic;
using System;
/// <summary>BOT 9: aim, trigger and cover movement share one contact policy.</summary>
public sealed partial class OMRBotController
{
[Property, Group( "BOT 9 / Tracking" ), Range( 0f, 0.25f )]
public float VisibleTrackingPredictionLimit { get; set; } = 0.16f;
[Property, Group( "BOT 9 / Tracking" ), Range( 0.1f, 1f )]
public float CombatFacingMemoryDuration { get; set; } = 0.65f;
[Property, Group( "BOT 9 / Cover" ), Range( 72f, 240f )]
public float MaximumExposedCoverApproach { get; set; } = 160f;
private bool _automaticTriggerCommitted;
private OneMoreRoundWeapon _automaticTrackingWeapon;
private float _lastTrackingAimAt = -999f;
private float _coverTravelStartedAt = -1f;
private float _nextCoverShortlistAt;
private Vector3 _coverShortlistOrigin;
private Vector3 _coverShortlistThreat;
private readonly List<(Vector3 Point, float Priority)> _coverShortlist = new();
private static readonly float[] CoverSearchAngles = { -86f, 86f, -58f, 58f, -32f, 32f, 0f, -118f, 118f, 155f, -155f };
private void ResetCombatContinuity()
{
_automaticTriggerCommitted = false;
_automaticTrackingWeapon = null;
_lastTrackingAimAt = -999f;
_coverTravelStartedAt = -1f;
_coverShortlist.Clear();
_nextCoverShortlistAt = 0f;
}
private bool HasCombatFacingEvidence() => _hasConfirmedSight ||
( HasLastKnownPosition && Time.Now - _lastConfirmedSeenAt <= MathF.Max( 0.1f, CombatFacingMemoryDuration ) );
private Vector3 ResolveTrackedAimPoint( Vector3 observedAim )
{
// Visible sample extrapolation compensates recognition-sampling and follow
// lag. Hidden-contact retention intentionally continues to use the old point.
if ( !_hasConfirmedSight || _previousObservedAt < 0f ||
( observedAim - _observedTargetAimPoint ).Length > 2f ) return observedAim;
float age = MathF.Max( 0f, Time.Now - _previousObservedAt );
float follow = MathF.Max( 1f, GetAimFollowSpeed() );
float skill = Difficulty switch
{
OMRBotDifficulty.Easy => 0.22f,
OMRBotDifficulty.Hard => 0.90f,
_ => 0.65f
};
float prediction = MathF.Min( MathF.Max( 0f, VisibleTrackingPredictionLimit ), age + skill / follow );
return observedAim + _lastSeenVelocity.ClampLength( 500f ) * prediction;
}
private bool AllowAutomaticTrackingFire( OneMoreRoundWeapon weapon, float acquireTolerance )
{
if ( _automaticTrackingWeapon != weapon || Time.Now - _lastTrackingAimAt > 0.25f )
_automaticTriggerCommitted = false;
_automaticTrackingWeapon = weapon;
_lastTrackingAimAt = Time.Now;
float releaseTolerance = acquireTolerance * ( _automaticTriggerCommitted ? 1.55f : 1f );
if ( CombatAimError > releaseTolerance )
{
_automaticTriggerCommitted = false;
return false;
}
_automaticTriggerCommitted = true;
return true;
}
private bool IsCoverApproachSafe( OMRBotRouteEvaluation route )
{
if ( !_hasConfirmedSight ) return true;
float maxExposure = MathF.Max( 72f, MaximumExposedCoverApproach );
if ( route.DetourRatio > 2f || route.FirstProtectionDistance < 0f ||
route.FirstProtectionDistance > maxExposure ) return false;
return route.ExposureFraction <= 0.80f || route.FirstProtectionDistance <= 96f;
}
private bool UpdateCoverTravel( OneMoreRoundWeapon weapon, float distance )
{
if ( _coverTravelStartedAt < 0f ) _coverTravelStartedAt = Time.Now;
if ( Time.Now - _coverTravelStartedAt > 3f || NavigationStatus == "LOCAL BLOCKED" ||
( NavigationStatus?.StartsWith( "NO PATH" ) ?? false ) || NavigationStatus == "NO START POINT" )
{
ClearCoverPlan( "APPROACH FAILED / REENGAGE" );
_utilityCoverFailureUntil = Time.Now + 0.75f;
_nextUtilityDecisionAt = MathF.Min( _nextUtilityDecisionAt, Time.Now + 0.12f );
return false;
}
bool hasVisual = _hasConfirmedSight && _perceptionTarget?.IsAlive == true;
bool faceThreat = HasCombatFacingEvidence();
float enemyDistance = ( _observedTargetPosition - WorldPosition ).WithZ( 0f ).Length;
bool wantsAds = hasVisual && ShouldUseAds( weapon, enemyDistance );
if ( hasVisual ) UpdateCombatAim( _observedTargetAimPoint );
else if ( faceThreat ) LookTowardWorldPoint( _observedTargetAimPoint, 7f );
else if ( HasRecentThreat() ) OrientTowardThreat();
CombatWantsAim = wantsAds;
weapon?.SetBotAimIntent( wantsAds );
NavigateToWorldPosition(
_coverTarget, "TACTICAL COVER", MathF.Max( 28f, CoverArrivalDistance ),
$"TACTICAL COVER / {_coverReason}",
allowSprint: !faceThreat && !HasRecentThreat() && distance >= 210f,
faceMovement: !faceThreat && !HasRecentThreat(),
advancedMovement: true, movementWeapon: weapon, visibleCombatTarget: hasVisual
);
// Retreating is a footwork decision, not permission to stop fighting. Real
// reload/cycle/ammo gates still decide whether a shot is physically possible.
if ( hasVisual ) UpdateVisibleWeaponAttack( weapon, enemyDistance, wantsAds );
else CombatStatus = weapon?.IsReloading == true ? "RELOAD / MOVING TO COVER" : "BREAKING CONTACT";
TacticalStatus = $"SEEK COVER / {_coverReason}";
CoverStatus = $"MOVING {_coverPosture.ToString().ToUpperInvariant()} · {distance:0}u";
return true;
}
}