Player/Bot/PlayerBotController.Combat.cs
using System.Threading;
public partial class PlayerBotController
{
private async Task<bool> Combat( CancellationToken token )
{
_currentTarget = FindAndUpdateTarget();
// The only way combat can fail is if we don't have an initial target
// As soon as we perfomed any action in here we consider the comhba a sucess even if it only lasted a couple of frames
if ( !_currentTarget.IsValid() || NoTarget )
{
return false;
}
while ( !token.IsCancellationRequested )
{
_currentTarget = FindAndUpdateTarget();
bool result = await RunParallel(
token,
ValidateTarget,
UpdateAim,
HandleMovement,
HandleShooting
);
if ( !result )
{
// we still return true because we at least did some combat
return true;
}
}
return true;
}
private async Task<bool> ValidateTarget( CancellationToken token )
{
while ( !token.IsCancellationRequested )
{
if ( !IsCurrentTargetValid() )
{
_currentTarget = null;
return false;
}
await Task.FixedUpdate();
}
return !token.IsCancellationRequested;
}
private async Task<bool> HandleShooting( CancellationToken token )
{
while ( !token.IsCancellationRequested )
{
// Can see target => shoot at it
if ( _lastSeenEnemies.TryGetValue( _currentTarget, out TimeSince value ) && value < 0.1f )
{
// TODO I still hate this. This needs more work.
// We either need to refactor our weapons to be more generic and have better entry points to trigger attacks,
// Or we need a way of simulating input events,
// Or we add some sor of interface to each weapon that takes over control of the bot when the weapon is equiped,
// For now just hardcode a bunch of simpler weapons.
var weaponsToTry = new List<Func<bool>>
{
() => TryShootWeapon<RpgWeapon>(w => w.Shoot(Player)),
() => TryShootWeapon<CrossbowWeapon>(w => w.Shoot(Player)),
() => TryShootWeapon<PythonWeapon>(w => w.ShootBullet(Player)),
() => TryShootWeapon<HornetGunWeapon>(w => w.Shoot(Player, HornetProjectile.FireMode.Normal)),
() => TryShootWeapon<Mp5Weapon>(w => w.ShootBullet(Player)),
() => TryShootWeapon<ShotgunWeapon>(w => w.ShootBullet(Player)),
() => TryShootWeapon<GlockWeapon>(w => w.ShootBullet(Player, false, w.PrimaryFireRate))
};
foreach ( var tryShoot in weaponsToTry )
{
if ( tryShoot() )
{
break; // Weapon fired successfully, exit the loop
}
}
}
await Task.FixedUpdate();
}
return !token.IsCancellationRequested;
}
private bool TryShootWeapon<TWeapon>( Action<TWeapon> shootAction ) where TWeapon : BaseWeapon
{
if ( Inventory.HasWeapon<TWeapon>() )
{
var weapon = Inventory.GetWeapon<TWeapon>();
if ( weapon.HasAmmo( weapon.AmmoResource ) || weapon.UsesClips && weapon.ClipContents > 0 )
{
Inventory.SwitchWeapon( weapon );
shootAction( weapon );
return true;
}
}
return false;
}
private async Task<bool> HandleMovement( CancellationToken token )
{
TimeSince lastCombatMovementUpdate = 0f;
while ( !token.IsCancellationRequested )
{
if ( lastCombatMovementUpdate > 1f )
{
// Try to pick up nearby health or armor while in combat
await TryDivertForConsumable(
token,
pickupFilter: pickup => pickup is HealthPickup or ArmourPickup
);
lastCombatMovementUpdate = 0f;
// If target not visible, chase it
if ( !_lastSeenEnemies.ContainsKey( _currentTarget ) || _lastSeenEnemies[_currentTarget] > 0.1f )
{
MeshAgent.MoveTo( _currentTarget.WorldPosition );
}
else
{
MeshAgent.MoveTo( _currentTarget.WorldPosition + Vector3.Random * 500f );
}
}
await Task.FixedUpdate();
}
return !token.IsCancellationRequested;
}
/// <summary>
/// Our Aim has two components
/// 1. Which Local Aim Target to aim at (Head, Body, etc)
/// 2. Track the global movement of the target ( WorldPosition )
/// </summary>
private async Task<bool> UpdateAim( CancellationToken token )
{
Vector3 localAimTarget = Vector3.Zero;
Vector3 newLocalAimTarget = Vector3.Zero;
TimeSince timeSinceLastLocalAimUpdate = 0;
const float localAimUpdateInterval = 1.5f;
while ( !token.IsCancellationRequested )
{
if ( timeSinceLastLocalAimUpdate > localAimUpdateInterval )
{
newLocalAimTarget = FindLocalAimTarget();
timeSinceLastLocalAimUpdate = 0;
}
// Local aim is not as snappy and rather slow
const float aimLerpFactor = 0.025f;
localAimTarget = localAimTarget.LerpTo( newLocalAimTarget, aimLerpFactor );
Vector3 worldAimTarget = _currentTarget.WorldPosition + localAimTarget;
// Clamp so we don't aim too much in to the ground
worldAimTarget = worldAimTarget.WithZ( MathF.Max( _currentTarget.WorldPosition.z + 24f, worldAimTarget.z ) );
Vector3 direction = worldAimTarget - Controller.EyePosition;
// If target hasn't been visible for a while stop tracking it
// otherwhise it looks like the bot has wallhacks. It does but we don't want people to know.
if ( _lastSeenEnemies.TryGetValue( _currentTarget, out var targetLastSeen ) && targetLastSeen > 1f )
{
worldAimTarget = MeshAgent.GetLookAhead( 30.0f ).WithZ( Controller.EyePosition.z );
}
if ( direction.LengthSquared > 0.01f * 0.01f )
{
Rotation targetRotation = Rotation.LookAt( direction.Normal );
// Adjust eye angles towards the target rotation with reaction speed bias
float lerpFactor = Random.Shared.Float( 0.1f, 0.25f ) * GameSettings.BotReactionSpeedBias;
Controller.EyeAngles = Controller.EyeAngles.LerpTo( targetRotation, lerpFactor );
}
await Task.FixedUpdate();
}
return !token.IsCancellationRequested;
}
// Let's cache these
private HitboxSet _cachedHitBoxSet;
private Dictionary<string, float> _hitboxWeights;
private Vector3 FindLocalAimTarget()
{
InitializeHitboxData();
// Reapply headshot bias
_hitboxWeights["HB_head"] = 0.6f * GameSettings.BotHeadShotBias;
float totalWeight = _hitboxWeights.Values.Sum();
float randomValue = Random.Shared.Float( 0, totalWeight );
float cumulativeWeight = 0;
foreach ( var hitbox in _cachedHitBoxSet.All )
{
cumulativeWeight += _hitboxWeights[hitbox.Name];
if ( randomValue <= cumulativeWeight )
{
if ( !_currentTarget.Controller.Renderer.TryGetBoneTransform( hitbox.Bone, out var boneTransform ) )
continue;
Vector3 localAimTarget = boneTransform.Position
+ GetRandomPointInHitbox( hitbox, 5f * (1f / MathF.Max( GameSettings.BotAccuracy, 0.1f ) ) )
- _currentTarget.Controller.Renderer.WorldPosition;
return localAimTarget;
}
}
Log.Warning( "Failed to find local aim target" );
return Vector3.Zero;
}
private Vector3 GetRandomPointInHitbox( HitboxSet.Box hitbox, float scale )
{
var shape = hitbox.Shape;
if ( shape is Sphere sphere )
{
var scaledSphere = new Sphere( sphere.Center, sphere.Radius * scale );
return scaledSphere.RandomPointInside;
}
if ( shape is BBox box )
{
var scaledBox = box * scale;
return scaledBox.RandomPointInside;
}
if ( shape is Capsule capsule )
{
var capsuleCenter = (capsule.CenterA + capsule.CenterB) * 0.5f; // Find the midpoint
var direction = capsule.CenterB - capsule.CenterA; // Get the direction vector
var halfLength = direction.Length * 0.5f; // Get half the length
var normalizedDirection = direction.Normal; // Get the normalized direction
// Scale both radius and length
var scaledRadius = capsule.Radius * scale;
var scaledHalfLength = halfLength * scale;
// Calculate new center points
var scaledCenterA = capsuleCenter - normalizedDirection * scaledHalfLength;
var scaledCenterB = capsuleCenter + normalizedDirection * scaledHalfLength;
// Create the scaled capsule
var scaledCapsule = new Capsule( scaledCenterA, scaledCenterB, scaledRadius );
return scaledCapsule.RandomPointInside;
}
throw new NotImplementedException( "Unsupported shape" );
}
private void InitializeHitboxData()
{
if ( _cachedHitBoxSet != null )
return;
_cachedHitBoxSet = _currentTarget.Controller.Renderer.Model.HitboxSet;
_hitboxWeights = new Dictionary<string, float>
{
{ "HB_pelvis", 0.2f },
{ "HB_spine_0", 0.3f },
{ "HB_spine_1", 0.4f },
{ "HB_spine_2", 0.5f },
{ "HB_neck_0", 0.6f },
{ "HB_head", 0.6f },
};
const float defaultWeight = 0.02f;
// Initialize remaining hitboxes with default weight
foreach ( var hitbox in _cachedHitBoxSet.All )
{
if ( !_hitboxWeights.ContainsKey( hitbox.Name ) )
{
_hitboxWeights[hitbox.Name] = defaultWeight;
}
}
}
}