UnityTowerDefense/Assets/_Project/Scripts/Gameplay/TowerInstance.cs
Matt F 1794f65b19 Fix sell hitch: batch footprint un-stamp; warn on missing SellEffectSpawner
TowerInstance.StampFootprint now writes walkability via SetWalkableBatch instead of per-tile SetWalkable, so despawning (selling) a 2x2 tower fires one OnWalkabilityChanged / enemy re-path instead of four — matching placement and removing the frame hitch on sell. PlaySellEffectRpc now logs a one-time warning when no SellEffectSpawner is in the scene, since a missing spawner silently suppressed the coin VFX and sound.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:44:11 -07:00

587 lines
No EOL
29 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 System.Collections.Generic;
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 tower's <c>TowerTypeId</c> (its index in
/// <see cref="TowerPlacementManager"/>'s catalog) via a <see cref="NetworkVariable{T}"/>,
/// then resolve the full asset locally with <see cref="TowerPlacementManager.GetDefinition"/>.
/// This avoids serializing the ScriptableObject over the network and reuses the same
/// identifier placement and the deck already use — a single source of truth.</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, 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<int> definitionTypeId =
new NetworkVariable<int>(
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<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);
// 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> paintColor =
new NetworkVariable<PaintColor>(
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<int> goldInvested =
new NetworkVariable<int>(
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<int> upgradeCount =
new NetworkVariable<int>(
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 -----------------------------------------------
/// <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 paint color applied to this tower, or <see cref="PaintColor.None"/>
/// if unpainted (showing its owner color).</summary>
public PaintColor Paint => paintColor.Value;
/// <summary>The footprint anchor tile (SW corner, world-tile coords).</summary>
public Vector2Int AnchorTile => anchorTile.Value;
/// <summary>Total gold sunk into this tower so far (placement + upgrades).</summary>
public int GoldInvested => goldInvested.Value;
/// <summary>How many upgrades have been applied to this tower (0 = never upgraded).</summary>
public int UpgradeCount => upgradeCount.Value;
/// <summary>World-unit height the post-construction drop animation falls from.</summary>
public float DropHeight => dropHeight;
/// <summary>Seconds the drop/land animation takes. Also how long TowerCombat withholds
/// targeting/attacks after construction completes.</summary>
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;
/// <summary>Display name shown in the HUD portrait when this tower is selected.</summary>
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 ---------------------------------------------------------
/// <summary>Fired on ALL peers when this tower finishes construction and spawns.</summary>
public event System.Action OnBuiltClient;
/// <summary>Fired locally on this peer when the client-side fall/land animation
/// finishes (see <see cref="TD.Combat.TowerLandingVisual"/>). Analogous to
/// <see cref="OnBuiltClient"/>, but delayed by <see cref="LandingDuration"/>.</summary>
public event System.Action OnLandedClient;
/// <summary>Called by <see cref="TD.Combat.TowerLandingVisual"/> when its local
/// fall/land animation finishes. Purely local — never invoked on a dedicated
/// (non-client) server, which never runs that animation.</summary>
public void ClientNotifyLanded() => OnLandedClient?.Invoke();
// ----- 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
// 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;
// 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;
// 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 -----------------------------------------------------
/// <summary>
/// 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). <see cref="PaintColor.None"/> resets the tower to its owner
/// color. The change replicates back to every client via
/// <see cref="paintColor"/>'s OnValueChanged.
/// </summary>
[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;
/// <summary>
/// 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 <see cref="TowerDefinition.SellRefundPercent"/> of everything
/// invested; a tower flagged <see cref="TowerDefinition.FullRefundIfUnupgraded"/>
/// (the Wall) returns the full amount while it has never been upgraded.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public void ServerAddUpgradeInvestment(int cost)
{
if (!IsServer) return;
if (cost > 0) goldInvested.Value += cost;
upgradeCount.Value += 1;
}
/// <summary>
/// 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 —
/// <see cref="OnNetworkDespawn"/> restores the footprint's grid state and clears
/// selection on every peer.
/// </summary>
[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<Vector2Int>(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<MeshRenderer>(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;
}
}
}