MissionTimer.cs
using Sandbox;
using System.Collections.Generic;
using System.Linq;
// Drives the mission flow: hold out for SurviveDuration, then the escape
// zone opens and players have to actually reach it to get paid. Surviving
// the timer is no longer a win by itself - it just starts the run for the
// exit, with cops still spawning and fighting the whole way.
public sealed class MissionTimer : Component
{
// How long the crew has to hold the building before the next stage.
//
// 0 skips the hold entirely. On a vault map that means the gate starts
// opening the moment the job is started and the crew goes straight to
// bagging cash - that's how the bank is set up. On a map without a
// vault it would open the escape immediately, so don't.
[Property]
public float SurviveDuration { get; set; } = 90f;
// Optional — if set, spawning stops once the mission is fully over
// (everyone has escaped or died) instead of continuing forever.
[Property]
public MissionSpawner Spawner { get; set; }
// The extraction point, left disabled in the scene until the survive
// timer runs out.
[Property]
public EscapeZone EscapeZone { get; set; }
// The area the crew has to hold for the robbery to progress - the gas
// station interior. Leave unset and the clock just runs regardless of
// where anyone is.
[Property]
public RobberyZone RobberyZone { get; set; }
// --- Optional vault stage -------------------------------------------
//
// Maps with loot to grab (the bank) put an extra step between the
// robbery timer and the escape:
//
// hold the building -> vault gate opens -> bag every table -> escape opens
//
// Leave MoneyTables empty and a map plays exactly as before: the timer
// runs out and the escape opens straight away. That keeps the gas
// station untouched by any of this.
// Opened when the robbery timer finishes. Optional even on a vault map -
// without one the tables are simply unlocked at that moment.
[Property]
public VaultGate VaultGate { get; set; }
// Every table that has to be emptied before the escape opens.
[Property]
public List<MoneyTable> MoneyTables { get; set; } = new();
// Where everyone goes once the job's over, win or lose. Leave unset to
// just stay on the end screen.
[Property]
public SceneFile HideoutScene { get; set; }
// What the HUD tells you to hold while the robbery timer runs - "THE
// STORE" reads wrong in a bank. Just the noun; the HUD adds the rest.
[Property]
public string HoldTargetName { get; set; } = "THE STORE";
// Long enough to read the result before being yanked back.
[Property]
public float ReturnToHideoutDelay { get; set; } = 5f;
// Read by MissionResultHud to know what to show.
public bool MissionEnded { get; private set; }
public bool Won { get; private set; }
// True once the survive timer has run out and the exit has opened.
public bool EscapeAvailable => _escapeZoneActive;
// True between the vault opening and the last table being emptied.
// Only ever true on a map with MoneyTables.
public bool LootingActive => _lootingStarted && !_escapeZoneActive;
// Totals across every table, for the HUD. Summed on read - there are
// two tables, so there's nothing worth caching.
public int StacksBagged => ValidTables().Sum( t => t.StacksBagged );
public int StacksTotal => ValidTables().Sum( t => t.StackCount );
// True between the job starting and it ending - i.e. whether there's
// anything worth putting on the HUD at all.
public bool MissionInProgress => _missionStarted && !MissionEnded;
// Seconds left before the escape opens, floored at zero. Meaningless
// once EscapeAvailable is true.
public float TimeUntilEscape => MathX.Clamp( SurviveDuration - _robberyProgress, 0f, SurviveDuration );
// True while the robbery is on hold because nobody's in the store.
// Drives the HUD warning.
public bool RobberyStalled { get; private set; }
// Seconds of robbery actually completed. Accumulated by hand rather
// than using a TimeSince, because it has to PAUSE when the crew leaves
// the store - a TimeSince counts wall-clock and can't be held.
float _robberyProgress;
bool _missionStarted = false;
bool _escapeZoneActive = false;
bool _lootingStarted = false;
// Called by MissionStartTrigger once the hold-E interaction completes.
public void StartMission()
{
_missionStarted = true;
_escapeZoneActive = false;
_lootingStarted = false;
MissionEnded = false;
_robberyProgress = 0f;
RobberyStalled = false;
// PoliceSpawns keeps the phase in a static so both the spawner and
// respawning cops can read it without being wired together. A
// static outlives the scene load, so without this reset a second
// job would start already in escape phase and pull its opening
// wave from the wrong end of the map.
PoliceSpawns.SetPhase( escapePhase: false );
WarnIfPlayersCantBeMarkedOut();
WarnIfEscapeSpawnsMissing();
}
protected override void OnUpdate()
{
// Mission's over - stop evaluating anything except the trip home.
// MissionResultHud is still reading MissionEnded/Won to draw the
// end screen, so this state has to stick rather than reset.
if ( MissionEnded )
{
TickReturnToHideout();
return;
}
// Nothing happens until someone triggers the job.
if ( !_missionStarted )
return;
AdvanceRobbery();
var robberyDone = _robberyProgress >= SurviveDuration;
// Vault maps: the timer finishing opens the vault instead of the
// exit. Happens once.
if ( robberyDone && !_lootingStarted && HasLootStage )
StartLooting();
// The escape waits for the robbery, and on a vault map for every
// table to be bare as well. On a map without tables AllLootBagged
// is simply true, so this is the original behaviour.
var readyToEscape = robberyDone && AllLootBagged();
// Held out long enough - open the exit. The run isn't won yet,
// this just gives players somewhere to run TO. Cops keep spawning
// and fighting the whole way out, which is the point: the
// extraction is the hard part, not the timer.
if ( !_escapeZoneActive && readyToEscape )
{
_escapeZoneActive = true;
EscapeZone?.Activate();
// Move the police response onto its second set of spawn
// points. The robbery-phase points cover the approaches to the
// store; these should sit between the store and the exit, so
// the run out is contested instead of being a walk down an
// empty street.
//
// Only affects cops that spawn from here on. Anyone already on
// the map keeps fighting from where they are, which is what
// makes the shift feel like reinforcements cutting you off
// rather than the map teleporting its enemies around.
PoliceSpawns.SetPhase( escapePhase: true );
Log.Info( "Escape zone is open — get out to secure your cut." );
}
// The mission is only over once nobody's left who could still
// change the outcome. Whoever already escaped keeps their cut
// regardless of how that shakes out.
if ( !AnyPlayerActive() )
EndMission( won: EscapeZone?.EscapedCount > 0 );
}
bool HasLootStage => ValidTables().Any();
IEnumerable<MoneyTable> ValidTables()
{
return MoneyTables?.Where( t => t.IsValid() ) ?? Enumerable.Empty<MoneyTable>();
}
// True when there's nothing left to grab - including when there was
// never anything to grab.
bool AllLootBagged()
{
return ValidTables().All( t => t.IsEmpty );
}
// Robbery timer's done on a vault map: swing the gate and let the crew
// at the tables. The escape stays shut until they're empty - see
// OnUpdate.
void StartLooting()
{
_lootingStarted = true;
VaultGate?.Open();
foreach ( var table in ValidTables() )
table.Unlock();
Log.Info( $"Vault open — bag {StacksTotal} stacks across {ValidTables().Count()} tables to open the escape." );
}
// The robbery only progresses while the crew is actually holding the
// store. Leaving stalls it rather than failing outright - you can back
// off to deal with a flank and come back, you just aren't making
// progress while you're out there, so kiting cops around the map for
// the whole timer isn't a strategy.
//
// Once the escape is open this stops applying: at that point the job
// is done and you're supposed to be leaving.
void AdvanceRobbery()
{
// Once the timer's done (whether that opened the escape or the
// vault) there's nothing left to hold, so leaving the building
// isn't a stall any more.
if ( _escapeZoneActive || _lootingStarted )
{
RobberyStalled = false;
return;
}
// No zone assigned - behave as it did before and just run the
// clock, so an older mission map without one still works.
var holding = RobberyZone is null || RobberyZone.AnyPlayerInside;
RobberyStalled = !holding;
if ( holding )
_robberyProgress += Time.Delta;
}
void EndMission( bool won )
{
MissionEnded = true;
Won = won;
// Only stops NEW waves. Any cop already on the map keeps going -
// harmless at this point, since there's nobody left for them to
// fight.
Spawner?.StopSpawning();
// The take itself is already handled - EscapeZone pays each player
// as they get out, so anyone who made it has banked their cut by
// the time this runs.
//
// Investment dividends are the other half, and they're paid here
// precisely because they don't care how the job went. Losing
// everything you were carrying still leaves you with an income,
// which is what stops a bad run turning into a dead save.
PayInvestmentDividends();
Log.Info( won ? "Mission over — at least one player escaped." : "Mission failed — nobody made it out." );
// Arms the countdown; TickReturnToHideout does the rest.
_returnToHideoutAt = ReturnToHideoutDelay;
}
// Counts down to the trip home once the job is over. Negative until
// EndMission sets it, so it never fires on its own.
TimeUntil _returnToHideoutAt = float.PositiveInfinity;
// Hold on the result screen for a beat, then head back to the hideout
// so the next job can be picked. Loadouts and cash survive the trip
// because PlayerLoadout keeps them in a static store keyed by SteamId,
// not on the GameObject.
//
// Driven from OnUpdate rather than "await GameTask.DelaySeconds(...)".
//
// The async version threw a NullReferenceException inside
// Game.ChangeScene every single time, with
// ExpirableSynchronizationContext.ProcessQueue in the stack - the
// continuation was resuming after the context that owned it had
// expired, and changing scene from there hits an engine that has
// already started letting go. A scene change that throws half way is
// the worst possible outcome, because it leaves exactly the
// partially-torn-down state that makes the next job behave oddly.
//
// A plain countdown in the update loop has none of that: it runs on the
// main loop, in a live scene, at a predictable moment. It's also what
// s&box's own SB3006 analyser warning was telling us to do.
void TickReturnToHideout()
{
if ( !_returnToHideoutAt )
return;
// Latch it off immediately - ChangeScene doesn't take effect until
// the end of the frame, so without this we'd ask again next tick.
_returnToHideoutAt = float.PositiveInfinity;
if ( HideoutScene is null )
{
Log.Warning( "MissionTimer: no HideoutScene set, staying on the end screen." );
return;
}
var options = new SceneLoadOptions();
options.SetScene( HideoutScene );
options.ShowLoadingScreen = true;
// NOTE: options.DeleteEverything is deliberately NOT set.
//
// It was tried, on the theory that network-spawned objects were
// surviving the change. It fixed nothing measurable and made
// Game.ChangeScene itself throw. Don't re-add it without a
// reproducing case.
// Let go of the outgoing scene before asking the engine to drop it.
// These caches hold a Scene reference and GameObjects inside it,
// and anything still referencing them keeps the whole old scene
// graph reachable.
Crew.Reset();
PoliceSpawns.Reset();
Game.ChangeScene( options );
}
// Pays every player what the investment table owes them for this job.
//
// Host-gated, then routed per player. EndMission runs independently on
// every client's own MissionTimer, so without the host check each
// client would fire the RPC and the owner would be paid once per
// person in the lobby. PayDividend is [Rpc.Owner], so one call from
// the host lands exactly once, on the machine that owns that player's
// cash.
//
// Everyone gets paid, including players who were eliminated - being
// dead is not a reason to lose money that was never at risk.
void PayInvestmentDividends()
{
if ( !Networking.IsHost )
return;
foreach ( var loadout in Scene.GetAllComponents<PlayerLoadout>() )
{
if ( loadout.IsValid() )
loadout.PayDividend();
}
}
// Still in the fight: not dead, not extracted.
//
// Both tags are applied by MissionPlayerState (escaping goes through
// it too). Tags rather than enabled state, because players who are out
// stay enabled - they're spectating, so their camera has to keep
// running.
bool AnyPlayerActive()
{
return Crew.ActivePlayers( Scene ).Count > 0;
}
// The whole end-of-job condition depends on players being marked out
// of the run, and that is MissionPlayerState's job. Without it on the
// player prefab nobody is ever eliminated or escaped, so AnyPlayerActive
// is permanently true: the job never ends, the result screen never
// shows, nobody goes back to the hideout, and the spawner keeps feeding
// cops in for as long as the game is open.
//
// That's a silent failure that looks like a dozen unrelated bugs, so
// it's worth shouting about once at the start of every job.
void WarnIfPlayersCantBeMarkedOut()
{
if ( Scene.GetAllComponents<MissionPlayerState>().Any() )
return;
Log.Warning( "MissionTimer: no MissionPlayerState on any player. " +
"Nobody can be marked eliminated or escaped, so this job will never end " +
"and cops will keep spawning. Add MissionPlayerState to player.prefab." );
}
// The escape phase falls back to the robbery spawn points when the map
// has none of its own, so a map without them still plays - it just
// doesn't get the second stage. Said once at the start of the job so
// it's obvious that's what happened, rather than leaving it looking
// like the phase switch didn't fire.
void WarnIfEscapeSpawnsMissing()
{
if ( PoliceSpawns.All( Scene, PoliceSpawns.EscapeTag ).Count > 0 )
return;
Log.Warning( $"MissionTimer: no GameObjects tagged '{PoliceSpawns.EscapeTag}' in this map. " +
$"The escape phase will keep using the '{PoliceSpawns.HeistTag}' points. " +
"Tag some spawn points between the objective and the escape zone to get the second stage." );
}
}