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,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;
}
}
}