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

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b086eebc9f3f87543bf2c98de8c8e4af
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

BIN
Assets/_Project/Art/Sprites/Spells/FireballSpell.png (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: eff45e5e3dab24f438c054a095d34578
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

BIN
Assets/_Project/Art/Sprites/Spells/SlowSpell.png (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: 5f5072d8c9f626c4690a7b231c7488f9
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

BIN
Assets/_Project/Audio/Sound Effects/FireballExplosion.ogg (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,23 @@
fileFormatVersion: 2
guid: e54448243d0478045b6566412c54e4a7
AudioImporter:
externalObjects: {}
serializedVersion: 8
defaultSettings:
serializedVersion: 2
loadType: 0
sampleRateSetting: 0
sampleRateOverride: 44100
compressionFormat: 1
quality: 1
conversionMode: 0
preloadAudioData: 0
platformSettingOverrides: {}
forceToMono: 0
normalize: 1
loadInBackground: 0
ambisonic: 0
3D: 1
userData:
assetBundleName:
assetBundleVariant:

BIN
Assets/_Project/Audio/Sound Effects/TimeSlow.ogg (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,23 @@
fileFormatVersion: 2
guid: 038ff3eab9aa0034bac26ccb648ef2a1
AudioImporter:
externalObjects: {}
serializedVersion: 8
defaultSettings:
serializedVersion: 2
loadType: 0
sampleRateSetting: 0
sampleRateOverride: 44100
compressionFormat: 1
quality: 1
conversionMode: 0
preloadAudioData: 0
platformSettingOverrides: {}
forceToMono: 0
normalize: 1
loadInBackground: 0
ambisonic: 0
3D: 1
userData:
assetBundleName:
assetBundleVariant:

View file

@ -14,18 +14,19 @@ MonoBehaviour:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuilderSpells.FireballSpellDefinition m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuilderSpells.FireballSpellDefinition
DisplayName: Fireball DisplayName: Fireball
Description: Shoot a fireball Description: Shoot a fireball
Icon: {fileID: 0} Icon: {fileID: 21300000, guid: eff45e5e3dab24f438c054a095d34578, type: 3}
Cooldown: 5 Cooldown: 1
TargetType: 0 TargetType: 0
Radius: 3 Radius: 5
enemyLayerMask: enemyLayerMask:
serializedVersion: 2 serializedVersion: 2
m_Bits: 1024 m_Bits: 1024
impactVfxPrefab: {fileID: 158073311046191680, guid: 80eaad6d0b024835482b64625b63053c, type: 3} impactVfxPrefab: {fileID: 158073311046191680, guid: 80eaad6d0b024835482b64625b63053c, type: 3}
impactVfxLifetime: 3 impactVfxLifetime: 3
impactDelay: 0.9
impactSound: impactSound:
clip: {fileID: 8300000, guid: 4ee1b63f27ea04451802bafd35ecef89, type: 3} clip: {fileID: 8300000, guid: e54448243d0478045b6566412c54e4a7, type: 3}
volume: 0.5 volume: 0.603
minPitch: 1.048 minPitch: 1.048
maxPitch: 0.969 maxPitch: 0.969
Damage: 50 Damage: 500

View file

@ -14,8 +14,8 @@ MonoBehaviour:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuilderSpells.SlowAreaSpellDefinition m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuilderSpells.SlowAreaSpellDefinition
DisplayName: Slow Area DisplayName: Slow Area
Description: Slows enemies in an area Description: Slows enemies in an area
Icon: {fileID: 0} Icon: {fileID: 21300000, guid: 5f5072d8c9f626c4690a7b231c7488f9, type: 3}
Cooldown: 5 Cooldown: 10
TargetType: 1 TargetType: 1
Radius: 5 Radius: 5
enemyLayerMask: enemyLayerMask:
@ -23,9 +23,9 @@ MonoBehaviour:
m_Bits: 1024 m_Bits: 1024
areaVfxPrefab: {fileID: 1462673130280185047, guid: bf2f1f2a9a54e9162b80673e3f7eeaf6, type: 3} areaVfxPrefab: {fileID: 1462673130280185047, guid: bf2f1f2a9a54e9162b80673e3f7eeaf6, type: 3}
areaSound: areaSound:
clip: {fileID: 0} clip: {fileID: 8300000, guid: 038ff3eab9aa0034bac26ccb648ef2a1, type: 3}
volume: 0 volume: 0.632
minPitch: 0 minPitch: 1.14
maxPitch: 0 maxPitch: 0.96
SlowFactor: 0.5 SlowFactor: 0.5
EffectDuration: 3 EffectDuration: 8

View file

@ -17,44 +17,40 @@ MonoBehaviour:
- Wave: {fileID: 11400000, guid: 65f66289ea1233b4897f46cd997d9c7a, type: 2} - Wave: {fileID: 11400000, guid: 65f66289ea1233b4897f46cd997d9c7a, type: 2}
GoldPerEnemy: 5 GoldPerEnemy: 5
CompletionBonus: 25 CompletionBonus: 25
NoLeaksBonus: 50 NoLeaksBonus: 10
- Wave: {fileID: 11400000, guid: 190e39db44aa0794aa808fd60976f7c4, type: 2} - Wave: {fileID: 11400000, guid: 190e39db44aa0794aa808fd60976f7c4, type: 2}
GoldPerEnemy: 7 GoldPerEnemy: 7
CompletionBonus: 30 CompletionBonus: 30
NoLeaksBonus: 50 NoLeaksBonus: 10
- Wave: {fileID: 11400000, guid: 39921b44a1a0a56478200028940c5202, type: 2} - Wave: {fileID: 11400000, guid: 39921b44a1a0a56478200028940c5202, type: 2}
GoldPerEnemy: 10 GoldPerEnemy: 10
CompletionBonus: 35 CompletionBonus: 35
NoLeaksBonus: 50 NoLeaksBonus: 10
- Wave: {fileID: 11400000, guid: 50f498bc5bfc46e44b064cc96403e2cb, type: 2} - Wave: {fileID: 11400000, guid: 50f498bc5bfc46e44b064cc96403e2cb, type: 2}
GoldPerEnemy: 15 GoldPerEnemy: 15
CompletionBonus: 40 CompletionBonus: 40
NoLeaksBonus: 50 NoLeaksBonus: 10
- Wave: {fileID: 11400000, guid: 9ee6dee12f0660844b4d46880f01c02f, type: 2} - Wave: {fileID: 11400000, guid: 9ee6dee12f0660844b4d46880f01c02f, type: 2}
GoldPerEnemy: 15 GoldPerEnemy: 15
CompletionBonus: 45 CompletionBonus: 45
NoLeaksBonus: 50 NoLeaksBonus: 10
- Wave: {fileID: 11400000, guid: 41231de63e8f25d448f19f3816e0c22f, type: 2} - Wave: {fileID: 11400000, guid: 41231de63e8f25d448f19f3816e0c22f, type: 2}
GoldPerEnemy: 20 GoldPerEnemy: 20
CompletionBonus: 50 CompletionBonus: 50
NoLeaksBonus: 100 NoLeaksBonus: 10
- Wave: {fileID: 11400000, guid: 6290df178cbd3144aa92a0f09833a7be, type: 2} - Wave: {fileID: 11400000, guid: 6290df178cbd3144aa92a0f09833a7be, type: 2}
GoldPerEnemy: 20 GoldPerEnemy: 20
CompletionBonus: 55 CompletionBonus: 55
NoLeaksBonus: 100 NoLeaksBonus: 20
- Wave: {fileID: 11400000, guid: 86736fd52c18fa84e8ced40f30b514fa, type: 2} - Wave: {fileID: 11400000, guid: 86736fd52c18fa84e8ced40f30b514fa, type: 2}
GoldPerEnemy: 25 GoldPerEnemy: 25
CompletionBonus: 60 CompletionBonus: 60
NoLeaksBonus: 100 NoLeaksBonus: 20
- Wave: {fileID: 11400000, guid: 8fdf53cfc405a5f41a00f376198b8d84, type: 2} - Wave: {fileID: 11400000, guid: 8fdf53cfc405a5f41a00f376198b8d84, type: 2}
GoldPerEnemy: 25 GoldPerEnemy: 25
CompletionBonus: 65 CompletionBonus: 65
NoLeaksBonus: 100 NoLeaksBonus: 20
- Wave: {fileID: 11400000, guid: 4db677d2940202340841471f90a5b73a, type: 2} - Wave: {fileID: 11400000, guid: 4db677d2940202340841471f90a5b73a, type: 2}
GoldPerEnemy: 25 GoldPerEnemy: 25
CompletionBonus: 70 CompletionBonus: 70
NoLeaksBonus: 100 NoLeaksBonus: 20
- Wave: {fileID: 11400000, guid: fc1a55fab4ce81047b4925f53a3a8b8d, type: 2}
GoldPerEnemy: 50
CompletionBonus: 75
NoLeaksBonus: 500

View file

@ -85,6 +85,7 @@ namespace TD.Gameplay
// Cached reference to the local TowerPaintController, looked up lazily (same // Cached reference to the local TowerPaintController, looked up lazily (same
// rationale as the placement controller). // rationale as the placement controller).
private TowerPaintController cachedPaintController; private TowerPaintController cachedPaintController;
private BuilderSpellCastController cachedSpellCastController;
// ----- Lifecycle -------------------------------------------------- // ----- Lifecycle --------------------------------------------------
@ -120,7 +121,7 @@ namespace TD.Gameplay
// Placement and paint are both modal: while either is active, the local // Placement and paint are both modal: while either is active, the local
// controller for that mode owns left-/right-click, so selection here yields. // 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(); Vector2 mousePos = mouse.position.ReadValue();
// UI Toolkit dispatches button click events AFTER Update runs, but raw mouse // UI Toolkit dispatches button click events AFTER Update runs, but raw mouse
@ -292,5 +293,21 @@ namespace TD.Gameplay
} }
return cachedPaintController.IsPainting; 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. // -1 when not aiming.
private int activeSlot = -1; 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 BuilderSpellDefinition activeDefinition;
private bool lastHitValid; private bool lastHitValid;
@ -85,6 +100,14 @@ namespace TD.Gameplay
if (activeSlot < 0) return; // idle — nothing to aim 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; var keyboard = Keyboard.current;
if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame) if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame)
{ {
@ -115,16 +138,32 @@ namespace TD.Gameplay
if (mouse.leftButton.wasPressedThisFrame && lastHitValid) 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(); TrySubmitCast();
} }
} }
// ----- Hotkey scan -------------------------------------------------- // ----- 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() private void ScanHotkeys()
{ {
if (activeSlot >= 0) return; // already aiming — hotkeys re-scanned only when idle 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; var loadout = PlayerSpellLoadout.Local;
if (loadout == null) return; 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 /// <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> + /// 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. /// <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 /// Presentation is split into <see cref="ClientSpawnVfx"/> (on cast) and
/// cast, purely for presentation.</para> /// <see cref="ClientPlayImpact"/> (on contact, <see cref="ImpactDelay"/> seconds later for a
/// projectile spell), both running on every peer including the server.</para>
/// </remarks> /// </remarks>
public abstract class BuilderSpellDefinition : ScriptableObject public abstract class BuilderSpellDefinition : ScriptableObject
{ {
@ -82,10 +83,28 @@ namespace TD.Gameplay.BuilderSpells
public abstract bool ServerCast(ulong clientId, Vector3 targetPoint); public abstract bool ServerCast(ulong clientId, Vector3 targetPoint);
/// <summary> /// <summary>
/// Runs on every peer (via <see cref="PlayerSpellLoadout"/>'s ClientRpc) after a /// Seconds between the cast and the effect landing. 0 = instant: damage and the impact
/// successful <see cref="ServerCast"/>. Default no-op; override to spawn an impact VFX /// sound resolve immediately on cast. A projectile-style spell (e.g. the Fireball meteor)
/// prefab and self-destroy it, the same idiom used elsewhere for one-off visuals. /// 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> /// </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> /// <remarks>
/// Plain MonoBehaviour: identical on every peer (same assets), so there is nothing to sync. /// 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 /// 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. /// <see cref="BuilderEffects.BuilderEffectPool"/> exactly.
/// </remarks> /// </remarks>
public class BuilderSpellPool : MonoBehaviour public class BuilderSpellPool : MonoBehaviour

View file

@ -28,6 +28,14 @@ namespace TD.Gameplay.BuilderSpells
[Min(0f)] [Min(0f)]
[SerializeField] private float impactVfxLifetime = 2f; [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")] [Header("Impact Sound")]
[Tooltip("Sound played on every peer when this spell resolves successfully. Same " + [Tooltip("Sound played on every peer when this spell resolves successfully. Same " +
"SoundConfig struct TowerBuiltSound uses — reuse a tower's clip directly if " + "SoundConfig struct TowerBuiltSound uses — reuse a tower's clip directly if " +
@ -59,11 +67,16 @@ namespace TD.Gameplay.BuilderSpells
return hitAny; 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) if (impactVfxPrefab != null)
Destroy(Instantiate(impactVfxPrefab, targetPoint, Quaternion.identity), impactVfxLifetime); Destroy(Instantiate(impactVfxPrefab, targetPoint, Quaternion.identity), impactVfxLifetime);
}
public override void ClientPlayImpact(Vector3 targetPoint)
{
if (impactSound.clip != null) if (impactSound.clip != null)
AudioManager.Instance?.Play(impactSound.clip, AudioCategory.Combat, AudioManager.Instance?.Play(impactSound.clip, AudioCategory.Combat,
impactSound.RandomPitch(), impactSound.volume); impactSound.RandomPitch(), impactSound.volume);

View file

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

View file

@ -227,16 +227,17 @@ namespace TD.Gameplay
{ {
Vector2 dir = Vector2.zero; Vector2 dir = Vector2.zero;
// Keyboard: WASD + arrow keys. Suppressed entirely while the player // Keyboard: arrow keys only. WASD is reserved for tower-build hotkeys (the command
// is typing — pressing 'a' or 'w' into chat should not pan the camera. // grid) — camera panning is arrow keys plus the mouse edge-pan below. Suppressed
// (Edge-pan below stays active since it's mouse-driven.) // 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; var kb = HUDController.IsTextInputActive ? null : Keyboard.current;
if (kb != null) if (kb != null)
{ {
if (kb.aKey.isPressed || kb.leftArrowKey.isPressed) dir.x -= 1f; if (kb.leftArrowKey.isPressed) dir.x -= 1f;
if (kb.dKey.isPressed || kb.rightArrowKey.isPressed) dir.x += 1f; if (kb.rightArrowKey.isPressed) dir.x += 1f;
if (kb.sKey.isPressed || kb.downArrowKey.isPressed) dir.y -= 1f; if (kb.downArrowKey.isPressed) dir.y -= 1f;
if (kb.wKey.isPressed || kb.upArrowKey.isPressed) dir.y += 1f; if (kb.upArrowKey.isPressed) dir.y += 1f;
} }
// Edge-pan: mouse near screen edge adds to keyboard direction. // 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); var loadout = PlayerSpellLoadout.GetForClient(clientId);
return loadout != null && loadout.ServerGrantSpell(Kind); 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)] [TextArea(2, 4)]
public string Description; 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; 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")] [Header("Generation")]
[Tooltip("Relative draw weight. Higher = offered more often. Rarer rewards use " + [Tooltip("Relative draw weight. Higher = offered more often. Rarer rewards use " +
"lower weights. Must be > 0.")] "lower weights. Must be > 0.")]

View file

@ -48,5 +48,9 @@ namespace TD.Gameplay.Draft
return deck.ServerGrantTower(typeId); 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 // Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs
using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using Unity.Netcode; using Unity.Netcode;
using UnityEngine; using UnityEngine;
@ -20,9 +21,12 @@ namespace TD.Gameplay
/// slot index and an aimed world point. The server re-validates everything (slot exists, /// 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 /// off cooldown, <see cref="MatchPhase.Playing"/>) since client-side checks in
/// <see cref="BuilderSpellCastController"/> are UX-only, resolves the /// <see cref="BuilderSpellCastController"/> are UX-only, resolves the
/// <see cref="BuilderSpellDefinition"/> via <see cref="BuilderSpellPool"/>, and calls /// <see cref="BuilderSpellDefinition"/> via <see cref="BuilderSpellPool"/>. An <b>instant</b>
/// <see cref="BuilderSpellDefinition.ServerCast"/>. A successful cast starts the cooldown /// spell (<see cref="BuilderSpellDefinition.ImpactDelay"/> == 0) resolves on cast: a hit starts
/// and fires <see cref="PlayVfxClientRpc"/> so every peer plays the same visual.</para> /// 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> /// </remarks>
public class PlayerSpellLoadout : NetworkBehaviour public class PlayerSpellLoadout : NetworkBehaviour
{ {
@ -142,7 +146,7 @@ namespace TD.Gameplay
/// <paramref name="slot"/> at <paramref name="targetPoint"/>. All validation happens /// <paramref name="slot"/> at <paramref name="targetPoint"/>. All validation happens
/// here on the server; the client only uses this to trigger an attempt. /// here on the server; the client only uses this to trigger an attempt.
/// </summary> /// </summary>
[Rpc(SendTo.Server, RequireOwnership = true)] [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
public void RequestCastSpellRpc(int slot, Vector3 targetPoint) public void RequestCastSpellRpc(int slot, Vector3 targetPoint)
{ {
if (MatchState.Instance == null || MatchState.Instance.Phase != MatchPhase.Playing) if (MatchState.Instance == null || MatchState.Instance.Phase != MatchPhase.Playing)
@ -160,18 +164,69 @@ namespace TD.Gameplay
return; 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; 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; slotValue.CooldownEndServerTime = NetworkManager.ServerTime.Time + definition.Cooldown;
spells[slot] = slotValue; 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] [ClientRpc]
private void PlayVfxClientRpc(BuilderSpellKind kind, Vector3 targetPoint) 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);
} }
} }
} }

View file

@ -116,13 +116,17 @@ namespace TD.UI
private bool draftSubscribed; private bool draftSubscribed;
private PlayerDraft subscribedDraft; private PlayerDraft subscribedDraft;
// Spell hotbar (bottom-ui Section 6). Frame is display:none while the local player // Spell hotbar (bottom-ui Section 6). Frame is visibility:hidden (but still occupies
// its reserved width, so the centered command bar never shifts) while the local player
// has no granted spells; rebuilt on grant (append-only, so this is rare). Cooldown // has no granted spells; rebuilt on grant (append-only, so this is rare). Cooldown
// state changes continuously and has no change event, so it's polled every Update. // state changes continuously and has no change event, so it's polled every Update.
private VisualElement spellHotbarFrame; private VisualElement spellHotbarFrame;
private VisualElement spellHotbar; private VisualElement spellHotbar;
private bool spellLoadoutSubscribed; private bool spellLoadoutSubscribed;
private PlayerSpellLoadout subscribedSpellLoadout; private PlayerSpellLoadout subscribedSpellLoadout;
// The loadout the hotbar is currently showing — the SELECTED builder's, or null when
// no builder is selected (hotbar hidden). Cooldown polling reads this.
private PlayerSpellLoadout displayedSpellLoadout;
private readonly List<SpellSlotUi> spellSlotUis = new List<SpellSlotUi>(); private readonly List<SpellSlotUi> spellSlotUis = new List<SpellSlotUi>();
private readonly struct SpellSlotUi private readonly struct SpellSlotUi
@ -419,9 +423,10 @@ namespace TD.UI
title.style.marginBottom = 6; title.style.marginBottom = 6;
card.Add(title); card.Add(title);
if (option.Icon != null) var iconSprite = option.ResolveIcon();
if (iconSprite != null)
{ {
var img = new Image { sprite = option.Icon }; var img = new Image { sprite = iconSprite };
img.style.width = 48; img.style.width = 48;
img.style.height = 48; img.style.height = 48;
img.style.marginBottom = 6; img.style.marginBottom = 6;
@ -475,23 +480,41 @@ namespace TD.UI
// ----- Spell hotbar ------------------------------------------------- // ----- Spell hotbar -------------------------------------------------
// Rebuilds the hotbar cells from the local player's current loadout. Called on // Rebuilds the hotbar cells for the SELECTED builder's loadout. Called on selection
// OnLoadoutChanged (a new spell granted) — spells are append-only, so this is rare, // change and on OnLoadoutChanged (a new spell granted). Spells are append-only, so
// not a per-frame concern. // this is rare, not a per-frame concern.
//
// The hotbar follows selection: it shows the selected builder's spells and is removed
// entirely when no builder is selected (spells belong to the builder). While a builder
// IS selected, the frame keeps its reserved width (visibility toggle, not display) so
// granting a spell mid-selection doesn't reflow/shift the centered command bar.
private void RebuildSpellHotbar() private void RebuildSpellHotbar()
{ {
if (spellHotbar == null) return; if (spellHotbar == null) return;
spellHotbar.Clear(); spellHotbar.Clear();
spellSlotUis.Clear(); spellSlotUis.Clear();
var loadout = PlayerSpellLoadout.Local; var loadout = GetSelectedBuilderLoadout();
int slotCount = loadout?.SlotCount ?? 0; displayedSpellLoadout = loadout;
if (spellHotbarFrame != null) if (spellHotbarFrame != null)
spellHotbarFrame.style.display = slotCount > 0 ? DisplayStyle.Flex : DisplayStyle.None; {
if (loadout == null)
{
// No builder selected → remove the slot (the command grid is hidden too).
spellHotbarFrame.style.display = DisplayStyle.None;
return;
}
// Builder selected → keep the reserved slot in layout; toggle only visibility so
// a mid-selection spell grant doesn't jump the command bar.
spellHotbarFrame.style.display = DisplayStyle.Flex;
spellHotbarFrame.style.visibility =
loadout.SlotCount > 0 ? Visibility.Visible : Visibility.Hidden;
}
if (loadout == null) return; if (loadout == null) return;
int slotCount = loadout.SlotCount;
var layout = SpellHotkeys.Layout; var layout = SpellHotkeys.Layout;
for (int i = 0; i < slotCount; i++) for (int i = 0; i < slotCount; i++)
{ {
@ -531,6 +554,17 @@ namespace TD.UI
} }
} }
// The loadout the spell hotbar should display: the currently-selected builder's
// (resolved via its owner, so it shows whichever builder is selected — normally the
// local player's), or null when the selection isn't a builder → hotbar hidden.
private PlayerSpellLoadout GetSelectedBuilderLoadout()
{
var selected = SelectionState.Instance?.SelectedObject;
if (selected is Builder builder)
return PlayerSpellLoadout.GetForClient(builder.OwnerClientId);
return null;
}
// Per-frame: dims each slot while on cooldown and shows the remaining whole seconds. // Per-frame: dims each slot while on cooldown and shows the remaining whole seconds.
// Cooldown has no change event (it advances continuously with server time), so this // Cooldown has no change event (it advances continuously with server time), so this
// has to be polled — cheap at hotbar scale (at most MaxSpellSlots cells). // has to be polled — cheap at hotbar scale (at most MaxSpellSlots cells).
@ -538,7 +572,7 @@ namespace TD.UI
{ {
if (spellSlotUis.Count == 0) return; if (spellSlotUis.Count == 0) return;
var loadout = PlayerSpellLoadout.Local; var loadout = displayedSpellLoadout;
if (loadout == null) return; if (loadout == null) return;
for (int i = 0; i < spellSlotUis.Count; i++) for (int i = 0; i < spellSlotUis.Count; i++)
@ -1403,6 +1437,10 @@ namespace TD.UI
// hides via PopulateGridForSelection when there are no actions. // hides via PopulateGridForSelection when there are no actions.
PopulateInfoPanel(selection); PopulateInfoPanel(selection);
PopulateGridForSelection(selection); PopulateGridForSelection(selection);
// Section 6 (spell hotbar) follows selection: show the selected builder's spells,
// hide entirely when the selection isn't a builder.
RebuildSpellHotbar();
} }
/// <summary> /// <summary>

View file

@ -169,8 +169,11 @@
flex-shrink: 0; flex-shrink: 0;
flex-direction: row; flex-direction: row;
align-items: flex-end; /* short sections hug the bottom */ align-items: flex-end; /* short sections hug the bottom */
justify-content: flex-start; justify-content: center; /* keep the command bar centered at ANY resolution — the row
padding: 0 100px; /* margin on either side — the X regions */ auto-centers, and re-centers symmetrically when the spell
hotbar appears/disappears */
padding: 0; /* was 0 100px; reclaimed so a smaller reference resolution
(= bigger HUD) still fits the bar without clipping */
background-color: rgba(0, 0, 0, 0); background-color: rgba(0, 0, 0, 0);
} }

View file

@ -76,7 +76,7 @@
<!-- Section 6: Spell Hotbar (short frame) — builder spells bound to fixed <!-- Section 6: Spell Hotbar (short frame) — builder spells bound to fixed
hotkeys (grant order = slot order). Hidden entirely until the player has hotkeys (grant order = slot order). Hidden entirely until the player has
been granted at least one spell. --> been granted at least one spell. -->
<ui:VisualElement name="spell-hotbar-frame" class="spell-hotbar-frame" style="display: none;"> <ui:VisualElement name="spell-hotbar-frame" class="spell-hotbar-frame" style="visibility: hidden;">
<ui:Label text="spells" class="section-label"/> <ui:Label text="spells" class="section-label"/>
<ui:VisualElement name="spell-hotbar" class="spell-hotbar"/> <ui:VisualElement name="spell-hotbar" class="spell-hotbar"/>
</ui:VisualElement> </ui:VisualElement>

View file

@ -18,14 +18,14 @@ MonoBehaviour:
m_RenderMode: 0 m_RenderMode: 0
m_ColliderUpdateMode: 0 m_ColliderUpdateMode: 0
m_ColliderIsTrigger: 1 m_ColliderIsTrigger: 1
m_ScaleMode: 1 m_ScaleMode: 2
m_ReferenceSpritePixelsPerUnit: 100 m_ReferenceSpritePixelsPerUnit: 100
m_PixelsPerUnit: 100 m_PixelsPerUnit: 100
m_Scale: 1 m_Scale: 1
m_ReferenceDpi: 96 m_ReferenceDpi: 96
m_FallbackDpi: 96 m_FallbackDpi: 96
m_ReferenceResolution: {x: 1200, y: 800} m_ReferenceResolution: {x: 1360, y: 765}
m_ScreenMatchMode: 0 m_ScreenMatchMode: 2
m_Match: 0 m_Match: 0
m_SortingOrder: 0 m_SortingOrder: 0
m_TargetDisplay: 0 m_TargetDisplay: 0