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,141 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ProjectM.Simulation;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// The replayable "How to Play" reference card (UI Toolkit), built on <see cref="MenuUi"/> like
|
||||
/// <see cref="SettingsScreen"/> 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: <see cref="Build"/> returns a fresh
|
||||
/// full-screen panel each call; the caller owns its lifetime (RemoveFromHierarchy on close).
|
||||
/// </summary>
|
||||
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<Button>();
|
||||
void Show(int idx)
|
||||
{
|
||||
content.Clear();
|
||||
BuildTab(content, idx);
|
||||
for (int i = 0; i < tabButtons.Count; i++)
|
||||
tabButtons[i].style.backgroundColor = i == idx
|
||||
? new Color(0.16f, 0.34f, 0.42f, 1f)
|
||||
: new Color(0.16f, 0.20f, 0.27f, 1f);
|
||||
}
|
||||
|
||||
for (int i = 0; i < Tabs.Length; i++)
|
||||
{
|
||||
int idx = i;
|
||||
var b = MenuUi.Button(Tabs[i], () => Show(idx));
|
||||
b.style.height = 32; b.style.fontSize = 13;
|
||||
b.style.marginLeft = 3; b.style.marginRight = 3;
|
||||
b.style.flexGrow = 1;
|
||||
tabButtons.Add(b);
|
||||
tabBar.Add(b);
|
||||
}
|
||||
|
||||
card.Add(MenuUi.Button("Back", () => onClose?.Invoke()));
|
||||
Show(0);
|
||||
return root;
|
||||
}
|
||||
|
||||
static void BuildTab(VisualElement c, int idx)
|
||||
{
|
||||
switch (idx)
|
||||
{
|
||||
case 0: // Controls (chosen class)
|
||||
bool ranger = WorldLauncher.SelectedClass == (byte)CharacterId.Ranger;
|
||||
Head(c, ranger ? "CLASS: Ranger (ranged anchor)" : "CLASS: Warrior (melee anchor)");
|
||||
Body(c, "Move — WASD / Left Stick");
|
||||
Body(c, "Aim — Mouse cursor / Right Stick");
|
||||
Body(c, "Attack — LMB / RT (your primary verb)");
|
||||
Body(c, "Build menu — Tab / Y");
|
||||
Body(c, "Place / Cancel (in build) — LMB / RMB (A / B on gamepad)");
|
||||
Body(c, "Deposit at base — G");
|
||||
Body(c, "Inventory — I");
|
||||
Body(c, "Pause — Esc");
|
||||
break;
|
||||
case 1: // The Loop
|
||||
Head(c, "THE LOOP — expeditions are how you win");
|
||||
Body(c, "1. BASE (Calm) — mine Ore, build Turrets + a Fabricator to defend your Engine Core.");
|
||||
Body(c, "2. EXPEDITION — walk to the Gate and fight the wave. CLEARING it is the progress beat.");
|
||||
Body(c, "3. RETURN — come home to bank the clear: +1 on the Engine meter.");
|
||||
Body(c, "4. SIEGE — returning provokes a retaliation attack. Defend the Core!");
|
||||
Body(c, "5. WIN — fill the Engine meter, then hold the final siege.");
|
||||
Body(c, "The Engine meter (top of screen) is the goal — base sieges are a consequence, not the win.");
|
||||
break;
|
||||
case 2: // Build & Economy
|
||||
Head(c, "RESOURCES & BUILDING");
|
||||
Body(c, "Ore — main currency. Mine it by attacking the glowing nodes at base.");
|
||||
Body(c, "Turret (40 Ore) — auto-fires at enemies. Needs Charge as ammo.");
|
||||
Body(c, "Fabricator (30 Ore) — converts Ore → Charge so turrets keep firing.");
|
||||
Body(c, "Wall (Biomass) — a cheap barrier that blocks enemies.");
|
||||
Body(c, "Aether — spend it on UPGRADE DMG to boost your damage.");
|
||||
Body(c, "Open Build with Tab (Y), pick a piece, click a green tile to place it.");
|
||||
break;
|
||||
case 3: // Threats
|
||||
Head(c, "THREATS");
|
||||
Body(c, "Husks assault the base during a Siege — keep them off the Engine Core.");
|
||||
Body(c, "A Husk that reaches the Core drains it. Lose the Core in the final siege and the run ends.");
|
||||
Body(c, "Expeditions throw waves at you — clear them all to bank the win.");
|
||||
Body(c, "Enemy variety scales the deeper you push.");
|
||||
break;
|
||||
default: // Win / Lose
|
||||
Head(c, "WIN / LOSE");
|
||||
Body(c, "WIN — clear expeditions to fill the Engine meter, then hold the final siege.");
|
||||
Body(c, "LOSE — the Engine Core falls during the final siege.");
|
||||
Body(c, "A Core breach mid-run is only a setback: resources lost, the Core recovers in Calm.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void Head(VisualElement c, string t)
|
||||
{
|
||||
var l = new Label(t);
|
||||
l.style.color = MenuUi.Accent;
|
||||
l.style.fontSize = 16;
|
||||
l.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
l.style.marginBottom = 8;
|
||||
l.style.whiteSpace = WhiteSpace.Normal;
|
||||
var th = HudTheme.Get();
|
||||
if (th != null) th.ApplyDisplay(l.style);
|
||||
c.Add(l);
|
||||
}
|
||||
|
||||
static void Body(VisualElement c, string t)
|
||||
{
|
||||
var l = new Label(t);
|
||||
l.style.color = MenuUi.TextCol;
|
||||
l.style.fontSize = 14;
|
||||
l.style.marginBottom = 5;
|
||||
l.style.whiteSpace = WhiteSpace.Normal;
|
||||
var th = HudTheme.Get();
|
||||
if (th != null) th.ApplyBody(l.style);
|
||||
c.Add(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fec1f60cab08999419a195c68923634e
|
||||
@@ -18,6 +18,7 @@ namespace ProjectM.Client
|
||||
UIDocument _doc;
|
||||
VisualElement _mainPanel;
|
||||
VisualElement _settingsPanel;
|
||||
VisualElement _howToPanel;
|
||||
TextField _ipField;
|
||||
Label _classLabel;
|
||||
|
||||
@@ -79,6 +80,21 @@ namespace ProjectM.Client
|
||||
card.Add(MenuUi.Button("Join", () => Launch(SessionMode.Join, false)));
|
||||
|
||||
card.Add(MenuUi.Button("Settings", ShowSettings));
|
||||
card.Add(MenuUi.Button("How to Play", ShowHowToPlay));
|
||||
|
||||
// Re-arm the first-run coach-marks (clears the client-local completed-step mask). The next session
|
||||
// replays them; the How-to-Play card stays available regardless.
|
||||
Button replayBtn = null;
|
||||
replayBtn = MenuUi.Button("Replay Tutorial", () =>
|
||||
{
|
||||
var s = SettingsService.Current;
|
||||
s.OnboardingMask = 0;
|
||||
s.TutorialHints = 1;
|
||||
SettingsService.Save(s);
|
||||
if (replayBtn != null) replayBtn.text = "Tutorial armed ✓";
|
||||
});
|
||||
card.Add(replayBtn);
|
||||
|
||||
card.Add(MenuUi.Button("Quit", Quit));
|
||||
|
||||
_mainPanel.Add(card);
|
||||
@@ -112,6 +128,19 @@ namespace ProjectM.Client
|
||||
_mainPanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
void ShowHowToPlay()
|
||||
{
|
||||
_mainPanel.style.display = DisplayStyle.None;
|
||||
_howToPanel = HowToPlayPanel.Build(HideHowToPlay);
|
||||
_doc.rootVisualElement.Add(_howToPanel);
|
||||
}
|
||||
|
||||
void HideHowToPlay()
|
||||
{
|
||||
if (_howToPanel != null) { _howToPanel.RemoveFromHierarchy(); _howToPanel = null; }
|
||||
_mainPanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
static void Quit()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace ProjectM.Client
|
||||
VisualElement _root;
|
||||
VisualElement _pausePanel;
|
||||
VisualElement _settingsPanel;
|
||||
VisualElement _howToPanel;
|
||||
bool _open;
|
||||
/// <summary>True while the pause overlay is shown (BuildSendSystem suspends build-clicks while paused).</summary>
|
||||
public static bool Open;
|
||||
@@ -49,6 +50,7 @@ namespace ProjectM.Client
|
||||
var card = MenuUi.Card("PAUSED");
|
||||
card.Add(MenuUi.Button("Resume", () => SetOpen(false)));
|
||||
card.Add(MenuUi.Button("Settings", ShowSettings));
|
||||
card.Add(MenuUi.Button("How to Play", ShowHowToPlay));
|
||||
card.Add(MenuUi.Button("Quit to Menu", WorldLauncher.TeardownToMenu));
|
||||
card.Add(MenuUi.Button("Quit to Desktop", Quit));
|
||||
_pausePanel.Add(card);
|
||||
@@ -61,6 +63,7 @@ namespace ProjectM.Client
|
||||
Open = open;
|
||||
if (_pausePanel != null) _pausePanel.style.display = open ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
if (!open && _settingsPanel != null) { _settingsPanel.RemoveFromHierarchy(); _settingsPanel = null; }
|
||||
if (!open && _howToPanel != null) { _howToPanel.RemoveFromHierarchy(); _howToPanel = null; }
|
||||
if (open) { UnityEngine.Cursor.lockState = CursorLockMode.None; UnityEngine.Cursor.visible = true; }
|
||||
}
|
||||
|
||||
@@ -75,6 +78,17 @@ namespace ProjectM.Client
|
||||
_root.Add(_settingsPanel);
|
||||
}
|
||||
|
||||
void ShowHowToPlay()
|
||||
{
|
||||
_pausePanel.style.display = DisplayStyle.None;
|
||||
_howToPanel = HowToPlayPanel.Build(() =>
|
||||
{
|
||||
if (_howToPanel != null) { _howToPanel.RemoveFromHierarchy(); _howToPanel = null; }
|
||||
_pausePanel.style.display = DisplayStyle.Flex;
|
||||
});
|
||||
_root.Add(_howToPanel);
|
||||
}
|
||||
|
||||
static void Quit()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
|
||||
@@ -59,6 +59,19 @@ namespace ProjectM.Client
|
||||
card.Add(VolumeRow("Music", working.Music, v => { working.Music = v; GameVolume.Music = v; }));
|
||||
card.Add(VolumeRow("SFX", working.Sfx, v => { working.Sfx = v; GameVolume.Sfx = v; }));
|
||||
|
||||
// ---------------- Onboarding ----------------
|
||||
card.Add(MenuUi.Caption("— ONBOARDING —"));
|
||||
string[] onoff = { "Off", "On" };
|
||||
card.Add(CycleRow("Tutorial Hints",
|
||||
() => onoff[Mathf.Clamp(working.TutorialHints, 0, 1)],
|
||||
dir => working.TutorialHints = Wrap(working.TutorialHints + dir, 2)));
|
||||
|
||||
// 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)",
|
||||
() => onoff[Mathf.Clamp(working.ForceOnboardingEachLaunch, 0, 1)],
|
||||
dir => working.ForceOnboardingEachLaunch = Wrap(working.ForceOnboardingEachLaunch + dir, 2)));
|
||||
|
||||
// ---------------- Buttons ----------------
|
||||
var apply = MenuUi.Button("Apply", () => SettingsService.SaveAndApply(working));
|
||||
apply.style.backgroundColor = new Color(0.12f, 0.30f, 0.22f, 1f);
|
||||
|
||||
Reference in New Issue
Block a user