// Assets/_Project/Scripts/Gameplay/TowerInstance.cs using Unity.Collections; using Unity.Netcode; using UnityEngine; using TD.Core; using TD.Towers; namespace TD.Gameplay { /// /// Per-tower runtime component. Lives on the tower's NetworkObject prefab root. /// /// Responsibilities: /// /// Hold the network-replicated identity of this tower: which /// it is and which owns it. /// On , stamp the tower's footprint into /// on every client so local grids stay in sync /// with the server-authoritative state. /// Apply the owner's player color to the tower mesh, so towers are /// visually distinct by zone during testing. /// /// /// /// Grid stamping split. The server stamps the footprint in /// TowerPlacementManager.ProcessRequest (before NetworkObject.Spawn) /// so the path-validity check in the same frame sees the updated grid. Non-host /// clients stamp in when NGO replicates the /// NetworkObject to them. The server's also runs, /// but by then the footprint is already stamped — and /// are idempotent writes, so double-stamping is safe. /// /// Definition reference replication. TowerDefinition assets live in the /// project on all clients. We replicate the asset by name via a /// holding a FixedString64Bytes, then look /// up the asset locally. This avoids serializing the full ScriptableObject over the /// network. The lookup uses a singleton that must be /// present in the scene. (Temporary: will be driven by RaceDefinition in Path E.) /// /// Combat. No combat logic here yet. Combat fields live stubbed on /// ; they will be consumed by a future /// TowerCombat component added to the same prefab. /// [RequireComponent(typeof(NetworkObject))] public class TowerInstance : NetworkBehaviour { // ----- 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 definitionName = new NetworkVariable( 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 anchorTile = new NetworkVariable( 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 ownerSlot = new NetworkVariable( 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 ----------------------------------------------- /// The TowerDefinition for this tower, resolved locally. Null until /// runs and the definition lookup succeeds. public TowerDefinition Definition => resolvedDefinition; /// The PlayerSlot that placed this tower. public PlayerSlot Owner => ownerSlot.Value; /// The footprint anchor tile (SW corner, world-tile coords). public Vector2Int AnchorTile => anchorTile.Value; // ----- Server-only initialization ------------------------------------- /// /// Called by TowerPlacementManager on the server immediately after /// instantiation and before NetworkObject.Spawn. Stores the data that /// the server's will copy into the /// NetworkVariables. NetworkVariables themselves are NOT written here — /// see the comment on the pending-init fields above for why. /// 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(); 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); } // ----- 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(); foreach (var rend in renderers) rend.SetPropertyBlock(colorPropertyBlock); if (renderers.Length == 0) { Debug.LogWarning($"[TowerInstance] NetworkObject {NetworkObjectId}: " + $"No MeshRenderers found for owner color tinting."); } } } }