An NPC subclass called PissingGuy. It defines model, movement, vision and attack parameters, returns to a starting position using a grid A* cell navigation, finds and targets players/Doobs, plays effects/sounds when within kill range, and handles simple animation toggles and periodic actions.
using System.Linq;
using System.Threading.Tasks;
using Sandbox;
using GridAStar;
namespace BrickJam;
/// <summary>
/// Stationary-ish monster that returns to its post when it loses its target. Scene-System port of
/// legacy <c>PissingGuy</c>, now on grid A*. (Piss particle + stomp footstep deferred to effects.)
/// </summary>
[Title( "Pissing Guy" )]
[Category( "NPC" )]
public sealed partial class PissingGuy : NPC
{
public override string ModelPath { get; set; } = "models/pissing_guy/pissing_guy.vmdl";
public override float WalkSpeed { get; set; } = 100f;
public override float RunSpeed { get; set; } = 100f;
public override float MaxVisionAngle { get; set; } = 360f;
public override float MaxVisionRange { get; set; } = 200f;
public override float MaxVisionRangeWhenChasing { get; set; } = 1024f;
public override float MaxVisionAngleWhenChasing { get; set; } = 180f;
public override float MaxRememberTime { get; set; } = 3f;
public override float KillRange { get; set; } = 40f;
public override string IdleSound => "";
public override string AttackSound => "sounds/piss/pissattack.sound";
public override float AttackVolume => 3f;
public Vector3 StartingPosition { get; set; } = Vector3.Zero;
public Rotation StartingRotation { get; set; } = Rotation.Identity;
private bool fixing = true;
protected override void OnStart()
{
base.OnStart();
_ = Funny();
}
public override void ComputeIdleAndSeek()
{
if ( InVision.Count > 0 )
{
Target = InVision.Where( x => x.Key.IsValid() )
.OrderBy( x => x.Key.WorldPosition.Distance( WorldPosition ) )
.FirstOrDefault().Key;
if ( Target is Player player && player.Doob.IsValid() )
Target = player.Doob;
LastTarget = Target;
nextIdle = MansionGame.Random.NextSingle() * 3f + 3f;
}
else
{
Target = null;
}
if ( Target is null && !IsFollowingPath && StartingPosition != Vector3.Zero )
{
if ( WorldPosition.Distance( StartingPosition ) <= 30f )
{
WorldRotation = Rotation.Lerp( WorldRotation, StartingRotation, Time.Delta * 5f );
}
else
{
var targetCell = CurrentGrid?.GetCell( StartingPosition, false );
if ( targetCell != null )
NavigateTo( targetCell );
}
LastTarget = null;
}
if ( Target.IsValid() && Target.WorldPosition.Distance( WorldPosition ) <= KillRange )
{
MansionGame.Instance?.PlayEffect( "prefabs/particles/piss.prefab", Target.WorldPosition, WorldRotation );
if ( Target is Player p )
_ = CatchPlayer( p );
else if ( Target is Doob d )
_ = CatchDoob( d );
}
}
private TimeUntil nextDoor = 0f;
public override void ComputeOpenDoors()
{
if ( nextDoor )
{
base.ComputeOpenDoors();
nextDoor = MansionGame.Random.NextSingle() * 0.1f + 0.1f;
}
}
private TimeUntil nextFind = 0f;
public override void FindTargets()
{
if ( nextFind )
{
base.FindTargets();
nextFind = MansionGame.Random.NextSingle() * 0.1f + 0.1f;
}
}
// Legacy animation kick: toggle "walking" once on spawn so the animgraph settles.
private async Task Funny()
{
await Task.DelayRealtimeSeconds( 0.5f );
Body?.Set( "walking", true );
await Task.DelayRealtimeSeconds( 0.5f );
Body?.Set( "walking", false );
fixing = false;
}
private TimeUntil nextStomp = 0f;
public override void ComputeAnimations()
{
if ( !fixing )
Body?.Set( "walking", Velocity.WithZ( 0 ).Length >= 5f );
// Heavy footstep stomp while actually moving. Host-driven (Think) but broadcast so every client
// hears it (see SoundExtensions.BroadcastPlay).
if ( nextStomp && Velocity.WithZ( 0 ).Length >= 20f )
{
SoundExtensions.BroadcastPlay( "sounds/piss/pisstomp.sound", WorldPosition );
nextStomp = 0.4f;
}
}
// PissingGuy doesn't tag cells for Doob avoidance.
public override void AssignNearbyTags() { }
}