MissionSpawner.cs
using Sandbox;
using System;
using System.Collections.Generic;
// 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 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;
Log.Info( $"Job difficulty: {ActiveMission.DifficultyName} " +
$"(spawn every {EffectiveSpawnInterval:0.0}s, up to {EffectiveMaxCops} cops, " +
$"crew {Crew.ScalingSize( Scene )}, 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.
//
// Note that a cop dying does NOT destroy it - SWB ragdolls it and
// respawns the same GameObject two seconds later - so in practice
// this list settles at the cap and stays there.
_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. The numbers to read:
//
// alive/cap if alive sits at cap, difficulty is doing what it
// says. If alive climbs past cap, the cap is broken.
// total how many have ever been made this job. Should stay
// close to alive, since cops respawn rather than
// being replaced.
// crew what difficulty is scaling off. Should equal the
// number of people actually playing.
// playerObjects raw count of player-tagged objects still in the
// fight. If this is bigger than connections, something
// is leaving player objects behind - that's what
// would inflate the whole police response.
void LogSpawnState()
{
if ( !DebugSpawning || _timeSinceDebugLog < DebugLogInterval )
return;
_timeSinceDebugLog = 0;
Log.Info( $"[spawner] alive={_activeCops.Count}/{EffectiveMaxCops} " +
$"total={_spawnedThisJob} " +
$"interval={EffectiveSpawnInterval:0.00}s " +
$"crew={Crew.ScalingSize( Scene )} " +
$"playerObjects={Crew.ActiveCount( Scene )} " +
$"connections={Connection.All?.Count ?? 0} " +
$"sceneObjects={Scene.Directory.GameObjectCount}" );
}
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 police_spawn point outside the perimeter around the crew.
// Shared with CopBotAI so that cops respawning after being killed
// respect the same rule - see PoliceSpawns.
var spawnPoint = PoliceSpawns.Pick( Scene, MinSpawnDistanceFromPlayers );
if ( spawnPoint is null )
{
Log.Warning( "MissionSpawner: no GameObjects tagged 'police_spawn' 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}";
// 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'." );
}
}
}