AI/Rat.cs
/// <summary>
/// A rat NPC
/// </summary>
public sealed class Rat : Component, Component.IDamageable, IKillIcon, Component.ICollisionListener, IInstigator
{
/// <summary>
/// The <see cref="NavMeshAgent"/> which lets the rat traverse the navmesh
/// </summary>
[RequireComponent] public NavMeshAgent Agent { get; set; }
/// <summary>
/// The <see cref="Rigidbody"/> which we switch out to when jumping off of the navmesh
/// </summary>
[RequireComponent] public Rigidbody Rigidbody { get; set; }
/// <summary>
/// What should we spawn when the Rat dies?
/// </summary>
[Property] public GameObject DeathEffects { get; set; }
/// <summary>
/// What sound should we play when the Rat dies?
/// </summary>
[Property] public SoundEvent AttackSound { get; set; }
/// <summary>
/// The mouth of the rat, used for attack range
/// </summary>
[Property] public GameObject Mouth { get; set; }
/// <summary>
/// An icon to show on the kill feed when the rat kills someone
/// </summary>
[Property] Texture IKillIcon.DisplayIcon { get; set; }
/// <summary>
/// How far (sq units) away should the rat be before it starts attacking people?
/// </summary>
[Property, Feature( "Balance" )] public float AttackDistance { get; set; } = 50000f;
/// <summary>
/// How long until the rat implodes
/// </summary>
[Property, Feature( "Balance" )] public float Lifetime { get; set; } = 30f;
/// <summary>
/// How frequently should the rat choose a new roam location
/// </summary>
[Property, Feature( "Balance" )] public float RoamFrequency { get; set; } = 5f;
/// <summary>
/// How far away can the rat roam
/// </summary>
[Property, Feature( "Balance" )] public float RoamRadius { get; set; } = 1024f;
/// <summary>
/// Damage dealt when the rat bites a target
/// </summary>
[Property, Feature( "Balance" )] public float BiteDamage { get; set; } = 10f;
/// <summary>
/// Time (in seconds) between bite attacks
/// </summary>
[Property, Feature( "Balance" )] public float BiteCooldown { get; set; } = 1f;
/// <summary>
/// Time (in seconds) between lunge attacks
/// </summary>
[Property, Feature( "Balance" )] public float LungeCooldown { get; set; } = 3f;
/// <summary>
/// How often the rat thinks/updates its behavior (in seconds)
/// </summary>
[Property, Feature( "Balance" )] public float ThinkInterval { get; set; } = 0.1f;
/// <summary>
/// Distance squared for bite attack range
/// </summary>
[Property, Feature( "Balance" )] public float BiteRangeSquared { get; set; } = 5000f;
/// <summary>
/// Distance squared for target detection range
/// </summary>
[Property, Feature( "Balance" )] public float TargetDetectionRangeSquared { get; set; } = 262144f;
/// <summary>
/// Distance threshold for reaching roam target
/// </summary>
[Property, Feature( "Balance" )] public float RoamTargetThreshold { get; set; } = 32f;
/// <summary>
/// Velocity when lunging at target (X = horizontal, Y = vertical)
/// </summary>
[Property, Feature( "Balance" )] public Vector2 LungeVelocity { get; set; } = new Vector2( 512f, 256f );
/// <summary>
/// Velocity when thrown (X = horizontal, Y = vertical)
/// </summary>
[Property, Feature( "Balance" )] public Vector2 ThrowVelocity { get; set; } = new Vector2( 1024f, 50f );
/// <summary>
/// Initial think timer delay on start
/// </summary>
[Property, Feature( "Balance" )] public float InitialThinkDelay { get; set; } = 5f;
/// <summary>
/// Lunge timer when a new target is acquired
/// </summary>
[Property, Feature( "Balance" )] public float NewTargetLungeDelay { get; set; } = 1.5f;
/// <summary>
/// Height for roam area bounding box
/// </summary>
[Property, Feature( "Balance" )] public float RoamAreaHeight { get; set; } = 128f;
/// <summary>
/// Ground check distance (up and down)
/// </summary>
[Property, Feature( "Balance" )] public float GroundCheckDistance { get; set; } = 8f;
/// <summary>
/// Who's the rat's friend (the person who threw them.. not much of a friend are they)
/// </summary>
[Sync]
public PlayerData Instigator { get; set; }
/// <summary>
/// Are we on the ground?
/// </summary>
bool IsOnGround = true;
/// <summary>
/// The current target of the rat
/// </summary>
Player target;
/// <summary>
/// How many seconds has it been since the rat bit someone?
/// </summary>
TimeSince TimeSinceBitten = 0;
/// <summary>
/// How many seconds has it been since we last LUNGED
/// </summary>
TimeSince LungeTimer = 0;
/// <summary>
/// How long has the rat been alive?
/// </summary>
TimeSince TimeSinceCreated = 0;
/// <summary>
/// Time since we picked a new target
/// </summary>
TimeSince ThinkTimer = 0;
/// <summary>
/// The roam target
/// </summary>
Vector3 RoamTarget;
protected override void OnStart()
{
TimeSinceCreated = 0;
ThinkTimer = InitialThinkDelay;
}
/// <summary>
/// Trace down, see if we're hitting the ground
/// </summary>
/// <returns></returns>
private void UpdateGrounded()
{
var tr = Scene.Trace.Ray( WorldPosition + Vector3.Up * GroundCheckDistance, WorldPosition + Vector3.Down * GroundCheckDistance )
.IgnoreGameObjectHierarchy( GameObject.Root )
.Run();
IsOnGround = tr.Hit;
}
/// <summary>
/// Throws a rat in a set direction
/// </summary>
/// <param name="rotation"></param>
public void Throw( Rotation rotation )
{
var direction = rotation.Forward;
Agent.UpdatePosition = false;
WorldRotation = Rotation.LookAt( direction, Vector3.Up );
Rigidbody.Velocity = WorldRotation.Forward * ThrowVelocity.x + Vector3.Up * ThrowVelocity.y;
}
/// <summary>
/// Called when the rat either lifts off the ground, or lands
/// </summary>
void OnGroundedChanged( bool before, bool after )
{
Agent.SetAgentPosition( WorldPosition );
}
private bool hasLanded = false;
void ICollisionListener.OnCollisionStart( Collision collision )
{
// if we're thrown somewhere, hit the ground running
if ( !hasLanded )
{
Rigidbody.Velocity = Vector3.Zero;
Agent.SetAgentPosition( WorldPosition );
hasLanded = true;
}
}
void Bite( Player target )
{
TimeSinceBitten = 0;
DoAttackEffects();
var dmg = new DamageInfo( BiteDamage, Instigator?.Player?.GameObject, GameObject );
target.OnDamage( dmg );
}
protected override void OnUpdate()
{
if ( IsProxy )
return;
if ( TimeSinceCreated > Lifetime )
{
Die();
return;
}
// We're lunging, and we have a target
if ( !IsOnGround && target.IsValid() && TimeSinceBitten > BiteCooldown )
{
// Bite the target if they're close enough
if ( target.WorldPosition.DistanceSquared( Mouth.WorldPosition ) < BiteRangeSquared )
Bite( target );
}
var prevOnGround = IsOnGround;
UpdateGrounded();
if ( prevOnGround != IsOnGround )
OnGroundedChanged( prevOnGround, IsOnGround );
if ( !IsOnGround )
return;
if ( !Agent.Enabled )
return;
if ( target.IsValid() )
{
// If we haven't attacked in a while, move towards the enemy and try to jump them
if ( ThinkTimer > ThinkInterval )
{
ThinkTimer = 0;
if ( WorldPosition.DistanceSquared( target.WorldPosition ) < AttackDistance && LungeTimer > LungeCooldown )
{
Attack();
return;
}
Agent.UpdatePosition = true;
Agent.UpdateRotation = true;
Agent.MoveTo( target.WorldPosition );
}
}
else // roaming
{
if ( ThinkTimer > RoamFrequency || Vector3.DistanceBetween( WorldPosition, RoamTarget ) <= RoamTargetThreshold )
{
ThinkTimer = 0;
var target = GetRoamPoint();
if ( target.HasValue )
{
RoamTarget = target.Value;
Agent.UpdatePosition = true;
Agent.UpdateRotation = true;
Agent.MoveTo( RoamTarget );
}
}
LookForTarget();
if ( target.IsValid() )
{
// attack soon if we've just got a target
LungeTimer = NewTargetLungeDelay;
}
}
}
private Vector3? GetRoamPoint()
{
var roamSize = new Vector3( RoamRadius, RoamRadius, RoamAreaHeight );
var bbox = BBox.FromPositionAndSize( WorldPosition, new Vector3( RoamRadius, RoamRadius, RoamAreaHeight ) );
// vaguely prefer moving in the direction we're looking
bbox += ((roamSize.WithZ( 0 ) * WorldRotation.Forward) * 0.4f);
for ( int i = 0; i < 10; i++ )
{
var p = Scene.NavMesh.GetClosestPoint( bbox.RandomPointInside );
if ( !p.HasValue || p.Value.Distance( WorldPosition ) > RoamRadius )
continue;
// check if navable
var path = Scene.NavMesh.CalculatePath( new Sandbox.Navigation.CalculatePathRequest()
{
Start = WorldPosition,
Target = p.Value
} );
if ( path.Status != Sandbox.Navigation.NavMeshPathStatus.Partial && path.Status != Sandbox.Navigation.NavMeshPathStatus.Complete )
continue;
return p.Value;
}
return default;
}
/// <summary>
/// Looks for a target, closest non-friend player is the target
/// </summary>
private void LookForTarget( bool withRange = true )
{
var allPlayers = Scene.GetAllComponents<Player>()
.Where( x => Instigator.IsValid() ? x.PlayerData != Instigator : true );
if ( withRange )
allPlayers = allPlayers.Where( x => x.WorldPosition.DistanceSquared( WorldPosition ) < TargetDetectionRangeSquared );
allPlayers = allPlayers.OrderBy( x => x.WorldPosition.DistanceSquared( WorldPosition ) );
if ( allPlayers.Any() ) target = allPlayers.First();
}
void Attack()
{
// wait the full attack length
LungeTimer = 0;
DoAttackEffects();
// We want to take over the rat movement by enabling a rigidbody and just flinging them at a player
Agent.UpdatePosition = false;
Agent.UpdateRotation = false;
var dir = (target.WorldPosition - WorldPosition).WithZ( 0 ).Normal;
IsOnGround = false;
Rigidbody.Velocity = (dir * LungeVelocity.x) + (Vector3.Up * LungeVelocity.y);
WorldRotation = Rotation.LookAt( dir );
target = null;
}
[Rpc.Broadcast]
void DoDeathEffects()
{
if ( Application.IsDedicatedServer ) return;
DeathEffects?.Clone( WorldPosition );
}
[Rpc.Broadcast]
void DoAttackEffects()
{
if ( Application.IsDedicatedServer ) return;
Sound.Play( AttackSound, WorldPosition );
}
void IDamageable.OnDamage( in DamageInfo damage )
{
Die();
}
void Die()
{
DoDeathEffects();
GameObject.Destroy();
}
}