MissionBoardMenu.cs
using Sandbox;
using Sandbox.UI;
using Sandbox.UI.Construct;
using SWB.Player;
using System.Collections.Generic;
// The job board UI. Lists every mission configured on the MissionBoard;
// clicking one loads that mission's scene.
//
// Same no-Razor PanelComponent pattern as the vendor. Put this on the same
// GameObject as the MissionBoard and assign Board; it creates its own
// ScreenPanel, so no extra editor wiring.
public sealed class MissionBoardMenu : PanelComponent
{
[Property]
public MissionBoard Board { get; set; }
Label _cashLabel;
Panel _list;
Panel _board;
Panel _pendingPopup;
Label _pendingProposer;
Label _pendingMission;
Label _pendingPrompt;
// Mission index -> its "READY n/n" label, so the status can be
// refreshed without rebuilding the whole list every frame.
readonly Dictionary<int, Label> _statusLabels = new();
bool _wasOpen = false;
protected override void OnStart()
{
Components.GetOrCreate<ScreenPanel>();
Panel.StyleSheet.Load( "/MissionBoardMenu.cs.scss" );
// Everything that belongs to the open board lives under one wrapper
// so it can be shown/hidden as a unit, independently of the
// pending popup which shows when the board is CLOSED.
_board = Panel.Add.Panel( "board" );
var header = _board.Add.Panel( "header" );
header.Add.Label( "Jobs", "title" );
_cashLabel = header.Add.Label( "", "cash" );
var close = header.Add.Label( "✕", "close" );
close.AddEventListener( "onmousedown", () => Board?.Close() );
_list = _board.Add.Panel( "list" );
// Separate from the menu panel above: this one shows while the
// board is CLOSED, so a player anywhere in the hideout finds out
// what's been proposed without walking over to read it.
_pendingPopup = Panel.Add.Panel( "pending" );
_pendingProposer = _pendingPopup.Add.Label( "", "proposer" );
_pendingMission = _pendingPopup.Add.Label( "", "mission" );
_pendingPrompt = _pendingPopup.Add.Label( "", "prompt" );
}
protected override void OnUpdate()
{
if ( Board is null )
return;
// Only the board chrome toggles - the root stays present so the
// pending popup can show while the board is closed.
_board.SetClass( "show", Board.IsOpen );
Panel.SetClass( "interactive", Board.IsOpen );
// Rebuild on open only, rather than churning panels every frame.
if ( Board.IsOpen && !_wasOpen )
Rebuild();
// Release the cursor so the menu can be clicked - see MenuInput for
// why this works on mouse-look rather than on Mouse.Visible.
if ( Board.IsOpen != _wasOpen )
MenuInput.SetMenuOpen( Board.IsOpen );
_wasOpen = Board.IsOpen;
if ( Board.IsOpen )
{
var loadout = LocalLoadout();
_cashLabel.Text = loadout is null ? "" : $"${loadout.Cash}";
UpdateReadyStatus();
}
UpdatePendingPopup();
}
// Bottom-right nudge for whoever hasn't got the board open. Hidden
// while the board IS open, since the full list already shows all of
// this and the two would just overlap.
void UpdatePendingPopup()
{
var mission = Board.PendingMission;
var show = mission is not null && !Board.IsOpen;
_pendingPopup.SetClass( "show", show );
if ( !show )
return;
var ready = Board.LocalPlayerIsReady;
_pendingProposer.Text = ready
? "Waiting on the crew"
: $"{Board.ProposedBy} wants to run";
_pendingMission.Text = string.IsNullOrWhiteSpace( mission.DifficultyName )
? mission.DisplayName
: $"{mission.DisplayName} — {mission.DifficultyName}";
// Asked for rather than hardcoded, so the prompt always names the
// key that's actually bound. The first version said "F1" in text
// while the binding moved, which is exactly the kind of drift that
// leaves a player pressing the wrong key. It also reads the right
// thing on a controller, where the binding isn't a key at all.
var key = ReadyUpKeyName();
_pendingPrompt.Text = ready
? $"READY {Board.ReadyCount}/{Board.CrewSize} — {key} to back out"
: $"[{key}] Ready up — {Board.ReadyCount}/{Board.CrewSize}";
_pendingPopup.SetClass( "ready", ready );
}
// Whatever "ReadyUp" is currently bound to, as something printable -
// "X", "SPACE", "A Button". Falls back to the action name if the
// binding has gone missing, which at least tells you what to go and
// rebind rather than showing an empty pair of brackets.
static string ReadyUpKeyName()
{
var origin = Input.GetButtonOrigin( "ReadyUp" );
return string.IsNullOrWhiteSpace( origin ) ? "ReadyUp" : origin.ToUpperInvariant();
}
void Rebuild()
{
_list.DeleteChildren();
_statusLabels.Clear();
if ( Board.Missions.Count == 0 )
{
_list.Add.Label( "No jobs available.", "empty" );
return;
}
for ( var i = 0; i < Board.Missions.Count; i++ )
{
if ( Board.Missions[i] is null )
continue;
AddEntry( Board.Missions[i], i );
}
}
void AddEntry( MissionEntry mission, int index )
{
var row = _list.Add.Panel( "entry" );
var text = row.Add.Panel( "text" );
var titleRow = text.Add.Panel( "titleRow" );
titleRow.Add.Label( mission.DisplayName, "name" );
if ( !string.IsNullOrWhiteSpace( mission.DifficultyName ) )
titleRow.Add.Label( mission.DifficultyName, "difficulty" );
if ( !string.IsNullOrWhiteSpace( mission.Description ) )
text.Add.Label( mission.Description, "description" );
row.Add.Label( $"${mission.AdvertisedPayout}", "payout" );
// Shows "READY 2/3" once anyone has voted for this job, so you can
// see who you're waiting on rather than clicking and wondering why
// nothing happened.
var status = row.Add.Label( "", "status" );
_statusLabels[index] = status;
// A job with no scene assigned would silently do nothing on click,
// so grey it out rather than letting it look launchable.
var launchable = mission.Scene is not null;
row.SetClass( "unavailable", !launchable );
if ( !launchable )
return;
row.AddEventListener( "onmousedown", () => Board.ToggleReady( index ) );
}
// Refreshed every frame while open rather than on click, because the
// count changes when OTHER players ready up, which produces no local
// event to react to.
void UpdateReadyStatus()
{
foreach ( var (index, label) in _statusLabels )
{
var isLocalPick = Board.LocalReadyIndex == index;
label.Text = isLocalPick
? $"READY {Board.ReadyCount}/{Board.CrewSize}"
: "";
label.Parent?.SetClass( "ready", isLocalPick );
}
}
PlayerLoadout LocalLoadout()
{
return PlayerBase.Local?.Components.Get<PlayerLoadout>();
}
}