304 lines
No EOL
14 KiB
C#
304 lines
No EOL
14 KiB
C#
// 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.");
|
||
}
|
||
}
|
||
}
|
||
} |