UnityTowerDefense/Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs

275 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Assets/_Project/Scripts/Gameplay/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
// 13 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;
}
}
}