using ProjectM.Simulation; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; using UnityEngine; using UnityEngine.UIElements; namespace ProjectM.Client { /// /// First-run onboarding overlay — a CLIENT-ONLY, observe-only presentation in /// (same shape/constraints as : 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 /// (via ), 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 . /// [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(); _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>().WithAll()) { 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(out var cyc)) phase = cyc.Phase; byte objState = ExpeditionObjectiveState.Idle; if (SystemAPI.TryGetSingleton(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(out var e)) { var buf = SystemAPI.GetBuffer(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>()) { 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>().WithAll()) { 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>().WithAll()) { 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; } } }