From ab657fa57885fe1e78132b863fc71681daea72ea Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Sun, 5 Jul 2026 20:21:42 -0700 Subject: [PATCH] Onboarding + loop flow for a hands-off demo; GoalTarget 4->2 Lap-1 Fabricator affordable (StartingOre 50->90); mine prompt shows IN the room; Return no longer false-completes on death (WasOnExpedition latch + soft timeout, never the 1-tick Returning edge); HUD keeps room/siege/ammo cues during combat steps; Welcome needs an explicit confirm; READY panel shows FINAL DEFENSE when goal-full; dev Force-Each-Launch hidden from public Settings. GoalProgress.Target 4->2 (~10-20 min demo). Onboarding tests updated to the new contracts (456/456). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Authoring/World/CycleDirectorAuthoring.cs | 2 +- .../Client/Onboarding/OnboardingState.cs | 8 +++++- .../Client/Onboarding/OnboardingStepMath.cs | 19 +++++++++---- .../Client/Onboarding/OnboardingSystem.cs | 27 ++++++++++++++++--- .../Scripts/Client/UI/SettingsScreen.cs | 6 ++++- .../Tests/EditMode/OnboardingStepTests.cs | 26 +++++++++++------- 6 files changed, 67 insertions(+), 21 deletions(-) diff --git a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs index 7e8ecad8a..db9e7d343 100644 --- a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs @@ -59,7 +59,7 @@ namespace ProjectM.Authoring }); AddComponent(entity); AddBuffer(entity); - AddComponent(entity, new GoalProgress { Charge = 0, Target = 4 }); // DR-042: 4 expedition clears -> the climactic final siege + AddComponent(entity, new GoalProgress { Charge = 0, Target = 2 }); // DR-044 demo pacing: 2 expedition clears -> the climactic final siege (~10-20 min hands-off run) // END-1: the losable Engine Core rides this GLOBAL ghost (no new ghost / no relevancy). Born full; // CycleDirectorSpawnSystem overrides Current with a persisted wounded value on Continue. AddComponent(entity, new CoreIntegrity diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs b/Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs index 7742947de..f43c3fecf 100644 --- a/Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs +++ b/Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs @@ -14,7 +14,13 @@ namespace ProjectM.Client /// True while the coach-mark sequence is the active prompt voice (a step is being shown). public static bool Active; + /// True only while an EARLY base-framing step (Welcome/Move/Build/Fabricator/ReadyUp) is showing, so + /// HudSystem blanks its own location line for those (the coach-mark owns the voice) but LEAVES the room/siege/ + /// out-of-ammo cues visible during the combat steps (D4). Set each frame by OnboardingSystem. + public static bool SuppressLocationLine; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] - static void ResetOnPlayEnter() => Active = false; + static void ResetOnPlayEnter() { Active = false; SuppressLocationLine = false; } } } diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs b/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs index 0136884b7..615fbba82 100644 --- a/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs +++ b/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs @@ -42,6 +42,8 @@ namespace ProjectM.Client public const float FabricatorSoftSeconds = 14f; // soft beat auto-advance if no Fabricator built public const float DefendNoSiegeSeconds = 20f; // advance if no siege ever materialises public const float DoneSeconds = 6f; // closing beat lingers before going dormant + public const float RoomsSeconds = 7f; // D2: keep the mine-the-crystals prompt + node pointer up a beat AFTER teleport (past the ~3s launch countdown -> ~4s in the room) + public const float ReturnMaxSeconds = 75f; // D3: soft backstop so a missed homecoming signal can NEVER stall the sequence // ---- spatial-cue kinds the System resolves to a live world target ---- public const byte PointerNone = 0; @@ -61,6 +63,8 @@ namespace ProjectM.Client public bool OnExpedition; // local player is in the expedition region public byte ObjectiveState; // ExpeditionObjective.State (Idle/Active/Cleared) public bool SawSiege; // a Siege phase was observed while the Defend step was showing + public bool WasOnExpedition;// D3: latched true once the player was seen on expedition during the Return step (so a start-at-base Return doesn't instantly satisfy) + public byte Phase; // CycleState.Phase (Calm/Siege) } @@ -74,11 +78,11 @@ namespace ProjectM.Client case Build: return s.TurretCount >= 1; case Fabricator: return s.FabricatorCount >= 1 || s.StepElapsed >= FabricatorSoftSeconds; case ReadyUp: return s.LocalReady || s.Lifecycle != RunLifecycle.Staging; - case Rooms: return s.OnExpedition || s.ObjectiveState == ExpeditionObjectiveState.Active; + case Rooms: return s.OnExpedition && s.StepElapsed >= RoomsSeconds; // D2: show the mine prompt + node pointer IN the room, not the instant we teleport case Boon: return s.ObjectiveState == ExpeditionObjectiveState.Cleared || s.Lifecycle == RunLifecycle.RoomReward || s.Lifecycle == RunLifecycle.RouteSelect; - case Return: return !s.OnExpedition; // entered mid-run; satisfied on coming home + case Return: return (s.WasOnExpedition && !s.OnExpedition) || s.StepElapsed >= ReturnMaxSeconds; // D3: home AFTER being on expedition, else a soft timeout (never gate on the 1-tick Returning edge) case Defend: return s.SawSiege ? s.Phase == CyclePhase.Calm : s.StepElapsed >= DefendNoSiegeSeconds; case Done: return s.StepElapsed >= DoneSeconds; default: return true; @@ -102,22 +106,27 @@ namespace ProjectM.Client string build = gamepad ? "Y" : "Tab"; // matches the existing HUD build-discovery chip glyph switch (step) { - case Welcome: return "CLEAR EXPEDITIONS to charge the Engine — defend the Core while you do. (Esc → Pause → How to Play)"; + case Welcome: return "CLEAR 2 EXPEDITIONS to charge the Engine, then survive the FINAL SIEGE. (Space to continue · Esc → How to Play)"; case Move: return move + " — Move"; case Build: return build + " — open Build, place a Turret by your Core (your 50 starting Ore covers it)"; case Fabricator: return "Build a Fabricator — turrets need Charge (Ore → ammo)"; case ReadyUp: return "Press T (or click READY UP) — when everyone is ready, the party launches"; case Rooms: return "Fight through the rooms — attack the glowing crystals to haul resources home"; case Boon: return "Clear the room — pick 1 of 3 BOONS, then choose your path on the map"; - case Return: return "Reach the boss and fell it — the haul comes home and the Engine charges (+1)"; + case Return: return "Fell the ALPHA HUSK, then return home — your haul + the Engine charge (+1) come with you"; case Defend: return "Defend the Core! — a completed run provokes a retaliation siege"; - case Done: return "You've got it. Clear expeditions to fill the Engine and win."; + case Done: return "You've got it — clear 2 expeditions to fill the Engine, then hold the final siege to win."; default: return ""; } } // ---- persisted-mask helpers (GameSettings.OnboardingMask) ---- + /// D4: the early BASE-framing steps (the coach-mark owns the prompt voice, so the HUD blanks its own + /// location line) vs the combat steps (the HUD MUST keep showing room/siege/out-of-ammo cues). Welcome..ReadyUp + /// are early. Read each frame by OnboardingSystem to set OnboardingState.SuppressLocationLine. + public static bool IsEarlyStep(byte step) => step <= ReadyUp; + /// All steps complete (the sequence is dormant). public static bool AllComplete(int mask) { diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs b/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs index 4bb82cb44..bf97f0c79 100644 --- a/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs +++ b/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs @@ -42,6 +42,8 @@ namespace ProjectM.Client float _moveAccum; float3 _lastPos; bool _sawSiege; + bool _wasOnExpedition; // D3: latched true once the player is seen on expedition during the Return step + protected override void OnStartRunning() { @@ -64,6 +66,8 @@ namespace ProjectM.Client var root = _doc.rootVisualElement; if (root == null) return; if (!_built) { BuildTree(root); _built = true; } + OnboardingState.SuppressLocationLine = false; // D4: default off each frame; a shown step sets it per-step below + float dt = SystemAPI.Time.DeltaTime; // wall-frame delta — correct in a presentation system @@ -106,7 +110,7 @@ namespace ProjectM.Client // ---- per-step entry init (baselines) ---- if (!_stepInit) { - _stepElapsed = 0f; _moveAccum = 0f; _sawSiege = false; + _stepElapsed = 0f; _moveAccum = 0f; _sawSiege = false; _wasOnExpedition = false; _lastPos = playerPos; _stepInit = true; } @@ -118,6 +122,8 @@ namespace ProjectM.Client _stepElapsed += dt; if (_step == OnboardingStepMath.Move) _moveAccum += math.distance(playerPos, _lastPos); if (_step == OnboardingStepMath.Defend && phase == CyclePhase.Siege) _sawSiege = true; + if (_step == OnboardingStepMath.Return && onExp) _wasOnExpedition = true; // D3: latch "was on expedition" so a start-at-base Return doesn't instantly satisfy + var snap = new OnboardingStepMath.Snapshot { @@ -130,12 +136,13 @@ namespace ProjectM.Client OnExpedition = onExp, ObjectiveState = objState, SawSiege = _sawSiege, + WasOnExpedition = _wasOnExpedition, Phase = phase, }; // The two pure-message beats can be dismissed with any input EXCEPT Esc (Esc opens Pause; see // AnyInputPressed) so following the "Esc → Pause → How to Play" hint doesn't self-skip the framing. - bool skip = (_step == OnboardingStepMath.Welcome || _step == OnboardingStepMath.Done) && AnyInputPressed(); + bool skip = (_step == OnboardingStepMath.Welcome && ConfirmPressed()) || (_step == OnboardingStepMath.Done && AnyInputPressed()); // D5: Welcome dismisses ONLY on an explicit confirm (not WASD) so the win-condition strip is actually read if (skip || OnboardingStepMath.IsSatisfied(_step, snap)) { _mask |= (1 << _step); @@ -154,6 +161,7 @@ namespace ProjectM.Client // ---- show the current step ---- OnboardingState.Active = true; + OnboardingState.SuppressLocationLine = OnboardingStepMath.IsEarlyStep(_step); // D4: blank the HUD line only for the early base-framing steps root.style.display = DisplayStyle.Flex; bool gamepad = AimPresentation.Scheme == InputSchemeId.Gamepad; _prompt.text = OnboardingStepMath.Prompt(_step, gamepad); @@ -194,7 +202,20 @@ namespace ProjectM.Client return false; } - // ---- world-space pointer ---- + // D5: an EXPLICIT confirm only (Space/Enter/click/South) — movement keys must NOT skip the Welcome strip. + static bool ConfirmPressed() + { + var kb = UnityEngine.InputSystem.Keyboard.current; + if (kb != null && (kb.spaceKey.wasPressedThisFrame || kb.enterKey.wasPressedThisFrame)) return true; + var ms = UnityEngine.InputSystem.Mouse.current; + if (ms != null && ms.leftButton.wasPressedThisFrame) return true; + var gp = UnityEngine.InputSystem.Gamepad.current; + if (gp != null && (gp.buttonSouth.wasPressedThisFrame || gp.startButton.wasPressedThisFrame)) return true; + return false; + } + + +// ---- world-space pointer ---- bool ResolveTarget(byte kind, float3 playerPos, out float3 target) { diff --git a/Assets/_Project/Scripts/Client/UI/SettingsScreen.cs b/Assets/_Project/Scripts/Client/UI/SettingsScreen.cs index 0e2e31af0..0f9850c65 100644 --- a/Assets/_Project/Scripts/Client/UI/SettingsScreen.cs +++ b/Assets/_Project/Scripts/Client/UI/SettingsScreen.cs @@ -68,9 +68,13 @@ namespace ProjectM.Client // DEV: forces the first-run coach-marks to replay fresh on every launch (wipes the completed-step mask at // each boot — see SettingsService.Boot). Off = normal once-only first-run behaviour. - card.Add(CycleRow("Force Each Launch (Dev)", + #if UNITY_EDITOR // D7: dev-only onboarding replay toggle — hidden from the public demo Settings so a player can't lock themselves into permanent tutorial + +card.Add(CycleRow("Force Each Launch (Dev)", () => onoff[Mathf.Clamp(working.ForceOnboardingEachLaunch, 0, 1)], dir => working.ForceOnboardingEachLaunch = Wrap(working.ForceOnboardingEachLaunch + dir, 2))); +#endif + // ---------------- Buttons ---------------- var apply = MenuUi.Button("Apply", () => SettingsService.SaveAndApply(working)); diff --git a/Assets/_Project/Tests/EditMode/OnboardingStepTests.cs b/Assets/_Project/Tests/EditMode/OnboardingStepTests.cs index 62ee7418e..3fc903514 100644 --- a/Assets/_Project/Tests/EditMode/OnboardingStepTests.cs +++ b/Assets/_Project/Tests/EditMode/OnboardingStepTests.cs @@ -88,14 +88,16 @@ namespace ProjectM.Tests } [Test] - public void Rooms_AdvancesOnExpeditionEntryOrActiveObjective() + public void Rooms_AdvancesInRoomAfterMinimumDwell() { var s = Empty(); Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, s)); - var onExp = Empty(); onExp.OnExpedition = true; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, onExp)); - var active = Empty(); active.ObjectiveState = ExpeditionObjectiveState.Active; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, active)); + var onExpTooSoon = Empty(); onExpTooSoon.OnExpedition = true; onExpTooSoon.StepElapsed = OnboardingStepMath.RoomsSeconds - 0.1f; + Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, onExpTooSoon)); // D2: don't advance the instant we teleport in + var dwelled = Empty(); dwelled.OnExpedition = true; dwelled.StepElapsed = OnboardingStepMath.RoomsSeconds; + Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, dwelled)); // the mine prompt + node pointer showed IN the room + var elapsedAtBase = Empty(); elapsedAtBase.StepElapsed = OnboardingStepMath.RoomsSeconds + 5f; + Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, elapsedAtBase)); // elapsed alone (still at base) must NOT satisfy } [Test] @@ -112,12 +114,16 @@ namespace ProjectM.Tests } [Test] - public void Return_AdvancesOnLeavingExpedition() + public void Return_AdvancesOnComingHomeAfterExpeditionOrTimeout() { - var s = Empty(); s.OnExpedition = true; - Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, s)); - s.OnExpedition = false; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, s)); + var onExp = Empty(); onExp.WasOnExpedition = true; onExp.OnExpedition = true; + Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, onExp)); // still out on expedition + var neverLeftBase = Empty(); neverLeftBase.OnExpedition = false; // at base but never observed on expedition this step + Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, neverLeftBase)); // D3: a start-at-base Return must NOT instantly satisfy + var cameHome = Empty(); cameHome.WasOnExpedition = true; cameHome.OnExpedition = false; + Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, cameHome)); // was on expedition, now home + var timedOut = Empty(); timedOut.StepElapsed = OnboardingStepMath.ReturnMaxSeconds; + Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, timedOut)); // D3 soft backstop (never a 1-tick edge) } [Test]