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,20 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Tiny static coordination bridge for the first-run onboarding overlay. <see cref="Active"/> is true while a
|
||||
/// coach-mark step is on screen (set each frame by <see cref="OnboardingSystem"/>); <see cref="HudSystem"/>
|
||||
/// reads it to suppress its own ad-hoc location/gate hint so the player ever sees a single prompt voice.
|
||||
/// A presentation-layer static, so it is RESET on play-enter (the CLAUDE.md stale-static rule) to avoid a
|
||||
/// stale flag surviving a fast-enter-playmode domain reload and leaving the HUD hint suppressed.
|
||||
/// </summary>
|
||||
public static class OnboardingState
|
||||
{
|
||||
/// <summary>True while the coach-mark sequence is the active prompt voice (a step is being shown).</summary>
|
||||
public static bool Active;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
static void ResetOnPlayEnter() => Active = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4e9b3bb074eef1c40b90591e85b90e32
|
||||
@@ -0,0 +1,131 @@
|
||||
using ProjectM.Simulation;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure, engine-free logic for the first-run onboarding coach-mark sequence — the testable core of
|
||||
/// <see cref="OnboardingSystem"/> (mirrors the project's <c>*Math</c> helper discipline; no UnityEngine /
|
||||
/// Entities types so it unit-tests as plain C#). Defines the ordered step list, a <see cref="Snapshot"/> of the
|
||||
/// observable client state each step reads, the deterministic per-step completion test, prompt copy, the
|
||||
/// spatial-cue kind, and the persisted-mask helpers.
|
||||
///
|
||||
/// Pacing (operator-locked = soft-gated): a step shows until its action is performed (no per-step timeout),
|
||||
/// EXCEPT two info beats — <see cref="Fabricator"/> and <see cref="Defend"/> — which also auto-advance, plus
|
||||
/// the timed <see cref="Welcome"/> strip. Veteran / co-op auto-suppress falls out for free: the count-based
|
||||
/// steps (<see cref="Build"/>, <see cref="Fabricator"/>) test an ABSOLUTE structure count, so a client joining
|
||||
/// an already-built base satisfies them on entry and skips straight past.
|
||||
/// </summary>
|
||||
public static class OnboardingStepMath
|
||||
{
|
||||
// ---- ordered steps (byte ids; bit i of GameSettings.OnboardingMask = step i complete) ----
|
||||
public const byte Welcome = 0; // tiny win-condition framing strip (timed)
|
||||
public const byte Move = 1;
|
||||
public const byte Mine = 2; // attack an Ore node — mining IS combat at the base (Calm = no enemies yet)
|
||||
public const byte Build = 3; // open palette + place a Turret
|
||||
public const byte Fabricator = 4; // Ore -> Charge (soft info beat)
|
||||
public const byte Gate = 5; // reach the Expedition Gate
|
||||
public const byte Clear = 6; // clear the expedition wave (the first real enemies)
|
||||
public const byte Return = 7; // walk back to base to bank +1 charge
|
||||
public const byte Defend = 8; // survive the retaliation siege (soft info beat)
|
||||
public const byte Done = 9; // closing beat
|
||||
public const byte StepCount = 10;
|
||||
|
||||
// ---- tunable thresholds (public so the EditMode tests pin the contract) ----
|
||||
public const float WelcomeSeconds = 5f;
|
||||
public const float MoveThreshold = 3f; // accumulated player movement (world units)
|
||||
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
|
||||
|
||||
// ---- spatial-cue kinds the System resolves to a live world target ----
|
||||
public const byte PointerNone = 0;
|
||||
public const byte PointerOreNode = 1;
|
||||
public const byte PointerBaseGate = 2; // the base-region gate (go to the expedition)
|
||||
public const byte PointerExpeditionGate = 3; // the expedition-region gate (return home)
|
||||
|
||||
/// <summary>Observable client state for one evaluation. Built by the System from ECS + input each frame.</summary>
|
||||
public struct Snapshot
|
||||
{
|
||||
public float StepElapsed; // seconds the current step has been shown
|
||||
public float MoveDistance; // accumulated player movement since the Move step began
|
||||
public int OreNow; // shared-ledger Ore right now
|
||||
public int OreBaseline; // ledger Ore captured when the Mine step began
|
||||
public int TurretCount; // live Turret structures (absolute)
|
||||
public int FabricatorCount; // live Fabricator structures (absolute)
|
||||
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 byte Phase; // CycleState.Phase (Calm/Siege)
|
||||
}
|
||||
|
||||
/// <summary>True when the step's taught action is complete (or its soft timeout has elapsed).</summary>
|
||||
public static bool IsSatisfied(byte step, in Snapshot s)
|
||||
{
|
||||
switch (step)
|
||||
{
|
||||
case Welcome: return s.StepElapsed >= WelcomeSeconds;
|
||||
case Move: return s.MoveDistance >= MoveThreshold;
|
||||
case Mine: return s.OreNow > s.OreBaseline;
|
||||
case Build: return s.TurretCount >= 1;
|
||||
case Fabricator: return s.FabricatorCount >= 1 || s.StepElapsed >= FabricatorSoftSeconds;
|
||||
case Gate: return s.OnExpedition || s.ObjectiveState == ExpeditionObjectiveState.Active;
|
||||
case Clear: return s.ObjectiveState == ExpeditionObjectiveState.Cleared;
|
||||
case Return: return !s.OnExpedition; // entered while on expedition; satisfied on crossing home
|
||||
case Defend: return s.SawSiege ? s.Phase == CyclePhase.Calm : s.StepElapsed >= DefendNoSiegeSeconds;
|
||||
case Done: return s.StepElapsed >= DoneSeconds;
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Which world target (if any) the prompt should point at this step.</summary>
|
||||
public static byte PointerKind(byte step)
|
||||
{
|
||||
switch (step)
|
||||
{
|
||||
case Mine: return PointerOreNode;
|
||||
case Gate: return PointerBaseGate;
|
||||
case Return: return PointerExpeditionGate;
|
||||
default: return PointerNone;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ultra-short, verb-first prompt copy with the player's real input glyph (scheme-aware).</summary>
|
||||
public static string Prompt(byte step, bool gamepad)
|
||||
{
|
||||
string move = gamepad ? "Left Stick" : "WASD";
|
||||
string attack = gamepad ? "RT" : "LMB";
|
||||
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 Move: return move + " — Move";
|
||||
case Mine: return attack + " — Attack the glowing Ore to mine it";
|
||||
case Build: return build + " — open Build, then place a Turret by your Core";
|
||||
case Fabricator: return "Build a Fabricator — turrets need Charge (Ore → ammo)";
|
||||
case Gate: return "Reach the Expedition Gate — clearing it charges the Engine";
|
||||
case Clear: return "Clear the zone — defeat every enemy";
|
||||
case Return: return "Return through the gate — bank your clear (+1 Engine charge)";
|
||||
case Defend: return "Defend the Core! — hold the line through the siege";
|
||||
case Done: return "You've got it. Clear expeditions to fill the Engine and win.";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- persisted-mask helpers (GameSettings.OnboardingMask) ----
|
||||
|
||||
/// <summary>All steps complete (the sequence is dormant).</summary>
|
||||
public static bool AllComplete(int mask)
|
||||
{
|
||||
int all = (1 << StepCount) - 1;
|
||||
return (mask & all) == all;
|
||||
}
|
||||
|
||||
/// <summary>The lowest not-yet-completed step (resume point); <see cref="Done"/> when all are complete.</summary>
|
||||
public static byte FirstIncomplete(int mask)
|
||||
{
|
||||
for (byte i = 0; i < StepCount; i++)
|
||||
if ((mask & (1 << i)) == 0) return i;
|
||||
return Done;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c264496096436e74ebba163a7a5d2205
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b4828f5a68386fa4da379dcddbf629de
|
||||
Reference in New Issue
Block a user