// 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; } } }