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:
Luis Gonzalez
2026-06-29 14:18:22 -07:00
parent 3bb9999173
commit 29e90a5008
20 changed files with 1058 additions and 9 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0861914135cacf948ae2adfd7f7d6870
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Tiny static coordination bridge for the first-run onboarding overlay. <see cref="Active"/> is true while a
/// coach-mark step is on screen (set each frame by <see cref="OnboardingSystem"/>); <see cref="HudSystem"/>
/// 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.
/// </summary>
public static class OnboardingState
{
/// <summary>True while the coach-mark sequence is the active prompt voice (a step is being shown).</summary>
public static bool Active;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetOnPlayEnter() => Active = false;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4e9b3bb074eef1c40b90591e85b90e32
@@ -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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c264496096436e74ebba163a7a5d2205
@@ -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
{
/// <summary>
/// First-run onboarding overlay — a CLIENT-ONLY, observe-only presentation <see cref="SystemBase"/> in
/// <see cref="PresentationSystemGroup"/> (same shape/constraints as <see cref="HudSystem"/>: 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 <see cref="GameSettings.OnboardingMask"/>
/// (via <see cref="SettingsService"/>), 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 <see cref="OnboardingStepMath"/>.
/// </summary>
[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<UIDocument>();
_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<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
{ 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<CycleState>(out var cyc)) phase = cyc.Phase;
byte objState = ExpeditionObjectiveState.Idle;
if (SystemAPI.TryGetSingleton<ExpeditionObjective>(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<ResourceLedger>(out var e))
{
var buf = SystemAPI.GetBuffer<StorageEntry>(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<RefRO<PlacedStructure>>())
{
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<RefRO<LocalTransform>>().WithAll<ResourceNode>())
{
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<RefRO<LocalTransform>>().WithAll<ExpeditionGate>())
{
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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b4828f5a68386fa4da379dcddbf629de
@@ -22,6 +22,7 @@ namespace ProjectM.Client
/// </summary>
[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<RunOutcome>(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress)
{
@@ -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)
/// <summary>Sensible defaults derived from the current display + active quality level.</summary>
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;
}
}
@@ -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;
}
}
}
@@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using ProjectM.Simulation;
using UnityEngine;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// The replayable "How to Play" reference card (UI Toolkit), built on <see cref="MenuUi"/> like
/// <see cref="SettingsScreen"/> 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 &amp; Economy, Threats, Win/Lose. Static + stateless: <see cref="Build"/> returns a fresh
/// full-screen panel each call; the caller owns its lifetime (RemoveFromHierarchy on close).
/// </summary>
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<Button>();
void Show(int idx)
{
content.Clear();
BuildTab(content, idx);
for (int i = 0; i < tabButtons.Count; i++)
tabButtons[i].style.backgroundColor = i == idx
? new Color(0.16f, 0.34f, 0.42f, 1f)
: new Color(0.16f, 0.20f, 0.27f, 1f);
}
for (int i = 0; i < Tabs.Length; i++)
{
int idx = i;
var b = MenuUi.Button(Tabs[i], () => Show(idx));
b.style.height = 32; b.style.fontSize = 13;
b.style.marginLeft = 3; b.style.marginRight = 3;
b.style.flexGrow = 1;
tabButtons.Add(b);
tabBar.Add(b);
}
card.Add(MenuUi.Button("Back", () => onClose?.Invoke()));
Show(0);
return root;
}
static void BuildTab(VisualElement c, int idx)
{
switch (idx)
{
case 0: // Controls (chosen class)
bool ranger = WorldLauncher.SelectedClass == (byte)CharacterId.Ranger;
Head(c, ranger ? "CLASS: Ranger (ranged anchor)" : "CLASS: Warrior (melee anchor)");
Body(c, "Move — WASD / Left Stick");
Body(c, "Aim — Mouse cursor / Right Stick");
Body(c, "Attack — LMB / RT (your primary verb)");
Body(c, "Build menu — Tab / Y");
Body(c, "Place / Cancel (in build) — LMB / RMB (A / B on gamepad)");
Body(c, "Deposit at base — G");
Body(c, "Inventory — I");
Body(c, "Pause — Esc");
break;
case 1: // The Loop
Head(c, "THE LOOP — expeditions are how you win");
Body(c, "1. BASE (Calm) — mine Ore, build Turrets + a Fabricator to defend your Engine Core.");
Body(c, "2. EXPEDITION — walk to the Gate and fight the wave. CLEARING it is the progress beat.");
Body(c, "3. RETURN — come home to bank the clear: +1 on the Engine meter.");
Body(c, "4. SIEGE — returning provokes a retaliation attack. Defend the Core!");
Body(c, "5. WIN — fill the Engine meter, then hold the final siege.");
Body(c, "The Engine meter (top of screen) is the goal — base sieges are a consequence, not the win.");
break;
case 2: // Build & Economy
Head(c, "RESOURCES & BUILDING");
Body(c, "Ore — main currency. Mine it by attacking the glowing nodes at base.");
Body(c, "Turret (40 Ore) — auto-fires at enemies. Needs Charge as ammo.");
Body(c, "Fabricator (30 Ore) — converts Ore → Charge so turrets keep firing.");
Body(c, "Wall (Biomass) — a cheap barrier that blocks enemies.");
Body(c, "Aether — spend it on UPGRADE DMG to boost your damage.");
Body(c, "Open Build with Tab (Y), pick a piece, click a green tile to place it.");
break;
case 3: // Threats
Head(c, "THREATS");
Body(c, "Husks assault the base during a Siege — keep them off the Engine Core.");
Body(c, "A Husk that reaches the Core drains it. Lose the Core in the final siege and the run ends.");
Body(c, "Expeditions throw waves at you — clear them all to bank the win.");
Body(c, "Enemy variety scales the deeper you push.");
break;
default: // Win / Lose
Head(c, "WIN / LOSE");
Body(c, "WIN — clear expeditions to fill the Engine meter, then hold the final siege.");
Body(c, "LOSE — the Engine Core falls during the final siege.");
Body(c, "A Core breach mid-run is only a setback: resources lost, the Core recovers in Calm.");
break;
}
}
static void Head(VisualElement c, string t)
{
var l = new Label(t);
l.style.color = MenuUi.Accent;
l.style.fontSize = 16;
l.style.unityFontStyleAndWeight = FontStyle.Bold;
l.style.marginBottom = 8;
l.style.whiteSpace = WhiteSpace.Normal;
var th = HudTheme.Get();
if (th != null) th.ApplyDisplay(l.style);
c.Add(l);
}
static void Body(VisualElement c, string t)
{
var l = new Label(t);
l.style.color = MenuUi.TextCol;
l.style.fontSize = 14;
l.style.marginBottom = 5;
l.style.whiteSpace = WhiteSpace.Normal;
var th = HudTheme.Get();
if (th != null) th.ApplyBody(l.style);
c.Add(l);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fec1f60cab08999419a195c68923634e
@@ -18,6 +18,7 @@ namespace ProjectM.Client
UIDocument _doc;
VisualElement _mainPanel;
VisualElement _settingsPanel;
VisualElement _howToPanel;
TextField _ipField;
Label _classLabel;
@@ -79,6 +80,21 @@ namespace ProjectM.Client
card.Add(MenuUi.Button("Join", () => Launch(SessionMode.Join, false)));
card.Add(MenuUi.Button("Settings", ShowSettings));
card.Add(MenuUi.Button("How to Play", ShowHowToPlay));
// Re-arm the first-run coach-marks (clears the client-local completed-step mask). The next session
// replays them; the How-to-Play card stays available regardless.
Button replayBtn = null;
replayBtn = MenuUi.Button("Replay Tutorial", () =>
{
var s = SettingsService.Current;
s.OnboardingMask = 0;
s.TutorialHints = 1;
SettingsService.Save(s);
if (replayBtn != null) replayBtn.text = "Tutorial armed ✓";
});
card.Add(replayBtn);
card.Add(MenuUi.Button("Quit", Quit));
_mainPanel.Add(card);
@@ -112,6 +128,19 @@ namespace ProjectM.Client
_mainPanel.style.display = DisplayStyle.Flex;
}
void ShowHowToPlay()
{
_mainPanel.style.display = DisplayStyle.None;
_howToPanel = HowToPlayPanel.Build(HideHowToPlay);
_doc.rootVisualElement.Add(_howToPanel);
}
void HideHowToPlay()
{
if (_howToPanel != null) { _howToPanel.RemoveFromHierarchy(); _howToPanel = null; }
_mainPanel.style.display = DisplayStyle.Flex;
}
static void Quit()
{
#if UNITY_EDITOR
@@ -16,6 +16,7 @@ namespace ProjectM.Client
VisualElement _root;
VisualElement _pausePanel;
VisualElement _settingsPanel;
VisualElement _howToPanel;
bool _open;
/// <summary>True while the pause overlay is shown (BuildSendSystem suspends build-clicks while paused).</summary>
public static bool Open;
@@ -49,6 +50,7 @@ namespace ProjectM.Client
var card = MenuUi.Card("PAUSED");
card.Add(MenuUi.Button("Resume", () => SetOpen(false)));
card.Add(MenuUi.Button("Settings", ShowSettings));
card.Add(MenuUi.Button("How to Play", ShowHowToPlay));
card.Add(MenuUi.Button("Quit to Menu", WorldLauncher.TeardownToMenu));
card.Add(MenuUi.Button("Quit to Desktop", Quit));
_pausePanel.Add(card);
@@ -61,6 +63,7 @@ namespace ProjectM.Client
Open = open;
if (_pausePanel != null) _pausePanel.style.display = open ? DisplayStyle.Flex : DisplayStyle.None;
if (!open && _settingsPanel != null) { _settingsPanel.RemoveFromHierarchy(); _settingsPanel = null; }
if (!open && _howToPanel != null) { _howToPanel.RemoveFromHierarchy(); _howToPanel = null; }
if (open) { UnityEngine.Cursor.lockState = CursorLockMode.None; UnityEngine.Cursor.visible = true; }
}
@@ -75,6 +78,17 @@ namespace ProjectM.Client
_root.Add(_settingsPanel);
}
void ShowHowToPlay()
{
_pausePanel.style.display = DisplayStyle.None;
_howToPanel = HowToPlayPanel.Build(() =>
{
if (_howToPanel != null) { _howToPanel.RemoveFromHierarchy(); _howToPanel = null; }
_pausePanel.style.display = DisplayStyle.Flex;
});
_root.Add(_howToPanel);
}
static void Quit()
{
#if UNITY_EDITOR
@@ -59,6 +59,19 @@ namespace ProjectM.Client
card.Add(VolumeRow("Music", working.Music, v => { working.Music = v; GameVolume.Music = v; }));
card.Add(VolumeRow("SFX", working.Sfx, v => { working.Sfx = v; GameVolume.Sfx = v; }));
// ---------------- Onboarding ----------------
card.Add(MenuUi.Caption("— ONBOARDING —"));
string[] onoff = { "Off", "On" };
card.Add(CycleRow("Tutorial Hints",
() => onoff[Mathf.Clamp(working.TutorialHints, 0, 1)],
dir => working.TutorialHints = Wrap(working.TutorialHints + dir, 2)));
// 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)",
() => onoff[Mathf.Clamp(working.ForceOnboardingEachLaunch, 0, 1)],
dir => working.ForceOnboardingEachLaunch = Wrap(working.ForceOnboardingEachLaunch + dir, 2)));
// ---------------- Buttons ----------------
var apply = MenuUi.Button("Apply", () => SettingsService.SaveAndApply(working));
apply.style.backgroundColor = new Color(0.12f, 0.30f, 0.22f, 1f);