diff --git a/Assets/_Project/Scripts/Client/Presentation/BoonModalHudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/BoonModalHudSystem.cs new file mode 100644 index 000000000..0449c7574 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/BoonModalHudSystem.cs @@ -0,0 +1,164 @@ +using System.Collections.Generic; +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; +using Unity.Transforms; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.UIElements; + +namespace ProjectM.Client +{ + /// + /// The choice-of-3 boon modal (RoomReward) — extracted from into its own client-only, + /// observe-only presentation in . Owns its own + /// runtime UIDocument sharing (sortingOrder 55) so it composes into the + /// same UITK panel + event dispatcher as the HUD (50) / markers (48) / onboarding (60). Reads the local player's + /// replicated + the blob; card clicks enqueue through + /// . Built lazily on first show. + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + [UpdateInGroup(typeof(PresentationSystemGroup))] + public partial class BoonModalHudSystem : SystemBase + { + GameObject _go; + UIDocument _doc; + bool _built; + + VisualElement _boonModal, _boonCardRow; + int _boonShownFor; // last exact (Option0|Option1<<8|Option2<<16)+1 signature the modal was built for + bool _boonModalBuilt; + + protected override void OnStartRunning() + { + if (_go != null) return; + MenuUi.EnsureEventSystem(); + _go = new GameObject("~HUDBoonModal"); + _doc = _go.AddComponent(); + _doc.panelSettings = MenuUi.LoadPanelSettings(); + _doc.sortingOrder = 55; + } + + protected override void OnDestroy() + { + if (_go != null) Object.Destroy(_go); + } + + protected override void OnUpdate() + { + if (_doc == null) return; + var root = _doc.rootVisualElement; + if (root == null) return; // panel not initialised yet (next frame) + if (!_built) + { + 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 game-world clicks + _built = true; + } + + bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); + + BoonOffer localOffer = default; + bool hasOffer = false; + foreach (var off in SystemAPI.Query>().WithAll()) + { + localOffer = off.ValueRO; + hasOffer = true; + break; + } + BlobAssetReference boonPool = default; + if (SystemAPI.TryGetSingleton(out var bcat)) + boonPool = bcat.Value; + // Lifecycle gate (post-impl review): even a stale replicated Pending never shows the modal outside + // the reward window. + UpdateBoonModal(localOffer, hasOffer && localOffer.Pending == 1 + && haveRun && runInfo.Lifecycle == RunLifecycle.RoomReward, boonPool); + } + + void UpdateBoonModal(BoonOffer offer, bool show, BlobAssetReference pool) + { + if (!show || !pool.IsCreated) + { + if (_boonModal != null) _boonModal.style.display = DisplayStyle.None; + _boonShownFor = 0; + return; + } + var root = _doc != null ? _doc.rootVisualElement : null; + if (root == null) return; + if (!_boonModalBuilt) + { + BuildBoonModal(root); + _boonModalBuilt = true; + } + + // Rebuild the three cards only when the offer actually changes (a new room's deal). + // Exact signature (post-impl review): the old lossy byte XOR could collide across consecutive + // rooms and leave stale card labels. +1 keeps 0 as the hidden/reset sentinel. + int sig = 1 + (offer.Option0 | (offer.Option1 << 8) | (offer.Option2 << 16)); + if (_boonShownFor != sig) + { + _boonCardRow.Clear(); + ref var defs = ref pool.Value; + for (byte k = 0; k < 3; k++) + { + byte id = k == 2 ? offer.Option2 : k == 1 ? offer.Option1 : offer.Option0; + int idx = BoonMath.FindDef(ref defs, id); + string title = idx >= 0 ? defs.Defs[idx].Name.ToString() : ("BOON " + id); + string desc = idx >= 0 ? defs.Defs[idx].Desc.ToString() : ""; + byte weight = idx >= 0 ? defs.Defs[idx].Weight : (byte)100; + byte pick = k; // capture a COPY into the closure, never the loop variable + var card = MenuUi.Button(title + "\n" + desc, () => BoonSendSystem.PickBoon(pick)); + card.style.width = 200; + card.style.height = StyleKeyword.Auto; // long descs grow the card + card.style.minHeight = 96; + // Rarity from the draw weight (100 common / 60 uncommon / 30 rare / 10 epic). + var rare = weight <= 10 ? new Color(1f, 0.82f, 0.30f) + : weight <= 30 ? new Color(0.65f, 0.50f, 1f) + : weight <= 60 ? new Color(0.45f, 0.95f, 0.55f) + : new Color(1f, 1f, 1f, 0.30f); + MenuUi.Border(card, rare, weight <= 30 ? 2.5f : 1.5f); + card.style.marginLeft = 8; + card.style.marginRight = 8; + card.style.whiteSpace = WhiteSpace.Normal; + _boonCardRow.Add(card); + } + _boonShownFor = sig; + } + _boonModal.style.display = DisplayStyle.Flex; + } + + void BuildBoonModal(VisualElement root) + { + _boonModal = new VisualElement { pickingMode = PickingMode.Ignore }; + _boonModal.style.position = Position.Absolute; + _boonModal.style.left = 0; _boonModal.style.right = 0; + _boonModal.style.top = 0; _boonModal.style.bottom = 0; + _boonModal.style.alignItems = Align.Center; + _boonModal.style.justifyContent = Justify.Center; + _boonModal.style.display = DisplayStyle.None; + + var box = new VisualElement(); + box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.96f); + box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10; + box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10; + box.style.paddingLeft = 18; box.style.paddingRight = 18; + box.style.paddingTop = 14; box.style.paddingBottom = 16; + box.style.alignItems = Align.Center; + + var title = new Label("ROOM CLEARED — CHOOSE A BOON"); + title.style.color = new Color(0.6f, 1f, 0.7f); + title.style.fontSize = 18; + title.style.unityFontStyleAndWeight = FontStyle.Bold; + title.style.marginBottom = 12; + box.Add(title); + + _boonCardRow = new VisualElement(); + _boonCardRow.style.flexDirection = FlexDirection.Row; + box.Add(_boonCardRow); + + _boonModal.Add(box); + root.Add(_boonModal); + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/BoonModalHudSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/BoonModalHudSystem.cs.meta new file mode 100644 index 000000000..4f29e033c --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/BoonModalHudSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 90c2dc6391260794dbdf466b2f8887e5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/ClassPrepPortalHudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/ClassPrepPortalHudSystem.cs new file mode 100644 index 000000000..1b47fee2f --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/ClassPrepPortalHudSystem.cs @@ -0,0 +1,242 @@ +using System.Collections.Generic; +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; +using Unity.Transforms; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.UIElements; + +namespace ProjectM.Client +{ + /// + /// DR-046 base class-select + prep-loadout panels (Staging) + the room-exit portal prompt (RoomExplore) — + /// extracted from into their own client-only, observe-only presentation + /// in . Owns its own runtime UIDocument sharing + /// (sortingOrder 54). Recomputes the local class + ore/bio/aether + the + /// Staging gate + the RoomExplore portal proximity locally; clicks enqueue through + /// / / . + /// NOTE (behavior-preserving): the class/prep Staging gate reproduces the original's FULL condition — it also + /// requires the + buffer to be present (the panels + /// were gated on the same metaShow boolean as the meta shop). + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + [UpdateInGroup(typeof(PresentationSystemGroup))] + public partial class ClassPrepPortalHudSystem : SystemBase + { + GameObject _go; + UIDocument _doc; + bool _built; + + VisualElement _classPanel, _prepPanel, _prepRowsHost; + Label _classTitle, _prepTitle, _portalPrompt; + Button _classWarBtn, _classRangerBtn; + bool _classPanelBuilt, _prepPanelBuilt, _portalBuilt; + int _classShownFor, _prepShownFor; + + protected override void OnStartRunning() + { + if (_go != null) return; + MenuUi.EnsureEventSystem(); + _go = new GameObject("~HUDClassPrepPortal"); + _doc = _go.AddComponent(); + _doc.panelSettings = MenuUi.LoadPanelSettings(); + _doc.sortingOrder = 54; + } + + protected override void OnDestroy() + { + if (_go != null) Object.Destroy(_go); + } + + protected override void OnUpdate() + { + if (_doc == null) return; + var root = _doc.rootVisualElement; + if (root == null) return; // panel not initialised yet (next frame) + if (!_built) + { + 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 game-world clicks + _built = true; + } + + bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); + bool haveCycle = SystemAPI.TryGetSingleton(out var cyc); + bool siege = haveCycle && cyc.Phase == CyclePhase.Siege; + + // Resources from the ledger (last entry per type wins, matching the core loop). + int aether = 0, ore = 0, bio = 0; + if (SystemAPI.TryGetSingletonEntity(out var ledgerE)) + { + var buf = SystemAPI.GetBuffer(ledgerE); + for (int i = 0; i < buf.Length; i++) + { + var en = buf[i]; + if (en.ItemId == ResourceId.Aether) aether = en.Count; + else if (en.ItemId == ResourceId.Ore) ore = en.Count; + else if (en.ItemId == ResourceId.Biomass) bio = en.Count; + } + } + + // Local class from the replicated AbilityRef (tracks the dev class-switch; PlayerClass is server-only). + byte localClass = ClassTraits.WarriorClass; + bool haveLocalPlayer = false; + foreach (var ar in SystemAPI.Query>().WithAll()) + { + localClass = ClassTraits.ClassForAbility(ar.ValueRO.Id); + haveLocalPlayer = true; + break; + } + + // Faithful reproduction of the original `metaShow` gate: class/prep were shown on the SAME condition as + // the meta shop, which requires the meta catalog + tier buffer to exist. + DynamicBuffer metaRecord = default; + bool metaShow = haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && !siege + && SystemAPI.TryGetSingleton(out var metaCat) && metaCat.Value.IsCreated + && SystemAPI.TryGetSingletonBuffer(out metaRecord, true); + + UpdateClassPanel(metaShow, localClass); // DR-046: base class pick (Staging) + UpdatePrepPanel(metaShow, ore, bio, aether); // DR-046: base prep loadout (Staging) + UpdatePortalPrompt(haveRun ? runInfo : default, haveRun); // DR-046: room-exit portal prompt (RoomExplore) + } + + void UpdateClassPanel(bool show, byte classId) + { + if (!show) { if (_classPanel != null) _classPanel.style.display = DisplayStyle.None; _classShownFor = 0; return; } + var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return; + if (!_classPanelBuilt) { BuildClassPanel(root); _classPanelBuilt = true; } + int sig = classId + 1; + if (_classShownFor != sig) + { + bool ranger = classId == ClassTraits.RangerClass; + _classWarBtn.text = ranger ? "WARRIOR" : "WARRIOR ✓"; + _classRangerBtn.text = ranger ? "RANGER ✓" : "RANGER"; + _classWarBtn.SetEnabled(ranger); + _classRangerBtn.SetEnabled(!ranger); + _classShownFor = sig; + } + _classPanel.style.display = DisplayStyle.Flex; + } + + void BuildClassPanel(VisualElement root) + { + _classPanel = new VisualElement { pickingMode = PickingMode.Ignore }; + _classPanel.style.position = Position.Absolute; + _classPanel.style.left = 12; _classPanel.style.top = Length.Percent(22); + _classPanel.style.display = DisplayStyle.None; + var box = new VisualElement(); + box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f); + MenuUi.Round(box, 10); + box.style.paddingLeft = 12; box.style.paddingRight = 12; box.style.paddingTop = 10; box.style.paddingBottom = 10; + _classTitle = new Label("CLASS"); + _classTitle.style.color = MenuUi.Accent; _classTitle.style.fontSize = 14; + _classTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _classTitle.style.marginBottom = 8; + box.Add(_classTitle); + _classWarBtn = MenuUi.Button("WARRIOR", () => ClassSelectSendSystem.RequestClass(ClassTraits.WarriorClass)); + _classWarBtn.style.marginBottom = 4; box.Add(_classWarBtn); + _classRangerBtn = MenuUi.Button("RANGER", () => ClassSelectSendSystem.RequestClass(ClassTraits.RangerClass)); + box.Add(_classRangerBtn); + _classPanel.Add(box); root.Add(_classPanel); + } + + void UpdatePrepPanel(bool show, int ore, int bio, int aether) + { + if (!show) { if (_prepPanel != null) _prepPanel.style.display = DisplayStyle.None; _prepShownFor = 0; return; } + var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return; + if (!_prepPanelBuilt) { BuildPrepPanel(root); _prepPanelBuilt = true; } + uint boughtMask = 0; + foreach (var mods in SystemAPI.Query>().WithAll()) + { + for (int m = 0; m < mods.Length; m++) + { + uint sid = mods[m].SourceId; + if (sid >= Tuning.PrepSourceIdBase && sid < Tuning.PrepSourceIdBase + Tuning.PrepSourceIdSpan) + boughtMask |= (uint)(1 << (int)(sid - Tuning.PrepSourceIdBase)); + } + break; + } + int sig = ore * 7 ^ bio * 13 ^ aether * 31 ^ (int)boughtMask * 101; + if (sig == 0) sig = 1; + if (_prepShownFor != sig) + { + _prepRowsHost.Clear(); + for (int i = 0; i < PrepCatalog.Count; i++) + { + var r = PrepCatalog.Rows[i]; + int have = r.CostResId == ResourceId.Aether ? aether : r.CostResId == ResourceId.Biomass ? bio : ore; + bool bought = (boughtMask & (uint)(1 << r.Id)) != 0; + string resName = r.CostResId == ResourceId.Aether ? "Aether" : r.CostResId == ResourceId.Biomass ? "Biomass" : "Ore"; + string label = PrepLabel(r.Id) + (bought ? " BOUGHT" : " - " + r.Cost + " " + resName); + byte buyId = r.Id; + var row = MenuUi.Button(label, () => PrepPurchaseSendSystem.RequestPrep(buyId)); + row.style.width = 240; row.style.marginBottom = 4; + row.style.whiteSpace = WhiteSpace.Normal; row.style.unityTextAlign = TextAnchor.MiddleLeft; + row.SetEnabled(!bought && have >= r.Cost); + _prepRowsHost.Add(row); + } + _prepShownFor = sig; + } + _prepPanel.style.display = DisplayStyle.Flex; + } + + static string PrepLabel(byte id) => id == 0 ? "+30 Max HP" : id == 1 ? "+12% Move Speed" + : id == 2 ? "+20% Melee Damage" : "+20% Ranged Damage"; + + void BuildPrepPanel(VisualElement root) + { + _prepPanel = new VisualElement { pickingMode = PickingMode.Ignore }; + _prepPanel.style.position = Position.Absolute; + _prepPanel.style.left = 12; _prepPanel.style.top = Length.Percent(45); + _prepPanel.style.display = DisplayStyle.None; + var box = new VisualElement(); + box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f); + MenuUi.Round(box, 10); + box.style.paddingLeft = 12; box.style.paddingRight = 12; box.style.paddingTop = 10; box.style.paddingBottom = 10; + _prepTitle = new Label("PREP LOADOUT (lasts the run)"); + _prepTitle.style.color = MenuUi.Accent; _prepTitle.style.fontSize = 14; + _prepTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _prepTitle.style.marginBottom = 8; + box.Add(_prepTitle); + _prepRowsHost = new VisualElement(); box.Add(_prepRowsHost); + _prepPanel.Add(box); root.Add(_prepPanel); + } + + void UpdatePortalPrompt(RunInfo runInfo, bool haveRun) + { + var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return; + if (!_portalBuilt) { BuildPortalPrompt(root); _portalBuilt = true; } + bool show = false, inRange = false; + if (haveRun && runInfo.Lifecycle == RunLifecycle.RoomExplore + && SystemAPI.TryGetSingleton(out var anchor)) + { + show = true; // room cleared -> ALWAYS steer the player to the (now visible) portal, not only when in range + float3 portalPos = RegionMath.ExpeditionPortalPos(BaseGridMath.PlotCenter(anchor), (byte)(runInfo.CurrentRoom & 1)); + foreach (var lt in SystemAPI.Query>().WithAll()) + { + inRange = math.distance(lt.ValueRO.Position.xz, portalPos.xz) <= Tuning.PortalInteractRange; + if (inRange) + { + var kb = UnityEngine.InputSystem.Keyboard.current; + if (kb != null && kb.eKey.wasPressedThisFrame) PortalInteractSendSystem.Interact(); + } + break; + } + _portalPrompt.text = inRange + ? "PRESS E TO LEAVE — the haul comes home" + : "ROOM CLEAR — reach the glowing portal to move on"; + } + _portalPrompt.style.display = show ? DisplayStyle.Flex : DisplayStyle.None; + } + + void BuildPortalPrompt(VisualElement root) + { + _portalPrompt = HudUi.Display("PRESS E TO LEAVE — the haul comes home", 20, new Color(0.55f, 0.95f, 1f), TextAnchor.MiddleCenter); + _portalPrompt.style.position = Position.Absolute; + _portalPrompt.style.left = 0; _portalPrompt.style.right = 0; _portalPrompt.style.bottom = 240; + _portalPrompt.pickingMode = PickingMode.Ignore; + _portalPrompt.style.display = DisplayStyle.None; + root.Add(_portalPrompt); + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/ClassPrepPortalHudSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/ClassPrepPortalHudSystem.cs.meta new file mode 100644 index 000000000..1781d7f1f --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/ClassPrepPortalHudSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 32e011d68689e89488879377a7fb4c3a \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index a38720bfa..b4907e845 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -40,7 +40,6 @@ namespace ProjectM.Client readonly Dictionary _cache = new(); bool _scanPrimed; // Phase 1: first health-scan completed -> new cache entries are true spawns, not the connect flood - GameObject _portalFx; // Phase 1: authored portal effect (VFXConfig.Portal) replacing the procedural pillar when wired readonly HashSet _seen = new(); readonly List _stale = new(); readonly List _numbers = new(); @@ -68,27 +67,6 @@ namespace ProjectM.Client uint _lastConeFireTick; // own latch — the muzzle block owns _lastLocalFireTick and runs first double _lastHoldTime; // C4: last hit-stop hold time (throttle so a horde wipe doesn't stutter) bool _coneTickInit; - Material _dangerMat; - readonly Dictionary _dangerZones = new(); Material _portalMat; // DR-046: room-exit portal beacon glow (mutated for the pulse; beacon-only mat) - GameObject _portalBeacon; // DR-046: pooled world-space "go here" pillar, shown only during RoomExplore - - readonly HashSet _dangerSeen = new(); - readonly List _dangerStale = new(); - // ---- Enemy health bars (Slice 1, Feature B) — one pooled world-space Canvas per live Husk ---- - struct HealthBarEntry { public GameObject CanvasGo; public UnityEngine.UI.Image Fill; public UnityEngine.UI.Image Bg; public float ShowTimer; public bool Visible; } - const int HealthBarPoolLimit = 24; - const float HealthBarShowDuration = 3f; - const float HealthBarFadeDuration = 0.5f; - const float HealthBarAlwaysOnThreshold = 0.25f; - const float HealthBarWorldYOffset = 2.3f; - readonly Dictionary _healthBars = new(); - readonly List _barStale = new(); - readonly List _barKeys = new(); - Material _barBgMat, _barFillMat; - // Telegraph scale-pulse (Slice 1, Feature C): per-enemy windup-onset time, folded into the danger cone. - readonly Dictionary _pulseStart = new(); - // Near-impact strike beep (deferred-items pass): entity -> the WindUpUntilTick it last beeped for (once/windup). - readonly Dictionary _strikeBeeped = new(); // Remote teammates' melee cleave arcs (deferred-items pass, co-op): one pooled slash renderer per remote // player, edge-detected from the replicated MeleeCombo.SwingStartTick (the local player keeps _slashMr). @@ -109,7 +87,7 @@ namespace ProjectM.Client AudioClip _telegraphClip; AudioClip _dashClip; AudioClip _swingClip; - AudioClip _meleeConnectClip, _footstepClip, _strikeBeepClip; // combat feel pass: connect thunk / footstep / strike beep + AudioClip _meleeConnectClip, _footstepClip; // combat feel pass: connect thunk / footstep Vector3 _lastFootPos; float _footTimer; bool _footInit; // footstep edge-detect (local player locomotion) Entity _localPlayer = Entity.Null; @@ -135,7 +113,6 @@ namespace ProjectM.Client _swingClip = MakeClip("swing", 720f, 200f, 0.09f, 0.42f, noise: false); _meleeConnectClip = MakeClip("melee_thunk", 180f, 60f, 0.13f, 0.55f, noise: true); // meaty low connect _footstepClip = MakeClip("step", 200f, 110f, 0.06f, 0.18f, noise: true); // soft footfall - _strikeBeepClip = MakeClip("strike", 1150f, 1500f, 0.05f, 0.30f, noise: false); // (reserved) near-impact beep } protected override void OnStartRunning() @@ -150,17 +127,6 @@ namespace ProjectM.Client _dashFx = MakeBurst(_fxRoot, "DashWhoosh", mat, new Color(0.7f, 2.6f, 3.0f), 0.16f, 4f, 0.30f, 256); _swingFx = MakeBurst(_fxRoot, "MeleeSwing", mat, new Color(3.0f, 2.6f, 0.9f), 0.14f, 6f, 0.28f, 256); BuildSlash(); - _dangerMat = MakeParticleMaterial(); - _dangerMat.name = "EnemyDanger"; - _dangerMat.color = new Color(3.2f, 0.28f, 0.18f, 1f); // HDR red (per-zone intensity carried in vertex alpha) - _portalMat = MakeParticleMaterial(); - _portalMat.name = "RoomPortal"; - _portalMat.color = new Color(0.25f, 1.2f, 1.55f, 0.85f); // DR-046: HDR cyan portal glow (Phase 0: tamed — 2.6/3.4 bloomed to a white blob) - - // Health-bar materials (UI/Default = always-included URP-compatible UI shader; per-instance Image.color carries alpha). - Shader uiShader = Shader.Find("UI/Default") ?? Shader.Find("Sprites/Default"); - _barBgMat = new Material(uiShader) { name = "HealthBarBg" }; - _barFillMat = new Material(uiShader) { name = "HealthBarFill" }; for (int i = 0; i < NumberPoolSize; i++) _numbers.Add(CreateNumber()); @@ -172,14 +138,7 @@ namespace ProjectM.Client Object.Destroy(_fxRoot.gameObject); if (_slashMesh != null) Object.Destroy(_slashMesh); if (_slashMat != null) Object.Destroy(_slashMat); - if (_dangerMat != null) Object.Destroy(_dangerMat); if (_portalMat != null) Object.Destroy(_portalMat); - if (_barBgMat != null) Object.Destroy(_barBgMat); - if (_barFillMat != null) Object.Destroy(_barFillMat); - foreach (var kv in _dangerZones) - if (kv.Value != null) { var mf = kv.Value.GetComponent(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); } - foreach (var kv in _healthBars) - if (kv.Value.CanvasGo != null) Object.Destroy(kv.Value.CanvasGo); foreach (var kv in _remoteSlashes) { if (kv.Value.Mesh != null) Object.Destroy(kv.Value.Mesh); @@ -203,9 +162,6 @@ namespace ProjectM.Client EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); EntityManager.CompleteDependencyBeforeRO(); - EntityManager.CompleteDependencyBeforeRO(); - EntityManager.CompleteDependencyBeforeRO(); - EntityManager.CompleteDependencyBeforeRO(); // Resolve the local player (for hit colouring + fire feedback). _localPlayer = Entity.Null; @@ -248,7 +204,6 @@ namespace ProjectM.Client // Attack telegraph: the wind-up just began -> warn the player ~0.3s before the strike lands. Burst(_hitFx, null, (Vector3)p + Vector3.up * 1.2f, 6); PlayClip(_telegraphClip, (Vector3)p, 0.5f); - _pulseStart[entity] = (float)SystemAPI.Time.ElapsedTime; // Feature C: scale-pulse onset } // Local hit feedback is SUPPRESSED while the local i-frame window is active: the server @@ -271,7 +226,6 @@ namespace ProjectM.Client // Camera-only hit-stop (NEVER Time.timeScale); keys on the enemy Health-decrease edge. float hitMag = math.saturate((prev.Hp - cur) / math.max(1f, FeelConfig.HitStopRefDamage)); PrototypeCameraRig.PunchFov(math.lerp(FeelConfig.HitStopFovKickMin, FeelConfig.HitStopFovKickMax, hitMag), FeelConfig.HitStopDurationMs); - ShowHealthBar(entity); // Feature B: arm/refresh this enemy's bar on a damage edge // Hit-flash: a bright body-scaled puff in FeelConfig.HitFlashColor — the staple "I lit it up" read. EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.7f, FeelConfig.HitFlashBurstCount, FeelConfig.HitFlashColor); if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) @@ -544,10 +498,8 @@ namespace ProjectM.Client PruneVfx(); AnimateNumbers(dt, cam); UpdateSlash(dt); - UpdateEnemyDanger(localPos); UpdatePortalBeacon(); UpdateRemoteSwings(dt); - UpdateHealthBars(dt, cam, localPos); } // ---- Authored VFX (GabrielAguiar prefabs via VFXConfig); fall back to the procedural burst ---- @@ -958,342 +910,5 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int return new RemoteSlash { Go = go, Mesh = mesh, Mr = mr, Mat = mat, Active = false, Init = false }; } - // DR-046: the room-exit PORTAL made VISIBLE. During the RoomExplore loot window a glowing cyan pillar marks the - // client-derived portal position so the player has an unmistakable "go here to continue" target — the HUD prompt - // alone left the exit invisible, so players waited out the ~30s grace timeout ("nothing happens for a while"). - // Client-only, observe-only; one pooled GameObject, hidden whenever the run isn't in RoomExplore. Position - // resolves through the SAME RegionMath.ExpeditionPortalPos authority the HUD prompt uses -> beacon + "PRESS E" - // range always agree. - void UpdatePortalBeacon() - { - if (_fxRoot == null || _portalMat == null) return; - bool inExplore = SystemAPI.TryGetSingleton(out var ri) && ri.Lifecycle == RunLifecycle.RoomExplore; - if (!inExplore || !SystemAPI.TryGetSingleton(out var anchor)) - { - if (_portalBeacon != null && _portalBeacon.activeSelf) _portalBeacon.SetActive(false); - if (_portalFx != null && _portalFx.activeSelf) _portalFx.SetActive(false); - return; - } - float3 pos = RegionMath.ExpeditionPortalPos(BaseGridMath.PlotCenter(anchor), (byte)(ri.CurrentRoom & 1)); - // Phase 1: prefer the authored portal effect (VFXConfig.Portal, PolygonParticleFX) over the - // procedural pillar; the pillar remains the asset-free fallback. - var vfx = VFXConfig.Instance; - if (vfx != null && vfx.Portal != null) - { - if (_portalFx == null) - { - _portalFx = Object.Instantiate(vfx.Portal, _fxRoot, false); - _portalFx.name = "~RoomPortalFx"; - } - _portalFx.transform.position = new Vector3(pos.x, 0f, pos.z); // terrain y=0 (pos.y is the capsule plane) - if (!_portalFx.activeSelf) _portalFx.SetActive(true); - if (_portalBeacon != null && _portalBeacon.activeSelf) _portalBeacon.SetActive(false); - return; - } - if (_portalBeacon == null) - { - _portalBeacon = GameObject.CreatePrimitive(PrimitiveType.Cylinder); - _portalBeacon.name = "~RoomPortalBeacon"; - var col = _portalBeacon.GetComponent(); if (col != null) Object.Destroy(col); // cosmetic only - _portalBeacon.transform.SetParent(_fxRoot, false); - var mr = _portalBeacon.GetComponent(); - mr.sharedMaterial = _portalMat; - mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; - mr.receiveShadows = false; - } - if (!_portalBeacon.activeSelf) _portalBeacon.SetActive(true); - float t = (float)SystemAPI.Time.ElapsedTime; - float breathe = 0.5f + 0.5f * math.sin(t * 3.5f); - var tr = _portalBeacon.transform; - // Cylinder is 2u tall in local space -> scale.y=2.2 gives a 4.4u pillar; lift the centre so the base sits - // on the TERRAIN (y=0) — pos.y is the CC capsule-center plane (GridOrigin.y=1), 1 u above the ground. - tr.position = new Vector3(pos.x, 2.2f, pos.z); - tr.localScale = new Vector3(0.9f + 0.12f * breathe, 2.2f, 0.9f + 0.12f * breathe); - _portalMat.color = new Color(0.25f, 1.2f, 1.55f, 0.45f + 0.3f * breathe); // glow throb (beacon-only mat; Phase 0: tamed + slimmed — the fat 6u pillar bloomed to a white egg swallowing the prompt) - } - - -// Enemy attack TELEGRAPH (MC-4 clarity): while an enemy's AttackWindup counts down, paint a red ground danger - // cone in its facing out to its reach, brightening + scaling as the strike nears -> the player reads WHERE + - // WHEN to dodge. Client-only, observe-only; one pooled mesh per winding-up enemy, pruned each frame. - void UpdateEnemyDanger(float3 localPos) - { - if (_fxRoot == null || _dangerMat == null) return; - Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton(out var nt) ? nt.ServerTick : default; - _dangerSeen.Clear(); - bool bossRoom = SystemAPI.TryGetSingleton(out var dangerRi) && dangerRi.Lifecycle == RunLifecycle.InRoom && dangerRi.CurrentRoomType == RoomTypeId.Boss; // A7: in a Boss room the Charger-kind enemy IS the boss (adds are swarmers) - - if (serverTick.IsValid) - { - foreach (var (xf, stats, windup, tele, entity) in - SystemAPI.Query, RefRO, RefRO, RefRO>() - .WithAll().WithEntityAccess()) - { - // Feature D: a committed Charger lunge keeps the cue ALIVE past windup (AttackWindup zeroes at commit). - bool lunging = SystemAPI.HasComponent(entity) && SystemAPI.IsComponentEnabled(entity); - bool isBoss = bossRoom && tele.ValueRO.Kind == ZoneEnemyMath.KindCharger; // A7: boss radial SLAM telegraph - - uint until = windup.ValueRO.WindUpUntilTick; - if (until == 0u && !lunging) continue; - - float intensity; - if (lunging) - { - intensity = 1f; // mid-lunge: max danger, persistent until IsLunging clears - } - else - { - var untilTick = new Unity.NetCode.NetworkTick(until); - if (!untilTick.IsValid || !untilTick.IsNewerThan(serverTick)) continue; // windup already elapsed - int remaining = untilTick.TicksSince(serverTick); - // Feature C: per-enemy windup duration (baked, client-safe) -> ramps 0->1 ending AT impact for - // any windup length (fixes the Charger plateauing early under the old hard-coded 22). - float windupDur = isBoss ? Tuning.BossSlamWindupTicks : math.max(1f, tele.ValueRO.WindupTicks); // A7: ramp over the boss's real slam wind-up - intensity = math.saturate(1f - remaining / windupDur); - - // Near-impact strike beep (deferred-items pass): a "dodge NOW" cue once per windup, gated to - // enemies near the local player (the danger cone already proves it's winding up to strike). - if (FeelConfig.StrikeBeepEnabled && _localPlayer != Entity.Null && remaining <= FeelConfig.StrikeBeepLeadTicks - && (!_strikeBeeped.TryGetValue(entity, out var beepedUntil) || beepedUntil != until)) - { - float3 ep = xf.ValueRO.Position; - if (math.distancesq(ep, localPos) <= FeelConfig.StrikeBeepMaxDistSq) - { - PlayClip(_strikeBeepClip, (Vector3)ep, FeelConfig.StrikeBeepVolume); - _strikeBeeped[entity] = until; - } - } - - } - - // Feature C: a short anticipation scale-pulse folded into the client-owned cone (never the ghost). - float pulse = 0f; - if (_pulseStart.TryGetValue(entity, out var t0)) - { - float age = (float)SystemAPI.Time.ElapsedTime - t0; - const float PulseLife = 0.18f; - if (age < PulseLife) pulse = (1f - age / PulseLife) * 0.35f; - else _pulseStart.Remove(entity); - } - - _dangerSeen.Add(entity); - if (!_dangerZones.TryGetValue(entity, out var go) || go == null) - { - go = new GameObject("EnemyDanger"); - go.transform.SetParent(_fxRoot, false); - go.AddComponent().sharedMesh = new Mesh { name = "EnemyDanger" }; - var mr = go.AddComponent(); - mr.sharedMaterial = _dangerMat; - mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; - mr.receiveShadows = false; - _dangerZones[entity] = go; - } - float coneRange = math.max(1f, stats.ValueRO.AttackRange + 0.6f); - if (lunging) coneRange += 1.5f; // forward-stretch the wedge to read the committed travel - if (isBoss && !lunging) - { - // A7: the boss SLAM is RADIAL (Tuning.BossSlamRadius) -> paint a FULL ground ring so the tell - // matches the hit area (a forward wedge sized to melee reach would lie about a radial AoE). - BuildDangerMesh(go.GetComponent().sharedMesh, Tuning.BossSlamRadius, 3.14159f, intensity); - } - else if (isBoss) - { - // B4: the boss LUNGE is a committed forward gap-closer (IsLunging bit on through windup + - // travel) - a radial ring would lie about the threat shape; paint a long narrow travel wedge. - BuildDangerMesh(go.GetComponent().sharedMesh, math.max(coneRange, 8f), 0.45f, intensity); - } - else if (tele.ValueRO.Kind == ZoneEnemyMath.KindSpitter) - { - // MC-3: a Spitter is a RANGED threat — a melee wedge at its feet is useless. Paint a thin aim - // LANE along its (face-locked) facing out to projectile reach during wind-up, brightening as the - // shot nears so the player reads the line to dodge/dash across it. - float laneLen = 12f; - if (SystemAPI.HasComponent(entity)) - { - var ss = SystemAPI.GetComponent(entity); - laneLen = math.max(4f, ss.PreferredRange + ss.RangeTolerance + 2f); - } - BuildLaneMesh(go.GetComponent().sharedMesh, laneLen, 0.28f, intensity); - } - else BuildDangerMesh(go.GetComponent().sharedMesh, coneRange, 0.7f, intensity); - float2 fwd = AnimParamMath.PlanarForward(xf.ValueRO.Rotation); - var tr = go.transform; - tr.position = (Vector3)xf.ValueRO.Position + Vector3.up * 0.06f; - tr.rotation = Quaternion.LookRotation(new Vector3(fwd.x, 0f, fwd.y), Vector3.up); - tr.localScale = Vector3.one * (0.92f + 0.12f * intensity + pulse); - } - } - if (_dangerZones.Count != _dangerSeen.Count) - { - _dangerStale.Clear(); - foreach (var kv in _dangerZones) if (!_dangerSeen.Contains(kv.Key)) _dangerStale.Add(kv.Key); - for (int i = 0; i < _dangerStale.Count; i++) - { - var g = _dangerZones[_dangerStale[i]]; - if (g != null) { var mf = g.GetComponent(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); Object.Destroy(g); } - _dangerZones.Remove(_dangerStale[i]); - _pulseStart.Remove(_dangerStale[i]); - _strikeBeeped.Remove(_dangerStale[i]); - - } - } - } - - // Filled forward wedge (pizza-slice) from the enemy out to `range`, vertex-alpha ramped by `intensity`. - // ---- Enemy Health Bars (Slice 1, Feature B) — pooled world-space Canvas, on-damage sticky + fade ---- - - void ShowHealthBar(Entity entity) - { - if (!_healthBars.TryGetValue(entity, out var entry) || entry.CanvasGo == null) - entry = CreateHealthBar(entity); - entry.ShowTimer = HealthBarShowDuration; - if (!entry.Visible) { entry.CanvasGo.SetActive(true); entry.Visible = true; } - _healthBars[entity] = entry; // struct — must re-assign - } - - HealthBarEntry CreateHealthBar(Entity entity) - { - var go = new GameObject("EnemyHPBar"); - if (_fxRoot != null) go.transform.SetParent(_fxRoot, false); - var canvas = go.AddComponent(); - canvas.renderMode = RenderMode.WorldSpace; - canvas.sortingOrder = 5; // below the UITK HUD (50); above world geometry - var rt = go.GetComponent(); - rt.sizeDelta = new Vector2(1.2f, 0.14f); - - var bgGo = new GameObject("Bg"); - bgGo.transform.SetParent(go.transform, false); - var bgRt = bgGo.AddComponent(); - bgRt.anchorMin = Vector2.zero; bgRt.anchorMax = Vector2.one; - bgRt.offsetMin = bgRt.offsetMax = Vector2.zero; - var bgImg = bgGo.AddComponent(); - bgImg.material = _barBgMat; - bgImg.color = new Color(0.05f, 0.05f, 0.06f, 0.82f); - - var fillGo = new GameObject("Fill"); - fillGo.transform.SetParent(go.transform, false); - var fillRt = fillGo.AddComponent(); - fillRt.anchorMin = Vector2.zero; fillRt.anchorMax = Vector2.one; - fillRt.offsetMin = new Vector2(0.02f, 0.02f); - fillRt.offsetMax = new Vector2(-0.02f, -0.02f); - var fillImg = fillGo.AddComponent(); - fillImg.material = _barFillMat; - fillImg.color = new Color(0.88f, 0.22f, 0.14f, 1f); - fillImg.type = UnityEngine.UI.Image.Type.Simple; // a sprite-less UI Image ignores fillAmount (it draws a full quad) -> - fillImg.raycastTarget = false; // the bar empties by sizing the fill RectTransform (anchorMax.x = frac) in UpdateHealthBars - - go.SetActive(false); - var entry = new HealthBarEntry { CanvasGo = go, Fill = fillImg, Bg = bgImg, ShowTimer = 0f, Visible = false }; - _healthBars[entity] = entry; - return entry; - } - - // Per-frame: prune dead bars (reusing the main loop's _seen set), pool-cap by distance, billboard + fade. - void UpdateHealthBars(float dt, Camera cam, float3 localPlayerPos) - { - if (_healthBars.Count > 0) - { - _barStale.Clear(); - foreach (var kv in _healthBars) - if (!_seen.Contains(kv.Key)) _barStale.Add(kv.Key); - for (int i = 0; i < _barStale.Count; i++) - { - var e2 = _barStale[i]; - if (_healthBars[e2].CanvasGo != null) Object.Destroy(_healthBars[e2].CanvasGo); - _healthBars.Remove(e2); - } - } - if (_healthBars.Count == 0) return; - - bool capBars = _localPlayer != Entity.Null && _healthBars.Count > HealthBarPoolLimit; - _barKeys.Clear(); - foreach (var k in _healthBars.Keys) _barKeys.Add(k); - for (int i = 0; i < _barKeys.Count; i++) - { - var key = _barKeys[i]; - var entry = _healthBars[key]; - if (entry.CanvasGo == null) continue; - if (!_cache.TryGetValue(key, out var fc)) continue; - - float frac = fc.MaxHp > 0f ? math.saturate(fc.Hp / fc.MaxHp) : 1f; - bool alwaysOn = frac < HealthBarAlwaysOnThreshold; - - if (capBars && math.lengthsq(fc.Pos - localPlayerPos) > FeelConfig.HealthBarMaxDistSq) - { - if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; } - _healthBars[key] = entry; - continue; - } - - if (!alwaysOn) entry.ShowTimer -= dt; - bool shouldShow = alwaysOn || entry.ShowTimer > -HealthBarFadeDuration; - if (shouldShow) - { - if (!entry.Visible) { entry.CanvasGo.SetActive(true); entry.Visible = true; } - if (cam != null) - { - entry.CanvasGo.transform.position = (Vector3)fc.Pos + Vector3.up * HealthBarWorldYOffset; - entry.CanvasGo.transform.rotation = cam.transform.rotation; // billboard - } - float alpha = (!alwaysOn && entry.ShowTimer < 0f) - ? 1f - math.saturate(-entry.ShowTimer / HealthBarFadeDuration) : 1f; - if (entry.Fill != null) { var c = entry.Fill.color; c.a = alpha; entry.Fill.color = c; entry.Fill.rectTransform.anchorMax = new Vector2(frac, 1f); } - if (entry.Bg != null) { var c = entry.Bg.color; c.a = 0.82f * alpha; entry.Bg.color = c; } - } - else if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; } - - _healthBars[key] = entry; - } - } - - static void BuildDangerMesh(Mesh mesh, float range, float halfAngle, float intensity) - { - const int seg = 14; - var verts = new Vector3[seg + 2]; - var cols = new Color[seg + 2]; - var uvs = new Vector2[seg + 2]; - var tris = new int[seg * 3]; - float aCenter = 0.18f + 0.62f * intensity; - verts[0] = Vector3.zero; cols[0] = new Color(1f, 1f, 1f, aCenter); uvs[0] = new Vector2(0.5f, 0.5f); - for (int i = 0; i <= seg; i++) - { - float a = Mathf.Lerp(-halfAngle, halfAngle, i / (float)seg); - verts[i + 1] = new Vector3(Mathf.Sin(a) * range, 0f, Mathf.Cos(a) * range); - cols[i + 1] = new Color(1f, 1f, 1f, aCenter * 0.22f); - uvs[i + 1] = new Vector2(0.5f, 0.5f); - } - for (int i = 0; i < seg; i++) { tris[i * 3] = 0; tris[i * 3 + 1] = i + 1; tris[i * 3 + 2] = i + 2; } - mesh.Clear(); - mesh.vertices = verts; mesh.colors = cols; mesh.uv = uvs; mesh.triangles = tris; - mesh.RecalculateBounds(); - } - - // MC-3: a thin forward LANE (filled quad in local +Z) for a Spitter's ranged aim telegraph, vertex-alpha - // ramped by `intensity` (brightening toward the shot). Built into the same pooled danger mesh; the GO is - // already rotated to the enemy facing, so +Z is "toward the locked target". - static void BuildLaneMesh(Mesh mesh, float length, float halfWidth, float intensity) - { - float a = 0.18f + 0.62f * intensity; - var verts = new Vector3[4] - { - new Vector3(-halfWidth, 0f, 0.2f), - new Vector3( halfWidth, 0f, 0.2f), - new Vector3(-halfWidth, 0f, length), - new Vector3( halfWidth, 0f, length), - }; - var cols = new Color[4] - { - new Color(1f, 1f, 1f, a), - new Color(1f, 1f, 1f, a), - new Color(1f, 1f, 1f, a * 0.12f), - new Color(1f, 1f, 1f, a * 0.12f), - }; - var uvs = new Vector2[4] { new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f) }; - var tris = new int[6] { 0, 2, 1, 1, 2, 3 }; - mesh.Clear(); - mesh.vertices = verts; mesh.colors = cols; mesh.uv = uvs; mesh.triangles = tris; - mesh.RecalculateBounds(); - } - } } diff --git a/Assets/_Project/Scripts/Client/Presentation/EnemyDangerTelegraphSystem.cs b/Assets/_Project/Scripts/Client/Presentation/EnemyDangerTelegraphSystem.cs new file mode 100644 index 000000000..f4af6dac8 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/EnemyDangerTelegraphSystem.cs @@ -0,0 +1,285 @@ +using System.Collections.Generic; +using ProjectM.Simulation; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; +using Unity.Transforms; +using UnityEngine; +using static ProjectM.Client.FeedbackFx; + +namespace ProjectM.Client +{ + /// + /// MC-3/MC-4/A7 — client-only enemy attack TELEGRAPHS. Observe-only presentation in + /// that reads replicated state and never mutates the sim. While an enemy's + /// counts down (or a Charger is mid-lunge) it paints a red ground danger shape in the + /// enemy's facing — a melee cone, a Spitter aim LANE, or the boss's radial SLAM ring / lunge wedge — brightening + + /// scaling as the strike nears so the player reads WHERE and WHEN to dodge. Also plays a near-impact "dodge NOW" + /// strike beep once per windup for enemies near the local player. SELF-DETECTS the windup-onset edge via its own + /// _prevWindup map (a 0 -> nonzero WindUpUntilTick transition arms the anticipation scale-pulse — + /// this was formerly written by CombatFeedbackSystem's health-scan loop). One pooled mesh per winding-up enemy, + /// pruned each frame; the tracking maps are pruned against a full enemy-seen set. Extracted from CombatFeedbackSystem; + /// owns its own FX-root + danger material + beep clip. + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + [UpdateInGroup(typeof(PresentationSystemGroup))] + public partial class EnemyDangerTelegraphSystem : SystemBase + { + Transform _fxRoot; + Material _dangerMat; + readonly Dictionary _dangerZones = new(); + readonly HashSet _dangerSeen = new(); // telegraph-ACTIVE enemies (zone lifecycle: a zone vanishes when its enemy stops winding up) + readonly List _dangerStale = new(); // scratch list reused by every prune + readonly Dictionary _pulseStart = new(); // per-enemy windup-onset time (anticipation scale-pulse) + readonly Dictionary _strikeBeeped = new(); // entity -> the WindUpUntilTick it last beeped for (once/windup) + readonly Dictionary _prevWindup = new(); // self-detect the windup-onset edge (was the core _cache.Windup) + readonly HashSet _enemySeen = new(); // ALL enemies this frame (prunes _pulseStart/_strikeBeeped/_prevWindup) + AudioClip _strikeBeepClip; // near-impact "dodge NOW" beep + Entity _localPlayer = Entity.Null; + + protected override void OnCreate() + { + _strikeBeepClip = MakeClip("strike", 1150f, 1500f, 0.05f, 0.30f, noise: false); // near-impact beep + } + + protected override void OnStartRunning() + { + if (_fxRoot != null) return; + _fxRoot = new GameObject("~EnemyDangerFX").transform; + _dangerMat = MakeParticleMaterial(); + _dangerMat.name = "EnemyDanger"; + _dangerMat.color = new Color(3.2f, 0.28f, 0.18f, 1f); // HDR red (per-zone intensity carried in vertex alpha) + } + + protected override void OnDestroy() + { + if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); + if (_dangerMat != null) Object.Destroy(_dangerMat); + foreach (var kv in _dangerZones) + if (kv.Value != null) { var mf = kv.Value.GetComponent(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); } + } + + protected override void OnUpdate() + { + if (_fxRoot == null || _dangerMat == null) return; + + // Predicted/physics jobs writing these must finish before this main-thread read. + EntityManager.CompleteDependencyBeforeRO(); + EntityManager.CompleteDependencyBeforeRO(); + EntityManager.CompleteDependencyBeforeRO(); + EntityManager.CompleteDependencyBeforeRO(); + EntityManager.CompleteDependencyBeforeRO(); + + // Local player (strike-beep proximity gate). + _localPlayer = Entity.Null; + float3 localPos = default; + foreach (var (xf, entity) in SystemAPI.Query>() + .WithAll().WithEntityAccess()) + { + _localPlayer = entity; + localPos = xf.ValueRO.Position; + } + + UpdateEnemyDanger(localPos); + } + + // Enemy attack TELEGRAPH (MC-4 clarity): while an enemy's AttackWindup counts down, paint a red ground danger + // cone in its facing out to its reach, brightening + scaling as the strike nears -> the player reads WHERE + + // WHEN to dodge. Client-only, observe-only; one pooled mesh per winding-up enemy, pruned each frame. + void UpdateEnemyDanger(float3 localPos) + { + if (_fxRoot == null || _dangerMat == null) return; + Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton(out var nt) ? nt.ServerTick : default; + _dangerSeen.Clear(); + _enemySeen.Clear(); + bool bossRoom = SystemAPI.TryGetSingleton(out var dangerRi) && dangerRi.Lifecycle == RunLifecycle.InRoom && dangerRi.CurrentRoomType == RoomTypeId.Boss; // A7: in a Boss room the Charger-kind enemy IS the boss (adds are swarmers) + + if (serverTick.IsValid) + { + foreach (var (xf, stats, windup, tele, entity) in + SystemAPI.Query, RefRO, RefRO, RefRO>() + .WithAll().WithEntityAccess()) + { + _enemySeen.Add(entity); + uint until = windup.ValueRO.WindUpUntilTick; + + // Self-detect the windup-onset edge (formerly written by the core's health-scan loop): a 0 -> nonzero + // transition of WindUpUntilTick arms the anticipation scale-pulse (Feature C). Requires a prior 0 + // record so a mid-windup relevancy re-entry doesn't spuriously pulse (matches the old prev.Windup==0). + bool hadPrev = _prevWindup.TryGetValue(entity, out var pw); + if (until != 0u && hadPrev && pw == 0u) _pulseStart[entity] = (float)SystemAPI.Time.ElapsedTime; + _prevWindup[entity] = until; + + // Feature D: a committed Charger lunge keeps the cue ALIVE past windup (AttackWindup zeroes at commit). + bool lunging = SystemAPI.HasComponent(entity) && SystemAPI.IsComponentEnabled(entity); + bool isBoss = bossRoom && tele.ValueRO.Kind == ZoneEnemyMath.KindCharger; // A7: boss radial SLAM telegraph + + if (until == 0u && !lunging) continue; + + float intensity; + if (lunging) + { + intensity = 1f; // mid-lunge: max danger, persistent until IsLunging clears + } + else + { + var untilTick = new Unity.NetCode.NetworkTick(until); + if (!untilTick.IsValid || !untilTick.IsNewerThan(serverTick)) continue; // windup already elapsed + int remaining = untilTick.TicksSince(serverTick); + // Feature C: per-enemy windup duration (baked, client-safe) -> ramps 0->1 ending AT impact for + // any windup length (fixes the Charger plateauing early under the old hard-coded 22). + float windupDur = isBoss ? Tuning.BossSlamWindupTicks : math.max(1f, tele.ValueRO.WindupTicks); // A7: ramp over the boss's real slam wind-up + intensity = math.saturate(1f - remaining / windupDur); + + // Near-impact strike beep (deferred-items pass): a "dodge NOW" cue once per windup, gated to + // enemies near the local player (the danger cone already proves it's winding up to strike). + if (FeelConfig.StrikeBeepEnabled && _localPlayer != Entity.Null && remaining <= FeelConfig.StrikeBeepLeadTicks + && (!_strikeBeeped.TryGetValue(entity, out var beepedUntil) || beepedUntil != until)) + { + float3 ep = xf.ValueRO.Position; + if (math.distancesq(ep, localPos) <= FeelConfig.StrikeBeepMaxDistSq) + { + PlayClip(_strikeBeepClip, (Vector3)ep, FeelConfig.StrikeBeepVolume); + _strikeBeeped[entity] = until; + } + } + + } + + // Feature C: a short anticipation scale-pulse folded into the client-owned cone (never the ghost). + float pulse = 0f; + if (_pulseStart.TryGetValue(entity, out var t0)) + { + float age = (float)SystemAPI.Time.ElapsedTime - t0; + const float PulseLife = 0.18f; + if (age < PulseLife) pulse = (1f - age / PulseLife) * 0.35f; + else _pulseStart.Remove(entity); + } + + _dangerSeen.Add(entity); + if (!_dangerZones.TryGetValue(entity, out var go) || go == null) + { + go = new GameObject("EnemyDanger"); + go.transform.SetParent(_fxRoot, false); + go.AddComponent().sharedMesh = new Mesh { name = "EnemyDanger" }; + var mr = go.AddComponent(); + mr.sharedMaterial = _dangerMat; + mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + mr.receiveShadows = false; + _dangerZones[entity] = go; + } + float coneRange = math.max(1f, stats.ValueRO.AttackRange + 0.6f); + if (lunging) coneRange += 1.5f; // forward-stretch the wedge to read the committed travel + if (isBoss && !lunging) + { + // A7: the boss SLAM is RADIAL (Tuning.BossSlamRadius) -> paint a FULL ground ring so the tell + // matches the hit area (a forward wedge sized to melee reach would lie about a radial AoE). + BuildDangerMesh(go.GetComponent().sharedMesh, Tuning.BossSlamRadius, 3.14159f, intensity); + } + else if (isBoss) + { + // B4: the boss LUNGE is a committed forward gap-closer (IsLunging bit on through windup + + // travel) - a radial ring would lie about the threat shape; paint a long narrow travel wedge. + BuildDangerMesh(go.GetComponent().sharedMesh, math.max(coneRange, 8f), 0.45f, intensity); + } + else if (tele.ValueRO.Kind == ZoneEnemyMath.KindSpitter) + { + // MC-3: a Spitter is a RANGED threat — a melee wedge at its feet is useless. Paint a thin aim + // LANE along its (face-locked) facing out to projectile reach during wind-up, brightening as the + // shot nears so the player reads the line to dodge/dash across it. + float laneLen = 12f; + if (SystemAPI.HasComponent(entity)) + { + var ss = SystemAPI.GetComponent(entity); + laneLen = math.max(4f, ss.PreferredRange + ss.RangeTolerance + 2f); + } + BuildLaneMesh(go.GetComponent().sharedMesh, laneLen, 0.28f, intensity); + } + else BuildDangerMesh(go.GetComponent().sharedMesh, coneRange, 0.7f, intensity); + float2 fwd = AnimParamMath.PlanarForward(xf.ValueRO.Rotation); + var tr = go.transform; + tr.position = (Vector3)xf.ValueRO.Position + Vector3.up * 0.06f; + tr.rotation = Quaternion.LookRotation(new Vector3(fwd.x, 0f, fwd.y), Vector3.up); + tr.localScale = Vector3.one * (0.92f + 0.12f * intensity + pulse); + } + } + + // Zone lifecycle: a zone vanishes the moment its enemy stops winding up (not in _dangerSeen) or despawns. + if (_dangerZones.Count != _dangerSeen.Count) + { + _dangerStale.Clear(); + foreach (var kv in _dangerZones) if (!_dangerSeen.Contains(kv.Key)) _dangerStale.Add(kv.Key); + for (int i = 0; i < _dangerStale.Count; i++) + { + var g = _dangerZones[_dangerStale[i]]; + if (g != null) { var mf = g.GetComponent(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); Object.Destroy(g); } + _dangerZones.Remove(_dangerStale[i]); + } + } + + // Prune the tracking maps against the FULL enemy-seen set (a despawned enemy drops its pulse/beep/windup state). + PruneTracking(_pulseStart); + PruneTracking(_strikeBeeped); + PruneTracking(_prevWindup); + } + + // Remove entries whose enemy wasn't seen this frame (keyed on the full enemy-seen set); reuses _dangerStale. + void PruneTracking(Dictionary dict) + { + if (dict.Count == 0) return; + _dangerStale.Clear(); + foreach (var kv in dict) if (!_enemySeen.Contains(kv.Key)) _dangerStale.Add(kv.Key); + for (int i = 0; i < _dangerStale.Count; i++) dict.Remove(_dangerStale[i]); + } + + // Filled forward wedge (pizza-slice) from the enemy out to `range`, vertex-alpha ramped by `intensity`. + static void BuildDangerMesh(Mesh mesh, float range, float halfAngle, float intensity) + { + const int seg = 14; + var verts = new Vector3[seg + 2]; + var cols = new Color[seg + 2]; + var uvs = new Vector2[seg + 2]; + var tris = new int[seg * 3]; + float aCenter = 0.18f + 0.62f * intensity; + verts[0] = Vector3.zero; cols[0] = new Color(1f, 1f, 1f, aCenter); uvs[0] = new Vector2(0.5f, 0.5f); + for (int i = 0; i <= seg; i++) + { + float a = Mathf.Lerp(-halfAngle, halfAngle, i / (float)seg); + verts[i + 1] = new Vector3(Mathf.Sin(a) * range, 0f, Mathf.Cos(a) * range); + cols[i + 1] = new Color(1f, 1f, 1f, aCenter * 0.22f); + uvs[i + 1] = new Vector2(0.5f, 0.5f); + } + for (int i = 0; i < seg; i++) { tris[i * 3] = 0; tris[i * 3 + 1] = i + 1; tris[i * 3 + 2] = i + 2; } + mesh.Clear(); + mesh.vertices = verts; mesh.colors = cols; mesh.uv = uvs; mesh.triangles = tris; + mesh.RecalculateBounds(); + } + + // MC-3: a thin forward LANE (filled quad in local +Z) for a Spitter's ranged aim telegraph, vertex-alpha + // ramped by `intensity` (brightening toward the shot). Built into the same pooled danger mesh; the GO is + // already rotated to the enemy facing, so +Z is "toward the locked target". + static void BuildLaneMesh(Mesh mesh, float length, float halfWidth, float intensity) + { + float a = 0.18f + 0.62f * intensity; + var verts = new Vector3[4] + { + new Vector3(-halfWidth, 0f, 0.2f), + new Vector3( halfWidth, 0f, 0.2f), + new Vector3(-halfWidth, 0f, length), + new Vector3( halfWidth, 0f, length), + }; + var cols = new Color[4] + { + new Color(1f, 1f, 1f, a), + new Color(1f, 1f, 1f, a), + new Color(1f, 1f, 1f, a * 0.12f), + new Color(1f, 1f, 1f, a * 0.12f), + }; + var uvs = new Vector2[4] { new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f) }; + var tris = new int[6] { 0, 2, 1, 1, 2, 3 }; + mesh.Clear(); + mesh.vertices = verts; mesh.colors = cols; mesh.uv = uvs; mesh.triangles = tris; + mesh.RecalculateBounds(); + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/EnemyDangerTelegraphSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/EnemyDangerTelegraphSystem.cs.meta new file mode 100644 index 000000000..82e2c0d72 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/EnemyDangerTelegraphSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3e80b545eb0e6e1498e81ec4962873ae \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/EnemyHealthBarSystem.cs b/Assets/_Project/Scripts/Client/Presentation/EnemyHealthBarSystem.cs new file mode 100644 index 000000000..b789fbfcc --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/EnemyHealthBarSystem.cs @@ -0,0 +1,221 @@ +using System.Collections.Generic; +using ProjectM.Simulation; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; +using Unity.Transforms; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Slice 1, Feature B — client-only enemy world-space HEALTH BARS (one pooled world-space Canvas per live Husk). + /// Observe-only presentation in that reads replicated + /// state and never mutates the sim or destroys a ghost. SELF-QUERIES enemies (Health + LocalTransform + /// ) and self-detects the damage edge from a per-enemy LastHp stored on the bar entry: + /// a decrease arms/refreshes that enemy's bar (sticky for , then fades). A bar + /// stays permanently on below HP; when more than + /// bars exist, distant ones (beyond of the local player) are hidden. + /// Billboards to the main camera. Prunes its cache against its own seen-set EVERY frame (a despawn drops the bar). + /// Extracted from CombatFeedbackSystem; owns its own FX-root + UI materials. + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + [UpdateInGroup(typeof(PresentationSystemGroup))] + public partial class EnemyHealthBarSystem : SystemBase + { + // CanvasGo == null => a tracking-only entry (enemy seen, not yet damaged, so no bar built). LastHp/MaxHp/Pos are + // refreshed from the live query each frame; LastHp is the self-owned damage-edge source (was the core's _cache). + struct HealthBarEntry + { + public GameObject CanvasGo; public UnityEngine.UI.Image Fill; public UnityEngine.UI.Image Bg; + public float ShowTimer; public bool Visible; + public float LastHp; public float MaxHp; public float3 Pos; + } + + const int HealthBarPoolLimit = 24; + const float HealthBarShowDuration = 3f; + const float HealthBarFadeDuration = 0.5f; + const float HealthBarAlwaysOnThreshold = 0.25f; + const float HealthBarWorldYOffset = 2.3f; + + readonly Dictionary _healthBars = new(); + readonly List _barStale = new(); + readonly List _barKeys = new(); + readonly HashSet _seen = new(); // own enemy seen-set (per-frame prune) + Material _barBgMat, _barFillMat; + Transform _fxRoot; + Entity _localPlayer = Entity.Null; + + protected override void OnStartRunning() + { + if (_fxRoot != null) return; + _fxRoot = new GameObject("~EnemyHealthBarFX").transform; + // Health-bar materials (UI/Default = always-included URP-compatible UI shader; per-instance Image.color carries alpha). + Shader uiShader = Shader.Find("UI/Default") ?? Shader.Find("Sprites/Default"); + _barBgMat = new Material(uiShader) { name = "HealthBarBg" }; + _barFillMat = new Material(uiShader) { name = "HealthBarFill" }; + } + + protected override void OnDestroy() + { + if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); + if (_barBgMat != null) Object.Destroy(_barBgMat); + if (_barFillMat != null) Object.Destroy(_barFillMat); + foreach (var kv in _healthBars) + if (kv.Value.CanvasGo != null) Object.Destroy(kv.Value.CanvasGo); + } + + protected override void OnUpdate() + { + float dt = SystemAPI.Time.DeltaTime; + var cam = Camera.main; + + // Predicted/physics jobs writing these must finish before this main-thread read. + EntityManager.CompleteDependencyBeforeRO(); + EntityManager.CompleteDependencyBeforeRO(); + + // Local player (drives the pool-cap distance gate). + _localPlayer = Entity.Null; + float3 localPos = default; + foreach (var (xf, entity) in SystemAPI.Query>() + .WithAll().WithEntityAccess()) + { + _localPlayer = entity; + localPos = xf.ValueRO.Position; + } + + // Self-query enemies: track HP per enemy to self-detect the damage edge; a decrease arms/refreshes the bar. + _seen.Clear(); + foreach (var (health, xf, entity) in + SystemAPI.Query, RefRO>().WithAll().WithEntityAccess()) + { + _seen.Add(entity); + float cur = health.ValueRO.Current; + float max = health.ValueRO.Max; + float3 pos = xf.ValueRO.Position; + + bool existed = _healthBars.TryGetValue(entity, out var entry); + bool damaged = existed && cur < entry.LastHp - 0.001f; // own damage edge (was core _cache prev.Hp) + entry.LastHp = cur; entry.MaxHp = max; entry.Pos = pos; + _healthBars[entity] = entry; + if (damaged) ShowHealthBar(entity); // arm/refresh this enemy's bar on a damage edge + } + + UpdateHealthBars(dt, cam, localPos); + } + + // ---- Enemy Health Bars (Slice 1, Feature B) — pooled world-space Canvas, on-damage sticky + fade ---- + + void ShowHealthBar(Entity entity) + { + if (!_healthBars.TryGetValue(entity, out var entry) || entry.CanvasGo == null) + entry = CreateHealthBar(entity); + entry.ShowTimer = HealthBarShowDuration; + if (!entry.Visible) { entry.CanvasGo.SetActive(true); entry.Visible = true; } + _healthBars[entity] = entry; // struct — must re-assign + } + + HealthBarEntry CreateHealthBar(Entity entity) + { + var go = new GameObject("EnemyHPBar"); + if (_fxRoot != null) go.transform.SetParent(_fxRoot, false); + var canvas = go.AddComponent(); + canvas.renderMode = RenderMode.WorldSpace; + canvas.sortingOrder = 5; // below the UITK HUD (50); above world geometry + var rt = go.GetComponent(); + rt.sizeDelta = new Vector2(1.2f, 0.14f); + + var bgGo = new GameObject("Bg"); + bgGo.transform.SetParent(go.transform, false); + var bgRt = bgGo.AddComponent(); + bgRt.anchorMin = Vector2.zero; bgRt.anchorMax = Vector2.one; + bgRt.offsetMin = bgRt.offsetMax = Vector2.zero; + var bgImg = bgGo.AddComponent(); + bgImg.material = _barBgMat; + bgImg.color = new Color(0.05f, 0.05f, 0.06f, 0.82f); + + var fillGo = new GameObject("Fill"); + fillGo.transform.SetParent(go.transform, false); + var fillRt = fillGo.AddComponent(); + fillRt.anchorMin = Vector2.zero; fillRt.anchorMax = Vector2.one; + fillRt.offsetMin = new Vector2(0.02f, 0.02f); + fillRt.offsetMax = new Vector2(-0.02f, -0.02f); + var fillImg = fillGo.AddComponent(); + fillImg.material = _barFillMat; + fillImg.color = new Color(0.88f, 0.22f, 0.14f, 1f); + fillImg.type = UnityEngine.UI.Image.Type.Simple; // a sprite-less UI Image ignores fillAmount (it draws a full quad) -> + fillImg.raycastTarget = false; // the bar empties by sizing the fill RectTransform (anchorMax.x = frac) in UpdateHealthBars + + go.SetActive(false); + _healthBars.TryGetValue(entity, out var prev); // preserve tracking (LastHp/MaxHp/Pos) recorded by the scan loop + var entry = new HealthBarEntry + { + CanvasGo = go, Fill = fillImg, Bg = bgImg, ShowTimer = 0f, Visible = false, + LastHp = prev.LastHp, MaxHp = prev.MaxHp, Pos = prev.Pos + }; + _healthBars[entity] = entry; + return entry; + } + + // Per-frame: prune dead bars (own seen-set), pool-cap by distance, billboard + fade. + void UpdateHealthBars(float dt, Camera cam, float3 localPlayerPos) + { + if (_healthBars.Count > 0) + { + _barStale.Clear(); + foreach (var kv in _healthBars) + if (!_seen.Contains(kv.Key)) _barStale.Add(kv.Key); + for (int i = 0; i < _barStale.Count; i++) + { + var e2 = _barStale[i]; + if (_healthBars[e2].CanvasGo != null) Object.Destroy(_healthBars[e2].CanvasGo); + _healthBars.Remove(e2); + } + } + if (_healthBars.Count == 0) return; + + // Cap keys on the number of BUILT bars (not the tracking-only entries), matching the original threshold. + int createdBars = 0; + foreach (var kv in _healthBars) if (kv.Value.CanvasGo != null) createdBars++; + bool capBars = _localPlayer != Entity.Null && createdBars > HealthBarPoolLimit; + + _barKeys.Clear(); + foreach (var k in _healthBars.Keys) _barKeys.Add(k); + for (int i = 0; i < _barKeys.Count; i++) + { + var key = _barKeys[i]; + var entry = _healthBars[key]; + if (entry.CanvasGo == null) continue; // tracking-only (undamaged) — no bar built yet + + float frac = entry.MaxHp > 0f ? math.saturate(entry.LastHp / entry.MaxHp) : 1f; + bool alwaysOn = frac < HealthBarAlwaysOnThreshold; + + if (capBars && math.lengthsq(entry.Pos - localPlayerPos) > FeelConfig.HealthBarMaxDistSq) + { + if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; } + _healthBars[key] = entry; + continue; + } + + if (!alwaysOn) entry.ShowTimer -= dt; + bool shouldShow = alwaysOn || entry.ShowTimer > -HealthBarFadeDuration; + if (shouldShow) + { + if (!entry.Visible) { entry.CanvasGo.SetActive(true); entry.Visible = true; } + if (cam != null) + { + entry.CanvasGo.transform.position = (Vector3)entry.Pos + Vector3.up * HealthBarWorldYOffset; + entry.CanvasGo.transform.rotation = cam.transform.rotation; // billboard + } + float alpha = (!alwaysOn && entry.ShowTimer < 0f) + ? 1f - math.saturate(-entry.ShowTimer / HealthBarFadeDuration) : 1f; + if (entry.Fill != null) { var c = entry.Fill.color; c.a = alpha; entry.Fill.color = c; entry.Fill.rectTransform.anchorMax = new Vector2(frac, 1f); } + if (entry.Bg != null) { var c = entry.Bg.color; c.a = 0.82f * alpha; entry.Bg.color = c; } + } + else if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; } + + _healthBars[key] = entry; + } + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/EnemyHealthBarSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/EnemyHealthBarSystem.cs.meta new file mode 100644 index 000000000..f51f089c4 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/EnemyHealthBarSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0e055bbb583ee594b95ae71f8db43b16 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs index 5159d4232..e6de440b1 100644 --- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs @@ -72,20 +72,6 @@ namespace ProjectM.Client // END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side). VisualElement _runBanner; Label _runBannerText, _runBannerSub; - // Step 14 (expedition redesign): the choice-of-3 boon modal + the route-choice panel. Both are - // observe-only readers of replicated state (BoonOffer via GhostOwnerIsLocal; RunInfo RouteOpt*); clicks - // enqueue through the client send-systems' statics. Built lazily on first show. - VisualElement _boonModal, _boonCardRow; - VisualElement _routePanel; - Label _routeTitle; - int _boonShownFor; // last exact (Option0|Option1<<8|Option2<<16)+1 signature the modal was built for - bool _boonModalBuilt, _routePanelBuilt; - // Step 14 (meta shop): Staging-only permanent-upgrade shop (replicated MetaTierState + ledger Aether; - // row clicks enqueue MetaSpendSendSystem.RequestPurchase — the server re-validates everything). - VisualElement _metaPanel, _metaRowsHost; - Label _metaShopTitle; - bool _metaShopBuilt; - int _metaShownFor; // last (class, tiers, aether) signature the shop rows were built for // Demo polish: the clickable READY panel (Staging/Launching) + the drawn branching route map // (RouteSelect) — the map is regenerated client-side from RunInfo.RunSeed for DISPLAY only; the // clickable next-layer nodes bind to the authoritative RouteOpt* bytes (never the regen). @@ -94,10 +80,6 @@ namespace ProjectM.Client Label _readyTitle; bool _readyPanelBuilt; int _readyShownFor; // (ready, total, localReady, secs, launching) rebuild signature - VisualElement _routeMapHost; // node circles + Painter2D edges - int _routeMapSig; // (seed, room, col, options) signature the map was drawn for - readonly List _routeVisited = new(); // client-local path trace (nodeIds), reset per RunSeed - uint _routeVisitedSeed; // Demo polish round 2: boss presence bar, run-depth dots, outcome flash. VisualElement _bossPanel, _bossFill; Label _bossText; @@ -319,36 +301,6 @@ namespace ProjectM.Client } - // ---- Step 14: the choice-of-3 boon modal + the route-choice panel (observe replicated state; the - // card/button clicks enqueue through the client send-systems' statics) ---- - BoonOffer localOffer = default; - bool hasOffer = false; - foreach (var off in SystemAPI.Query>().WithAll()) - { - localOffer = off.ValueRO; - hasOffer = true; - break; - } - BlobAssetReference boonPool = default; - if (SystemAPI.TryGetSingleton(out var bcat)) - boonPool = bcat.Value; - // Lifecycle gate (post-impl review): even a stale replicated Pending never shows the modal outside - // the reward window. - UpdateBoonModal(localOffer, hasOffer && localOffer.Pending == 1 - && haveRun && runInfo.Lifecycle == RunLifecycle.RoomReward, boonPool); - UpdateRoutePanel(haveRun ? runInfo : default); - - // Client-local path trace for the route map (nodeIds visited this run; display-only). - if (haveRun && runInfo.RunSeed != _routeVisitedSeed) - { - _routeVisited.Clear(); - _routeVisitedSeed = runInfo.RunSeed; - } - if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom) - { - int visitedNode = RunMap.NodeId(runInfo.CurrentRoom, runInfo.CurrentCol); - if (_routeVisited.Count == 0 || _routeVisited[^1] != visitedNode) _routeVisited.Add(visitedNode); - } // The clickable READY panel (Staging/Launching, hidden once the outcome latched — the banner owns // the screen then). Counts are the replicated send-to-all PlayerReady flags. @@ -452,31 +404,6 @@ namespace ProjectM.Client _bioNum.text = bio.ToString(); _chargeNum.text = charge.ToString(); - // ---- Step 14 (meta shop): Staging-only permanent-upgrade shop for the LOCAL class. Class derives from - // the replicated AbilityRef (tracks the dev class-switch; PlayerClass is server-only); tiers from the - // replicated MetaTierState record on the director ghost; Aether from the ledger read above. ---- - byte localClass = ClassTraits.WarriorClass; - bool haveLocalPlayer = false; - foreach (var ar in SystemAPI.Query>().WithAll()) - { - localClass = ClassTraits.ClassForAbility(ar.ValueRO.Id); - haveLocalPlayer = true; - break; - } - bool metaShow = false; - BlobAssetReference metaPool = default; - DynamicBuffer metaRecord = default; - if (haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && !siege - && SystemAPI.TryGetSingleton(out var metaCat) && metaCat.Value.IsCreated - && SystemAPI.TryGetSingletonBuffer(out metaRecord, true)) - { - metaPool = metaCat.Value; - metaShow = true; - } - UpdateMetaShop(metaShow, localClass, aether, metaPool, metaRecord); - UpdateClassPanel(metaShow, localClass); // DR-046: base class pick (Staging) - UpdatePrepPanel(metaShow, ore, bio, aether); // DR-046: base prep loadout (Staging) - UpdatePortalPrompt(haveRun ? runInfo : default, haveRun); // DR-046: room-exit portal prompt (RoomExplore) // DR-042 C6a: dim the Aether upgrade button when it isn't affordable (cost is a compile-time const). // (Step 11: upgrade-button affordability tint retired with the button.) @@ -1441,269 +1368,27 @@ namespace ProjectM.Client : roomType == RoomTypeId.Elite ? "[ELITE]" : roomType == RoomTypeId.Reward ? "[REWARD]" : "[COMBAT]"; - void UpdateBoonModal(BoonOffer offer, bool show, BlobAssetReference pool) - { - if (!show || !pool.IsCreated) - { - if (_boonModal != null) _boonModal.style.display = DisplayStyle.None; - _boonShownFor = 0; - return; - } - var root = _doc != null ? _doc.rootVisualElement : null; - if (root == null) return; - if (!_boonModalBuilt) - { - BuildBoonModal(root); - _boonModalBuilt = true; - } - // Rebuild the three cards only when the offer actually changes (a new room's deal). - // Exact signature (post-impl review): the old lossy byte XOR could collide across consecutive - // rooms and leave stale card labels. +1 keeps 0 as the hidden/reset sentinel. - int sig = 1 + (offer.Option0 | (offer.Option1 << 8) | (offer.Option2 << 16)); - if (_boonShownFor != sig) - { - _boonCardRow.Clear(); - ref var defs = ref pool.Value; - for (byte k = 0; k < 3; k++) - { - byte id = k == 2 ? offer.Option2 : k == 1 ? offer.Option1 : offer.Option0; - int idx = BoonMath.FindDef(ref defs, id); - string title = idx >= 0 ? defs.Defs[idx].Name.ToString() : ("BOON " + id); - string desc = idx >= 0 ? defs.Defs[idx].Desc.ToString() : ""; - byte weight = idx >= 0 ? defs.Defs[idx].Weight : (byte)100; - byte pick = k; // capture a COPY into the closure, never the loop variable - var card = MenuUi.Button(title + "\n" + desc, () => BoonSendSystem.PickBoon(pick)); - card.style.width = 200; - card.style.height = StyleKeyword.Auto; // long descs grow the card - card.style.minHeight = 96; - // Rarity from the draw weight (100 common / 60 uncommon / 30 rare / 10 epic). - var rare = weight <= 10 ? new Color(1f, 0.82f, 0.30f) - : weight <= 30 ? new Color(0.65f, 0.50f, 1f) - : weight <= 60 ? new Color(0.45f, 0.95f, 0.55f) - : new Color(1f, 1f, 1f, 0.30f); - MenuUi.Border(card, rare, weight <= 30 ? 2.5f : 1.5f); - card.style.marginLeft = 8; - card.style.marginRight = 8; - card.style.whiteSpace = WhiteSpace.Normal; - _boonCardRow.Add(card); - } - _boonShownFor = sig; - } - _boonModal.style.display = DisplayStyle.Flex; - } - void BuildBoonModal(VisualElement root) - { - _boonModal = new VisualElement { pickingMode = PickingMode.Ignore }; - _boonModal.style.position = Position.Absolute; - _boonModal.style.left = 0; _boonModal.style.right = 0; - _boonModal.style.top = 0; _boonModal.style.bottom = 0; - _boonModal.style.alignItems = Align.Center; - _boonModal.style.justifyContent = Justify.Center; - _boonModal.style.display = DisplayStyle.None; - var box = new VisualElement(); - box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.96f); - box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10; - box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10; - box.style.paddingLeft = 18; box.style.paddingRight = 18; - box.style.paddingTop = 14; box.style.paddingBottom = 16; - box.style.alignItems = Align.Center; - - var title = new Label("ROOM CLEARED — CHOOSE A BOON"); - title.style.color = new Color(0.6f, 1f, 0.7f); - title.style.fontSize = 18; - title.style.unityFontStyleAndWeight = FontStyle.Bold; - title.style.marginBottom = 12; - box.Add(title); - - _boonCardRow = new VisualElement(); - _boonCardRow.style.flexDirection = FlexDirection.Row; - box.Add(_boonCardRow); - - _boonModal.Add(box); - root.Add(_boonModal); - } // ---- the drawn branching route map (Slay-the-Spire style; display regen from RunSeed, clicks bind // to the authoritative RouteOpt* bytes) ---- - const float MapStrideX = 58f, MapStrideY = 46f, MapNodeSize = 34f, MapPad = 14f; - static Vector2 MapNodePos(int layer, int col, byte layerWidth) - { - float x = MapPad + layer * MapStrideX; - float y = MapPad + MapStrideY + (col - (layerWidth - 1) * 0.5f) * MapStrideY; - return new Vector2(x, y); - } - static string RoomGlyph(byte t) => t == RoomTypeId.Boss ? "B" - : t == RoomTypeId.Elite ? "E" : t == RoomTypeId.Reward ? "R" : "C"; - static Color RoomColor(byte t) => t == RoomTypeId.Boss ? new Color(0.92f, 0.28f, 0.22f) - : t == RoomTypeId.Elite ? new Color(0.80f, 0.45f, 1f) - : t == RoomTypeId.Reward ? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 0.72f, 0.35f); - void UpdateRoutePanel(RunInfo runInfo) - { - // Keyed on the LIFECYCLE (never RouteOptionCount alone — the review's D-F6 criterion). - bool show = runInfo.Lifecycle == RunLifecycle.RouteSelect && runInfo.RouteOptionCount > 0; - if (!show) - { - if (_routePanel != null) _routePanel.style.display = DisplayStyle.None; - _routeMapSig = 0; - return; - } - var root = _doc != null ? _doc.rootVisualElement : null; - if (root == null) return; - if (!_routePanelBuilt) - { - BuildRoutePanel(root); - _routePanelBuilt = true; - } - int sig = (int)runInfo.RunSeed ^ (runInfo.CurrentRoom + 1) * 131 ^ runInfo.CurrentCol * 31 - ^ (runInfo.RouteOptionCount << 24) ^ (runInfo.RouteOpt0Col << 16) - ^ (runInfo.RouteOpt1Col << 18) ^ (runInfo.RouteOpt2Col << 20); - if (sig == 0) sig = 1; - if (_routeMapSig != sig) - { - RebuildRouteMap(runInfo); - _routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount; - _routeMapSig = sig; - } - _routePanel.style.display = DisplayStyle.Flex; - } - void BuildRoutePanel(VisualElement root) - { - _routePanel = new VisualElement { pickingMode = PickingMode.Ignore }; - _routePanel.style.position = Position.Absolute; - _routePanel.style.left = 0; _routePanel.style.right = 0; - _routePanel.style.top = 0; _routePanel.style.bottom = 0; - _routePanel.style.alignItems = Align.Center; - _routePanel.style.justifyContent = Justify.Center; - _routePanel.style.display = DisplayStyle.None; - var box = new VisualElement { pickingMode = PickingMode.Position }; // swallow world clicks under the map - box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.95f); - box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10; - box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10; - box.style.paddingLeft = 18; box.style.paddingRight = 18; - box.style.paddingTop = 12; box.style.paddingBottom = 12; - box.style.alignItems = Align.Center; - _routeTitle = new Label("CHOOSE YOUR PATH"); - _routeTitle.style.color = new Color(0.55f, 0.85f, 1f); - _routeTitle.style.fontSize = 16; - _routeTitle.style.unityFontStyleAndWeight = FontStyle.Bold; - _routeTitle.style.marginBottom = 10; - box.Add(_routeTitle); - _routeMapHost = new VisualElement { pickingMode = PickingMode.Ignore }; - _routeMapHost.style.position = Position.Relative; - box.Add(_routeMapHost); - var cap = HudUi.Text("your path is lit — click a highlighted room to commit the party", 13, - MenuUi.SubCol, TextAnchor.MiddleCenter); - cap.style.marginTop = 10; - box.Add(cap); - _routePanel.Add(box); - root.Add(_routePanel); - } - void RebuildRouteMap(RunInfo runInfo) - { - _routeMapHost.Clear(); - var map = RunMapMath.Generate(runInfo.RunSeed); - _routeMapHost.style.width = MapPad * 2f + (map.LayerCount - 1) * MapStrideX + MapNodeSize; - _routeMapHost.style.height = MapPad * 2f + 2f * MapStrideY + MapNodeSize; - // Edges under the nodes (Painter2D); walked segments glow, the rest are faint. - var edges = new VisualElement { pickingMode = PickingMode.Ignore }; - edges.style.position = Position.Absolute; - edges.style.left = 0; edges.style.top = 0; edges.style.right = 0; edges.style.bottom = 0; - var mapCopy = map; - var visited = new List(_routeVisited); - edges.generateVisualContent += ctx => - { - var p = ctx.painter2D; - p.lineWidth = 2f; - var c = new Vector2(MapNodeSize * 0.5f, MapNodeSize * 0.5f); - for (int layer = 0; layer < mapCopy.LayerCount - 1; layer++) - for (int col = 0; col < mapCopy.LayerWidths[layer]; col++) - { - var node = mapCopy.Node(layer, col); - if (node.NextMask == 0) continue; - var a = MapNodePos(layer, col, mapCopy.LayerWidths[layer]); - for (int j = 0; j < mapCopy.LayerWidths[layer + 1]; j++) - { - if ((node.NextMask & (1 << j)) == 0) continue; - var b = MapNodePos(layer + 1, j, mapCopy.LayerWidths[layer + 1]); - bool walked = visited.Contains(RunMap.NodeId(layer, col)) - && visited.Contains(RunMap.NodeId(layer + 1, j)); - p.strokeColor = walked ? new Color(0.55f, 0.85f, 1f, 0.9f) : new Color(1f, 1f, 1f, 0.16f); - p.BeginPath(); - p.MoveTo(a + c); - p.LineTo(b + c); - p.Stroke(); - } - } - }; - _routeMapHost.Add(edges); - int nextLayer = runInfo.CurrentRoom + 1; - for (int layer = 0; layer < map.LayerCount; layer++) - for (int col = 0; col < map.LayerWidths[layer]; col++) - { - var node = map.Node(layer, col); - bool isCurrent = layer == runInfo.CurrentRoom && col == runInfo.CurrentCol; - bool wasVisited = _routeVisited.Contains(RunMap.NodeId(layer, col)); - byte opt = 255; - if (layer == nextLayer) - { - if (runInfo.RouteOptionCount > 0 && col == runInfo.RouteOpt0Col) opt = 0; - else if (runInfo.RouteOptionCount > 1 && col == runInfo.RouteOpt1Col) opt = 1; - else if (runInfo.RouteOptionCount > 2 && col == runInfo.RouteOpt2Col) opt = 2; - } - _routeMapHost.Add(MakeMapNode(node.RoomType, - MapNodePos(layer, col, map.LayerWidths[layer]), isCurrent, wasVisited, opt, layer <= runInfo.CurrentRoom)); - } - } - - VisualElement MakeMapNode(byte roomType, Vector2 pos, bool isCurrent, bool visited, byte optionIndex, bool past) - { - bool clickable = optionIndex != 255; - var n = new VisualElement { pickingMode = clickable ? PickingMode.Position : PickingMode.Ignore }; - n.style.position = Position.Absolute; - n.style.left = pos.x; n.style.top = pos.y; - n.style.width = MapNodeSize; n.style.height = MapNodeSize; - MenuUi.Round(n, MapNodeSize * 0.5f); - var c = RoomColor(roomType); - float bgA = clickable ? 0.95f : visited || isCurrent ? 0.85f : past ? 0.20f : 0.40f; - var restBg = new Color(c.r * 0.35f, c.g * 0.35f, c.b * 0.35f, bgA); - n.style.backgroundColor = restBg; - MenuUi.Border(n, isCurrent ? new Color(0.55f, 0.85f, 1f) : clickable ? c : new Color(1f, 1f, 1f, 0.18f), - isCurrent || clickable ? 2.5f : 1.2f); - var lbl = new Label(RoomGlyph(roomType)) { pickingMode = PickingMode.Ignore }; - lbl.style.unityTextAlign = TextAnchor.MiddleCenter; - lbl.style.flexGrow = 1; - lbl.style.color = clickable || visited || isCurrent ? c : new Color(1f, 1f, 1f, 0.35f); - lbl.style.fontSize = 15; - lbl.style.unityFontStyleAndWeight = FontStyle.Bold; - n.Add(lbl); - if (clickable) - { - byte pick = optionIndex; // closure copy, never the loop variable - n.RegisterCallback(_ => RouteSendSystem.PickRoute(pick)); - n.RegisterCallback(_ => - n.style.backgroundColor = new Color(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1f)); - n.RegisterCallback(_ => n.style.backgroundColor = restBg); - } - return n; - } // ---- the clickable READY panel (Staging: toggle + party pips; Launching: countdown + abort) ---- @@ -1874,255 +1559,30 @@ namespace ProjectM.Client _depthPanel.style.display = DisplayStyle.Flex; } - // DR-046: base class-select + prep-loadout panels (Staging) + the room-exit portal prompt (RoomExplore). - VisualElement _classPanel, _prepPanel, _prepRowsHost; - Label _classTitle, _prepTitle, _portalPrompt; - Button _classWarBtn, _classRangerBtn; - bool _classPanelBuilt, _prepPanelBuilt, _portalBuilt; // B6 run-failed read (client-local edge state; see the tracker near the top of OnUpdate). byte _prevRunLifecycle; int _chargeAtLaunch; bool _wentInRun; float _runFailedUntil; - int _classShownFor, _prepShownFor; - void UpdateClassPanel(bool show, byte classId) - { - if (!show) { if (_classPanel != null) _classPanel.style.display = DisplayStyle.None; _classShownFor = 0; return; } - var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return; - if (!_classPanelBuilt) { BuildClassPanel(root); _classPanelBuilt = true; } - int sig = classId + 1; - if (_classShownFor != sig) - { - bool ranger = classId == ClassTraits.RangerClass; - _classWarBtn.text = ranger ? "WARRIOR" : "WARRIOR ✓"; - _classRangerBtn.text = ranger ? "RANGER ✓" : "RANGER"; - _classWarBtn.SetEnabled(ranger); - _classRangerBtn.SetEnabled(!ranger); - _classShownFor = sig; - } - _classPanel.style.display = DisplayStyle.Flex; - } - void BuildClassPanel(VisualElement root) - { - _classPanel = new VisualElement { pickingMode = PickingMode.Ignore }; - _classPanel.style.position = Position.Absolute; - _classPanel.style.left = 12; _classPanel.style.top = Length.Percent(22); - _classPanel.style.display = DisplayStyle.None; - var box = new VisualElement(); - box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f); - MenuUi.Round(box, 10); - box.style.paddingLeft = 12; box.style.paddingRight = 12; box.style.paddingTop = 10; box.style.paddingBottom = 10; - _classTitle = new Label("CLASS"); - _classTitle.style.color = MenuUi.Accent; _classTitle.style.fontSize = 14; - _classTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _classTitle.style.marginBottom = 8; - box.Add(_classTitle); - _classWarBtn = MenuUi.Button("WARRIOR", () => ClassSelectSendSystem.RequestClass(ClassTraits.WarriorClass)); - _classWarBtn.style.marginBottom = 4; box.Add(_classWarBtn); - _classRangerBtn = MenuUi.Button("RANGER", () => ClassSelectSendSystem.RequestClass(ClassTraits.RangerClass)); - box.Add(_classRangerBtn); - _classPanel.Add(box); root.Add(_classPanel); - } - void UpdatePrepPanel(bool show, int ore, int bio, int aether) - { - if (!show) { if (_prepPanel != null) _prepPanel.style.display = DisplayStyle.None; _prepShownFor = 0; return; } - var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return; - if (!_prepPanelBuilt) { BuildPrepPanel(root); _prepPanelBuilt = true; } - uint boughtMask = 0; - foreach (var mods in SystemAPI.Query>().WithAll()) - { - for (int m = 0; m < mods.Length; m++) - { - uint sid = mods[m].SourceId; - if (sid >= Tuning.PrepSourceIdBase && sid < Tuning.PrepSourceIdBase + Tuning.PrepSourceIdSpan) - boughtMask |= (uint)(1 << (int)(sid - Tuning.PrepSourceIdBase)); - } - break; - } - int sig = ore * 7 ^ bio * 13 ^ aether * 31 ^ (int)boughtMask * 101; - if (sig == 0) sig = 1; - if (_prepShownFor != sig) - { - _prepRowsHost.Clear(); - for (int i = 0; i < PrepCatalog.Count; i++) - { - var r = PrepCatalog.Rows[i]; - int have = r.CostResId == ResourceId.Aether ? aether : r.CostResId == ResourceId.Biomass ? bio : ore; - bool bought = (boughtMask & (uint)(1 << r.Id)) != 0; - string resName = r.CostResId == ResourceId.Aether ? "Aether" : r.CostResId == ResourceId.Biomass ? "Biomass" : "Ore"; - string label = PrepLabel(r.Id) + (bought ? " BOUGHT" : " - " + r.Cost + " " + resName); - byte buyId = r.Id; - var row = MenuUi.Button(label, () => PrepPurchaseSendSystem.RequestPrep(buyId)); - row.style.width = 240; row.style.marginBottom = 4; - row.style.whiteSpace = WhiteSpace.Normal; row.style.unityTextAlign = TextAnchor.MiddleLeft; - row.SetEnabled(!bought && have >= r.Cost); - _prepRowsHost.Add(row); - } - _prepShownFor = sig; - } - _prepPanel.style.display = DisplayStyle.Flex; - } - static string PrepLabel(byte id) => id == 0 ? "+30 Max HP" : id == 1 ? "+12% Move Speed" - : id == 2 ? "+20% Melee Damage" : "+20% Ranged Damage"; - void BuildPrepPanel(VisualElement root) - { - _prepPanel = new VisualElement { pickingMode = PickingMode.Ignore }; - _prepPanel.style.position = Position.Absolute; - _prepPanel.style.left = 12; _prepPanel.style.top = Length.Percent(45); - _prepPanel.style.display = DisplayStyle.None; - var box = new VisualElement(); - box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f); - MenuUi.Round(box, 10); - box.style.paddingLeft = 12; box.style.paddingRight = 12; box.style.paddingTop = 10; box.style.paddingBottom = 10; - _prepTitle = new Label("PREP LOADOUT (lasts the run)"); - _prepTitle.style.color = MenuUi.Accent; _prepTitle.style.fontSize = 14; - _prepTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _prepTitle.style.marginBottom = 8; - box.Add(_prepTitle); - _prepRowsHost = new VisualElement(); box.Add(_prepRowsHost); - _prepPanel.Add(box); root.Add(_prepPanel); - } - void UpdatePortalPrompt(RunInfo runInfo, bool haveRun) - { - var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return; - if (!_portalBuilt) { BuildPortalPrompt(root); _portalBuilt = true; } - bool show = false, inRange = false; - if (haveRun && runInfo.Lifecycle == RunLifecycle.RoomExplore - && SystemAPI.TryGetSingleton(out var anchor)) - { - show = true; // room cleared -> ALWAYS steer the player to the (now visible) portal, not only when in range - float3 portalPos = RegionMath.ExpeditionPortalPos(BaseGridMath.PlotCenter(anchor), (byte)(runInfo.CurrentRoom & 1)); - foreach (var lt in SystemAPI.Query>().WithAll()) - { - inRange = math.distance(lt.ValueRO.Position.xz, portalPos.xz) <= Tuning.PortalInteractRange; - if (inRange) - { - var kb = UnityEngine.InputSystem.Keyboard.current; - if (kb != null && kb.eKey.wasPressedThisFrame) PortalInteractSendSystem.Interact(); - } - break; - } - _portalPrompt.text = inRange - ? "PRESS E TO LEAVE — the haul comes home" - : "ROOM CLEAR — reach the glowing portal to move on"; - } - _portalPrompt.style.display = show ? DisplayStyle.Flex : DisplayStyle.None; - } - void BuildPortalPrompt(VisualElement root) - { - _portalPrompt = HudUi.Display("PRESS E TO LEAVE — the haul comes home", 20, new Color(0.55f, 0.95f, 1f), TextAnchor.MiddleCenter); - _portalPrompt.style.position = Position.Absolute; - _portalPrompt.style.left = 0; _portalPrompt.style.right = 0; _portalPrompt.style.bottom = 240; - _portalPrompt.pickingMode = PickingMode.Ignore; - _portalPrompt.style.display = DisplayStyle.None; - root.Add(_portalPrompt); - } + + + + + + + -void UpdateMetaShop(bool show, byte classId, int aether, - BlobAssetReference pool, DynamicBuffer record) - { - if (!show) - { - if (_metaPanel != null) _metaPanel.style.display = DisplayStyle.None; - _metaShownFor = 0; - return; - } - var root = _doc != null ? _doc.rootVisualElement : null; - if (root == null) return; - if (!_metaShopBuilt) - { - BuildMetaShop(root); - _metaShopBuilt = true; - } - // Rebuild the rows only when class / owned tiers / affordability actually change (Staging-only, <=8 rows). - int sig = classId * 131 ^ aether * 31; - for (int i = 0; i < record.Length; i++) - sig ^= (record[i].ClassId * 7 + record[i].UpgradeId * 13 + record[i].Tier) * (i + 3); - if (sig == 0) sig = 1; - if (_metaShownFor != sig) - { - _metaRowsHost.Clear(); - _metaShopTitle.text = (classId == ClassTraits.RangerClass ? "RANGER" : "WARRIOR") - + " PERMANENT UPGRADES - AETHER " + aether; - ref var defs = ref pool.Value; - byte classBit = BoonMath.MaskFor(classId); - for (int d = 0; d < defs.Defs.Length; d++) - { - if ((defs.Defs[d].ClassMask & classBit) == 0) continue; - byte id = defs.Defs[d].Id; - byte owned = MetaMath.TierOf(record, classId, id); - if (owned > defs.Defs[d].MaxTier) owned = defs.Defs[d].MaxTier; // D-F5 display clamp (seed AND spend AND shop) - bool maxed = owned >= defs.Defs[d].MaxTier; - int cost = MetaMath.CostForTier(in defs.Defs[d], owned); - string label = defs.Defs[d].Name.ToString() - + (maxed ? " MAXED" : " - " + cost + " Aether") - + "\n" + defs.Defs[d].Desc.ToString(); - byte buyId = id; // closure copy, never the loop variable - var row = MenuUi.Button(label, () => MetaSpendSendSystem.RequestPurchase(buyId)); - row.style.width = 290; - row.style.height = StyleKeyword.Auto; // two-line labels must grow the row (overlap fix) - row.style.paddingTop = 6; row.style.paddingBottom = 6; - row.style.marginBottom = 4; - row.style.whiteSpace = WhiteSpace.Normal; - row.style.unityTextAlign = TextAnchor.MiddleLeft; - row.SetEnabled(!maxed && aether >= cost); // honest UI; the server re-validates everything anyway - // Owned-tier pips (replaces the "[2/5]" text — reads at a glance). - var pipRow = new VisualElement { pickingMode = PickingMode.Ignore }; - pipRow.style.flexDirection = FlexDirection.Row; - pipRow.style.marginTop = 3; - for (int p = 0; p < defs.Defs[d].MaxTier; p++) - { - var tp = new VisualElement { pickingMode = PickingMode.Ignore }; - tp.style.width = 9; tp.style.height = 9; - tp.style.marginRight = 3; - MenuUi.Round(tp, 4.5f); - tp.style.backgroundColor = p < owned ? AetherCyan : new Color(1f, 1f, 1f, 0.14f); - pipRow.Add(tp); - } - row.Add(pipRow); - _metaRowsHost.Add(row); - } - _metaShownFor = sig; - } - _metaPanel.style.display = DisplayStyle.Flex; - } - void BuildMetaShop(VisualElement root) - { - _metaPanel = new VisualElement { pickingMode = PickingMode.Ignore }; - _metaPanel.style.position = Position.Absolute; - _metaPanel.style.right = 12; - _metaPanel.style.top = Length.Percent(22); - _metaPanel.style.alignItems = Align.FlexEnd; - _metaPanel.style.display = DisplayStyle.None; - var box = new VisualElement(); - box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f); - box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10; - box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10; - box.style.paddingLeft = 12; box.style.paddingRight = 12; - box.style.paddingTop = 10; box.style.paddingBottom = 10; - - _metaShopTitle = new Label("PERMANENT UPGRADES"); - _metaShopTitle.style.color = AetherCyan; - _metaShopTitle.style.fontSize = 14; - _metaShopTitle.style.unityFontStyleAndWeight = FontStyle.Bold; - _metaShopTitle.style.marginBottom = 8; - box.Add(_metaShopTitle); - - _metaRowsHost = new VisualElement(); - box.Add(_metaRowsHost); - - _metaPanel.Add(box); - root.Add(_metaPanel); - } } } diff --git a/Assets/_Project/Scripts/Client/Presentation/MetaShopHudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/MetaShopHudSystem.cs new file mode 100644 index 000000000..6aae6053d --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/MetaShopHudSystem.cs @@ -0,0 +1,199 @@ +using System.Collections.Generic; +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; +using Unity.Transforms; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.UIElements; + +namespace ProjectM.Client +{ + /// + /// The Staging-only permanent-upgrade shop (meta shop) — extracted from into its own + /// client-only, observe-only presentation in . + /// Owns its own runtime UIDocument sharing (sortingOrder 51). Recomputes + /// its inputs locally: the local class from the replicated + /// (), Aether from the buffer, siege from + /// , and the Staging gate from . Row clicks enqueue through + /// — the server re-validates everything. + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + [UpdateInGroup(typeof(PresentationSystemGroup))] + public partial class MetaShopHudSystem : SystemBase + { + GameObject _go; + UIDocument _doc; + bool _built; + + VisualElement _metaPanel, _metaRowsHost; + Label _metaShopTitle; + bool _metaShopBuilt; + int _metaShownFor; // last (class, tiers, aether) signature the shop rows were built for + + protected override void OnStartRunning() + { + if (_go != null) return; + MenuUi.EnsureEventSystem(); + _go = new GameObject("~HUDMetaShop"); + _doc = _go.AddComponent(); + _doc.panelSettings = MenuUi.LoadPanelSettings(); + _doc.sortingOrder = 51; + } + + protected override void OnDestroy() + { + if (_go != null) Object.Destroy(_go); + } + + protected override void OnUpdate() + { + if (_doc == null) return; + var root = _doc.rootVisualElement; + if (root == null) return; // panel not initialised yet (next frame) + if (!_built) + { + 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 game-world clicks + _built = true; + } + + bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); + bool haveCycle = SystemAPI.TryGetSingleton(out var cyc); + bool siege = haveCycle && cyc.Phase == CyclePhase.Siege; + + // Aether from the ledger (the sole meta-shop currency; last entry wins, matching the core loop). + int aether = 0; + if (SystemAPI.TryGetSingletonEntity(out var ledgerE)) + { + var buf = SystemAPI.GetBuffer(ledgerE); + for (int i = 0; i < buf.Length; i++) + if (buf[i].ItemId == ResourceId.Aether) aether = buf[i].Count; + } + + // Local class derives from the replicated AbilityRef (tracks the dev class-switch; PlayerClass is + // server-only); tiers from the replicated MetaTierState record on the director ghost. + byte localClass = ClassTraits.WarriorClass; + bool haveLocalPlayer = false; + foreach (var ar in SystemAPI.Query>().WithAll()) + { + localClass = ClassTraits.ClassForAbility(ar.ValueRO.Id); + haveLocalPlayer = true; + break; + } + + bool metaShow = false; + BlobAssetReference metaPool = default; + DynamicBuffer metaRecord = default; + if (haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && !siege + && SystemAPI.TryGetSingleton(out var metaCat) && metaCat.Value.IsCreated + && SystemAPI.TryGetSingletonBuffer(out metaRecord, true)) + { + metaPool = metaCat.Value; + metaShow = true; + } + UpdateMetaShop(metaShow, localClass, aether, metaPool, metaRecord); + } + + void UpdateMetaShop(bool show, byte classId, int aether, + BlobAssetReference pool, DynamicBuffer record) + { + if (!show) + { + if (_metaPanel != null) _metaPanel.style.display = DisplayStyle.None; + _metaShownFor = 0; + return; + } + var root = _doc != null ? _doc.rootVisualElement : null; + if (root == null) return; + if (!_metaShopBuilt) + { + BuildMetaShop(root); + _metaShopBuilt = true; + } + + // Rebuild the rows only when class / owned tiers / affordability actually change (Staging-only, <=8 rows). + int sig = classId * 131 ^ aether * 31; + for (int i = 0; i < record.Length; i++) + sig ^= (record[i].ClassId * 7 + record[i].UpgradeId * 13 + record[i].Tier) * (i + 3); + if (sig == 0) sig = 1; + if (_metaShownFor != sig) + { + _metaRowsHost.Clear(); + _metaShopTitle.text = (classId == ClassTraits.RangerClass ? "RANGER" : "WARRIOR") + + " PERMANENT UPGRADES - AETHER " + aether; + ref var defs = ref pool.Value; + byte classBit = BoonMath.MaskFor(classId); + for (int d = 0; d < defs.Defs.Length; d++) + { + if ((defs.Defs[d].ClassMask & classBit) == 0) continue; + byte id = defs.Defs[d].Id; + byte owned = MetaMath.TierOf(record, classId, id); + if (owned > defs.Defs[d].MaxTier) owned = defs.Defs[d].MaxTier; // D-F5 display clamp (seed AND spend AND shop) + bool maxed = owned >= defs.Defs[d].MaxTier; + int cost = MetaMath.CostForTier(in defs.Defs[d], owned); + string label = defs.Defs[d].Name.ToString() + + (maxed ? " MAXED" : " - " + cost + " Aether") + + "\n" + defs.Defs[d].Desc.ToString(); + byte buyId = id; // closure copy, never the loop variable + var row = MenuUi.Button(label, () => MetaSpendSendSystem.RequestPurchase(buyId)); + row.style.width = 290; + row.style.height = StyleKeyword.Auto; // two-line labels must grow the row (overlap fix) + row.style.paddingTop = 6; row.style.paddingBottom = 6; + row.style.marginBottom = 4; + row.style.whiteSpace = WhiteSpace.Normal; + row.style.unityTextAlign = TextAnchor.MiddleLeft; + row.SetEnabled(!maxed && aether >= cost); // honest UI; the server re-validates everything anyway + // Owned-tier pips (replaces the "[2/5]" text — reads at a glance). + var pipRow = new VisualElement { pickingMode = PickingMode.Ignore }; + pipRow.style.flexDirection = FlexDirection.Row; + pipRow.style.marginTop = 3; + for (int p = 0; p < defs.Defs[d].MaxTier; p++) + { + var tp = new VisualElement { pickingMode = PickingMode.Ignore }; + tp.style.width = 9; tp.style.height = 9; + tp.style.marginRight = 3; + MenuUi.Round(tp, 4.5f); + tp.style.backgroundColor = p < owned ? MenuUi.Accent : new Color(1f, 1f, 1f, 0.14f); + pipRow.Add(tp); + } + row.Add(pipRow); + _metaRowsHost.Add(row); + } + _metaShownFor = sig; + } + _metaPanel.style.display = DisplayStyle.Flex; + } + + void BuildMetaShop(VisualElement root) + { + _metaPanel = new VisualElement { pickingMode = PickingMode.Ignore }; + _metaPanel.style.position = Position.Absolute; + _metaPanel.style.right = 12; + _metaPanel.style.top = Length.Percent(22); + _metaPanel.style.alignItems = Align.FlexEnd; + _metaPanel.style.display = DisplayStyle.None; + + var box = new VisualElement(); + box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f); + box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10; + box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10; + box.style.paddingLeft = 12; box.style.paddingRight = 12; + box.style.paddingTop = 10; box.style.paddingBottom = 10; + + _metaShopTitle = new Label("PERMANENT UPGRADES"); + _metaShopTitle.style.color = MenuUi.Accent; + _metaShopTitle.style.fontSize = 14; + _metaShopTitle.style.unityFontStyleAndWeight = FontStyle.Bold; + _metaShopTitle.style.marginBottom = 8; + box.Add(_metaShopTitle); + + _metaRowsHost = new VisualElement(); + box.Add(_metaRowsHost); + + _metaPanel.Add(box); + root.Add(_metaPanel); + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/MetaShopHudSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/MetaShopHudSystem.cs.meta new file mode 100644 index 000000000..95d6ce937 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/MetaShopHudSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2551683f48286ae41980c5bb4e48d6e1 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/RoomPortalBeaconSystem.cs b/Assets/_Project/Scripts/Client/Presentation/RoomPortalBeaconSystem.cs new file mode 100644 index 000000000..3a855294d --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/RoomPortalBeaconSystem.cs @@ -0,0 +1,102 @@ +using ProjectM.Simulation; +using Unity.Entities; +using Unity.Mathematics; +using UnityEngine; +using static ProjectM.Client.FeedbackFx; + +namespace ProjectM.Client +{ + /// + /// DR-046 — client-only, observe-only presentation of the room-exit PORTAL made visible. A managed + /// in that OBSERVES replicated + /// and never mutates the sim. During the loot window it shows a glowing cyan + /// pillar (or the authored effect when wired) at the client-derived portal position + /// so the player has an unmistakable "go here to continue" target; hidden whenever the run isn't in RoomExplore. + /// Position resolves through the SAME authority the HUD prompt uses, so + /// the beacon and the "PRESS E" range always agree. Extracted from CombatFeedbackSystem (owns its own FX-root + + /// beacon material); no Entity-keyed cache. + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + [UpdateInGroup(typeof(PresentationSystemGroup))] + public partial class RoomPortalBeaconSystem : SystemBase + { + Transform _fxRoot; + GameObject _portalFx; // Phase 1: authored portal effect (VFXConfig.Portal) replacing the procedural pillar when wired + Material _portalMat; // DR-046: room-exit portal beacon glow (mutated for the pulse; beacon-only mat) + GameObject _portalBeacon; // DR-046: pooled world-space "go here" pillar, shown only during RoomExplore + + protected override void OnStartRunning() + { + if (_fxRoot != null) return; + _fxRoot = new GameObject("~RoomPortalBeaconFX").transform; + _portalMat = MakeParticleMaterial(); + _portalMat.name = "RoomPortal"; + _portalMat.color = new Color(0.25f, 1.2f, 1.55f, 0.85f); // DR-046: HDR cyan portal glow (Phase 0: tamed — 2.6/3.4 bloomed to a white blob) + } + + protected override void OnDestroy() + { + if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); + if (_portalMat != null) Object.Destroy(_portalMat); + } + + protected override void OnUpdate() + { + UpdatePortalBeacon(); + } + + // DR-046: the room-exit PORTAL made VISIBLE. During the RoomExplore loot window a glowing cyan pillar marks the + // client-derived portal position so the player has an unmistakable "go here to continue" target — the HUD prompt + // alone left the exit invisible, so players waited out the ~30s grace timeout ("nothing happens for a while"). + // Client-only, observe-only; one pooled GameObject, hidden whenever the run isn't in RoomExplore. Position + // resolves through the SAME RegionMath.ExpeditionPortalPos authority the HUD prompt uses -> beacon + "PRESS E" + // range always agree. + void UpdatePortalBeacon() + { + if (_fxRoot == null || _portalMat == null) return; + bool inExplore = SystemAPI.TryGetSingleton(out var ri) && ri.Lifecycle == RunLifecycle.RoomExplore; + if (!inExplore || !SystemAPI.TryGetSingleton(out var anchor)) + { + if (_portalBeacon != null && _portalBeacon.activeSelf) _portalBeacon.SetActive(false); + if (_portalFx != null && _portalFx.activeSelf) _portalFx.SetActive(false); + return; + } + float3 pos = RegionMath.ExpeditionPortalPos(BaseGridMath.PlotCenter(anchor), (byte)(ri.CurrentRoom & 1)); + // Phase 1: prefer the authored portal effect (VFXConfig.Portal, PolygonParticleFX) over the + // procedural pillar; the pillar remains the asset-free fallback. + var vfx = VFXConfig.Instance; + if (vfx != null && vfx.Portal != null) + { + if (_portalFx == null) + { + _portalFx = Object.Instantiate(vfx.Portal, _fxRoot, false); + _portalFx.name = "~RoomPortalFx"; + } + _portalFx.transform.position = new Vector3(pos.x, 0f, pos.z); // terrain y=0 (pos.y is the capsule plane) + if (!_portalFx.activeSelf) _portalFx.SetActive(true); + if (_portalBeacon != null && _portalBeacon.activeSelf) _portalBeacon.SetActive(false); + return; + } + if (_portalBeacon == null) + { + _portalBeacon = GameObject.CreatePrimitive(PrimitiveType.Cylinder); + _portalBeacon.name = "~RoomPortalBeacon"; + var col = _portalBeacon.GetComponent(); if (col != null) Object.Destroy(col); // cosmetic only + _portalBeacon.transform.SetParent(_fxRoot, false); + var mr = _portalBeacon.GetComponent(); + mr.sharedMaterial = _portalMat; + mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + mr.receiveShadows = false; + } + if (!_portalBeacon.activeSelf) _portalBeacon.SetActive(true); + float t = (float)SystemAPI.Time.ElapsedTime; + float breathe = 0.5f + 0.5f * math.sin(t * 3.5f); + var tr = _portalBeacon.transform; + // Cylinder is 2u tall in local space -> scale.y=2.2 gives a 4.4u pillar; lift the centre so the base sits + // on the TERRAIN (y=0) — pos.y is the CC capsule-center plane (GridOrigin.y=1), 1 u above the ground. + tr.position = new Vector3(pos.x, 2.2f, pos.z); + tr.localScale = new Vector3(0.9f + 0.12f * breathe, 2.2f, 0.9f + 0.12f * breathe); + _portalMat.color = new Color(0.25f, 1.2f, 1.55f, 0.45f + 0.3f * breathe); // glow throb (beacon-only mat; Phase 0: tamed + slimmed — the fat 6u pillar bloomed to a white egg swallowing the prompt) + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/RoomPortalBeaconSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/RoomPortalBeaconSystem.cs.meta new file mode 100644 index 000000000..5dfa01fb9 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/RoomPortalBeaconSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 587c50c122f52954da052232f28a6052 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/RouteMapHudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/RouteMapHudSystem.cs new file mode 100644 index 000000000..938e22fad --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/RouteMapHudSystem.cs @@ -0,0 +1,259 @@ +using System.Collections.Generic; +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; +using Unity.Transforms; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.UIElements; + +namespace ProjectM.Client +{ + /// + /// The drawn branching route map (RouteSelect) — extracted from into its own client-only, + /// observe-only presentation in . Owns its own + /// runtime UIDocument sharing (sortingOrder 55). The map is regenerated + /// client-side from RunInfo.RunSeed for DISPLAY only; the clickable next-layer nodes bind to the + /// authoritative RouteOpt* bytes (never the regen) via . Also owns the + /// client-local visited-path trace (nodeIds; reset per RunSeed) that lights walked edges. + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + [UpdateInGroup(typeof(PresentationSystemGroup))] + public partial class RouteMapHudSystem : SystemBase + { + GameObject _go; + UIDocument _doc; + bool _built; + + VisualElement _routePanel; + Label _routeTitle; + bool _routePanelBuilt; + VisualElement _routeMapHost; // node circles + Painter2D edges + int _routeMapSig; // (seed, room, col, options) signature the map was drawn for + readonly List _routeVisited = new(); // client-local path trace (nodeIds), reset per RunSeed + uint _routeVisitedSeed; + + protected override void OnStartRunning() + { + if (_go != null) return; + MenuUi.EnsureEventSystem(); + _go = new GameObject("~HUDRouteMap"); + _doc = _go.AddComponent(); + _doc.panelSettings = MenuUi.LoadPanelSettings(); + _doc.sortingOrder = 55; + } + + protected override void OnDestroy() + { + if (_go != null) Object.Destroy(_go); + } + + protected override void OnUpdate() + { + if (_doc == null) return; + var root = _doc.rootVisualElement; + if (root == null) return; // panel not initialised yet (next frame) + if (!_built) + { + 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 game-world clicks + _built = true; + } + + bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); + UpdateRoutePanel(haveRun ? runInfo : default); + + // Client-local path trace for the route map (nodeIds visited this run; display-only). + if (haveRun && runInfo.RunSeed != _routeVisitedSeed) + { + _routeVisited.Clear(); + _routeVisitedSeed = runInfo.RunSeed; + } + if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom) + { + int visitedNode = RunMap.NodeId(runInfo.CurrentRoom, runInfo.CurrentCol); + if (_routeVisited.Count == 0 || _routeVisited[^1] != visitedNode) _routeVisited.Add(visitedNode); + } + } + + // ---- the drawn branching route map (Slay-the-Spire style; display regen from RunSeed, clicks bind + // to the authoritative RouteOpt* bytes) ---- + + const float MapStrideX = 58f, MapStrideY = 46f, MapNodeSize = 34f, MapPad = 14f; + + static Vector2 MapNodePos(int layer, int col, byte layerWidth) + { + float x = MapPad + layer * MapStrideX; + float y = MapPad + MapStrideY + (col - (layerWidth - 1) * 0.5f) * MapStrideY; + return new Vector2(x, y); + } + + static string RoomGlyph(byte t) => t == RoomTypeId.Boss ? "B" + : t == RoomTypeId.Elite ? "E" : t == RoomTypeId.Reward ? "R" : "C"; + + static Color RoomColor(byte t) => t == RoomTypeId.Boss ? new Color(0.92f, 0.28f, 0.22f) + : t == RoomTypeId.Elite ? new Color(0.80f, 0.45f, 1f) + : t == RoomTypeId.Reward ? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 0.72f, 0.35f); + + void UpdateRoutePanel(RunInfo runInfo) + { + // Keyed on the LIFECYCLE (never RouteOptionCount alone — the review's D-F6 criterion). + bool show = runInfo.Lifecycle == RunLifecycle.RouteSelect && runInfo.RouteOptionCount > 0; + if (!show) + { + if (_routePanel != null) _routePanel.style.display = DisplayStyle.None; + _routeMapSig = 0; + return; + } + var root = _doc != null ? _doc.rootVisualElement : null; + if (root == null) return; + if (!_routePanelBuilt) + { + BuildRoutePanel(root); + _routePanelBuilt = true; + } + + int sig = (int)runInfo.RunSeed ^ (runInfo.CurrentRoom + 1) * 131 ^ runInfo.CurrentCol * 31 + ^ (runInfo.RouteOptionCount << 24) ^ (runInfo.RouteOpt0Col << 16) + ^ (runInfo.RouteOpt1Col << 18) ^ (runInfo.RouteOpt2Col << 20); + if (sig == 0) sig = 1; + if (_routeMapSig != sig) + { + RebuildRouteMap(runInfo); + _routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount; + _routeMapSig = sig; + } + _routePanel.style.display = DisplayStyle.Flex; + } + + void BuildRoutePanel(VisualElement root) + { + _routePanel = new VisualElement { pickingMode = PickingMode.Ignore }; + _routePanel.style.position = Position.Absolute; + _routePanel.style.left = 0; _routePanel.style.right = 0; + _routePanel.style.top = 0; _routePanel.style.bottom = 0; + _routePanel.style.alignItems = Align.Center; + _routePanel.style.justifyContent = Justify.Center; + _routePanel.style.display = DisplayStyle.None; + + var box = new VisualElement { pickingMode = PickingMode.Position }; // swallow world clicks under the map + box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.95f); + box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10; + box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10; + box.style.paddingLeft = 18; box.style.paddingRight = 18; + box.style.paddingTop = 12; box.style.paddingBottom = 12; + box.style.alignItems = Align.Center; + + _routeTitle = new Label("CHOOSE YOUR PATH"); + _routeTitle.style.color = new Color(0.55f, 0.85f, 1f); + _routeTitle.style.fontSize = 16; + _routeTitle.style.unityFontStyleAndWeight = FontStyle.Bold; + _routeTitle.style.marginBottom = 10; + box.Add(_routeTitle); + + _routeMapHost = new VisualElement { pickingMode = PickingMode.Ignore }; + _routeMapHost.style.position = Position.Relative; + box.Add(_routeMapHost); + + var cap = HudUi.Text("your path is lit — click a highlighted room to commit the party", 13, + MenuUi.SubCol, TextAnchor.MiddleCenter); + cap.style.marginTop = 10; + box.Add(cap); + + _routePanel.Add(box); + root.Add(_routePanel); + } + + void RebuildRouteMap(RunInfo runInfo) + { + _routeMapHost.Clear(); + var map = RunMapMath.Generate(runInfo.RunSeed); + _routeMapHost.style.width = MapPad * 2f + (map.LayerCount - 1) * MapStrideX + MapNodeSize; + _routeMapHost.style.height = MapPad * 2f + 2f * MapStrideY + MapNodeSize; + + // Edges under the nodes (Painter2D); walked segments glow, the rest are faint. + var edges = new VisualElement { pickingMode = PickingMode.Ignore }; + edges.style.position = Position.Absolute; + edges.style.left = 0; edges.style.top = 0; edges.style.right = 0; edges.style.bottom = 0; + var mapCopy = map; + var visited = new List(_routeVisited); + edges.generateVisualContent += ctx => + { + var p = ctx.painter2D; + p.lineWidth = 2f; + var c = new Vector2(MapNodeSize * 0.5f, MapNodeSize * 0.5f); + for (int layer = 0; layer < mapCopy.LayerCount - 1; layer++) + for (int col = 0; col < mapCopy.LayerWidths[layer]; col++) + { + var node = mapCopy.Node(layer, col); + if (node.NextMask == 0) continue; + var a = MapNodePos(layer, col, mapCopy.LayerWidths[layer]); + for (int j = 0; j < mapCopy.LayerWidths[layer + 1]; j++) + { + if ((node.NextMask & (1 << j)) == 0) continue; + var b = MapNodePos(layer + 1, j, mapCopy.LayerWidths[layer + 1]); + bool walked = visited.Contains(RunMap.NodeId(layer, col)) + && visited.Contains(RunMap.NodeId(layer + 1, j)); + p.strokeColor = walked ? new Color(0.55f, 0.85f, 1f, 0.9f) : new Color(1f, 1f, 1f, 0.16f); + p.BeginPath(); + p.MoveTo(a + c); + p.LineTo(b + c); + p.Stroke(); + } + } + }; + _routeMapHost.Add(edges); + + int nextLayer = runInfo.CurrentRoom + 1; + for (int layer = 0; layer < map.LayerCount; layer++) + for (int col = 0; col < map.LayerWidths[layer]; col++) + { + var node = map.Node(layer, col); + bool isCurrent = layer == runInfo.CurrentRoom && col == runInfo.CurrentCol; + bool wasVisited = _routeVisited.Contains(RunMap.NodeId(layer, col)); + byte opt = 255; + if (layer == nextLayer) + { + if (runInfo.RouteOptionCount > 0 && col == runInfo.RouteOpt0Col) opt = 0; + else if (runInfo.RouteOptionCount > 1 && col == runInfo.RouteOpt1Col) opt = 1; + else if (runInfo.RouteOptionCount > 2 && col == runInfo.RouteOpt2Col) opt = 2; + } + _routeMapHost.Add(MakeMapNode(node.RoomType, + MapNodePos(layer, col, map.LayerWidths[layer]), isCurrent, wasVisited, opt, layer <= runInfo.CurrentRoom)); + } + } + + VisualElement MakeMapNode(byte roomType, Vector2 pos, bool isCurrent, bool visited, byte optionIndex, bool past) + { + bool clickable = optionIndex != 255; + var n = new VisualElement { pickingMode = clickable ? PickingMode.Position : PickingMode.Ignore }; + n.style.position = Position.Absolute; + n.style.left = pos.x; n.style.top = pos.y; + n.style.width = MapNodeSize; n.style.height = MapNodeSize; + MenuUi.Round(n, MapNodeSize * 0.5f); + var c = RoomColor(roomType); + float bgA = clickable ? 0.95f : visited || isCurrent ? 0.85f : past ? 0.20f : 0.40f; + var restBg = new Color(c.r * 0.35f, c.g * 0.35f, c.b * 0.35f, bgA); + n.style.backgroundColor = restBg; + MenuUi.Border(n, isCurrent ? new Color(0.55f, 0.85f, 1f) : clickable ? c : new Color(1f, 1f, 1f, 0.18f), + isCurrent || clickable ? 2.5f : 1.2f); + var lbl = new Label(RoomGlyph(roomType)) { pickingMode = PickingMode.Ignore }; + lbl.style.unityTextAlign = TextAnchor.MiddleCenter; + lbl.style.flexGrow = 1; + lbl.style.color = clickable || visited || isCurrent ? c : new Color(1f, 1f, 1f, 0.35f); + lbl.style.fontSize = 15; + lbl.style.unityFontStyleAndWeight = FontStyle.Bold; + n.Add(lbl); + if (clickable) + { + byte pick = optionIndex; // closure copy, never the loop variable + n.RegisterCallback(_ => RouteSendSystem.PickRoute(pick)); + n.RegisterCallback(_ => + n.style.backgroundColor = new Color(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1f)); + n.RegisterCallback(_ => n.style.backgroundColor = restBg); + } + return n; + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/RouteMapHudSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/RouteMapHudSystem.cs.meta new file mode 100644 index 000000000..a5d84fe6b --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/RouteMapHudSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e2330bd90a6294442959883258d476b4 \ No newline at end of file