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