a4edf7a03b
Rebuild the in-game HUD on UI Toolkit (HudUi/HudSystem, Aether palette) consistent with the menu; build-palette bar (BuildPaletteState) drives cursor->cell ground-ghost preview (green/red via BuildPreviewMath), left-click place / right-click cancel / rotate; fire suppressed in build mode; combat juice restyle. +4 BuildPreviewMath EditMode tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
330 lines
15 KiB
C#
330 lines
15 KiB
C#
using System.Collections.Generic;
|
|
using ProjectM.Simulation;
|
|
using Unity.Entities;
|
|
using Unity.NetCode;
|
|
using UnityEngine;
|
|
using UnityEngine.UIElements;
|
|
|
|
namespace ProjectM.Client
|
|
{
|
|
/// <summary>
|
|
/// Client-only screen HUD, rebuilt on UI Toolkit so it reads in the same Aether-cyan visual language as the
|
|
/// menu / pause / settings screens (<see cref="MenuUi"/> / <see cref="HudUi"/>). A managed presentation
|
|
/// 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 clicks meant for the game world — only the build-palette buttons pick. Presentation only
|
|
/// (client world, no simulation). The palette buttons set <see cref="BuildPaletteState"/> (client-local UI
|
|
/// state), which BuildSendSystem turns into a ground ghost + click-to-place. The visual tree is built on the
|
|
/// first OnUpdate where the UIDocument's root exists (giving the panel a frame to initialise its PanelSettings).
|
|
/// </summary>
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
|
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
|
public partial class HudSystem : SystemBase
|
|
{
|
|
GameObject _hudGo;
|
|
UIDocument _doc;
|
|
bool _built;
|
|
|
|
VisualElement _healthFill, _cooldownFill, _goalFill, _downed, _paletteRow;
|
|
Label _healthText, _threatText, _phaseText, _resourceText, _locationText, _goalText;
|
|
|
|
bool _paletteBuilt;
|
|
readonly Dictionary<byte, PaletteItem> _palette = new();
|
|
|
|
EntityQuery _huskQuery;
|
|
|
|
struct PaletteItem { public VisualElement Root; public Label Cost; public int CostAmount; }
|
|
|
|
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;
|
|
}
|
|
|
|
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
|
|
int huskCount = _huskQuery.CalculateEntityCount();
|
|
|
|
// ---- Macro loop: phase + cycle + countdown ----
|
|
bool haveCycle = SystemAPI.TryGetSingleton<CycleState>(out var cyc);
|
|
if (haveCycle)
|
|
{
|
|
var endTick = new NetworkTick(cyc.PhaseEndTick);
|
|
string detail;
|
|
if (cyc.Phase == CyclePhase.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 = "";
|
|
_phaseText.text = PhaseLabel(cyc.Phase) + (detail.Length > 0 ? " - " + detail : "");
|
|
_phaseText.style.color = PhaseColor(cyc.Phase);
|
|
}
|
|
else
|
|
{
|
|
_phaseText.text = "";
|
|
}
|
|
|
|
// ---- Location + gate hint ----
|
|
var cam = Camera.main;
|
|
bool onExpedition = cam != null && cam.transform.position.x > 500f;
|
|
_locationText.text = onExpedition
|
|
? "ON EXPEDITION - return through the gate"
|
|
: "AT BASE - deploy through the gate when you're ready";
|
|
_locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f) : new Color(0.6f, 0.85f, 1f);
|
|
|
|
// ---- Goal ----
|
|
if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal))
|
|
{
|
|
float gfrac = goal.Target > 0 ? Mathf.Clamp01(goal.Charge / (float)goal.Target) : 0f;
|
|
HudUi.SetFill(_goalFill, gfrac);
|
|
_goalText.text = "GOAL " + goal.Charge + " / " + goal.Target;
|
|
}
|
|
|
|
// ---- Resources (Ore feeds the 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;
|
|
}
|
|
}
|
|
_resourceText.text = "AETHER " + aether + " ORE " + ore + " BIO " + bio;
|
|
|
|
// ---- Build palette (lazy-built once the catalog has streamed; hidden off-base) ----
|
|
UpdatePalette(ore, onExpedition);
|
|
|
|
// ---- 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<Health>, RefRO<EffectiveCharacterStats>, RefRO<EffectiveAbilityStats>,
|
|
RefRO<AbilityCooldown>, 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);
|
|
|
|
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;
|
|
if (!found) { _downed.style.display = DisplayStyle.None; return; }
|
|
|
|
float frac = maxHp > 0f ? Mathf.Clamp01(hp / maxHp) : 0f;
|
|
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) + (shielded ? " SHIELDED" : "");
|
|
|
|
HudUi.SetFill(_cooldownFill, cdFrac);
|
|
_threatText.text = "HUSKS " + huskCount;
|
|
_downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
|
|
}
|
|
|
|
void UpdatePalette(int ore, bool onExpedition)
|
|
{
|
|
if (!_paletteBuilt && SystemAPI.TryGetSingletonEntity<StructureCatalog>(out var catE))
|
|
{
|
|
var cat = SystemAPI.GetBuffer<StructureCatalogEntry>(catE);
|
|
for (int i = 0; i < cat.Length; i++)
|
|
AddPaletteItem(cat[i].Type, cat[i].CostAmount);
|
|
_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;
|
|
bool affordable = ore >= item.CostAmount;
|
|
bool selected = BuildPaletteState.Selected == kv.Key;
|
|
item.Root.style.opacity = affordable ? 1f : 0.45f;
|
|
item.Cost.style.color = affordable ? new Color(0.7f, 0.95f, 0.8f) : new Color(1f, 0.5f, 0.4f);
|
|
MenuUi.Border(item.Root, selected ? MenuUi.Accent : new Color(1f, 1f, 1f, 0.08f), selected ? 2 : 1);
|
|
item.Root.style.backgroundColor = selected
|
|
? new Color(0.16f, 0.26f, 0.32f, 0.95f)
|
|
: new Color(0.09f, 0.11f, 0.15f, 0.92f);
|
|
}
|
|
}
|
|
|
|
void AddPaletteItem(byte type, int cost)
|
|
{
|
|
if (type == 0 || _palette.ContainsKey(type)) return;
|
|
var root = new VisualElement();
|
|
root.style.width = 92;
|
|
root.style.marginLeft = 4; root.style.marginRight = 4;
|
|
root.style.paddingTop = 6; root.style.paddingBottom = 6;
|
|
root.style.alignItems = Align.Center;
|
|
root.style.backgroundColor = new Color(0.09f, 0.11f, 0.15f, 0.92f);
|
|
root.pickingMode = PickingMode.Position;
|
|
MenuUi.Round(root, 6);
|
|
MenuUi.Border(root, new Color(1f, 1f, 1f, 0.08f), 1);
|
|
|
|
var nameLabel = HudUi.Text(StructureName(type), 13, MenuUi.TextCol, TextAnchor.MiddleCenter);
|
|
var costLabel = HudUi.Text(cost + " ORE", 12, new Color(0.7f, 0.95f, 0.8f), TextAnchor.MiddleCenter);
|
|
costLabel.style.marginTop = 2;
|
|
root.Add(nameLabel);
|
|
root.Add(costLabel);
|
|
|
|
byte t = type;
|
|
root.RegisterCallback<ClickEvent>(_ =>
|
|
BuildPaletteState.Select(BuildPaletteState.Selected == t ? (byte)0 : t));
|
|
|
|
_paletteRow.Add(root);
|
|
_palette[type] = new PaletteItem { Root = root, Cost = costLabel, CostAmount = cost };
|
|
}
|
|
|
|
// ---- UITK construction ----
|
|
|
|
void BuildTree(VisualElement root)
|
|
{
|
|
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
|
|
|
|
// Health + cooldown (bottom-left).
|
|
var vitals = HudUi.Group();
|
|
vitals.style.position = Position.Absolute;
|
|
vitals.style.left = 40; vitals.style.bottom = 40;
|
|
var cdBar = HudUi.Bar(440, 12, new Color(0.4f, 0.8f, 1f), out _cooldownFill);
|
|
cdBar.style.marginBottom = 6;
|
|
var hpBar = HudUi.Bar(440, 40, new Color(0.25f, 0.9f, 0.5f), out _healthFill);
|
|
_healthText = HudUi.Text("100 / 100", 22, 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);
|
|
vitals.Add(cdBar);
|
|
vitals.Add(hpBar);
|
|
root.Add(vitals);
|
|
|
|
// Threat (top-right).
|
|
_threatText = HudUi.Text("HUSKS 0", 30, new Color(1f, 0.62f, 0.4f), TextAnchor.UpperRight);
|
|
_threatText.style.position = Position.Absolute;
|
|
_threatText.style.right = 40; _threatText.style.top = 28; _threatText.style.width = 380;
|
|
root.Add(_threatText);
|
|
|
|
// Macro stack (top-center).
|
|
var macro = HudUi.Group(Align.Center);
|
|
macro.style.position = Position.Absolute;
|
|
macro.style.top = 22; macro.style.left = 0; macro.style.right = 0;
|
|
_phaseText = HudUi.Text("", 30, new Color(0.55f, 0.9f, 1f), TextAnchor.MiddleCenter);
|
|
_resourceText = HudUi.Text("", 22, new Color(0.7f, 0.95f, 0.8f), TextAnchor.MiddleCenter);
|
|
_resourceText.style.marginTop = 4;
|
|
_locationText = HudUi.Text("", 18, new Color(0.6f, 0.85f, 1f), TextAnchor.MiddleCenter);
|
|
_locationText.style.marginTop = 4;
|
|
var goalBar = HudUi.Bar(360, 16, new Color(0.8f, 0.6f, 1f), out _goalFill);
|
|
goalBar.style.marginTop = 8;
|
|
_goalText = HudUi.Text("GOAL 0 / 10", 13, Color.white, TextAnchor.MiddleCenter);
|
|
_goalText.style.position = Position.Absolute;
|
|
_goalText.style.left = 0; _goalText.style.right = 0; _goalText.style.top = 0; _goalText.style.bottom = 0;
|
|
goalBar.Add(_goalText);
|
|
macro.Add(_phaseText);
|
|
macro.Add(_resourceText);
|
|
macro.Add(_locationText);
|
|
macro.Add(goalBar);
|
|
root.Add(macro);
|
|
|
|
// Build palette row (bottom-center). The row passes clicks through; its buttons pick.
|
|
_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;
|
|
root.Add(_paletteRow);
|
|
|
|
// Downed overlay.
|
|
_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.backgroundColor = new Color(0.35f, 0f, 0f, 0.35f);
|
|
_downed.style.alignItems = Align.Center;
|
|
_downed.style.justifyContent = Justify.Center;
|
|
_downed.pickingMode = PickingMode.Ignore;
|
|
_downed.Add(HudUi.Text("DOWNED - RESPAWNING", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter));
|
|
_downed.style.display = DisplayStyle.None;
|
|
root.Add(_downed);
|
|
}
|
|
|
|
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 "?";
|
|
}
|
|
}
|
|
}
|
|
}
|