EscapeZone.cs
using Sandbox;
using SWB.Player;
using System;
// The extraction point. Put this on a GameObject with a trigger Collider
// covering the area players have to reach, and leave that GameObject
// DISABLED in the editor - MissionTimer turns it on once the survive
// timer runs out.
//
// Escaping is per-player: whoever walks in gets pulled out of the map and
// keeps their cut, whether or not anyone else makes it. You can leave
// friends behind.
//
// Component.ITriggerListener is s&box's interface for "tell me when
// something enters/leaves my trigger". It only does anything if there's a
// Collider on this same GameObject with IsTrigger ticked - the Collider
// defines the shape, this class decides what to do about it. The interface
// declares several overloads (Collider, GameObject, and pairs of both) but
// they have default implementations, so we only write the one we care
// about. MissionStartTrigger works the same way.
public sealed class EscapeZone : Component, Component.ITriggerListener
{
// [Property] exposes this in the s&box editor's inspector so it can be
// tuned per-scene without recompiling.
//
// What this job pays each person who actually gets out. Sits on the
// escape zone rather than the timer because each mission map has its
// own exit, so the job's value travels with it.
[Property]
public int EscapeReward { get; set; } = 1000;
// What escaping actually pays, after the chosen job difficulty. The
// property above stays the map's baseline so the inspector is
// readable; harder jobs scale it up on the way out.
public int ActualReward => (int)(EscapeReward * ActiveMission.PayoutMultiplier);
// How many players have got out. MissionTimer reads this to tell a
// win from a wipe.
//
// Counted explicitly rather than by scanning the scene for "escaped"
// tags, because escaping disables the player GameObject and
// Scene.GetAllObjects( true ) returns only ENABLED objects - so a
// scan can never see anyone who escaped. That mistake is exactly why
// a successful extraction used to report MISSION FAILED.
public int EscapedCount { get; private set; }
// Whether the person at THIS screen got out, and for how much. Read by
// MissionResultHud to confirm the extraction.
//
// It has to be tracked here rather than on the player, because
// escaping disables the player GameObject - which takes any HUD
// component riding on it down too. This component lives in the scene
// and survives.
public bool LocalPlayerEscaped { get; private set; }
public int LastRewardPaid { get; private set; }
// Called by MissionTimer when the survive timer elapses. Until then
// the whole GameObject (trigger Collider included) stays off, so
// nobody can extract early by standing in the exit.
//
// Note this enables the GAMEOBJECT, not just this component. Disabling
// the whole object switches off the Collider too - if only this
// component were disabled the trigger would still be live, and s&box's
// behaviour about delivering callbacks to a disabled listener isn't
// something worth betting on.
public void Activate()
{
GameObject.Enabled = true;
}
public void OnTriggerEnter( Collider other )
{
// Trigger callbacks fire on every client that simulates the
// overlap, so only the host is allowed to resolve an extraction -
// otherwise the same escape gets processed once per machine and
// the payout RPC fires once per machine too.
if ( !Networking.IsHost )
return;
// `other` is the Collider that touched us, which on a player is a
// CapsuleCollider sitting on a child "Body" object - not the
// player root. .Root walks up to the top of the hierarchy so we're
// dealing with the actual player GameObject, which is where the
// tags and PlayerBase/PlayerLoadout components live.
var player = other.GameObject.Root;
// Two guards here:
// - "player" tag: keeps cops, bullets, ragdolls and anything else
// that wanders into the zone from triggering an extraction.
// - "escaped" tag: OnTriggerEnter can fire more than once for the
// same body (re-entering, multiple colliders), and paying
// someone twice for one escape would be an easy exploit.
if ( !player.Tags.Has( "player" ) || player.Tags.Has( "escaped" ) )
return;
// Grab the component holding this player's cash and gear. It's on
// the player prefab next to PlayerBase.
var loadout = player.Components.Get<PlayerLoadout>();
if ( loadout is not null )
{
// Escaping is also what preserves the gear they bought: their
// loadout is only cleared by LoseLoadout(), which fires when
// PlayerLoadout notices them die. MissionPlayerState puts them
// in GodMode on the way out, so they can't be killed after the
// fact and lose what they just earned. That's the whole
// risk/reward hook - extract and keep it, go down and lose it.
loadout.AddCash( ActualReward );
}
else
{
// Non-fatal, but it means this player just did the hard part
// for free - worth shouting about rather than silently
// swallowing.
Log.Warning( $"{player.Name} escaped but has no PlayerLoadout - no payout. Is it on the player prefab?" );
}
RegisterEscape( player.Id );
}
// Host decides, everyone applies. Every client needs the "escaped" tag
// locally, because each one runs its own MissionTimer and would
// otherwise keep counting this player as still in the fight - so the
// job would never end for anybody but the host.
[Rpc.Broadcast]
void RegisterEscape( Guid playerId )
{
var player = Scene.Directory.FindByGuid( playerId );
if ( player is null )
return;
EscapedCount++;
// MissionPlayerState applies the tag and takes over from here:
// hides the body, makes them untouchable so a stray bullet can't
// take the loadout they just earned, and puts them on a teammate's
// camera to watch the rest of the run.
var state = player.Components.Get<MissionPlayerState>();
if ( state is not null )
{
state.MarkEscaped();
}
else
{
// No state component - fall back to tagging directly so the
// mission can still resolve, just without the spectate view.
player.Tags.Add( "escaped" );
Log.Warning( $"{player.Name} escaped but has no MissionPlayerState - no spectate view. Is it on the player prefab?" );
}
if ( PlayerBase.Local?.GameObject == player )
{
LocalPlayerEscaped = true;
LastRewardPaid = ActualReward;
}
// Deliberately NOT disabling the GameObject any more. It used to be
// switched off here, but a disabled object stops updating - which
// killed the player's own camera along with it. They stay enabled
// and MissionPlayerState hides them instead, so the camera lives
// on to spectate whoever's still inside.
Log.Info( $"{player.Name} escaped." );
}
}