draft options working; spells won't cast though

This commit is contained in:
Ian Woods 2026-07-14 20:56:50 -07:00
parent 2429efbf6a
commit 7cf15dcaa6
37 changed files with 1340 additions and 0 deletions

View file

@ -0,0 +1,226 @@
// 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
{
/// <summary>
/// Per-client controller for the builder-spell casting UX. Watches
/// <see cref="SpellHotkeys"/> 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 <see cref="PlayerSpellLoadout"/> via RPC on confirm.
/// </summary>
/// <remarks>
/// <para><b>Plain MonoBehaviour.</b> Aiming visuals are purely cosmetic and local, same
/// rationale as <see cref="TowerPlacementController"/>. All server-authoritative
/// resolution lives in <see cref="PlayerSpellLoadout.RequestCastSpellRpc"/>.</para>
///
/// <para><b>Point vs. area preview.</b> <see cref="SpellTargetType.PointTarget"/> shows a
/// small fixed-size reticle, even if the spell has an internal splash radius.
/// <see cref="SpellTargetType.AreaOfEffect"/> scales the same decal to the spell's
/// <see cref="BuilderSpellDefinition.Radius"/>, mirroring <see cref="BuildRangeIndicator"/>'s
/// diameter-sizing idiom.</para>
///
/// <para><b>Single-shot only.</b> Unlike tower placement, there is no chained/shift-held
/// casting — one keypress aims one cast.</para>
/// </remarks>
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<DecalProjector>();
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();
}
}
}