Npcs/Roller/RollerNpc.cs
using Sandbox.Npcs.Layers;
using Sandbox.Npcs.Roller.Schedules;
namespace Sandbox.Npcs.Roller;
/// <summary>
/// A physics-driven NPC that chases players, leaps at them, and bounces off dealing damage on contact.
/// </summary>
public sealed class RollerNpc : Npc, Component.IDamageable, Component.ICollisionListener
{
[Property, ClientEditable, Range( 1f, 500f ), Sync]
public float Health { get; set; } = 35f;
/// <summary>
/// Continuous force applied per-frame while rolling toward a target.
/// </summary>
[Property, Group( "Balance" )]
public float RollForce { get; set; } = 80000f;
/// <summary>
/// Torque applied per-frame to spin the sphere visually.
/// </summary>
[Property, Group( "Balance" )]
public float RollTorque { get; set; } = 40000f;
/// <summary>
/// Upward impulse applied when the rollermine gets stuck.
/// </summary>
[Property, Group( "Balance" )]
public float StuckJumpForce { get; set; } = 500f;
/// <summary>
/// Impulse applied when leaping at the target.
/// </summary>
[Property, Group( "Balance" )]
public float LeapForce { get; set; } = 60000f;
/// <summary>
/// Upward component added to the leap direction (0 = flat, 1 = 45°).
/// </summary>
[Property, Group( "Balance" )]
public float LeapUpwardBias { get; set; } = 0.2f;
/// <summary>
/// Impulse magnitude applied to self when bouncing off a surface/player.
/// </summary>
[Property, Group( "Balance" )]
public float BounceForce { get; set; } = 450f;
/// <summary>
/// Damage applied to anything we crash into.
/// </summary>
[Property, Group( "Balance" )]
public float ContactDamage { get; set; } = 20f;
/// <summary>
/// Distance at which we switch from rolling to leaping.
/// </summary>
[Property, Group( "Balance" )]
public float LeapRange { get; set; } = 160f;
/// <summary>
/// Eye child GameObject — assign in editor.
/// Rotated to face the current target each frame.
/// </summary>
[Property]
public GameObject Eye { get; set; }
/// <summary>
/// GameObject whose child ParticleEffects are enabled while actively hunting a target.
/// </summary>
[Property, Group( "Effects" )]
public GameObject HuntingEffects { get; set; }
/// <summary>
/// Prefab cloned at world position when leaping at the target.
/// </summary>
[Property, Group( "Effects" )]
public GameObject LeapEffect { get; set; }
/// <summary>
/// Prefab cloned at the contact point when hitting a player.
/// </summary>
[Property, Group( "Effects" )]
public GameObject ContactEffect { get; set; }
/// <summary>
/// Looping roll sound — pitch is driven by speed.
/// </summary>
[Property, Group( "Effects" )]
public SoundEvent RollSound { get; set; }
/// <summary>Speed (units/s) at which pitch reaches PitchMax.</summary>
[Property, Group( "Effects" )]
public float PitchSpeedMax { get; set; } = 600f;
/// <summary>Pitch at rest.</summary>
[Property, Group( "Effects" )]
public float PitchMin { get; set; } = 0.6f;
/// <summary>Pitch at full speed.</summary>
[Property, Group( "Effects" )]
public float PitchMax { get; set; } = 1.6f;
private SoundHandle _rollSound;
public Rigidbody Rigidbody { get; private set; }
[Sync] public bool IsHunting { get; private set; }
private TimeSince _lastBounce;
private const float BounceCooldown = 0.25f;
private SphereCollider _collider;
private float _baseRadius;
/// <summary>
/// Called by chase/idle schedules to toggle the hunting particle children.
/// </summary>
public void SetHunting( bool hunting )
{
if ( IsHunting == hunting ) return;
IsHunting = hunting;
if ( HuntingEffects.IsValid() )
HuntingEffects.Enabled = hunting;
if ( _collider.IsValid() )
{
_collider.Radius = hunting ? _baseRadius * 1.4f : _baseRadius;
if ( hunting && Rigidbody.IsValid() )
Rigidbody.ApplyImpulse( Vector3.Up * 50000f );
}
}
/// <summary>
/// Clones the leap effect prefab at our current position (broadcast so all clients see it).
/// </summary>
[Rpc.Broadcast]
public void BroadcastLeapEffect()
{
if ( LeapEffect is null ) return;
LeapEffect.Clone( WorldPosition );
}
/// <summary>
/// Clones the contact effect prefab at the hit position (broadcast so all clients see it).
/// </summary>
[Rpc.Broadcast]
public void BroadcastContactEffect( Vector3 position )
{
if ( ContactEffect is null ) return;
ContactEffect.Clone( position );
}
protected override void OnStart()
{
base.OnStart();
Rigidbody = GetComponent<Rigidbody>();
_collider = GetComponent<SphereCollider>();
if ( _collider.IsValid() )
_baseRadius = _collider.Radius;
if ( Rigidbody.IsValid() )
Rigidbody.MotionEnabled = true;
if ( HuntingEffects.IsValid() )
HuntingEffects.Enabled = false;
StartRollSound();
}
protected override void OnDestroy()
{
base.OnDestroy();
StopRollSound();
}
protected override void OnUpdate()
{
base.OnUpdate();
TrackEye();
UpdateRollSound();
}
public override ScheduleBase GetSchedule()
{
var target = Senses.GetNearestVisible();
if ( target.IsValid() )
return GetSchedule<RollerChaseSchedule>();
return GetSchedule<RollerIdleSchedule>();
}
void IDamageable.OnDamage( in DamageInfo damage )
{
if ( IsProxy ) return;
Health -= damage.Damage;
if ( Health <= 0f )
Die( damage );
}
protected override void Die( in DamageInfo damage )
{
GameManager.Current?.OnNpcDeath( DisplayName, damage );
// TODO: explosion effect / sound
GameObject.Destroy();
}
void ICollisionListener.OnCollisionStart( Collision collision )
{
if ( IsProxy ) return;
if ( !Rigidbody.IsValid() ) return;
if ( _lastBounce < BounceCooldown ) return;
var root = collision.Other.GameObject?.Root;
if ( !root.IsValid() ) return;
// Only react to damageable targets (players, NPCs) — not terrain/props
if ( !root.Components.TryGet( out IDamageable damageable ) )
return;
_lastBounce = 0f;
damageable.OnDamage( new DamageInfo
{
Damage = ContactDamage,
Attacker = GameObject,
Position = collision.Contact.Point,
} );
BroadcastContactEffect( collision.Contact.Point );
// Bounce up and awayfrom the player rather than a pure reflection
var away = (WorldPosition - root.WorldPosition).WithZ( 0 );
if ( away.LengthSquared < 0.01f )
away = WorldRotation.Backward.WithZ( 0 );
var bounceDir = (away.Normal + Vector3.Up * 2f).Normal;
Rigidbody.Velocity = Vector3.Zero;
Rigidbody.ApplyImpulse( bounceDir * BounceForce );
}
private void StartRollSound()
{
if ( RollSound is null ) return;
if ( _rollSound.IsValid() && !_rollSound.IsStopped ) return;
_rollSound = Sound.Play( RollSound, WorldPosition );
_rollSound.Parent = GameObject;
_rollSound.FollowParent = true;
_rollSound.Pitch = PitchMin;
}
private void StopRollSound()
{
if ( _rollSound.IsValid() )
{
_rollSound.Stop();
_rollSound = default;
}
}
private void UpdateRollSound()
{
if ( !_rollSound.IsValid() || _rollSound.IsStopped ) return;
if ( !Rigidbody.IsValid() ) return;
var speed = Rigidbody.Velocity.Length;
var t = MathX.Clamp( speed / PitchSpeedMax, 0f, 1f );
_rollSound.Pitch = MathX.Lerp( PitchMin, PitchMax, t );
}
private void TrackEye()
{
if ( !Eye.IsValid() ) return;
var target = Senses.GetNearestVisible();
if ( !target.IsValid() ) return;
var dir = (target.WorldPosition - Eye.WorldPosition).Normal;
if ( dir.LengthSquared < 0.01f ) return;
Eye.WorldRotation = Rotation.LookAt( dir, Vector3.Up );
}
}