Adding tons of new functionality
Decals, ghost textures, placement functionality, builder stub ins, a new camera system, and more.
This commit is contained in:
parent
56dc775c68
commit
a63cce53e2
54 changed files with 4817 additions and 238 deletions
421
Assets/_Project/Scripts/Gameplay/TowerPlacementController.cs
Normal file
421
Assets/_Project/Scripts/Gameplay/TowerPlacementController.cs
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue