Npcs/Combat/PoliceDispatcher.cs
using System.Collections.Generic;
using Sandbox.Npcs.CombatNpc;
/// <summary>
/// Scene-placed police director — the "wanted level" brain.
///
/// Listens for <see cref="ICrimeEvents.OnCrimeReported"/> (fired when a diner is harmed) and,
/// while an APB is active, maintains a population of hostile cop NPCs that hunt the culprit.
///
/// DESIGN (agreed with the team):
/// • Maintain-N — keeps up to <see cref="MaxActiveCops"/> cops alive, replacing the fallen.
/// • Finish-the-fight — when the APB lapses we STOP spawning but let living cops finish up.
/// • Culprit-by-tag — the suspect is flagged with the "culprit" tag, so ANY taggable entity
/// (a player, or the rhino once it trips a diner) can become wanted, and
/// cops prioritise it while still engaging any player they encounter.
///
/// SPAWNING follows the exact, proven sequence in EntitySpawnerEntity:
/// Clone(StartEnabled:false) → Ownable.Set → NetworkSpawn(true,null) → Enabled = true.
///
/// PREFAB SETUP (cop.prefab)
/// ─────────────────────────
/// A CombatNpc (Friendly = false) with its usual layers + NavMeshAgent + Rigidbody,
/// a PeachLauncherWeapon wired into CombatNpc.Weapon, and InfiniteAmmo left ON so the
/// launcher never dry-fires (NPCs have no ammo inventory to draw from).
///
/// SCENE SETUP
/// ───────────
/// Drop one PoliceDispatcher on an empty GameObject. Assign CopPrefab and a handful of
/// SpawnPoints (empty GameObjects placed off-screen / at a station). Eventually this whole
/// object rides inside the police van and the van IS the mobile spawner.
/// </summary>
[Title( "Police Dispatcher" )]
[Category( "Game / Npcs" )]
public sealed class PoliceDispatcher : Component, ICrimeEvents
{
// ── Wiring ────────────────────────────────────────────────────────────────
/// <summary>The cop prefab to spawn. Root must have a <see cref="CombatNpc"/>.</summary>
[Property] public GameObject CopPrefab { get; set; }
/// <summary>Candidate spawn locations. The one nearest the suspect is used so cops converge.</summary>
[Property] public List<GameObject> SpawnPoints { get; set; } = new();
// ── Tuning ────────────────────────────────────────────────────────────────
/// <summary>How many cops are kept alive at once while the APB is active.</summary>
[Property, Range( 1, 12 )] public int MaxActiveCops { get; set; } = 4;
/// <summary>How long the APB stays up after the most recent crime. Re-armed by new crimes.</summary>
[Property, Range( 10f, 300f )] public float ApbDuration { get; set; } = 90f;
/// <summary>Minimum seconds between cop spawns while below the cap.</summary>
[Property, Range( 0.5f, 15f )] public float SpawnInterval { get; set; } = 4f;
/// <summary>Snap each spawn position onto the navmesh so the agent can path immediately.</summary>
[Property] public bool SnapToNavMesh { get; set; } = true;
// ── Runtime ─────────────────────────────────────────────────────────────────
private TimeUntil _apbExpires;
private TimeSince _timeSinceSpawn;
private GameObject _culprit;
private readonly List<CombatNpc> _activeCops = new();
/// <summary>Is there a live APB right now?</summary>
public bool ApbActive => _apbExpires > 0f;
// ── Crime reporting ──────────────────────────────────────────────────────
void ICrimeEvents.OnCrimeReported( GameObject culprit, GameObject victim )
{
if ( !Networking.IsHost ) return;
var resolved = ResolveCulprit( culprit );
if ( !resolved.IsValid() ) return;
SetCulprit( resolved );
_apbExpires = ApbDuration; // raise or refresh the wanted window
}
// ── Spawn loop ───────────────────────────────────────────────────────────
protected override void OnFixedUpdate()
{
if ( !Networking.IsHost ) return;
// Drop any cops that have died — this is what lets us "replace the fallen".
_activeCops.RemoveAll( c => !c.IsValid() );
if ( !ApbActive )
{
// APB just lapsed → stand down once. Living cops finish their current fight.
if ( _culprit.IsValid() )
StandDown();
return;
}
if ( _activeCops.Count >= MaxActiveCops ) return;
if ( _timeSinceSpawn < SpawnInterval ) return;
SpawnCop();
_timeSinceSpawn = 0f;
}
// ── Culprit handling ──────────────────────────────────────────────────────
private void SetCulprit( GameObject culprit )
{
if ( _culprit == culprit ) return;
if ( _culprit.IsValid() )
_culprit.Tags.Remove( "culprit" );
_culprit = culprit;
_culprit.Tags.Add( "culprit" );
// Re-point every existing cop at the new suspect.
foreach ( var cop in _activeCops )
{
if ( cop.IsValid() )
cop.PriorityTarget = _culprit;
}
}
private void StandDown()
{
if ( _culprit.IsValid() )
_culprit.Tags.Remove( "culprit" );
_culprit = null;
// Clear dispatch intel so cops stop hunting; they'll engage whatever's already
// in front of them, then fall back to patrol.
foreach ( var cop in _activeCops )
{
if ( cop.IsValid() )
cop.PriorityTarget = null;
}
}
// ── Spawning ──────────────────────────────────────────────────────────────
private void SpawnCop()
{
if ( !CopPrefab.IsValid() )
{
Log.Warning( "PoliceDispatcher: CopPrefab is not assigned." );
return;
}
var spawnTransform = GetSpawnTransform();
var go = CopPrefab.Clone( new CloneConfig
{
Transform = spawnTransform,
StartEnabled = false,
} );
go.Tags.Add( "removable" );
Ownable.Set( go, GameObject.Network.Owner );
go.NetworkSpawn( true, null );
go.Enabled = true;
var cop = go.GetComponent<CombatNpc>();
if ( cop.IsValid() )
{
cop.Friendly = false;
cop.PriorityTarget = _culprit;
_activeCops.Add( cop );
}
else
{
Log.Warning( "PoliceDispatcher: spawned cop has no CombatNpc component." );
}
}
private Transform GetSpawnTransform()
{
// Converge on the suspect: pick the spawn point closest to them (or to us if unknown).
var origin = _culprit.IsValid() ? _culprit.WorldPosition : WorldPosition;
GameObject best = null;
float bestDist = float.MaxValue;
foreach ( var sp in SpawnPoints )
{
if ( !sp.IsValid() ) continue;
var d = origin.Distance( sp.WorldPosition );
if ( d < bestDist )
{
bestDist = d;
best = sp;
}
}
var t = best.IsValid() ? best.WorldTransform : WorldTransform;
// Land the cop on the navmesh, otherwise its NavMeshAgent won't move.
if ( SnapToNavMesh && Scene.NavMesh is not null
&& Scene.NavMesh.GetClosestPoint( t.Position ) is { } navPos )
{
t = new Transform( navPos, t.Rotation );
}
return t;
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>
/// Walk up from the raw damage attacker to the most meaningful "responsible" object:
/// a controlling player if there is one, else an NPC / kill-source, else the object itself.
///
/// NOTE: a peach fired by a player currently attributes to the peach projectile (it carries
/// no player parent once host-spawned), so it falls through to "the object itself". If/when
/// you want peach-shooters to get wanted, stamp the firer onto PeachProjectile at spawn and
/// resolve it here.
/// </summary>
private static GameObject ResolveCulprit( GameObject attacker )
{
if ( !attacker.IsValid() ) return null;
if ( attacker.GetComponentInParent<Player>( true ) is { } player )
return player.GameObject;
if ( attacker.GetComponentInParent<IKillSource>( true ) is Component src )
return src.GameObject;
return attacker;
}
}