WelcomePopup.cs
using Sandbox;
using Sandbox.UI;
using Sandbox.UI.Construct;
using SWB.Player;
using System;
using System.Linq;
// First-run explainer for the hideout. Shows once, the first time someone
// ever loads into the hideout on this machine, and walks through the loop:
// take a job, get out alive, spend the cut, invest what you can spare.
//
// Same no-Razor PanelComponent pattern as the other hideout menus. Drop it
// on any GameObject in the hideout scene - it creates its own ScreenPanel,
// so there's nothing to wire.
//
// Two console commands for testing, since "first time" is otherwise a
// one-shot you can't get back:
// welcome - show it again right now
// welcome_reset - forget it's been seen, so the next hideout load
// behaves like a fresh install
public sealed class WelcomePopup : PanelComponent
{
// The marker that says "already seen it". FileSystem.Data rather than
// Game.Cookies for the same reason PlayerLoadout's save lives there:
// cookies expire after a month, and this popup reappearing out of
// nowhere for a returning player would be a small but real annoyance.
//
// Its own file rather than a field on the loadout save, because the
// loadout is keyed per SteamId and saved from a component that only
// exists once the player has spawned. This is a per-machine flag and
// only needs to exist or not - there's nothing to put in it.
const string SeenFile = "welcome_seen.txt";
// Hold-off before showing, in seconds after the local player exists.
// Popping it the instant the pawn spawns lands it on top of the loading
// screen fading out; a beat of standing in the room first means the
// player sees where they are before being told what's in it.
[Property]
public float ShowDelay { get; set; } = 0.75f;
// The four step descriptions. Filled in when the popup opens rather than
// at build time, because they quote live numbers (the ready-up key,
// armour %, investment return) and those should always match what the
// game actually does, even after someone tunes them in the inspector.
Label _jobText;
Label _escapeText;
Label _vendorText;
Label _investText;
bool _isOpen = false;
// Whether we've made the show/don't-show call for this hideout visit.
// It's made once, then left alone - otherwise closing the popup would
// just have it decide to open again next frame.
bool _decided = false;
// Counts up from the moment the local player first exists. Starts
// "unset" (null) because in OnStart there may be no player yet.
TimeSince? _sincePlayerExisted;
protected override void OnStart()
{
var screen = Components.GetOrCreate<ScreenPanel>();
// Above the other hideout menus and the HUD. They're normally all
// hidden while this is up, but this is the one screen that should
// never end up underneath something else.
screen.ZIndex = 200;
Panel.StyleSheet.Load( "/WelcomePopup.cs.scss" );
var window = Panel.Add.Panel( "window" );
var header = window.Add.Panel( "header" );
header.Add.Label( "Welcome to the crew", "title" );
var body = window.Add.Panel( "body" );
body.Add.Label( "Here's how it works. It's one loop, and you'll run it a lot.", "blurb" );
var steps = body.Add.Panel( "steps" );
_jobText = AddStep( steps, "1", "Take a job" );
_escapeText = AddStep( steps, "2", "Get out alive" );
_vendorText = AddStep( steps, "3", "Gear up" );
_investText = AddStep( steps, "4", "Invest" );
var footer = body.Add.Panel( "footer" );
footer.Add.Label( "One more round.", "tagline" );
var dismiss = footer.Add.Panel( "dismiss" );
dismiss.Add.Label( "Got it", "dismissLabel" );
dismiss.AddEventListener( "onmousedown", Dismiss );
}
// One numbered row: a badge, a heading, and a description label that's
// handed back so Open() can fill it with current numbers.
static Label AddStep( Panel steps, string number, string heading )
{
var row = steps.Add.Panel( "step" );
row.Add.Label( number, "number" );
var text = row.Add.Panel( "stepText" );
text.Add.Label( heading, "stepHeading" );
return text.Add.Label( "", "stepBody" );
}
protected override void OnUpdate()
{
Panel.SetClass( "show", _isOpen );
if ( _decided )
return;
// Wait for the local player. MenuInput needs them to release the
// cursor, and the step text reads their PlayerLoadout - neither
// exists for the first few frames after the scene loads.
if ( PlayerBase.Local is null )
return;
_sincePlayerExisted ??= 0f;
if ( _sincePlayerExisted.Value < ShowDelay )
return;
_decided = true;
if ( !HasBeenSeen() )
Open();
}
public void Open()
{
FillStepText();
_isOpen = true;
// Free the cursor so the button can be clicked. Same helper the
// vendor and job board use, so they all agree on who owns the mouse.
MenuInput.SetMenuOpen( true );
}
void Dismiss()
{
if ( !_isOpen )
return;
_isOpen = false;
MenuInput.SetMenuOpen( false );
// Marked seen on dismiss, not on open. If someone quits with it on
// screen they never actually read it, so it should come back.
MarkSeen();
}
// The wording lives here rather than in OnStart so the numbers are
// read at the moment it's shown. Every figure comes from the component
// that actually applies it - nothing here is a second copy of a value
// that could drift out of sync.
void FillStepText()
{
var loadout = PlayerBase.Local?.Components.Get<PlayerLoadout>();
// Falls back to the code defaults if there's no loadout, which only
// happens on a player prefab set up without one.
var armorPercent = (int)MathF.Round( (loadout?.ArmorDamageReduction ?? 0.35f) * 100f );
var increment = loadout?.InvestmentIncrement ?? 100;
var payout = loadout?.InvestmentReturnPerIncrement ?? 20;
var readyKey = MissionBoardMenu.ReadyUpKeyName();
_jobText.Text =
$"Hold E at the job board to pick a mission, then press {readyKey} to ready up. "
+ "The job starts once the whole crew is ready.";
_escapeText.Text =
"Hold the store until the robbery's done, then fight your way to the escape zone. "
+ "Only the ones who make it out get paid - go down and you lose the gear you were carrying.";
_vendorText.Text =
$"Spend your cut at the vendor on better guns and armour. Armour cuts all incoming damage by {armorPercent}% "
+ "until its bar is shot through - then it's gone and you'll need a new one.";
_investText.Text =
$"The table at the back takes ${increment} at a time and pays ${payout} per ${increment} after every job, "
+ "win or lose. Money in there is never lost - it's what gets you back on your feet after a bad run.";
}
// Any failure reading the flag counts as "seen". Showing a tutorial to
// someone who's already played is harmless once; showing it on every
// hideout load because the data folder is unreadable would not be.
static bool HasBeenSeen()
{
try
{
return FileSystem.Data.FileExists( SeenFile );
}
catch ( System.Exception e )
{
Log.Warning( $"WelcomePopup: couldn't check {SeenFile} - {e.Message}" );
return true;
}
}
static void MarkSeen()
{
try
{
// Contents don't matter; the file existing is the flag.
FileSystem.Data.WriteAllText( SeenFile, "seen" );
}
catch ( System.Exception e )
{
Log.Warning( $"WelcomePopup: couldn't write {SeenFile} - {e.Message}" );
}
}
[ConCmd( "welcome", Help = "Shows the first-time welcome popup again" )]
public static void ShowAgain()
{
var popup = Game.ActiveScene?.GetAllComponents<WelcomePopup>().FirstOrDefault();
if ( popup is null )
{
Log.Info( "No welcome popup in this scene - it lives in the hideout." );
return;
}
popup.Open();
}
[ConCmd( "welcome_reset", Help = "Forgets the welcome popup was seen, so it shows on the next hideout load" )]
public static void ResetSeen()
{
try
{
if ( FileSystem.Data.FileExists( SeenFile ) )
FileSystem.Data.DeleteFile( SeenFile );
Log.Info( "Welcome popup reset - it'll show next time you load into the hideout." );
}
catch ( System.Exception e )
{
Log.Warning( $"WelcomePopup: couldn't delete {SeenFile} - {e.Message}" );
}
}
}