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>
738 lines
No EOL
36 KiB
C#
738 lines
No EOL
36 KiB
C#
// 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;
|
||
}
|
||
}
|
||
} |