AI/Rat.cs

A rat NPC component that handles movement, roaming, targeting players, lunging attacks, biting, lifetime and death effects. Uses NavMeshAgent for pathing, Rigidbody for physics when launched, and RPCs to play sounds and spawn death effects.

NetworkingNative Interop
/// <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();
	}
}