Director/DemoNpc.cs

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).

NetworkingFile AccessNative Interop
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();
	}
}