// Assets/_Project/Scripts/Gameplay/TowerInstance.cs using System.Collections.Generic; 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 tower's TowerTypeId (its index in /// 's catalog) via a , /// then resolve the full asset locally with . /// This avoids serializing the ScriptableObject over the network and reuses the same /// identifier placement and the deck already use — a single source of truth. /// /// 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; [Header("Landing (post-construction drop)")] [Tooltip("World-unit height the tower visually drops from when construction completes. " + "Consumed only by the client-side fall visual (TowerLandingVisual).")] [SerializeField] private float dropHeight = 15f; [Tooltip("Seconds the fall-and-land animation takes. Single source of truth: " + "TowerCombat withholds targeting/attacks for exactly this long " + "(server-authoritative), and TowerLandingVisual uses the same value to time " + "its client-side animation, so the two stay in lockstep without duplicating " + "the number.")] [SerializeField] private float landingDuration = 0.4f; // ----- Networked state ------------------------------------------------ // The TowerTypeId (index into TowerPlacementManager's catalog) for this tower. // Replicated so every client resolves the full definition locally via the catalog — // the same identifier placement and the deck already use. 0 = unset/invalid (the // reserved catalog index). Replaces the old replicate-by-name + TowerRegistry path. private readonly NetworkVariable definitionTypeId = new NetworkVariable( 0, 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); // Total gold sunk into this tower: its placement cost plus any gold later spent // upgrading it. Set on the server at spawn (= placement cost) and grown by the // (future) upgrade system via ServerAddUpgradeInvestment. Replicated so the HUD's // Sell button can preview the exact refund without a server round-trip. private readonly NetworkVariable goldInvested = new NetworkVariable( 0, readPerm: NetworkVariableReadPermission.Everyone, writePerm: NetworkVariableWritePermission.Server); // Number of upgrades applied to this tower. 0 = never upgraded. Drives the Wall's // "full refund only while un-upgraded" rule and will back tier display later. private readonly NetworkVariable upgradeCount = new NetworkVariable( 0, readPerm: NetworkVariableReadPermission.Everyone, writePerm: NetworkVariableWritePermission.Server); // ----- Local resolved state ------------------------------------------- // Resolved on every client in OnNetworkSpawn from definitionTypeId via the catalog. // Null if the lookup fails (TypeId not in the catalog on this peer). 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; /// Total gold sunk into this tower so far (placement + upgrades). public int GoldInvested => goldInvested.Value; /// How many upgrades have been applied to this tower (0 = never upgraded). public int UpgradeCount => upgradeCount.Value; /// World-unit height the post-construction drop animation falls from. public float DropHeight => dropHeight; /// Seconds the drop/land animation takes. Also how long TowerCombat withholds /// targeting/attacks after construction completes. public float LandingDuration => landingDuration; // ----- 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; } } // ----- Events --------------------------------------------------------- /// Fired on ALL peers when this tower finishes construction and spawns. public event System.Action OnBuiltClient; /// Fired locally on this peer when the client-side fall/land animation /// finishes (see ). Analogous to /// , but delayed by . public event System.Action OnLandedClient; /// Called by when its local /// fall/land animation finishes. Purely local — never invoked on a dedicated /// (non-client) server, which never runs that animation. public void ClientNotifyLanded() => OnLandedClient?.Invoke(); // ----- 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 // resolve from the replicated TowerTypeId via the catalog once it 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) { // Resolve the catalog index for this definition and replicate that. int typeId = 0; var pm = TowerPlacementManager.Instance; if (pm == null || !pm.TryGetTypeId(pendingDefinition, out typeId)) { Debug.LogError($"[TowerInstance] Could not resolve a TowerTypeId for " + $"'{pendingDefinition?.name}'. Ensure it is in the " + $"TowerPlacementManager catalog (towerDefinitions)."); } definitionTypeId.Value = typeId; anchorTile.Value = pendingAnchor; ownerSlot.Value = pendingOwner; // Seed invested gold with the placement cost — the same amount // TowerPlacementManager deducted to build this tower. The upgrade system // grows this later via ServerAddUpgradeInvestment. goldInvested.Value = pendingDefinition != null ? pendingDefinition.GoldCost : 0; // 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; // An upgrade swaps the replicated TypeId out from under every peer; re-resolve so // clients pick up the new stats and tint instead of holding the pre-upgrade asset. definitionTypeId.OnValueChanged += HandleDefinitionTypeChanged; // 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}"); } OnBuiltClient?.Invoke(); } public override void OnNetworkDespawn() { paintColor.OnValueChanged -= HandlePaintColorChanged; definitionTypeId.OnValueChanged -= HandleDefinitionTypeChanged; // 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(); // ----- Selling -------------------------------------------------------- // Server-only guard: a fast double-click could deliver two sell RPCs before the // despawn propagates. Mirrors BuildSiteVisual.serverCancelled. private bool serverSold; /// /// Gold returned if this tower is sold right now. Single source of truth for both /// the server (which awards it) and the HUD (which labels the Sell button). By /// default this is of everything /// invested; a tower flagged /// (the Wall) returns the full amount while it has never been upgraded. /// public int ComputeSellRefund() { int invested = goldInvested.Value; if (resolvedDefinition == null) return Mathf.RoundToInt(invested * 0.75f); if (resolvedDefinition.FullRefundIfUnupgraded && upgradeCount.Value == 0) return invested; return Mathf.RoundToInt(invested * resolvedDefinition.SellRefundPercent); } /// /// Server-only: records gold spent upgrading this tower (so a later sell refunds a /// share of it) and marks the tower upgraded — which forfeits any /// full-refund-while-unupgraded rule. The upgrade system calls this when it lands. /// public void ServerAddUpgradeInvestment(int cost) { if (!IsServer) return; if (cost > 0) goldInvested.Value += cost; upgradeCount.Value += 1; } // ----- Upgrading ------------------------------------------------------ /// /// Fired on every peer when this tower's definition changes (i.e. it was upgraded). /// The HUD subscribes to relabel a selected tower's action grid. /// public event System.Action OnDefinitionChanged; /// /// Gold cost to convert this tower into . The target's own /// is the price — upgrade nodes are never placed /// directly, so their cost field is free to mean "what this upgrade costs". /// public static int GetUpgradeCost(TowerDefinition target) => target != null ? target.GoldCost : 0; /// /// True if is a legal upgrade of this tower's current type: a /// direct child in the upgrade tree, with a matching footprint. /// /// /// The footprint check is not cosmetic. A tower's occupied/unwalkable tiles are /// stamped into the grid at its current size; converting to a differently-sized node would /// leave the stamp describing a shape the tower no longer has, corrupting both pathfinding /// and future placement checks. Growing a tower's footprint needs a stamp-swap (and a /// re-validation that the new tiles are even free), which the tree doesn't currently need — /// so it's rejected loudly rather than half-supported. /// public bool CanUpgradeTo(TowerDefinition target) { if (target == null || resolvedDefinition == null) return false; if (resolvedDefinition.UpgradePaths == null) return false; bool isChild = false; foreach (var path in resolvedDefinition.UpgradePaths) { if (path == target) { isChild = true; break; } } if (!isChild) return false; return target.FootprintSize == resolvedDefinition.FootprintSize; } /// /// Collects the upgrades can currently apply to this tower: /// direct children of its type that the player has unlocked. Does not filter on gold — /// the HUD shows unaffordable upgrades disabled rather than hiding them, so players can /// see what they're saving for. /// public void CollectAvailableUpgrades(ulong clientId, List<(TowerDefinition Def, int TypeId)> into) { into.Clear(); var unlocked = PlayerTowerUpgrades.GetForClient(clientId); var pm = TowerPlacementManager.Instance; if (unlocked == null || pm == null || resolvedDefinition?.UpgradePaths == null) return; foreach (var path in resolvedDefinition.UpgradePaths) { if (path == null) continue; if (!pm.TryGetTypeId(path, out int typeId)) continue; if (!unlocked.Contains(typeId)) continue; if (!CanUpgradeTo(path)) continue; into.Add((path, typeId)); } } /// /// Client → server request to convert this tower into . /// Accepted only from the owner, only for a node they've unlocked, only along a real tree /// edge, and only if they can pay. /// /// /// Every check is repeated here even though the HUD already filters — the HUD is a /// convenience, this is the authority. Same posture as placement, paint, and sell. /// [Rpc(SendTo.Server)] public void RequestUpgradeServerRpc(int targetTypeId, RpcParams rpcParams = default) { if (!IsServer) return; if (serverSold) return; // mid-sell; nothing to upgrade ulong senderClientId = rpcParams.Receive.SenderClientId; PlayerSlot senderSlot = PlayerMatchState.SlotForClient(senderClientId); if (senderSlot == PlayerSlot.None || senderSlot != ownerSlot.Value) { Debug.Log($"[TowerInstance] Upgrade rejected: client {senderClientId} " + $"({senderSlot}) does not own tower owned by {ownerSlot.Value}."); return; } var target = TowerPlacementManager.GetDefinition(targetTypeId); if (target == null) { Debug.Log($"[TowerInstance] Upgrade rejected: TypeId {targetTypeId} is not in the catalog."); return; } if (!CanUpgradeTo(target)) { Debug.Log($"[TowerInstance] Upgrade rejected: '{target.name}' is not a " + $"same-footprint child of '{resolvedDefinition?.name}'."); return; } var unlocked = PlayerTowerUpgrades.GetForClient(senderClientId); if (unlocked == null || !unlocked.Contains(targetTypeId)) { Debug.Log($"[TowerInstance] Upgrade rejected: client {senderClientId} has not " + $"unlocked '{target.name}'."); return; } int cost = GetUpgradeCost(target); var gold = PlayerGoldManager.GetForClient(senderClientId); if (gold == null || gold.CurrentGold < cost) { Debug.Log($"[TowerInstance] Upgrade rejected: client {senderClientId} cannot " + $"afford '{target.name}' ({cost}g)."); return; } if (cost > 0) gold.DeductGold(cost); // Record the spend before switching type, so the refund reflects everything sunk in // and the tower loses any full-refund-while-unupgraded status. ServerAddUpgradeInvestment(cost); // Switching the replicated TypeId is the upgrade: TowerCombat re-reads Definition // every tick, so the new stats take effect on the next shot with nothing to notify. definitionTypeId.Value = targetTypeId; resolvedDefinition = target; OnDefinitionChanged?.Invoke(); } // Re-resolve and re-tint on clients when the type changes under them (an upgrade landed). private void HandleDefinitionTypeChanged(int previous, int current) { if (previous == current) return; resolvedDefinition = TowerPlacementManager.GetDefinition(current); ApplyTint(); OnDefinitionChanged?.Invoke(); } /// /// Client → server request to sell this tower. Accepted only from the tower's owner /// (same ownership rule as placement and paint). The server refunds gold, broadcasts /// the sell VFX/SFX from a persistent object, then despawns the tower — /// restores the footprint's grid state and clears /// selection on every peer. /// [Rpc(SendTo.Server)] public void RequestSellServerRpc(RpcParams rpcParams = default) { if (!IsServer) return; if (serverSold) return; // idempotent guard against a double-click ulong senderClientId = rpcParams.Receive.SenderClientId; PlayerSlot senderSlot = PlayerMatchState.SlotForClient(senderClientId); if (senderSlot == PlayerSlot.None || senderSlot != ownerSlot.Value) { Debug.Log($"[TowerInstance] Sell rejected: client {senderClientId} " + $"({senderSlot}) does not own tower owned by {ownerSlot.Value}."); return; } serverSold = true; int refund = ComputeSellRefund(); var goldManager = PlayerGoldManager.GetForClient(senderClientId); if (goldManager != null && refund > 0) // countAsEarned: false — a sell refund returns spent gold, it is not round // income, so it must not inflate the per-wave "earned" counter. goldManager.AwardGold(refund, countAsEarned: false); // Broadcast VFX/SFX from the persistent placement manager, capturing the world // position NOW — this NetworkObject despawns below, so it can't carry the RPC to // remote peers itself (same reason WaveManager routes kill/leak popups). var pm = TowerPlacementManager.Instance; if (pm != null) pm.BroadcastSellEffect(transform.position); if (NetworkObject != null && NetworkObject.IsSpawned) NetworkObject.Despawn(destroy: true); } // ----- 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; int typeId = definitionTypeId.Value; resolvedDefinition = TowerPlacementManager.GetDefinition(typeId); if (resolvedDefinition == null) { Debug.LogError($"[TowerInstance] NetworkObject {NetworkObjectId}: " + $"no TowerDefinition for TypeId {typeId}. Is the " + $"TowerPlacementManager catalog populated on this peer?"); } } 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); // Collect the footprint, then stamp walkability as a BATCH so a single // OnWalkabilityChanged fires for the whole footprint. Per-tile SetWalkable fires // that event once PER TILE — on despawn (sell) that meant up to 4 full enemy A* // re-paths for a 2×2 tower in one frame, which was the sell hitch. Placement // batches for the same reason (TowerPlacementManager.StampWalkable). Occupancy // doesn't fire walkability events, so it stays per-tile. var footprint = new List(footprintSize.x * footprintSize.y); foreach (var tile in GridCoordinates.GetFootprintTiles(anchorTile.Value, footprintSize)) { footprint.Add(tile); loader.SetOccupied(tile, occupied); } loader.SetWalkableBatch(footprint, walkable); } // 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; } } }