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) <noreply@anthropic.com>
This commit is contained in:
@@ -59,7 +59,7 @@ namespace ProjectM.Authoring
|
||||
});
|
||||
AddComponent<ResourceLedger>(entity);
|
||||
AddBuffer<StorageEntry>(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
|
||||
|
||||
@@ -14,7 +14,13 @@ namespace ProjectM.Client
|
||||
/// <summary>True while the coach-mark sequence is the active prompt voice (a step is being shown).</summary>
|
||||
public static bool Active;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public static bool SuppressLocationLine;
|
||||
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
static void ResetOnPlayEnter() => Active = false;
|
||||
static void ResetOnPlayEnter() { Active = false; SuppressLocationLine = false; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) ----
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public static bool IsEarlyStep(byte step) => step <= ReadyUp;
|
||||
|
||||
/// <summary>All steps complete (the sequence is dormant).</summary>
|
||||
public static bool AllComplete(int mask)
|
||||
{
|
||||
|
||||
@@ -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,6 +202,19 @@ namespace ProjectM.Client
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -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.
|
||||
#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));
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user