Editor/demo bootstrap component for the Field Guide Tips demo scene. It wires a demo pawn into the TipsWorld, forces the demo save file and reset progress, ensures a TipsCoach and Tips Studio UI exist, ticks the coach each frame with a TipContext carrying a demo flag, and exposes a replay key.
using System;
using System.Linq;
using Sandbox;
namespace FieldGuide.Tips;
/// <summary>
/// Wires the demo scene in one component so the whole kit runs from a single press of play: it points the
/// <see cref="TipsWorld"/> seams at the demo pawn, creates the coach and its display with
/// <see cref="TipsCoach.Ensure"/>, and pushes a context every frame carrying the demo flag the demo tips
/// gate on. The tips themselves are authored assets (<c>Assets/demo/*.tip</c> plus
/// <c>Assets/starter.tip</c>), not code, so the scene also shows the no-code authoring path.
///
/// THE DEMO FLAG. Every demo tip's Relevance is <c>Flag("fg_tips_demo")</c>, and only this component sets
/// that flag. A game that vendors the kit and never runs this scene therefore never sees a demo tip, even
/// if it forgets to delete the assets: the tips merge into the catalog but stay irrelevant forever. Delete
/// them anyway.
///
/// THE ENDING. The walkthrough hands the player to the authoring tool: the last tip coaches <c>T</c>, and
/// opening the Tips Studio is what retires it. Two pieces do that, both of them demo wiring rather than
/// kit runtime. This component builds the Studio's host object at boot (the same one line a game writes
/// from the README), and it raises the <see cref="StudioOpenedSignal"/> string signal while the Studio is
/// open, which is the completion <c>demo_wrap.tip</c> waits on.
///
/// Not part of the kit's runtime surface: delete <c>Code/Demo</c> and <c>Assets/demo</c> when you drop the
/// kit into your own project, and write your own bootstrap from the README instead.
/// </summary>
[Title( "Tips Demo Bootstrap" )]
[Category( "Field Guide Tips" )]
[Icon( "auto_awesome" )]
public sealed class TipsDemoBootstrap : Component
{
/// <summary>The context flag every demo tip's Relevance reads, so demo content is inert anywhere this
/// component is not running.</summary>
public const string DemoFlag = "fg_tips_demo";
/// <summary>Progress file the demo writes, kept apart from the kit default so replaying the demo never
/// touches the progress your own game saves.</summary>
public const string DemoSaveFile = "fieldguide_tips_demo.json";
/// <summary>The string signal this component latches while the Tips Studio is open, and the completion
/// <c>demo_wrap.tip</c> waits on. A demo-side name: the kit knows nothing about it, which is the point.
/// Any game can retire a tip on its own UI the same way, with one <see cref="TipsCoach.Signal(string)"/>
/// call and a Signal trigger on the tip.</summary>
public const string StudioOpenedSignal = "fg_tips_studio_opened";
/// <summary>Raw key that resets progress and replays the sequence from the top.</summary>
[Property] public string ReplayKey { get; set; } = "r";
private TipsCoach _coach;
private TipsDemoPawn _pawn;
private string _previousSaveFile;
private Func<bool> _previousHasPlayer;
private Func<Vector3> _previousPlayerPosition;
private Func<Ray> _previousAimRay;
protected override void OnStart()
{
_pawn = Scene.GetAllComponents<TipsDemoPawn>().FirstOrDefault();
// Set the save file BEFORE Ensure: the first load reads whatever name is set then.
_previousSaveFile = TipsCoach.SaveFileName;
TipsCoach.SaveFileName = DemoSaveFile;
// Always start the demo from the top, the way a first-time player would see it.
TipsCoach.ResetProgress();
// World seams. A library cannot reach into your player, so the demo hands the kit its pawn. The
// marker zone's PlayerEntered trigger reads LocalPlayerPosition; nothing here uses LookedAt, so
// AimRay stays null and those triggers stay inert. They are statics, so the old values are kept
// and handed back in OnDestroy: a demo pawn that no longer exists must not answer for a game
// scene opened later in the same session.
_previousHasPlayer = TipsWorld.HasLocalPlayer;
_previousPlayerPosition = TipsWorld.LocalPlayerPosition;
_previousAimRay = TipsWorld.AimRay;
TipsWorld.HasLocalPlayer = () => _pawn.IsValid();
TipsWorld.LocalPlayerPosition = () => _pawn.IsValid() ? _pawn.WorldPosition : Vector3.Zero;
TipsWorld.AimRay = null;
_coach = TipsCoach.Ensure( Scene );
EnsureStudioHost();
Log.Info( "[tips] demo ready: move with W A S D or the left stick, jump with Space or A, " +
"switch the marker on inside its ring, then press T for the Tips Studio. " +
$"Press {ReplayKey.ToUpperInvariant()} to replay." );
}
protected override void OnUpdate()
{
if ( _coach is null )
return;
// Ignored while the Studio is open. The key is read raw, so without this an "r" typed into one of
// the Studio's text boxes would restart the walkthrough under the panel, and the walkthrough now
// ENDS in that panel. Closed, the last beat is there to walk again.
if ( !TipsStudio.Open && Input.Keyboard.Pressed( ReplayKey ) )
Replay();
// The last beat. Latched while the Studio is OPEN rather than on the frame it opens: a latch is
// idempotent, so pushing it every frame costs nothing, and it leaves no edge state to go stale
// when the walkthrough is reset out from under it. Raised before the tick below, because the tick
// is what reads it.
if ( TipsStudio.Open )
_coach.Signal( StudioOpenedSignal );
// The context path: one small neutral struct per frame. The demo only needs two things in it, a
// player and the demo flag; a real game fills the fields its own tips read.
var ctx = new TipContext { HasPlayer = _pawn.IsValid() };
ctx.SetFlag( DemoFlag, true );
_coach.Tick( ctx );
}
protected override void OnDestroy()
{
// Hand the statics back so a game scene opened later in the same editor session sees the kit
// exactly as it was before the demo ran.
if ( !string.IsNullOrEmpty( _previousSaveFile ) )
TipsCoach.SaveFileName = _previousSaveFile;
if ( _previousHasPlayer is not null )
TipsWorld.HasLocalPlayer = _previousHasPlayer;
else
TipsWorld.HasLocalPlayer = static () => true;
TipsWorld.LocalPlayerPosition = _previousPlayerPosition;
TipsWorld.AimRay = _previousAimRay;
}
/// <summary>
/// Build the Tips Studio's host: one <see cref="ScreenPanel"/> carrying the panel, created in code so
/// the demo scene file holds no razor reference (the same idiom the other kits' demos use). It sits
/// above the coach's own panel, since the Studio is a modal over the card it is editing. The Studio
/// ships CLOSED and opens on its own OpenKey, which is T; nothing here opens it.
///
/// Idempotent: a scene that already carries a Studio panel of its own keeps it.
/// </summary>
private void EnsureStudioHost()
{
if ( Scene.GetAllComponents<TipsStudioPanel>().Any() )
return;
var go = Scene.CreateObject();
go.Name = "UI.TipsStudio";
var screen = go.Components.Create<ScreenPanel>();
screen.ZIndex = 60; // above the coach's card at 50
go.Components.Create<TipsStudioPanel>();
}
private void Replay()
{
TipsCoach.ResetProgress();
foreach ( var marker in Scene.GetAllComponents<TipsDemoMarker>() )
marker.ResetMarker();
_pawn?.ResetPawn();
Log.Info( "[tips] demo replayed from the first tip." );
}
}