// Assets/_Project/Scripts/Gameplay/TowerPlacementController.cs using UnityEngine; using UnityEngine.InputSystem; using TD.Core; using TD.Towers; namespace TD.Gameplay { /// /// Per-client controller for the tower placement UX. Handles hover raycasts against /// the BuildablePlane collider, drives the placement ghost, and dispatches placement /// requests to via RPC. /// /// /// Plain MonoBehaviour. Placement visuals (ghost, cursor color) are /// purely cosmetic and local. This component does not need to be a NetworkBehaviour. /// All server-authoritative logic lives in . /// /// Ghost validity check. The ghost checks ownership, placement state, /// and tile occupancy only. It does NOT run a local path check. If the server rejects /// because the tower would block the path, the ghost disappears and a rejection /// message is shown. This avoids the complexity of maintaining a client-side BFS /// that may be slightly stale. /// /// Ghost colors. /// /// White — all local checks pass. /// Red — any local check fails (wrong zone, not buildable, already occupied). /// /// Green "pending construction" ghost is a separate system implemented in Path D. /// /// Placement activation. The controller is idle until /// is called (e.g., from a HUD tower button). The player /// right-clicks or the placement is confirmed/rejected to return to idle. /// /// Input System. Uses the New Input System package. Mouse position and /// button state are read from Mouse.current each frame. /// /// Player slot. The local player slot is currently a stub /// (client 0 = Player1, etc.) matching TowerPlacementManager.ClientIdToPlayerSlot. /// This will be replaced when MatchState carries the authoritative slot assignment. /// public class TowerPlacementController : MonoBehaviour { // ----- Inspector -------------------------------------------------- [Tooltip("Project-wide placement settings: ghost materials and rejection messages.")] [SerializeField] private TowerPlacementSettings settings; [Tooltip("Physics layer mask for the BuildablePlane collider. Set this to the " + "'BuildablePlane' layer. Raycasts only hit this layer so stray colliders " + "in the scene don't interfere.")] [SerializeField] private LayerMask buildablePlaneLayerMask; [Tooltip("Maximum raycast distance from the camera to the buildable plane. " + "Should be at least as large as the camera's far clip plane.")] [SerializeField] private float raycastMaxDistance = 500f; // ----- Active-placement state ------------------------------------- // Null when placement mode is inactive. private TowerDefinition activeDef; private int activeTowerTypeId; // The ghost GameObject: the tower prefab instantiated with transparent materials. // Null when placement mode is inactive. private GameObject ghostGO; // Cached renderers on the ghost, populated when the ghost is created. private Renderer[] ghostRenderers = System.Array.Empty(); // Reused each frame to avoid allocation. Lazily constructed on first use because // Unity disallows MaterialPropertyBlock construction in field initializers. private MaterialPropertyBlock ghostPropertyBlock; private static readonly int ColorPropertyId = Shader.PropertyToID("_Color"); // The anchor tile computed last frame (used to avoid re-evaluating when the // cursor hasn't moved to a new tile). private Vector2Int lastAnchor; private bool lastAnchorValid; // false until first raycast succeeds private bool lastPlacementValid; // result of the last local validity check // ----- Events ----------------------------------------------------- /// /// Fired on the local client when the server rejects a placement request. /// Payload is the human-readable rejection message from /// . Subscribe here to display feedback UI. /// public static event System.Action OnRejectionMessageReady; // ----- Lifecycle -------------------------------------------------- private void OnEnable() { TowerPlacementManager.OnPlacementRejected += HandlePlacementRejected; } private void OnDisable() { TowerPlacementManager.OnPlacementRejected -= HandlePlacementRejected; CancelPlacement(); } private void Update() { if (activeDef == null) return; // idle — nothing to do var mouse = Mouse.current; if (mouse == null) return; // no mouse device connected // Right-click cancels placement. if (mouse.rightButton.wasPressedThisFrame) { CancelPlacement(); return; } // Raycast mouse position against the BuildablePlane. if (!TryGetBuildablePlaneHit(mouse.position.ReadValue(), out Vector3 hitPoint)) { // Cursor is off the buildable plane — hide the ghost but stay in // placement mode so the player can move back onto the plane. SetGhostVisible(false); lastAnchorValid = false; return; } SetGhostVisible(true); // Compute the footprint anchor from the hit point. Vector2Int anchor = ComputeAnchor(hitPoint, activeDef.FootprintSize); // Position the ghost at the footprint center. Vector3 ghostPos = GridCoordinates.GetFootprintCenterWorld(anchor, activeDef.FootprintSize); ghostPos.y = 0.5f; // lift off the plane so the cube base sits flush ghostGO.transform.position = ghostPos; // Only re-evaluate validity when the anchor tile changes, to avoid // re-running grid lookups every frame when the cursor is stationary. if (!lastAnchorValid || anchor != lastAnchor) { lastAnchor = anchor; lastAnchorValid = true; lastPlacementValid = EvaluateLocalValidity(anchor, activeDef); } // Update ghost color. ApplyGhostColor(lastPlacementValid); // Left-click to attempt placement. if (mouse.leftButton.wasPressedThisFrame) { TrySubmitPlacement(anchor); } } // ----- Public API ------------------------------------------------- /// /// Activates placement mode for the given tower type. The ghost appears /// immediately under the cursor. Call this from HUD tower buttons. /// /// The TowerDefinition to place. /// The type ID registered in /// . public void BeginPlacement(TowerDefinition def, int towerTypeId) { if (def == null) { Debug.LogError("[TowerPlacementController] BeginPlacement called with null " + "TowerDefinition."); return; } // Cancel any existing placement before starting a new one. CancelPlacement(); activeDef = def; activeTowerTypeId = towerTypeId; CreateGhost(def); } /// /// Cancels placement mode and destroys the ghost. Safe to call when idle. /// public void CancelPlacement() { activeDef = null; activeTowerTypeId = 0; lastAnchorValid = false; DestroyGhost(); } /// /// True when placement mode is currently active. /// public bool IsPlacing => activeDef != null; // ----- Ghost management ------------------------------------------- private void CreateGhost(TowerDefinition def) { if (def.TowerPrefab == null) { Debug.LogWarning($"[TowerPlacementController] '{def.DisplayName}' has no " + $"TowerPrefab — ghost cannot be created."); return; } ghostGO = Instantiate(def.TowerPrefab); ghostGO.name = $"Ghost_{def.name}"; // Disable all NetworkObject, TowerInstance, and Collider components on // the ghost — it must not participate in networking or physics. DisableGhostComponents(); ghostRenderers = ghostGO.GetComponentsInChildren(); // Apply ghost valid material to all renderers initially. // ApplyGhostColor will update each frame. if (settings != null && settings.GhostValidMaterial != null) { foreach (var rend in ghostRenderers) rend.sharedMaterial = settings.GhostValidMaterial; } // Start invisible; shown on the first successful raycast. SetGhostVisible(false); } private void DestroyGhost() { if (ghostGO != null) { Destroy(ghostGO); ghostGO = null; } ghostRenderers = System.Array.Empty(); } private void SetGhostVisible(bool visible) { if (ghostGO != null) ghostGO.SetActive(visible); } /// /// Disables components on the ghost that must not run: NetworkObject, /// TowerInstance, Colliders, and Rigidbodies. The ghost is purely visual. /// private void DisableGhostComponents() { // NetworkObject — must be disabled so NGO doesn't try to register it. var netObj = ghostGO.GetComponent(); if (netObj != null) netObj.enabled = false; // TowerInstance — must not stamp grids or fire OnNetworkSpawn. var towerInstance = ghostGO.GetComponent(); if (towerInstance != null) towerInstance.enabled = false; // Colliders — ghost must not block raycasts or physics queries. foreach (var col in ghostGO.GetComponentsInChildren()) col.enabled = false; // Rigidbodies — ghost must not fall or interact with physics. foreach (var rb in ghostGO.GetComponentsInChildren()) rb.isKinematic = true; } /// /// Sets the ghost material color using . /// Switches between valid (white) and invalid (red) materials. /// private void ApplyGhostColor(bool valid) { if (settings == null) return; Material ghostMat = valid ? settings.GhostValidMaterial : settings.GhostInvalidMaterial; if (ghostMat == null) return; foreach (var rend in ghostRenderers) { rend.sharedMaterial = ghostMat; // Property block is set empty here — color comes from the material itself. // If the ghost materials use _Color, override it via the block instead: // ghostPropertyBlock.SetColor(ColorPropertyId, valid ? Color.white : Color.red); // rend.SetPropertyBlock(ghostPropertyBlock); } } // ----- Raycasting ------------------------------------------------- private bool TryGetBuildablePlaneHit(Vector2 screenPos, out Vector3 hitPoint) { hitPoint = Vector3.zero; var cam = Camera.main; if (cam == null) return false; Ray ray = cam.ScreenPointToRay(new Vector3(screenPos.x, screenPos.y, 0f)); if (Physics.Raycast(ray, out RaycastHit hit, raycastMaxDistance, buildablePlaneLayerMask)) { hitPoint = hit.point; return true; } return false; } // ----- Anchor computation ----------------------------------------- /// /// Converts a world hit point to the footprint anchor tile (SW corner) such /// that the footprint center is as close as possible to the hit point. /// /// /// For a 2×2 footprint: anchor = (Round(hitX - 0.5), Round(hitZ - 0.5)) /// For a 1×1 footprint: anchor = (Round(hitX), Round(hitZ)) /// For a 3×3 footprint: anchor = (Round(hitX - 1.0), Round(hitZ - 1.0)) /// private static Vector2Int ComputeAnchor(Vector3 hitPoint, Vector2Int footprintSize) { float t = GridCoordinates.TILE_SIZE; float halfW = (footprintSize.x - 1) * 0.5f; float halfH = (footprintSize.y - 1) * 0.5f; int anchorX = Mathf.RoundToInt(hitPoint.x / t - halfW); int anchorY = Mathf.RoundToInt(hitPoint.z / t - halfH); return new Vector2Int(anchorX, anchorY); } // ----- Local validity check --------------------------------------- /// /// Evaluates the local (client-side) placement validity. Checks ownership, /// placement state, and occupancy. Does NOT check gold or run a path BFS — /// those are server-side only. /// private bool EvaluateLocalValidity(Vector2Int anchor, TowerDefinition def) { var loader = LevelLoader.Instance; if (loader == null || !loader.IsLoaded) return false; PlayerSlot localSlot = GetLocalPlayerSlot(); foreach (var tile in GridCoordinates.GetFootprintTiles(anchor, def.FootprintSize)) { if (loader.GetOwner(tile) != localSlot) return false; if (loader.GetPlacement(tile) != PlacementState.Buildable) return false; if (loader.IsOccupied(tile)) return false; } return true; } // ----- Placement submission --------------------------------------- private void TrySubmitPlacement(Vector2Int anchor) { var manager = TowerPlacementManager.Instance; if (manager == null) { Debug.LogWarning("[TowerPlacementController] No TowerPlacementManager in " + "scene. Cannot submit placement request."); return; } // Send the RPC regardless of local validity state — the server is // authoritative. The local check drives the ghost color only. // The server will reject and send back a reason if invalid. manager.RequestPlaceTowerRpc(anchor.x, anchor.y, activeTowerTypeId); // Exit placement mode immediately after submitting. If the server // rejects, the rejection message fires via HandlePlacementRejected. // If it accepts, the TowerInstance NetworkObject spawns and the // placed tower appears — no ghost lingering is needed. CancelPlacement(); } // ----- Rejection feedback ----------------------------------------- private void HandlePlacementRejected(PlacementRejectionReason reason) { if (settings == null) { Debug.LogWarning($"[TowerPlacementController] Placement rejected: {reason} " + $"(no TowerPlacementSettings assigned — cannot show message)."); return; } string message = settings.GetRejectionMessage(reason); Debug.Log($"[TowerPlacementController] Placement rejected: {reason} → \"{message}\""); // Fire the event so HUD components can display the message on screen. // The HUD that subscribes and renders this is implemented in a later path. OnRejectionMessageReady?.Invoke(message); } // ----- Player slot ------------------------------------------------ /// /// Returns the local player's PlayerSlot. /// STUB: Uses the same trivial client-ID → slot mapping as /// TowerPlacementManager.ClientIdToPlayerSlot. Will be replaced /// when MatchState carries the authoritative assignment. /// private static PlayerSlot GetLocalPlayerSlot() { var nm = Unity.Netcode.NetworkManager.Singleton; if (nm == null || !nm.IsClient) return PlayerSlot.None; ulong clientId = nm.LocalClientId; byte slotByte = (byte)(clientId + 1); if (slotByte < 1 || slotByte > 9) return PlayerSlot.None; return (PlayerSlot)slotByte; } } }