265 lines
10 KiB
C#
265 lines
10 KiB
C#
// 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;
|
|
|
|
// Frame on which the confirming cast click was consumed. Lets the selection controller
|
|
// ignore that click order-independently — see ConsumedCastClickThisFrame.
|
|
private int lastCastClickFrame = -1;
|
|
|
|
/// <summary>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.</summary>
|
|
public bool IsAiming => activeSlot >= 0;
|
|
|
|
/// <summary>True during the frame the confirming cast click was consumed. Aim mode exits
|
|
/// the same frame the cast is submitted, flipping <see cref="IsAiming"/> 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.</summary>
|
|
public bool ConsumedCastClickThisFrame => lastCastClickFrame == Time.frameCount;
|
|
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.IsUiCapturingInput)
|
|
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();
|
|
}
|
|
}
|
|
}
|