Files
Project-M/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs
T
kronic e0c59ad663 Perf: pool one-shot SFX + authored VFX, cut per-frame presentation allocation
Track B. All 21 one-shot cues funnelled through FeedbackFx.PlayClip ->
AudioSource.PlayClipAtPoint, which allocates a GameObject + AudioSource per call
and schedules a delayed Destroy — ~20-33 times a second in light combat. New
OneShotAudioPool is a 32-voice 3D ring behind an UNCHANGED PlayClip signature, so
all 20 consuming call sites are untouched.

Parity is the whole game here: PlayClipAtPoint sets spatialBlend = 1 explicitly (a
fresh AudioSource is 2D) and leaves the rest at stock defaults. Two deliberate
divergences, both forced by the voices being long-lived: playOnAwake = false, and
dopplerLevel = 0 because a pooled voice TELEPORTS between events and would
otherwise pitch-bend. Root is DontDestroyOnLoad (WorldLauncher does
LoadScene(Single) while the client world is alive) with a SubsystemRegistration
reset, or session two rents destroyed voices.

Authored impact VFX are pooled per prefab instead of Instantiate/Destroy per hit:
components cached per INSTANCE (refs are instance-scoped), main.stopAction forced
to None (a prefab set to Destroy silently drains the pool), instances filled under
an inactive root so Awake/Start never run — which is what makes the DestroyImmediate
in StripCosmetic safe — ps.Clear before Play, TrailRenderer.Clear after the
reposition, and a Rented flag as the at-most-once guard against a double Return
aliasing one instance to two callers.

Per-frame allocation: the slash-arc and enemy-wedge mesh builders each allocated
four arrays on every call (up to twice a frame, and once per winding enemy); HUD
and ability-bar labels rebuilt their strings every frame; damage-number fades
rewrote TextMesh vertex colours every frame; health bars pushed uGUI writes
unconditionally; two systems played back an empty EntityCommandBuffer (a
structural-change sync point) every frame.

Also closes an AudioClip leak across all seven clip-owning systems: an
AudioClip.Create'd clip is a standalone UnityEngine.Object, so destroying a
system's FX root left it alive (MusicSystem ~6.8 MB, AmbientAudioSystem ~2 MB per
client-world teardown).

CombatFeedbackSystem's TryHold call sites go with this commit because they share
the file; the camera-side removal lands in the next one.

Verified live: PlayClipAtPoint's "One shot audio" GameObject never appears again
across 270 frames of combat with kills; the VFX pool fills to its retain cap and
stabilises; real cues route through the ring. 304/304 EditMode green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:02:50 -07:00

601 lines
28 KiB
C#

