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}" );
		}
	}
}