Updates to spells, HUD, Gold, and Camera Controls. New sound effects!

This commit is contained in:
Matt F 2026-07-18 18:12:56 -07:00
parent 689fe7be88
commit a33d951abd
27 changed files with 578 additions and 68 deletions

View file

@ -85,6 +85,7 @@ namespace TD.Gameplay
// Cached reference to the local TowerPaintController, looked up lazily (same
// rationale as the placement controller).
private TowerPaintController cachedPaintController;
private BuilderSpellCastController cachedSpellCastController;
// ----- Lifecycle --------------------------------------------------
@ -120,7 +121,7 @@ namespace TD.Gameplay
// Placement and paint are both modal: while either is active, the local
// controller for that mode owns left-/right-click, so selection here yields.
bool isModal = IsLocalPlayerPlacing() || IsLocalPlayerPainting();
bool isModal = IsLocalPlayerPlacing() || IsLocalPlayerPainting() || IsLocalPlayerAimingSpell();
Vector2 mousePos = mouse.position.ReadValue();
// UI Toolkit dispatches button click events AFTER Update runs, but raw mouse
@ -292,5 +293,21 @@ namespace TD.Gameplay
}
return cachedPaintController.IsPainting;
}
private bool IsLocalPlayerAimingSpell()
{
if (cachedSpellCastController == null)
{
// Find lazily — controller may have been added after this component spawned.
cachedSpellCastController =
UnityEngine.Object.FindAnyObjectByType<BuilderSpellCastController>();
if (cachedSpellCastController == null) return false;
}
// IsAiming covers "aiming, click not yet made"; ConsumedCastClickThisFrame covers the
// frame the confirming click casts and exits aim mode — together they suppress the
// selection click regardless of Update order between the two controllers.
return cachedSpellCastController.IsAiming
|| cachedSpellCastController.ConsumedCastClickThisFrame;
}
}
}

View file

@ -56,6 +56,21 @@ namespace TD.Gameplay
// -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;
@ -85,6 +100,14 @@ namespace TD.Gameplay
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)
{
@ -115,16 +138,32 @@ namespace TD.Gameplay
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;

View file

@ -24,8 +24,9 @@ namespace TD.Gameplay.BuilderSpells
/// <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>
/// Presentation is split into <see cref="ClientSpawnVfx"/> (on cast) and
/// <see cref="ClientPlayImpact"/> (on contact, <see cref="ImpactDelay"/> seconds later for a
/// projectile spell), both running on every peer including the server.</para>
/// </remarks>
public abstract class BuilderSpellDefinition : ScriptableObject
{
@ -82,10 +83,28 @@ namespace TD.Gameplay.BuilderSpells
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.
/// Seconds between the cast and the effect landing. 0 = instant: damage and the impact
/// sound resolve immediately on cast. A projectile-style spell (e.g. the Fireball meteor)
/// overrides this with its fall/travel time, so <see cref="PlayerSpellLoadout"/> spawns the
/// visual on cast but holds <see cref="ServerCast"/> (damage) and <see cref="ClientPlayImpact"/>
/// (sound) until the projectile reaches the ground. Set it to match the VFX's fall time.
/// </summary>
public virtual void ClientPlayVfx(Vector3 targetPoint) { }
public virtual float ImpactDelay => 0f;
/// <summary>
/// Runs on every peer (via <see cref="PlayerSpellLoadout"/>'s ClientRpc) the moment the
/// spell is cast. Spawns the visual: for an instant spell that's the whole effect; for a
/// delayed spell it's the projectile/travel visual that lands after <see cref="ImpactDelay"/>.
/// Default no-op.
/// </summary>
public virtual void ClientSpawnVfx(Vector3 targetPoint) { }
/// <summary>
/// Runs on every peer when the spell makes contact — immediately for an instant spell, or
/// <see cref="ImpactDelay"/> seconds after cast for a delayed one. Play the impact sound
/// (and any impact-moment visual) here so it lands with the effect, not the throw.
/// Default no-op.
/// </summary>
public virtual void ClientPlayImpact(Vector3 targetPoint) { }
}
}

View file

@ -14,7 +14,8 @@ namespace TD.Gameplay.BuilderSpells
/// <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
/// cref="BuilderSpellDefinition.ClientSpawnVfx"/> / <see
/// cref="BuilderSpellDefinition.ClientPlayImpact"/> and to render the cast hotbar. Mirrors
/// <see cref="BuilderEffects.BuilderEffectPool"/> exactly.
/// </remarks>
public class BuilderSpellPool : MonoBehaviour

View file

@ -28,6 +28,14 @@ namespace TD.Gameplay.BuilderSpells
[Min(0f)]
[SerializeField] private float impactVfxLifetime = 2f;
[Tooltip("Seconds from cast until the falling fireball reaches the ground. Damage and the " +
"impact sound are held until then so they land with the visual — SET THIS TO MATCH " +
"the meteor VFX's fall time. 0 = everything resolves instantly on cast.")]
[Min(0f)]
[SerializeField] private float impactDelay = 0.8f;
public override float ImpactDelay => impactDelay;
[Header("Impact Sound")]
[Tooltip("Sound played on every peer when this spell resolves successfully. Same " +
"SoundConfig struct TowerBuiltSound uses — reuse a tower's clip directly if " +
@ -59,11 +67,16 @@ namespace TD.Gameplay.BuilderSpells
return hitAny;
}
public override void ClientPlayVfx(Vector3 targetPoint)
public override void ClientSpawnVfx(Vector3 targetPoint)
{
// The meteor VFX plays its full fall-and-crash animation from here; it reaches the
// ground after ImpactDelay, which is when ClientPlayImpact and ServerCast fire.
if (impactVfxPrefab != null)
Destroy(Instantiate(impactVfxPrefab, targetPoint, Quaternion.identity), impactVfxLifetime);
}
public override void ClientPlayImpact(Vector3 targetPoint)
{
if (impactSound.clip != null)
AudioManager.Instance?.Play(impactSound.clip, AudioCategory.Combat,
impactSound.RandomPitch(), impactSound.volume);

View file

@ -62,7 +62,7 @@ namespace TD.Gameplay.BuilderSpells
return hitAny;
}
public override void ClientPlayVfx(Vector3 targetPoint)
public override void ClientSpawnVfx(Vector3 targetPoint)
{
if (areaVfxPrefab != null)
{
@ -70,7 +70,11 @@ namespace TD.Gameplay.BuilderSpells
instance.transform.localScale *= Mathf.Max(Radius, 0.01f);
Destroy(instance, EffectDuration);
}
}
public override void ClientPlayImpact(Vector3 targetPoint)
{
// Instant spell (ImpactDelay = 0), so this fires on cast alongside ClientSpawnVfx.
if (areaSound.clip != null)
AudioManager.Instance?.Play(areaSound.clip, AudioCategory.Combat,
areaSound.RandomPitch(), areaSound.volume);

View file

@ -227,16 +227,17 @@ namespace TD.Gameplay
{
Vector2 dir = Vector2.zero;
// Keyboard: WASD + arrow keys. Suppressed entirely while the player
// is typing — pressing 'a' or 'w' into chat should not pan the camera.
// (Edge-pan below stays active since it's mouse-driven.)
// Keyboard: arrow keys only. WASD is reserved for tower-build hotkeys (the command
// grid) — camera panning is arrow keys plus the mouse edge-pan below. Suppressed
// entirely while the player is typing so arrow keys in chat navigate text instead of
// panning. (Edge-pan below stays active since it's mouse-driven.)
var kb = HUDController.IsTextInputActive ? null : Keyboard.current;
if (kb != null)
{
if (kb.aKey.isPressed || kb.leftArrowKey.isPressed) dir.x -= 1f;
if (kb.dKey.isPressed || kb.rightArrowKey.isPressed) dir.x += 1f;
if (kb.sKey.isPressed || kb.downArrowKey.isPressed) dir.y -= 1f;
if (kb.wKey.isPressed || kb.upArrowKey.isPressed) dir.y += 1f;
if (kb.leftArrowKey.isPressed) dir.x -= 1f;
if (kb.rightArrowKey.isPressed) dir.x += 1f;
if (kb.downArrowKey.isPressed) dir.y -= 1f;
if (kb.upArrowKey.isPressed) dir.y += 1f;
}
// Edge-pan: mouse near screen edge adds to keyboard direction.

View file

@ -37,5 +37,14 @@ namespace TD.Gameplay.Draft
var loadout = PlayerSpellLoadout.GetForClient(clientId);
return loadout != null && loadout.ServerGrantSpell(Kind);
}
/// <summary>Inherits the granted spell's icon (resolved from the pool by
/// <see cref="Kind"/>) unless this option assigns an override.</summary>
public override Sprite ResolveIcon()
{
if (Icon != null) return Icon;
var def = BuilderSpellPool.Instance != null ? BuilderSpellPool.Instance.Get(Kind) : null;
return def != null ? def.Icon : null;
}
}
}

View file

@ -34,9 +34,19 @@ namespace TD.Gameplay.Draft
[TextArea(2, 4)]
public string Description;
[Tooltip("Icon shown on the draft card. Optional for placeholder content.")]
[Tooltip("OPTIONAL override for the draft card icon. Leave empty to inherit the icon of " +
"the item this option grants (its tower / spell); assign one only when you want " +
"to override that inherited icon.")]
public Sprite Icon;
/// <summary>
/// The icon actually shown on the draft card. Returns the explicit <see cref="Icon"/>
/// override when one is assigned; otherwise subclasses fall back to the icon of the item
/// they grant (tower, spell). The base class has no referenced item, so it just returns
/// <see cref="Icon"/> (which may be null).
/// </summary>
public virtual Sprite ResolveIcon() => Icon;
[Header("Generation")]
[Tooltip("Relative draw weight. Higher = offered more often. Rarer rewards use " +
"lower weights. Must be > 0.")]

View file

@ -48,5 +48,9 @@ namespace TD.Gameplay.Draft
return deck.ServerGrantTower(typeId);
}
/// <summary>Inherits the granted tower's icon unless this option assigns an override.</summary>
public override Sprite ResolveIcon()
=> Icon != null ? Icon : (Tower != null ? Tower.Icon : null);
}
}

