// 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 { /// /// 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, IMinimapEntity, ISelectable { // ----- Inspector -------------------------------------------------- [Header("Visuals")] [Tooltip("Mesh renderers tinted with the owner's player color (and the Paint " + "tool's color). Drag in only the tower body's renderers to exclude " + "anything with its own color rules (selection rings, range indicators, " + "FX). If left EMPTY, every MeshRenderer under the tower is tinted " + "automatically — fine for most prefabs; populate this only when you need " + "to exclude specific children.")] [SerializeField] private MeshRenderer[] tintedRenderers; // ----- 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); // Paint color applied by the Paint tool. None means "unpainted" — the tower // shows its owner color. Set server-side via RequestPaintServerRpc (own-tower // only). Replicated so every client re-tints; later iterations will read this // to drive projectile behavior. private readonly NetworkVariable paintColor = new NetworkVariable( PaintColor.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 paint color applied to this tower, or /// if unpainted (showing its owner color). public PaintColor Paint => paintColor.Value; /// The footprint anchor tile (SW corner, world-tile coords). public Vector2Int AnchorTile => anchorTile.Value; // ----- ISelectable ---------------------------------------------------- // Absolute world-unit margin that the selection ring extends beyond the // tower's footprint edges. Tuned for visibility — the tower body sits ON // the ring at ground level, so only the area outside the footprint is // actually rendered. Too small (was 0.15) and the ring is invisible under // anything taller than a paving stone. 0.5 gives a half-tile-wide visible // band around the tower at any footprint size. private const float SelectionRingPadding = 0.5f; /// Display name shown in the HUD portrait when this tower is selected. public string DisplayName => resolvedDefinition != null ? resolvedDefinition.DisplayName : "Tower"; public SelectableKind Kind => SelectableKind.Tower; public Transform SelectionTransform => transform; // Ring radius derived from footprint: max axis * 0.5 * tile size, plus a // small padding so the ring is visible outside the tower's edges. Falls // back to 1×1 if the definition hasn't resolved yet (transient, harmless — // the HUD won't allow selection until OnNetworkSpawn finishes anyway). public float SelectionRadius { get { Vector2Int fp = resolvedDefinition != null ? resolvedDefinition.FootprintSize : new Vector2Int(1, 1); return Mathf.Max(fp.x, fp.y) * 0.5f * GridCoordinates.TILE_SIZE + SelectionRingPadding; } } // ----- 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 the tower tint (paint color if painted, else owner color), and // re-tint on every client whenever the paint color changes. ApplyTint(); paintColor.OnValueChanged += HandlePaintColorChanged; // Register for minimap rendering. MinimapEntityRegistry.Register(this); // Selection auto-transfer: if a BuildSiteVisual at our anchor is the // active local selection, the player was watching this tower complete — // hand selection off to the new TowerInstance so the HUD/visualizer // transition smoothly. Server's completion order (spawn THEN despawn) // means we get here BEFORE the BuildSiteVisual's OnNetworkDespawn, // so the old reference is still valid and selected. var selState = SelectionState.Instance; if (selState != null && selState.SelectedObject is BuildSiteVisual bsv && bsv.Anchor == anchorTile.Value) { selState.Select(this); } if (resolvedDefinition != null) { Debug.Log($"[TowerInstance] Spawned '{resolvedDefinition.DisplayName}' " + $"for {ownerSlot.Value} at anchor {anchorTile.Value}. " + $"IsServer={IsServer}"); } } public override void OnNetworkDespawn() { paintColor.OnValueChanged -= HandlePaintColorChanged; // 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); // Clear local selection if THIS tower was selected. Without this, // SelectionState (and any subscriber holding our reference — HUD, // SelectionVisualizer) keeps pointing at a soon-to-be-destroyed Unity // object and throws MissingReferenceException on the next access. if (SelectionState.Instance != null && SelectionState.Instance.IsSelected(this)) SelectionState.Instance.Clear(); } // ----- Paint tool ----------------------------------------------------- /// /// Client → server request to paint this tower. The server applies the color /// only if the requesting client owns this tower (matching the placement /// ownership rule). resets the tower to its owner /// color. The change replicates back to every client via /// 's OnValueChanged. /// [Rpc(SendTo.Server)] public void RequestPaintServerRpc(PaintColor color, RpcParams rpcParams = default) { PlayerSlot senderSlot = PlayerMatchState.SlotForClient(rpcParams.Receive.SenderClientId); if (senderSlot == PlayerSlot.None || senderSlot != ownerSlot.Value) { Debug.Log($"[TowerInstance] Paint rejected: client " + $"{rpcParams.Receive.SenderClientId} ({senderSlot}) does not own " + $"tower owned by {ownerSlot.Value}."); return; } paintColor.Value = color; } // Re-tint on every client (and the server) when the replicated paint color changes. private void HandlePaintColorChanged(PaintColor previous, PaintColor current) => ApplyTint(); // ----- 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 ApplyTint() { // Paint color takes precedence when set; otherwise fall back to the owner // color. Paint.None means "unpainted" → show owner color. Color tint = paintColor.Value != PaintColor.None ? PaintColors.Get(paintColor.Value) : PlayerColors.Get(ownerSlot.Value); tint.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 writing // both lets the tint apply regardless of which shader the prefab uses. colorPropertyBlock ??= new MaterialPropertyBlock(); colorPropertyBlock.SetColor(ColorPropertyId, tint); colorPropertyBlock.SetColor(BaseColorPropertyId, tint); foreach (var rend in ResolveTintRenderers()) { if (rend == null) continue; rend.SetPropertyBlock(colorPropertyBlock); } } // Renderers actually tinted. Prefer the inspector-assigned list (lets a prefab // exclude decorative children, FX, etc.). When that list is empty — the common // case for imported models nobody has hand-wired — fall back to every MeshRenderer // under the tower so owner-color and paint Just Work without per-prefab setup. // Cached after the first resolve. private MeshRenderer[] resolvedTintRenderers; private MeshRenderer[] ResolveTintRenderers() { if (tintedRenderers != null && tintedRenderers.Length > 0) return tintedRenderers; if (resolvedTintRenderers == null) { resolvedTintRenderers = GetComponentsInChildren(includeInactive: true); if (resolvedTintRenderers.Length == 0) Debug.LogWarning($"[TowerInstance] '{name}' has no MeshRenderers to tint — " + $"owner color and paint will have no visible effect."); } return resolvedTintRenderers; } } }