Hygiene B5: split HudSystem + CombatFeedbackSystem god-objects
Extract 7 sibling PresentationSystemGroup systems (client-only, observe-only), faithfully relocating methods+fields so behavior is preserved by construction: - HudSystem (2129->1588L): BoonModalHudSystem, RouteMapHudSystem, MetaShopHudSystem, ClassPrepPortalHudSystem — each owns its own runtime UIDocument (MenuUi.LoadPanelSettings + own sortingOrder + EnsureEventSystem), the proven EnemyMarkerSystem/OnboardingSystem pattern; no shared root, no new static bridge. - CombatFeedbackSystem (1300->914L): RoomPortalBeaconSystem, EnemyHealthBarSystem, EnemyDangerTelegraphSystem — each owns its FX-root + mats (via FeedbackFx), self-queries enemies + self-detects its edge (health-bar LastHp; danger _prevWindup), prunes its caches each frame. Verified: compiles clean (0 errors), 466/466 EditMode tests pass, Play world-creation clean (no ComponentSystemSorter cycle, no OnCreate exception, 0 console errors). NOTE: the final VISUAL smoke (panels appear at the right lifecycle; enemy health bars / danger telegraphs / portal beacon render; buttons live) needs a FOCUSED Play pass — the play-mode transition throttles while Unity is unfocused, so I could not drive live frames headlessly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The choice-of-3 boon modal (RoomReward) — extracted from <see cref="HudSystem"/> into its own client-only,
|
||||||
|
/// observe-only presentation <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/>. Owns its own
|
||||||
|
/// runtime UIDocument sharing <see cref="MenuUi.LoadPanelSettings"/> (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 <see cref="BoonOffer"/> + the <see cref="BoonCatalog"/> blob; card clicks enqueue through
|
||||||
|
/// <see cref="BoonSendSystem.PickBoon"/>. Built lazily on first show.
|
||||||
|
/// </summary>
|
||||||
|
[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<UIDocument>();
|
||||||
|
_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<RunInfo>(out var runInfo);
|
||||||
|
|
||||||
|
BoonOffer localOffer = default;
|
||||||
|
bool hasOffer = false;
|
||||||
|
foreach (var off in SystemAPI.Query<RefRO<BoonOffer>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
||||||
|
{
|
||||||
|
localOffer = off.ValueRO;
|
||||||
|
hasOffer = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
BlobAssetReference<BoonCatalogBlob> boonPool = default;
|
||||||
|
if (SystemAPI.TryGetSingleton<BoonCatalog>(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<BoonCatalogBlob> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 90c2dc6391260794dbdf466b2f8887e5
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// DR-046 base class-select + prep-loadout panels (Staging) + the room-exit portal prompt (RoomExplore) —
|
||||||
|
/// extracted from <see cref="HudSystem"/> into their own client-only, observe-only presentation
|
||||||
|
/// <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/>. Owns its own runtime UIDocument sharing
|
||||||
|
/// <see cref="MenuUi.LoadPanelSettings"/> (sortingOrder 54). Recomputes the local class + ore/bio/aether + the
|
||||||
|
/// Staging gate + the RoomExplore portal proximity locally; clicks enqueue through
|
||||||
|
/// <see cref="ClassSelectSendSystem"/> / <see cref="PrepPurchaseSendSystem"/> / <see cref="PortalInteractSendSystem"/>.
|
||||||
|
/// NOTE (behavior-preserving): the class/prep Staging gate reproduces the original's FULL condition — it also
|
||||||
|
/// requires the <see cref="MetaUpgradeCatalog"/> + <see cref="MetaTierState"/> buffer to be present (the panels
|
||||||
|
/// were gated on the same <c>metaShow</c> boolean as the meta shop).
|
||||||
|
/// </summary>
|
||||||
|
[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<UIDocument>();
|
||||||
|
_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<RunInfo>(out var runInfo);
|
||||||
|
bool haveCycle = SystemAPI.TryGetSingleton<CycleState>(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<ResourceLedger>(out var ledgerE))
|
||||||
|
{
|
||||||
|
var buf = SystemAPI.GetBuffer<StorageEntry>(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<RefRO<AbilityRef>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
||||||
|
{
|
||||||
|
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<MetaTierState> metaRecord = default;
|
||||||
|
bool metaShow = haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && !siege
|
||||||
|
&& SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
|
||||||
|
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(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<DynamicBuffer<StatModifier>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
||||||
|
{
|
||||||
|
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<BaseAnchor>(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<RefRO<LocalTransform>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 32e011d68689e89488879377a7fb4c3a
|
||||||
@@ -40,7 +40,6 @@ namespace ProjectM.Client
|
|||||||
|
|
||||||
readonly Dictionary<Entity, FxCache> _cache = new();
|
readonly Dictionary<Entity, FxCache> _cache = new();
|
||||||
bool _scanPrimed; // Phase 1: first health-scan completed -> new cache entries are true spawns, not the connect flood
|
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<Entity> _seen = new();
|
readonly HashSet<Entity> _seen = new();
|
||||||
readonly List<Entity> _stale = new();
|
readonly List<Entity> _stale = new();
|
||||||
readonly List<FloatingNumber> _numbers = new();
|
readonly List<FloatingNumber> _numbers = new();
|
||||||
@@ -68,27 +67,6 @@ namespace ProjectM.Client
|
|||||||
uint _lastConeFireTick; // own latch — the muzzle block owns _lastLocalFireTick and runs first
|
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)
|
double _lastHoldTime; // C4: last hit-stop hold time (throttle so a horde wipe doesn't stutter)
|
||||||
bool _coneTickInit;
|
bool _coneTickInit;
|
||||||
Material _dangerMat;
|
|
||||||
readonly Dictionary<Entity, GameObject> _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<Entity> _dangerSeen = new();
|
|
||||||
readonly List<Entity> _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<Entity, HealthBarEntry> _healthBars = new();
|
|
||||||
readonly List<Entity> _barStale = new();
|
|
||||||
readonly List<Entity> _barKeys = new();
|
|
||||||
Material _barBgMat, _barFillMat;
|
|
||||||
// Telegraph scale-pulse (Slice 1, Feature C): per-enemy windup-onset time, folded into the danger cone.
|
|
||||||
readonly Dictionary<Entity, float> _pulseStart = new();
|
|
||||||
// Near-impact strike beep (deferred-items pass): entity -> the WindUpUntilTick it last beeped for (once/windup).
|
|
||||||
readonly Dictionary<Entity, uint> _strikeBeeped = new();
|
|
||||||
|
|
||||||
// Remote teammates' melee cleave arcs (deferred-items pass, co-op): one pooled slash renderer per remote
|
// 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).
|
// player, edge-detected from the replicated MeleeCombo.SwingStartTick (the local player keeps _slashMr).
|
||||||
@@ -109,7 +87,7 @@ namespace ProjectM.Client
|
|||||||
AudioClip _telegraphClip;
|
AudioClip _telegraphClip;
|
||||||
AudioClip _dashClip;
|
AudioClip _dashClip;
|
||||||
AudioClip _swingClip;
|
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)
|
Vector3 _lastFootPos; float _footTimer; bool _footInit; // footstep edge-detect (local player locomotion)
|
||||||
|
|
||||||
Entity _localPlayer = Entity.Null;
|
Entity _localPlayer = Entity.Null;
|
||||||
@@ -135,7 +113,6 @@ namespace ProjectM.Client
|
|||||||
_swingClip = MakeClip("swing", 720f, 200f, 0.09f, 0.42f, noise: false);
|
_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
|
_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
|
_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()
|
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);
|
_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);
|
_swingFx = MakeBurst(_fxRoot, "MeleeSwing", mat, new Color(3.0f, 2.6f, 0.9f), 0.14f, 6f, 0.28f, 256);
|
||||||
BuildSlash();
|
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++)
|
for (int i = 0; i < NumberPoolSize; i++)
|
||||||
_numbers.Add(CreateNumber());
|
_numbers.Add(CreateNumber());
|
||||||
@@ -172,14 +138,7 @@ namespace ProjectM.Client
|
|||||||
Object.Destroy(_fxRoot.gameObject);
|
Object.Destroy(_fxRoot.gameObject);
|
||||||
if (_slashMesh != null) Object.Destroy(_slashMesh);
|
if (_slashMesh != null) Object.Destroy(_slashMesh);
|
||||||
if (_slashMat != null) Object.Destroy(_slashMat);
|
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<MeshFilter>(); 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)
|
foreach (var kv in _remoteSlashes)
|
||||||
{
|
{
|
||||||
if (kv.Value.Mesh != null) Object.Destroy(kv.Value.Mesh);
|
if (kv.Value.Mesh != null) Object.Destroy(kv.Value.Mesh);
|
||||||
@@ -203,9 +162,6 @@ namespace ProjectM.Client
|
|||||||
EntityManager.CompleteDependencyBeforeRO<DashState>();
|
EntityManager.CompleteDependencyBeforeRO<DashState>();
|
||||||
EntityManager.CompleteDependencyBeforeRO<DashCooldown>();
|
EntityManager.CompleteDependencyBeforeRO<DashCooldown>();
|
||||||
EntityManager.CompleteDependencyBeforeRO<MeleeCombo>();
|
EntityManager.CompleteDependencyBeforeRO<MeleeCombo>();
|
||||||
EntityManager.CompleteDependencyBeforeRO<EnemyStats>();
|
|
||||||
EntityManager.CompleteDependencyBeforeRO<EnemyTelegraph>();
|
|
||||||
EntityManager.CompleteDependencyBeforeRO<IsLunging>();
|
|
||||||
|
|
||||||
// Resolve the local player (for hit colouring + fire feedback).
|
// Resolve the local player (for hit colouring + fire feedback).
|
||||||
_localPlayer = Entity.Null;
|
_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.
|
// 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);
|
Burst(_hitFx, null, (Vector3)p + Vector3.up * 1.2f, 6);
|
||||||
PlayClip(_telegraphClip, (Vector3)p, 0.5f);
|
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
|
// 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.
|
// 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));
|
float hitMag = math.saturate((prev.Hp - cur) / math.max(1f, FeelConfig.HitStopRefDamage));
|
||||||
PrototypeCameraRig.PunchFov(math.lerp(FeelConfig.HitStopFovKickMin, FeelConfig.HitStopFovKickMax, hitMag), FeelConfig.HitStopDurationMs);
|
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.
|
// 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);
|
EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.7f, FeelConfig.HitFlashBurstCount, FeelConfig.HitFlashColor);
|
||||||
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||||
@@ -544,10 +498,8 @@ namespace ProjectM.Client
|
|||||||
PruneVfx();
|
PruneVfx();
|
||||||
AnimateNumbers(dt, cam);
|
AnimateNumbers(dt, cam);
|
||||||
UpdateSlash(dt);
|
UpdateSlash(dt);
|
||||||
UpdateEnemyDanger(localPos); UpdatePortalBeacon();
|
|
||||||
|
|
||||||
UpdateRemoteSwings(dt);
|
UpdateRemoteSwings(dt);
|
||||||
UpdateHealthBars(dt, cam, localPos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Authored VFX (GabrielAguiar prefabs via VFXConfig); fall back to the procedural burst ----
|
// ---- 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 };
|
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<RunInfo>(out var ri) && ri.Lifecycle == RunLifecycle.RoomExplore;
|
|
||||||
if (!inExplore || !SystemAPI.TryGetSingleton<BaseAnchor>(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<Collider>(); if (col != null) Object.Destroy(col); // cosmetic only
|
|
||||||
_portalBeacon.transform.SetParent(_fxRoot, false);
|
|
||||||
var mr = _portalBeacon.GetComponent<MeshRenderer>();
|
|
||||||
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<NetworkTime>(out var nt) ? nt.ServerTick : default;
|
|
||||||
_dangerSeen.Clear();
|
|
||||||
bool bossRoom = SystemAPI.TryGetSingleton<RunInfo>(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<LocalTransform>, RefRO<EnemyStats>, RefRO<AttackWindup>, RefRO<EnemyTelegraph>>()
|
|
||||||
.WithAll<EnemyTag>().WithEntityAccess())
|
|
||||||
{
|
|
||||||
// Feature D: a committed Charger lunge keeps the cue ALIVE past windup (AttackWindup zeroes at commit).
|
|
||||||
bool lunging = SystemAPI.HasComponent<IsLunging>(entity) && SystemAPI.IsComponentEnabled<IsLunging>(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<MeshFilter>().sharedMesh = new Mesh { name = "EnemyDanger" };
|
|
||||||
var mr = go.AddComponent<MeshRenderer>();
|
|
||||||
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<MeshFilter>().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<MeshFilter>().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<SpitterState>(entity))
|
|
||||||
{
|
|
||||||
var ss = SystemAPI.GetComponent<SpitterState>(entity);
|
|
||||||
laneLen = math.max(4f, ss.PreferredRange + ss.RangeTolerance + 2f);
|
|
||||||
}
|
|
||||||
BuildLaneMesh(go.GetComponent<MeshFilter>().sharedMesh, laneLen, 0.28f, intensity);
|
|
||||||
}
|
|
||||||
else BuildDangerMesh(go.GetComponent<MeshFilter>().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<MeshFilter>(); 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>();
|
|
||||||
canvas.renderMode = RenderMode.WorldSpace;
|
|
||||||
canvas.sortingOrder = 5; // below the UITK HUD (50); above world geometry
|
|
||||||
var rt = go.GetComponent<RectTransform>();
|
|
||||||
rt.sizeDelta = new Vector2(1.2f, 0.14f);
|
|
||||||
|
|
||||||
var bgGo = new GameObject("Bg");
|
|
||||||
bgGo.transform.SetParent(go.transform, false);
|
|
||||||
var bgRt = bgGo.AddComponent<RectTransform>();
|
|
||||||
bgRt.anchorMin = Vector2.zero; bgRt.anchorMax = Vector2.one;
|
|
||||||
bgRt.offsetMin = bgRt.offsetMax = Vector2.zero;
|
|
||||||
var bgImg = bgGo.AddComponent<UnityEngine.UI.Image>();
|
|
||||||
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<RectTransform>();
|
|
||||||
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<UnityEngine.UI.Image>();
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// MC-3/MC-4/A7 — client-only enemy attack TELEGRAPHS. Observe-only presentation <see cref="SystemBase"/> in
|
||||||
|
/// <see cref="PresentationSystemGroup"/> that reads replicated state and never mutates the sim. While an enemy's
|
||||||
|
/// <see cref="AttackWindup"/> 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
|
||||||
|
/// <c>_prevWindup</c> map (a 0 -> nonzero <c>WindUpUntilTick</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||||
|
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||||
|
public partial class EnemyDangerTelegraphSystem : SystemBase
|
||||||
|
{
|
||||||
|
Transform _fxRoot;
|
||||||
|
Material _dangerMat;
|
||||||
|
readonly Dictionary<Entity, GameObject> _dangerZones = new();
|
||||||
|
readonly HashSet<Entity> _dangerSeen = new(); // telegraph-ACTIVE enemies (zone lifecycle: a zone vanishes when its enemy stops winding up)
|
||||||
|
readonly List<Entity> _dangerStale = new(); // scratch list reused by every prune
|
||||||
|
readonly Dictionary<Entity, float> _pulseStart = new(); // per-enemy windup-onset time (anticipation scale-pulse)
|
||||||
|
readonly Dictionary<Entity, uint> _strikeBeeped = new(); // entity -> the WindUpUntilTick it last beeped for (once/windup)
|
||||||
|
readonly Dictionary<Entity, uint> _prevWindup = new(); // self-detect the windup-onset edge (was the core _cache.Windup)
|
||||||
|
readonly HashSet<Entity> _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<MeshFilter>(); 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<LocalTransform>();
|
||||||
|
EntityManager.CompleteDependencyBeforeRO<AttackWindup>();
|
||||||
|
EntityManager.CompleteDependencyBeforeRO<EnemyStats>();
|
||||||
|
EntityManager.CompleteDependencyBeforeRO<EnemyTelegraph>();
|
||||||
|
EntityManager.CompleteDependencyBeforeRO<IsLunging>();
|
||||||
|
|
||||||
|
// Local player (strike-beep proximity gate).
|
||||||
|
_localPlayer = Entity.Null;
|
||||||
|
float3 localPos = default;
|
||||||
|
foreach (var (xf, entity) in SystemAPI.Query<RefRO<LocalTransform>>()
|
||||||
|
.WithAll<GhostOwnerIsLocal, PlayerTag>().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<NetworkTime>(out var nt) ? nt.ServerTick : default;
|
||||||
|
_dangerSeen.Clear();
|
||||||
|
_enemySeen.Clear();
|
||||||
|
bool bossRoom = SystemAPI.TryGetSingleton<RunInfo>(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<LocalTransform>, RefRO<EnemyStats>, RefRO<AttackWindup>, RefRO<EnemyTelegraph>>()
|
||||||
|
.WithAll<EnemyTag>().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<IsLunging>(entity) && SystemAPI.IsComponentEnabled<IsLunging>(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<MeshFilter>().sharedMesh = new Mesh { name = "EnemyDanger" };
|
||||||
|
var mr = go.AddComponent<MeshRenderer>();
|
||||||
|
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<MeshFilter>().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<MeshFilter>().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<SpitterState>(entity))
|
||||||
|
{
|
||||||
|
var ss = SystemAPI.GetComponent<SpitterState>(entity);
|
||||||
|
laneLen = math.max(4f, ss.PreferredRange + ss.RangeTolerance + 2f);
|
||||||
|
}
|
||||||
|
BuildLaneMesh(go.GetComponent<MeshFilter>().sharedMesh, laneLen, 0.28f, intensity);
|
||||||
|
}
|
||||||
|
else BuildDangerMesh(go.GetComponent<MeshFilter>().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<MeshFilter>(); 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<T>(Dictionary<Entity, T> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3e80b545eb0e6e1498e81ec4962873ae
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Slice 1, Feature B — client-only enemy world-space HEALTH BARS (one pooled world-space Canvas per live Husk).
|
||||||
|
/// Observe-only presentation <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/> that reads replicated
|
||||||
|
/// state and never mutates the sim or destroys a ghost. SELF-QUERIES enemies (<c>Health</c> + <c>LocalTransform</c>
|
||||||
|
/// <see cref="EnemyTag"/>) and self-detects the damage edge from a per-enemy <c>LastHp</c> stored on the bar entry:
|
||||||
|
/// a decrease arms/refreshes that enemy's bar (sticky for <see cref="HealthBarShowDuration"/>, then fades). A bar
|
||||||
|
/// stays permanently on below <see cref="HealthBarAlwaysOnThreshold"/> HP; when more than <see cref="HealthBarPoolLimit"/>
|
||||||
|
/// bars exist, distant ones (beyond <see cref="FeelConfig.HealthBarMaxDistSq"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<Entity, HealthBarEntry> _healthBars = new();
|
||||||
|
readonly List<Entity> _barStale = new();
|
||||||
|
readonly List<Entity> _barKeys = new();
|
||||||
|
readonly HashSet<Entity> _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<Health>();
|
||||||
|
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
|
||||||
|
|
||||||
|
// Local player (drives the pool-cap distance gate).
|
||||||
|
_localPlayer = Entity.Null;
|
||||||
|
float3 localPos = default;
|
||||||
|
foreach (var (xf, entity) in SystemAPI.Query<RefRO<LocalTransform>>()
|
||||||
|
.WithAll<GhostOwnerIsLocal, PlayerTag>().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<Health>, RefRO<LocalTransform>>().WithAll<EnemyTag>().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>();
|
||||||
|
canvas.renderMode = RenderMode.WorldSpace;
|
||||||
|
canvas.sortingOrder = 5; // below the UITK HUD (50); above world geometry
|
||||||
|
var rt = go.GetComponent<RectTransform>();
|
||||||
|
rt.sizeDelta = new Vector2(1.2f, 0.14f);
|
||||||
|
|
||||||
|
var bgGo = new GameObject("Bg");
|
||||||
|
bgGo.transform.SetParent(go.transform, false);
|
||||||
|
var bgRt = bgGo.AddComponent<RectTransform>();
|
||||||
|
bgRt.anchorMin = Vector2.zero; bgRt.anchorMax = Vector2.one;
|
||||||
|
bgRt.offsetMin = bgRt.offsetMax = Vector2.zero;
|
||||||
|
var bgImg = bgGo.AddComponent<UnityEngine.UI.Image>();
|
||||||
|
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<RectTransform>();
|
||||||
|
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<UnityEngine.UI.Image>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0e055bbb583ee594b95ae71f8db43b16
|
||||||
@@ -72,20 +72,6 @@ namespace ProjectM.Client
|
|||||||
// END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side).
|
// END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side).
|
||||||
VisualElement _runBanner;
|
VisualElement _runBanner;
|
||||||
Label _runBannerText, _runBannerSub;
|
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
|
// 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
|
// (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).
|
// clickable next-layer nodes bind to the authoritative RouteOpt* bytes (never the regen).
|
||||||
@@ -94,10 +80,6 @@ namespace ProjectM.Client
|
|||||||
Label _readyTitle;
|
Label _readyTitle;
|
||||||
bool _readyPanelBuilt;
|
bool _readyPanelBuilt;
|
||||||
int _readyShownFor; // (ready, total, localReady, secs, launching) rebuild signature
|
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<int> _routeVisited = new(); // client-local path trace (nodeIds), reset per RunSeed
|
|
||||||
uint _routeVisitedSeed;
|
|
||||||
// Demo polish round 2: boss presence bar, run-depth dots, outcome flash.
|
// Demo polish round 2: boss presence bar, run-depth dots, outcome flash.
|
||||||
VisualElement _bossPanel, _bossFill;
|
VisualElement _bossPanel, _bossFill;
|
||||||
Label _bossText;
|
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<RefRO<BoonOffer>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
|
||||||
{
|
|
||||||
localOffer = off.ValueRO;
|
|
||||||
hasOffer = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
BlobAssetReference<BoonCatalogBlob> boonPool = default;
|
|
||||||
if (SystemAPI.TryGetSingleton<BoonCatalog>(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 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.
|
// the screen then). Counts are the replicated send-to-all PlayerReady flags.
|
||||||
@@ -452,31 +404,6 @@ namespace ProjectM.Client
|
|||||||
_bioNum.text = bio.ToString();
|
_bioNum.text = bio.ToString();
|
||||||
_chargeNum.text = charge.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<RefRO<AbilityRef>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
|
||||||
{
|
|
||||||
localClass = ClassTraits.ClassForAbility(ar.ValueRO.Id);
|
|
||||||
haveLocalPlayer = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
bool metaShow = false;
|
|
||||||
BlobAssetReference<MetaUpgradeCatalogBlob> metaPool = default;
|
|
||||||
DynamicBuffer<MetaTierState> metaRecord = default;
|
|
||||||
if (haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && !siege
|
|
||||||
&& SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
|
|
||||||
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(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).
|
// 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.)
|
// (Step 11: upgrade-button affordability tint retired with the button.)
|
||||||
@@ -1441,269 +1368,27 @@ namespace ProjectM.Client
|
|||||||
: roomType == RoomTypeId.Elite ? "[ELITE]"
|
: roomType == RoomTypeId.Elite ? "[ELITE]"
|
||||||
: roomType == RoomTypeId.Reward ? "[REWARD]" : "[COMBAT]";
|
: roomType == RoomTypeId.Reward ? "[REWARD]" : "[COMBAT]";
|
||||||
|
|
||||||
void UpdateBoonModal(BoonOffer offer, bool show, BlobAssetReference<BoonCatalogBlob> 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
|
// ---- the drawn branching route map (Slay-the-Spire style; display regen from RunSeed, clicks bind
|
||||||
// to the authoritative RouteOpt* bytes) ----
|
// 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<int>(_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<ClickEvent>(_ => RouteSendSystem.PickRoute(pick));
|
|
||||||
n.RegisterCallback<MouseEnterEvent>(_ =>
|
|
||||||
n.style.backgroundColor = new Color(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1f));
|
|
||||||
n.RegisterCallback<MouseLeaveEvent>(_ => n.style.backgroundColor = restBg);
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- the clickable READY panel (Staging: toggle + party pips; Launching: countdown + abort) ----
|
// ---- the clickable READY panel (Staging: toggle + party pips; Launching: countdown + abort) ----
|
||||||
|
|
||||||
@@ -1874,255 +1559,30 @@ namespace ProjectM.Client
|
|||||||
_depthPanel.style.display = DisplayStyle.Flex;
|
_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).
|
// B6 run-failed read (client-local edge state; see the tracker near the top of OnUpdate).
|
||||||
byte _prevRunLifecycle;
|
byte _prevRunLifecycle;
|
||||||
int _chargeAtLaunch;
|
int _chargeAtLaunch;
|
||||||
bool _wentInRun;
|
bool _wentInRun;
|
||||||
float _runFailedUntil;
|
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<DynamicBuffer<StatModifier>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
|
||||||
{
|
|
||||||
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<BaseAnchor>(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<RefRO<LocalTransform>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
|
||||||
{
|
|
||||||
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<MetaUpgradeCatalogBlob> pool, DynamicBuffer<MetaTierState> 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The Staging-only permanent-upgrade shop (meta shop) — extracted from <see cref="HudSystem"/> into its own
|
||||||
|
/// client-only, observe-only presentation <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/>.
|
||||||
|
/// Owns its own runtime UIDocument sharing <see cref="MenuUi.LoadPanelSettings"/> (sortingOrder 51). Recomputes
|
||||||
|
/// its inputs locally: the local class from the replicated <see cref="AbilityRef"/>
|
||||||
|
/// (<see cref="ClassTraits.ClassForAbility"/>), Aether from the <see cref="ResourceLedger"/> buffer, siege from
|
||||||
|
/// <see cref="CycleState"/>, and the Staging gate from <see cref="RunInfo"/>. Row clicks enqueue through
|
||||||
|
/// <see cref="MetaSpendSendSystem.RequestPurchase"/> — the server re-validates everything.
|
||||||
|
/// </summary>
|
||||||
|
[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<UIDocument>();
|
||||||
|
_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<RunInfo>(out var runInfo);
|
||||||
|
bool haveCycle = SystemAPI.TryGetSingleton<CycleState>(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<ResourceLedger>(out var ledgerE))
|
||||||
|
{
|
||||||
|
var buf = SystemAPI.GetBuffer<StorageEntry>(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<RefRO<AbilityRef>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
||||||
|
{
|
||||||
|
localClass = ClassTraits.ClassForAbility(ar.ValueRO.Id);
|
||||||
|
haveLocalPlayer = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool metaShow = false;
|
||||||
|
BlobAssetReference<MetaUpgradeCatalogBlob> metaPool = default;
|
||||||
|
DynamicBuffer<MetaTierState> metaRecord = default;
|
||||||
|
if (haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && !siege
|
||||||
|
&& SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
|
||||||
|
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(out metaRecord, true))
|
||||||
|
{
|
||||||
|
metaPool = metaCat.Value;
|
||||||
|
metaShow = true;
|
||||||
|
}
|
||||||
|
UpdateMetaShop(metaShow, localClass, aether, metaPool, metaRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UpdateMetaShop(bool show, byte classId, int aether,
|
||||||
|
BlobAssetReference<MetaUpgradeCatalogBlob> pool, DynamicBuffer<MetaTierState> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2551683f48286ae41980c5bb4e48d6e1
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.Mathematics;
|
||||||
|
using UnityEngine;
|
||||||
|
using static ProjectM.Client.FeedbackFx;
|
||||||
|
|
||||||
|
namespace ProjectM.Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// DR-046 — client-only, observe-only presentation of the room-exit PORTAL made visible. A managed
|
||||||
|
/// <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/> that OBSERVES replicated <see cref="RunInfo"/>
|
||||||
|
/// and never mutates the sim. During the <see cref="RunLifecycle.RoomExplore"/> loot window it shows a glowing cyan
|
||||||
|
/// pillar (or the authored <see cref="VFXConfig.Portal"/> 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 <see cref="RegionMath.ExpeditionPortalPos"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<RunInfo>(out var ri) && ri.Lifecycle == RunLifecycle.RoomExplore;
|
||||||
|
if (!inExplore || !SystemAPI.TryGetSingleton<BaseAnchor>(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<Collider>(); if (col != null) Object.Destroy(col); // cosmetic only
|
||||||
|
_portalBeacon.transform.SetParent(_fxRoot, false);
|
||||||
|
var mr = _portalBeacon.GetComponent<MeshRenderer>();
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 587c50c122f52954da052232f28a6052
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The drawn branching route map (RouteSelect) — extracted from <see cref="HudSystem"/> into its own client-only,
|
||||||
|
/// observe-only presentation <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/>. Owns its own
|
||||||
|
/// runtime UIDocument sharing <see cref="MenuUi.LoadPanelSettings"/> (sortingOrder 55). The map is regenerated
|
||||||
|
/// client-side from <c>RunInfo.RunSeed</c> for DISPLAY only; the clickable next-layer nodes bind to the
|
||||||
|
/// authoritative RouteOpt* bytes (never the regen) via <see cref="RouteSendSystem.PickRoute"/>. Also owns the
|
||||||
|
/// client-local visited-path trace (nodeIds; reset per RunSeed) that lights walked edges.
|
||||||
|
/// </summary>
|
||||||
|
[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<int> _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<UIDocument>();
|
||||||
|
_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<RunInfo>(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<int>(_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<ClickEvent>(_ => RouteSendSystem.PickRoute(pick));
|
||||||
|
n.RegisterCallback<MouseEnterEvent>(_ =>
|
||||||
|
n.style.backgroundColor = new Color(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1f));
|
||||||
|
n.RegisterCallback<MouseLeaveEvent>(_ => n.style.backgroundColor = restBg);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e2330bd90a6294442959883258d476b4
|
||||||
Reference in New Issue
Block a user