UnityTowerDefense/Assets/_Project/Scripts/Gameplay/TowerInstance.cs
Matt F 4892d7253d First pass at full refactor to 2.0 design
Restructures the game around the cyclical run loop from Game Design Doc V2:
5 waves = a cycle, 3 cycles = a phase, each phase ends in a boss.

- New TD.Gameplay.Waves: WaveGroup / PhaseDefinition / RunDefinition author the
  run as draggable weighted pools; RunState owns phase/cycle position, the drawn
  wave slots, and the per-slot enemy buff sets. WaveManager's flat wave array is
  gone -- it now only runs the encounter RunState points at.
- New TD.Gameplay.EnemyUpgrades: the post-wave enemy-buff vote, with public
  live-replicated ballots so the HUD can show who voted for what.
- Inter-wave flow is now strictly sequential: draft -> vote -> build, each stage
  ending early once every player has acted.
- Enemy abilities inverted from per-instance random rolls to deterministic,
  stacking per-wave-slot sets. Six cards ship: Split (reworked), Flight, Blink,
  No Bounty, Gold Theft, Double Up.
- Tower upgrades are a two-step tree: a draft pick unlocks a node, gold converts
  an already-placed tower in place.
- Boss encounters flag their enemies and drive a boss HP bar.
- Player cap reduced to 3 via MatchRules.MaxPlayers.
- GoldConfig is now keyed by global encounter number rather than wave index.

Compiles clean; NOT yet verified in-engine. Editor wiring still required --
see Docs/2.0_Setup_Checklist.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:45:11 -07:00

738 lines
No EOL
36 KiB
C#
Raw Permalink 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;
// 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 -----------------------------------------------------
/// <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;
}
// ----- Upgrading ------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
public event System.Action OnDefinitionChanged;
/// <summary>
/// Gold cost to convert this tower into <paramref name="target"/>. The target's own
/// <see cref="TowerDefinition.GoldCost"/> is the price — upgrade nodes are never placed
/// directly, so their cost field is free to mean "what this upgrade costs".
/// </summary>
public static int GetUpgradeCost(TowerDefinition target) => target != null ? target.GoldCost : 0;
/// <summary>
/// True if <paramref name="target"/> is a legal upgrade of this tower's current type: a
/// direct child in the upgrade tree, with a matching footprint.
/// </summary>
/// <remarks>
/// <b>The footprint check is not cosmetic.</b> 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.
/// </remarks>
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;
}
/// <summary>
/// Collects the upgrades <paramref name="clientId"/> 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.
/// </summary>
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));
}
}
/// <summary>
/// Client → server request to convert this tower into <paramref name="targetTypeId"/>.
/// Accepted only from the owner, only for a node they've unlocked, only along a real tree
/// edge, and only if they can pay.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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();
}
/// <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;
}
}
}