UnityTowerDefense/Assets/_Project/Scripts/Gameplay/TowerPlacementController.cs
Matt F a63cce53e2 Adding tons of new functionality
Decals, ghost textures, placement functionality, builder stub ins, a new camera system,  and more.
2026-05-04 00:01:30 -07:00

421 lines
No EOL
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Assets/_Project/Scripts/Gameplay/TowerPlacementController.cs
using UnityEngine;
using UnityEngine.InputSystem;
using TD.Core;
using TD.Towers;
namespace TD.Gameplay
{
/// <summary>
/// Per-client controller for the tower placement UX. Handles hover raycasts against
/// the BuildablePlane collider, drives the placement ghost, and dispatches placement
/// requests to <see cref="TowerPlacementManager"/> via RPC.
/// </summary>
/// <remarks>
/// <para><b>Plain MonoBehaviour.</b> 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 <see cref="TowerPlacementManager"/>.</para>
///
/// <para><b>Ghost validity check.</b> 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.</para>
///
/// <para><b>Ghost colors.</b>
/// <list type="bullet">
/// <item>White — all local checks pass.</item>
/// <item>Red — any local check fails (wrong zone, not buildable, already occupied).</item>
/// </list>
/// Green "pending construction" ghost is a separate system implemented in Path D.</para>
///
/// <para><b>Placement activation.</b> The controller is idle until
/// <see cref="BeginPlacement"/> is called (e.g., from a HUD tower button). The player
/// right-clicks or the placement is confirmed/rejected to return to idle.</para>
///
/// <para><b>Input System.</b> Uses the New Input System package. Mouse position and
/// button state are read from <c>Mouse.current</c> each frame.</para>
///
/// <para><b>Player slot.</b> The local player slot is currently a stub
/// (client 0 = Player1, etc.) matching <c>TowerPlacementManager.ClientIdToPlayerSlot</c>.
/// This will be replaced when MatchState carries the authoritative slot assignment.</para>
/// </remarks>
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<Renderer>();
// 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 -----------------------------------------------------
/// <summary>
/// Fired on the local client when the server rejects a placement request.
/// Payload is the human-readable rejection message from
/// <see cref="TowerPlacementSettings"/>. Subscribe here to display feedback UI.
/// </summary>
public static event System.Action<string> 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 -------------------------------------------------
/// <summary>
/// Activates placement mode for the given tower type. The ghost appears
/// immediately under the cursor. Call this from HUD tower buttons.
/// </summary>
/// <param name="def">The TowerDefinition to place.</param>
/// <param name="towerTypeId">The type ID registered in
/// <see cref="TowerPlacementManager"/>.</param>
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);
}
/// <summary>
/// Cancels placement mode and destroys the ghost. Safe to call when idle.
/// </summary>
public void CancelPlacement()
{
activeDef = null;
activeTowerTypeId = 0;
lastAnchorValid = false;
DestroyGhost();
}
/// <summary>
/// True when placement mode is currently active.
/// </summary>
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<Renderer>();
// 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<Renderer>();
}
private void SetGhostVisible(bool visible)
{
if (ghostGO != null)
ghostGO.SetActive(visible);
}
/// <summary>
/// Disables components on the ghost that must not run: NetworkObject,
/// TowerInstance, Colliders, and Rigidbodies. The ghost is purely visual.
/// </summary>
private void DisableGhostComponents()
{
// NetworkObject — must be disabled so NGO doesn't try to register it.
var netObj = ghostGO.GetComponent<Unity.Netcode.NetworkObject>();
if (netObj != null) netObj.enabled = false;
// TowerInstance — must not stamp grids or fire OnNetworkSpawn.
var towerInstance = ghostGO.GetComponent<TowerInstance>();
if (towerInstance != null) towerInstance.enabled = false;
// Colliders — ghost must not block raycasts or physics queries.
foreach (var col in ghostGO.GetComponentsInChildren<Collider>())
col.enabled = false;
// Rigidbodies — ghost must not fall or interact with physics.
foreach (var rb in ghostGO.GetComponentsInChildren<Rigidbody>())
rb.isKinematic = true;
}
/// <summary>
/// Sets the ghost material color using <see cref="MaterialPropertyBlock"/>.
/// Switches between valid (white) and invalid (red) materials.
/// </summary>
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 -----------------------------------------
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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))
/// </remarks>
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 ---------------------------------------
/// <summary>
/// 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.
/// </summary>
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 ------------------------------------------------
/// <summary>
/// Returns the local player's PlayerSlot.
/// STUB: Uses the same trivial client-ID → slot mapping as
/// <c>TowerPlacementManager.ClientIdToPlayerSlot</c>. Will be replaced
/// when MatchState carries the authoritative assignment.
/// </summary>
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;
}
}
}