diff --git a/Assets/_Project/Scripts/Client/Onboarding.meta b/Assets/_Project/Scripts/Client/Onboarding.meta
new file mode 100644
index 000000000..7e2971492
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Onboarding.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 0861914135cacf948ae2adfd7f7d6870
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs b/Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs
new file mode 100644
index 000000000..7742947de
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs
@@ -0,0 +1,20 @@
+using UnityEngine;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Tiny static coordination bridge for the first-run onboarding overlay. is true while a
+ /// coach-mark step is on screen (set each frame by );
+ /// reads it to suppress its own ad-hoc location/gate hint so the player ever sees a single prompt voice.
+ /// A presentation-layer static, so it is RESET on play-enter (the CLAUDE.md stale-static rule) to avoid a
+ /// stale flag surviving a fast-enter-playmode domain reload and leaving the HUD hint suppressed.
+ ///
+ public static class OnboardingState
+ {
+ /// True while the coach-mark sequence is the active prompt voice (a step is being shown).
+ public static bool Active;
+
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
+ static void ResetOnPlayEnter() => Active = false;
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs.meta b/Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs.meta
new file mode 100644
index 000000000..122cf07d9
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4e9b3bb074eef1c40b90591e85b90e32
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs b/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs
new file mode 100644
index 000000000..494d744ff
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs
@@ -0,0 +1,131 @@
+using ProjectM.Simulation;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Pure, engine-free logic for the first-run onboarding coach-mark sequence — the testable core of
+ /// (mirrors the project's *Math helper discipline; no UnityEngine /
+ /// Entities types so it unit-tests as plain C#). Defines the ordered step list, a 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 — and — which also auto-advance, plus
+ /// the timed strip. Veteran / co-op auto-suppress falls out for free: the count-based
+ /// steps (, ) test an ABSOLUTE structure count, so a client joining
+ /// an already-built base satisfies them on entry and skips straight past.
+ ///
+ 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)
+
+ /// Observable client state for one evaluation. Built by the System from ECS + input each frame.
+ 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)
+ }
+
+ /// True when the step's taught action is complete (or its soft timeout has elapsed).
+ 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;
+ }
+ }
+
+ /// Which world target (if any) the prompt should point at this step.
+ public static byte PointerKind(byte step)
+ {
+ switch (step)
+ {
+ case Mine: return PointerOreNode;
+ case Gate: return PointerBaseGate;
+ case Return: return PointerExpeditionGate;
+ default: return PointerNone;
+ }
+ }
+
+ /// Ultra-short, verb-first prompt copy with the player's real input glyph (scheme-aware).
+ 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) ----
+
+ /// All steps complete (the sequence is dormant).
+ public static bool AllComplete(int mask)
+ {
+ int all = (1 << StepCount) - 1;
+ return (mask & all) == all;
+ }
+
+ /// The lowest not-yet-completed step (resume point); when all are complete.
+ public static byte FirstIncomplete(int mask)
+ {
+ for (byte i = 0; i < StepCount; i++)
+ if ((mask & (1 << i)) == 0) return i;
+ return Done;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs.meta b/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs.meta
new file mode 100644
index 000000000..5c73bab17
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c264496096436e74ebba163a7a5d2205
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs b/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs
new file mode 100644
index 000000000..2dc64f077
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs
@@ -0,0 +1,321 @@
+using ProjectM.Simulation;
+using Unity.Entities;
+using Unity.Mathematics;
+using Unity.NetCode;
+using Unity.Transforms;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace ProjectM.Client
+{
+ ///
+ /// First-run onboarding overlay — a CLIENT-ONLY, observe-only presentation in
+ /// (same shape/constraints as : never mutates the
+ /// sim, never destroys a ghost, reads already-replicated state once per frame). Owns its own runtime UIDocument
+ /// (sortingOrder 60 — above the HUD's 50, below the pause overlay's 100) showing a single bottom-center
+ /// coach-mark prompt plus a world-space directional pointer.
+ ///
+ /// The sequence is PER-CLIENT and client-local: progress lives in
+ /// (via ), keyed to THIS player's own first-encounter — so a veteran host sees
+ /// nothing, a brand-new join-client is still taught, and a save wipe never re-teaches the host (the mask is in
+ /// settings.json, not the host-only SaveData). Soft-gated pacing: a step shows until its action is done; the
+ /// pure rules + auto-suppress (absolute count checks) live in .
+ ///
+ [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
+ [UpdateInGroup(typeof(PresentationSystemGroup))]
+ public partial class OnboardingSystem : SystemBase
+ {
+ const float ExpeditionRegionXMin = 500f; // player x past this = the +1000 expedition region (mirrors HudSystem)
+
+ GameObject _go;
+ UIDocument _doc;
+ bool _built;
+ Label _prompt;
+ Label _pointer;
+
+ // step machine (in-memory; persisted to the mask on each completion)
+ bool _maskLoaded;
+ int _mask;
+ byte _step;
+ bool _stepInit;
+ float _stepElapsed;
+ float _moveAccum;
+ float3 _lastPos;
+ int _oreBaseline;
+ bool _sawSiege;
+
+ protected override void OnStartRunning()
+ {
+ if (_go != null) return;
+ _go = new GameObject("~Onboarding");
+ _doc = _go.AddComponent();
+ _doc.panelSettings = MenuUi.LoadPanelSettings();
+ _doc.sortingOrder = 60; // above HUD (50), below pause (100)
+ }
+
+ protected override void OnDestroy()
+ {
+ OnboardingState.Active = false; // never let the static outlive its owning system (HUD suppression)
+ if (_go != null) Object.Destroy(_go);
+ }
+
+ protected override void OnUpdate()
+ {
+ if (_doc == null) return;
+ var root = _doc.rootVisualElement;
+ if (root == null) return;
+ if (!_built) { BuildTree(root); _built = true; }
+
+ float dt = SystemAPI.Time.DeltaTime; // wall-frame delta — correct in a presentation system
+
+ // ---- local player presence + position ----
+ bool havePlayer = false; float3 playerPos = default;
+ foreach (var lt in SystemAPI.Query>().WithAll())
+ { havePlayer = true; playerPos = lt.ValueRO.Position; break; }
+
+ var settings = SettingsService.Current;
+ if (!_maskLoaded)
+ {
+ _mask = settings.OnboardingMask;
+ _step = OnboardingStepMath.FirstIncomplete(_mask);
+ _stepInit = false;
+ _maskLoaded = true;
+ }
+ bool hintsOn = settings.TutorialHints != 0;
+
+ // Dormant (hints off / all steps done) or no local player yet → fully hidden, no voice.
+ if (!hintsOn || OnboardingStepMath.AllComplete(_mask) || !havePlayer)
+ {
+ OnboardingState.Active = false;
+ root.style.display = DisplayStyle.None;
+ return;
+ }
+
+ // ---- remaining observable state ----
+ int ore = LedgerOre();
+ CountStructures(out int turrets, out int fabs);
+ byte phase = CyclePhase.Calm;
+ if (SystemAPI.TryGetSingleton(out var cyc)) phase = cyc.Phase;
+ byte objState = ExpeditionObjectiveState.Idle;
+ if (SystemAPI.TryGetSingleton(out var obj)) objState = obj.State;
+ bool onExp = playerPos.x > ExpeditionRegionXMin;
+
+ // ---- per-step entry init (baselines) ----
+ if (!_stepInit)
+ {
+ _stepElapsed = 0f; _moveAccum = 0f; _sawSiege = false;
+ _oreBaseline = ore; _lastPos = playerPos;
+ _stepInit = true;
+ }
+
+ // ---- advance (FROZEN while the pause overlay is open, so the timed beats — Welcome/Fabricator/
+ // Defend/Done — aren't silently lost behind the pause dim that sits above this overlay) ----
+ if (!PauseMenuController.Open)
+ {
+ _stepElapsed += dt;
+ if (_step == OnboardingStepMath.Move) _moveAccum += math.distance(playerPos, _lastPos);
+ if (_step == OnboardingStepMath.Defend && phase == CyclePhase.Siege) _sawSiege = true;
+
+ var snap = new OnboardingStepMath.Snapshot
+ {
+ StepElapsed = _stepElapsed,
+ MoveDistance = _moveAccum,
+ OreNow = ore,
+ OreBaseline = _oreBaseline,
+ TurretCount = turrets,
+ FabricatorCount = fabs,
+ OnExpedition = onExp,
+ ObjectiveState = objState,
+ SawSiege = _sawSiege,
+ 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();
+ if (skip || OnboardingStepMath.IsSatisfied(_step, snap))
+ {
+ _mask |= (1 << _step);
+ Persist(_mask);
+ _step = OnboardingStepMath.FirstIncomplete(_mask); // auto-suppressed steps cascade one/frame
+ _stepInit = false;
+ if (OnboardingStepMath.AllComplete(_mask))
+ {
+ OnboardingState.Active = false;
+ root.style.display = DisplayStyle.None;
+ return;
+ }
+ }
+ }
+ _lastPos = playerPos;
+
+ // ---- show the current step ----
+ OnboardingState.Active = true;
+ root.style.display = DisplayStyle.Flex;
+ bool gamepad = AimPresentation.Scheme == InputSchemeId.Gamepad;
+ _prompt.text = OnboardingStepMath.Prompt(_step, gamepad);
+ UpdatePointer(_step, playerPos);
+ }
+
+ // ---- state gathering helpers ----
+
+ int LedgerOre()
+ {
+ if (SystemAPI.TryGetSingletonEntity(out var e))
+ {
+ var buf = SystemAPI.GetBuffer(e);
+ for (int i = 0; i < buf.Length; i++)
+ if (buf[i].ItemId == ResourceId.Ore) return buf[i].Count;
+ }
+ return 0;
+ }
+
+ void CountStructures(out int turrets, out int fabs)
+ {
+ turrets = 0; fabs = 0;
+ foreach (var ps in SystemAPI.Query>())
+ {
+ byte t = ps.ValueRO.Type;
+ if (t == StructureType.Turret) turrets++;
+ else if (t == StructureType.Fabricator) fabs++;
+ }
+ }
+
+ void Persist(int mask)
+ {
+ var s = SettingsService.Current;
+ s.OnboardingMask = mask;
+ SettingsService.Save(s); // atomic write; ~once per completed step
+ }
+
+ static bool AnyInputPressed()
+ {
+ var kb = UnityEngine.InputSystem.Keyboard.current;
+ // any key dismisses a message beat — EXCEPT Esc, which is the pause key (don't self-skip the framing).
+ if (kb != null && kb.anyKey.wasPressedThisFrame && !kb.escapeKey.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)
+ {
+ target = default;
+ if (kind == OnboardingStepMath.PointerOreNode)
+ {
+ float best = float.MaxValue; bool found = false;
+ foreach (var lt in SystemAPI.Query>().WithAll())
+ {
+ float d = math.distancesq(lt.ValueRO.Position, playerPos);
+ if (d < best) { best = d; target = lt.ValueRO.Position; found = true; }
+ }
+ return found;
+ }
+ // base gate (go) lives in the base region; expedition gate (return) lives past the region split.
+ bool wantBase = kind == OnboardingStepMath.PointerBaseGate;
+ foreach (var lt in SystemAPI.Query>().WithAll())
+ {
+ var p = lt.ValueRO.Position;
+ if ((p.x < ExpeditionRegionXMin) == wantBase) { target = p; return true; }
+ }
+ return false;
+ }
+
+ void UpdatePointer(byte step, float3 playerPos)
+ {
+ byte kind = OnboardingStepMath.PointerKind(step);
+ var cam = Camera.main;
+ var root = _doc.rootVisualElement;
+ if (kind == OnboardingStepMath.PointerNone || cam == null || !ResolveTarget(kind, playerPos, out float3 target))
+ { _pointer.style.display = DisplayStyle.None; return; }
+
+ float pw = root.layout.width, ph = root.layout.height;
+ if (pw <= 1f || ph <= 1f) { _pointer.style.display = DisplayStyle.None; return; }
+
+ Vector3 sp = cam.WorldToScreenPoint((Vector3)target);
+ bool behind = sp.z < 0f;
+ float px = (sp.x / Mathf.Max(1f, Screen.width)) * pw;
+ float py = (1f - sp.y / Mathf.Max(1f, Screen.height)) * ph;
+ if (behind) { px = pw - px; py = ph - py; }
+
+ const float margin = 64f;
+ bool off = behind || px < margin || px > pw - margin || py < margin || py > ph - margin;
+
+ float cx = pw * 0.5f, cy = ph * 0.5f;
+ float dx = px - cx, dy = py - cy;
+ float len = Mathf.Sqrt(dx * dx + dy * dy);
+ if (len < 0.001f) { dx = 1f; dy = 0f; len = 1f; }
+ float ndx = dx / len, ndy = dy / len;
+
+ float ax, ay;
+ if (off)
+ {
+ // intersect the center→target ray with the margin rectangle (edge arrow)
+ float tx = (ndx > 0 ? (pw - margin - cx) : (margin - cx)) / (Mathf.Abs(ndx) < 1e-4f ? (ndx < 0 ? -1e-4f : 1e-4f) : ndx);
+ float ty = (ndy > 0 ? (ph - margin - cy) : (margin - cy)) / (Mathf.Abs(ndy) < 1e-4f ? (ndy < 0 ? -1e-4f : 1e-4f) : ndy);
+ float tt = Mathf.Min(Mathf.Abs(tx), Mathf.Abs(ty));
+ ax = cx + ndx * tt; ay = cy + ndy * tt;
+ }
+ else { ax = px; ay = py - 44f; } // float just above the on-screen target
+
+ float angle = Mathf.Atan2(dy, dx) * Mathf.Rad2Deg; // "▶" art points +x at 0°
+ _pointer.style.left = ax - 15f;
+ _pointer.style.top = ay - 18f;
+ _pointer.style.rotate = new StyleRotate(new Rotate(new Angle(angle)));
+ _pointer.style.display = DisplayStyle.Flex;
+ }
+
+ // ---- UITK construction ----
+
+ void BuildTree(VisualElement root)
+ {
+ root.style.position = Position.Absolute;
+ root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0;
+ root.pickingMode = PickingMode.Ignore; // never eat world clicks
+
+ var panel = new VisualElement();
+ panel.style.position = Position.Absolute;
+ panel.style.bottom = 210; panel.style.left = 0; panel.style.right = 0;
+ panel.style.flexDirection = FlexDirection.Row;
+ panel.style.justifyContent = Justify.Center;
+ panel.style.alignItems = Align.Center;
+ panel.pickingMode = PickingMode.Ignore;
+
+ var chip = new VisualElement();
+ chip.style.backgroundColor = new Color(0.05f, 0.07f, 0.10f, 0.92f);
+ chip.style.paddingLeft = 22; chip.style.paddingRight = 22;
+ chip.style.paddingTop = 10; chip.style.paddingBottom = 10;
+ chip.style.maxWidth = 920;
+ chip.pickingMode = PickingMode.Ignore;
+ MenuUi.Round(chip, 8);
+ MenuUi.Border(chip, new Color(MenuUi.Accent.r, MenuUi.Accent.g, MenuUi.Accent.b, 0.55f), 1);
+
+ _prompt = new Label(string.Empty);
+ _prompt.style.color = MenuUi.TextCol;
+ _prompt.style.fontSize = 18;
+ _prompt.style.unityFontStyleAndWeight = FontStyle.Bold;
+ _prompt.style.unityTextAlign = TextAnchor.MiddleCenter;
+ _prompt.style.whiteSpace = WhiteSpace.Normal;
+ var theme = HudTheme.Get();
+ if (theme != null) theme.ApplyBody(_prompt.style);
+ chip.Add(_prompt);
+ panel.Add(chip);
+ root.Add(panel);
+
+ _pointer = new Label("▶"); // ▶ right-pointing triangle (rotated toward the target)
+ _pointer.style.position = Position.Absolute;
+ _pointer.style.fontSize = 30;
+ _pointer.style.color = MenuUi.Accent;
+ _pointer.style.unityFontStyleAndWeight = FontStyle.Bold;
+ _pointer.pickingMode = PickingMode.Ignore;
+ _pointer.style.display = DisplayStyle.None;
+ root.Add(_pointer);
+
+ root.style.display = DisplayStyle.None;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs.meta b/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs.meta
new file mode 100644
index 000000000..112f5e451
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b4828f5a68386fa4da379dcddbf629de
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs
index 1824cd303..5850181d3 100644
--- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs
+++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs
@@ -22,6 +22,7 @@ namespace ProjectM.Client
///
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
+ [UpdateAfter(typeof(OnboardingSystem))] // read OnboardingState.Active same-frame (single prompt voice)
public partial class HudSystem : SystemBase
{
// ---- palette (Aether language; Synty white skins are tinted into these) ----
@@ -290,6 +291,9 @@ namespace ProjectM.Client
_locationText.text = "BASE OVERRUN - resources lost; the Core will recover";
_locationText.style.color = new Color(1f, 0.3f, 0.25f);
}
+ // First-run onboarding owns the prompt voice: while a coach-mark step is showing, blank the HUD's own
+ // location/gate hint so the player sees a single prompt (OnboardingSystem drives its own overlay).
+ if (OnboardingState.Active) _locationText.text = "";
// ---- END-2: terminal run banner (Victory / Loss), observed from the replicated RunOutcome ----
if (SystemAPI.TryGetSingleton(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress)
{
diff --git a/Assets/_Project/Scripts/Client/Settings/GameSettings.cs b/Assets/_Project/Scripts/Client/Settings/GameSettings.cs
index a03f44d25..2a31c23c0 100644
--- a/Assets/_Project/Scripts/Client/Settings/GameSettings.cs
+++ b/Assets/_Project/Scripts/Client/Settings/GameSettings.cs
@@ -12,7 +12,7 @@ namespace ProjectM.Client
[Serializable]
public struct GameSettings
{
- public const int CurrentVersion = 1;
+ public const int CurrentVersion = 2;
public int Version;
@@ -30,6 +30,13 @@ namespace ProjectM.Client
public float Music;
public float Sfx;
+ // ---- Onboarding (client-local first-run state; NEVER replicated — a Join client keeps its own,
+ // unlike the host-only SaveData) ----
+ public int TutorialHints; // 0 = first-run coach-marks off, 1 = on
+ public int OnboardingMask; // bitmask of completed coach-mark steps (0 = nothing seen; bit i = step i done)
+ public int ForceOnboardingEachLaunch; // DEV: 1 = wipe OnboardingMask + force hints on at every launch so the
+ // first-run coach-marks always replay fresh (additive; 0-default off)
+
/// Sensible defaults derived from the current display + active quality level.
public static GameSettings Defaults()
{
@@ -47,6 +54,9 @@ namespace ProjectM.Client
Master = 1f,
Music = 1f,
Sfx = 1f,
+ TutorialHints = 1,
+ OnboardingMask = 0,
+ ForceOnboardingEachLaunch = 0,
};
}
@@ -65,6 +75,9 @@ namespace ProjectM.Client
s.Master = Mathf.Clamp01(s.Master);
s.Music = Mathf.Clamp01(s.Music);
s.Sfx = Mathf.Clamp01(s.Sfx);
+ s.TutorialHints = s.TutorialHints != 0 ? 1 : 0;
+ s.ForceOnboardingEachLaunch = s.ForceOnboardingEachLaunch != 0 ? 1 : 0;
+ // OnboardingMask is an opaque bitmask — deliberately NOT clamped.
return s;
}
}
diff --git a/Assets/_Project/Scripts/Client/Settings/SettingsService.cs b/Assets/_Project/Scripts/Client/Settings/SettingsService.cs
index c4145b095..1f48a94c6 100644
--- a/Assets/_Project/Scripts/Client/Settings/SettingsService.cs
+++ b/Assets/_Project/Scripts/Client/Settings/SettingsService.cs
@@ -22,6 +22,18 @@ namespace ProjectM.Client
static void Boot()
{
Load();
+ // DEV convenience ("Force Each Launch", Settings → Onboarding): wipe the persisted completed-step mask at
+ // EVERY boot — each editor Play-enter / each built-player launch runs this hook — so the first-run
+ // coach-marks always replay from the top, and force hints on so they actually show. In-memory only (the
+ // wipe is NOT written back to disk); OnboardingSystem re-persists progress as the player advances, and the
+ // next launch wipes it again. Toggle it off to return to normal once-only first-run behaviour.
+ if (Current.ForceOnboardingEachLaunch != 0)
+ {
+ var s = Current;
+ s.OnboardingMask = 0;
+ s.TutorialHints = 1;
+ Current = s;
+ }
Apply(Current);
}
@@ -103,14 +115,21 @@ namespace ProjectM.Client
// Additive-only as the schema grows (never throws on an unknown version).
static GameSettings Migrate(GameSettings old)
{
- var def = GameSettings.Defaults();
- if (old.ResWidth > 0) def.ResWidth = old.ResWidth;
- if (old.ResHeight > 0) def.ResHeight = old.ResHeight;
- if (old.Master > 0f) def.Master = old.Master;
- if (old.Music > 0f) def.Music = old.Music;
- if (old.Sfx > 0f) def.Sfx = old.Sfx;
- def.Version = GameSettings.CurrentVersion;
- return def;
+ // Preserve EVERY recognized field from the old save (Load() Clamps the result, fixing any 0/garbage),
+ // and seed ONLY the genuinely-new fields — a v1 file deserializes those to 0. Migrating from a fresh
+ // Defaults() instead would silently reset graphics fields (display mode / quality / v-sync / fps cap /
+ // refresh rate) that v1 already carried — a regression the version bump would otherwise surface.
+ var s = old;
+ if (old.Version < 2)
+ {
+ // v1 had no onboarding fields. An existing settings.json ⇒ a returning player who already played a
+ // pre-onboarding build, so mark every coach-mark step complete (dormant); hints stay on so
+ // "Replay Tutorial" (which clears OnboardingMask) still re-arms.
+ s.TutorialHints = 1;
+ s.OnboardingMask = int.MaxValue;
+ }
+ s.Version = GameSettings.CurrentVersion;
+ return s;
}
}
}
diff --git a/Assets/_Project/Scripts/Client/UI/HowToPlayPanel.cs b/Assets/_Project/Scripts/Client/UI/HowToPlayPanel.cs
new file mode 100644
index 000000000..c3bbaf37a
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/UI/HowToPlayPanel.cs
@@ -0,0 +1,141 @@
+using System;
+using System.Collections.Generic;
+using ProjectM.Simulation;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace ProjectM.Client
+{
+ ///
+ /// The replayable "How to Play" reference card (UI Toolkit), built on like
+ /// and reachable from BOTH the main menu and the in-game pause overlay. Tabbed,
+ /// one glanceable page each: Controls (for the chosen class), The Loop (the annotated win-condition diagram —
+ /// the single highest-value page, since the inverted goal "clear expeditions to win" is the #1 new-player
+ /// confusion), Build & Economy, Threats, Win/Lose. Static + stateless: returns a fresh
+ /// full-screen panel each call; the caller owns its lifetime (RemoveFromHierarchy on close).
+ ///
+ public static class HowToPlayPanel
+ {
+ static readonly string[] Tabs = { "Controls", "The Loop", "Build & Economy", "Threats", "Win / Lose" };
+
+ public static VisualElement Build(Action onClose)
+ {
+ var root = MenuUi.FullScreenRoot(true);
+ var card = MenuUi.Card("HOW TO PLAY");
+ card.style.minWidth = 640;
+ card.style.maxWidth = 780;
+ root.Add(card);
+
+ var tabBar = new VisualElement();
+ tabBar.style.flexDirection = FlexDirection.Row;
+ tabBar.style.justifyContent = Justify.Center;
+ tabBar.style.marginBottom = 12;
+ card.Add(tabBar);
+
+ var content = new VisualElement();
+ content.style.minHeight = 230;
+ card.Add(content);
+
+ var tabButtons = new List