Files
Project-M/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs
T
kronic 419debad74 DR-042 Phase C (legibility, part 1): expedition objective HUD, Aether button, cold-start seed, Biomass sink, palette declutter
Scoping/design-gated (wf_7c5a555e-136). Fixes "the base reads as inert after Phase A":

- C7b objective readout: new replicated ExpeditionObjective{[GhostField] byte State, short Remaining} on the
  untagged CycleDirector ghost (cross-region safe). Sole writer ZoneEnemyDirectorSystem, written ABOVE its
  early-returns (snapshot-above-early-return) so the HUD never freezes stale. Play-verified it replicates
  server->client.
- C7a gate prompt + C7b HUD readout: HudSystem shows "GO TO THE EXPEDITION GATE" / "EXPEDITION IN PROGRESS - N
  remaining" / "CLEARED - return to claim", below the siege/overrun overrides.
- C6a Aether upgrade button: un-gated BuildSendSystem.UpgradeAbility (was #if UNITY_EDITOR); HudSystem adds a
  MenuUi.Button with live affordability tint (the only Aether sink was U-key only).
- C6c cold-start seed: CycleDirectorSpawnSystem seeds Tuning.StartingOre (50) into the ledger on a NEW game only
  (born-correct, pre-Playback), killing the silent turret-before-fabricator deadlock. Play-verified seededOre=50.
- C6b Biomass sink: Wall cost Ore->Biomass (the dead currency now has a home). Play-verified WallCostRes=Biomass.
- C6d palette declutter: hide dead Pylon/Harvester/Conveyor from the build palette + trimmed their dev hotkeys
  (catalog/prefabs stay baked, code-intact per DR-020).

389/389 EditMode + clean netcode Play smoke (ghost re-hash OK, no exceptions). SaveData stays v5.
C5 (walls block enemies) is the remaining Phase C item, sequenced separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:18:17 -07:00

282 lines
14 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/F 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.F, StructureType.Fabricator),
// DR-042 C6d: Pylon/Harvester/Conveyor are dead (unwired automation) — dropped from the hotkey fallback
// to match the hidden build palette; their PlaceStructure execute_code statics remain for dev.
};
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
// DR-042 C6a: the ability-upgrade send is RUNTIME (the HUD Aether button calls UpgradeAbility); only the
// execute_code PLACE statics stay editor-gated. Mirrors EquipSendSystem's unconditional queue + drain.
static int s_PendingUpgrades = 0;
/// <summary>Runtime hook (HUD Aether button) + execute_code: queue an ability-damage upgrade.</summary>
public static void UpgradeAbility() => s_PendingUpgrades++;
#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>();
/// <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);
#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);
// --- Build-palette toggle (Tab / gamepad Y): Slice 1 HUD declutter — the palette is hidden by default ---
var keyboard = UnityEngine.InputSystem.Keyboard.current;
var gamepad = UnityEngine.InputSystem.Gamepad.current;
bool togglePressed =
(keyboard != null && keyboard.tabKey.wasPressedThisFrame) ||
(gamepad != null && gamepad.buttonNorth.wasPressedThisFrame);
if (togglePressed && !PauseMenuController.Open)
BuildPaletteState.TogglePalette();
// Hotkey fallback (suppressed while the palette build mode is active).
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);
}
// DR-042 C6a: the ability-upgrade drain runs at RUNTIME (the HUD Aether button enqueues via UpgradeAbility);
// only the execute_code PLACE drain stays editor-gated.
while (s_PendingUpgrades > 0)
{
s_PendingUpgrades--;
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);
}
#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 });
}
}
}