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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user