A game component implementing a demo NPC (Citizen) used by the Director. It handles setup (model, hitboxes, collider, nav agent, animation, health bar), movement toward a player, receiving damage, regional damage accumulation and dismemberment, ragdolling on death, and cleanup (collapse, corpse fade).
using AdaptiveDirectorDemo.Combat;
using AdaptiveDirectorDemo.UI;
using Sandbox;
using Sandbox.Citizen;
using SboxDirector;
using System;
using System.Collections.Generic;
using System.Linq;
namespace AdaptiveDirectorDemo.Director;
/// <summary>A grounded, animated, location-damageable Citizen controlled by a NavMeshAgent.</summary>
[Title( "Director Demo NPC" )]
[Category( "Adaptive Director Demo" )]
public sealed class DemoNpc : Component, Component.IDamageable
{
[Property] public EncounterKind Kind { get; set; } = EncounterKind.Ambient;
[Property] public string Archetype { get; set; } = "ambient";
[Property] public float MaximumHealth { get; set; } = 80.0f;
public float Health { get; private set; }
public float HealthFraction => MaximumHealth <= 0.0f ? 0.0f : Math.Clamp( Health / MaximumHealth, 0.0f, 1.0f );
public bool IsAlive { get; private set; } = true;
public bool IsCollapsed { get; private set; }
public string LastHitRegion { get; private set; } = "none";
public int DetachedLimbCount => _detachedRegions.Count;
private SkinnedModelRenderer _renderer;
private CitizenAnimationHelper _animation;
private NavMeshAgent _agent;
private ModelHitboxes _hitboxes;
private CapsuleCollider _collider;
private ModelPhysics _ragdoll;
private GameObject _healthBarObject;
private GameObject _player;
private DemoDirectorRuntime _runtime;
private TimeSince _sincePathRefresh;
private TimeSince _sinceDeath;
private TimeSince _sinceCollapse;
private bool _ragdollPoseCopied;
private Color _aliveTint = Color.White;
private float _configuredSpeed;
private readonly Dictionary<NpcBodyRegion, float> _regionalDamage = new();
private readonly HashSet<NpcBodyRegion> _detachedRegions = new();
private readonly HashSet<string> _hiddenBoneRoots = new();
protected override void OnStart()
{
ConfigureArchetype();
_renderer = Components.GetOrCreate<SkinnedModelRenderer>();
_renderer.Model = Model.Load( "models/citizen/citizen.vmdl" );
_renderer.Tint = _aliveTint;
_hitboxes = Components.GetOrCreate<ModelHitboxes>();
_hitboxes.Renderer = _renderer;
_hitboxes.Rebuild();
_collider = Components.GetOrCreate<CapsuleCollider>();
_collider.Start = new Vector3( 0.0f, 0.0f, 8.0f );
_collider.End = new Vector3( 0.0f, 0.0f, 66.0f );
_collider.Radius = Kind == EncounterKind.Boss ? 22.0f : 16.0f;
_collider.Static = false;
// Navigation supplies movement/avoidance. A solid capsule in front of the
// model intercepts SWB traces without hitbox data, preventing localized
// damage and gibs. ModelHitboxes remains the weapon target instead.
_collider.Enabled = false;
_agent = Components.GetOrCreate<NavMeshAgent>();
_agent.Height = Kind == EncounterKind.Boss ? 92.0f : 72.0f;
_agent.Radius = Kind == EncounterKind.Boss ? 24.0f : 18.0f;
_agent.Separation = Kind == EncounterKind.Boss ? 52.0f : 38.0f;
_agent.MaxSpeed = SpeedForKind();
_agent.Acceleration = 700.0f;
_agent.UpdatePosition = true;
_agent.UpdateRotation = true;
_agent.SetAgentPosition( WorldPosition );
_animation = Components.GetOrCreate<CitizenAnimationHelper>();
_animation.Target = _renderer;
_animation.MoveStyle = CitizenAnimationHelper.MoveStyles.Run;
_animation.HoldType = CitizenAnimationHelper.HoldTypes.None;
_animation.IsGrounded = true;
CreateHealthBar();
Components.GetOrCreate<NpcDismembermentVisual>().Owner = this;
}
protected override void OnUpdate()
{
if ( !IsAlive )
{
UpdateCorpse();
return;
}
if ( IsCollapsed )
{
UpdateCollapsed();
return;
}
if ( _player is null || !_player.IsValid || !_player.Active )
_player = Scene.GetAllObjects( false ).FirstOrDefault( item => item.Name == "Player" && item.Active );
if ( _runtime is null || !_runtime.IsValid )
_runtime = Scene.GetAllComponents<DemoDirectorRuntime>().FirstOrDefault();
if ( _player is null )
{
_agent.Stop();
_animation.WithVelocity( Vector3.Zero );
_animation.WithWishVelocity( Vector3.Zero );
return;
}
var distance = (_player.WorldPosition - WorldPosition).Length;
var contactDistance = Kind == EncounterKind.Boss ? 54.0f : 42.0f;
if ( distance <= contactDistance )
{
_agent.Stop();
_runtime?.ReportNpcThreat( this );
}
else if ( _sincePathRefresh > 0.35f )
{
_sincePathRefresh = 0.0f;
_agent.MoveTo( _player.WorldPosition );
}
_animation.IsGrounded = true;
_animation.WithVelocity( _agent.Velocity );
_animation.WithWishVelocity( _agent.WishVelocity );
}
public void OnDamage( in DamageInfo damage )
{
if ( !IsAlive || damage.Damage <= 0.0f )
return;
// SWB can legitimately report a collider hit without a model hitbox
// (capsule hits, non-hitbox traces, or a non-model collision surface). Treat
// those as ordinary health damage instead of failing the weapon Update.
var hitbox = damage.Hitbox;
var region = ClassifyRegion( hitbox is null ? null : hitbox.Bone?.Name );
LastHitRegion = region.ToString();
if ( region != NpcBodyRegion.None && !_detachedRegions.Contains( region ) )
{
_regionalDamage.TryGetValue( region, out var accumulated );
accumulated += damage.Damage;
_regionalDamage[region] = accumulated;
if ( accumulated >= DismembermentThreshold( region ) )
DetachRegion( region, damage.Position, damage.Origin, damage.Damage );
}
Health = Math.Max( 0.0f, Health - damage.Damage );
if ( Health > 0.0f )
{
// CitizenAnimationHelper.ProceduralHitReaction assumes character-controller
// state that these lightweight NavMesh NPCs intentionally do not own. Calling
// it throws through SWB's hit-scan Update path, so damage feedback remains the
// health bar and final ragdoll instead of invoking that incompatible helper.
return;
}
Die();
}
/// <summary>Developer smoke-test hook used by the autonomous editor gate.</summary>
public void SeverForDebug( string regionName )
{
if ( !IsAlive || !Enum.TryParse<NpcBodyRegion>( regionName, true, out var region ) )
return;
DetachRegion( region, WorldPosition + Vector3.Up * 44.0f, WorldPosition - WorldRotation.Forward * 80.0f, 50.0f );
}
/// <summary>Developer smoke-test hook used by the autonomous editor gate.</summary>
public void KillForDebug()
{
if ( !IsAlive )
return;
Health = 0.0f;
Die();
}
/// <summary>
/// Removes an encounter that the host has proven is no longer relevant.
/// This is intentionally different from combat death: it frees the Director's
/// population slot without leaving a ragdoll for an NPC nobody can observe.
/// </summary>
public void DespawnForDirector()
{
if ( !IsAlive )
return;
IsAlive = false;
GameObject.Destroy();
}
private void ConfigureArchetype()
{
WorldScale = Vector3.One;
switch ( Kind )
{
case EncounterKind.CommonWave:
MaximumHealth = 70.0f;
_aliveTint = new Color( 0.92f, 0.82f, 0.78f );
_configuredSpeed = 155.0f;
break;
case EncounterKind.Special:
MaximumHealth = 180.0f;
_aliveTint = new Color( 1.0f, 0.48f, 0.24f );
_configuredSpeed = 205.0f;
break;
case EncounterKind.Boss:
MaximumHealth = 650.0f;
_aliveTint = new Color( 0.52f, 0.12f, 0.08f );
WorldScale = Vector3.One * 1.28f;
_configuredSpeed = 105.0f;
break;
default:
MaximumHealth = 80.0f;
_aliveTint = new Color( 0.84f, 0.88f, 0.92f );
_configuredSpeed = 125.0f;
break;
}
// Archetypes vary gameplay numbers and silhouettes while retaining the
// Citizen rig/AnimGraph, so the demo exercises mixed encounter composition.
switch ( Archetype?.ToLowerInvariant() )
{
case "drifter":
MaximumHealth = 65.0f;
_configuredSpeed = 105.0f;
_aliveTint = new Color( 0.67f, 0.75f, 0.84f );
break;
case "shambler":
MaximumHealth = 95.0f;
_configuredSpeed = 82.0f;
WorldScale = Vector3.One * 1.06f;
_aliveTint = new Color( 0.58f, 0.68f, 0.54f );
break;
case "runner":
MaximumHealth = 55.0f;
_configuredSpeed = 205.0f;
WorldScale = Vector3.One * 0.94f;
_aliveTint = new Color( 0.92f, 0.84f, 0.78f );
break;
case "bruiser":
MaximumHealth = 115.0f;
_configuredSpeed = 112.0f;
WorldScale = Vector3.One * 1.12f;
_aliveTint = new Color( 0.68f, 0.48f, 0.42f );
break;
case "swarm":
MaximumHealth = 42.0f;
_configuredSpeed = 178.0f;
WorldScale = Vector3.One * 0.88f;
_aliveTint = new Color( 0.78f, 0.82f, 0.70f );
break;
case "stalker":
MaximumHealth = 140.0f;
_configuredSpeed = 230.0f;
WorldScale = Vector3.One * 0.96f;
_aliveTint = new Color( 0.72f, 0.28f, 0.22f );
break;
case "charger":
MaximumHealth = 260.0f;
_configuredSpeed = 176.0f;
WorldScale = Vector3.One * 1.18f;
_aliveTint = new Color( 0.90f, 0.34f, 0.16f );
break;
case "screecher":
MaximumHealth = 120.0f;
_configuredSpeed = 194.0f;
WorldScale = Vector3.One * 0.92f;
_aliveTint = new Color( 0.88f, 0.24f, 0.48f );
break;
case "juggernaut":
MaximumHealth = 850.0f;
_configuredSpeed = 104.0f;
WorldScale = Vector3.One * 1.42f;
_aliveTint = new Color( 0.44f, 0.07f, 0.05f );
break;
case "behemoth":
MaximumHealth = 1150.0f;
_configuredSpeed = 78.0f;
WorldScale = Vector3.One * 1.58f;
_aliveTint = new Color( 0.28f, 0.06f, 0.04f );
break;
}
Health = MaximumHealth;
}
private float SpeedForKind() => _configuredSpeed;
private void CreateHealthBar()
{
_healthBarObject = Scene.CreateObject( false );
_healthBarObject.Name = "NPC Health Bar";
_healthBarObject.SetParent( GameObject, false );
_healthBarObject.LocalPosition = Vector3.Up * (Kind == EncounterKind.Boss ? 108.0f : 82.0f);
var worldPanel = _healthBarObject.Components.GetOrCreate<WorldPanel>();
worldPanel.PanelSize = new Vector2( 180.0f, 22.0f );
worldPanel.RenderScale = 0.18f;
worldPanel.LookAtCamera = true;
_healthBarObject.Components.GetOrCreate<NpcHealthBar>();
_healthBarObject.Enabled = true;
}
private void Die()
{
if ( !IsAlive )
return;
IsAlive = false;
if ( _runtime is null || !_runtime.IsValid )
_runtime = Scene.GetAllComponents<DemoDirectorRuntime>().FirstOrDefault();
_runtime?.ReportNpcKilled( this );
_sinceDeath = 0.0f;
if ( _agent is not null && _agent.IsValid )
{
_agent.Stop();
_agent.Enabled = false;
}
if ( _animation is not null && _animation.IsValid )
_animation.Enabled = false;
if ( _hitboxes is not null && _hitboxes.IsValid )
_hitboxes.Enabled = false;
if ( _collider is not null && _collider.IsValid )
_collider.Enabled = false;
if ( _healthBarObject is not null && _healthBarObject.IsValid )
_healthBarObject.Enabled = false;
RestoreDismemberedBoneScales();
ApplyStableRagdollBodyGroups();
_ragdoll = Components.GetOrCreate<ModelPhysics>();
_ragdoll.Model = _renderer.Model;
_ragdoll.Renderer = _renderer;
_ragdoll.MotionEnabled = true;
_ragdoll.IgnoreRoot = false;
}
private void UpdateCollapsed()
{
ApplyDismembermentVisuals();
if ( !_ragdollPoseCopied && _ragdoll is not null && _ragdoll.PhysicsWereCreated )
{
_ragdoll.CopyBonesFrom( _renderer, true );
_ragdollPoseCopied = true;
}
if ( _sinceCollapse >= 8.0f )
{
Health = 0.0f;
Die();
}
}
private void CollapseFromLimbLoss()
{
if ( IsCollapsed )
return;
IsCollapsed = true;
_sinceCollapse = 0.0f;
_agent.Stop();
_agent.Enabled = false;
_animation.Enabled = false;
_collider.Enabled = false;
RestoreDismemberedBoneScales();
ApplyStableRagdollBodyGroups();
_ragdollPoseCopied = false;
_ragdoll = Components.GetOrCreate<ModelPhysics>();
_ragdoll.Model = _renderer.Model;
_ragdoll.Renderer = _renderer;
_ragdoll.MotionEnabled = true;
_ragdoll.IgnoreRoot = false;
}
private static NpcBodyRegion ClassifyRegion( string boneName )
{
if ( string.IsNullOrWhiteSpace( boneName ) )
return NpcBodyRegion.None;
var bone = boneName.ToLowerInvariant();
var left = bone.Contains( "_l" ) || bone.Contains( "left" );
var right = bone.Contains( "_r" ) || bone.Contains( "right" );
if ( bone.Contains( "head" ) || bone.Contains( "neck" ) ) return NpcBodyRegion.Head;
if ( bone.Contains( "foot" ) || bone.Contains( "toe" ) ) return left ? NpcBodyRegion.LeftFoot : NpcBodyRegion.RightFoot;
if ( bone.Contains( "hand" ) || bone.Contains( "finger" ) ) return left ? NpcBodyRegion.LeftHand : NpcBodyRegion.RightHand;
if ( bone.Contains( "leg" ) || bone.Contains( "thigh" ) || bone.Contains( "calf" ) ) return left ? NpcBodyRegion.LeftLeg : NpcBodyRegion.RightLeg;
if ( bone.Contains( "arm" ) || bone.Contains( "clavicle" ) ) return left ? NpcBodyRegion.LeftArm : NpcBodyRegion.RightArm;
if ( bone.Contains( "spine" ) || bone.Contains( "pelvis" ) || bone.Contains( "chest" ) ) return NpcBodyRegion.Torso;
return right ? NpcBodyRegion.RightArm : NpcBodyRegion.None;
}
private float DismembermentThreshold( NpcBodyRegion region )
{
var threshold = region switch
{
NpcBodyRegion.LeftHand or NpcBodyRegion.RightHand or NpcBodyRegion.LeftFoot or NpcBodyRegion.RightFoot => 18.0f,
NpcBodyRegion.LeftArm or NpcBodyRegion.RightArm => 28.0f,
NpcBodyRegion.LeftLeg or NpcBodyRegion.RightLeg => 34.0f,
NpcBodyRegion.Head => 45.0f,
NpcBodyRegion.Torso => 70.0f,
_ => float.MaxValue
};
return Kind == EncounterKind.Boss ? threshold * 2.2f : threshold;
}
private void DetachRegion( NpcBodyRegion region, Vector3 hitPosition, Vector3 damageOrigin, float damageAmount )
{
if ( region == NpcBodyRegion.None || _detachedRegions.Contains( region ) )
return;
_detachedRegions.Add( region );
LastHitRegion = region.ToString();
var boneRoot = BoneRootForRegion( region );
if ( !string.IsNullOrWhiteSpace( boneRoot ) )
_hiddenBoneRoots.Add( boneRoot );
var gibModel = GibModelForRegion( region );
if ( !string.IsNullOrWhiteSpace( gibModel ) )
SpawnPhysicalGib( gibModel, hitPosition, damageOrigin, damageAmount );
if ( region == NpcBodyRegion.Torso )
{
SpawnPhysicalGib( "models/citizen_gibs/models/organ_gib.vmdl", hitPosition, damageOrigin, damageAmount );
SpawnPhysicalGib( "models/citizen_gibs/models/intestine_gib.vmdl", hitPosition, damageOrigin, damageAmount );
}
ApplyDismembermentVisuals();
if ( region is NpcBodyRegion.LeftLeg or NpcBodyRegion.RightLeg or NpcBodyRegion.LeftFoot or NpcBodyRegion.RightFoot )
CollapseFromLimbLoss();
else if ( region is NpcBodyRegion.Head or NpcBodyRegion.Torso )
{
Health = 0.0f;
Die();
}
}
private void SpawnPhysicalGib( string modelPath, Vector3 hitPosition, Vector3 damageOrigin, float damageAmount )
{
var model = Model.Load( modelPath );
if ( model is null || model.IsError )
return;
var gib = Scene.CreateObject( false );
gib.Name = $"Citizen Gib - {LastHitRegion}";
gib.WorldPosition = hitPosition;
gib.WorldRotation = Rotation.Random;
gib.WorldScale = Vector3.One * Math.Clamp( WorldScale.x, 0.9f, 1.35f );
gib.Tags.Add( "gib" );
var gibRenderer = gib.Components.GetOrCreate<ModelRenderer>();
gibRenderer.Model = model;
gibRenderer.Tint = _aliveTint;
var gibCollider = gib.Components.GetOrCreate<ModelCollider>();
gibCollider.Model = model;
gibCollider.Static = false;
var rigidbody = gib.Components.GetOrCreate<Rigidbody>();
rigidbody.MotionEnabled = true;
var impulseDirection = (hitPosition - damageOrigin).Normal;
if ( impulseDirection.LengthSquared < 0.01f )
impulseDirection = WorldRotation.Forward;
rigidbody.Velocity = impulseDirection * Math.Clamp( 100.0f + damageAmount * 3.5f, 140.0f, 420.0f ) + Vector3.Up * 75.0f;
rigidbody.AngularVelocity = new Vector3( 210.0f, 135.0f, 175.0f );
gib.Components.GetOrCreate<GibLifetime>();
gib.Enabled = true;
}
private static string BoneRootForRegion( NpcBodyRegion region ) => region switch
{
NpcBodyRegion.Head => "head",
NpcBodyRegion.LeftArm => "arm_upper_L",
NpcBodyRegion.RightArm => "arm_upper_R",
NpcBodyRegion.LeftHand => "hand_L",
NpcBodyRegion.RightHand => "hand_R",
NpcBodyRegion.LeftLeg => "leg_upper_L",
NpcBodyRegion.RightLeg => "leg_upper_R",
NpcBodyRegion.LeftFoot => "foot_L",
NpcBodyRegion.RightFoot => "foot_R",
_ => ""
};
private static string GibModelForRegion( NpcBodyRegion region ) => region switch
{
NpcBodyRegion.Head => "models/citizen_gibs/models/organ_gib.vmdl",
NpcBodyRegion.Torso => "models/citizen_gibs/models/torn_torso_gib.vmdl",
NpcBodyRegion.LeftArm => "models/citizen_gibs/models/left_arm_gib_cap.vmdl",
NpcBodyRegion.RightArm => "models/citizen_gibs/models/torn_arm_gib.vmdl",
NpcBodyRegion.LeftHand or NpcBodyRegion.RightHand => "models/citizen_gibs/models/torn_hand_gib.vmdl",
NpcBodyRegion.LeftLeg or NpcBodyRegion.RightLeg => "models/citizen_gibs/models/right_leg_gib_cap.vmdl",
NpcBodyRegion.LeftFoot => "models/citizen_gibs/models/left_foot_gib_cap.vmdl",
NpcBodyRegion.RightFoot => "models/citizen_gibs/models/right_foot_gib_cap.vmdl",
_ => ""
};
public void ApplyDismembermentVisuals()
{
if ( _renderer is null || !_renderer.IsValid )
return;
// Citizen vertices are smoothly weighted across adjacent bones. Scaling a
// limb bone cannot create a hard cut and instead stretches the shared seam.
// Citizen mesh bodygroups provide deformation-free removal while the matching
// physical gib communicates the specific side and region that was severed.
ApplyStableRagdollBodyGroups();
}
private void RestoreDismemberedBoneScales()
{
if ( _renderer is null || !_renderer.IsValid || _renderer.Model is null )
return;
foreach ( var boneName in _hiddenBoneRoots )
{
var bone = _renderer.Model.Bones.GetBone( boneName );
if ( bone is null || !_renderer.TryGetBoneTransformLocal( bone, out var boneTransform ) )
continue;
boneTransform.Scale = Vector3.One;
_renderer.SetBoneTransform( bone, boneTransform );
}
}
private void ApplyStableRagdollBodyGroups()
{
if ( _renderer is null || !_renderer.IsValid )
return;
if ( _detachedRegions.Contains( NpcBodyRegion.Head ) )
_renderer.SetBodyGroup( "Head", 1 );
if ( _detachedRegions.Contains( NpcBodyRegion.Torso ) )
_renderer.SetBodyGroup( "Chest", 1 );
if ( _detachedRegions.Any( region => region is NpcBodyRegion.LeftArm or NpcBodyRegion.RightArm or NpcBodyRegion.LeftHand or NpcBodyRegion.RightHand ) )
_renderer.SetBodyGroup( "Hands", 1 );
if ( _detachedRegions.Any( region => region is NpcBodyRegion.LeftLeg or NpcBodyRegion.RightLeg ) )
_renderer.SetBodyGroup( "Legs", 1 );
if ( _detachedRegions.Any( region => region is NpcBodyRegion.LeftFoot or NpcBodyRegion.RightFoot ) )
_renderer.SetBodyGroup( "Feet", 1 );
}
private void UpdateCorpse()
{
if ( !_ragdollPoseCopied && _ragdoll is not null && _ragdoll.PhysicsWereCreated )
{
_ragdoll.CopyBonesFrom( _renderer, true );
_ragdollPoseCopied = true;
}
if ( _sinceDeath < 15.0f )
return;
if ( _ragdoll is not null )
_ragdoll.MotionEnabled = false;
var fade = Math.Clamp( (_sinceDeath - 15.0f) / 2.0f, 0.0f, 1.0f );
_renderer.Tint = _aliveTint.WithAlpha( 1.0f - fade );
if ( fade >= 1.0f )
GameObject.Destroy();
}
}