MissionResultHud.cs
using Sandbox;
using Sandbox.UI;
using Sandbox.UI.Construct;
using SWB.Player;

// The in-mission HUD: a small objective banner up top while the job is
// running, and the big win/lose text once it's over.
//
// (Named for the result screen because that's all it did originally - the
// class name is wired into the scene by type, so renaming it would break
// that. It's the mission HUD now.)
//
// Add this component anywhere in the scene and assign Timer. It creates
// its own ScreenPanel (PanelComponent needs one on the same GameObject to
// render), so no other editor wiring is required.
public sealed class MissionResultHud : PanelComponent
{
	[Property]
	public MissionTimer Timer { get; set; }

	Label _objectiveLabel;
	Panel _resultWrapper;
	Label _resultLabel;

	protected override void OnStart()
	{
		Components.GetOrCreate<ScreenPanel>();

		Panel.StyleSheet.Load( "/MissionResultHud.cs.scss" );

		_objectiveLabel = Panel.Add.Label( "", "objective" );

		// The result text needs a wrapper to centre against. Flex centring
		// set directly on a Label positions that Label's children, and its
		// text isn't a child panel - so the label has to be a child of
		// something that does the centring.
		_resultWrapper = Panel.Add.Panel( "resultWrapper" );
		_resultLabel = _resultWrapper.Add.Label( "", "result" );
	}

	protected override void OnUpdate()
	{
		if ( Timer is null )
			return;

		UpdateObjective();
		UpdateResult();
	}

	// Sits near the top of the screen so it doesn't cover the fight.
	// Counts down while holding out, then switches to the extraction call
	// once the exit opens.
	void UpdateObjective()
	{
		var show = Timer.MissionInProgress;
		_objectiveLabel.SetClass( "show", show );

		if ( !show )
			return;

		var state = LocalState();
		var escaped = state?.Current == MissionPlayerState.State.Escaped
			|| (Timer.EscapeZone?.LocalPlayerEscaped ?? false);
		var eliminated = state?.Current == MissionPlayerState.State.Eliminated;

		if ( eliminated )
		{
			// Without this the view silently jumping into someone else's
			// head just reads as a bug.
			var watching = state.SpectateTarget?.GameObject?.Name;

			_objectiveLabel.Text = string.IsNullOrWhiteSpace( watching )
				? "ELIMINATED"
				: $"ELIMINATED — SPECTATING {watching}";
		}
		else if ( escaped )
		{
			// You're out. Confirms the extraction actually paid, and gives
			// you something to look at while waiting on teammates who
			// haven't made it yet.
			var reward = Timer.EscapeZone?.LastRewardPaid ?? 0;
			_objectiveLabel.Text = $"EXTRACTED — +${reward}";
		}
		else if ( Timer.EscapeAvailable )
		{
			_objectiveLabel.Text = "ESCAPE ZONE OPEN — GET OUT";
		}
		else if ( Timer.RobberyStalled )
		{
			// Without this the frozen countdown just looks broken - the
			// player has no way to know leaving is what stopped it.
			_objectiveLabel.Text = "ROBBERY STALLED — GET BACK INSIDE";
		}
		else
		{
			// Whole seconds only; a ticking decimal is noise at a glance.
			var remaining = Timer.TimeUntilEscape.CeilToInt();
			_objectiveLabel.Text = $"HOLD THE STORE — {remaining}s";
		}

		// Deliberately blue rather than red: red already means MISSION
		// FAILED on this same HUD, so reusing it for "the exit is open"
		// would read as bad news at a glance. Green once you're actually
		// out and paid.
		var stalled = Timer.RobberyStalled && !escaped && !eliminated;

		_objectiveLabel.SetClass( "escape", Timer.EscapeAvailable && !escaped && !eliminated );
		_objectiveLabel.SetClass( "extracted", escaped && !eliminated );
		_objectiveLabel.SetClass( "eliminated", eliminated );
		_objectiveLabel.SetClass( "stalled", stalled );
	}

	static MissionPlayerState LocalState()
	{
		return PlayerBase.Local?.Components.Get<MissionPlayerState>();
	}

	void UpdateResult()
	{
		_resultWrapper.SetClass( "show", Timer.MissionEnded );

		if ( !Timer.MissionEnded )
			return;

		_resultLabel.Text = Timer.Won ? "YOU WON" : "MISSION FAILED";
		_resultLabel.SetClass( "won", Timer.Won );
		_resultLabel.SetClass( "lost", !Timer.Won );
	}
}