// 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; 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 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) { TrySubmitCast(); } } // ----- Hotkey scan -------------------------------------------------- private void ScanHotkeys() { if (activeSlot >= 0) return; // already aiming — hotkeys re-scanned only when idle 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(); } } }