using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
///
/// Client-only screen HUD on UI Toolkit, skinned with the curated Synty sci-fi-soldier kit
/// () over 's Aether palette so it reads in one visual language with
/// the menu / pause / settings. A managed presentation ()
/// 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 pickingMode = Ignore 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 it falls back to
/// the flat-colour HUD. Presentation only (client world, no simulation, no rollback double-fire).
///
[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 ChargeViolet = new(0.80f, 0.45f, 1f);
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 int MaxPips = 12;
const float ExpeditionRegionXMin = 500f; // 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, _cooldownFill, _shieldRow, _cdRow;
Label _healthText;
// threat
VisualElement _threatPanel, _threatIcon;
Label _threatNum;
// macro: banner + location + goal
VisualElement _banner, _goalContainer, _goalPipsRow, _goalBar, _goalFill;
Label _phaseText, _cycleText, _locationText, _goalText;
readonly List _pips = new();
// resources
Label _aetherNum, _oreNum, _bioNum, _chargeNum;
// build palette + hints
VisualElement _paletteRow, _hintBar, _facingArrow;
bool _paletteBuilt, _hintBuilt, _hintConveyor;
byte _hintScheme = 255;
readonly Dictionary _palette = new();
// overlays
VisualElement _vignette, _downed;
float _prevHp, _flash;
bool _haveHp;
// 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());
}
protected override void OnStartRunning()
{
if (_hudGo != null) return;
MenuUi.EnsureEventSystem();
_hudGo = new GameObject("~HUD");
_doc = _hudGo.AddComponent();
_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();
EntityManager.CompleteDependencyBeforeRO();
EntityManager.CompleteDependencyBeforeRO();
EntityManager.CompleteDependencyBeforeRO();
EntityManager.CompleteDependencyBeforeRO();
float dt = SystemAPI.Time.DeltaTime; // wall-frame delta — correct in a presentation system
bool haveTick = SystemAPI.TryGetSingleton(out var nt);
int huskCount = _huskQuery.CalculateEntityCount();
// ---- Macro: phase + cycle + countdown (center-top banner) ----
bool haveCycle = SystemAPI.TryGetSingleton(out var cyc);
bool siege = haveCycle && cyc.Phase == CyclePhase.Siege;
if (haveCycle)
{
var endTick = new NetworkTick(cyc.PhaseEndTick);
string detail;
if (siege)
detail = "WAVE " + cyc.WaveNumber + " - " + huskCount + " HUSKS";
else if (haveTick && cyc.PhaseEndTick != 0 && endTick.IsValid && endTick.IsNewerThan(nt.ServerTick))
detail = "INCURSION IN " + (endTick.TicksSince(nt.ServerTick) / 60 + 1) + "s";
else
detail = "";
var col = PhaseColor(cyc.Phase);
_phaseText.text = PhaseLabel(cyc.Phase) + (detail.Length > 0 ? " - " + detail : "");
_phaseText.style.color = col;
_cycleText.text = "CYCLE " + cyc.CycleNumber;
_banner.style.borderBottomColor = col;
RetintPanel(_banner, siege ? PanelWarm : PanelDark);
}
else
{
_phaseText.text = "";
_cycleText.text = "";
}
// ---- Location + gate hint (banner sub-line) ----
var cam = Camera.main;
bool onExpedition = cam != null && cam.transform.position.x > ExpeditionRegionXMin;
_locationText.text = onExpedition
? "ON EXPEDITION - carve the frontier, then return"
: siege
? "DEFEND THE BASE - hold the line"
: "MINE THE CRYSTALS - any attack harvests Ore, then BUILD";
_locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f)
: siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f);
// ---- Goal (hex-pip meter, or a continuous bar for large targets) ----
if (SystemAPI.TryGetSingleton(out var goal))
{
_goalContainer.style.display = DisplayStyle.Flex;
float gfrac = goal.Target > 0 ? Mathf.Clamp01(goal.Charge / (float)goal.Target) : 0f;
_goalText.text = "GOAL " + goal.Charge + " / " + goal.Target;
if (goal.Target >= 1 && goal.Target <= MaxPips)
{
_goalPipsRow.style.display = DisplayStyle.Flex;
_goalBar.style.display = DisplayStyle.None;
int active = Mathf.Min(goal.Charge, goal.Target); // Charge is the integer pip count; never over-fill
for (int i = 0; i < _pips.Count; i++)
{
bool show = i < goal.Target;
_pips[i].style.display = show ? DisplayStyle.Flex : DisplayStyle.None;
if (show) SetPip(_pips[i], i < active);
}
}
else
{
_goalPipsRow.style.display = DisplayStyle.None;
_goalBar.style.display = DisplayStyle.Flex;
HudUi.SetFill(_goalFill, gfrac);
}
}
else
{
_goalContainer.style.display = DisplayStyle.None;
}
// ---- Resources (feed palette affordability) ----
int aether = 0, ore = 0, bio = 0, charge = 0;
if (SystemAPI.TryGetSingletonEntity(out var ledgerE))
{
var buf = SystemAPI.GetBuffer(ledgerE);
for (int i = 0; i < buf.Length; i++)
{
var en = buf[i];
if (en.ItemId == ResourceId.Aether) aether = en.Count;
else if (en.ItemId == ResourceId.Ore) ore = en.Count;
else if (en.ItemId == ResourceId.Biomass) bio = en.Count;
else if (en.ItemId == ResourceId.Charge) charge = en.Count;
}
}
_aetherNum.text = aether.ToString();
_oreNum.text = ore.ToString();
_bioNum.text = bio.ToString();
_chargeNum.text = charge.ToString();
// EB-2 quiet-turret cue (GLOBAL, not per-turret, so the deterministic Charge split never reads as one
// broken turret): a dry base during a siege tells the player to build a Fabricator.
if (siege && charge == 0 && !onExpedition)
{
_locationText.text = "TURRETS OUT OF CHARGE - build a Fabricator (Ore -> Charge)";
_locationText.style.color = new Color(1f, 0.4f, 0.9f);
}
// ---- Threat readout (top-right) — hidden entirely at base with zero husks; its reappearance is the cue ----
bool showThreat = siege || huskCount > 0;
_threatPanel.style.display = showThreat ? DisplayStyle.Flex : DisplayStyle.None;
if (showThreat)
{
float intensity = Mathf.Clamp01(huskCount / 30f);
Color tc = siege ? Color.Lerp(ThreatWarm, BlightRed, intensity) : ThreatWarm;
_threatNum.text = huskCount.ToString();
_threatNum.style.color = tc;
_threatIcon.style.unityBackgroundImageTintColor = tc;
RetintPanel(_threatPanel, siege ? PanelWarm : PanelDark);
}
// ---- Build palette + control hints (bottom-center) ----
UpdatePalette(aether, ore, bio, onExpedition);
bool buildActive = BuildPaletteState.Active && !onExpedition && _paletteBuilt;
if (buildActive)
{
byte scheme = AimPresentation.Scheme;
bool conv = BuildPaletteState.Selected == StructureType.Conveyor;
if (!_hintBuilt || _hintScheme != scheme || _hintConveyor != conv) RebuildHints(scheme, conv);
if (conv && _facingArrow != null)
_facingArrow.style.rotate = new StyleRotate(new Rotate(new Angle(FacingDegrees(BuildPaletteState.Direction))));
_hintBar.style.display = DisplayStyle.Flex;
}
else
{
_hintBar.style.display = DisplayStyle.None;
}
// ---- Per-player vitals ----
bool found = false;
float hp = 0f, maxHp = 1f, cdFrac = 1f;
bool dead = false, shielded = false;
foreach (var (health, effChar, effAbility, cd, invuln, entity) in
SystemAPI.Query, RefRO, RefRO,
RefRO, RefRO>()
.WithAll().WithEntityAccess())
{
found = true;
hp = health.ValueRO.Current;
maxHp = effChar.ValueRO.MaxHealth > 0f ? effChar.ValueRO.MaxHealth : health.ValueRO.Max;
dead = SystemAPI.IsComponentEnabled(entity);
uint nextFire = cd.ValueRO.NextFireTick;
int cdTicks = effAbility.ValueRO.CooldownTicks;
var nextTick = new NetworkTick(nextFire);
cdFrac = (haveTick && nextFire != 0 && cdTicks > 0 && nextTick.IsValid && nextTick.IsNewerThan(nt.ServerTick))
? Mathf.Clamp01(1f - nextTick.TicksSince(nt.ServerTick) / (float)cdTicks)
: 1f;
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 || haveCycle) ? 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);
_healthText.text = Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(maxHp);
_shieldRow.style.display = shielded ? DisplayStyle.Flex : DisplayStyle.None;
HudUi.SetFill(_cooldownFill, cdFrac);
// A READY weapon (full bar) recedes; a CHARGING one is bright — so the inverted-vs-health polarity reads.
if (_cdRow != null) _cdRow.style.opacity = cdFrac >= 1f ? 0.4f : 1f;
_downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
}
else
{
_haveHp = false; _flash = 0f;
_vignette.style.display = DisplayStyle.None;
_downed.style.display = DisplayStyle.None;
}
// ---- Personal inventory (read-only; toggle with I, deposit-all with G via InventoryDepositSendSystem) ----
var invKb = UnityEngine.InputSystem.Keyboard.current;
if (invKb != null && invKb.iKey.wasPressedThisFrame) _invOpen = !_invOpen;
if (_invOpen && found)
{
EntityManager.CompleteDependencyBeforeRO();
EntityManager.CompleteDependencyBeforeRO();
bool haveItemDb = SystemAPI.TryGetSingleton(out var itemDb);
_invPanel.style.display = DisplayStyle.Flex;
_invList.Clear();
int shown = 0;
foreach (var bag in SystemAPI.Query>()
.WithAll())
{
for (int i = 0; i < bag.Length; i++)
{
var slot = bag[i];
if (slot.ItemId == 0 || slot.Count <= 0) continue;
AddInvRow(slot.ItemId, ItemName(haveItemDb, itemDb, slot.ItemId), ItemTint(slot.ItemId), slot.Count,
IsEquippable(haveItemDb, itemDb, slot.ItemId));
shown++;
}
break;
}
if (shown == 0)
_invList.Add(HudUi.Text("(empty)", 13, MenuUi.SubCol, TextAnchor.MiddleLeft));
_equipList.Clear();
foreach (var slots in SystemAPI.Query>()
.WithAll())
{
for (byte s = 0; s < EquipSlotId.Count && s < slots.Length; s++)
{
ushort id = slots[s].ItemId;
string label = SlotName(s) + ": " + (id == 0 ? "-" : ItemName(haveItemDb, itemDb, id));
AddEquipRow(s, label, id != 0);
}
break;
}
}
else
{
_invPanel.style.display = DisplayStyle.None;
}
}
// ---- per-frame helpers ----
void RetintPanel(VisualElement p, Color c)
{
if (_themed) p.style.unityBackgroundImageTintColor = c;
else p.style.backgroundColor = c;
}
void SetPip(VisualElement pip, bool active)
{
var theme = HudTheme.Get();
var spr = active ? theme?.PipActive : theme?.PipInactive;
if (spr != null)
{
pip.style.backgroundImage = new StyleBackground(Background.FromSprite(spr));
pip.style.unityBackgroundImageTintColor = active ? AetherCyan : PipDim;
pip.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Contain));
}
else
{
pip.style.backgroundColor = active ? AetherCyan : PipDim;
MenuUi.Round(pip, 3);
}
}
void UpdatePalette(int aether, int ore, int bio, bool onExpedition)
{
if (!_paletteBuilt && SystemAPI.TryGetSingletonEntity(out var catE))
{
var cat = SystemAPI.GetBuffer(catE);
for (int i = 0; i < cat.Length; i++)
AddPaletteItem(cat[i].Type, cat[i].CostAmount, cat[i].CostResourceId);
_paletteBuilt = true;
}
if (!_paletteBuilt) { _paletteRow.style.display = DisplayStyle.None; return; }
_paletteRow.style.display = onExpedition ? DisplayStyle.None : DisplayStyle.Flex;
foreach (var kv in _palette)
{
var item = kv.Value;
int have = item.CostRes == ResourceId.Aether ? aether : item.CostRes == ResourceId.Biomass ? bio : ore;
bool affordable = have >= item.CostAmount;
bool selected = BuildPaletteState.Selected == kv.Key;
item.Root.style.opacity = affordable ? 1f : 0.5f;
item.Cost.style.color = affordable ? new Color(0.7f, 0.95f, 0.8f) : new Color(1f, 0.5f, 0.4f);
if (item.Icon != null)
item.Icon.style.unityBackgroundImageTintColor = affordable ? AetherCyan : new Color(0.5f, 0.55f, 0.6f);
MenuUi.Border(item.Root, selected ? MenuUi.Accent : SlotIdleBorder, selected ? 2 : 1);
item.Root.style.backgroundColor = selected ? SlotSelBg : SlotIdleBg;
if (item.Glow != null) item.Glow.style.opacity = selected ? 0.6f : 0f;
}
}
void AddPaletteItem(byte type, int cost, byte costRes)
{
if (type == 0 || _palette.ContainsKey(type)) return;
var theme = HudTheme.Get();
var root = new VisualElement();
root.style.width = 86;
root.style.marginLeft = 4; root.style.marginRight = 4;
root.style.paddingTop = 8; root.style.paddingBottom = 6;
root.style.alignItems = Align.Center;
root.style.backgroundColor = SlotIdleBg;
root.pickingMode = PickingMode.Position;
MenuUi.Round(root, 6);
MenuUi.Border(root, SlotIdleBorder, 1);
// selection glow: a soft Synty glow filling the slot behind everything, opacity toggled on select.
var glow = new VisualElement();
glow.style.position = Position.Absolute;
glow.style.left = 3; glow.style.right = 3; glow.style.top = 4; glow.style.bottom = 4;
glow.pickingMode = PickingMode.Ignore;
glow.style.opacity = 0f;
if (theme != null && theme.Glow != null)
{
glow.style.backgroundImage = new StyleBackground(Background.FromSprite(theme.Glow));
glow.style.unityBackgroundImageTintColor = AetherCyan;
glow.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Cover));
}
root.Add(glow);
var iconEl = HudUi.Icon(theme != null ? theme.StructureIcon(type) : null, 44, AetherCyan);
root.Add(iconEl);
var nameLabel = HudUi.Text(StructureName(type), 12, MenuUi.TextCol, TextAnchor.MiddleCenter);
nameLabel.style.marginTop = 2;
root.Add(nameLabel);
var costRow = new VisualElement();
costRow.style.flexDirection = FlexDirection.Row;
costRow.style.alignItems = Align.Center;
costRow.style.marginTop = 2;
costRow.pickingMode = PickingMode.Ignore;
var costIcon = HudUi.Icon(ResourceSprite(theme, costRes), 14, ResourceTint(costRes));
costIcon.style.marginRight = 3;
costRow.Add(costIcon);
var costLabel = HudUi.Display(cost.ToString(), 13, new Color(0.7f, 0.95f, 0.8f), TextAnchor.MiddleCenter);
costRow.Add(costLabel);
root.Add(costRow);
byte t = type;
root.RegisterCallback(_ =>
BuildPaletteState.Select(BuildPaletteState.Selected == t ? (byte)0 : t));
_paletteRow.Add(root);
_palette[type] = new PaletteItem { Root = root, Cost = costLabel, CostAmount = cost, CostRes = costRes, Glow = glow, Icon = iconEl };
}
void RebuildHints(byte scheme, bool conveyor)
{
_hintBar.Clear();
_facingArrow = null;
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");
if (conveyor)
{
// Rotate hint + a LIVE facing arrow (resolves the DR-021 conveyor-facing indicator). Only conveyors
// rotate, so this chip is gated to them — the other buildables don't show a meaningless ROTATE.
var chip = MakeChip();
chip.Add(HudUi.Glyph(pad ? theme?.PadRotate : null, pad ? "LB" : "R", 26));
var lbl = HudUi.Text("FACING", 12, MenuUi.SubCol, TextAnchor.MiddleLeft);
lbl.style.marginLeft = 5; lbl.style.marginRight = 6;
chip.Add(lbl);
_facingArrow = HudUi.Icon(theme != null ? theme.ConveyorIcon : null, 24, AetherCyan);
chip.Add(_facingArrow);
_hintBar.Add(chip);
}
AddHint(pad ? theme?.PadExit : null, pad ? "MENU" : "ESC", "EXIT");
_hintScheme = scheme;
_hintConveyor = conveyor;
_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);
BuildPaletteRow(root);
BuildHintBar(root);
BuildDowned(root);
BuildInventory(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);
// cooldown row: weapon icon + thin bar
_cdRow = new VisualElement();
_cdRow.style.flexDirection = FlexDirection.Row;
_cdRow.style.alignItems = Align.Center;
_cdRow.style.marginBottom = 6;
_cdRow.pickingMode = PickingMode.Ignore;
var cdIcon = HudUi.Icon(theme != null ? theme.CooldownIcon : null, 22, AetherCyan);
cdIcon.style.marginRight = 8;
_cdRow.Add(cdIcon);
var cdBar = HudUi.Bar(420, 12, new Color(0.4f, 0.8f, 1f), out _cooldownFill);
_cdRow.Add(cdBar);
panel.Add(_cdRow);
// 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);
_cycleText = HudUi.Text("", 14, MenuUi.SubCol, TextAnchor.MiddleCenter);
_cycleText.style.marginLeft = 14;
_banner.Add(_cycleText);
macro.Add(_banner);
_locationText = HudUi.Text("", 15, new Color(0.6f, 0.85f, 1f), TextAnchor.MiddleCenter);
_locationText.style.marginTop = 5;
macro.Add(_locationText);
// goal: hex-pip meter (or fallback bar) + numeral
_goalContainer = HudUi.Group(Align.Center);
_goalContainer.style.marginTop = 8;
var goalLine = new VisualElement();
goalLine.style.flexDirection = FlexDirection.Row;
goalLine.style.alignItems = Align.Center;
goalLine.pickingMode = PickingMode.Ignore;
_goalPipsRow = new VisualElement();
_goalPipsRow.style.flexDirection = FlexDirection.Row;
_goalPipsRow.style.alignItems = Align.Center;
_goalPipsRow.pickingMode = PickingMode.Ignore;
for (int i = 0; i < MaxPips; i++)
{
var pip = new VisualElement();
pip.style.width = 22; pip.style.height = 22;
pip.style.marginLeft = 2; pip.style.marginRight = 2;
pip.style.flexShrink = 0;
pip.pickingMode = PickingMode.Ignore;
pip.style.display = DisplayStyle.None;
_pips.Add(pip);
_goalPipsRow.Add(pip);
}
goalLine.Add(_goalPipsRow);
_goalText = HudUi.Display("GOAL 0 / 10", 16, AetherCyan, TextAnchor.MiddleCenter);
_goalText.style.marginLeft = 10;
goalLine.Add(_goalText);
_goalContainer.Add(goalLine);
// fallback continuous bar (large targets)
_goalBar = HudUi.Bar(360, 16, new Color(0.8f, 0.6f, 1f), out _goalFill);
_goalBar.style.marginTop = 4;
_goalBar.style.display = DisplayStyle.None;
_goalContainer.Add(_goalBar);
macro.Add(_goalContainer);
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));
strip.Add(ResourceChip(null, ChargeViolet, "0", out _chargeNum, 26, 20)); // EB-2 turret ammo (flat violet, no icon)
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 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);
}
_downed.Add(HudUi.Display("DOWNED - RESPAWNING", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter));
_downed.style.display = DisplayStyle.None;
root.Add(_downed);
}
void BuildInventory(VisualElement root)
{
_invPanel = HudUi.Panel(PanelDark);
_invPanel.style.position = Position.Absolute;
_invPanel.style.right = 40; _invPanel.style.bottom = 40;
_invPanel.style.minWidth = 224;
_invPanel.style.paddingLeft = 14; _invPanel.style.paddingRight = 14;
_invPanel.style.paddingTop = 10; _invPanel.style.paddingBottom = 10;
_invPanel.style.alignItems = Align.FlexStart;
_invPanel.pickingMode = PickingMode.Ignore;
var header = HudUi.Display("INVENTORY", 16, AetherCyan, TextAnchor.MiddleLeft);
header.style.marginBottom = 6;
_invPanel.Add(header);
_invList = new VisualElement();
_invList.pickingMode = PickingMode.Ignore;
_invPanel.Add(_invList);
var equipHeader = HudUi.Display("EQUIPMENT", 14, AetherCyan, TextAnchor.MiddleLeft);
equipHeader.style.marginTop = 8; equipHeader.style.marginBottom = 4;
_invPanel.Add(equipHeader);
_equipList = new VisualElement();
_equipList.pickingMode = PickingMode.Ignore;
_invPanel.Add(_equipList);
var hint = HudUi.Text("I close - click item=equip / slot=unequip - G deposit", 11, MenuUi.SubCol, TextAnchor.MiddleLeft);
hint.style.marginTop = 8;
_invPanel.Add(hint);
_invPanel.style.display = DisplayStyle.None;
root.Add(_invPanel);
}
void AddInvRow(ushort itemId, string name, Color tint, int count, bool equippable)
{
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.justifyContent = Justify.SpaceBetween;
row.style.minWidth = 196;
row.style.marginTop = 2;
row.Add(HudUi.Text(name + (equippable ? " (equip)" : ""), 13, tint, TextAnchor.MiddleLeft));
row.Add(HudUi.Display("x" + count, 13, Color.white, TextAnchor.MiddleRight));
if (equippable)
{
row.pickingMode = PickingMode.Position;
ushort id = itemId;
row.RegisterCallback(_ => EquipSendSystem.Equip(id));
}
else row.pickingMode = PickingMode.Ignore;
_invList.Add(row);
}
static string ItemName(bool haveDb, ItemDatabase db, ushort id)
{
if (haveDb && db.Value.IsCreated)
{
ref var blob = ref db.Value.Value;
if (blob.TryGetItem(id, out var def)) return def.Name.ToString();
}
if (id == ResourceId.Aether) return "Aether";
if (id == ResourceId.Ore) return "Ore";
if (id == ResourceId.Biomass) return "Biomass";
return "Item " + id;
}
static Color ItemTint(ushort id)
{
if (id == ResourceId.Aether) return AetherCyan;
if (id == ResourceId.Ore) return OreAmber;
if (id == ResourceId.Biomass) return BioGreen;
return new Color(0.85f, 0.85f, 0.9f);
}
static bool IsEquippable(bool haveDb, ItemDatabase db, ushort id)
{
if (!haveDb || !db.Value.IsCreated) return false;
ref var b = ref db.Value.Value;
return b.TryGetItem(id, out var def) && def.EquipSlot < EquipSlotId.Count;
}
static string SlotName(byte slot)
{
switch (slot)
{
case EquipSlotId.Weapon: return "Weapon";
case EquipSlotId.Armor: return "Armor";
case EquipSlotId.Trinket: return "Trinket";
case EquipSlotId.Tool: return "Tool";
default: return "Slot " + slot;
}
}
void AddEquipRow(byte slot, string label, bool occupied)
{
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.justifyContent = Justify.SpaceBetween;
row.style.minWidth = 196;
row.style.marginTop = 2;
row.Add(HudUi.Text(label, 13, occupied ? AetherCyan : MenuUi.SubCol, TextAnchor.MiddleLeft));
if (occupied)
{
row.pickingMode = PickingMode.Position;
byte s = slot;
row.RegisterCallback(_ => EquipSendSystem.Unequip(s));
row.Add(HudUi.Text("unequip", 11, new Color(1f, 0.6f, 0.5f), TextAnchor.MiddleRight));
}
else row.pickingMode = PickingMode.Ignore;
_equipList.Add(row);
}
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;
}
// Conveyor facing (BuildPaletteState.Direction 0=+X,1=-X,2=+Z,3=-Z) → arrow rotation; the arrow art points up (+Z).
static float FacingDegrees(byte dir)
{
switch (dir)
{
case 0: return 90f; // +X
case 1: return 270f; // -X
case 3: return 180f; // -Z
default: return 0f; // +Z
}
}
static Color PhaseColor(byte phase)
{
switch (phase)
{
case CyclePhase.Calm: return new Color(0.45f, 0.9f, 0.7f);
case CyclePhase.Siege: return new Color(1f, 0.45f, 0.3f);
default: return Color.white;
}
}
static string PhaseLabel(byte phase)
{
switch (phase)
{
case CyclePhase.Calm: return "AT BASE";
case CyclePhase.Siege: return "UNDER SIEGE";
default: return "";
}
}
static string StructureName(byte type)
{
switch (type)
{
case StructureType.Turret: return "Turret";
case StructureType.Wall: return "Wall";
case StructureType.Pylon: return "Pylon";
case StructureType.Harvester: return "Harvester";
case StructureType.Fabricator: return "Fabricator";
case StructureType.Conveyor: return "Conveyor";
default: return "?";
}
}
}
}