438 lines
No EOL
21 KiB
C#
438 lines
No EOL
21 KiB
C#
// Assets/_Project/Scripts/Gameplay/TowerInstance.cs
|
||
using Unity.Collections;
|
||
using Unity.Netcode;
|
||
using UnityEngine;
|
||
using TD.Core;
|
||
using TD.Towers;
|
||
using TD.UI.Minimap;
|
||
|
||
namespace TD.Gameplay
|
||
{
|
||
/// <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 asset by name via a
|
||
/// <see cref="NetworkVariable{T}"/> holding a <c>FixedString64Bytes</c>, then look
|
||
/// up the asset locally. This avoids serializing the full ScriptableObject over the
|
||
/// network. The lookup uses a <see cref="TowerRegistry"/> singleton that must be
|
||
/// present in the scene. (Temporary: will be driven by RaceDefinition in Path E.)</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;
|
||
|
||
// ----- Networked state ------------------------------------------------
|
||
|
||
// The name of the TowerDefinition asset for this tower. Replicated so all
|
||
// clients can look up the full definition locally without sending the whole
|
||
// ScriptableObject over the wire.
|
||
private readonly NetworkVariable<FixedString64Bytes> definitionName =
|
||
new NetworkVariable<FixedString64Bytes>(
|
||
default,
|
||
readPerm: NetworkVariableReadPermission.Everyone,
|
||
writePerm: NetworkVariableWritePermission.Server);
|
||
|
||
// The footprint anchor (SW corner, world-tile coords). Replicated so
|
||
// clients can stamp the correct tiles in OnNetworkSpawn.
|
||
private readonly NetworkVariable<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);
|
||
|
||
// ----- Local resolved state -------------------------------------------
|
||
|
||
// Resolved on every client in OnNetworkSpawn from definitionName.
|
||
// Null if the lookup fails (missing TowerRegistry or unknown name).
|
||
private TowerDefinition resolvedDefinition;
|
||
|
||
// ----- Pre-spawn initialization data ----------------------------------
|
||
//
|
||
// Set by InitializeServer (called by TowerPlacementManager BEFORE Spawn).
|
||
// Read by the server's OnNetworkSpawn to populate the NetworkVariables.
|
||
//
|
||
// Why this two-step dance: NGO 2.x disallows writing NetworkVariables
|
||
// before NetworkObject.Spawn() — those writes produce warnings and may
|
||
// not replicate reliably. The supported pattern is to set NVs inside
|
||
// OnNetworkSpawn on the server; NGO captures those writes and includes
|
||
// them in the initial sync message sent to clients, so every client
|
||
// sees correct values on its very first OnNetworkSpawn callback.
|
||
|
||
private TowerDefinition pendingDefinition;
|
||
private Vector2Int pendingAnchor;
|
||
private PlayerSlot pendingOwner = PlayerSlot.None;
|
||
private bool hasPendingInit;
|
||
|
||
// ----- Public accessors -----------------------------------------------
|
||
|
||
/// <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;
|
||
|
||
// ----- 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;
|
||
}
|
||
}
|
||
|
||
// ----- 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
|
||
// will resolve via the registry lookup once definitionName arrives.
|
||
resolvedDefinition = def;
|
||
}
|
||
|
||
// ----- NGO lifecycle --------------------------------------------------
|
||
|
||
public override void OnNetworkSpawn()
|
||
{
|
||
// Server-only step: now that the NetworkObject is fully spawned,
|
||
// write the pending init values to the NetworkVariables. These writes
|
||
// will be captured in the initial sync message sent to clients, so
|
||
// every client sees correct values on its very first OnNetworkSpawn.
|
||
if (IsServer && hasPendingInit)
|
||
{
|
||
definitionName.Value = new FixedString64Bytes(pendingDefinition.name);
|
||
anchorTile.Value = pendingAnchor;
|
||
ownerSlot.Value = pendingOwner;
|
||
|
||
// Clear the pending data — it's now committed to NetworkVariables.
|
||
hasPendingInit = false;
|
||
}
|
||
|
||
// Resolve the TowerDefinition from the (now-available) replicated name.
|
||
// On the server this is already set by InitializeServer; the lookup is
|
||
// redundant but harmless and keeps the code path uniform.
|
||
ResolveDefinition();
|
||
|
||
// Stamp the footprint into the local LevelLoader grids.
|
||
// The server already stamped in TowerPlacementManager before Spawn(),
|
||
// but SetWalkable/SetOccupied are idempotent — double-stamping is safe.
|
||
StampFootprint(walkable: false, occupied: true);
|
||
|
||
// Apply the tower tint (paint color if painted, else owner color), and
|
||
// re-tint on every client whenever the paint color changes.
|
||
ApplyTint();
|
||
paintColor.OnValueChanged += HandlePaintColorChanged;
|
||
|
||
// Register for minimap rendering.
|
||
MinimapEntityRegistry.Register(this);
|
||
|
||
// Selection auto-transfer: if a BuildSiteVisual at our anchor is the
|
||
// active local selection, the player was watching this tower complete —
|
||
// hand selection off to the new TowerInstance so the HUD/visualizer
|
||
// transition smoothly. Server's completion order (spawn THEN despawn)
|
||
// means we get here BEFORE the BuildSiteVisual's OnNetworkDespawn,
|
||
// so the old reference is still valid and selected.
|
||
var selState = SelectionState.Instance;
|
||
if (selState != null
|
||
&& selState.SelectedObject is BuildSiteVisual bsv
|
||
&& bsv.Anchor == anchorTile.Value)
|
||
{
|
||
selState.Select(this);
|
||
}
|
||
|
||
if (resolvedDefinition != null)
|
||
{
|
||
Debug.Log($"[TowerInstance] Spawned '{resolvedDefinition.DisplayName}' " +
|
||
$"for {ownerSlot.Value} at anchor {anchorTile.Value}. " +
|
||
$"IsServer={IsServer}");
|
||
}
|
||
}
|
||
|
||
public override void OnNetworkDespawn()
|
||
{
|
||
paintColor.OnValueChanged -= HandlePaintColorChanged;
|
||
|
||
// Un-stamp the footprint when the tower is destroyed (sold, wave end, etc.)
|
||
// so the tiles become walkable and buildable again.
|
||
StampFootprint(walkable: true, occupied: false);
|
||
|
||
MinimapEntityRegistry.Deregister(this);
|
||
|
||
// Clear local selection if THIS tower was selected. Without this,
|
||
// SelectionState (and any subscriber holding our reference — HUD,
|
||
// SelectionVisualizer) keeps pointing at a soon-to-be-destroyed Unity
|
||
// object and throws MissingReferenceException on the next access.
|
||
if (SelectionState.Instance != null && SelectionState.Instance.IsSelected(this))
|
||
SelectionState.Instance.Clear();
|
||
}
|
||
|
||
// ----- Paint tool -----------------------------------------------------
|
||
|
||
/// <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();
|
||
|
||
// ----- IMinimapEntity -------------------------------------------------
|
||
//
|
||
// Towers are static, so WorldPosition is cheap (no movement to track). Color reflects
|
||
// the replicated ownerSlot; reads safely on every client because ownerSlot is set in
|
||
// OnNetworkSpawn before this entity is added to the registry.
|
||
|
||
Vector3 IMinimapEntity.WorldPosition => transform.position;
|
||
Color IMinimapEntity.MinimapColor => PlayerColors.Get(ownerSlot.Value);
|
||
MinimapIconKind IMinimapEntity.IconKind => MinimapIconKind.Tower;
|
||
|
||
// Tower footprint in world units. Uses the larger axis if the footprint isn't square,
|
||
// so an Nx1 tower still occupies its full long-side on the minimap.
|
||
// Falls back to one tile if the definition hasn't resolved yet (transient, harmless).
|
||
float IMinimapEntity.MinimapWorldSize
|
||
{
|
||
get
|
||
{
|
||
if (resolvedDefinition == null) return GridCoordinates.TILE_SIZE;
|
||
int extent = Mathf.Max(
|
||
resolvedDefinition.FootprintSize.x,
|
||
resolvedDefinition.FootprintSize.y);
|
||
return extent * GridCoordinates.TILE_SIZE;
|
||
}
|
||
}
|
||
|
||
// ----- Private helpers ------------------------------------------------
|
||
|
||
private void ResolveDefinition()
|
||
{
|
||
// Already resolved (server path via InitializeServer).
|
||
if (resolvedDefinition != null) return;
|
||
|
||
string defName = definitionName.Value.ToString();
|
||
if (string.IsNullOrEmpty(defName))
|
||
{
|
||
Debug.LogError($"[TowerInstance] NetworkObject {NetworkObjectId}: " +
|
||
$"definitionName is empty. Cannot resolve TowerDefinition.");
|
||
return;
|
||
}
|
||
|
||
var registry = TowerRegistry.Instance;
|
||
if (registry == null)
|
||
{
|
||
Debug.LogError($"[TowerInstance] NetworkObject {NetworkObjectId}: " +
|
||
$"No TowerRegistry found in scene. Cannot resolve '{defName}'.");
|
||
return;
|
||
}
|
||
|
||
resolvedDefinition = registry.Get(defName);
|
||
if (resolvedDefinition == null)
|
||
{
|
||
Debug.LogError($"[TowerInstance] NetworkObject {NetworkObjectId}: " +
|
||
$"TowerRegistry does not contain a definition named '{defName}'.");
|
||
}
|
||
}
|
||
|
||
private void StampFootprint(bool walkable, bool occupied)
|
||
{
|
||
var loader = LevelLoader.Instance;
|
||
if (loader == null || !loader.IsLoaded)
|
||
{
|
||
Debug.LogWarning($"[TowerInstance] NetworkObject {NetworkObjectId}: " +
|
||
$"LevelLoader not available during footprint stamp. " +
|
||
$"Grids may be out of sync.");
|
||
return;
|
||
}
|
||
|
||
// Determine footprint size from the resolved definition, falling back to
|
||
// 2×2 if the definition hasn't resolved yet (shouldn't happen, but defensive).
|
||
Vector2Int footprintSize = resolvedDefinition != null
|
||
? resolvedDefinition.FootprintSize
|
||
: new Vector2Int(2, 2);
|
||
|
||
foreach (var tile in GridCoordinates.GetFootprintTiles(anchorTile.Value, footprintSize))
|
||
{
|
||
loader.SetWalkable(tile, walkable);
|
||
loader.SetOccupied(tile, occupied);
|
||
}
|
||
}
|
||
|
||
// Reused per-instance across color updates to avoid per-call GC allocation.
|
||
// MaterialPropertyBlock is not thread-safe but all rendering runs on the
|
||
// main thread, so a single instance per TowerInstance is fine.
|
||
private MaterialPropertyBlock colorPropertyBlock;
|
||
private static readonly int ColorPropertyId = Shader.PropertyToID("_Color");
|
||
// URP Lit uses _BaseColor, not _Color. Writing both ensures the tint applies
|
||
// regardless of which shader the prefab uses; unknown property writes are
|
||
// silently ignored.
|
||
private static readonly int BaseColorPropertyId = Shader.PropertyToID("_BaseColor");
|
||
|
||
private void ApplyTint()
|
||
{
|
||
// Paint color takes precedence when set; otherwise fall back to the owner
|
||
// color. Paint.None means "unpainted" → show owner color.
|
||
Color tint = paintColor.Value != PaintColor.None
|
||
? PaintColors.Get(paintColor.Value)
|
||
: PlayerColors.Get(ownerSlot.Value);
|
||
tint.a = 1f;
|
||
|
||
// MaterialPropertyBlock sets per-renderer properties without allocating
|
||
// a new Material object. Safe to reuse across calls on the same instance.
|
||
// All Unity standard/URP shaders expose _Color or _BaseColor, so writing
|
||
// both lets the tint apply regardless of which shader the prefab uses.
|
||
colorPropertyBlock ??= new MaterialPropertyBlock();
|
||
colorPropertyBlock.SetColor(ColorPropertyId, tint);
|
||
colorPropertyBlock.SetColor(BaseColorPropertyId, tint);
|
||
|
||
foreach (var rend in ResolveTintRenderers())
|
||
{
|
||
if (rend == null) continue;
|
||
rend.SetPropertyBlock(colorPropertyBlock);
|
||
}
|
||
}
|
||
|
||
// Renderers actually tinted. Prefer the inspector-assigned list (lets a prefab
|
||
// exclude decorative children, FX, etc.). When that list is empty — the common
|
||
// case for imported models nobody has hand-wired — fall back to every MeshRenderer
|
||
// under the tower so owner-color and paint Just Work without per-prefab setup.
|
||
// Cached after the first resolve.
|
||
private MeshRenderer[] resolvedTintRenderers;
|
||
|
||
private MeshRenderer[] ResolveTintRenderers()
|
||
{
|
||
if (tintedRenderers != null && tintedRenderers.Length > 0)
|
||
return tintedRenderers;
|
||
|
||
if (resolvedTintRenderers == null)
|
||
{
|
||
resolvedTintRenderers = GetComponentsInChildren<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;
|
||
}
|
||
}
|
||
} |