View file

@ -1,4 +1,5 @@
// Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs
using System.Collections;
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
@ -20,9 +21,12 @@ namespace TD.Gameplay
/// slot index and an aimed world point. The server re-validates everything (slot exists,
/// off cooldown, <see cref="MatchPhase.Playing"/>) since client-side checks in
/// <see cref="BuilderSpellCastController"/> are UX-only, resolves the
/// <see cref="BuilderSpellDefinition"/> via <see cref="BuilderSpellPool"/>, and calls
/// <see cref="BuilderSpellDefinition.ServerCast"/>. A successful cast starts the cooldown
/// and fires <see cref="PlayVfxClientRpc"/> so every peer plays the same visual.</para>
/// <see cref="BuilderSpellDefinition"/> via <see cref="BuilderSpellPool"/>. An <b>instant</b>
/// spell (<see cref="BuilderSpellDefinition.ImpactDelay"/> == 0) resolves on cast: a hit starts
/// the cooldown and fires <see cref="PlayVfxClientRpc"/>; a miss is a no-op. A <b>delayed</b>
/// spell (a projectile, e.g. the Fireball meteor) commits on the throw — cooldown + the falling
/// visual fire immediately — while its damage and impact sound are held until the projectile
/// lands <see cref="BuilderSpellDefinition.ImpactDelay"/> seconds later.</para>
/// </remarks>
public class PlayerSpellLoadout : NetworkBehaviour
{
@ -142,7 +146,7 @@ namespace TD.Gameplay
/// <paramref name="slot"/> at <paramref name="targetPoint"/>. All validation happens
/// here on the server; the client only uses this to trigger an attempt.
/// </summary>
[Rpc(SendTo.Server, RequireOwnership = true)]
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
public void RequestCastSpellRpc(int slot, Vector3 targetPoint)
{
if (MatchState.Instance == null || MatchState.Instance.Phase != MatchPhase.Playing)
@ -160,18 +164,69 @@ namespace TD.Gameplay
return;
}
if (!definition.ServerCast(OwnerClientId, targetPoint)) return;
if (definition.ImpactDelay <= 0f)
{
// Instant spell (e.g. Slow Area): resolve on cast. A miss (hit nothing) is a no-op
// and does NOT start the cooldown — unchanged behavior.
if (!definition.ServerCast(OwnerClientId, targetPoint)) return;
StartCooldown(slot, definition);
PlayVfxClientRpc(slotValue.Kind, targetPoint);
}
else
{
// Delayed spell (e.g. the Fireball meteor): the projectile has to fall before it
// lands, so the cast is COMMITTED on the throw — cooldown starts and the falling
// visual spawns now — while damage and the impact sound wait until it hits the
// ground (ImpactDelay later). Enemies are re-scanned at impact, so movement during
// the fall resolves correctly.
StartCooldown(slot, definition);
PlayVfxClientRpc(slotValue.Kind, targetPoint);
StartCoroutine(ServerResolveImpactAfterDelay(
slotValue.Kind, OwnerClientId, targetPoint, definition.ImpactDelay));
}
}
// Server-only: stamp the slot's cooldown from the definition and replicate it.
private void StartCooldown(int slot, BuilderSpellDefinition definition)
{
var slotValue = spells[slot];
slotValue.CooldownEndServerTime = NetworkManager.ServerTime.Time + definition.Cooldown;
spells[slot] = slotValue;
}
PlayVfxClientRpc(slotValue.Kind, targetPoint);
// Server-only: apply a delayed spell's effect once its projectile has landed. Re-resolves
// targets at impact — the enemy set may have shifted during the fall.
private IEnumerator ServerResolveImpactAfterDelay(
BuilderSpellKind kind, ulong clientId, Vector3 targetPoint, float delay)
{
yield return new WaitForSeconds(delay);
if (!IsServer) yield break;
BuilderSpellPool.Instance?.Get(kind)?.ServerCast(clientId, targetPoint);
}
[ClientRpc]
private void PlayVfxClientRpc(BuilderSpellKind kind, Vector3 targetPoint)
{
BuilderSpellPool.Instance?.Get(kind)?.ClientPlayVfx(targetPoint);
var def = BuilderSpellPool.Instance?.Get(kind);
if (def == null) return;
def.ClientSpawnVfx(targetPoint); // spawn the (possibly falling) visual now
if (def.ImpactDelay <= 0f)
def.ClientPlayImpact(targetPoint);
else
StartCoroutine(ClientPlayImpactAfterDelay(kind, targetPoint, def.ImpactDelay));
}
// Client-side: play the impact (sound + any contact visual) when the projectile lands.
// Timed locally off the same ImpactDelay so it stays in lockstep with this peer's own
// falling visual — no second RPC, no double latency.
private IEnumerator ClientPlayImpactAfterDelay(
BuilderSpellKind kind, Vector3 targetPoint, float delay)
{
yield return new WaitForSeconds(delay);
BuilderSpellPool.Instance?.Get(kind)?.ClientPlayImpact(targetPoint);
}
}
}