MissionTimer.cs
using Sandbox;
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
{
[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; }
// 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; }
// 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 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;
// Called by MissionStartTrigger once the hold-E interaction completes.
public void StartMission()
{
_missionStarted = true;
_escapeZoneActive = false;
MissionEnded = false;
_robberyProgress = 0f;
RobberyStalled = false;
WarnIfPlayersCantBeMarkedOut();
}
protected override void OnUpdate()
{
// Mission's over - stop evaluating anything. MissionResultHud is
// still reading MissionEnded/Won to draw the end screen, so this
// state has to stick rather than reset.
if ( MissionEnded )
return;
// Nothing happens until someone triggers the job.
if ( !_missionStarted )
return;
AdvanceRobbery();
// 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 && _robberyProgress >= SurviveDuration )
{
_escapeZoneActive = true;
EscapeZone?.Activate();
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 );
}
// 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()
{
if ( _escapeZoneActive )
{
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();
// Nothing to pay out here - EscapeZone pays each player as they
// get out, so anyone who made it has already banked their cut by
// the time this runs.
Log.Info( won ? "Mission over — at least one player escaped." : "Mission failed — nobody made it out." );
ReturnToHideout();
}
// 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.
async void ReturnToHideout()
{
if ( HideoutScene is null )
{
Log.Warning( "MissionTimer: no HideoutScene set, staying on the end screen." );
return;
}
await GameTask.DelaySeconds( ReturnToHideoutDelay );
var options = new SceneLoadOptions();
options.SetScene( HideoutScene );
options.ShowLoadingScreen = true;
Game.ChangeScene( options );
}
// 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." );
}
}