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