// 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;
}
}
}