using ProjectM.Simulation; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; using UnityEngine; namespace ProjectM.Client { /// /// Client-only build input + RPC sender. Two ways to build: /// (1) the HUD build PALETTE (primary): a selected buildable () 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 /// ), 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. /// [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 s_PendingBuild = new System.Collections.Generic.Queue(); static int s_PendingUpgrades = 0; /// EDITOR / execute_code hook: queue a structure placement at a specific cell. 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 }); /// EDITOR / execute_code hook: queue a turret placement at a specific cell. public static void PlaceTurret(int cellX, int cellZ) => PlaceStructure(StructureType.Turret, cellX, cellZ); /// EDITOR / execute_code hook: queue a wall placement at a specific cell. public static void PlaceWall(int cellX, int cellZ) => PlaceStructure(StructureType.Wall, cellX, cellZ); /// EDITOR / execute_code hook: queue a pylon placement at a specific cell. public static void PlacePylon(int cellX, int cellZ) => PlaceStructure(StructureType.Pylon, cellX, cellZ); /// EDITOR / execute_code hook: queue a harvester placement at a specific cell. public static void PlaceHarvester(int cellX, int cellZ) => PlaceStructure(StructureType.Harvester, cellX, cellZ); /// EDITOR / execute_code hook: queue a fabricator placement at a specific cell. public static void PlaceFabricator(int cellX, int cellZ) => PlaceStructure(StructureType.Fabricator, cellX, cellZ); /// EDITOR / execute_code hook: queue a conveyor placement facing direction (0=+X,1=-X,2=+Z,3=-Z). public static void PlaceConveyor(int cellX, int cellZ, byte direction) => PlaceStructure(StructureType.Conveyor, cellX, cellZ, direction); /// EDITOR / execute_code hook: queue an ability-damage upgrade. public static void UpgradeAbility() => s_PendingUpgrades++; #endif protected override void OnCreate() { RequireForUpdate(); } protected override void OnDestroy() { if (_ghost != null) Object.Destroy(_ghost); if (_ghostMat != null) Object.Destroy(_ghostMat); } protected override void OnUpdate() { if (!SystemAPI.TryGetSingletonEntity(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(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>()) 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(out var le)) return 0; var buf = SystemAPI.GetBuffer(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(out var ce)) return int.MaxValue; var cat = SystemAPI.GetBuffer(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(); if (col != null) Object.Destroy(col); var mr = _ghost.GetComponent(); 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(); if (rig != null) cam = rig.GetComponent(); } return cam; } bool TryGetLocalPlayerCell(out int2 cell) { cell = default; if (!SystemAPI.TryGetSingleton(out var anchor)) return false; foreach (var xform in SystemAPI.Query>().WithAll()) { 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 }); } } }