UnityTowerDefense/Assets/_Project/Scripts/Gameplay/TowerInstance.cs
2026-05-10 22:26:55 -07:00

304 lines
No EOL
14 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/TowerInstance.cs
using Unity.Collections;
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Towers;
using TD.UI.Minimap;
namespace TD.Gameplay
{
/// <summary>
/// Per-tower runtime component. Lives on the tower's NetworkObject prefab root.
///
/// Responsibilities:
/// <list type="bullet">
/// <item>Hold the network-replicated identity of this tower: which
/// <see cref="TowerDefinition"/> it is and which <see cref="PlayerSlot"/> owns it.</item>
/// <item>On <see cref="OnNetworkSpawn"/>, stamp the tower's footprint into
/// <see cref="LevelLoader"/> on every client so local grids stay in sync
/// with the server-authoritative state.</item>
/// <item>Apply the owner's player color to the tower mesh, so towers are
/// visually distinct by zone during testing.</item>
/// </list>
/// </remarks>
/// <remarks>
/// <para><b>Grid stamping split.</b> The server stamps the footprint in
/// <c>TowerPlacementManager.ProcessRequest</c> (before <c>NetworkObject.Spawn</c>)
/// so the path-validity check in the same frame sees the updated grid. Non-host
/// clients stamp in <see cref="OnNetworkSpawn"/> when NGO replicates the
/// NetworkObject to them. The server's <see cref="OnNetworkSpawn"/> also runs,
/// but by then the footprint is already stamped — <see cref="SetWalkable"/> and
/// <see cref="SetOccupied"/> are idempotent writes, so double-stamping is safe.</para>
///
/// <para><b>Definition reference replication.</b> TowerDefinition assets live in the
/// project on all clients. We replicate the asset by name via a
/// <see cref="NetworkVariable{T}"/> holding a <c>FixedString64Bytes</c>, then look
/// up the asset locally. This avoids serializing the full ScriptableObject over the
/// network. The lookup uses a <see cref="TowerRegistry"/> singleton that must be
/// present in the scene. (Temporary: will be driven by RaceDefinition in Path E.)</para>
///
/// <para><b>Combat.</b> No combat logic here yet. Combat fields live stubbed on
/// <see cref="TowerDefinition"/>; they will be consumed by a future
/// <c>TowerCombat</c> component added to the same prefab.</para>
/// </remarks>
[RequireComponent(typeof(NetworkObject))]
public class TowerInstance : NetworkBehaviour, IMinimapEntity
{
// ----- Networked state ------------------------------------------------
// The name of the TowerDefinition asset for this tower. Replicated so all
// clients can look up the full definition locally without sending the whole
// ScriptableObject over the wire.
private readonly NetworkVariable<FixedString64Bytes> definitionName =
new NetworkVariable<FixedString64Bytes>(
default,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// The footprint anchor (SW corner, world-tile coords). Replicated so
// clients can stamp the correct tiles in OnNetworkSpawn.
private readonly NetworkVariable<Vector2Int> anchorTile =
new NetworkVariable<Vector2Int>(
default,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// The PlayerSlot that placed this tower. Replicated for the HUD context
// panel and for view-selection by non-owning clients.
private readonly NetworkVariable<PlayerSlot> ownerSlot =
new NetworkVariable<PlayerSlot>(
PlayerSlot.None,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// ----- Local resolved state -------------------------------------------
// Resolved on every client in OnNetworkSpawn from definitionName.
// Null if the lookup fails (missing TowerRegistry or unknown name).
private TowerDefinition resolvedDefinition;
// ----- Pre-spawn initialization data ----------------------------------
//
// Set by InitializeServer (called by TowerPlacementManager BEFORE Spawn).
// Read by the server's OnNetworkSpawn to populate the NetworkVariables.
//
// Why this two-step dance: NGO 2.x disallows writing NetworkVariables
// before NetworkObject.Spawn() — those writes produce warnings and may
// not replicate reliably. The supported pattern is to set NVs inside
// OnNetworkSpawn on the server; NGO captures those writes and includes
// them in the initial sync message sent to clients, so every client
// sees correct values on its very first OnNetworkSpawn callback.
private TowerDefinition pendingDefinition;
private Vector2Int pendingAnchor;
private PlayerSlot pendingOwner = PlayerSlot.None;
private bool hasPendingInit;
// ----- Public accessors -----------------------------------------------
/// <summary>The TowerDefinition for this tower, resolved locally. Null until
/// <see cref="OnNetworkSpawn"/> runs and the definition lookup succeeds.</summary>
public TowerDefinition Definition => resolvedDefinition;
/// <summary>The PlayerSlot that placed this tower.</summary>
public PlayerSlot Owner => ownerSlot.Value;
/// <summary>The footprint anchor tile (SW corner, world-tile coords).</summary>
public Vector2Int AnchorTile => anchorTile.Value;
// ----- Server-only initialization -------------------------------------
/// <summary>
/// Called by <c>TowerPlacementManager</c> on the server immediately after
/// instantiation and before <c>NetworkObject.Spawn</c>. Stores the data that
/// the server's <see cref="OnNetworkSpawn"/> will copy into the
/// NetworkVariables. NetworkVariables themselves are NOT written here —
/// see the comment on the pending-init fields above for why.
/// </summary>
public void InitializeServer(TowerDefinition def, Vector2Int anchor, PlayerSlot owner)
{
var nm = NetworkManager.Singleton;
if (nm == null || !nm.IsServer)
{
Debug.LogError("[TowerInstance] InitializeServer called when not running " +
"as a server. This must only be called by " +
"TowerPlacementManager on the server.");
return;
}
pendingDefinition = def;
pendingAnchor = anchor;
pendingOwner = owner;
hasPendingInit = true;
// Cache the resolved definition on the server immediately — clients
// will resolve via the registry lookup once definitionName arrives.
resolvedDefinition = def;
}
// ----- NGO lifecycle --------------------------------------------------
public override void OnNetworkSpawn()
{
// Server-only step: now that the NetworkObject is fully spawned,
// write the pending init values to the NetworkVariables. These writes
// will be captured in the initial sync message sent to clients, so
// every client sees correct values on its very first OnNetworkSpawn.
if (IsServer && hasPendingInit)
{
definitionName.Value = new FixedString64Bytes(pendingDefinition.name);
anchorTile.Value = pendingAnchor;
ownerSlot.Value = pendingOwner;
// Clear the pending data — it's now committed to NetworkVariables.
hasPendingInit = false;
}
// Resolve the TowerDefinition from the (now-available) replicated name.
// On the server this is already set by InitializeServer; the lookup is
// redundant but harmless and keeps the code path uniform.
ResolveDefinition();
// Stamp the footprint into the local LevelLoader grids.
// The server already stamped in TowerPlacementManager before Spawn(),
// but SetWalkable/SetOccupied are idempotent — double-stamping is safe.
StampFootprint(walkable: false, occupied: true);
// Apply owner color to the mesh renderer.
ApplyOwnerColor();
// Register for minimap rendering.
MinimapEntityRegistry.Register(this);
if (resolvedDefinition != null)
{
Debug.Log($"[TowerInstance] Spawned '{resolvedDefinition.DisplayName}' " +
$"for {ownerSlot.Value} at anchor {anchorTile.Value}. " +
$"IsServer={IsServer}");
}
}
public override void OnNetworkDespawn()
{
// Un-stamp the footprint when the tower is destroyed (sold, wave end, etc.)
// so the tiles become walkable and buildable again.
StampFootprint(walkable: true, occupied: false);
MinimapEntityRegistry.Deregister(this);
}
// ----- IMinimapEntity -------------------------------------------------
//
// Towers are static, so WorldPosition is cheap (no movement to track). Color reflects
// the replicated ownerSlot; reads safely on every client because ownerSlot is set in
// OnNetworkSpawn before this entity is added to the registry.
Vector3 IMinimapEntity.WorldPosition => transform.position;
Color IMinimapEntity.MinimapColor => PlayerColors.Get(ownerSlot.Value);
MinimapIconKind IMinimapEntity.IconKind => MinimapIconKind.Tower;
// Tower footprint in world units. Uses the larger axis if the footprint isn't square,
// so an Nx1 tower still occupies its full long-side on the minimap.
// Falls back to one tile if the definition hasn't resolved yet (transient, harmless).
float IMinimapEntity.MinimapWorldSize
{
get
{
if (resolvedDefinition == null) return GridCoordinates.TILE_SIZE;
int extent = Mathf.Max(
resolvedDefinition.FootprintSize.x,
resolvedDefinition.FootprintSize.y);
return extent * GridCoordinates.TILE_SIZE;
}
}
// ----- Private helpers ------------------------------------------------
private void ResolveDefinition()
{
// Already resolved (server path via InitializeServer).
if (resolvedDefinition != null) return;
string defName = definitionName.Value.ToString();
if (string.IsNullOrEmpty(defName))
{
Debug.LogError($"[TowerInstance] NetworkObject {NetworkObjectId}: " +
$"definitionName is empty. Cannot resolve TowerDefinition.");
return;
}
var registry = TowerRegistry.Instance;
if (registry == null)
{
Debug.LogError($"[TowerInstance] NetworkObject {NetworkObjectId}: " +
$"No TowerRegistry found in scene. Cannot resolve '{defName}'.");
return;
}
resolvedDefinition = registry.Get(defName);
if (resolvedDefinition == null)
{
Debug.LogError($"[TowerInstance] NetworkObject {NetworkObjectId}: " +
$"TowerRegistry does not contain a definition named '{defName}'.");
}
}
private void StampFootprint(bool walkable, bool occupied)
{
var loader = LevelLoader.Instance;
if (loader == null || !loader.IsLoaded)
{
Debug.LogWarning($"[TowerInstance] NetworkObject {NetworkObjectId}: " +
$"LevelLoader not available during footprint stamp. " +
$"Grids may be out of sync.");
return;
}
// Determine footprint size from the resolved definition, falling back to
// 2×2 if the definition hasn't resolved yet (shouldn't happen, but defensive).
Vector2Int footprintSize = resolvedDefinition != null
? resolvedDefinition.FootprintSize
: new Vector2Int(2, 2);
foreach (var tile in GridCoordinates.GetFootprintTiles(anchorTile.Value, footprintSize))
{
loader.SetWalkable(tile, walkable);
loader.SetOccupied(tile, occupied);
}
}
// Reused per-instance across color updates to avoid per-call GC allocation.
// MaterialPropertyBlock is not thread-safe but all rendering runs on the
// main thread, so a single instance per TowerInstance is fine.
private MaterialPropertyBlock colorPropertyBlock;
private static readonly int ColorPropertyId = Shader.PropertyToID("_Color");
// URP Lit uses _BaseColor, not _Color. Writing both ensures the tint applies
// regardless of which shader the prefab uses; unknown property writes are
// silently ignored.
private static readonly int BaseColorPropertyId = Shader.PropertyToID("_BaseColor");
private void ApplyOwnerColor()
{
Color ownerColor = PlayerColors.Get(ownerSlot.Value);
ownerColor.a = 1f;
// MaterialPropertyBlock sets per-renderer properties without allocating
// a new Material object. Safe to reuse across calls on the same instance.
// All Unity standard/URP shaders expose _Color or _BaseColor, so no shader changes needed.
colorPropertyBlock ??= new MaterialPropertyBlock();
colorPropertyBlock.SetColor(ColorPropertyId, ownerColor);
colorPropertyBlock.SetColor(BaseColorPropertyId, ownerColor);
var renderers = GetComponentsInChildren<MeshRenderer>();
foreach (var rend in renderers)
rend.SetPropertyBlock(colorPropertyBlock);
if (renderers.Length == 0)
{
Debug.LogWarning($"[TowerInstance] NetworkObject {NetworkObjectId}: " +
$"No MeshRenderers found for owner color tinting.");
}
}
}
}