Decals, ghost textures, placement functionality, builder stub ins, a new camera system, and more.
421 lines
No EOL
17 KiB
C#
421 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 (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;
|
||
}
|
||
}
|
||
} |