Major updates to the HUD and selectable objects
This commit is contained in:
parent
5bc757b385
commit
c100db52e5
23 changed files with 1615 additions and 614 deletions
|
|
@ -4,7 +4,6 @@ using Unity.Netcode;
|
|||
using UnityEngine;
|
||||
using TD.Core;
|
||||
using TD.Towers;
|
||||
using TD.UI;
|
||||
|
||||
namespace TD.Gameplay
|
||||
{
|
||||
|
|
@ -40,7 +39,7 @@ namespace TD.Gameplay
|
|||
/// races with the Builder. See lessons in the project context doc.</para>
|
||||
/// </remarks>
|
||||
[RequireComponent(typeof(NetworkObject))]
|
||||
public class BuildSiteVisual : NetworkBehaviour
|
||||
public class BuildSiteVisual : NetworkBehaviour, ISelectable
|
||||
{
|
||||
// ----- Inspector --------------------------------------------------
|
||||
|
||||
|
|
@ -201,6 +200,36 @@ namespace TD.Gameplay
|
|||
return Mathf.Clamp01((currentRunElapsed + accumulatedConstructionTime.Value) / bt);
|
||||
}
|
||||
|
||||
// ----- ISelectable ------------------------------------------------
|
||||
|
||||
/// <summary>Name shown in the HUD portrait. Pulls from the resolved
|
||||
/// TowerDefinition; falls back to a generic label if the registry hasn't
|
||||
/// resolved the def yet on this client.</summary>
|
||||
public string DisplayName
|
||||
{
|
||||
get
|
||||
{
|
||||
var def = TowerPlacementManager.GetDefinition(towerTypeId.Value);
|
||||
return def != null ? def.DisplayName : "Tower (building)";
|
||||
}
|
||||
}
|
||||
|
||||
public SelectableKind Kind => SelectableKind.BuildSite;
|
||||
|
||||
public Transform SelectionTransform => transform;
|
||||
|
||||
// Match the TowerInstance's radius formula so the selection ring is the
|
||||
// same size before vs. after the build completes — no visual pop on transition.
|
||||
public float SelectionRadius
|
||||
{
|
||||
get
|
||||
{
|
||||
var def = TowerPlacementManager.GetDefinition(towerTypeId.Value);
|
||||
Vector2Int fp = def != null ? def.FootprintSize : new Vector2Int(1, 1);
|
||||
return Mathf.Max(fp.x, fp.y) * 0.5f * GridCoordinates.TILE_SIZE + 0.5f;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Pre-spawn init data (server) -------------------------------
|
||||
|
||||
private string pendingDefName;
|
||||
|
|
@ -266,12 +295,6 @@ namespace TD.Gameplay
|
|||
|
||||
// Apply initial visual state based on the (now-replicated) values.
|
||||
ApplyStageVisual(currentStage.Value);
|
||||
|
||||
// Attach a local (non-networked) progress bar — each client creates its own.
|
||||
// Destroyed automatically when this NetworkObject is despawned (it's a child).
|
||||
var barHost = new GameObject("ProgressBar");
|
||||
barHost.transform.SetParent(transform, false);
|
||||
barHost.AddComponent<BuildProgressBar>().Initialize(this);
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
|
|
@ -287,6 +310,15 @@ namespace TD.Gameplay
|
|||
{
|
||||
RestoreFootprintGridState();
|
||||
}
|
||||
|
||||
// Local-only selection hygiene. If this visual was the active selection
|
||||
// AND nothing has transferred selection to a replacement (e.g., a
|
||||
// TowerInstance that just spawned at the same anchor), clear so HUD/
|
||||
// visualizer don't hold a destroyed reference. The completion path
|
||||
// spawns TowerInstance BEFORE this despawn arrives on the client; that
|
||||
// spawn already transferred selection, so this check passes through.
|
||||
if (SelectionState.Instance != null && SelectionState.Instance.IsSelected(this))
|
||||
SelectionState.Instance.Clear();
|
||||
}
|
||||
|
||||
// Server-only: restore walkability=true and occupancy=false on this build site's
|
||||
|
|
@ -347,6 +379,82 @@ namespace TD.Gameplay
|
|||
isShelved.Value = false;
|
||||
}
|
||||
|
||||
// ----- Player-initiated cancel (HUD action) -----------------------
|
||||
|
||||
// Server-only guard against double cancel — Cancel RPC could arrive twice
|
||||
// if the player clicks quickly before the visual finishes despawning.
|
||||
private bool serverCancelled;
|
||||
|
||||
/// <summary>
|
||||
/// Owner-only RPC. Routes to <see cref="ServerCancel"/>. Hooked up to the
|
||||
/// Cancel action button in the HUD's action menu.
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
|
||||
public void RequestCancelRpc()
|
||||
{
|
||||
ServerCancel();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: cancel this build. Two paths depending on shelve state:
|
||||
/// - <b>Shelved</b>: this visual is standalone (not in any builder's queue).
|
||||
/// Refund gold ourselves and despawn — <see cref="OnNetworkDespawn"/>
|
||||
/// restores the footprint's grid state because <c>isShelved</c> is true.
|
||||
/// - <b>In a builder's queue</b>: route through the owning builder's
|
||||
/// <see cref="Builder.ServerCancelJobAtAnchor"/>, which already handles
|
||||
/// refund + grid restore + visual despawn through its job-cleanup path.
|
||||
/// </summary>
|
||||
public void ServerCancel()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
if (serverCancelled) return; // idempotent guard
|
||||
serverCancelled = true;
|
||||
|
||||
if (isShelved.Value)
|
||||
{
|
||||
RefundOwner();
|
||||
if (NetworkObject.IsSpawned)
|
||||
NetworkObject.Despawn(destroy: true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Queued / Constructing / Paused — owned by a builder's job queue.
|
||||
var builder = Builder.GetForClient(OwnerClientId);
|
||||
if (builder != null)
|
||||
{
|
||||
bool found = builder.ServerCancelJobAtAnchor(anchor.Value);
|
||||
if (found) return;
|
||||
// Race: visual exists but no matching job (e.g., job just completed
|
||||
// and the visual is mid-despawn). Fall through to manual cleanup.
|
||||
Debug.LogWarning($"[BuildSiteVisual] ServerCancel: no matching job " +
|
||||
$"at anchor {anchor.Value} on builder for client " +
|
||||
$"{OwnerClientId}. Performing manual refund+despawn.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[BuildSiteVisual] ServerCancel: owning builder " +
|
||||
"not found. Performing manual refund+despawn.");
|
||||
}
|
||||
|
||||
// Manual fallback for the race / no-builder cases. Restore grid since
|
||||
// the builder isn't going to do it for us.
|
||||
RefundOwner();
|
||||
if (currentStage.Value == BuildStage.Constructing
|
||||
|| currentStage.Value == BuildStage.Paused)
|
||||
{
|
||||
RestoreFootprintGridState();
|
||||
}
|
||||
if (NetworkObject.IsSpawned)
|
||||
NetworkObject.Despawn(destroy: true);
|
||||
}
|
||||
|
||||
private void RefundOwner()
|
||||
{
|
||||
var goldManager = PlayerGoldManager.GetForClient(OwnerClientId);
|
||||
if (goldManager == null) return;
|
||||
goldManager.AwardGold(goldSpent.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: transitions the visual from Queued (or Paused) to Constructing
|
||||
/// and records the server time for stage progression. Caller is responsible
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ namespace TD.Gameplay
|
|||
/// traversal.</para>
|
||||
/// </remarks>
|
||||
[RequireComponent(typeof(NetworkObject))]
|
||||
public class Builder : NetworkBehaviour, IMinimapEntity
|
||||
public class Builder : NetworkBehaviour, IMinimapEntity, ISelectable
|
||||
{
|
||||
// ----- Static registry --------------------------------------------
|
||||
|
||||
|
|
@ -156,6 +156,8 @@ namespace TD.Gameplay
|
|||
/// <summary>Maximum jobs allowed in the queue.</summary>
|
||||
public int MaxQueueDepth => settings.maxQueueDepth;
|
||||
|
||||
// ----- ISelectable ------------------------------------------------
|
||||
|
||||
/// <summary>Display name shown in the HUD portrait. Stub until MatchState provides player names.</summary>
|
||||
public string DisplayName
|
||||
{
|
||||
|
|
@ -167,6 +169,15 @@ namespace TD.Gameplay
|
|||
}
|
||||
}
|
||||
|
||||
public SelectableKind Kind => SelectableKind.Builder;
|
||||
|
||||
public Transform SelectionTransform => transform;
|
||||
|
||||
// Builders are point units; the visible silhouette is roughly 1 unit wide.
|
||||
// 0.6 puts a small visible gap between the silhouette and the ring.
|
||||
// Bump up if BuilderSettings later exposes a width or selection radius.
|
||||
public float SelectionRadius => 0.6f;
|
||||
|
||||
/// <summary>True if a tile is currently part of any queued or constructing job.</summary>
|
||||
/// <remarks>
|
||||
/// Used by <c>TowerPlacementManager</c> to reject placement on tiles already
|
||||
|
|
@ -232,6 +243,14 @@ namespace TD.Gameplay
|
|||
s_byClientId.Remove(OwnerClientId);
|
||||
MinimapEntityRegistry.Deregister(this);
|
||||
|
||||
// Clear local selection if THIS builder 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.
|
||||
// Local-only state, so safe to touch from any peer.
|
||||
if (SelectionState.Instance != null && SelectionState.Instance.IsSelected(this))
|
||||
SelectionState.Instance.Clear();
|
||||
|
||||
// Server-only cleanup: despawn any remaining build-site visuals so they
|
||||
// don't leak when a player disconnects mid-construction.
|
||||
if (IsServer)
|
||||
|
|
@ -980,7 +999,30 @@ namespace TD.Gameplay
|
|||
jobs.RemoveAt(0);
|
||||
}
|
||||
|
||||
// Cancels the job at index i. Used for cancel-all and any future targeted cancel.
|
||||
/// <summary>
|
||||
/// Server-only: cancel the job in this builder's queue whose anchor matches
|
||||
/// <paramref name="targetAnchor"/>. Refunds gold, frees the footprint tiles
|
||||
/// (restoring walkability if the stage was blocking), and despawns the
|
||||
/// build-site visual. Returns true if a matching job was found and
|
||||
/// cancelled. Used by <see cref="BuildSiteVisual.RequestCancelRpc"/> so the
|
||||
/// player can cancel a specific in-progress build from the HUD without
|
||||
/// affecting other queued/constructing builds.
|
||||
/// </summary>
|
||||
public bool ServerCancelJobAtAnchor(Vector2Int targetAnchor)
|
||||
{
|
||||
if (!IsServer) return false;
|
||||
for (int i = 0; i < jobs.Count; i++)
|
||||
{
|
||||
if (jobs[i].Anchor == targetAnchor)
|
||||
{
|
||||
ServerCancelJobAt(i);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cancels the job at index i. Used for cancel-all and targeted cancel paths.
|
||||
private void ServerCancelJobAt(int index)
|
||||
{
|
||||
if (index < 0 || index >= jobs.Count) return;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using Unity.Netcode;
|
|||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using TD.Core;
|
||||
using TD.UI;
|
||||
|
||||
namespace TD.Gameplay
|
||||
{
|
||||
|
|
@ -58,10 +59,11 @@ namespace TD.Gameplay
|
|||
"against this layer to determine the move target.")]
|
||||
[SerializeField] private LayerMask buildablePlaneLayerMask;
|
||||
|
||||
[Tooltip("Physics layer mask for the builder selection trigger collider. The " +
|
||||
"builder prefab's child selection collider sits on this layer. The mask " +
|
||||
"must NOT overlap with BuildablePlane or TerrainGeometry; selection is " +
|
||||
"a separate concern.")]
|
||||
[Tooltip("Physics layer mask for selection trigger colliders. Builder selection " +
|
||||
"colliders AND tower selection colliders both sit on this layer. The " +
|
||||
"raycast walks up the hit hierarchy to find an ISelectable component, so " +
|
||||
"any selectable kind on this layer Just Works. The mask must NOT overlap " +
|
||||
"with BuildablePlane or TerrainGeometry; selection is a separate concern.")]
|
||||
[SerializeField] private LayerMask selectionLayerMask;
|
||||
|
||||
[Tooltip("Physics layer mask for build-site visual click targets. The " +
|
||||
|
|
@ -113,12 +115,19 @@ namespace TD.Gameplay
|
|||
if (mouse == null) return;
|
||||
|
||||
bool isPlacing = IsLocalPlayerPlacing();
|
||||
Vector2 mousePos = mouse.position.ReadValue();
|
||||
|
||||
// UI Toolkit dispatches button click events AFTER Update runs, but raw mouse
|
||||
// input is already true this frame. Without this gate, clicking a HUD button
|
||||
// also fires HandleLeftClickSelection — the raycast misses the builder collider
|
||||
// and deselects before the button's action fires. Same risk on right-click.
|
||||
bool pointerOverHud = HUDController.IsPointerOverInteractiveHud(mousePos);
|
||||
|
||||
// Left-click: selection. Suppressed during placement mode (left-click is
|
||||
// the placement-submit gesture there).
|
||||
if (!isPlacing && mouse.leftButton.wasPressedThisFrame)
|
||||
// the placement-submit gesture there) and when the pointer is over HUD.
|
||||
if (!isPlacing && !pointerOverHud && mouse.leftButton.wasPressedThisFrame)
|
||||
{
|
||||
HandleLeftClickSelection(mouse.position.ReadValue());
|
||||
HandleLeftClickSelection(mousePos);
|
||||
}
|
||||
|
||||
// Escape: clear selection. Allowed during placement mode too — Escape never
|
||||
|
|
@ -129,11 +138,12 @@ namespace TD.Gameplay
|
|||
}
|
||||
|
||||
// Right-click. Suppressed entirely during placement mode (TowerPlacementController
|
||||
// handles right-click as cancel-placement there).
|
||||
// handles right-click as cancel-placement there) and when over HUD.
|
||||
if (isPlacing) return;
|
||||
if (pointerOverHud) return;
|
||||
if (!mouse.rightButton.wasPressedThisFrame) return;
|
||||
|
||||
HandleRightClick(mouse.position.ReadValue());
|
||||
HandleRightClick(mousePos);
|
||||
}
|
||||
|
||||
// ----- Selection (left-click) -------------------------------------
|
||||
|
|
@ -147,17 +157,24 @@ namespace TD.Gameplay
|
|||
if (cam == null) return;
|
||||
|
||||
Ray ray = cam.ScreenPointToRay(new Vector3(screenPos.x, screenPos.y, 0f));
|
||||
|
||||
// Single raycast against the unified Selection layer. Whatever we hit, walk
|
||||
// up its hierarchy to find an ISelectable component (Builder, Tower, or
|
||||
// any future kind). The closest hit wins automatically — no priority logic
|
||||
// needed because builders and towers don't visually overlap in practice.
|
||||
if (Physics.Raycast(ray, out RaycastHit hit, raycastMaxDistance, selectionLayerMask))
|
||||
{
|
||||
// Walk up the hierarchy to find a Builder component (the selection
|
||||
// collider may sit on a child of the Builder's root).
|
||||
var hitBuilder = hit.collider.GetComponentInParent<Builder>();
|
||||
|
||||
// Only allow selecting OUR builder. A click on someone else's builder
|
||||
// collider clears our selection rather than selecting theirs.
|
||||
if (hitBuilder != null && hitBuilder == builder)
|
||||
var hitSelectable = hit.collider.GetComponentInParent<ISelectable>();
|
||||
if (hitSelectable != null)
|
||||
{
|
||||
selection.Select(builder);
|
||||
// Don't let players select someone else's builder. Treat that as
|
||||
// "clicked empty" so we clear, rather than steal their selection.
|
||||
if (hitSelectable is Builder b && b != builder)
|
||||
{
|
||||
selection.Clear();
|
||||
return;
|
||||
}
|
||||
selection.Select(hitSelectable);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,6 +135,56 @@ namespace TD.Gameplay
|
|||
/// <summary>Ends external drag mode. Normal input handling resumes.</summary>
|
||||
public void EndDrag() => isExternalDragActive = false;
|
||||
|
||||
/// <summary>
|
||||
/// Computes where the four screen corners project onto the buildable plane and
|
||||
/// writes them into <paramref name="cornersOut"/> in the order BL, BR, TR, TL.
|
||||
/// Returns true when every ray hits the plane in front of the camera. When the
|
||||
/// camera angles above the horizon for one or more corners (rare in our pitch
|
||||
/// range), those corner(s) fall back to a far point along the ray and the method
|
||||
/// returns false — the caller may still use the result; the off-plane corners
|
||||
/// just sit far outside the map. Used by the minimap to draw the viewport
|
||||
/// trapezoid (the "what the player sees" rectangle).
|
||||
/// </summary>
|
||||
public bool TryGetViewportWorldCorners(Vector3[] cornersOut)
|
||||
{
|
||||
if (cornersOut == null || cornersOut.Length < 4) return false;
|
||||
if (cameraChild == null) return false;
|
||||
|
||||
// Buildable plane is Y = BUILDABLE_PLANE_Y, normal = up.
|
||||
var plane = new Plane(Vector3.up,
|
||||
new Vector3(0f, GridCoordinates.BUILDABLE_PLANE_Y, 0f));
|
||||
|
||||
// Screen-space order: BL, BR, TR, TL (origin at bottom-left, Y up).
|
||||
// Resulting world points form a trapezoid on the buildable plane (camera
|
||||
// is angled, so the far edge — top of screen — projects wider than the
|
||||
// near edge — bottom of screen).
|
||||
float w = Screen.width;
|
||||
float h = Screen.height;
|
||||
cornersOut[0] = ProjectScreenToPlane(new Vector2(0f, 0f), plane, out bool a);
|
||||
cornersOut[1] = ProjectScreenToPlane(new Vector2(w, 0f), plane, out bool b);
|
||||
cornersOut[2] = ProjectScreenToPlane(new Vector2(w, h ), plane, out bool c);
|
||||
cornersOut[3] = ProjectScreenToPlane(new Vector2(0f, h ), plane, out bool d);
|
||||
return a && b && c && d;
|
||||
}
|
||||
|
||||
// Helper: ray from screen point, intersect plane. If it doesn't hit in front of
|
||||
// the camera (rare — only at horizon-or-above pitches), use a far fallback so
|
||||
// the caller still gets a usable value.
|
||||
private Vector3 ProjectScreenToPlane(Vector2 screenPoint, Plane plane, out bool hit)
|
||||
{
|
||||
Ray ray = cameraChild.ScreenPointToRay(new Vector3(screenPoint.x, screenPoint.y, 0f));
|
||||
if (plane.Raycast(ray, out float dist) && dist > 0f)
|
||||
{
|
||||
hit = true;
|
||||
return ray.GetPoint(dist);
|
||||
}
|
||||
hit = false;
|
||||
// Far point along the ray as a graceful fallback. Distance picked large enough
|
||||
// that the resulting UI coord lands well outside the minimap bounds and gets
|
||||
// clipped by overflow:hidden.
|
||||
return ray.GetPoint(1000f);
|
||||
}
|
||||
|
||||
// ----- Lifecycle --------------------------------------------------
|
||||
|
||||
private void Start()
|
||||
|
|
|
|||
46
Assets/_Project/Scripts/Gameplay/ISelectable.cs
Normal file
46
Assets/_Project/Scripts/Gameplay/ISelectable.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Assets/_Project/Scripts/Gameplay/ISelectable.cs
|
||||
using UnityEngine;
|
||||
|
||||
namespace TD.Gameplay
|
||||
{
|
||||
/// <summary>
|
||||
/// Categorizes the kind of selectable object so HUD can decide which command
|
||||
/// buttons (tower-build vs upgrade/sell vs none) to show without doing
|
||||
/// type-tests against every concrete component.
|
||||
/// </summary>
|
||||
public enum SelectableKind
|
||||
{
|
||||
Builder,
|
||||
Tower,
|
||||
Enemy,
|
||||
BuildSite, // tower in queued / constructing / paused / shelved state
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anything the local player can click to select. Implementers expose a
|
||||
/// display name for the HUD portrait, a kind for context-aware UI, and the
|
||||
/// position + size hints the <see cref="SelectionVisualizer"/> needs to draw
|
||||
/// the selection ring.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Selection is a local UI concept — implementers don't need to be
|
||||
/// NetworkBehaviours (though Builder and TowerInstance happen to be).
|
||||
/// </remarks>
|
||||
public interface ISelectable
|
||||
{
|
||||
string DisplayName { get; }
|
||||
SelectableKind Kind { get; }
|
||||
|
||||
/// <summary>Transform whose XZ position the scene-wide selection ring
|
||||
/// follows. The visualizer projects Y to the buildable plane regardless
|
||||
/// of where this transform sits, so implementers can simply return
|
||||
/// <c>this.transform</c>.</summary>
|
||||
Transform SelectionTransform { get; }
|
||||
|
||||
/// <summary>Half-width of the selection ring in world units. The visualizer
|
||||
/// scales its base 1-unit-diameter ring mesh to <c>2 * SelectionRadius</c>.
|
||||
/// Computed on every selection change, so implementers may derive it from
|
||||
/// runtime state (e.g., tower footprint, collider bounds).</summary>
|
||||
float SelectionRadius { get; }
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Gameplay/ISelectable.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/ISelectable.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 6570f6402d58acb48bd8f0e9202cb1d7
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
// Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs
|
||||
using UnityEngine;
|
||||
|
||||
namespace TD.Gameplay
|
||||
{
|
||||
/// <summary>
|
||||
/// Local-only visual indicator for builder selection. Sits as a child of the
|
||||
/// builder prefab. Subscribes to <see cref="SelectionState.OnSelectionChanged"/>
|
||||
/// and toggles the visibility of its own renderers (and any descendant
|
||||
/// renderers) when the parent builder becomes selected/unselected.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Pure local visualization.</b> No NetworkBehaviour. Selection is a
|
||||
/// UI concept — every client renders selection state for its own player only.
|
||||
/// This component has no networked state.</para>
|
||||
///
|
||||
/// <para><b>Why a separate component.</b> Keeps Builder focused on gameplay
|
||||
/// state. The visual prefab structure can change independently
|
||||
/// (disc → ring → decal projector → animated effect) without touching gameplay
|
||||
/// code. The contract is just "renderers visible when selected."</para>
|
||||
///
|
||||
/// <para><b>Why renderer-toggle, not GameObject.SetActive.</b> Disabling our
|
||||
/// own GameObject would prevent OnEnable/Update from running, which would
|
||||
/// break the SelectionState subscription lifecycle (we'd never receive the
|
||||
/// "you're selected again" event). Toggling the renderers' enabled state
|
||||
/// achieves the same visual effect without breaking the event flow.</para>
|
||||
///
|
||||
/// <para><b>Prefab setup.</b> Attach this component to a child GameObject of
|
||||
/// the Builder prefab. The child carries (or has descendants carrying) a
|
||||
/// flattened cylinder mesh sitting just above the ground plane, with an
|
||||
/// unlit transparent green material. The component handles initial visibility
|
||||
/// — leave the renderer enabled in the prefab; we'll turn it off in Awake
|
||||
/// until selection fires.</para>
|
||||
/// </remarks>
|
||||
public class SelectionRingVisual : MonoBehaviour
|
||||
{
|
||||
// Cached parent builder, resolved in Awake.
|
||||
private Builder parentBuilder;
|
||||
|
||||
// Cached renderers (this object plus all descendants). Captured once in
|
||||
// Awake to avoid per-event GetComponentsInChildren allocations.
|
||||
private Renderer[] cachedRenderers;
|
||||
|
||||
// Tracks subscription state so OnDisable / OnDestroy unsubscribe correctly,
|
||||
// and so Update can retry subscription if SelectionState wasn't ready at OnEnable.
|
||||
private bool subscribed;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
parentBuilder = GetComponentInParent<Builder>();
|
||||
if (parentBuilder == null)
|
||||
{
|
||||
Debug.LogError("[SelectionRingVisual] No Builder component found on " +
|
||||
"self or any parent. Disabling.");
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
cachedRenderers = GetComponentsInChildren<Renderer>(includeInactive: true);
|
||||
|
||||
// Start hidden. If selection fires later (including the auto-select
|
||||
// in Builder.OnNetworkSpawn), HandleSelectionChanged will turn the
|
||||
// renderers back on.
|
||||
SetRenderersVisible(false);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
TrySubscribe();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
Unsubscribe();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
Unsubscribe();
|
||||
}
|
||||
|
||||
// Per-frame fallback: if SelectionState wasn't available at OnEnable
|
||||
// (scene load ordering), keep trying. Cost is one null-check per frame
|
||||
// until subscription succeeds, then nothing.
|
||||
private void Update()
|
||||
{
|
||||
if (!subscribed) TrySubscribe();
|
||||
}
|
||||
|
||||
private void TrySubscribe()
|
||||
{
|
||||
if (subscribed) return;
|
||||
var sel = SelectionState.Instance;
|
||||
if (sel == null) return;
|
||||
|
||||
sel.OnSelectionChanged += HandleSelectionChanged;
|
||||
subscribed = true;
|
||||
|
||||
// Sync to current state — selection may have happened before we subscribed
|
||||
// (e.g., Builder.OnNetworkSpawn auto-selecting before this Awake runs).
|
||||
HandleSelectionChanged(sel.SelectedBuilder);
|
||||
}
|
||||
|
||||
private void Unsubscribe()
|
||||
{
|
||||
if (!subscribed) return;
|
||||
var sel = SelectionState.Instance;
|
||||
if (sel != null) sel.OnSelectionChanged -= HandleSelectionChanged;
|
||||
subscribed = false;
|
||||
}
|
||||
|
||||
private void HandleSelectionChanged(Builder newSelection)
|
||||
{
|
||||
SetRenderersVisible(newSelection == parentBuilder);
|
||||
}
|
||||
|
||||
private void SetRenderersVisible(bool visible)
|
||||
{
|
||||
if (cachedRenderers == null) return;
|
||||
foreach (var rend in cachedRenderers)
|
||||
{
|
||||
if (rend != null) rend.enabled = visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 67895f626233fdc499dffbbfcc225530
|
||||
|
|
@ -4,35 +4,28 @@ using UnityEngine;
|
|||
namespace TD.Gameplay
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimal scene-local selection state. Holds a reference to whichever
|
||||
/// <see cref="Builder"/> the local player has selected, fires an event when
|
||||
/// the selection changes, and exposes a query for "is this builder selected
|
||||
/// right now?".
|
||||
/// Scene-local selection state. Holds a single <see cref="ISelectable"/> (the
|
||||
/// builder, a tower, or any future selectable type) and fires
|
||||
/// <see cref="OnSelectionChanged"/> when it changes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Scope.</b> D2 only needs this for: "Escape with builder selected
|
||||
/// cancels its queue", and "right-click with builder selected and queue
|
||||
/// established cancels the queue (the right-click is consumed by selection
|
||||
/// instead of issuing a move)". A full selection system that supports world
|
||||
/// highlighting, multi-select, and HUD context panels is deferred to the HUD
|
||||
/// path.</para>
|
||||
///
|
||||
/// <para><b>Local-only.</b> Selection is a UI concept, not a gameplay one.
|
||||
/// Other clients have no business knowing whether you've selected your own
|
||||
/// builder. The component is a plain MonoBehaviour and lives in the scene
|
||||
/// alongside other client-side controllers.</para>
|
||||
/// <para><b>Local-only.</b> Selection is a UI concept — other clients have no
|
||||
/// business knowing whether you've selected something. The component is a plain
|
||||
/// MonoBehaviour and lives in the scene alongside other client-side controllers.</para>
|
||||
///
|
||||
/// <para><b>Singleton.</b> One per scene, accessed via <see cref="Instance"/>.
|
||||
/// The selection consumer (BuilderInputController) and the selection driver
|
||||
/// (mouse-click raycast) both go through this single source of truth.</para>
|
||||
/// Selection drivers (input controller, minimap) and selection consumers
|
||||
/// (HUD, selection-ring visuals) all go through this single source of truth.</para>
|
||||
///
|
||||
/// <para><b>Builder convenience.</b> <see cref="SelectedBuilder"/> returns the
|
||||
/// current selection cast to Builder (or null if non-builder). Lets the input
|
||||
/// controller and minimap keep the "is the local builder selected?" check
|
||||
/// short without re-type-testing.</para>
|
||||
/// </remarks>
|
||||
public class SelectionState : MonoBehaviour
|
||||
{
|
||||
// ----- Singleton --------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The active SelectionState. Null before the scene loads. Always null-check.
|
||||
/// </summary>
|
||||
public static SelectionState Instance { get; private set; }
|
||||
|
||||
private void Awake()
|
||||
|
|
@ -52,16 +45,22 @@ namespace TD.Gameplay
|
|||
|
||||
// ----- Selection state --------------------------------------------
|
||||
|
||||
private Builder selectedBuilder;
|
||||
private ISelectable selected;
|
||||
|
||||
/// <summary>The currently selected builder, or null if nothing is selected.</summary>
|
||||
public Builder SelectedBuilder => selectedBuilder;
|
||||
/// <summary>The currently selected object, or null if nothing is selected.</summary>
|
||||
public ISelectable SelectedObject => selected;
|
||||
|
||||
/// <summary>True if any builder is currently selected.</summary>
|
||||
public bool HasSelection => selectedBuilder != null;
|
||||
/// <summary>Convenience: the selected object if it's a Builder, else null.</summary>
|
||||
public Builder SelectedBuilder => selected as Builder;
|
||||
|
||||
/// <summary>True if <paramref name="b"/> is the currently selected builder.</summary>
|
||||
public bool IsSelected(Builder b) => b != null && selectedBuilder == b;
|
||||
/// <summary>True if any object is currently selected.</summary>
|
||||
public bool HasSelection => selected != null;
|
||||
|
||||
/// <summary>True if <paramref name="s"/> is the currently selected object.</summary>
|
||||
public bool IsSelected(ISelectable s) => s != null && (object)selected == (object)s;
|
||||
|
||||
/// <summary>Builder overload — same semantics as <see cref="IsSelected(ISelectable)"/>.</summary>
|
||||
public bool IsSelected(Builder b) => b != null && (object)selected == (object)b;
|
||||
|
||||
// ----- Events -----------------------------------------------------
|
||||
|
||||
|
|
@ -69,22 +68,22 @@ namespace TD.Gameplay
|
|||
/// Fired when the selection changes. Argument is the new selection (may be null).
|
||||
/// Subscribe to drive selection-aware UI: highlights, context panels, hotkey hints.
|
||||
/// </summary>
|
||||
public event System.Action<Builder> OnSelectionChanged;
|
||||
public event System.Action<ISelectable> OnSelectionChanged;
|
||||
|
||||
// ----- Mutators ---------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Sets the selected builder. Pass null to clear.
|
||||
/// Sets the selected object. Pass null to clear.
|
||||
/// Fires <see cref="OnSelectionChanged"/> only if the selection actually changes.
|
||||
/// </summary>
|
||||
public void Select(Builder builder)
|
||||
public void Select(ISelectable s)
|
||||
{
|
||||
if (selectedBuilder == builder) return;
|
||||
selectedBuilder = builder;
|
||||
OnSelectionChanged?.Invoke(selectedBuilder);
|
||||
if ((object)selected == (object)s) return;
|
||||
selected = s;
|
||||
OnSelectionChanged?.Invoke(selected);
|
||||
}
|
||||
|
||||
/// <summary>Clears the selection. Equivalent to Select(null).</summary>
|
||||
public void Clear() => Select(null);
|
||||
public void Clear() => Select((ISelectable)null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
275
Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs
Normal file
275
Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
// Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
||||
namespace TD.Gameplay
|
||||
{
|
||||
/// <summary>
|
||||
/// Scene-wide selection ring renderer. One per scene. Listens to
|
||||
/// <see cref="SelectionState.OnSelectionChanged"/> and drives a single pooled
|
||||
/// ring GameObject — sized from the new selection's <see cref="ISelectable.SelectionRadius"/>,
|
||||
/// positioned each <see cref="LateUpdate"/> from <see cref="ISelectable.SelectionTransform"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Why scene-wide instead of per-prefab.</b> A per-prefab selection ring
|
||||
/// child requires authoring every new selectable (towers, enemies, future races)
|
||||
/// with the right mesh, material, scale, and child script. That doesn't scale.
|
||||
/// A single scene-wide visualizer reuses one prefab, derives size and position
|
||||
/// from the ISelectable hints, and adding a new selectable type costs ~2 lines
|
||||
/// of interface implementation rather than a whole prefab subtree.</para>
|
||||
///
|
||||
/// <para><b>Color isolation.</b> Because the ring is instantiated as a child of
|
||||
/// THIS visualizer (not of the selectable), <c>TowerInstance.ApplyOwnerColor</c>'s
|
||||
/// MaterialPropertyBlock writes can never reach it. The ring's authored material
|
||||
/// (green) shows through regardless of the selectable's own tinting.</para>
|
||||
///
|
||||
/// <para><b>Single instance, recycled.</b> One ring GameObject is instantiated on
|
||||
/// the first selection and reused for every subsequent one (SetActive toggles
|
||||
/// visibility). Avoids the GC churn of Instantiate/Destroy on every click.
|
||||
/// When multi-select lands, pool to N instances here.</para>
|
||||
///
|
||||
/// <para><b>Prefab requirements.</b> Assign a prefab whose mesh is authored at
|
||||
/// 1-unit DIAMETER (i.e., radius = 0.5). The visualizer multiplies localScale's
|
||||
/// X and Z by <c>2 * SelectionRadius</c> so the rendered ring matches the
|
||||
/// selectable's intended size. Y scale is preserved so the disc stays flat.</para>
|
||||
/// </remarks>
|
||||
public class SelectionVisualizer : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Flat ground-decal disc/ring prefab with the green selection " +
|
||||
"material. Mesh must be authored at 1-unit base diameter — the " +
|
||||
"visualizer scales by 2 * ISelectable.SelectionRadius to match " +
|
||||
"the selectable's intended size.")]
|
||||
[SerializeField] private GameObject selectionRingPrefab;
|
||||
|
||||
[Tooltip("Offset along the projected surface's normal to keep the ring " +
|
||||
"from z-fighting with whatever surface it lands on.")]
|
||||
[SerializeField] private float yOffset = 0.02f;
|
||||
|
||||
[Tooltip("Physics layers the selection ring projects onto. Typically " +
|
||||
"BuildablePlane + TerrainGeometry + Selection (the last so the " +
|
||||
"ring lands on top of a tower the selectable is standing on). " +
|
||||
"The visualizer automatically filters out hits on the selectable's " +
|
||||
"own hierarchy. Default ~0 = everything.")]
|
||||
[SerializeField] private LayerMask projectionLayerMask = ~0;
|
||||
|
||||
// How far above the selectable we start the downward raycast. Must be tall
|
||||
// enough that the origin is OUTSIDE any collider the selectable might be
|
||||
// sitting on or inside; 50 covers any reasonable tower/terrain stack.
|
||||
private const float RaycastOriginUpOffset = 50f;
|
||||
|
||||
// How far down we cast PAST the selectable's position. 100 covers any
|
||||
// map's terrain depth; deeper hits we don't care about anyway.
|
||||
private const float MaxProjectionDistance = 100f;
|
||||
|
||||
// Reused buffer for downward raycasts so the per-frame projection is GC-free.
|
||||
// 8 is plenty: typical case has 1-3 hits (selectable's own collider, a
|
||||
// tower below, the buildable plane).
|
||||
private static readonly RaycastHit[] s_raycastBuffer = new RaycastHit[8];
|
||||
|
||||
// The single pooled ring instance. Created lazily on the first non-null
|
||||
// selection and reused thereafter. Toggled via SetActive on selection
|
||||
// change so we don't churn through Instantiate/Destroy.
|
||||
private GameObject ringInstance;
|
||||
|
||||
// Cached current selection so LateUpdate can keep position in sync as the
|
||||
// selectable moves. Cleared (and ring hidden) when selection is null.
|
||||
private ISelectable currentSelection;
|
||||
|
||||
// Standard deferred-subscribe pattern — see HUDController. SelectionState.Awake
|
||||
// may not have run by the time our OnEnable fires; Update retries until it has.
|
||||
private bool subscribed;
|
||||
|
||||
private void OnEnable() => TrySubscribe();
|
||||
private void OnDisable() => Unsubscribe();
|
||||
private void OnDestroy() => Unsubscribe();
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!subscribed) TrySubscribe();
|
||||
}
|
||||
|
||||
// Position sync runs in LateUpdate so any earlier-frame movement (server
|
||||
// tick on the builder, NetworkTransform interpolation, etc.) is already
|
||||
// applied before we read it. Otherwise the ring lags one frame.
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (currentSelection == null || ringInstance == null) return;
|
||||
|
||||
// The interface reference doesn't go through Unity's overloaded == null,
|
||||
// so a destroyed Unity object still reads as non-null on the C# side
|
||||
// and throws MissingReferenceException when we access any inherited
|
||||
// member (e.g., transform). Detect destruction explicitly and treat
|
||||
// it as a deselect. This is a backstop — Builder/TowerInstance.OnNetworkDespawn
|
||||
// also call SelectionState.Clear so we normally see the event first.
|
||||
if (IsDestroyedUnityObject(currentSelection))
|
||||
{
|
||||
currentSelection = null;
|
||||
ringInstance.SetActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var t = currentSelection.SelectionTransform;
|
||||
if (t == null)
|
||||
{
|
||||
// SelectionTransform legitimately returned null (the implementer
|
||||
// chose to suppress its own visual). Hide and bail.
|
||||
ringInstance.SetActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
ProjectRingDown(t);
|
||||
}
|
||||
|
||||
// True iff the selectable is a Unity object that has been destroyed. Plain
|
||||
// `s == null` against the interface ref doesn't invoke Unity's overloaded
|
||||
// equality — it does C# reference equality and returns false for destroyed
|
||||
// objects. Cast first; then Unity's overload kicks in.
|
||||
private static bool IsDestroyedUnityObject(ISelectable s)
|
||||
{
|
||||
return s is UnityEngine.Object uo && uo == null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raycasts straight down from above the selectable and places the ring
|
||||
/// at the first hit that isn't on the selectable's own hierarchy. The
|
||||
/// ring's up vector is aligned to the surface normal so it sits flat on
|
||||
/// slanted terrain or on top of towers below the selectable. When no
|
||||
/// surface is hit (rare — selectable floating in space), falls back to
|
||||
/// the buildable plane Y so the ring stays visible.
|
||||
/// </summary>
|
||||
private void ProjectRingDown(Transform selectableTransform)
|
||||
{
|
||||
Vector3 origin = selectableTransform.position
|
||||
+ Vector3.up * RaycastOriginUpOffset;
|
||||
float maxDist = RaycastOriginUpOffset + MaxProjectionDistance;
|
||||
|
||||
int count = Physics.RaycastNonAlloc(
|
||||
origin, Vector3.down, s_raycastBuffer, maxDist,
|
||||
projectionLayerMask, QueryTriggerInteraction.Collide);
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
// RaycastNonAlloc doesn't promise order; sort by distance ascending
|
||||
// so the first non-self hit is the closest one BELOW the origin
|
||||
// (and therefore the topmost surface beneath the selectable).
|
||||
SortBufferByDistance(count);
|
||||
|
||||
Transform selfRoot = selectableTransform.root;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var hit = s_raycastBuffer[i];
|
||||
// Skip hits on the selectable itself (its selection collider,
|
||||
// its visual collider if any). Hierarchy comparison via
|
||||
// transform.root catches all children of the same root.
|
||||
if (hit.collider.transform.root == selfRoot) continue;
|
||||
|
||||
ringInstance.transform.position = hit.point + hit.normal * yOffset;
|
||||
ringInstance.transform.rotation =
|
||||
Quaternion.FromToRotation(Vector3.up, hit.normal);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: no usable surface found. Park on the buildable plane
|
||||
// directly under the selectable so the ring stays visible.
|
||||
Vector3 fallback = selectableTransform.position;
|
||||
fallback.y = GridCoordinates.BUILDABLE_PLANE_Y + yOffset;
|
||||
ringInstance.transform.position = fallback;
|
||||
ringInstance.transform.rotation = Quaternion.identity;
|
||||
}
|
||||
|
||||
// Insertion sort — count is bounded by buffer size (8). Typical case has
|
||||
// 1–3 hits. No GC, no allocation, no LINQ.
|
||||
private static void SortBufferByDistance(int count)
|
||||
{
|
||||
for (int i = 1; i < count; i++)
|
||||
{
|
||||
var current = s_raycastBuffer[i];
|
||||
int j = i - 1;
|
||||
while (j >= 0 && s_raycastBuffer[j].distance > current.distance)
|
||||
{
|
||||
s_raycastBuffer[j + 1] = s_raycastBuffer[j];
|
||||
j--;
|
||||
}
|
||||
s_raycastBuffer[j + 1] = current;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Subscription -----------------------------------------------
|
||||
|
||||
private void TrySubscribe()
|
||||
{
|
||||
if (subscribed) return;
|
||||
var sel = SelectionState.Instance;
|
||||
if (sel == null) return;
|
||||
|
||||
sel.OnSelectionChanged += HandleSelectionChanged;
|
||||
subscribed = true;
|
||||
|
||||
// Pick up whatever was selected before we managed to subscribe.
|
||||
HandleSelectionChanged(sel.SelectedObject);
|
||||
}
|
||||
|
||||
private void Unsubscribe()
|
||||
{
|
||||
if (!subscribed) return;
|
||||
var sel = SelectionState.Instance;
|
||||
if (sel != null) sel.OnSelectionChanged -= HandleSelectionChanged;
|
||||
subscribed = false;
|
||||
}
|
||||
|
||||
// ----- Selection handling -----------------------------------------
|
||||
|
||||
private void HandleSelectionChanged(ISelectable newSelection)
|
||||
{
|
||||
// Treat a destroyed Unity object the same as null. Same reasoning as
|
||||
// the LateUpdate guard — interface refs don't trigger Unity's == null.
|
||||
if (IsDestroyedUnityObject(newSelection)) newSelection = null;
|
||||
|
||||
currentSelection = newSelection;
|
||||
|
||||
if (newSelection == null)
|
||||
{
|
||||
if (ringInstance != null) ringInstance.SetActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!EnsureRingInstance()) return;
|
||||
|
||||
// Scale the ring to 2 * radius (mesh is authored at 1-unit diameter).
|
||||
// Y is preserved so the disc's flatness from the prefab is kept.
|
||||
float diameter = newSelection.SelectionRadius * 2f;
|
||||
Vector3 ls = ringInstance.transform.localScale;
|
||||
ringInstance.transform.localScale = new Vector3(diameter, ls.y, diameter);
|
||||
|
||||
// Position will be re-projected in LateUpdate, but project now too so
|
||||
// the first frame doesn't flash at the previous selection's position
|
||||
// (or at the origin when the instance is first created).
|
||||
var t = newSelection.SelectionTransform;
|
||||
if (t != null) ProjectRingDown(t);
|
||||
|
||||
ringInstance.SetActive(true);
|
||||
}
|
||||
|
||||
// Lazy-initializes the ring instance on first use. Returns true if the
|
||||
// instance is usable; false (and logs) if the prefab field is empty.
|
||||
private bool EnsureRingInstance()
|
||||
{
|
||||
if (ringInstance != null) return true;
|
||||
if (selectionRingPrefab == null)
|
||||
{
|
||||
Debug.LogError("[SelectionVisualizer] selectionRingPrefab is not " +
|
||||
"assigned. Selection rings will not render.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parent under this visualizer so the ring travels with us in the
|
||||
// hierarchy and is auto-destroyed when the scene unloads. It still
|
||||
// moves freely in world space — we drive its position absolutely.
|
||||
ringInstance = Instantiate(selectionRingPrefab, transform);
|
||||
ringInstance.name = "ActiveSelectionRing";
|
||||
ringInstance.SetActive(false); // hidden until HandleSelectionChanged turns it on
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e28d10549c8d2ac4585eded3ad8d2198
|
||||
|
|
@ -43,8 +43,18 @@ namespace TD.Gameplay
|
|||
/// <c>TowerCombat</c> component added to the same prefab.</para>
|
||||
/// </remarks>
|
||||
[RequireComponent(typeof(NetworkObject))]
|
||||
public class TowerInstance : NetworkBehaviour, IMinimapEntity
|
||||
public class TowerInstance : NetworkBehaviour, IMinimapEntity, ISelectable
|
||||
{
|
||||
// ----- Inspector --------------------------------------------------
|
||||
|
||||
[Header("Visuals")]
|
||||
[Tooltip("Mesh renderers tinted with the owner's player color. " +
|
||||
"Drag in only the tower body's renderers — exclude anything " +
|
||||
"that has its own color rules (selection rings, range " +
|
||||
"indicators, FX). If left empty, the tower is NOT tinted " +
|
||||
"and the prefab's baked materials show through.")]
|
||||
[SerializeField] private MeshRenderer[] tintedRenderers;
|
||||
|
||||
// ----- Networked state ------------------------------------------------
|
||||
|
||||
// The name of the TowerDefinition asset for this tower. Replicated so all
|
||||
|
|
@ -107,6 +117,40 @@ namespace TD.Gameplay
|
|||
/// <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>
|
||||
|
|
@ -171,6 +215,20 @@ namespace TD.Gameplay
|
|||
// 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}' " +
|
||||
|
|
@ -186,6 +244,13 @@ namespace TD.Gameplay
|
|||
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();
|
||||
}
|
||||
|
||||
// ----- IMinimapEntity -------------------------------------------------
|
||||
|
|
@ -285,19 +350,20 @@ namespace TD.Gameplay
|
|||
|
||||
// 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 no shader changes needed.
|
||||
// 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, ownerColor);
|
||||
colorPropertyBlock.SetColor(BaseColorPropertyId, ownerColor);
|
||||
|
||||
var renderers = GetComponentsInChildren<MeshRenderer>();
|
||||
foreach (var rend in renderers)
|
||||
rend.SetPropertyBlock(colorPropertyBlock);
|
||||
|
||||
if (renderers.Length == 0)
|
||||
// Tint only the renderers explicitly listed in the inspector. Avoids
|
||||
// accidentally re-coloring decorative children, FX, etc. (Mirrors
|
||||
// Builder.tintedRenderers — same rationale.)
|
||||
if (tintedRenderers == null) return;
|
||||
foreach (var rend in tintedRenderers)
|
||||
{
|
||||
Debug.LogWarning($"[TowerInstance] NetworkObject {NetworkObjectId}: " +
|
||||
$"No MeshRenderers found for owner color tinting.");
|
||||
if (rend == null) continue;
|
||||
rend.SetPropertyBlock(colorPropertyBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue