// 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;
// Set true by a single-shot placement submit; processed at the top of the next
// Update so that BuilderInputController still sees IsPlacing=true on the same
// frame as the click and doesn't deselect the builder via its empty-space check.
private bool pendingCancel;
// 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;
ExitPlacementMode();
}
private void Update()
{
if (pendingCancel)
{
pendingCancel = false;
ExitPlacementMode();
return;
}
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)
{
ExitPlacementMode();
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.
ExitPlacementMode();
activeDef = def;
activeTowerTypeId = towerTypeId;
CreateGhost(def);
}
///
/// Cancels placement mode and destroys the ghost. Safe to call when idle.
///
void ExitPlacementMode()
{
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: defer the cancel to the next frame so BuilderInputController
// still sees IsPlacing=true this frame and doesn't deselect the builder.
pendingCancel = true;
}
// ----- 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 ------------------------------------------------
private static PlayerSlot GetLocalPlayerSlot()
=> PlayerMatchState.Local?.Slot ?? PlayerSlot.None;
}
}