// 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 (cursor ghost, 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 cursor 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 cursor ghost disappears and a /// rejection message is shown. /// /// Two ghosts, two systems. The cursor ghost (handled here) /// follows the mouse and turns white/red. The build-site visual (in D2, /// handled by Builder + BuildSiteVisual) is the green queued ghost or staged /// construction visual that appears at a confirmed placement site. They are /// distinct prefabs and lifecycles. /// /// Chained queueing. Holding Shift while left-clicking submits the /// placement and stays in placement mode for another submission. Releasing Shift /// before the click submits and exits placement (single-shot). Right-click always /// cancels. /// /// Input System. Uses the New Input System package. Mouse and modifier /// state are read directly from Mouse.current and Keyboard.current /// each frame. No InputAction asset needed for these placement-specific bindings. /// 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 cursor 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 cursor 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) { bool chained = IsShiftHeld(); TrySubmitPlacement(anchor, chained); } } // ----- Public API ------------------------------------------------- /// /// Activates placement mode for the given tower type. The ghost appears /// immediately under the cursor. Call this from HUD tower buttons. /// 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; // ----- Modifier helpers ------------------------------------------- private static bool IsShiftHeld() { var kb = Keyboard.current; if (kb == null) return false; return kb.leftShiftKey.isPressed || kb.rightShiftKey.isPressed; } // ----- 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; } } // ----- 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. /// 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, bool chained) { 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. manager.RequestPlaceTowerRpc(anchor.x, anchor.y, activeTowerTypeId); if (chained) { // Stay in placement mode. The server will stamp occupancy on success, // so when EvaluateLocalValidity runs next frame it will correctly // report the just-clicked tile as occupied (cursor turns red there). // Force a re-evaluation by invalidating the cached anchor. lastAnchorValid = false; return; } // Single-shot: exit placement mode immediately. 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. 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; } } }