Files
Project-M/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs
T
kronic a4edf7a03b 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>
2026-06-06 15:05:49 -07:00

270 lines
13 KiB
C#

using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// 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; 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, 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);
/// <summary>EDITOR / execute_code hook: queue a wall placement at a specific cell.</summary>
public static void PlaceWall(int cellX, int cellZ) => PlaceStructure(StructureType.Wall, cellX, cellZ);
/// <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
protected override void OnCreate()
{
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 && !BuildPaletteState.Active)
{
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);
}
#if UNITY_EDITOR
while (s_PendingBuild.Count > 0)
{
var b = s_PendingBuild.Dequeue();
SendBuild(connection, b.Type, b.CellX, b.CellZ, b.Direction);
}
while (s_PendingUpgrades > 0)
{
s_PendingUpgrades--;
SendUpgrade(connection);
}
#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;
if (!SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
return false;
foreach (var xform in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
{
cell = BaseGridMath.WorldToCell(anchor, xform.ValueRO.Position);
return true;
}
return false;
}
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, Direction = direction });
EntityManager.AddComponentData(e, new SendRpcCommandRequest { TargetConnection = connection });
}
void SendUpgrade(Entity connection)
{
var e = EntityManager.CreateEntity();
EntityManager.AddComponentData(e, new AbilityUpgradeRequest());
EntityManager.AddComponentData(e, new SendRpcCommandRequest { TargetConnection = connection });
}
}
}