275 lines
13 KiB
C#
275 lines
13 KiB
C#
// 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;
|
||
}
|
||
}
|
||
}
|