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:
@@ -3,29 +3,53 @@ using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 (<see cref="BuildPaletteState"/>) 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
|
||||
/// <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>
|
||||
[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<PendingBuild> s_PendingBuild =
|
||||
new System.Collections.Generic.Queue<PendingBuild>();
|
||||
static int s_PendingUpgrades = 0;
|
||||
|
||||
/// <summary>EDITOR / execute_code hook: queue a structure placement at a specific cell.</summary>
|
||||
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 });
|
||||
|
||||
/// <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);
|
||||
@@ -36,6 +60,15 @@ namespace ProjectM.Client
|
||||
/// <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);
|
||||
|
||||
/// <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>
|
||||
public static void UpgradeAbility() => s_PendingUpgrades++;
|
||||
#endif
|
||||
@@ -45,20 +78,31 @@ namespace ProjectM.Client
|
||||
RequireForUpdate<NetworkId>();
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
if (_ghost != null) Object.Destroy(_ghost);
|
||||
if (_ghostMat != null) Object.Destroy(_ghostMat);
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (!SystemAPI.TryGetSingletonEntity<NetworkId>(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<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)
|
||||
{
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user