OMRBotController.Movement.cs
using Sandbox;
using System;
using System.Collections.Generic;
public enum OMRBotLocomotionMode
{
Idle,
Traverse,
CombatApproach,
CombatMicro,
TacticalReposition,
Flank,
Search,
Patrol,
Recovery
}
/// <summary>
/// BOT 7B fluid movement layer.
///
/// This partial does not replace OMR locomotion or NavMesh pathfinding. It makes
/// the existing synthetic-input consumer more continuous: refreshed paths keep
/// their forward progress, upcoming corners are anticipated instead of followed
/// as hard point-to-point turns, and visible combat movement is selected from
/// short local movement candidates instead of a timed left/right metronome.
/// </summary>
public sealed partial class OMRBotController
{
[Property, Group( "Movement / Fluid" )]
public bool FluidMovementEnabled { get; set; } = true;
[Property, Group( "Movement / Fluid" )]
public float FluidSteeringResponse { get; set; } = 9.0f;
[Property, Group( "Movement / Fluid" )]
public float CornerAnticipationDistance { get; set; } = 135f;
[Property, Group( "Movement / Fluid" )]
public float CornerSprintSlowDistance { get; set; } = 118f;
[Property, Group( "Movement / Fluid" )]
public float PathContinuityMatchDistance { get; set; } = 110f;
[Property, Group( "Movement / Fluid" ), Range( 0.2f, 2f )]
public float FailedPathRetryDelay { get; set; } = 0.65f;
[Property, Group( "Movement / Fluid Combat" )]
public float CombatMoveDecisionInterval { get; set; } = 0.42f;
[Property, Group( "Movement / Fluid Combat" )]
public float CombatMoveProbeDistance { get; set; } = 88f;
[Property, Group( "Movement / Fluid Combat" )]
public float CombatMoveCommitMinimum { get; set; } = 0.30f;
[Property, Sync( SyncFlags.FromHost )]
public string LocomotionStatus { get; set; } = "IDLE";
private Vector3 _fluidSteeringDirection = Vector3.Zero;
private Vector3 _previousPathSteeringPoint = Vector3.Zero;
private OMRBotLocomotionMode _locomotionMode = OMRBotLocomotionMode.Idle;
private Vector3 _combatCommittedInput = Vector3.Zero;
private string _combatCommittedLabel = "HOLD";
private float _combatCommitUntil = -1f;
private float _nextCombatMoveDecisionAt;
private float _combatCommittedScore;
private float _combatLateralTieBias;
private readonly struct CombatMoveCandidate
{
public Vector3 LocalInput { get; }
public string Label { get; }
public float BasePreference { get; }
public CombatMoveCandidate( Vector3 localInput, string label, float basePreference )
{
LocalInput = localInput;
Label = label;
BasePreference = basePreference;
}
}
private static readonly CombatMoveCandidate[] CombatMoveCandidates =
{
new( Vector3.Zero, "HOLD", 0.10f ),
new( new Vector3( 0f, -0.92f, 0f ), "LEFT", 0.24f ),
new( new Vector3( 0f, 0.92f, 0f ), "RIGHT", 0.24f ),
new( new Vector3( -0.48f, -0.78f, 0f ), "BACK-LEFT", 0.18f ),
new( new Vector3( -0.48f, 0.78f, 0f ), "BACK-RIGHT", 0.18f ),
new( new Vector3( 0.34f, -0.78f, 0f ), "FORWARD-LEFT", 0.16f ),
new( new Vector3( 0.34f, 0.78f, 0f ), "FORWARD-RIGHT", 0.16f ),
new( new Vector3( -0.72f, 0f, 0f ), "BACK", 0.08f )
};
private void ResetFluidMovementState()
{
_fluidSteeringDirection = Vector3.Zero;
_previousPathSteeringPoint = Vector3.Zero;
_locomotionMode = OMRBotLocomotionMode.Idle;
LocomotionStatus = "IDLE";
_combatCommittedInput = Vector3.Zero;
_combatCommittedLabel = "HOLD";
_combatCommitUntil = -1f;
_nextCombatMoveDecisionAt = 0f;
_combatCommittedScore = 0f;
_combatLateralTieBias = 0f;
}
private OMRBotLocomotionMode ResolveLocomotionMode(
string movingIntent,
bool visibleCombatTarget
)
{
if ( visibleCombatTarget )
return OMRBotLocomotionMode.CombatApproach;
string intent = ( movingIntent ?? string.Empty ).ToUpperInvariant();
if ( intent.Contains( "FLANK" ) )
return OMRBotLocomotionMode.Flank;
if ( intent.Contains( "SEARCH" ) || intent.Contains( "INVESTIGATE" ) )
return OMRBotLocomotionMode.Search;
if ( intent.Contains( "COVER" ) || intent.Contains( "REPOSITION" ) )
return OMRBotLocomotionMode.TacticalReposition;
if ( intent.Contains( "PATROL" ) )
return OMRBotLocomotionMode.Patrol;
if ( intent.Contains( "RECOVER" ) || intent.Contains( "BYPASS" ) )
return OMRBotLocomotionMode.Recovery;
return OMRBotLocomotionMode.Traverse;
}
private void SetLocomotionMode( OMRBotLocomotionMode mode )
{
_locomotionMode = mode;
LocomotionStatus = mode.ToString().ToUpperInvariant();
}
/// <summary>
/// A fresh NavMesh path starts near the bot each time. BOT 6.1 reset the
/// consumer to point zero, which was safe but could produce tiny direction
/// corrections on every repath. Keep the previous steering corner when the new
/// path contains a nearby equivalent, otherwise start at the first point that
/// is meaningfully ahead of the bot.
/// </summary>
private int ChoosePathContinuityIndex(
IReadOnlyList<Vector3> refreshedPoints,
Vector3 previousSteeringPoint
)
{
if ( refreshedPoints is null || refreshedPoints.Count == 0 )
return 0;
float reachDistance = Math.Clamp( LookAheadDistance * 0.42f, 24f, 42f );
int firstAhead = 0;
while (
firstAhead < refreshedPoints.Count &&
( refreshedPoints[firstAhead] - WorldPosition ).WithZ( 0f ).Length <= reachDistance
)
{
firstAhead++;
}
if ( firstAhead >= refreshedPoints.Count )
return Math.Max( 0, refreshedPoints.Count - 1 );
if ( previousSteeringPoint.IsNearlyZero( 0.01f ) )
return firstAhead;
float matchDistance = MathF.Max( 48f, PathContinuityMatchDistance );
int maxIndex = Math.Min( refreshedPoints.Count - 1, firstAhead + 3 );
int bestIndex = firstAhead;
float bestDistance = float.MaxValue;
for ( int i = firstAhead; i <= maxIndex; i++ )
{
float distance =
( refreshedPoints[i] - previousSteeringPoint )
.WithZ( 0f )
.Length;
if ( distance < bestDistance )
{
bestDistance = distance;
bestIndex = i;
}
}
if ( bestDistance > matchDistance )
return firstAhead;
if ( bestIndex > firstAhead && !IsDirectPathSteeringClear( refreshedPoints[bestIndex] ) )
return firstAhead;
return bestIndex;
}
private bool IsDirectPathSteeringClear( Vector3 point )
{
if ( Scene is null )
return false;
Vector3 flat = ( point - WorldPosition ).WithZ( 0f );
if ( flat.IsNearlyZero( 0.01f ) )
return true;
Vector3 startLow = WorldPosition + Vector3.Up * 22f;
Vector3 endLow = point.WithZ( WorldPosition.z ) + Vector3.Up * 22f;
Vector3 startHigh = WorldPosition + Vector3.Up * 54f;
Vector3 endHigh = point.WithZ( WorldPosition.z ) + Vector3.Up * 54f;
SceneTraceResult low =
Scene.Trace.Ray( startLow, endLow )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "trigger" )
.UseHitboxes( false )
.Run();
if ( low.Hit )
return false;
SceneTraceResult high =
Scene.Trace.Ray( startHigh, endHigh )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "trigger" )
.UseHitboxes( false )
.Run();
return !high.Hit;
}
private Vector3 GetFluidNavigationDirection( Vector3 steeringPoint )
{
Vector3 direct = ( steeringPoint - WorldPosition ).WithZ( 0f );
if ( direct.IsNearlyZero( 0.01f ) )
return direct;
Vector3 desired = direct.Normal;
if (
FluidMovementEnabled &&
_pathPointIndex + 1 < _pathPoints.Count
)
{
Vector3 nextSegment =
( _pathPoints[_pathPointIndex + 1] - steeringPoint )
.WithZ( 0f );
if ( !nextSegment.IsNearlyZero( 0.01f ) )
{
float distanceToCorner = direct.Length;
float anticipationDistance = GetCornerAnticipationDistance();
float anticipation =
1f - MathX.Clamp( distanceToCorner / anticipationDistance, 0f, 1f );
// Never fully abandon the current corner. This is steering anticipation,
// not a shortcut that lets the bot cut through geometry.
float blend = anticipation * 0.42f;
Vector3 anticipated =
Vector3.Lerp( desired, nextSegment.Normal, blend )
.WithZ( 0f );
if ( !anticipated.IsNearlyZero( 0.01f ) )
{
anticipated = anticipated.Normal;
if ( IsTraversalDirectionClear( anticipated ) )
desired = anticipated;
}
}
}
if ( !FluidMovementEnabled )
return desired;
desired = ApplyHumanizedPathSteering( desired );
float response = GetFluidSteeringResponse();
float follow = 1f - MathF.Exp( -response * MathF.Max( Time.Delta, 0f ) );
if ( _fluidSteeringDirection.IsNearlyZero( 0.01f ) )
{
_fluidSteeringDirection = desired;
}
else
{
float alignment = Vector3.Dot( _fluidSteeringDirection.Normal, desired );
if ( alignment < -0.20f )
{
// A genuine reversal should not spend half a second smoothing through a
// wall. Reset immediately when the route really turns back.
_fluidSteeringDirection = desired;
}
else
{
_fluidSteeringDirection =
Vector3.Lerp( _fluidSteeringDirection, desired, follow )
.WithZ( 0f );
if ( !_fluidSteeringDirection.IsNearlyZero( 0.01f ) )
_fluidSteeringDirection = _fluidSteeringDirection.Normal;
}
}
return _fluidSteeringDirection;
}
private float GetCornerAnticipationDistance()
{
float authored = MathF.Max( 64f, CornerAnticipationDistance );
return _locomotionMode switch
{
OMRBotLocomotionMode.Search => authored * 0.72f,
OMRBotLocomotionMode.TacticalReposition => authored * 0.82f,
OMRBotLocomotionMode.Flank => authored * 1.08f,
OMRBotLocomotionMode.CombatApproach => authored * 0.88f,
OMRBotLocomotionMode.Recovery => authored * 0.62f,
_ => authored
};
}
private float GetFluidSteeringResponse()
{
float authored = MathF.Max( 2f, FluidSteeringResponse );
return _locomotionMode switch
{
OMRBotLocomotionMode.Search => authored * 0.82f,
OMRBotLocomotionMode.Flank => authored * 1.06f,
OMRBotLocomotionMode.CombatApproach => authored * 1.12f,
OMRBotLocomotionMode.Recovery => authored * 1.35f,
_ => authored
};
}
private bool ShouldSlowForUpcomingCorner()
{
if (
!FluidMovementEnabled ||
_pathPointIndex >= _pathPoints.Count ||
_pathPointIndex + 1 >= _pathPoints.Count
)
return false;
Vector3 toCorner =
( _pathPoints[_pathPointIndex] - WorldPosition )
.WithZ( 0f );
if ( toCorner.Length > MathF.Max( 72f, CornerSprintSlowDistance ) )
return false;
Vector3 incoming = toCorner.Normal;
Vector3 outgoing =
( _pathPoints[_pathPointIndex + 1] - _pathPoints[_pathPointIndex] )
.WithZ( 0f );
if ( outgoing.IsNearlyZero( 0.01f ) )
return false;
float dot = Math.Clamp( Vector3.Dot( incoming, outgoing.Normal ), -1f, 1f );
return dot < 0.58f;
}
/// <summary>
/// BOT 7B local combat locomotion. This intentionally makes no strategic
/// choice such as "take cover" or "flank"; BOT 7D will own those decisions.
/// It only asks which human-equivalent movement input best serves the current
/// visible firefight for the next short commitment window.
/// </summary>
private void UpdateFluidCombatMovement(
OneMoreRoundWeapon weapon,
float distance,
float holdDistance
)
{
_pathPoints.Clear();
_pathPointIndex = 0;
_fluidSteeringDirection = Vector3.Zero;
SetLocomotionMode( OMRBotLocomotionMode.CombatMicro );
NavigationStatus = "COMBAT MICRO";
NavigationTargetName = _perceptionTarget?.DisplayName ?? "PLAYER";
NavigationTargetDistance = MathF.Round( distance );
bool needsDecision =
Time.Now >= _nextCombatMoveDecisionAt ||
Time.Now >= _combatCommitUntil ||
!IsCommittedCombatMoveStillUsable();
if ( needsDecision )
ChooseCombatMovementCandidate( weapon, distance, holdDistance );
Vector3 moveInput = _combatCommittedInput;
bool crouchDown = false;
bool jumpPressed = false;
bool sprint = false;
ApplyCombatEvasionActions(
weapon,
distance,
ref crouchDown,
ref jumpPressed
);
_movementInput.SetBotInput(
moveInput,
sprintDown: sprint,
crouchDown: crouchDown,
jumpPressed: jumpPressed
);
if ( _slide?.IsSliding == true )
{
SetMovementDiagnostics( "SLIDE" );
CurrentIntent = "COMBAT / SLIDING";
}
else if ( jumpPressed )
{
SetMovementDiagnostics( _controller.IsAirborne ? "AIR JUMP REQUEST" : "JUMP REQUEST" );
CurrentIntent = $"COMBAT / {_combatCommittedLabel} / JUMP";
}
else if ( _controller?.IsDucking == true || _crouch?.WantsCrouch == true )
{
SetMovementDiagnostics( $"CROUCH {_combatCommittedLabel}" );
CurrentIntent = $"COMBAT / CROUCH {_combatCommittedLabel}";
}
else
{
SetMovementDiagnostics( $"MICRO {_combatCommittedLabel}" );
CurrentIntent = $"COMBAT / {_combatCommittedLabel} · {_combatCommittedScore:0.00}";
}
}
private void ChooseCombatMovementCandidate(
OneMoreRoundWeapon weapon,
float distance,
float holdDistance
)
{
OMRBotWeaponDoctrine doctrine = GetWeaponDoctrine( weapon );
_combatLateralTieBias = Game.Random.Float( -0.12f, 0.12f );
float bestScore = float.MinValue;
Vector3 bestInput = Vector3.Zero;
string bestLabel = "HOLD";
foreach ( CombatMoveCandidate candidate in CombatMoveCandidates )
{
if ( !TryScoreCombatMovementCandidate(
candidate,
doctrine,
distance,
holdDistance,
out float score
) )
{
continue;
}
if ( score <= bestScore )
continue;
bestScore = score;
bestInput = candidate.LocalInput;
bestLabel = candidate.Label;
}
if ( bestScore == float.MinValue )
{
bestScore = 0f;
bestInput = Vector3.Zero;
bestLabel = "HOLD";
}
_combatCommittedInput = bestInput.ClampLength( 1f );
_combatCommittedLabel = bestLabel;
_combatCommittedScore = bestScore;
if ( MathF.Abs( _combatCommittedInput.y ) > 0.05f )
_strafeDirection = _combatCommittedInput.y >= 0f ? 1f : -1f;
float precision = GetCombatMovementPrecision( doctrine );
float mobility = MathX.Clamp( doctrine.MovementFireTolerance, 0f, 1f );
float variation = Game.Random.Float( -0.055f, 0.075f );
float interval =
(
MathF.Max( 0.20f, CombatMoveDecisionInterval ) +
precision * 0.12f -
mobility * 0.06f +
variation
) * GetHumanizedCombatDecisionIntervalScale( doctrine );
if ( Difficulty == OMRBotDifficulty.Easy )
interval *= 1.18f;
else if ( Difficulty == OMRBotDifficulty.Hard )
interval *= 0.88f;
interval = MathF.Max( MathF.Max( 0.16f, CombatMoveCommitMinimum ), interval );
_nextCombatMoveDecisionAt = Time.Now + interval;
_combatCommitUntil = Time.Now + MathF.Max( 0.16f, CombatMoveCommitMinimum );
}
private bool TryScoreCombatMovementCandidate(
CombatMoveCandidate candidate,
OMRBotWeaponDoctrine doctrine,
float currentDistance,
float holdDistance,
out float score
)
{
score = candidate.BasePreference;
Vector3 localInput = candidate.LocalInput.ClampLength( 1f );
float movementAmount = localInput.WithZ( 0f ).Length;
bool isHold = movementAmount <= 0.01f;
Vector3 predicted = WorldPosition;
Vector3 worldDirection = Vector3.Zero;
if ( !isHold )
{
Rotation basis = Rotation.FromYaw( _controller.EyeAngles.yaw );
worldDirection = ( basis * localInput ).WithZ( 0f );
if ( worldDirection.IsNearlyZero( 0.01f ) )
return false;
worldDirection = worldDirection.Normal;
if ( !IsTraversalDirectionClear( worldDirection ) )
return false;
float probeDistance =
MathF.Max( 48f, CombatMoveProbeDistance ) *
MathX.Clamp( movementAmount, 0.55f, 1f );
Vector3 raw = WorldPosition + worldDirection * probeDistance;
Vector3? projected = Scene?.NavMesh?.GetClosestPoint( raw, 58f );
if ( projected is null )
return false;
if ( ( projected.Value - raw ).WithZ( 0f ).Length > 54f )
return false;
predicted = projected.Value;
}
float predictedDistance =
( _observedTargetPosition - predicted )
.WithZ( 0f )
.Length;
float band = MathF.Max( 80f, holdDistance * 0.42f );
float distanceError = MathF.Abs( predictedDistance - holdDistance );
float distanceFitness = 1f - MathX.Clamp( distanceError / band, 0f, 1f );
score += distanceFitness * 1.10f;
float tooCloseThreshold = holdDistance * MathX.Clamp( RetreatDistanceScale, 0.35f, 0.9f );
bool tooClose = currentDistance < tooCloseThreshold;
bool tooFar = currentDistance > holdDistance * 1.05f;
if ( tooClose )
{
if ( localInput.x < -0.08f )
score += 0.70f;
else if ( localInput.x > 0.08f )
score -= 0.70f;
}
else if ( tooFar )
{
if ( localInput.x > 0.08f )
score += 0.34f * MathX.Clamp( doctrine.ClosePressureBias + 0.35f, 0.35f, 1.25f );
else if ( localInput.x < -0.08f )
score -= 0.28f;
}
float lateralAmount = MathF.Abs( localInput.y );
score += lateralAmount * 0.24f;
float movementTolerance = MathX.Clamp( doctrine.MovementFireTolerance, 0f, 1f );
float precision = GetCombatMovementPrecision( doctrine );
float matchupMovement = WeaponMatchupReasoningEnabled ? ActiveMatchup.MovementAdjustment : 0f;
if ( isHold )
{
score -= matchupMovement * 0.24f;
score += precision * 0.24f;
score += doctrine.IsPrecisionMovementWeapon ? 0.28f : 0f;
score += ( 1f - movementTolerance ) * 0.10f;
}
else
{
score += matchupMovement * movementAmount * 0.30f;
score += movementAmount * movementTolerance * 0.34f;
score += movementAmount * doctrine.ClosePressureBias * 0.14f;
score -= movementAmount * ( 1f - movementTolerance ) * 0.42f;
score -= movementAmount * precision * 0.16f;
}
// Avoid repeatedly stepping back into cells where this bot has already
// learned that movement is dangerous. This uses BOT 6.1's own-death/damage
// memory, never hidden opponent coordinates.
float learnedRisk = GetSpatialMemoryRiskAt( predicted );
score -= MathX.Clamp( learnedRisk / 8f, 0f, 1f ) * 0.42f;
// Arena-wall hugging is visually bot-like and tends to collapse its escape
// options. Penalize candidate footsteps that put the capsule right against
// nearby geometry. Cover itself is handled by the tactical cover system.
float wallClearance = MeasureWallClearance01( predicted );
if ( !isHold && wallClearance <= 0.02f )
return false;
score += wallClearance * ( isHold ? 0.08f : 0.34f );
score -= ( 1f - wallClearance ) * ( isHold ? 0.08f : 0.28f );
if ( _perceptionTarget?.IsValid == true )
{
Vector3 threatReference = _observedTargetAimPoint;
if ( threatReference.IsNearlyZero( 0.01f ) )
threatReference = _observedTargetPosition + Vector3.Up * 34f;
int coverSamples = CountBlockedThreatSamples( predicted, threatReference );
float health = GetOwnHealthFraction();
if ( coverSamples == 0 )
{
score += 0.16f;
}
else if ( coverSamples == 1 )
{
// Partial protection can be useful, especially for precision/cycle
// weapons, but don't let micro-movement replace the actual cover system.
score += doctrine.CoverCycleBias * 0.16f + ( 1f - health ) * 0.12f;
}
else
{
// Fully breaking sight during ordinary strafe is usually undesirable.
// Low health and cover-centric weapons soften the penalty; tactical cover
// selection remains responsible for deliberate disengagement.
float tolerance = doctrine.CoverCycleBias * 0.24f + ( 1f - health ) * 0.24f;
score -= MathF.Max( 0.12f, 0.62f - tolerance );
}
}
// Commitment/hysteresis: don't bounce between nearly equivalent inputs every
// scoring pass. The previous good choice receives a modest continuity bonus.
if ( candidate.Label == _combatCommittedLabel )
score += 0.10f;
ApplyCombatSequenceMovementScore(
localInput,
doctrine,
currentDistance,
holdDistance,
ref score
);
if ( !ApplyHumanizedCombatCandidateScore( localInput, wallClearance, ref score ) )
return false;
// Maintain bounded BOT 6 variation only between otherwise sensible choices.
// The tiny signed bias breaks left/right ties without overriding geometry,
// weapon accuracy, danger memory or desired combat range.
float movementVariation = MathX.Clamp( _dynamicMovementBias, -0.25f, 0.25f );
score += lateralAmount * movementVariation * 0.18f;
score += localInput.y * _combatLateralTieBias;
return true;
}
private float GetCombatMovementPrecision( OMRBotWeaponDoctrine doctrine )
{
if ( doctrine is null )
return 0.5f;
float precision = MathX.Clamp( doctrine.PrecisionBias, 0f, 1f );
if ( doctrine.IsPrecisionMovementWeapon )
precision = MathF.Max( precision, 0.78f );
return precision;
}
private bool IsCommittedCombatMoveStillUsable()
{
Vector3 localInput = _combatCommittedInput.WithZ( 0f );
if ( localInput.IsNearlyZero( 0.01f ) )
return true;
Rotation basis = Rotation.FromYaw( _controller.EyeAngles.yaw );
Vector3 worldDirection = ( basis * localInput ).WithZ( 0f );
if ( worldDirection.IsNearlyZero( 0.01f ) )
return false;
return IsTraversalDirectionClear( worldDirection.Normal );
}
}