420 lines
No EOL
17 KiB
C#
420 lines
No EOL
17 KiB
C#
// 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 (cursor ghost, 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 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.</para>
|
|
///
|
|
/// <para><b>Two ghosts, two systems.</b> The <i>cursor ghost</i> (handled here)
|
|
/// follows the mouse and turns white/red. The <i>build-site visual</i> (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.</para>
|
|
///
|
|
/// <para><b>Chained queueing.</b> 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.</para>
|
|
///
|
|
/// <para><b>Input System.</b> Uses the New Input System package. Mouse and modifier
|
|
/// state are read directly from <c>Mouse.current</c> and <c>Keyboard.current</c>
|
|
/// each frame. No InputAction asset needed for these placement-specific bindings.</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 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<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 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 -------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Activates placement mode for the given tower type. The ghost appears
|
|
/// immediately under the cursor. Call this from HUD tower buttons.
|
|
/// </summary>
|
|
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;
|
|
|
|
// ----- 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<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;
|
|
}
|
|
}
|
|
|
|
// ----- 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>
|
|
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, 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 ------------------------------------------------
|
|
|
|
/// <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;
|
|
}
|
|
}
|
|
} |