419debad74
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>
282 lines
14 KiB
C#
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 });
|
|
}
|
|
}
|
|
}
|