// Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.Rendering.Universal; using TD.Core; using TD.UI; using TD.Gameplay.BuilderSpells; namespace TD.Gameplay { /// /// Per-client controller for the builder-spell casting UX. Watches /// to enter aim mode, raycasts against a broad ground layer /// (must cover the enemy path, not just buildable tiles), previews the aim point, and /// dispatches the cast to via RPC on confirm. /// /// /// Plain MonoBehaviour. Aiming visuals are purely cosmetic and local, same /// rationale as . All server-authoritative /// resolution lives in . /// /// Point vs. area preview. shows a /// small fixed-size reticle, even if the spell has an internal splash radius. /// scales the same decal to the spell's /// , mirroring 's /// diameter-sizing idiom. /// /// Single-shot only. Unlike tower placement, there is no chained/shift-held /// casting — one keypress aims one cast. /// public class BuilderSpellCastController : MonoBehaviour { // ----- Inspector -------------------------------------------------- [Tooltip("Physics layer(s) the aim raycast hits. Must cover the whole level's ground " + "(including over the enemy path), not just buildable tiles — set this to " + "'TerrainGeometry', not 'BuildablePlane'.")] [SerializeField] private LayerMask groundLayerMask; [Tooltip("Maximum raycast distance from the camera to the ground.")] [SerializeField] private float raycastMaxDistance = 500f; [Tooltip("Ground decal used for both the point reticle and the area-of-effect " + "radius preview. Auto-found in children if empty.")] [SerializeField] private DecalProjector aimDecal; [Tooltip("Fixed diameter of the reticle shown for PointTarget spells, regardless of " + "any internal splash radius the spell may use server-side.")] [SerializeField] private float pointReticleDiameter = 1.5f; [Tooltip("Vertical thickness of the decal projector's projection volume. Should " + "exceed the map's vertical range so it projects onto terrain at any height.")] [SerializeField] private float projectionDepth = 50f; // ----- Active-aim state --------------------------------------------- // -1 when not aiming. private int activeSlot = -1; // Frame on which the confirming cast click was consumed. Lets the selection controller // ignore that click order-independently — see ConsumedCastClickThisFrame. private int lastCastClickFrame = -1; /// True while a spell is being aimed (awaiting the confirming click). The /// selection input controller treats this as a modal state, so the click that confirms /// the cast doesn't also fall through to selection and deselect the builder. public bool IsAiming => activeSlot >= 0; /// True during the frame the confirming cast click was consumed. Aim mode exits /// the same frame the cast is submitted, flipping false; this stays /// true for the rest of that frame so the selection controller ignores the click no matter /// which controller's Update ran first. public bool ConsumedCastClickThisFrame => lastCastClickFrame == Time.frameCount; private BuilderSpellDefinition activeDefinition; private bool lastHitValid; private Vector3 lastHitPoint; // ----- Lifecycle ---------------------------------------------------- private void Awake() { if (aimDecal == null) aimDecal = GetComponentInChildren(); if (aimDecal != null) { aimDecal.transform.localRotation = Quaternion.Euler(90f, 0f, 0f); aimDecal.enabled = false; } } private void OnDisable() { ExitAimMode(); } private void Update() { if (!HUDController.IsTextInputActive) ScanHotkeys(); if (activeSlot < 0) return; // idle — nothing to aim // Deselecting the builder mid-aim cancels the cast, so you can't start aiming while // selected and then deselect to sneak a cast past the gate above. if (!LocalBuilderSelected()) { ExitAimMode(); return; } var keyboard = Keyboard.current; if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame) { ExitAimMode(); return; } var mouse = Mouse.current; if (mouse == null) return; if (mouse.rightButton.wasPressedThisFrame) { ExitAimMode(); return; } if (TryGetGroundHit(mouse.position.ReadValue(), out Vector3 hitPoint)) { lastHitValid = true; lastHitPoint = hitPoint; ShowAimDecal(hitPoint); } else { lastHitValid = false; HideAimDecal(); } if (mouse.leftButton.wasPressedThisFrame && lastHitValid) { // Record the frame so the selection controller ignores this click even though // TrySubmitCast exits aim mode this same frame (IsAiming flips false). Without // this, whether the click also deselected the builder depended on Update order. lastCastClickFrame = Time.frameCount; TrySubmitCast(); } } // ----- Hotkey scan -------------------------------------------------- // True when the local player's own builder is the current selection. Casting is gated // on this so it matches the spell HUD (shown only for the selected builder). private static bool LocalBuilderSelected() { var selected = SelectionState.Instance?.SelectedObject; return selected is Builder builder && builder.IsOwner; } private void ScanHotkeys() { if (activeSlot >= 0) return; // already aiming — hotkeys re-scanned only when idle // Spells cast only while the local player's OWN builder is selected — keeps casting // in sync with the spell HUD, which is shown only for the selected builder. if (!LocalBuilderSelected()) return; var loadout = PlayerSpellLoadout.Local; if (loadout == null) return; var keyboard = Keyboard.current; if (keyboard == null) return; var layout = SpellHotkeys.Layout; int slotCount = Mathf.Min(loadout.SlotCount, layout.Length); for (int i = 0; i < slotCount; i++) { if (!keyboard[layout[i]].wasPressedThisFrame) continue; if (loadout.IsSlotOnCooldown(i)) continue; BeginAiming(i, loadout); break; } } private void BeginAiming(int slot, PlayerSpellLoadout loadout) { var kind = loadout.GetKind(slot); if (kind == null) return; var definition = BuilderSpellPool.Instance?.Get(kind.Value); if (definition == null) { Debug.LogWarning($"[BuilderSpellCastController] No BuilderSpellDefinition for {kind.Value}."); return; } activeSlot = slot; activeDefinition = definition; lastHitValid = false; } private void ExitAimMode() { activeSlot = -1; activeDefinition = null; lastHitValid = false; HideAimDecal(); } // ----- Raycasting --------------------------------------------------- private bool TryGetGroundHit(Vector2 screenPos, out Vector3 hitPoint) { hitPoint = Vector3.zero; var cam = Camera.main; if (cam == null) return false; Ray ray = cam.ScreenPointToRay(new Vector3(screenPos.x, screenPos.y, 0f)); if (Physics.Raycast(ray, out RaycastHit hit, raycastMaxDistance, groundLayerMask)) { hitPoint = hit.point; return true; } return false; } // ----- Aim decal ----------------------------------------------------- private void ShowAimDecal(Vector3 point) { if (aimDecal == null) return; float diameter = activeDefinition.TargetType == SpellTargetType.AreaOfEffect ? activeDefinition.Radius * 2f : pointReticleDiameter; aimDecal.size = new Vector3(diameter, diameter, projectionDepth); aimDecal.pivot = Vector3.zero; aimDecal.transform.position = point; aimDecal.enabled = true; } private void HideAimDecal() { if (aimDecal != null) aimDecal.enabled = false; } // ----- Cast submission ------------------------------------------------ private void TrySubmitCast() { var loadout = PlayerSpellLoadout.Local; if (loadout == null) { ExitAimMode(); return; } loadout.RequestCastSpellRpc(activeSlot, lastHitPoint); ExitAimMode(); } } }