First-run onboarding: contextual coach-marks + How-to-Play card + dev replay toggle
Teaches the deep, interlocking loop — especially the inverted win condition (you win by CLEARING EXPEDITIONS, not by surviving base sieges; DR-042/DR-043). - OnboardingSystem: client-only observe-only PresentationSystemGroup overlay (own UIDocument @ sortingOrder 60), soft-gated 10-beat coach-mark sequence with a world-space ▶ pointer; never mutates sim / never destroys a ghost. - OnboardingStepMath: pure, unit-tested step machine (snapshot + IsSatisfied + scheme-aware prompts + pointer kinds + persisted-mask helpers). - HowToPlayPanel: tabbed reference card (Controls / The Loop / Build / Threats / Win-Lose), reachable from the main menu and the pause overlay. - Per-client client-local state in GameSettings (TutorialHints + OnboardingMask bitmask, additive) — a Join client keeps its own; a host save-wipe never re-teaches. Settings toggle + menu "Replay Tutorial". - Dev "Force Each Launch" toggle (GameSettings.ForceOnboardingEachLaunch): SettingsService.Boot wipes the mask + forces hints on in-memory every launch so the tutorial always replays fresh. - HudSystem suppresses its own location hint while onboarding is active (single prompt voice), via OnboardingState + [UpdateAfter(OnboardingSystem)]. Validated green: 20/20 EditMode; Play smoke confirmed overlay render, clean U+25B6 pointer glyph, no system sort-cycle, and the force-wipe end-to-end. Docs: DR-043 + session log; reusable lesson archived in the build-gotchas note. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
using ProjectM.Simulation;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure, engine-free logic for the first-run onboarding coach-mark sequence — the testable core of
|
||||
/// <see cref="OnboardingSystem"/> (mirrors the project's <c>*Math</c> helper discipline; no UnityEngine /
|
||||
/// Entities types so it unit-tests as plain C#). Defines the ordered step list, a <see cref="Snapshot"/> of the
|
||||
/// observable client state each step reads, the deterministic per-step completion test, prompt copy, the
|
||||
/// spatial-cue kind, and the persisted-mask helpers.
|
||||
///
|
||||
/// Pacing (operator-locked = soft-gated): a step shows until its action is performed (no per-step timeout),
|
||||
/// EXCEPT two info beats — <see cref="Fabricator"/> and <see cref="Defend"/> — which also auto-advance, plus
|
||||
/// the timed <see cref="Welcome"/> strip. Veteran / co-op auto-suppress falls out for free: the count-based
|
||||
/// steps (<see cref="Build"/>, <see cref="Fabricator"/>) test an ABSOLUTE structure count, so a client joining
|
||||
/// an already-built base satisfies them on entry and skips straight past.
|
||||
/// </summary>
|
||||
public static class OnboardingStepMath
|
||||
{
|
||||
// ---- ordered steps (byte ids; bit i of GameSettings.OnboardingMask = step i complete) ----
|
||||
public const byte Welcome = 0; // tiny win-condition framing strip (timed)
|
||||
public const byte Move = 1;
|
||||
public const byte Mine = 2; // attack an Ore node — mining IS combat at the base (Calm = no enemies yet)
|
||||
public const byte Build = 3; // open palette + place a Turret
|
||||
public const byte Fabricator = 4; // Ore -> Charge (soft info beat)
|
||||
public const byte Gate = 5; // reach the Expedition Gate
|
||||
public const byte Clear = 6; // clear the expedition wave (the first real enemies)
|
||||
public const byte Return = 7; // walk back to base to bank +1 charge
|
||||
public const byte Defend = 8; // survive the retaliation siege (soft info beat)
|
||||
public const byte Done = 9; // closing beat
|
||||
public const byte StepCount = 10;
|
||||
|
||||
// ---- tunable thresholds (public so the EditMode tests pin the contract) ----
|
||||
public const float WelcomeSeconds = 5f;
|
||||
public const float MoveThreshold = 3f; // accumulated player movement (world units)
|
||||
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
|
||||
|
||||
// ---- spatial-cue kinds the System resolves to a live world target ----
|
||||
public const byte PointerNone = 0;
|
||||
public const byte PointerOreNode = 1;
|
||||
public const byte PointerBaseGate = 2; // the base-region gate (go to the expedition)
|
||||
public const byte PointerExpeditionGate = 3; // the expedition-region gate (return home)
|
||||
|
||||
/// <summary>Observable client state for one evaluation. Built by the System from ECS + input each frame.</summary>
|
||||
public struct Snapshot
|
||||
{
|
||||
public float StepElapsed; // seconds the current step has been shown
|
||||
public float MoveDistance; // accumulated player movement since the Move step began
|
||||
public int OreNow; // shared-ledger Ore right now
|
||||
public int OreBaseline; // ledger Ore captured when the Mine step began
|
||||
public int TurretCount; // live Turret structures (absolute)
|
||||
public int FabricatorCount; // live Fabricator structures (absolute)
|
||||
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 byte Phase; // CycleState.Phase (Calm/Siege)
|
||||
}
|
||||
|
||||
/// <summary>True when the step's taught action is complete (or its soft timeout has elapsed).</summary>
|
||||
public static bool IsSatisfied(byte step, in Snapshot s)
|
||||
{
|
||||
switch (step)
|
||||
{
|
||||
case Welcome: return s.StepElapsed >= WelcomeSeconds;
|
||||
case Move: return s.MoveDistance >= MoveThreshold;
|
||||
case Mine: return s.OreNow > s.OreBaseline;
|
||||
case Build: return s.TurretCount >= 1;
|
||||
case Fabricator: return s.FabricatorCount >= 1 || s.StepElapsed >= FabricatorSoftSeconds;
|
||||
case Gate: return s.OnExpedition || s.ObjectiveState == ExpeditionObjectiveState.Active;
|
||||
case Clear: return s.ObjectiveState == ExpeditionObjectiveState.Cleared;
|
||||
case Return: return !s.OnExpedition; // entered while on expedition; satisfied on crossing home
|
||||
case Defend: return s.SawSiege ? s.Phase == CyclePhase.Calm : s.StepElapsed >= DefendNoSiegeSeconds;
|
||||
case Done: return s.StepElapsed >= DoneSeconds;
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Which world target (if any) the prompt should point at this step.</summary>
|
||||
public static byte PointerKind(byte step)
|
||||
{
|
||||
switch (step)
|
||||
{
|
||||
case Mine: return PointerOreNode;
|
||||
case Gate: return PointerBaseGate;
|
||||
case Return: return PointerExpeditionGate;
|
||||
default: return PointerNone;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ultra-short, verb-first prompt copy with the player's real input glyph (scheme-aware).</summary>
|
||||
public static string Prompt(byte step, bool gamepad)
|
||||
{
|
||||
string move = gamepad ? "Left Stick" : "WASD";
|
||||
string attack = gamepad ? "RT" : "LMB";
|
||||
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 Move: return move + " — Move";
|
||||
case Mine: return attack + " — Attack the glowing Ore to mine it";
|
||||
case Build: return build + " — open Build, then place a Turret by your Core";
|
||||
case Fabricator: return "Build a Fabricator — turrets need Charge (Ore → ammo)";
|
||||
case Gate: return "Reach the Expedition Gate — clearing it charges the Engine";
|
||||
case Clear: return "Clear the zone — defeat every enemy";
|
||||
case Return: return "Return through the gate — bank your clear (+1 Engine charge)";
|
||||
case Defend: return "Defend the Core! — hold the line through the siege";
|
||||
case Done: return "You've got it. Clear expeditions to fill the Engine and win.";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- persisted-mask helpers (GameSettings.OnboardingMask) ----
|
||||
|
||||
/// <summary>All steps complete (the sequence is dormant).</summary>
|
||||
public static bool AllComplete(int mask)
|
||||
{
|
||||
int all = (1 << StepCount) - 1;
|
||||
return (mask & all) == all;
|
||||
}
|
||||
|
||||
/// <summary>The lowest not-yet-completed step (resume point); <see cref="Done"/> when all are complete.</summary>
|
||||
public static byte FirstIncomplete(int mask)
|
||||
{
|
||||
for (byte i = 0; i < StepCount; i++)
|
||||
if ((mask & (1 << i)) == 0) return i;
|
||||
return Done;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user