Onboarding + loop flow for a hands-off demo; GoalTarget 4->2

Lap-1 Fabricator affordable (StartingOre 50->90); mine prompt shows IN the room;
Return no longer false-completes on death (WasOnExpedition latch + soft timeout,
never the 1-tick Returning edge); HUD keeps room/siege/ammo cues during combat
steps; Welcome needs an explicit confirm; READY panel shows FINAL DEFENSE when
goal-full; dev Force-Each-Launch hidden from public Settings. GoalProgress.Target
4->2 (~10-20 min demo). Onboarding tests updated to the new contracts (456/456).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 20:21:42 -07:00
parent 43d7cca2f8
commit ab657fa578
6 changed files with 67 additions and 21 deletions
@@ -14,7 +14,13 @@ namespace ProjectM.Client
/// <summary>True while the coach-mark sequence is the active prompt voice (a step is being shown).</summary>
public static bool Active;
/// <summary>True only while an EARLY base-framing step (Welcome/Move/Build/Fabricator/ReadyUp) is showing, so
/// HudSystem blanks its own location line for those (the coach-mark owns the voice) but LEAVES the room/siege/
/// out-of-ammo cues visible during the combat steps (D4). Set each frame by OnboardingSystem.</summary>
public static bool SuppressLocationLine;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetOnPlayEnter() => Active = false;
static void ResetOnPlayEnter() { Active = false; SuppressLocationLine = false; }
}
}
@@ -42,6 +42,8 @@ namespace ProjectM.Client
public const float FabricatorSoftSeconds = 14f; // soft beat auto-advance if no Fabricator built
public const float DefendNoSiegeSeconds = 20f; // advance if no siege ever materialises
public const float DoneSeconds = 6f; // closing beat lingers before going dormant
public const float RoomsSeconds = 7f; // D2: keep the mine-the-crystals prompt + node pointer up a beat AFTER teleport (past the ~3s launch countdown -> ~4s in the room)
public const float ReturnMaxSeconds = 75f; // D3: soft backstop so a missed homecoming signal can NEVER stall the sequence
// ---- spatial-cue kinds the System resolves to a live world target ----
public const byte PointerNone = 0;
@@ -61,6 +63,8 @@ namespace ProjectM.Client
public bool OnExpedition; // local player is in the expedition region
public byte ObjectiveState; // ExpeditionObjective.State (Idle/Active/Cleared)
public bool SawSiege; // a Siege phase was observed while the Defend step was showing
public bool WasOnExpedition;// D3: latched true once the player was seen on expedition during the Return step (so a start-at-base Return doesn't instantly satisfy)
public byte Phase; // CycleState.Phase (Calm/Siege)
}
@@ -74,11 +78,11 @@ namespace ProjectM.Client
case Build: return s.TurretCount >= 1;
case Fabricator: return s.FabricatorCount >= 1 || s.StepElapsed >= FabricatorSoftSeconds;
case ReadyUp: return s.LocalReady || s.Lifecycle != RunLifecycle.Staging;
case Rooms: return s.OnExpedition || s.ObjectiveState == ExpeditionObjectiveState.Active;
case Rooms: return s.OnExpedition && s.StepElapsed >= RoomsSeconds; // D2: show the mine prompt + node pointer IN the room, not the instant we teleport
case Boon: return s.ObjectiveState == ExpeditionObjectiveState.Cleared
|| s.Lifecycle == RunLifecycle.RoomReward
|| s.Lifecycle == RunLifecycle.RouteSelect;
case Return: return !s.OnExpedition; // entered mid-run; satisfied on coming home
case Return: return (s.WasOnExpedition && !s.OnExpedition) || s.StepElapsed >= ReturnMaxSeconds; // D3: home AFTER being on expedition, else a soft timeout (never gate on the 1-tick Returning edge)
case Defend: return s.SawSiege ? s.Phase == CyclePhase.Calm : s.StepElapsed >= DefendNoSiegeSeconds;
case Done: return s.StepElapsed >= DoneSeconds;
default: return true;
@@ -102,22 +106,27 @@ namespace ProjectM.Client
string build = gamepad ? "Y" : "Tab"; // matches the existing HUD build-discovery chip glyph
switch (step)
{
case Welcome: return "CLEAR EXPEDITIONS to charge the Engine — defend the Core while you do. (Esc → Pause → How to Play)";
case Welcome: return "CLEAR 2 EXPEDITIONS to charge the Engine, then survive the FINAL SIEGE. (Space to continue · Esc → How to Play)";
case Move: return move + " — Move";
case Build: return build + " — open Build, place a Turret by your Core (your 50 starting Ore covers it)";
case Fabricator: return "Build a Fabricator — turrets need Charge (Ore → ammo)";
case ReadyUp: return "Press T (or click READY UP) — when everyone is ready, the party launches";
case Rooms: return "Fight through the rooms — attack the glowing crystals to haul resources home";
case Boon: return "Clear the room — pick 1 of 3 BOONS, then choose your path on the map";
case Return: return "Reach the boss and fell itthe haul comes home and the Engine charges (+1)";
case Return: return "Fell the ALPHA HUSK, then return homeyour haul + the Engine charge (+1) come with you";
case Defend: return "Defend the Core! — a completed run provokes a retaliation siege";
case Done: return "You've got it. Clear expeditions to fill the Engine and win.";
case Done: return "You've got it — clear 2 expeditions to fill the Engine, then hold the final siege to win.";
default: return "";
}
}
// ---- persisted-mask helpers (GameSettings.OnboardingMask) ----
/// <summary>D4: the early BASE-framing steps (the coach-mark owns the prompt voice, so the HUD blanks its own
/// location line) vs the combat steps (the HUD MUST keep showing room/siege/out-of-ammo cues). Welcome..ReadyUp
/// are early. Read each frame by OnboardingSystem to set OnboardingState.SuppressLocationLine.</summary>
public static bool IsEarlyStep(byte step) => step <= ReadyUp;
/// <summary>All steps complete (the sequence is dormant).</summary>
public static bool AllComplete(int mask)
{
@@ -42,6 +42,8 @@ namespace ProjectM.Client
float _moveAccum;
float3 _lastPos;
bool _sawSiege;
bool _wasOnExpedition; // D3: latched true once the player is seen on expedition during the Return step
protected override void OnStartRunning()
{
@@ -64,6 +66,8 @@ namespace ProjectM.Client
var root = _doc.rootVisualElement;
if (root == null) return;
if (!_built) { BuildTree(root); _built = true; }
OnboardingState.SuppressLocationLine = false; // D4: default off each frame; a shown step sets it per-step below
float dt = SystemAPI.Time.DeltaTime; // wall-frame delta — correct in a presentation system
@@ -106,7 +110,7 @@ namespace ProjectM.Client
// ---- per-step entry init (baselines) ----
if (!_stepInit)
{
_stepElapsed = 0f; _moveAccum = 0f; _sawSiege = false;
_stepElapsed = 0f; _moveAccum = 0f; _sawSiege = false; _wasOnExpedition = false;
_lastPos = playerPos;
_stepInit = true;
}
@@ -118,6 +122,8 @@ namespace ProjectM.Client
_stepElapsed += dt;
if (_step == OnboardingStepMath.Move) _moveAccum += math.distance(playerPos, _lastPos);
if (_step == OnboardingStepMath.Defend && phase == CyclePhase.Siege) _sawSiege = true;
if (_step == OnboardingStepMath.Return && onExp) _wasOnExpedition = true; // D3: latch "was on expedition" so a start-at-base Return doesn't instantly satisfy
var snap = new OnboardingStepMath.Snapshot
{
@@ -130,12 +136,13 @@ namespace ProjectM.Client
OnExpedition = onExp,
ObjectiveState = objState,
SawSiege = _sawSiege,
WasOnExpedition = _wasOnExpedition,
Phase = phase,
};
// The two pure-message beats can be dismissed with any input EXCEPT Esc (Esc opens Pause; see
// AnyInputPressed) so following the "Esc → Pause → How to Play" hint doesn't self-skip the framing.
bool skip = (_step == OnboardingStepMath.Welcome || _step == OnboardingStepMath.Done) && AnyInputPressed();
bool skip = (_step == OnboardingStepMath.Welcome && ConfirmPressed()) || (_step == OnboardingStepMath.Done && AnyInputPressed()); // D5: Welcome dismisses ONLY on an explicit confirm (not WASD) so the win-condition strip is actually read
if (skip || OnboardingStepMath.IsSatisfied(_step, snap))
{
_mask |= (1 << _step);
@@ -154,6 +161,7 @@ namespace ProjectM.Client
// ---- show the current step ----
OnboardingState.Active = true;
OnboardingState.SuppressLocationLine = OnboardingStepMath.IsEarlyStep(_step); // D4: blank the HUD line only for the early base-framing steps
root.style.display = DisplayStyle.Flex;
bool gamepad = AimPresentation.Scheme == InputSchemeId.Gamepad;
_prompt.text = OnboardingStepMath.Prompt(_step, gamepad);
@@ -194,7 +202,20 @@ namespace ProjectM.Client
return false;
}
// ---- world-space pointer ----
// D5: an EXPLICIT confirm only (Space/Enter/click/South) — movement keys must NOT skip the Welcome strip.
static bool ConfirmPressed()
{
var kb = UnityEngine.InputSystem.Keyboard.current;
if (kb != null && (kb.spaceKey.wasPressedThisFrame || kb.enterKey.wasPressedThisFrame)) return true;
var ms = UnityEngine.InputSystem.Mouse.current;
if (ms != null && ms.leftButton.wasPressedThisFrame) return true;
var gp = UnityEngine.InputSystem.Gamepad.current;
if (gp != null && (gp.buttonSouth.wasPressedThisFrame || gp.startButton.wasPressedThisFrame)) return true;
return false;
}
// ---- world-space pointer ----
bool ResolveTarget(byte kind, float3 playerPos, out float3 target)
{
@@ -68,9 +68,13 @@ namespace ProjectM.Client
// DEV: forces the first-run coach-marks to replay fresh on every launch (wipes the completed-step mask at
// each boot — see SettingsService.Boot). Off = normal once-only first-run behaviour.
card.Add(CycleRow("Force Each Launch (Dev)",
#if UNITY_EDITOR // D7: dev-only onboarding replay toggle — hidden from the public demo Settings so a player can't lock themselves into permanent tutorial
card.Add(CycleRow("Force Each Launch (Dev)",
() => onoff[Mathf.Clamp(working.ForceOnboardingEachLaunch, 0, 1)],
dir => working.ForceOnboardingEachLaunch = Wrap(working.ForceOnboardingEachLaunch + dir, 2)));
#endif
// ---------------- Buttons ----------------
var apply = MenuUi.Button("Apply", () => SettingsService.SaveAndApply(working));