PoliceSpawns.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
// Shared spawn-point picking for cops. Used by both MissionSpawner (new
// cops) and CopBotAI (cops respawning after being killed) - without both
// going through here, dead cops would keep popping back into existence
// next to the player even though fresh ones respect the perimeter.
//
// --- Two-stage response ------------------------------------------------
//
// A job has two very different shapes, and the police response should
// reflect that rather than trickling out of the same doorway for the whole
// run:
//
// Robbery phase you're dug in holding the store. Cops come from the
// HeistTag points - place these on the approaches, so
// the fight is about defending the place you're in.
//
// Escape phase the exit is open and you're moving. Cops come from the
// EscapeTag points instead - place these between the
// store and the extraction, so the run out is contested
// rather than a walk down an empty street.
//
// A point can carry BOTH tags if it should be used in both phases - tags
// are additive, so that costs nothing and needs no extra code.
//
// MissionTimer drives the switch; nothing else needs to know which phase
// it is, because Pick() reads it for everyone.
public static class PoliceSpawns
{
/// <summary>Spawn points used while the robbery is in progress.</summary>
public const string HeistTag = "police_spawn";
/// <summary>Spawn points used once the escape zone has opened.</summary>
public const string EscapeTag = "police_spawn_escape";
/// <summary>True once the crew is pushing for the exit.</summary>
public static bool EscapePhase { get; private set; }
/// <summary>Which tag Pick is currently drawing from.</summary>
public static string ActiveTag => EscapePhase ? EscapeTag : HeistTag;
// Static because it's a property of the job rather than of any one
// object, and because both callers need the same answer without having
// to be wired to each other in the editor. Reset by MissionTimer at
// the start of every job, which matters: statics outlive the scene
// load, so without that reset a second job would begin already in
// escape phase.
public static void SetPhase( bool escapePhase )
{
if ( EscapePhase == escapePhase )
return;
EscapePhase = escapePhase;
// Cheap and makes the switch legible in the console, which is
// worth it for something that changes where every subsequent cop
// comes from.
Log.Info( $"Police response now spawning from '{ActiveTag}' points." );
}
// Spawn points are placed in the editor and never move, so finding
// them once per scene is enough. Worth caching because Pick() runs on
// every cop death, and with a dozen cops fighting that's constant.
//
// Keyed by tag so the two phases don't evict each other's list every
// time the phase flips.
static readonly Dictionary<string, List<GameObject>> _pointsByTag = new();
static Scene _cachedScene;
/// <summary>
/// Drop everything cached from the current scene, and go back to the
/// robbery phase.
/// </summary>
///
/// Call this immediately BEFORE a scene change. _cachedScene, the
/// cached point lists and _lastPicked are all references into the
/// scene we are leaving, and while they are held the engine cannot
/// finish tearing it down - see the longer note on Crew.Reset.
public static void Reset()
{
_pointsByTag.Clear();
_cachedScene = null;
_lastPicked = null;
EscapePhase = false;
}
/// <summary>
/// Every point in the scene carrying the given tag. Shared list - read
/// it, don't hold onto it or modify it.
/// </summary>
public static IReadOnlyList<GameObject> All( Scene scene, string tag )
{
if ( scene is null )
return Array.Empty<GameObject>();
// A scene change invalidates everything at once - the cached
// GameObjects belong to a scene that no longer exists.
if ( _cachedScene != scene )
{
_cachedScene = scene;
_pointsByTag.Clear();
}
// Also rebuild if anything in the cached list has been destroyed,
// which is the only way the set can change mid-job. Cheaper to
// check than to rebuild every call.
if ( _pointsByTag.TryGetValue( tag, out var cached ) && cached.All( go => go.IsValid() ) )
return cached;
var points = scene.FindAllWithTag( tag ).ToList();
_pointsByTag[tag] = points;
return points;
}
/// <summary>
/// A random spawn point for the current phase, at least minDistance
/// away from every player. Null if the map has no spawn points at all.
/// </summary>
public static GameObject Pick( Scene scene, float minDistance, GameObject ignore = null )
{
var points = All( scene, ActiveTag );
// A map that hasn't had escape-phase points placed yet falls back
// to the robbery set rather than quietly going silent. Adding this
// feature shouldn't be able to break a map that already worked -
// the response just stays where it was until the points exist.
if ( points.Count == 0 && EscapePhase )
points = All( scene, HeistTag );
if ( points.Count == 0 )
return null;
var players = Crew.ActivePlayers( scene );
var cops = CopPositions( scene, ignore );
// Squared distances - avoids a square root per point per player,
// and comparisons come out the same as long as both sides are
// squared. Matters because this runs every time a cop dies.
var minDistanceSqr = minDistance * minDistance;
var scored = points
.Select( p => (
Point: p,
PlayerSqr: NearestSqr( p.WorldPosition, players ),
CopSqr: NearestSqr( p.WorldPosition, cops )) )
.ToList();
// First choice: everything outside the no-spawn perimeter.
var tier = scored.Where( s => s.PlayerSqr >= minDistanceSqr ).ToList();
if ( tier.Count == 0 )
{
// Nothing qualifies - a small map, or the crew has moved into
// the middle of the spawn points, which is exactly what
// happens once they break for the exit.
//
// This used to return the single furthest point, and that was
// the bug: it's deterministic, so from that moment on EVERY
// cop respawned on one identical spot. They arrived stacked
// inside one another, and the physics push-out that separates
// overlapping bodies shoved them through whatever was nearest
// - the store walls. It also explains why only respawns broke:
// at the start of a job the crew is at the objective, far from
// every spawn, so the perimeter is satisfied and the random
// branch above is the one that runs.
//
// Take the furthest half instead, so there is always a choice.
var ordered = scored.OrderByDescending( s => s.PlayerSqr ).ToList();
tier = ordered.Take( Math.Max( 1, ordered.Count / 2 ) ).ToList();
}
// Prefer somewhere no other cop is already standing. Checked by
// position rather than by collider, because a cop that is dead has
// had its body disabled and would trace as empty space right up
// until it respawns into you.
var unoccupied = tier.Where( s => s.CopSqr >= OccupiedRadius * OccupiedRadius ).ToList();
if ( unoccupied.Count > 0 )
tier = unoccupied;
// And don't use the same point twice running when there's an
// alternative - even with everything above satisfied, consecutive
// picks landing on one spot is what makes a squad arrive as a
// single clump.
if ( tier.Count > 1 )
{
var notLast = tier.Where( s => s.Point != _lastPicked ).ToList();
if ( notLast.Count > 0 )
tier = notLast;
}
var chosen = tier[Random.Shared.Next( tier.Count )].Point;
_lastPicked = chosen;
return chosen;
}
// How close another cop has to be for a spawn point to count as taken.
// Comfortably more than two body widths, so they arrive beside each
// other rather than inside each other.
const float OccupiedRadius = 128f;
static GameObject _lastPicked;
static float NearestSqr( Vector3 from, IReadOnlyList<Vector3> targets )
{
var nearest = float.MaxValue;
foreach ( var target in targets )
nearest = MathF.Min( nearest, from.DistanceSquared( target ) );
return nearest;
}
static float NearestSqr( Vector3 from, IReadOnlyList<GameObject> targets )
{
var nearest = float.MaxValue;
foreach ( var target in targets )
nearest = MathF.Min( nearest, from.DistanceSquared( target.WorldPosition ) );
return nearest;
}
// Every cop in the scene except the one asking. Dead ones count: they
// are about to come back, and where they come back is what we're
// trying not to collide with.
static List<Vector3> CopPositions( Scene scene, GameObject ignore )
{
var positions = new List<Vector3>();
foreach ( var cop in scene.GetAllComponents<CopBotAI>() )
{
if ( !cop.IsValid() || cop.GameObject == ignore )
continue;
positions.Add( cop.WorldPosition );
}
return positions;
}
}