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/F place at the local player's cell. /// Editor-only statics (PlaceStructure / PlaceTurret / ...) 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 { // Dev hotkey fallback: key -> buildable. static readonly (UnityEngine.InputSystem.Key Key, byte Type)[] s_BuildHotkeys = { (UnityEngine.InputSystem.Key.B, StructureType.Turret), (UnityEngine.InputSystem.Key.V, StructureType.Wall), // LANTERN purge: the automation buildables (Fabricator/Harvester/Conveyor) are deleted; Pylon stays // hidden from the hotkey fallback to match the build palette. PlaceStructure execute_code statics remain. }; UnityEngine.Camera _camera; // cursor -> ground re-raycast for click-to-place (resolved lazily) GameObject _ghost; // translucent ground preview cube Material _ghostMat; MeshFilter _ghostMf; // ghost mesh swaps to the selected structure's preview mesh (HudTheme) MeshRenderer _ghostMr; Mesh _cubeMesh; // procedural fallback (the original preview cube) byte _ghostType = 255; // last-applied preview type (255 = unset) byte _lastSelected; // skip placing on the frame a palette click changes the selection // (Step 11: UpgradeAbility/s_PendingUpgrades RETIRED with the AbilityUpgradeRequest wire — in-run boons + // the base meta-shop replaced the Aether damage upgrade.) #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(); /// 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); #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); // --- 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) { foreach (var (key, type) in s_BuildHotkeys) if (keyboard[key].wasPressedThisFrame && TryGetLocalPlayerCell(out int2 cell)) SendBuild(connection, type, cell.x, cell.y, (byte)0); } #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(out var anchor)) { HideGhost(); return; } if (_camera == null) _camera = CameraResolver.Resolve(); 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, sel); // 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: the selected structure's REAL mesh (HudTheme, build-safe serialized refs) // tinted translucent green/red; falls back to the original procedural cube when no mesh is authored. ---- void ShowGhost(float3 center, float cellSize, bool valid, byte type) { EnsureGhost(); ApplyGhostMesh(type); if (_ghostMf.sharedMesh == _cubeMesh) { _ghost.transform.position = (Vector3)center + Vector3.up * 0.5f; _ghost.transform.localScale = new Vector3(cellSize * 0.9f, 1f, cellSize * 0.9f); } else { // preview meshes are authored real-size with a ground pivot (SM_Turret_01 / SM_Wall_01 / SM_Fabricator_01) _ghost.transform.position = (Vector3)center; _ghost.transform.localScale = Vector3.one; } _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); } // Swap the ghost's mesh when the palette selection changes (255 = unset sentinel; no selection is 0 -> cube). void ApplyGhostMesh(byte type) { if (type == _ghostType) return; _ghostType = type; var theme = HudTheme.Get(); Mesh mesh = theme != null ? theme.StructureGhostMesh(type) : null; if (mesh == null) mesh = _cubeMesh; _ghostMf.sharedMesh = mesh; if (_ghostMr.sharedMaterials.Length != mesh.subMeshCount) { var mats = new Material[mesh.subMeshCount]; // one translucent mat per submesh so the whole preview tints for (int i = 0; i < mats.Length; i++) mats[i] = _ghostMat; _ghostMr.sharedMaterials = mats; } } 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); _ghostMf = _ghost.GetComponent(); _cubeMesh = _ghostMf.sharedMesh; _ghostType = 255; _ghostMr = _ghost.GetComponent(); _ghostMr.sharedMaterial = _ghostMat; _ghostMr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; _ghostMr.receiveShadows = false; _ghost.SetActive(false); } 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 }); } // (Step 11: the Aether ability-upgrade sender was RETIRED with AbilityUpgradeRequest — boons replaced it.) } }