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,91 @@
// Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellDefinition.cs
using UnityEngine;
using TD.Core;
namespace TD.Gameplay.BuilderSpells
{
/// <summary>
/// Base class for one builder spell — a player-cast, key-triggered ability (e.g. "drop a
/// fireball on a point", "slow enemies in a radius"). Granted via the draft (<see
/// cref="TD.Gameplay.Draft.BuilderSpellDraftOption"/>) and tracked per-player by <see
/// cref="PlayerSpellLoadout"/>, which owns the hotkey slot and cooldown state.
/// </summary>
/// <remarks>
/// <para><b>Contrast with <see cref="BuilderEffects.BuilderEffectDefinition"/>.</b> Effects
/// are passive and queried by consumers; spells are active and resolve themselves.
/// <see cref="ServerCast"/> is that resolution — it IS the "apply the effect" method,
/// replacing the effect system's <c>IsValidFor</c>-only gate pattern (grant-time validity
/// still lives on <see cref="TD.Gameplay.Draft.BuilderSpellDraftOption.IsValidFor"/>).</para>
///
/// <para><b>One asset per kind.</b> <see cref="Kind"/> is fixed per subclass, same as
/// <see cref="BuilderEffects.BuilderEffectDefinition"/>. <see cref="BuilderSpellPool"/> uses
/// it to build a fixed-size, enum-indexed lookup table.</para>
///
/// <para><b>Server-only resolution, client-only visual.</b> <see cref="ServerCast"/> runs on
/// the server and applies damage/status via the same <c>Physics.OverlapSphereNonAlloc</c> +
/// <c>EnemyHealth</c>/<c>EnemyStatus</c> pattern <c>TowerCombat</c> already uses.
/// <see cref="ClientPlayVfx"/> runs on every peer (including the server) after a successful
/// cast, purely for presentation.</para>
/// </remarks>
public abstract class BuilderSpellDefinition : ScriptableObject
{
/// <summary>Which builder spell this asset's data belongs to.</summary>
public abstract BuilderSpellKind Kind { get; }
[Header("Presentation")]
[Tooltip("Name shown on the draft card and the cast hotbar.")]
public string DisplayName;
[Tooltip("Short description shown on the draft card.")]
[TextArea(2, 4)]
public string Description;
[Tooltip("Icon shown on the draft card and the cast hotbar.")]
public Sprite Icon;
[Header("Casting")]
[Tooltip("Seconds before this spell can be cast again after a successful cast.")]
[Min(0f)]
public float Cooldown = 5f;
[Tooltip("How the cast controller aims this spell. AreaOfEffect previews Radius as a " +
"decal while aiming; PointTarget does not, even if Radius is used internally " +
"(e.g. a splash radius).")]
public SpellTargetType TargetType;
[Tooltip("AreaOfEffect: the resolution radius AND the client-side preview size. " +
"PointTarget: optional internal-only radius (e.g. splash) with no preview.")]
[Min(0f)]
public float Radius;
[Tooltip("Physics layer(s) enemies occupy, queried by this spell's own OverlapSphere " +
"call. Each spell asset authors its own mask — same convention as " +
"TowerCombat/Projectile's per-instance enemyLayerMask.")]
[SerializeField]
protected LayerMask enemyLayerMask;
// Shared scratch buffer for OverlapSphereNonAlloc queries. The server processes casts
// sequentially (one RPC handler at a time), so a static buffer shared across all spell
// assets is safe — mirrors TowerCombat.s_overlapBuffer, but sized larger (64 vs. 32):
// OverlapSphereNonAlloc silently truncates past the buffer length with no way to
// detect the truncation, and spells are more likely than a single tower's splash/chain
// radius to catch a dense horde. TowerCombat's buffer is intentionally left at 32 —
// out of scope here, flagged separately for the team to revisit.
protected static readonly Collider[] s_overlapBuffer = new Collider[64];
/// <summary>
/// Server-only: resolve this spell's effect at <paramref name="targetPoint"/> for the
/// casting player. Returns false if the cast could not be applied (e.g. hit nothing) —
/// <see cref="PlayerSpellLoadout"/> treats a false return as a no-op and does not start
/// the cooldown.
/// </summary>
public abstract bool ServerCast(ulong clientId, Vector3 targetPoint);
/// <summary>
/// Runs on every peer (via <see cref="PlayerSpellLoadout"/>'s ClientRpc) after a
/// successful <see cref="ServerCast"/>. Default no-op; override to spawn an impact VFX
/// prefab and self-destroy it, the same idiom used elsewhere for one-off visuals.
/// </summary>
public virtual void ClientPlayVfx(Vector3 targetPoint) { }
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9fca3191d98aa2652a9c5475d5c6b153

View file

@ -0,0 +1,68 @@
// Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellPool.cs
using System;
using UnityEngine;
using TD.Core;
namespace TD.Gameplay.BuilderSpells
{
/// <summary>
/// Scene singleton holding every <see cref="BuilderSpellDefinition"/> available this match,
/// authored as a flat array in the inspector for convenience. Internally it builds a
/// fixed-size table indexed by <see cref="BuilderSpellKind"/> so lookups are a single array
/// access, not a scan.
/// </summary>
/// <remarks>
/// Plain MonoBehaviour: identical on every peer (same assets), so there is nothing to sync.
/// The server reads it to resolve casts; clients read it to resolve <see
/// cref="BuilderSpellDefinition.ClientPlayVfx"/> and to render the cast hotbar. Mirrors
/// <see cref="BuilderEffects.BuilderEffectPool"/> exactly.
/// </remarks>
public class BuilderSpellPool : MonoBehaviour
{
public static BuilderSpellPool Instance { get; private set; }
[Tooltip("Every BuilderSpellDefinition asset available this match. One entry per " +
"BuilderSpellKind — order doesn't matter, Kind on the asset itself decides " +
"its slot.")]
[SerializeField] private BuilderSpellDefinition[] spells;
// Fixed-size, enum-indexed lookup built once in Awake. Sized to the enum's entry count,
// not authored count, so an out-of-range Kind is a compile-time impossibility rather
// than a bounds check we'd otherwise need on every Get().
private BuilderSpellDefinition[] byKind;
private void Awake()
{
if (Instance != null && Instance != this)
{
Debug.LogError("[BuilderSpellPool] Multiple instances detected. Only one per scene.");
return;
}
Instance = this;
int kindCount = Enum.GetValues(typeof(BuilderSpellKind)).Length;
byKind = new BuilderSpellDefinition[kindCount];
if (spells == null) return;
for (int i = 0; i < spells.Length; i++)
{
var def = spells[i];
if (def == null) continue;
byKind[(int)def.Kind] = def;
}
}
private void OnDestroy()
{
if (Instance == this) Instance = null;
}
/// <summary>Returns the spell asset for <paramref name="kind"/>, or null if none is
/// authored in this pool.</summary>
public BuilderSpellDefinition Get(BuilderSpellKind kind)
{
int i = (int)kind;
return (byKind != null && i >= 0 && i < byKind.Length) ? byKind[i] : null;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 36a7be1b3824682b8a183fbcf8564652

View file

@ -0,0 +1,47 @@
// Assets/_Project/Scripts/Gameplay/BuilderSpells/FireballSpellDefinition.cs
using UnityEngine;
using TD.Core;
namespace TD.Gameplay.BuilderSpells
{
/// <summary>
/// Builder spell #1 — drop a fireball on a point, dealing direct damage to whatever's
/// there plus splash damage to nearby enemies.
/// </summary>
/// <remarks>
/// <see cref="BuilderSpellDefinition.TargetType"/> is <see cref="SpellTargetType.PointTarget"/>
/// — the cast controller shows only a fixed-size reticle, even though
/// <see cref="BuilderSpellDefinition.Radius"/> is still used internally here as the splash
/// radius (0 = no splash, direct hit only).
/// </remarks>
[CreateAssetMenu(fileName = "FireballSpell", menuName = "TD/Builder Spells/Fireball")]
public class FireballSpellDefinition : BuilderSpellDefinition
{
public override BuilderSpellKind Kind => BuilderSpellKind.Fireball;
[Header("Fireball")]
[Tooltip("Damage dealt to every enemy within Radius of the target point.")]
[Min(0f)]
public float Damage = 50f;
public override bool ServerCast(ulong clientId, Vector3 targetPoint)
{
PlayerSlot owner = PlayerMatchState.SlotForClient(clientId);
int count = Physics.OverlapSphereNonAlloc(
targetPoint, Mathf.Max(Radius, 0.01f), s_overlapBuffer, enemyLayerMask);
bool hitAny = false;
for (int i = 0; i < count; i++)
{
var enemyHealth = s_overlapBuffer[i].GetComponent<EnemyHealth>();
if (enemyHealth == null || enemyHealth.IsDead) continue;
enemyHealth.TakeDamage(Damage, DamageType.Fire, owner);
hitAny = true;
}
return hitAny;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 22b36bfcd0ae4af4098d908138e874e1

View file

@ -0,0 +1,55 @@
// Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs
using UnityEngine;
using TD.Core;
namespace TD.Gameplay.BuilderSpells
{
/// <summary>
/// Builder spell #2 — slow every enemy within an area for a duration.
/// </summary>
/// <remarks>
/// <see cref="BuilderSpellDefinition.TargetType"/> is <see cref="SpellTargetType.AreaOfEffect"/>
/// — the cast controller previews <see cref="BuilderSpellDefinition.Radius"/> as a decal
/// while aiming, and this spell resolves against that same radius (no separate field).
/// Reuses the existing slow/DoT system (<see cref="EnemyStatus.ApplyEffect"/>) rather than
/// introducing a new status mechanism.
/// </remarks>
[CreateAssetMenu(fileName = "SlowAreaSpell", menuName = "TD/Builder Spells/Slow Area")]
public class SlowAreaSpellDefinition : BuilderSpellDefinition
{
public override BuilderSpellKind Kind => BuilderSpellKind.SlowArea;
[Header("Slow Area")]
[Tooltip("Speed multiplier applied to affected enemies for EffectDuration " +
"(e.g. 0.5 = half speed).")]
[Range(0f, 1f)]
public float SlowFactor = 0.5f;
[Tooltip("Seconds the slow lasts. Re-casting on an already-slowed enemy refreshes it.")]
[Min(0f)]
public float EffectDuration = 3f;
public override bool ServerCast(ulong clientId, Vector3 targetPoint)
{
PlayerSlot owner = PlayerMatchState.SlotForClient(clientId);
int count = Physics.OverlapSphereNonAlloc(
targetPoint, Mathf.Max(Radius, 0.01f), s_overlapBuffer, enemyLayerMask);
bool hitAny = false;
for (int i = 0; i < count; i++)
{
var enemyHealth = s_overlapBuffer[i].GetComponent<EnemyHealth>();
if (enemyHealth == null || enemyHealth.IsDead) continue;
var enemyStatus = s_overlapBuffer[i].GetComponent<EnemyStatus>();
if (enemyStatus == null) continue;
enemyStatus.ApplyEffect(DamageType.Cold, SlowFactor, EffectDuration, owner);
hitAny = true;
}
return hitAny;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 249c65067c3bac80693255e594d844c7