MissionSpawner.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
// Produces the police response for a job.
//
// Sits dormant until MissionStartTrigger calls StartSpawning(), then feeds
// cops in continuously - a trickle at an interval rather than one big wave,
// so pressure is sustained instead of arriving all at once and then never
// again.
//
// Three separate things scale the difficulty, and they stack:
// 1. The map's own baseline, set on this component in the editor.
// 2. The job difficulty picked at the board (ActiveMission).
// 3. How many players are actually in the fight right now.
//
// All three are combined in the Effective* properties below. The editor
// values are never written to, only read - so the inspector always shows
// the map's baseline rather than whatever the last job mutated it into.
public sealed class MissionSpawner : Component
{
// The cop to clone. Should be COP.prefab: a DemoBot with CopBotAI and
// a NavMeshAgent on it.
[Property]
public GameObject CopPrefab { get; set; }
// Seconds between spawns at baseline, before difficulty and crew size
// are taken into account.
[Property]
public float SpawnInterval { get; set; } = 4f;
// Caps how many cops can be alive at once so continuous spawning
// doesn't spiral out of control over a long job.
[Property]
public int MaxActiveCops { get; set; } = 8;
// The number no amount of stacked multipliers is allowed to exceed.
//
// EffectiveMaxCops is a product of three separate numbers, two set in
// the editor and one derived from live scene state. Any one of them
// being wrong multiplies straight into the cop count, and that failure
// mode is the worst kind: the map fills with pathfinding, shooting
// bots until the game stops responding, which is hard to even diagnose
// because you can't play long enough to read the console.
//
// So this is a floor under the whole difficulty system, not a tuning
// knob. Raise it only if a map genuinely wants more than this.
[Property]
public int AbsoluteMaxCops { get; set; } = 40;
// No-spawn perimeter around every player still in the fight. Cops
// appearing in your face reads as cheating rather than difficulty, so
// this is deliberately generous - roughly 50 metres.
//
// If the whole map falls inside the perimeter, the furthest point is
// used anyway; see PoliceSpawns.Pick.
[Property]
public float MinSpawnDistanceFromPlayers { get; set; } = 2000f;
// --- Crew scaling -----------------------------------------------------
//
// Without these a four-player job is the same fight as a solo one,
// which makes bringing friends strictly easier while paying out four
// times as much.
//
// Both are expressed as "per EXTRA player" - the first player counts as
// the baseline and adds nothing - so a solo run is exactly the map's
// configured values and nothing has to be special-cased for 1 player.
// Multiplies the cop cap. 0.75 means each extra player adds 75% of the
// base cap, so 8 cops becomes 14 at two players and 26 at four.
//
// A multiplier rather than a flat "+3 per player" so that it tracks the
// map: a dense map configured for 20 cops scales proportionally instead
// of getting the same small bump a quiet 4-cop map would.
[Property]
public float MaxCopsMultiplierPerExtraPlayer { get; set; } = 0.75f;
// How close this map's cops get before they stop advancing and fight
// from where they stand. 0 leaves the cop prefab's own EngageRange
// alone (500 at the time of writing), which is what the gas station
// uses.
//
// It lives on the spawner because it's a property of the MAP, not the
// cop. At 500 a cop that can see you through the vault door plants
// its feet in the lobby and shoots from there - fine for a shop
// front, wrong for a vault the crew is meant to have to hold. Lower
// it and cops keep walking in while they shoot, which is what makes a
// room feel pushed rather than besieged.
//
// Each cop still gets its own +/- EngageRangeVariation on top, so they
// don't all stop on one ring.
[Property]
public float CopEngageRange { get; set; } = 0f;
// How much faster cops arrive per extra player, as a fraction. 0.75
// means a second player makes them arrive 1.75x as fast.
[Property]
public float SpawnRateBonusPerExtraPlayer { get; set; } = 0.75f;
// Prints what the spawner is actually doing, once every
// DebugLogInterval seconds while a job is running. Worth leaving on
// until the difficulty numbers are tuned - it's the difference between
// "cops feel wrong" and knowing the cap is 12 with 12 alive.
[Property]
public bool DebugSpawning { get; set; } = true;
[Property]
public float DebugLogInterval { get; set; } = 10f;
// --- Runtime state ----------------------------------------------------
bool _spawning = false;
// TimeSince is an s&box struct that counts UP in seconds by itself once
// assigned. Setting it to 0 means "this just happened"; comparing it
// against a duration is how you ask "has that long passed yet?".
TimeSince _timeSinceLastSpawn = 0;
TimeSince _timeSinceDebugLog = 0;
// Every cop this spawner has produced and that is still alive. Used
// only to enforce the cap - see the RemoveAll in OnUpdate for why it
// needs pruning rather than just counting.
readonly List<GameObject> _activeCops = new();
// Total ever produced this job. Only feeds the debug line, but it's
// the number that gives the game away if the cap ever fails: a job
// that has made 300 cops against a cap of 12 is a very different
// problem from one that made 12.
int _spawnedThisJob = 0;
// --- Effective values -------------------------------------------------
// Players beyond the first. Counted from players still IN THE FIGHT,
// not everyone who started: as the crew is whittled down the pressure
// drops with it, which stops a partial wipe becoming a death spiral and
// gives a lone survivor a real chance of reaching the exit.
//
// Crew.ScalingSize rather than a raw count of player-tagged objects, so
// a stray duplicate player object can't silently multiply the entire
// police response - see the comment there.
int ExtraPlayers => Crew.ScalingSize( Scene ) - 1;
// Interval is DIVIDED because a shorter interval means more cops. With
// the 0.75 default: 1 player divides by 1.0, two by 1.75, four by 3.25.
//
// Floored well above zero: this is one bad divisor away from being a
// spawn-every-frame loop, and the clamp costs nothing.
float EffectiveSpawnInterval => MathF.Max( SpawnInterval
* ActiveMission.SpawnIntervalScale
/ (1f + SpawnRateBonusPerExtraPlayer * ExtraPlayers), 0.1f );
// Both modifiers are multipliers applied to the map's own cap, so they
// compound: a 2x job run by four players is 8 * 2 * 3.25 = 52 cops.
//
// Multiplying rather than adding means both scale WITH the map - a
// dense map built for 20 cops gets a proportional increase, where a
// flat "+4" would barely register on it while overwhelming a quiet
// 4-cop map.
//
// Then clamped, because a product of three numbers is only ever as
// sane as its worst input - see AbsoluteMaxCops.
int EffectiveMaxCops => Math.Clamp( (int)(MaxActiveCops
* ActiveMission.MaxCopsMultiplier
* (1f + MaxCopsMultiplierPerExtraPlayer * ExtraPlayers)), 0, AbsoluteMaxCops );
// ----------------------------------------------------------------------
// Called by MissionStartTrigger once the player holds the interact
// button long enough to kick off the job.
public void StartSpawning()
{
_spawning = true;
_spawnedThisJob = 0;
_timeSinceDebugLog = 0;
// Pre-loading the timer with a full interval makes the first cop
// spawn immediately rather than after a wait - the response should
// feel like it starts the moment the alarm goes off.
_timeSinceLastSpawn = EffectiveSpawnInterval;
// Raw player-object counts alongside the clamped one.
//
// "crew" is clamped to the connection count, so it reads 1 whether
// there is one player object in the scene or five - which is
// exactly the case worth being able to see. Cops target the
// NEAREST active player, so a leftover player object is a cop
// getting a genuinely clear line to something that is not you,
// and firing along it.
var activePlayers = Crew.ActiveCount( Scene );
var totalPlayers = Scene.GetAllComponents<SWB.Player.PlayerBase>()
.Count( p => p.IsValid() && !p.IsBot );
Log.Info( $"Job difficulty: {ActiveMission.DifficultyName} " +
$"(spawn every {EffectiveSpawnInterval:0.0}s, up to {EffectiveMaxCops} cops, " +
$"crew {Crew.ScalingSize( Scene )}, activePlayers {activePlayers}, " +
$"totalPlayers {totalPlayers}, payout x{ActiveMission.PayoutMultiplier:0.00})" );
}
public void StopSpawning()
{
_spawning = false;
}
protected override void OnUpdate()
{
// Host spawns, everyone else receives. Without this every client
// runs its own copy of this loop and network-spawns a full set of
// cops, so a two-player game gets two squads - twice the enemies
// and twice the gunfire audio.
if ( !Networking.IsHost )
return;
if ( !_spawning )
return;
// Prunes cops that have been destroyed, so the cap counts what's
// actually on the map. IsValid() is the s&box way to test a
// destroyed object; a plain null check won't catch it, because the
// reference still points at an object that has been torn down.
//
// This is also how replacements happen. A cop that dies destroys
// itself rather than respawning in place (see CopBotAI's
// DespawnOnDeath), so the list shrinks, and the interval below
// feeds a brand new one in to take its place. Every cop on the map
// is therefore always on its first life, which is the only state
// SWB's player teardown leaves fully intact.
_activeCops.RemoveAll( go => !go.IsValid() );
LogSpawnState();
if ( _activeCops.Count >= EffectiveMaxCops )
return;
if ( _timeSinceLastSpawn < EffectiveSpawnInterval )
return;
// Reset BEFORE spawning so the next interval is measured from now.
_timeSinceLastSpawn = 0;
SpawnOneCop();
}
// One line, every DebugLogInterval seconds, and only when something
// is actually wrong.
//
// The previous version printed the same healthy numbers every ten
// seconds forever, which is worse than no logging: it buries the one
// line that matters in nine that don't. It did earn its keep once -
// playerObjects reading 174 against a single connection is what
// exposed tag inheritance counting every bone in the player model as
// a separate player - so the check stays, it just keeps quiet until
// the numbers disagree.
void LogSpawnState()
{
if ( !DebugSpawning || _timeSinceDebugLog < DebugLogInterval )
return;
_timeSinceDebugLog = 0;
var players = Crew.ActiveCount( Scene );
var connections = Connection.All?.Count ?? 0;
// One player object per connected human is the only healthy shape.
// More means something is being miscounted as a player, and every
// system that asks "where are the players" - difficulty scaling,
// the spawn perimeter, cop targeting - is being fed that answer.
if ( players > connections )
{
Log.Warning( $"[spawner] {players} player objects for {connections} connections. " +
"Something is being counted as a player that isn't one." );
}
// A cop count that has outrun its own cap is the other thing worth
// shouting about, because it compounds until the game stops.
if ( _activeCops.Count > EffectiveMaxCops )
{
Log.Warning( $"[spawner] {_activeCops.Count} cops alive against a cap of {EffectiveMaxCops} " +
$"({_spawnedThisJob} spawned this job)." );
}
}
void SpawnOneCop()
{
if ( !CopPrefab.IsValid() )
{
// Nothing to clone, and nothing is going to fix that mid-job -
// so stop rather than logging this every interval forever.
Log.Warning( "MissionSpawner: no CopPrefab assigned - spawning disabled." );
StopSpawning();
return;
}
// Picks a spawn point outside the perimeter around the crew, from
// whichever set the current phase calls for - the approaches to
// the store during the robbery, the route to the exit after it
// opens. Shared with CopBotAI so that cops respawning after being
// killed respect both rules - see PoliceSpawns.
var spawnPoint = PoliceSpawns.Pick( Scene, MinSpawnDistanceFromPlayers );
if ( spawnPoint is null )
{
Log.Warning( $"MissionSpawner: no GameObjects tagged '{PoliceSpawns.ActiveTag}' found in the scene." );
return;
}
// Clone() copies the prefab into the scene; NetworkSpawn() then
// makes it exist for every connected client. Both are needed - a
// clone that is never network-spawned is invisible to everyone but
// the host.
//
// The position passed here barely matters: PlayerBase.Respawn()
// fires automatically on spawn and moves it to a generic spawn
// point, and CopBotAI then corrects it back to a police_spawn
// point once the cop comes alive (see RelocateToPoliceSpawn).
var cop = CopPrefab.Clone( spawnPoint.WorldPosition, spawnPoint.WorldRotation );
cop.Name = $"Cop_{_spawnedThisJob}";
// Set before the cop's first life starts: CopBotAI reads
// EngageRange when it rolls its per-life personality on coming
// alive, which is a frame or more after Clone. Set on the object
// rather than per life, so it carries through every respawn of
// this cop without the spawner having to be involved again.
if ( CopEngageRange > 0f && cop.Components.Get<CopBotAI>() is { } ai )
ai.EngageRange = CopEngageRange;
// Counted BEFORE network-spawning, on purpose. NetworkSpawn
// returns false rather than throwing when it can't do its job (the
// prefab root being set to NetworkMode.Never is the usual reason),
// and a cop that never makes it into this list doesn't count
// toward the cap - which would mean unlimited spawning.
_activeCops.Add( cop );
_spawnedThisJob++;
if ( !cop.NetworkSpawn() )
{
Log.Warning( $"MissionSpawner: {cop.Name} failed to network spawn - other players " +
"won't see it. Check the cop prefab root's NetworkMode isn't set to 'Never'." );
}
}
}