// Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs
using UnityEngine;
using TD.Core;
namespace TD.Gameplay
{
///
/// Scene-wide selection ring renderer. One per scene. Listens to
/// and drives a single pooled
/// ring GameObject — sized from the new selection's ,
/// positioned each from .
///
///
/// Why scene-wide instead of per-prefab. 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.
///
/// Color isolation. Because the ring is instantiated as a child of
/// THIS visualizer (not of the selectable), TowerInstance.ApplyOwnerColor's
/// MaterialPropertyBlock writes can never reach it. The ring's authored material
/// (green) shows through regardless of the selectable's own tinting.
///
/// Single instance, recycled. 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.
///
/// Prefab requirements. 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 2 * SelectionRadius so the rendered ring matches the
/// selectable's intended size. Y scale is preserved so the disc stays flat.
///
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;
}
///
/// 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.
///
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;
}
}
}