UITK HUD rework + build palette (click-to-place ghost)
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>
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
namespace ProjectM.Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public static class BuildPaletteState
|
||||||
|
{
|
||||||
|
/// <summary>Selected structure type (StructureType.*); 0 = none / build mode off.</summary>
|
||||||
|
public static byte Selected;
|
||||||
|
|
||||||
|
/// <summary>Pending conveyor facing (0=+X,1=-X,2=+Z,3=-Z); rotated by [ / ] or R.</summary>
|
||||||
|
public static byte Direction;
|
||||||
|
|
||||||
|
/// <summary>True while a buildable is selected (build mode active).</summary>
|
||||||
|
public static bool Active => Selected != 0;
|
||||||
|
|
||||||
|
/// <summary>Select a type (or 0 to leave build mode), resetting the pending conveyor facing.</summary>
|
||||||
|
public static void Select(byte type) { Selected = type; Direction = 0; }
|
||||||
|
|
||||||
|
/// <summary>Leave build mode.</summary>
|
||||||
|
public static void Clear() { Selected = 0; Direction = 0; }
|
||||||
|
|
||||||
|
[UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||||
|
static void ResetStatics() { Selected = 0; Direction = 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b0f12fac7a937bf418aaf47eba57cc74
|
||||||
@@ -3,29 +3,53 @@ using Unity.Entities;
|
|||||||
using Unity.Mathematics;
|
using Unity.Mathematics;
|
||||||
using Unity.NetCode;
|
using Unity.NetCode;
|
||||||
using Unity.Transforms;
|
using Unity.Transforms;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
namespace ProjectM.Client
|
namespace ProjectM.Client
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Client-only sender for build + upgrade RPCs. Keyboard: B builds a Turret at the local player's current
|
/// Client-only build input + RPC sender. Two ways to build:
|
||||||
/// grid cell; U upgrades ability damage. Editor-only statics (PlaceStructure / PlaceTurret / UpgradeAbility)
|
/// (1) the HUD build PALETTE (primary): a selected buildable (<see cref="BuildPaletteState"/>) drives a ground
|
||||||
/// drive the same path from execute_code for headless validation (a one-shot key can't be injected on an
|
/// GHOST preview at the cursor cell — green when valid (in-plot, unoccupied, affordable; the same legality the
|
||||||
/// unfocused editor — the StorageOpSendSystem idiom). Managed SystemBase (reads the Input System);
|
/// server re-validates), red otherwise — and a LEFT-CLICK places it; right-click / Esc cancels; [ / ] or R
|
||||||
/// UnityEngine.InputSystem is fully qualified to avoid the ProjectM.Simulation.PlayerInput name collision.
|
/// rotates a conveyor's facing. Fire is suppressed while build mode is active (PlayerInputGatherSystem reads
|
||||||
/// The server re-validates legality + cost authoritatively; this only sends a hint.
|
/// <see cref="BuildPaletteState.Active"/>), 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||||
public partial class BuildSendSystem : SystemBase
|
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
|
#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<PendingBuild> s_PendingBuild =
|
static readonly System.Collections.Generic.Queue<PendingBuild> s_PendingBuild =
|
||||||
new System.Collections.Generic.Queue<PendingBuild>();
|
new System.Collections.Generic.Queue<PendingBuild>();
|
||||||
static int s_PendingUpgrades = 0;
|
static int s_PendingUpgrades = 0;
|
||||||
|
|
||||||
/// <summary>EDITOR / execute_code hook: queue a structure placement at a specific cell.</summary>
|
/// <summary>EDITOR / execute_code hook: queue a structure placement at a specific cell.</summary>
|
||||||
public static void PlaceStructure(byte type, int cellX, int 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 });
|
s_PendingBuild.Enqueue(new PendingBuild { Type = type, CellX = cellX, CellZ = cellZ, Direction = direction });
|
||||||
|
|
||||||
/// <summary>EDITOR / execute_code hook: queue a turret placement at a specific cell.</summary>
|
/// <summary>EDITOR / execute_code hook: queue a turret placement at a specific cell.</summary>
|
||||||
public static void PlaceTurret(int cellX, int cellZ) => PlaceStructure(StructureType.Turret, cellX, cellZ);
|
public static void PlaceTurret(int cellX, int cellZ) => PlaceStructure(StructureType.Turret, cellX, cellZ);
|
||||||
@@ -36,6 +60,15 @@ namespace ProjectM.Client
|
|||||||
/// <summary>EDITOR / execute_code hook: queue a pylon placement at a specific cell.</summary>
|
/// <summary>EDITOR / execute_code hook: queue a pylon placement at a specific cell.</summary>
|
||||||
public static void PlacePylon(int cellX, int cellZ) => PlaceStructure(StructureType.Pylon, cellX, cellZ);
|
public static void PlacePylon(int cellX, int cellZ) => PlaceStructure(StructureType.Pylon, cellX, cellZ);
|
||||||
|
|
||||||
|
/// <summary>EDITOR / execute_code hook: queue a harvester placement at a specific cell.</summary>
|
||||||
|
public static void PlaceHarvester(int cellX, int cellZ) => PlaceStructure(StructureType.Harvester, cellX, cellZ);
|
||||||
|
|
||||||
|
/// <summary>EDITOR / execute_code hook: queue a fabricator placement at a specific cell.</summary>
|
||||||
|
public static void PlaceFabricator(int cellX, int cellZ) => PlaceStructure(StructureType.Fabricator, cellX, cellZ);
|
||||||
|
|
||||||
|
/// <summary>EDITOR / execute_code hook: queue a conveyor placement facing direction (0=+X,1=-X,2=+Z,3=-Z).</summary>
|
||||||
|
public static void PlaceConveyor(int cellX, int cellZ, byte direction) => PlaceStructure(StructureType.Conveyor, cellX, cellZ, direction);
|
||||||
|
|
||||||
/// <summary>EDITOR / execute_code hook: queue an ability-damage upgrade.</summary>
|
/// <summary>EDITOR / execute_code hook: queue an ability-damage upgrade.</summary>
|
||||||
public static void UpgradeAbility() => s_PendingUpgrades++;
|
public static void UpgradeAbility() => s_PendingUpgrades++;
|
||||||
#endif
|
#endif
|
||||||
@@ -45,20 +78,31 @@ namespace ProjectM.Client
|
|||||||
RequireForUpdate<NetworkId>();
|
RequireForUpdate<NetworkId>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void OnDestroy()
|
||||||
|
{
|
||||||
|
if (_ghost != null) Object.Destroy(_ghost);
|
||||||
|
if (_ghostMat != null) Object.Destroy(_ghostMat);
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnUpdate()
|
protected override void OnUpdate()
|
||||||
{
|
{
|
||||||
if (!SystemAPI.TryGetSingletonEntity<NetworkId>(out var connection))
|
if (!SystemAPI.TryGetSingletonEntity<NetworkId>(out var connection))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
HandleBuildMode(connection);
|
||||||
|
|
||||||
|
// Hotkey fallback (suppressed while the palette build mode is active).
|
||||||
var keyboard = UnityEngine.InputSystem.Keyboard.current;
|
var keyboard = UnityEngine.InputSystem.Keyboard.current;
|
||||||
if (keyboard != null)
|
if (keyboard != null && !BuildPaletteState.Active)
|
||||||
{
|
{
|
||||||
if (keyboard.bKey.wasPressedThisFrame && TryGetLocalPlayerCell(out int2 cell))
|
if (keyboard.leftBracketKey.wasPressedThisFrame)
|
||||||
SendBuild(connection, StructureType.Turret, cell.x, cell.y);
|
s_ConveyorDir = (byte)((s_ConveyorDir + 3) % 4);
|
||||||
if (keyboard.vKey.wasPressedThisFrame && TryGetLocalPlayerCell(out int2 wcell))
|
if (keyboard.rightBracketKey.wasPressedThisFrame)
|
||||||
SendBuild(connection, StructureType.Wall, wcell.x, wcell.y);
|
s_ConveyorDir = (byte)((s_ConveyorDir + 1) % 4);
|
||||||
if (keyboard.nKey.wasPressedThisFrame && TryGetLocalPlayerCell(out int2 pcell))
|
|
||||||
SendBuild(connection, StructureType.Pylon, pcell.x, pcell.y);
|
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)
|
if (keyboard.uKey.wasPressedThisFrame)
|
||||||
SendUpgrade(connection);
|
SendUpgrade(connection);
|
||||||
}
|
}
|
||||||
@@ -67,7 +111,7 @@ namespace ProjectM.Client
|
|||||||
while (s_PendingBuild.Count > 0)
|
while (s_PendingBuild.Count > 0)
|
||||||
{
|
{
|
||||||
var b = s_PendingBuild.Dequeue();
|
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)
|
while (s_PendingUpgrades > 0)
|
||||||
{
|
{
|
||||||
@@ -77,6 +121,124 @@ namespace ProjectM.Client
|
|||||||
#endif
|
#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<BaseAnchor>(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<PlacedStructure>, RefRO<LocalTransform>>())
|
||||||
|
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<ResourceLedger>(out var le)) return 0;
|
||||||
|
var buf = SystemAPI.GetBuffer<StorageEntry>(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<StructureCatalog>(out var ce)) return int.MaxValue;
|
||||||
|
var cat = SystemAPI.GetBuffer<StructureCatalogEntry>(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<Collider>();
|
||||||
|
if (col != null) Object.Destroy(col);
|
||||||
|
var mr = _ghost.GetComponent<MeshRenderer>();
|
||||||
|
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<PrototypeCameraRig>();
|
||||||
|
if (rig != null) cam = rig.GetComponent<UnityEngine.Camera>();
|
||||||
|
}
|
||||||
|
return cam;
|
||||||
|
}
|
||||||
|
|
||||||
bool TryGetLocalPlayerCell(out int2 cell)
|
bool TryGetLocalPlayerCell(out int2 cell)
|
||||||
{
|
{
|
||||||
cell = default;
|
cell = default;
|
||||||
@@ -90,10 +252,10 @@ namespace ProjectM.Client
|
|||||||
return false;
|
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();
|
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 });
|
EntityManager.AddComponentData(e, new SendRpcCommandRequest { TargetConnection = connection });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ namespace ProjectM.Client
|
|||||||
|
|
||||||
// Movement is source-agnostic (WASD or left stick) — read from the merged action.
|
// Movement is source-agnostic (WASD or left stick) — read from the merged action.
|
||||||
float2 move = (float2)gameplay.Move.ReadValue<UnityEngine.Vector2>();
|
float2 move = (float2)gameplay.Move.ReadValue<UnityEngine.Vector2>();
|
||||||
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 ---
|
// --- Active-device detection: last meaningful actuation wins; hold last when idle ---
|
||||||
var gamepad = UnityEngine.InputSystem.Gamepad.current;
|
var gamepad = UnityEngine.InputSystem.Gamepad.current;
|
||||||
|
|||||||
@@ -343,6 +343,7 @@ namespace ProjectM.Client
|
|||||||
tm.anchor = TextAnchor.MiddleCenter;
|
tm.anchor = TextAnchor.MiddleCenter;
|
||||||
tm.alignment = TextAlignment.Center;
|
tm.alignment = TextAlignment.Center;
|
||||||
tm.color = Color.white;
|
tm.color = Color.white;
|
||||||
|
tm.fontStyle = FontStyle.Bold;
|
||||||
go.SetActive(false);
|
go.SetActive(false);
|
||||||
return new FloatingNumber { Tm = tm, Tr = go.transform, Active = false };
|
return new FloatingNumber { Tm = tm, Tr = go.transform, Active = false };
|
||||||
}
|
}
|
||||||
@@ -358,7 +359,7 @@ namespace ProjectM.Client
|
|||||||
fn.Age = 0f;
|
fn.Age = 0f;
|
||||||
fn.Life = 0.7f;
|
fn.Life = 0.7f;
|
||||||
fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString();
|
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.Tm.color = fn.BaseColor;
|
||||||
fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f);
|
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);
|
fn.Vel = new Vector3(0f, 2.2f, 0f);
|
||||||
@@ -478,7 +479,7 @@ namespace ProjectM.Client
|
|||||||
static void PlayClip(AudioClip clip, Vector3 pos, float vol)
|
static void PlayClip(AudioClip clip, Vector3 pos, float vol)
|
||||||
{
|
{
|
||||||
if (clip == null) return;
|
if (clip == null) return;
|
||||||
AudioSource.PlayClipAtPoint(clip, pos, vol);
|
AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,41 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
using ProjectM.Simulation;
|
using ProjectM.Simulation;
|
||||||
using Unity.Entities;
|
using Unity.Entities;
|
||||||
using Unity.NetCode;
|
using Unity.NetCode;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.UI;
|
using UnityEngine.UIElements;
|
||||||
|
|
||||||
namespace ProjectM.Client
|
namespace ProjectM.Client
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Client-only screen HUD. A managed presentation SystemBase (<see cref="PresentationSystemGroup"/>) that builds
|
/// Client-only screen HUD, rebuilt on UI Toolkit so it reads in the same Aether-cyan visual language as the
|
||||||
/// a uGUI overlay canvas in code and drives it from the LOCAL player ghost each frame: a health bar
|
/// menu / pause / settings screens (<see cref="MenuUi"/> / <see cref="HudUi"/>). A managed presentation
|
||||||
/// (Health / EffectiveCharacterStats.MaxHealth), an ability-cooldown bar (AbilityCooldown vs NetworkTime
|
/// SystemBase (<see cref="PresentationSystemGroup"/>) that OBSERVES the local player ghost + the global
|
||||||
/// ServerTick + EffectiveAbilityStats.CooldownTicks), a live Husk threat count, and a DOWNED/RESPAWNING overlay
|
/// cycle / ledger / goal each frame and pushes values into a runtime UIDocument (shared PanelSettings,
|
||||||
/// (the derived <see cref="Dead"/> gate). Presentation only — no simulation, client world only. Bars are
|
/// sortingOrder 50 so it sits BEHIND the pause overlay's 100). The root is <c>pickingMode = Ignore</c> so the
|
||||||
/// RawImages over <c>Texture2D.whiteTexture</c> (always available; the fill width is the RectTransform's
|
/// HUD never eats clicks meant for the game world — only the build-palette buttons pick. Presentation only
|
||||||
/// anchorMax.x), so the HUD needs no sprite assets — only a resolved builtin font for the labels.
|
/// (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>
|
/// </summary>
|
||||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||||
public partial class HudSystem : SystemBase
|
public partial class HudSystem : SystemBase
|
||||||
{
|
{
|
||||||
Canvas _canvas;
|
GameObject _hudGo;
|
||||||
RectTransform _healthFill;
|
UIDocument _doc;
|
||||||
RectTransform _cooldownFill;
|
bool _built;
|
||||||
Text _healthText;
|
|
||||||
Text _threatText;
|
VisualElement _healthFill, _cooldownFill, _goalFill, _downed, _paletteRow;
|
||||||
Text _phaseText;
|
Label _healthText, _threatText, _phaseText, _resourceText, _locationText, _goalText;
|
||||||
Text _resourceText;
|
|
||||||
Text _locationText;
|
bool _paletteBuilt;
|
||||||
RectTransform _goalFill;
|
readonly Dictionary<byte, PaletteItem> _palette = new();
|
||||||
Text _goalText;
|
|
||||||
GameObject _respawnOverlay;
|
|
||||||
EntityQuery _huskQuery;
|
EntityQuery _huskQuery;
|
||||||
|
|
||||||
|
struct PaletteItem { public VisualElement Root; public Label Cost; public int CostAmount; }
|
||||||
|
|
||||||
protected override void OnCreate()
|
protected override void OnCreate()
|
||||||
{
|
{
|
||||||
_huskQuery = GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
|
_huskQuery = GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
|
||||||
@@ -39,64 +43,74 @@ namespace ProjectM.Client
|
|||||||
|
|
||||||
protected override void OnStartRunning()
|
protected override void OnStartRunning()
|
||||||
{
|
{
|
||||||
if (_canvas == null) BuildHud();
|
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()
|
protected override void OnDestroy()
|
||||||
{
|
{
|
||||||
if (_canvas != null) Object.Destroy(_canvas.gameObject);
|
if (_hudGo != null) Object.Destroy(_hudGo);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnUpdate()
|
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<NetworkTime>(out var nt);
|
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(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<CycleState>(out var cyc);
|
bool haveCycle = SystemAPI.TryGetSingleton<CycleState>(out var cyc);
|
||||||
if (_phaseText != null && haveCycle)
|
if (haveCycle)
|
||||||
{
|
{
|
||||||
var endTick = new NetworkTick(cyc.PhaseEndTick);
|
var endTick = new NetworkTick(cyc.PhaseEndTick);
|
||||||
string detail;
|
string detail;
|
||||||
if (cyc.Phase == CyclePhase.Siege)
|
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))
|
else if (haveTick && cyc.PhaseEndTick != 0 && endTick.IsValid && endTick.IsNewerThan(nt.ServerTick))
|
||||||
detail = "INCURSION IN " + (endTick.TicksSince(nt.ServerTick) / 60 + 1) + "s";
|
detail = "INCURSION IN " + (endTick.TicksSince(nt.ServerTick) / 60 + 1) + "s";
|
||||||
else
|
else
|
||||||
detail = "";
|
detail = "";
|
||||||
_phaseText.text = PhaseLabel(cyc.Phase) + (detail.Length > 0 ? " - " + 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 = "";
|
_phaseText.text = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_locationText != null)
|
// ---- Location + gate hint ----
|
||||||
{
|
|
||||||
var cam = Camera.main;
|
var cam = Camera.main;
|
||||||
bool onExpedition = cam != null && cam.transform.position.x > 500f;
|
bool onExpedition = cam != null && cam.transform.position.x > 500f;
|
||||||
_locationText.text = onExpedition
|
_locationText.text = onExpedition
|
||||||
? "ON EXPEDITION - return through the gate"
|
? "ON EXPEDITION - return through the gate"
|
||||||
: "AT BASE - deploy through the gate when you're ready";
|
: "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);
|
_locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f) : new Color(0.6f, 0.85f, 1f);
|
||||||
}
|
|
||||||
|
|
||||||
if (_goalFill != null && SystemAPI.TryGetSingleton<GoalProgress>(out var goal))
|
// ---- Goal ----
|
||||||
|
if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal))
|
||||||
{
|
{
|
||||||
float gfrac = goal.Target > 0 ? Mathf.Clamp01(goal.Charge / (float)goal.Target) : 0f;
|
float gfrac = goal.Target > 0 ? Mathf.Clamp01(goal.Charge / (float)goal.Target) : 0f;
|
||||||
SetFill(_goalFill, gfrac);
|
HudUi.SetFill(_goalFill, gfrac);
|
||||||
if (_goalText != null) _goalText.text = "GOAL " + goal.Charge + " / " + goal.Target;
|
_goalText.text = "GOAL " + goal.Charge + " / " + goal.Target;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_resourceText != null)
|
// ---- Resources (Ore feeds the palette affordability) ----
|
||||||
{
|
int aether = 0, ore = 0, bio = 0;
|
||||||
string res = "";
|
|
||||||
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerE))
|
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerE))
|
||||||
{
|
{
|
||||||
var buf = SystemAPI.GetBuffer<StorageEntry>(ledgerE);
|
var buf = SystemAPI.GetBuffer<StorageEntry>(ledgerE);
|
||||||
int aether = 0, ore = 0, bio = 0;
|
|
||||||
for (int i = 0; i < buf.Length; i++)
|
for (int i = 0; i < buf.Length; i++)
|
||||||
{
|
{
|
||||||
var en = buf[i];
|
var en = buf[i];
|
||||||
@@ -104,11 +118,13 @@ namespace ProjectM.Client
|
|||||||
else if (en.ItemId == ResourceId.Ore) ore = 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.Biomass) bio = en.Count;
|
||||||
}
|
}
|
||||||
res = "AETHER " + aether + " ORE " + ore + " BIO " + bio;
|
|
||||||
}
|
|
||||||
_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;
|
bool found = false;
|
||||||
float hp = 0f, maxHp = 1f, cdFrac = 1f;
|
float hp = 0f, maxHp = 1f, cdFrac = 1f;
|
||||||
bool dead = false, shielded = false;
|
bool dead = false, shielded = false;
|
||||||
@@ -123,8 +139,6 @@ namespace ProjectM.Client
|
|||||||
maxHp = effChar.ValueRO.MaxHealth > 0f ? effChar.ValueRO.MaxHealth : health.ValueRO.Max;
|
maxHp = effChar.ValueRO.MaxHealth > 0f ? effChar.ValueRO.MaxHealth : health.ValueRO.Max;
|
||||||
dead = SystemAPI.IsComponentEnabled<Dead>(entity);
|
dead = SystemAPI.IsComponentEnabled<Dead>(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;
|
uint nextFire = cd.ValueRO.NextFireTick;
|
||||||
int cdTicks = effAbility.ValueRO.CooldownTicks;
|
int cdTicks = effAbility.ValueRO.CooldownTicks;
|
||||||
var nextTick = new NetworkTick(nextFire);
|
var nextTick = new NetworkTick(nextFire);
|
||||||
@@ -138,166 +152,144 @@ namespace ProjectM.Client
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
_canvas.enabled = found || haveCycle;
|
_doc.rootVisualElement.style.display = (found || haveCycle) ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
if (!found) return;
|
if (!found) { _downed.style.display = DisplayStyle.None; return; }
|
||||||
|
|
||||||
float frac = maxHp > 0f ? Mathf.Clamp01(hp / maxHp) : 0f;
|
float frac = maxHp > 0f ? Mathf.Clamp01(hp / maxHp) : 0f;
|
||||||
SetFill(_healthFill, frac);
|
HudUi.SetFill(_healthFill, frac);
|
||||||
var hc = _healthFill.GetComponent<RawImage>();
|
_healthFill.style.backgroundColor = shielded
|
||||||
if (hc != null)
|
|
||||||
hc.color = shielded
|
|
||||||
? new Color(0.45f, 0.85f, 1f)
|
? 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);
|
: 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" : "");
|
_healthText.text = Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(maxHp) + (shielded ? " SHIELDED" : "");
|
||||||
|
|
||||||
SetFill(_cooldownFill, cdFrac);
|
HudUi.SetFill(_cooldownFill, cdFrac);
|
||||||
|
_threatText.text = "HUSKS " + huskCount;
|
||||||
if (_threatText != null)
|
_downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
_threatText.text = "HUSKS " + _huskQuery.CalculateEntityCount();
|
|
||||||
|
|
||||||
_respawnOverlay.SetActive(dead);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void SetFill(RectTransform fill, float frac)
|
void UpdatePalette(int ore, bool onExpedition)
|
||||||
{
|
{
|
||||||
if (fill == null) return;
|
if (!_paletteBuilt && SystemAPI.TryGetSingletonEntity<StructureCatalog>(out var catE))
|
||||||
var max = fill.anchorMax;
|
{
|
||||||
max.x = Mathf.Clamp01(frac);
|
var cat = SystemAPI.GetBuffer<StructureCatalogEntry>(catE);
|
||||||
fill.anchorMax = max;
|
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 AddPaletteItem(byte type, int cost)
|
||||||
|
|
||||||
void BuildHud()
|
|
||||||
{
|
{
|
||||||
var go = new GameObject("~HUD");
|
if (type == 0 || _palette.ContainsKey(type)) return;
|
||||||
_canvas = go.AddComponent<Canvas>();
|
var root = new VisualElement();
|
||||||
_canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
root.style.width = 92;
|
||||||
_canvas.sortingOrder = 100;
|
root.style.marginLeft = 4; root.style.marginRight = 4;
|
||||||
var scaler = go.AddComponent<CanvasScaler>();
|
root.style.paddingTop = 6; root.style.paddingBottom = 6;
|
||||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
root.style.alignItems = Align.Center;
|
||||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
root.style.backgroundColor = new Color(0.09f, 0.11f, 0.15f, 0.92f);
|
||||||
go.AddComponent<GraphicRaycaster>();
|
root.pickingMode = PickingMode.Position;
|
||||||
|
MenuUi.Round(root, 6);
|
||||||
|
MenuUi.Border(root, new Color(1f, 1f, 1f, 0.08f), 1);
|
||||||
|
|
||||||
var font = GetFont();
|
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);
|
||||||
|
|
||||||
// Health bar (bottom-left).
|
byte t = type;
|
||||||
var hpBg = MakeBar("HealthBg", _canvas.transform, new Color(0f, 0f, 0f, 0.6f),
|
root.RegisterCallback<ClickEvent>(_ =>
|
||||||
new Vector2(40, 46), new Vector2(440, 40));
|
BuildPaletteState.Select(BuildPaletteState.Selected == t ? (byte)0 : t));
|
||||||
_healthFill = MakeFill("HealthFill", hpBg, new Color(0.25f, 0.9f, 0.5f));
|
|
||||||
_healthText = MakeText("HealthText", hpBg, "100 / 100", 24, TextAnchor.MiddleCenter, Color.white, font);
|
|
||||||
|
|
||||||
// Cooldown bar (just above health).
|
_paletteRow.Add(root);
|
||||||
var cdBg = MakeBar("CooldownBg", _canvas.transform, new Color(0f, 0f, 0f, 0.55f),
|
_palette[type] = new PaletteItem { Root = root, Cost = costLabel, CostAmount = cost };
|
||||||
new Vector2(40, 92), new Vector2(440, 14));
|
|
||||||
_cooldownFill = MakeFill("CooldownFill", cdBg, new Color(0.4f, 0.8f, 1f));
|
|
||||||
|
|
||||||
// Threat count (top-right).
|
|
||||||
_threatText = MakeText("ThreatText", _canvas.transform, "HUSKS 0", 30, TextAnchor.UpperRight,
|
|
||||||
new Color(1f, 0.62f, 0.4f), font);
|
|
||||||
var trt = _threatText.rectTransform;
|
|
||||||
trt.anchorMin = new Vector2(1, 1); trt.anchorMax = new Vector2(1, 1); trt.pivot = new Vector2(1, 1);
|
|
||||||
trt.anchoredPosition = new Vector2(-40, -30); trt.sizeDelta = new Vector2(380, 50);
|
|
||||||
|
|
||||||
// Cycle phase + number (top-center).
|
|
||||||
_phaseText = MakeText("PhaseText", _canvas.transform, "EXPEDITION CYCLE 1", 34, TextAnchor.UpperCenter,
|
|
||||||
new Color(0.55f, 0.9f, 1f), font);
|
|
||||||
var prt = _phaseText.rectTransform;
|
|
||||||
prt.anchorMin = new Vector2(0.5f, 1f); prt.anchorMax = new Vector2(0.5f, 1f); prt.pivot = new Vector2(0.5f, 1f);
|
|
||||||
prt.anchoredPosition = new Vector2(0, -24); prt.sizeDelta = new Vector2(600, 50);
|
|
||||||
|
|
||||||
// Resource ledger counts (top-center, below phase).
|
|
||||||
_resourceText = MakeText("ResourceText", _canvas.transform, "", 24, TextAnchor.UpperCenter,
|
|
||||||
new Color(0.7f, 0.95f, 0.8f), font);
|
|
||||||
var rrt = _resourceText.rectTransform;
|
|
||||||
rrt.anchorMin = new Vector2(0.5f, 1f); rrt.anchorMax = new Vector2(0.5f, 1f); rrt.pivot = new Vector2(0.5f, 1f);
|
|
||||||
rrt.anchoredPosition = new Vector2(0, -64); rrt.sizeDelta = new Vector2(600, 40);
|
|
||||||
|
|
||||||
// Location + gate hint (top-center, below resources).
|
|
||||||
_locationText = MakeText("LocationText", _canvas.transform, "", 22, TextAnchor.UpperCenter,
|
|
||||||
new Color(0.6f, 0.85f, 1f), font);
|
|
||||||
var lrt = _locationText.rectTransform;
|
|
||||||
lrt.anchorMin = new Vector2(0.5f, 1f); lrt.anchorMax = new Vector2(0.5f, 1f); lrt.pivot = new Vector2(0.5f, 1f);
|
|
||||||
lrt.anchoredPosition = new Vector2(0, -96); lrt.sizeDelta = new Vector2(760, 36);
|
|
||||||
// Goal progress bar (top-center, below the location line).
|
|
||||||
var goalBg = MakeBar("GoalBg", _canvas.transform, new Color(0f, 0f, 0f, 0.55f), Vector2.zero, new Vector2(360, 16));
|
|
||||||
goalBg.anchorMin = new Vector2(0.5f, 1f); goalBg.anchorMax = new Vector2(0.5f, 1f); goalBg.pivot = new Vector2(0.5f, 1f);
|
|
||||||
goalBg.anchoredPosition = new Vector2(0, -126); goalBg.sizeDelta = new Vector2(360, 16);
|
|
||||||
_goalFill = MakeFill("GoalFill", goalBg, new Color(0.8f, 0.6f, 1f));
|
|
||||||
_goalText = MakeText("GoalText", goalBg, "GOAL 0 / 10", 15, TextAnchor.MiddleCenter, Color.white, font);
|
|
||||||
|
|
||||||
// Downed / respawning overlay (full screen, toggled by Dead).
|
|
||||||
_respawnOverlay = new GameObject("RespawnOverlay", typeof(RectTransform));
|
|
||||||
_respawnOverlay.transform.SetParent(_canvas.transform, false);
|
|
||||||
var ov = _respawnOverlay.AddComponent<RawImage>();
|
|
||||||
ov.texture = Texture2D.whiteTexture;
|
|
||||||
ov.color = new Color(0.35f, 0f, 0f, 0.35f);
|
|
||||||
ov.raycastTarget = false;
|
|
||||||
Stretch((RectTransform)_respawnOverlay.transform);
|
|
||||||
var rtext = MakeText("RespawnText", _respawnOverlay.transform, "DOWNED - RESPAWNING", 56,
|
|
||||||
TextAnchor.MiddleCenter, new Color(1f, 0.45f, 0.4f), font);
|
|
||||||
Stretch(rtext.rectTransform);
|
|
||||||
_respawnOverlay.SetActive(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static RectTransform MakeBar(string name, Transform parent, Color color, Vector2 anchoredPos, Vector2 size)
|
// ---- UITK construction ----
|
||||||
{
|
|
||||||
var go = new GameObject(name, typeof(RectTransform));
|
|
||||||
go.transform.SetParent(parent, false);
|
|
||||||
var img = go.AddComponent<RawImage>();
|
|
||||||
img.texture = Texture2D.whiteTexture;
|
|
||||||
img.color = color;
|
|
||||||
img.raycastTarget = false;
|
|
||||||
var rt = (RectTransform)go.transform;
|
|
||||||
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.zero; rt.pivot = Vector2.zero;
|
|
||||||
rt.anchoredPosition = anchoredPos; rt.sizeDelta = size;
|
|
||||||
return rt;
|
|
||||||
}
|
|
||||||
|
|
||||||
static RectTransform MakeFill(string name, RectTransform parent, Color color)
|
void BuildTree(VisualElement root)
|
||||||
{
|
{
|
||||||
var go = new GameObject(name, typeof(RectTransform));
|
root.style.position = Position.Absolute;
|
||||||
go.transform.SetParent(parent, false);
|
root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0;
|
||||||
var img = go.AddComponent<RawImage>();
|
root.pickingMode = PickingMode.Ignore; // never eat game-world clicks
|
||||||
img.texture = Texture2D.whiteTexture;
|
|
||||||
img.color = color;
|
|
||||||
img.raycastTarget = false;
|
|
||||||
var rt = (RectTransform)go.transform;
|
|
||||||
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; rt.pivot = Vector2.zero;
|
|
||||||
rt.offsetMin = new Vector2(3, 3); rt.offsetMax = new Vector2(-3, -3);
|
|
||||||
return rt;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Text MakeText(string name, Transform parent, string text, int size, TextAnchor anchor, Color color, Font font)
|
// Health + cooldown (bottom-left).
|
||||||
{
|
var vitals = HudUi.Group();
|
||||||
var go = new GameObject(name, typeof(RectTransform));
|
vitals.style.position = Position.Absolute;
|
||||||
go.transform.SetParent(parent, false);
|
vitals.style.left = 40; vitals.style.bottom = 40;
|
||||||
var t = go.AddComponent<Text>();
|
var cdBar = HudUi.Bar(440, 12, new Color(0.4f, 0.8f, 1f), out _cooldownFill);
|
||||||
t.text = text;
|
cdBar.style.marginBottom = 6;
|
||||||
t.font = font;
|
var hpBar = HudUi.Bar(440, 40, new Color(0.25f, 0.9f, 0.5f), out _healthFill);
|
||||||
t.fontSize = size;
|
_healthText = HudUi.Text("100 / 100", 22, Color.white, TextAnchor.MiddleCenter);
|
||||||
t.alignment = anchor;
|
_healthText.style.position = Position.Absolute;
|
||||||
t.color = color;
|
_healthText.style.left = 0; _healthText.style.right = 0; _healthText.style.top = 0; _healthText.style.bottom = 0;
|
||||||
t.horizontalOverflow = HorizontalWrapMode.Overflow;
|
hpBar.Add(_healthText);
|
||||||
t.verticalOverflow = VerticalWrapMode.Overflow;
|
vitals.Add(cdBar);
|
||||||
t.raycastTarget = false;
|
vitals.Add(hpBar);
|
||||||
Stretch(t.rectTransform);
|
root.Add(vitals);
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void Stretch(RectTransform rt)
|
// Threat (top-right).
|
||||||
{
|
_threatText = HudUi.Text("HUSKS 0", 30, new Color(1f, 0.62f, 0.4f), TextAnchor.UpperRight);
|
||||||
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
|
_threatText.style.position = Position.Absolute;
|
||||||
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
|
_threatText.style.right = 40; _threatText.style.top = 28; _threatText.style.width = 380;
|
||||||
}
|
root.Add(_threatText);
|
||||||
|
|
||||||
static Font GetFont()
|
// Macro stack (top-center).
|
||||||
{
|
var macro = HudUi.Group(Align.Center);
|
||||||
Font f = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
|
macro.style.position = Position.Absolute;
|
||||||
if (f == null) f = Resources.GetBuiltinResource<Font>("Arial.ttf");
|
macro.style.top = 22; macro.style.left = 0; macro.style.right = 0;
|
||||||
if (f == null) f = Font.CreateDynamicFontFromOSFont(new[] { "Arial", "Liberation Sans", "DejaVu Sans" }, 28);
|
_phaseText = HudUi.Text("", 30, new Color(0.55f, 0.9f, 1f), TextAnchor.MiddleCenter);
|
||||||
return f;
|
_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)
|
static Color PhaseColor(byte phase)
|
||||||
@@ -319,5 +311,19 @@ namespace ProjectM.Client
|
|||||||
default: return "";
|
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 "?";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ namespace ProjectM.Client
|
|||||||
static void PlayClip(AudioClip clip, Vector3 pos, float vol)
|
static void PlayClip(AudioClip clip, Vector3 pos, float vol)
|
||||||
{
|
{
|
||||||
if (clip == null) return;
|
if (clip == null) return;
|
||||||
AudioSource.PlayClipAtPoint(clip, pos, vol);
|
AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
|
||||||
|
namespace ProjectM.Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// UI Toolkit factories for the in-game HUD — a thin extension of <see cref="MenuUi"/>'s Aether palette so the
|
||||||
|
/// HUD reads in the same visual language as the menu / pause / settings screens. Bars are a dark rounded track
|
||||||
|
/// + a percent-width fill; labels use the shared text weights/colours. Every element is built
|
||||||
|
/// <c>pickingMode = Ignore</c> by default so the HUD never eats clicks meant for the game world (only the
|
||||||
|
/// interactive build-palette buttons opt back into picking).
|
||||||
|
/// </summary>
|
||||||
|
public static class HudUi
|
||||||
|
{
|
||||||
|
public static readonly Color Track = new(0f, 0f, 0f, 0.55f);
|
||||||
|
|
||||||
|
/// <summary>A dark rounded bar track with a percent-width fill child (returned via <paramref name="fill"/>).</summary>
|
||||||
|
public static VisualElement Bar(float width, float height, Color fillColor, out VisualElement fill)
|
||||||
|
{
|
||||||
|
var track = new VisualElement();
|
||||||
|
track.style.width = width;
|
||||||
|
track.style.height = height;
|
||||||
|
track.style.backgroundColor = Track;
|
||||||
|
track.style.paddingLeft = 2; track.style.paddingRight = 2;
|
||||||
|
track.style.paddingTop = 2; track.style.paddingBottom = 2;
|
||||||
|
track.style.flexDirection = FlexDirection.Row;
|
||||||
|
track.pickingMode = PickingMode.Ignore;
|
||||||
|
MenuUi.Round(track, 4);
|
||||||
|
|
||||||
|
fill = new VisualElement();
|
||||||
|
fill.style.height = Length.Percent(100);
|
||||||
|
fill.style.width = Length.Percent(100);
|
||||||
|
fill.style.backgroundColor = fillColor;
|
||||||
|
fill.pickingMode = PickingMode.Ignore;
|
||||||
|
MenuUi.Round(fill, 3);
|
||||||
|
track.Add(fill);
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A bold HUD label (non-interactive).</summary>
|
||||||
|
public static Label Text(string text, int size, Color color, TextAnchor align)
|
||||||
|
{
|
||||||
|
var l = new Label(text);
|
||||||
|
l.style.fontSize = size;
|
||||||
|
l.style.color = color;
|
||||||
|
l.style.unityTextAlign = align;
|
||||||
|
l.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||||
|
l.pickingMode = PickingMode.Ignore;
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Set a fill's width to a 0..1 fraction of its track.</summary>
|
||||||
|
public static void SetFill(VisualElement fill, float frac)
|
||||||
|
{
|
||||||
|
if (fill != null) fill.style.width = Length.Percent(Mathf.Clamp01(frac) * 100f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A semi-transparent rounded panel for grouping a cluster of HUD elements.</summary>
|
||||||
|
public static VisualElement Group(Align items = Align.FlexStart)
|
||||||
|
{
|
||||||
|
var g = new VisualElement();
|
||||||
|
g.style.alignItems = items;
|
||||||
|
g.pickingMode = PickingMode.Ignore;
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f598e45d8e87c234186aaec809996041
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Unity.Mathematics;
|
||||||
|
|
||||||
|
namespace ProjectM.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pure validity check for the client build-placement PREVIEW (the ground-ghost colour) — the same legality
|
||||||
|
/// the server re-validates authoritatively in BuildPlaceSystem, computed client-side so the ghost can read
|
||||||
|
/// green (valid) vs red (why-not). No managed types / RNG / wall-clock → unit-testable. The caller supplies
|
||||||
|
/// the live occupancy result + the affordability inputs (it owns the structure scan + the ledger read).
|
||||||
|
/// </summary>
|
||||||
|
public static class BuildPreviewMath
|
||||||
|
{
|
||||||
|
public const byte Valid = 0;
|
||||||
|
public const byte OutOfPlot = 1;
|
||||||
|
public const byte Occupied = 2;
|
||||||
|
public const byte Unaffordable = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluate placement at <paramref name="cell"/>: must be in-plot, unoccupied, and affordable.
|
||||||
|
/// <paramref name="occupied"/> = the caller's live-structure cell check; <paramref name="have"/>/<paramref name="cost"/>
|
||||||
|
/// the resource on hand vs the catalog cost. Returns the first failing reason, else <see cref="Valid"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static byte Evaluate(in BaseAnchor anchor, int2 cell, bool occupied, int have, int cost)
|
||||||
|
{
|
||||||
|
if (!BaseGridMath.IsCellInPlot(anchor, cell)) return OutOfPlot;
|
||||||
|
if (occupied) return Occupied;
|
||||||
|
if (have < cost) return Unaffordable;
|
||||||
|
return Valid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4deb41f803f65bd4b946354e4a2adcf2
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Mathematics;
|
||||||
|
|
||||||
|
namespace ProjectM.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pure tests for <see cref="BuildPreviewMath"/> — the client build-ghost validity (in-plot, unoccupied,
|
||||||
|
/// affordable) that mirrors the server's authoritative BuildPlaceSystem check, colouring the ground ghost
|
||||||
|
/// green (valid) vs red (the first failing reason).
|
||||||
|
/// </summary>
|
||||||
|
public class BuildPreviewMathTests
|
||||||
|
{
|
||||||
|
static BaseAnchor Anchor() => new BaseAnchor
|
||||||
|
{
|
||||||
|
AnchorPos = new float3(0, 0, 0),
|
||||||
|
GridOrigin = new float3(0, 0, 0),
|
||||||
|
CellSize = 1f,
|
||||||
|
GridDims = new int2(8, 8),
|
||||||
|
};
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void InPlot_Unoccupied_Affordable_IsValid()
|
||||||
|
{
|
||||||
|
Assert.AreEqual(BuildPreviewMath.Valid,
|
||||||
|
BuildPreviewMath.Evaluate(Anchor(), new int2(3, 3), occupied: false, have: 50, cost: 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void OutOfPlot_Reported_First()
|
||||||
|
{
|
||||||
|
Assert.AreEqual(BuildPreviewMath.OutOfPlot,
|
||||||
|
BuildPreviewMath.Evaluate(Anchor(), new int2(99, 0), occupied: true, have: 0, cost: 999),
|
||||||
|
"Out-of-plot is reported before occupancy / cost.");
|
||||||
|
Assert.AreEqual(BuildPreviewMath.OutOfPlot,
|
||||||
|
BuildPreviewMath.Evaluate(Anchor(), new int2(-1, 3), occupied: false, have: 50, cost: 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Occupied_Cell_IsBlocked()
|
||||||
|
{
|
||||||
|
Assert.AreEqual(BuildPreviewMath.Occupied,
|
||||||
|
BuildPreviewMath.Evaluate(Anchor(), new int2(3, 3), occupied: true, have: 50, cost: 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Unaffordable_When_Have_Below_Cost_Exact_Funds_Ok()
|
||||||
|
{
|
||||||
|
Assert.AreEqual(BuildPreviewMath.Unaffordable,
|
||||||
|
BuildPreviewMath.Evaluate(Anchor(), new int2(3, 3), occupied: false, have: 5, cost: 20));
|
||||||
|
Assert.AreEqual(BuildPreviewMath.Valid,
|
||||||
|
BuildPreviewMath.Evaluate(Anchor(), new int2(3, 3), occupied: false, have: 20, cost: 20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2d3f16d92d3045044a9efd5545320111
|
||||||
Reference in New Issue
Block a user