diff --git a/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs b/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs
new file mode 100644
index 000000000..ebfedf5b8
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs
@@ -0,0 +1,29 @@
+namespace ProjectM.Client
+{
+ ///
+ /// Client-local build-mode state shared between the HUD build palette (sets the selected buildable), the
+ /// build placement input (ground ghost preview + click-to-place + conveyor rotation), and the input gather
+ /// (suppresses Fire while a build is selected, so a left-click places instead of firing). Pure UI state —
+ /// never replicated, never touches the sim. Reset on play-enter (statics survive fast-enter domain reloads).
+ ///
+ public static class BuildPaletteState
+ {
+ /// Selected structure type (StructureType.*); 0 = none / build mode off.
+ public static byte Selected;
+
+ /// Pending conveyor facing (0=+X,1=-X,2=+Z,3=-Z); rotated by [ / ] or R.
+ public static byte Direction;
+
+ /// True while a buildable is selected (build mode active).
+ public static bool Active => Selected != 0;
+
+ /// Select a type (or 0 to leave build mode), resetting the pending conveyor facing.
+ public static void Select(byte type) { Selected = type; Direction = 0; }
+
+ /// Leave build mode.
+ public static void Clear() { Selected = 0; Direction = 0; }
+
+ [UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)]
+ static void ResetStatics() { Selected = 0; Direction = 0; }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs.meta b/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs.meta
new file mode 100644
index 000000000..99674e144
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b0f12fac7a937bf418aaf47eba57cc74
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs b/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs
index 303ef413b..63757be67 100644
--- a/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs
+++ b/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs
@@ -3,29 +3,53 @@ using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
+using UnityEngine;
namespace ProjectM.Client
{
///
- /// Client-only sender for build + upgrade RPCs. Keyboard: B builds a Turret at the local player's current
- /// grid cell; U upgrades ability damage. Editor-only statics (PlaceStructure / PlaceTurret / UpgradeAbility)
- /// drive the same path from execute_code for headless validation (a one-shot key can't be injected on an
- /// unfocused editor — the StorageOpSendSystem idiom). Managed SystemBase (reads the Input System);
- /// UnityEngine.InputSystem is fully qualified to avoid the ProjectM.Simulation.PlayerInput name collision.
- /// The server re-validates legality + cost authoritatively; this only sends a hint.
+ /// Client-only build input + RPC sender. Two ways to build:
+ /// (1) the HUD build PALETTE (primary): a selected buildable () drives a ground
+ /// GHOST preview at the cursor cell — green when valid (in-plot, unoccupied, affordable; the same legality the
+ /// server re-validates), red otherwise — and a LEFT-CLICK places it; right-click / Esc cancels; [ / ] or R
+ /// rotates a conveyor's facing. Fire is suppressed while build mode is active (PlayerInputGatherSystem reads
+ /// ), so the place-click never also fires. Build mode is suspended while
+ /// the pause overlay is open, and the frame a palette button changes the selection never also places.
+ /// (2) keyboard hotkeys (fallback, suppressed in palette mode): B/V/N/H/F/C place at the local player's cell.
+ /// Editor-only statics (PlaceStructure / PlaceHarvester / ...) drive the same RPC path from execute_code for
+ /// headless validation. Managed SystemBase; UnityEngine.InputSystem types are fully qualified to avoid the
+ /// ProjectM.Simulation.PlayerInput name collision. The server re-validates legality + cost authoritatively.
///
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class BuildSendSystem : SystemBase
{
+ static byte s_ConveyorDir; // key-flow pending conveyor facing (0=+X,1=-X,2=+Z,3=-Z)
+
+ // Dev hotkey fallback: key -> buildable (Conveyor uses s_ConveyorDir for facing).
+ static readonly (UnityEngine.InputSystem.Key Key, byte Type)[] s_BuildHotkeys =
+ {
+ (UnityEngine.InputSystem.Key.B, StructureType.Turret),
+ (UnityEngine.InputSystem.Key.V, StructureType.Wall),
+ (UnityEngine.InputSystem.Key.N, StructureType.Pylon),
+ (UnityEngine.InputSystem.Key.H, StructureType.Harvester),
+ (UnityEngine.InputSystem.Key.F, StructureType.Fabricator),
+ (UnityEngine.InputSystem.Key.C, StructureType.Conveyor),
+ };
+
+ UnityEngine.Camera _camera; // cursor -> ground re-raycast for click-to-place (resolved lazily)
+ GameObject _ghost; // translucent ground preview cube
+ Material _ghostMat;
+ byte _lastSelected; // skip placing on the frame a palette click changes the selection
+
#if UNITY_EDITOR
- struct PendingBuild { public byte Type; public int CellX; public int CellZ; }
+ struct PendingBuild { public byte Type; public int CellX; public int CellZ; public byte Direction; }
static readonly System.Collections.Generic.Queue s_PendingBuild =
new System.Collections.Generic.Queue();
static int s_PendingUpgrades = 0;
/// EDITOR / execute_code hook: queue a structure placement at a specific cell.
- public static void PlaceStructure(byte type, int cellX, int cellZ) =>
- s_PendingBuild.Enqueue(new PendingBuild { Type = type, CellX = cellX, CellZ = cellZ });
+ public static void PlaceStructure(byte type, int cellX, int cellZ, byte direction = 0) =>
+ s_PendingBuild.Enqueue(new PendingBuild { Type = type, CellX = cellX, CellZ = cellZ, Direction = direction });
/// EDITOR / execute_code hook: queue a turret placement at a specific cell.
public static void PlaceTurret(int cellX, int cellZ) => PlaceStructure(StructureType.Turret, cellX, cellZ);
@@ -36,6 +60,15 @@ namespace ProjectM.Client
/// EDITOR / execute_code hook: queue a pylon placement at a specific cell.
public static void PlacePylon(int cellX, int cellZ) => PlaceStructure(StructureType.Pylon, cellX, cellZ);
+ /// EDITOR / execute_code hook: queue a harvester placement at a specific cell.
+ public static void PlaceHarvester(int cellX, int cellZ) => PlaceStructure(StructureType.Harvester, cellX, cellZ);
+
+ /// EDITOR / execute_code hook: queue a fabricator placement at a specific cell.
+ public static void PlaceFabricator(int cellX, int cellZ) => PlaceStructure(StructureType.Fabricator, cellX, cellZ);
+
+ /// EDITOR / execute_code hook: queue a conveyor placement facing direction (0=+X,1=-X,2=+Z,3=-Z).
+ public static void PlaceConveyor(int cellX, int cellZ, byte direction) => PlaceStructure(StructureType.Conveyor, cellX, cellZ, direction);
+
/// EDITOR / execute_code hook: queue an ability-damage upgrade.
public static void UpgradeAbility() => s_PendingUpgrades++;
#endif
@@ -45,20 +78,31 @@ namespace ProjectM.Client
RequireForUpdate();
}
+ protected override void OnDestroy()
+ {
+ if (_ghost != null) Object.Destroy(_ghost);
+ if (_ghostMat != null) Object.Destroy(_ghostMat);
+ }
+
protected override void OnUpdate()
{
if (!SystemAPI.TryGetSingletonEntity(out var connection))
return;
+ HandleBuildMode(connection);
+
+ // Hotkey fallback (suppressed while the palette build mode is active).
var keyboard = UnityEngine.InputSystem.Keyboard.current;
- if (keyboard != null)
+ if (keyboard != null && !BuildPaletteState.Active)
{
- if (keyboard.bKey.wasPressedThisFrame && TryGetLocalPlayerCell(out int2 cell))
- SendBuild(connection, StructureType.Turret, cell.x, cell.y);
- if (keyboard.vKey.wasPressedThisFrame && TryGetLocalPlayerCell(out int2 wcell))
- SendBuild(connection, StructureType.Wall, wcell.x, wcell.y);
- if (keyboard.nKey.wasPressedThisFrame && TryGetLocalPlayerCell(out int2 pcell))
- SendBuild(connection, StructureType.Pylon, pcell.x, pcell.y);
+ if (keyboard.leftBracketKey.wasPressedThisFrame)
+ s_ConveyorDir = (byte)((s_ConveyorDir + 3) % 4);
+ if (keyboard.rightBracketKey.wasPressedThisFrame)
+ s_ConveyorDir = (byte)((s_ConveyorDir + 1) % 4);
+
+ foreach (var (key, type) in s_BuildHotkeys)
+ if (keyboard[key].wasPressedThisFrame && TryGetLocalPlayerCell(out int2 cell))
+ SendBuild(connection, type, cell.x, cell.y, type == StructureType.Conveyor ? s_ConveyorDir : (byte)0);
if (keyboard.uKey.wasPressedThisFrame)
SendUpgrade(connection);
}
@@ -67,7 +111,7 @@ namespace ProjectM.Client
while (s_PendingBuild.Count > 0)
{
var b = s_PendingBuild.Dequeue();
- SendBuild(connection, b.Type, b.CellX, b.CellZ);
+ SendBuild(connection, b.Type, b.CellX, b.CellZ, b.Direction);
}
while (s_PendingUpgrades > 0)
{
@@ -77,6 +121,124 @@ namespace ProjectM.Client
#endif
}
+ // ---- Palette-driven build mode: ground ghost preview + click-to-place ----
+ void HandleBuildMode(Entity connection)
+ {
+ byte sel = BuildPaletteState.Selected;
+ bool justSelected = sel != _lastSelected; // the selecting click must not also place
+ _lastSelected = sel;
+
+ bool active = BuildPaletteState.Active && !PauseMenuController.Open;
+ AimPresentation.ForceCursorVisible = active;
+ if (!active) { HideGhost(); return; }
+
+ var keyboard = UnityEngine.InputSystem.Keyboard.current;
+ var mouse = UnityEngine.InputSystem.Mouse.current;
+
+ // Cancel build mode (right-click / Esc).
+ if ((mouse != null && mouse.rightButton.wasPressedThisFrame) ||
+ (keyboard != null && keyboard.escapeKey.wasPressedThisFrame))
+ {
+ BuildPaletteState.Clear();
+ HideGhost();
+ return;
+ }
+
+ // Rotate a conveyor's facing ([ / ] or R).
+ if (keyboard != null)
+ {
+ if (keyboard.leftBracketKey.wasPressedThisFrame)
+ BuildPaletteState.Direction = (byte)((BuildPaletteState.Direction + 3) % 4);
+ if (keyboard.rightBracketKey.wasPressedThisFrame || keyboard.rKey.wasPressedThisFrame)
+ BuildPaletteState.Direction = (byte)((BuildPaletteState.Direction + 1) % 4);
+ }
+
+ if (!SystemAPI.TryGetSingleton(out var anchor)) { HideGhost(); return; }
+ if (_camera == null) _camera = ResolveCamera();
+ if (_camera == null || mouse == null) { HideGhost(); return; }
+
+ // Cursor -> ground -> cell.
+ UnityEngine.Vector2 sp = mouse.position.ReadValue();
+ UnityEngine.Ray ray = _camera.ScreenPointToRay(new UnityEngine.Vector3(sp.x, sp.y, 0f));
+ if (!AimMath.TryGroundHit((float3)ray.origin, (float3)ray.direction, anchor.GridOrigin.y, out var groundPoint))
+ { HideGhost(); return; }
+ int2 targetCell = BaseGridMath.WorldToCell(anchor, groundPoint);
+
+ // Validity — client mirror of the server check (in-plot, unoccupied, affordable).
+ bool occupied = false;
+ foreach (var (ps, xf) in SystemAPI.Query, RefRO>())
+ if (math.all(BaseGridMath.WorldToCell(anchor, xf.ValueRO.Position) == targetCell)) { occupied = true; break; }
+
+ int cost = CatalogCost(sel);
+ byte reason = BuildPreviewMath.Evaluate(anchor, targetCell, occupied, LedgerOre(), cost);
+ bool valid = reason == BuildPreviewMath.Valid;
+
+ ShowGhost(BaseGridMath.CellToWorld(anchor, targetCell), anchor.CellSize, valid);
+
+ // Place on a left-click (valid, not the selecting click).
+ if (valid && !justSelected && mouse.leftButton.wasPressedThisFrame)
+ SendBuild(connection, sel, targetCell.x, targetCell.y, BuildPaletteState.Direction);
+ }
+
+ int LedgerOre()
+ {
+ if (!SystemAPI.TryGetSingletonEntity(out var le)) return 0;
+ var buf = SystemAPI.GetBuffer(le);
+ for (int i = 0; i < buf.Length; i++) if (buf[i].ItemId == ResourceId.Ore) return buf[i].Count;
+ return 0;
+ }
+
+ int CatalogCost(byte type)
+ {
+ if (type == 0 || !SystemAPI.TryGetSingletonEntity(out var ce)) return int.MaxValue;
+ var cat = SystemAPI.GetBuffer(ce);
+ for (int i = 0; i < cat.Length; i++) if (cat[i].Type == type) return cat[i].CostAmount;
+ return int.MaxValue;
+ }
+
+ // ---- Ground ghost preview (procedural translucent cube, like AimReticleSystem's reticle) ----
+ void ShowGhost(float3 center, float cellSize, bool valid)
+ {
+ EnsureGhost();
+ _ghost.transform.position = (Vector3)center + Vector3.up * 0.5f;
+ _ghost.transform.localScale = new Vector3(cellSize * 0.9f, 1f, cellSize * 0.9f);
+ _ghostMat.color = valid ? new Color(0.3f, 1f, 0.45f, 0.35f) : new Color(1f, 0.32f, 0.26f, 0.35f);
+ if (!_ghost.activeSelf) _ghost.SetActive(true);
+ }
+
+ void HideGhost()
+ {
+ if (_ghost != null && _ghost.activeSelf) _ghost.SetActive(false);
+ }
+
+ void EnsureGhost()
+ {
+ if (_ghost != null) return;
+ var shader = Shader.Find("Sprites/Default");
+ if (shader == null) shader = Shader.Find("Universal Render Pipeline/Unlit");
+ _ghostMat = new Material(shader) { color = new Color(0.3f, 1f, 0.45f, 0.35f) };
+ _ghost = GameObject.CreatePrimitive(PrimitiveType.Cube);
+ _ghost.name = "~BuildGhost";
+ var col = _ghost.GetComponent();
+ if (col != null) Object.Destroy(col);
+ var mr = _ghost.GetComponent();
+ mr.sharedMaterial = _ghostMat;
+ mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
+ mr.receiveShadows = false;
+ _ghost.SetActive(false);
+ }
+
+ UnityEngine.Camera ResolveCamera()
+ {
+ var cam = UnityEngine.Camera.main;
+ if (cam == null)
+ {
+ var rig = Object.FindAnyObjectByType();
+ if (rig != null) cam = rig.GetComponent();
+ }
+ return cam;
+ }
+
bool TryGetLocalPlayerCell(out int2 cell)
{
cell = default;
@@ -90,10 +252,10 @@ namespace ProjectM.Client
return false;
}
- void SendBuild(Entity connection, byte type, int cellX, int cellZ)
+ void SendBuild(Entity connection, byte type, int cellX, int cellZ, byte direction)
{
var e = EntityManager.CreateEntity();
- EntityManager.AddComponentData(e, new BuildPlaceRequest { StructureType = type, CellX = cellX, CellZ = cellZ });
+ EntityManager.AddComponentData(e, new BuildPlaceRequest { StructureType = type, CellX = cellX, CellZ = cellZ, Direction = direction });
EntityManager.AddComponentData(e, new SendRpcCommandRequest { TargetConnection = connection });
}
diff --git a/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs b/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs
index b9f330626..ebec3102b 100644
--- a/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs
+++ b/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs
@@ -71,7 +71,7 @@ namespace ProjectM.Client
// Movement is source-agnostic (WASD or left stick) — read from the merged action.
float2 move = (float2)gameplay.Move.ReadValue();
- bool firePressed = gameplay.Fire.WasPressedThisFrame();
+ bool firePressed = gameplay.Fire.WasPressedThisFrame() && !BuildPaletteState.Active; // no fire while placing a build
// --- Active-device detection: last meaningful actuation wins; hold last when idle ---
var gamepad = UnityEngine.InputSystem.Gamepad.current;
diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs
index 5bf4699b2..2273611dd 100644
--- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs
+++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs
@@ -343,6 +343,7 @@ namespace ProjectM.Client
tm.anchor = TextAnchor.MiddleCenter;
tm.alignment = TextAlignment.Center;
tm.color = Color.white;
+ tm.fontStyle = FontStyle.Bold;
go.SetActive(false);
return new FloatingNumber { Tm = tm, Tr = go.transform, Active = false };
}
@@ -358,7 +359,7 @@ namespace ProjectM.Client
fn.Age = 0f;
fn.Life = 0.7f;
fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString();
- fn.BaseColor = isLocalPlayer ? new Color(1f, 0.32f, 0.26f) : new Color(1f, 0.92f, 0.45f);
+ fn.BaseColor = isLocalPlayer ? new Color(1f, 0.5f, 0.22f) : new Color(0.45f, 0.92f, 1f); // Blight orange (hurt) / Aether cyan (you hit)
fn.Tm.color = fn.BaseColor;
fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f);
fn.Vel = new Vector3(0f, 2.2f, 0f);
@@ -478,7 +479,7 @@ namespace ProjectM.Client
static void PlayClip(AudioClip clip, Vector3 pos, float vol)
{
if (clip == null) return;
- AudioSource.PlayClipAtPoint(clip, pos, vol);
+ AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx);
}
}
}
diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs
index bb84091e7..08ff7c1d9 100644
--- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs
+++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs
@@ -1,37 +1,41 @@
+using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
-using UnityEngine.UI;
+using UnityEngine.UIElements;
namespace ProjectM.Client
{
///
- /// Client-only screen HUD. A managed presentation SystemBase () that builds
- /// a uGUI overlay canvas in code and drives it from the LOCAL player ghost each frame: a health bar
- /// (Health / EffectiveCharacterStats.MaxHealth), an ability-cooldown bar (AbilityCooldown vs NetworkTime
- /// ServerTick + EffectiveAbilityStats.CooldownTicks), a live Husk threat count, and a DOWNED/RESPAWNING overlay
- /// (the derived gate). Presentation only — no simulation, client world only. Bars are
- /// RawImages over Texture2D.whiteTexture (always available; the fill width is the RectTransform's
- /// anchorMax.x), so the HUD needs no sprite assets — only a resolved builtin font for the labels.
+ /// Client-only screen HUD, rebuilt on UI Toolkit so it reads in the same Aether-cyan visual language as the
+ /// menu / pause / settings screens ( / ). A managed presentation
+ /// SystemBase () 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 clicks meant for the game world — only the build-palette buttons pick. Presentation only
+ /// (client world, no simulation). The palette buttons set (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).
///
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class HudSystem : SystemBase
{
- Canvas _canvas;
- RectTransform _healthFill;
- RectTransform _cooldownFill;
- Text _healthText;
- Text _threatText;
- Text _phaseText;
- Text _resourceText;
- Text _locationText;
- RectTransform _goalFill;
- Text _goalText;
- GameObject _respawnOverlay;
+ GameObject _hudGo;
+ UIDocument _doc;
+ bool _built;
+
+ VisualElement _healthFill, _cooldownFill, _goalFill, _downed, _paletteRow;
+ Label _healthText, _threatText, _phaseText, _resourceText, _locationText, _goalText;
+
+ bool _paletteBuilt;
+ readonly Dictionary _palette = new();
+
EntityQuery _huskQuery;
+ struct PaletteItem { public VisualElement Root; public Label Cost; public int CostAmount; }
+
protected override void OnCreate()
{
_huskQuery = GetEntityQuery(ComponentType.ReadOnly());
@@ -39,76 +43,88 @@ namespace ProjectM.Client
protected override void OnStartRunning()
{
- if (_canvas == null) BuildHud();
+ 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 (_canvas != null) Object.Destroy(_canvas.gameObject);
+ if (_hudGo != null) Object.Destroy(_hudGo);
}
protected override void OnUpdate()
{
- if (_canvas == null) return;
+ 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(out var nt);
+ int huskCount = _huskQuery.CalculateEntityCount();
- // Macro-loop HUD (phase + cycle + countdown + location), read before the per-player early-out so it persists pre-spawn.
+ // ---- Macro loop: phase + cycle + countdown ----
bool haveCycle = SystemAPI.TryGetSingleton(out var cyc);
- if (_phaseText != null && haveCycle)
+ if (haveCycle)
{
var endTick = new NetworkTick(cyc.PhaseEndTick);
string detail;
if (cyc.Phase == CyclePhase.Siege)
- detail = "WAVE " + cyc.WaveNumber + " - " + _huskQuery.CalculateEntityCount() + " HUSKS";
+ 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.color = PhaseColor(cyc.Phase);
+ _phaseText.style.color = PhaseColor(cyc.Phase);
}
- else if (_phaseText != null)
+ else
{
_phaseText.text = "";
}
- if (_locationText != null)
- {
- 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.color = onExpedition ? new Color(1f, 0.8f, 0.4f) : new Color(0.6f, 0.85f, 1f);
- }
+ // ---- 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);
- if (_goalFill != null && SystemAPI.TryGetSingleton(out var goal))
+ // ---- Goal ----
+ if (SystemAPI.TryGetSingleton(out var goal))
{
float gfrac = goal.Target > 0 ? Mathf.Clamp01(goal.Charge / (float)goal.Target) : 0f;
- SetFill(_goalFill, gfrac);
- if (_goalText != null) _goalText.text = "GOAL " + goal.Charge + " / " + goal.Target;
+ HudUi.SetFill(_goalFill, gfrac);
+ _goalText.text = "GOAL " + goal.Charge + " / " + goal.Target;
}
- if (_resourceText != null)
+ // ---- Resources (Ore feeds the palette affordability) ----
+ int aether = 0, ore = 0, bio = 0;
+ if (SystemAPI.TryGetSingletonEntity(out var ledgerE))
{
- string res = "";
- if (SystemAPI.TryGetSingletonEntity(out var ledgerE))
+ var buf = SystemAPI.GetBuffer(ledgerE);
+ for (int i = 0; i < buf.Length; i++)
{
- var buf = SystemAPI.GetBuffer(ledgerE);
- int aether = 0, ore = 0, bio = 0;
- 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;
- }
- res = "AETHER " + aether + " ORE " + ore + " BIO " + bio;
+ 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 = res;
}
+ _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;
@@ -123,8 +139,6 @@ namespace ProjectM.Client
maxHp = effChar.ValueRO.MaxHealth > 0f ? effChar.ValueRO.MaxHealth : health.ValueRO.Max;
dead = SystemAPI.IsComponentEnabled(entity);
- // Cooldown fraction via wrap-safe NetworkTick compare (raw uint subtraction is unsafe across
- // tick wraparound — the project convention, mirroring AbilityFireSystem/EnemyAISystem).
uint nextFire = cd.ValueRO.NextFireTick;
int cdTicks = effAbility.ValueRO.CooldownTicks;
var nextTick = new NetworkTick(nextFire);
@@ -138,166 +152,144 @@ namespace ProjectM.Client
break;
}
- _canvas.enabled = found || haveCycle;
- if (!found) return;
+ _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;
- SetFill(_healthFill, frac);
- var hc = _healthFill.GetComponent();
- if (hc != null)
- hc.color = 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);
- if (_healthText != null)
- _healthText.text = Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(maxHp) + (shielded ? " SHIELDED" : "");
+ 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" : "");
- SetFill(_cooldownFill, cdFrac);
-
- if (_threatText != null)
- _threatText.text = "HUSKS " + _huskQuery.CalculateEntityCount();
-
- _respawnOverlay.SetActive(dead);
+ HudUi.SetFill(_cooldownFill, cdFrac);
+ _threatText.text = "HUSKS " + huskCount;
+ _downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
}
- static void SetFill(RectTransform fill, float frac)
+ void UpdatePalette(int ore, bool onExpedition)
{
- if (fill == null) return;
- var max = fill.anchorMax;
- max.x = Mathf.Clamp01(frac);
- fill.anchorMax = max;
+ 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);
+ _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);
+ }
}
- // ---- uGUI construction (code-built; no prefab/sprite assets) ----
-
- void BuildHud()
+ void AddPaletteItem(byte type, int cost)
{
- var go = new GameObject("~HUD");
- _canvas = go.AddComponent