using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms; // A6: boss-bar query reads LocalTransform (source-gen needs the using in this file)
using Unity.Mathematics; // DR-046: portal proximity math (float3/.xz/math.distance)
using UnityEngine;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// Client-only screen HUD on UI Toolkit, skinned with the curated Synty sci-fi-soldier kit
/// (<see cref="HudTheme"/>) over <see cref="MenuUi"/>'s Aether palette so it reads in one visual language with
/// the menu / pause / settings. A managed presentation <see cref="SystemBase"/> (<see cref="PresentationSystemGroup"/>)
/// that OBSERVES the local player ghost + the global cycle / ledger / goal each frame and pushes values into a
/// runtime UIDocument (shared PanelSettings, sortingOrder 50 so it sits behind the pause overlay's 100). The
/// root is <c>pickingMode = Ignore</c> so the HUD never eats world clicks — only the build-palette slots pick.
/// Layout (good-HUD spatial convention): persistent self-state hugs the corners (health bottom-left, resources
/// top-left, threat top-right, build deck bottom-center); transient mission state (phase / countdown / wave /
/// goal) lives center-top; a low-health vignette + hurt-flash + a scheme-aware build-mode control-hint bar give
/// just-in-time feedback. EVERY skinned element is null-safe: with no <see cref="HudTheme"/> it falls back to
/// the flat-colour HUD. Presentation only (client world, no simulation, no rollback double-fire).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class HudSystem : SystemBase
{
// ---- palette (Aether language; Synty white skins are tinted into these) ----
static readonly Color AetherCyan = new(0.30f, 0.85f, 1f);
static readonly Color OreAmber = new(1f, 0.72f, 0.35f);
static readonly Color BioGreen = new(0.55f, 0.85f, 0.45f);
static readonly Color PanelDark = new(0.08f, 0.11f, 0.15f, 0.90f);
static readonly Color PanelWarm = new(0.16f, 0.09f, 0.09f, 0.88f);
static readonly Color PipDim = new(0.25f, 0.30f, 0.36f, 0.9f);
static readonly Color BlightRed = new(0.85f, 0.10f, 0.08f);
static readonly Color ThreatWarm = new(1f, 0.62f, 0.4f);
static readonly Color SlotIdleBg = new(0.09f, 0.11f, 0.15f, 0.92f);
static readonly Color SlotSelBg = new(0.16f, 0.26f, 0.32f, 0.95f);
static readonly Color SlotIdleBorder = new(1f, 1f, 1f, 0.08f);
const float ExpeditionRegionXMin = RegionMath.RegionBoundaryX; // camera x past this = the +1000 expedition region (DR-013)
GameObject _hudGo;
UIDocument _doc;
bool _built;
bool _themed; // HudTheme + PanelBox present (drives sprite-tint vs flat-colour retint)
// vitals
VisualElement _healthFill, _shieldRow;
Label _healthText;
// threat
VisualElement _threatPanel, _threatIcon;
Label _threatNum;
// macro: banner + location line
VisualElement _banner;
Label _phaseText, _locationText;
// Demo polish: the clickable READY panel (Staging/Launching).
VisualElement _readyPanel, _readyPipRow;
Button _readyBtn;
Label _readyTitle;
bool _readyPanelBuilt;
int _readyShownFor; // (ready, total, localReady, secs, launching) rebuild signature
// Demo polish round 2: boss presence bar, run-depth dots, outcome flash.
VisualElement _bossPanel, _bossFill;
Label _bossText;
bool _bossBarBuilt;
VisualElement _depthPanel;
int _depthShownFor;
bool _depthBuilt;
// resources
Label _aetherNum, _oreNum, _bioNum;
// build palette + hints
VisualElement _paletteRow, _hintBar, _buildDiscoveryChip;
bool _paletteBuilt, _hintBuilt;
byte _hintScheme = 255;
readonly Dictionary<byte, PaletteItem> _palette = new();
// overlays
VisualElement _vignette, _downed;
Label _downedText; // "RESPAWNING IN N" countdown (client-local: baked DelayTicks + death-edge latch)
float _downedSince = -1f;
float _prevHp, _flash;
bool _haveHp;
// Track B: last value pushed to each Label. A UITK text assignment is cheap, but BUILDING the string
// (int.ToString / concat) allocates every frame regardless — these gate the build, not just the write.
// int.MinValue rather than -1 so a legitimately negative or zero first value still renders.
int _shownAether = int.MinValue, _shownOre = int.MinValue, _shownBio = int.MinValue;
int _shownThreat = int.MinValue, _shownHp = int.MinValue, _shownMaxHp = int.MinValue;
int _shownRespawnSecs = int.MinValue;
// personal inventory panel (read-only; toggled with I)
VisualElement _invPanel, _invList, _equipList;
bool _invOpen;
EntityQuery _huskQuery;
struct PaletteItem { public VisualElement Root; public Label Cost; public int CostAmount; public byte CostRes; public VisualElement Glow; public VisualElement Icon; }
protected override void OnCreate()
{
_huskQuery = GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
}
protected override void OnStartRunning()
{
if (_hudGo != null) return;
MenuUi.EnsureEventSystem();
_hudGo = new GameObject("~HUD");
_doc = _hudGo.AddComponent<UIDocument>();
_doc.panelSettings = MenuUi.LoadPanelSettings();
_doc.sortingOrder = 50; // behind the pause overlay (100)
}
protected override void OnDestroy()
{
if (_hudGo != null) Object.Destroy(_hudGo);
}
protected override void OnUpdate()
{
if (_doc == null) return;
if (!_built)
{
var r = _doc.rootVisualElement;
if (r == null) return; // panel not initialised yet (next frame)
BuildTree(r);
_built = true;
}
// Job-safety insurance (matches the sibling presentation systems + CLAUDE.md): finish any jobs writing
// the components we read on the main thread before reading them. No job writes these today, but this
// stays correct the day a Health/stats writer is parallelised.
EntityManager.CompleteDependencyBeforeRO<Health>();
EntityManager.CompleteDependencyBeforeRO<EffectiveCharacterStats>();
EntityManager.CompleteDependencyBeforeRO<SocketCooldown>();
EntityManager.CompleteDependencyBeforeRO<EffectiveSocketStats>();
EntityManager.CompleteDependencyBeforeRO<RespawnInvuln>();
float dt = SystemAPI.Time.DeltaTime; // wall-frame delta — correct in a presentation system
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
int huskCount = _huskQuery.CalculateEntityCount();
// 2026-08-07 audit purge: the macro run banner, the location sub-line, the ready-check panel, the
// boss bar and the run-depth dots were all driven by RunInfo / ExpeditionObjective / PlayerReady —
// the superseded base/expedition FSM. All deleted; the HUD is now vitals + threat + resources + the
// ability bar (its own system).
// ---- Resources (feed palette affordability) ----
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;
}
}
// Track B: three int.ToString()s every frame, for counts that change a few times a minute.
if (aether != _shownAether) { _shownAether = aether; _aetherNum.text = aether.ToString(); }
if (ore != _shownOre) { _shownOre = ore; _oreNum.text = ore.ToString(); }
if (bio != _shownBio) { _shownBio = bio; _bioNum.text = bio.ToString(); }
// ---- Threat readout (top-right) — hidden entirely with zero husks; its reappearance is the cue ----
bool showThreat = huskCount > 0;
_threatPanel.style.display = showThreat ? DisplayStyle.Flex : DisplayStyle.None;
if (showThreat)
{
float intensity = Mathf.Clamp01(huskCount / 30f);
Color tc = Color.Lerp(ThreatWarm, BlightRed, intensity);
if (huskCount != _shownThreat) { _shownThreat = huskCount; _threatNum.text = huskCount.ToString(); } // Track B: changes on a spawn/kill, not per frame
_threatNum.style.color = tc;
_threatIcon.style.unityBackgroundImageTintColor = tc;
RetintPanel(_threatPanel, PanelDark);
}
// ---- Per-player vitals ----
bool found = false;
float hp = 0f, maxHp = 1f;
bool dead = false, shielded = false;
foreach (var (health, effChar, cd, invuln, entity) in
SystemAPI.Query<RefRO<Health>, RefRO<EffectiveCharacterStats>,
RefRO<SocketCooldown>, RefRO<RespawnInvuln>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>().WithEntityAccess())
{
found = true;
hp = health.ValueRO.Current;
maxHp = effChar.ValueRO.MaxHealth > 0f ? effChar.ValueRO.MaxHealth : health.ValueRO.Max;
dead = SystemAPI.IsComponentEnabled<Dead>(entity);
// (07-21 UI rework: socket/dash cooldown readouts moved to AbilityBarSystem.)
uint invulnUntil = invuln.ValueRO.UntilTick;
var invulnTick = new NetworkTick(invulnUntil);
shielded = haveTick && invulnUntil != 0 && invulnTick.IsValid && invulnTick.IsNewerThan(nt.ServerTick);
break;
}
_doc.rootVisualElement.style.display = found ? DisplayStyle.Flex : DisplayStyle.None;
// ---- Low-health vignette + hurt flash (full-screen) ----
_flash = HudVisualMath.DecayFlash(_flash, dt);
if (found)
{
float frac = maxHp > 0f ? Mathf.Clamp01(hp / maxHp) : 0f;
if (_haveHp && hp < _prevHp - 1f) _flash = HudVisualMath.HurtFlashKick;
_prevHp = hp; _haveHp = true;
float vigOp = dead ? 0f : HudVisualMath.CombinedOpacity(frac, _flash);
_vignette.style.opacity = vigOp;
_vignette.style.display = vigOp > 0.001f ? DisplayStyle.Flex : DisplayStyle.None;
HudUi.SetFill(_healthFill, frac);
_healthFill.style.backgroundColor = shielded
? new Color(0.45f, 0.85f, 1f)
: Color.Lerp(new Color(0.92f, 0.16f, 0.16f), new Color(0.25f, 0.9f, 0.5f), frac);
// Track B: `int + " / " + int` allocated a fresh string EVERY frame even at full health.
int hpI = Mathf.CeilToInt(Mathf.Max(0f, hp)), maxI = Mathf.CeilToInt(maxHp);
if (hpI != _shownHp || maxI != _shownMaxHp)
{
_shownHp = hpI; _shownMaxHp = maxI;
_healthText.text = hpI + " / " + maxI;
}
_shieldRow.style.display = shielded ? DisplayStyle.Flex : DisplayStyle.None;
if (dead)
{
// Client-local countdown: latch the death edge; the baked (non-replicated) DelayTicks is the
// honest duration — RespawnTick itself is server-only.
if (_downedSince < 0f) _downedSince = (float)SystemAPI.Time.ElapsedTime;
int delayTicks = 180;
foreach (var rs in SystemAPI.Query<RefRO<RespawnState>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{ delayTicks = Mathf.Max(1, rs.ValueRO.DelayTicks); break; }
float left = delayTicks / 60f - ((float)SystemAPI.Time.ElapsedTime - _downedSince);
// Track B: the countdown only changes ~1x/second, but this ran every frame — the
// concat allocated a fresh string ~80x/second while dead.
int secsLeft = left > 0.05f ? Mathf.CeilToInt(left) : -1;
if (secsLeft != _shownRespawnSecs)
{
_shownRespawnSecs = secsLeft;
_downedText.text = secsLeft > 0 ? "RESPAWNING IN " + secsLeft : "RESPAWNING...";
}
}
else { _downedSince = -1f; _shownRespawnSecs = int.MinValue; } // re-render on the next death
_downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
}
else
{
_haveHp = false; _flash = 0f;
_vignette.style.display = DisplayStyle.None;
_downed.style.display = DisplayStyle.None;
}
}
// ---- per-frame helpers ----
void RetintPanel(VisualElement p, Color c)
{
if (_themed) p.style.unityBackgroundImageTintColor = c;
else p.style.backgroundColor = c;
}
// 2026-08-07 audit purge: AddPaletteItem built one build-palette slot (icon, cost row, selection glow,
// click-to-select). The build palette went with the structures layer.
void RebuildHints(byte scheme)
{
_hintBar.Clear();
var theme = HudTheme.Get();
bool pad = scheme == InputSchemeId.Gamepad;
AddHint(pad ? theme?.PadPlace : theme?.KbmPlace, pad ? "A" : "LMB", "PLACE");
AddHint(pad ? theme?.PadCancel : theme?.KbmCancel, pad ? "B" : "RMB", "CANCEL");
AddHint(pad ? theme?.PadExit : null, pad ? "MENU" : "ESC", "EXIT");
_hintScheme = scheme;
_hintBuilt = true;
}
VisualElement MakeChip()
{
var chip = new VisualElement();
chip.style.flexDirection = FlexDirection.Row;
chip.style.alignItems = Align.Center;
chip.style.marginLeft = 8; chip.style.marginRight = 8;
chip.pickingMode = PickingMode.Ignore;
return chip;
}
void AddHint(Sprite glyph, string fallback, string action)
{
var chip = MakeChip();
chip.Add(HudUi.Glyph(glyph, fallback, 26));
var lbl = HudUi.Text(action, 12, MenuUi.SubCol, TextAnchor.MiddleLeft);
lbl.style.marginLeft = 5;
chip.Add(lbl);
_hintBar.Add(chip);
}
// ---- UITK construction ----
void BuildTree(VisualElement root)
{
var theme = HudTheme.Get();
_themed = theme != null && theme.PanelBox != null;
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
BuildVignette(root);
BuildVitals(root);
BuildThreat(root);
BuildMacro(root);
BuildResources(root);
BuildDowned(root);
}
void BuildVignette(VisualElement root)
{
_vignette = new VisualElement();
_vignette.style.position = Position.Absolute;
_vignette.style.left = 0; _vignette.style.right = 0; _vignette.style.top = 0; _vignette.style.bottom = 0;
_vignette.pickingMode = PickingMode.Ignore;
var theme = HudTheme.Get();
if (theme != null && theme.Vignette != null)
{
_vignette.style.backgroundImage = new StyleBackground(Background.FromSprite(theme.Vignette));
_vignette.style.unityBackgroundImageTintColor = BlightRed;
_vignette.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Cover));
}
else
{
_vignette.style.backgroundColor = new Color(BlightRed.r, BlightRed.g, BlightRed.b, 1f);
}
_vignette.style.display = DisplayStyle.None;
root.Add(_vignette);
}
void BuildVitals(VisualElement root)
{
var panel = HudUi.Panel(PanelDark);
panel.style.position = Position.Absolute;
panel.style.left = 40; panel.style.bottom = 40;
panel.style.paddingLeft = 14; panel.style.paddingRight = 14;
panel.style.paddingTop = 12; panel.style.paddingBottom = 12;
panel.style.alignItems = Align.FlexStart;
var theme = HudTheme.Get();
// shield chip (shown only while the respawn shield is active)
_shieldRow = new VisualElement();
_shieldRow.style.flexDirection = FlexDirection.Row;
_shieldRow.style.alignItems = Align.Center;
_shieldRow.style.marginBottom = 6;
_shieldRow.pickingMode = PickingMode.Ignore;
var shieldIcon = HudUi.Icon(theme != null ? theme.ShieldIcon : null, 22, AetherCyan);
shieldIcon.style.marginRight = 6;
_shieldRow.Add(shieldIcon);
_shieldRow.Add(HudUi.Text("SHIELDED", 13, new Color(0.45f, 0.85f, 1f), TextAnchor.MiddleLeft));
_shieldRow.style.display = DisplayStyle.None;
panel.Add(_shieldRow);
// 07-21 UI rework: the single socket-0 charge strip is gone — AbilityBarSystem (bottom-center)
// now shows ALL socket cooldowns + dash.
// health row: health icon + big bar with numeric overlay
var hpRow = new VisualElement();
hpRow.style.flexDirection = FlexDirection.Row;
hpRow.style.alignItems = Align.Center;
hpRow.pickingMode = PickingMode.Ignore;
var hpIcon = HudUi.Icon(theme != null ? theme.HealthIcon : null, 34, new Color(0.95f, 0.4f, 0.4f));
hpIcon.style.marginRight = 8;
hpRow.Add(hpIcon);
var hpBar = HudUi.Bar(420, 40, new Color(0.25f, 0.9f, 0.5f), out _healthFill);
_healthText = HudUi.Display("100 / 100", 24, Color.white, TextAnchor.MiddleCenter);
_healthText.style.position = Position.Absolute;
_healthText.style.left = 0; _healthText.style.right = 0; _healthText.style.top = 0; _healthText.style.bottom = 0;
hpBar.Add(_healthText);
hpRow.Add(hpBar);
panel.Add(hpRow);
root.Add(panel);
}
void BuildThreat(VisualElement root)
{
_threatPanel = HudUi.Panel(PanelDark);
_threatPanel.style.position = Position.Absolute;
_threatPanel.style.right = 40; _threatPanel.style.top = 28;
_threatPanel.style.paddingLeft = 16; _threatPanel.style.paddingRight = 16;
_threatPanel.style.paddingTop = 8; _threatPanel.style.paddingBottom = 8;
_threatPanel.style.alignItems = Align.FlexEnd;
var theme = HudTheme.Get();
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.alignItems = Align.Center;
row.pickingMode = PickingMode.Ignore;
_threatIcon = HudUi.Icon(theme != null ? theme.ThreatIcon : null, 36, ThreatWarm);
_threatIcon.style.marginRight = 8;
row.Add(_threatIcon);
_threatNum = HudUi.Display("0", 34, ThreatWarm, TextAnchor.MiddleRight);
row.Add(_threatNum);
_threatPanel.Add(row);
var caption = HudUi.Text("HUSKS", 13, MenuUi.SubCol, TextAnchor.MiddleRight);
caption.style.letterSpacing = 4;
_threatPanel.Add(caption);
_threatPanel.style.display = DisplayStyle.None;
root.Add(_threatPanel);
}
void BuildMacro(VisualElement root)
{
var macro = HudUi.Group(Align.Center);
macro.style.position = Position.Absolute;
macro.style.top = 16; macro.style.left = 0; macro.style.right = 0;
var theme = HudTheme.Get();
// banner: objective icon + phase line + cycle, phase-coloured underline
_banner = HudUi.Panel(PanelDark);
_banner.style.flexDirection = FlexDirection.Row;
_banner.style.alignItems = Align.Center;
_banner.style.paddingLeft = 22; _banner.style.paddingRight = 22;
_banner.style.paddingTop = 8; _banner.style.paddingBottom = 8;
_banner.style.borderBottomWidth = 2;
_banner.style.borderBottomColor = AetherCyan;
var bIcon = HudUi.Icon(theme != null ? theme.GoalIcon : null, 26, AetherCyan);
bIcon.style.marginRight = 10;
_banner.Add(bIcon);
_phaseText = HudUi.Display("", 30, AetherCyan, TextAnchor.MiddleCenter);
_banner.Add(_phaseText);
macro.Add(_banner);
_locationText = HudUi.Text("", 15, new Color(0.6f, 0.85f, 1f), TextAnchor.MiddleCenter);
_locationText.style.marginTop = 5;
macro.Add(_locationText);
root.Add(macro);
}
void BuildResources(VisualElement root)
{
var strip = HudUi.Panel(PanelDark);
strip.style.position = Position.Absolute;
strip.style.left = 40; strip.style.top = 28;
strip.style.flexDirection = FlexDirection.Row;
strip.style.alignItems = Align.Center;
strip.style.paddingLeft = 14; strip.style.paddingRight = 14;
strip.style.paddingTop = 8; strip.style.paddingBottom = 8;
var theme = HudTheme.Get();
strip.Add(ResourceChip(theme != null ? theme.AetherIcon : null, AetherCyan, "0", out _aetherNum, 26, 20));
strip.Add(ResourceChip(theme != null ? theme.OreIcon : null, OreAmber, "0", out _oreNum, 30, 22));
strip.Add(ResourceChip(theme != null ? theme.BioIcon : null, BioGreen, "0", out _bioNum, 26, 20));
// DR-042 C6a: the only Aether sink (ability-damage upgrade) gets a visible, clickable button (was U-key
// only). The Button element handles its own picking even though the HUD root Ignores clicks.
// (Step 11: the Aether UPGRADE-DMG button was RETIRED with AbilityUpgradeRequest — the choice-of-3
// boon modal + the base meta-shop (Step 14) replace it.)
root.Add(strip);
}
VisualElement ResourceChip(Sprite icon, Color tint, string initial, out Label num, float iconSize, int fontSize)
{
var chip = new VisualElement();
chip.style.flexDirection = FlexDirection.Row;
chip.style.alignItems = Align.Center;
chip.style.marginLeft = 9; chip.style.marginRight = 9;
chip.pickingMode = PickingMode.Ignore;
var ic = HudUi.Icon(icon, iconSize, tint);
ic.style.marginRight = 6;
chip.Add(ic);
num = HudUi.Display(initial, fontSize, tint, TextAnchor.MiddleLeft);
chip.Add(num);
return chip;
}
void BuildPaletteRow(VisualElement root)
{
_paletteRow = new VisualElement();
_paletteRow.style.position = Position.Absolute;
_paletteRow.style.bottom = 24; _paletteRow.style.left = 0; _paletteRow.style.right = 0;
_paletteRow.style.flexDirection = FlexDirection.Row;
_paletteRow.style.justifyContent = Justify.Center;
_paletteRow.pickingMode = PickingMode.Ignore; // the row passes clicks through; its slots pick
root.Add(_paletteRow);
}
void BuildHintBar(VisualElement root)
{
_hintBar = new VisualElement();
_hintBar.style.position = Position.Absolute;
_hintBar.style.bottom = 138; _hintBar.style.left = 0; _hintBar.style.right = 0;
_hintBar.style.flexDirection = FlexDirection.Row;
_hintBar.style.justifyContent = Justify.Center;
_hintBar.pickingMode = PickingMode.Ignore;
_hintBar.style.display = DisplayStyle.None;
root.Add(_hintBar);
}
void BuildDiscoveryChip(VisualElement root)
{
// Slice 1 HUD declutter: a subtle bottom-center chip teaching the build-mode toggle, shown only while
// the palette is CLOSED at base. The glyph uses the text fallback ("Tab"/"Y") — no HudTheme sprite needed.
bool pad = AimPresentation.Scheme == InputSchemeId.Gamepad;
_buildDiscoveryChip = new VisualElement();
_buildDiscoveryChip.style.position = Position.Absolute;
_buildDiscoveryChip.style.bottom = 28; _buildDiscoveryChip.style.left = 0; _buildDiscoveryChip.style.right = 0;
_buildDiscoveryChip.style.flexDirection = FlexDirection.Row;
_buildDiscoveryChip.style.justifyContent = Justify.Center;
_buildDiscoveryChip.style.alignItems = Align.Center;
_buildDiscoveryChip.pickingMode = PickingMode.Ignore;
_buildDiscoveryChip.style.opacity = 0.6f;
_buildDiscoveryChip.Add(HudUi.Glyph(null, pad ? "Y" : "Tab", 26));
var lbl = HudUi.Text("BUILD", 12, MenuUi.SubCol, TextAnchor.MiddleLeft);
lbl.style.marginLeft = 5;
_buildDiscoveryChip.Add(lbl);
_buildDiscoveryChip.style.display = DisplayStyle.None;
root.Add(_buildDiscoveryChip);
}
void BuildDowned(VisualElement root)
{
_downed = new VisualElement();
_downed.style.position = Position.Absolute;
_downed.style.left = 0; _downed.style.right = 0; _downed.style.top = 0; _downed.style.bottom = 0;
_downed.style.alignItems = Align.Center;
_downed.style.justifyContent = Justify.Center;
_downed.pickingMode = PickingMode.Ignore;
var theme = HudTheme.Get();
if (theme != null && theme.Vignette != null)
{
_downed.style.backgroundImage = new StyleBackground(Background.FromSprite(theme.Vignette));
_downed.style.unityBackgroundImageTintColor = new Color(0.45f, 0f, 0f, 0.6f);
_downed.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Cover));
}
else
{
_downed.style.backgroundColor = new Color(0.35f, 0f, 0f, 0.35f);
}
var downedCol = HudUi.Group(Align.Center);
downedCol.Add(HudUi.Display("DOWNED", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter));
_downedText = HudUi.Text("RESPAWNING...", 24, new Color(1f, 0.75f, 0.7f), TextAnchor.MiddleCenter);
_downedText.style.marginTop = 6;
downedCol.Add(_downedText);
_downed.Add(downedCol);
_downed.style.display = DisplayStyle.None;
root.Add(_downed);
}
// 2026-08-07 audit purge: BuildInventory / AddInvRow / ItemName / ItemTint / IsEquippable / SlotName /
// AddEquipRow drove the personal-inventory + equipment strip. That layer was already PAUSED in CLAUDE.md
// and went with the shell.
static Color ResourceTint(byte resId)
=> resId == ResourceId.Aether ? AetherCyan : resId == ResourceId.Biomass ? BioGreen : OreAmber;
static Sprite ResourceSprite(HudTheme t, byte resId)
{
if (t == null) return null;
return resId == ResourceId.Aether ? t.AetherIcon : resId == ResourceId.Biomass ? t.BioIcon : t.OreIcon;
}
// 2026-08-07 audit purge: StructureName, RoomTypeLabel, the branching route map, the ready panel, the
// boss presence bar and the run-depth dots all belonged to the superseded base/expedition loop and are
// deleted along with RunInfo / PlayerReady / BossState / StructureCatalog. Recover from git if the
// roguelite spine returns.
}
}