diff --git a/Assets/_Project/Art/Sprites/Spells.meta b/Assets/_Project/Art/Sprites/Spells.meta new file mode 100644 index 0000000..599da31 --- /dev/null +++ b/Assets/_Project/Art/Sprites/Spells.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b086eebc9f3f87543bf2c98de8c8e4af +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Sprites/Spells/FireballSpell.png b/Assets/_Project/Art/Sprites/Spells/FireballSpell.png new file mode 100644 index 0000000..6c62964 --- /dev/null +++ b/Assets/_Project/Art/Sprites/Spells/FireballSpell.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c58fd337b19c098a34a4eb7730e87fad72ec3b4819665e00535676b008348146 +size 177456 diff --git a/Assets/_Project/Art/Sprites/Spells/FireballSpell.png.meta b/Assets/_Project/Art/Sprites/Spells/FireballSpell.png.meta new file mode 100644 index 0000000..25bac7d --- /dev/null +++ b/Assets/_Project/Art/Sprites/Spells/FireballSpell.png.meta @@ -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: diff --git a/Assets/_Project/Art/Sprites/Spells/SlowSpell.png b/Assets/_Project/Art/Sprites/Spells/SlowSpell.png new file mode 100644 index 0000000..9f0cdef --- /dev/null +++ b/Assets/_Project/Art/Sprites/Spells/SlowSpell.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d54e2ec41954205c8775d2c6f09e6a134e8a960a797394c6e01ce67daebc840 +size 357045 diff --git a/Assets/_Project/Art/Sprites/Spells/SlowSpell.png.meta b/Assets/_Project/Art/Sprites/Spells/SlowSpell.png.meta new file mode 100644 index 0000000..98e89b4 --- /dev/null +++ b/Assets/_Project/Art/Sprites/Spells/SlowSpell.png.meta @@ -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: diff --git a/Assets/_Project/Audio/Sound Effects/FireballExplosion.ogg b/Assets/_Project/Audio/Sound Effects/FireballExplosion.ogg new file mode 100644 index 0000000..77a3f9b --- /dev/null +++ b/Assets/_Project/Audio/Sound Effects/FireballExplosion.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2263f258fb816411ff3c118c796cf622bf8327c597c2037f628d5b7dee32ab2a +size 54249 diff --git a/Assets/_Project/Audio/Sound Effects/FireballExplosion.ogg.meta b/Assets/_Project/Audio/Sound Effects/FireballExplosion.ogg.meta new file mode 100644 index 0000000..e3b4013 --- /dev/null +++ b/Assets/_Project/Audio/Sound Effects/FireballExplosion.ogg.meta @@ -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: diff --git a/Assets/_Project/Audio/Sound Effects/TimeSlow.ogg b/Assets/_Project/Audio/Sound Effects/TimeSlow.ogg new file mode 100644 index 0000000..6540f44 --- /dev/null +++ b/Assets/_Project/Audio/Sound Effects/TimeSlow.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4d6c70f74ae50b15bcbcf1b53cdf63f521f671b119aa6c32ea9a41c9e07e563 +size 30845 diff --git a/Assets/_Project/Audio/Sound Effects/TimeSlow.ogg.meta b/Assets/_Project/Audio/Sound Effects/TimeSlow.ogg.meta new file mode 100644 index 0000000..fe80db5 --- /dev/null +++ b/Assets/_Project/Audio/Sound Effects/TimeSlow.ogg.meta @@ -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: diff --git a/Assets/_Project/Definitions/BuilderSpells/FireballSpell.asset b/Assets/_Project/Definitions/BuilderSpells/FireballSpell.asset index d78d1f8..b63d958 100644 --- a/Assets/_Project/Definitions/BuilderSpells/FireballSpell.asset +++ b/Assets/_Project/Definitions/BuilderSpells/FireballSpell.asset @@ -14,18 +14,19 @@ MonoBehaviour: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuilderSpells.FireballSpellDefinition DisplayName: Fireball Description: Shoot a fireball - Icon: {fileID: 0} - Cooldown: 5 + Icon: {fileID: 21300000, guid: eff45e5e3dab24f438c054a095d34578, type: 3} + Cooldown: 1 TargetType: 0 - Radius: 3 + Radius: 5 enemyLayerMask: serializedVersion: 2 m_Bits: 1024 impactVfxPrefab: {fileID: 158073311046191680, guid: 80eaad6d0b024835482b64625b63053c, type: 3} impactVfxLifetime: 3 + impactDelay: 0.9 impactSound: - clip: {fileID: 8300000, guid: 4ee1b63f27ea04451802bafd35ecef89, type: 3} - volume: 0.5 + clip: {fileID: 8300000, guid: e54448243d0478045b6566412c54e4a7, type: 3} + volume: 0.603 minPitch: 1.048 maxPitch: 0.969 - Damage: 50 + Damage: 500 diff --git a/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset b/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset index d6f043a..e2550d2 100644 --- a/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset +++ b/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset @@ -14,8 +14,8 @@ MonoBehaviour: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuilderSpells.SlowAreaSpellDefinition DisplayName: Slow Area Description: Slows enemies in an area - Icon: {fileID: 0} - Cooldown: 5 + Icon: {fileID: 21300000, guid: 5f5072d8c9f626c4690a7b231c7488f9, type: 3} + Cooldown: 10 TargetType: 1 Radius: 5 enemyLayerMask: @@ -23,9 +23,9 @@ MonoBehaviour: m_Bits: 1024 areaVfxPrefab: {fileID: 1462673130280185047, guid: bf2f1f2a9a54e9162b80673e3f7eeaf6, type: 3} areaSound: - clip: {fileID: 0} - volume: 0 - minPitch: 0 - maxPitch: 0 + clip: {fileID: 8300000, guid: 038ff3eab9aa0034bac26ccb648ef2a1, type: 3} + volume: 0.632 + minPitch: 1.14 + maxPitch: 0.96 SlowFactor: 0.5 - EffectDuration: 3 + EffectDuration: 8 diff --git a/Assets/_Project/Definitions/GoldConfig.asset b/Assets/_Project/Definitions/GoldConfig.asset index 58f29f6..abf9261 100644 --- a/Assets/_Project/Definitions/GoldConfig.asset +++ b/Assets/_Project/Definitions/GoldConfig.asset @@ -17,44 +17,40 @@ MonoBehaviour: - Wave: {fileID: 11400000, guid: 65f66289ea1233b4897f46cd997d9c7a, type: 2} GoldPerEnemy: 5 CompletionBonus: 25 - NoLeaksBonus: 50 + NoLeaksBonus: 10 - Wave: {fileID: 11400000, guid: 190e39db44aa0794aa808fd60976f7c4, type: 2} GoldPerEnemy: 7 CompletionBonus: 30 - NoLeaksBonus: 50 + NoLeaksBonus: 10 - Wave: {fileID: 11400000, guid: 39921b44a1a0a56478200028940c5202, type: 2} GoldPerEnemy: 10 CompletionBonus: 35 - NoLeaksBonus: 50 + NoLeaksBonus: 10 - Wave: {fileID: 11400000, guid: 50f498bc5bfc46e44b064cc96403e2cb, type: 2} GoldPerEnemy: 15 CompletionBonus: 40 - NoLeaksBonus: 50 + NoLeaksBonus: 10 - Wave: {fileID: 11400000, guid: 9ee6dee12f0660844b4d46880f01c02f, type: 2} GoldPerEnemy: 15 CompletionBonus: 45 - NoLeaksBonus: 50 + NoLeaksBonus: 10 - Wave: {fileID: 11400000, guid: 41231de63e8f25d448f19f3816e0c22f, type: 2} GoldPerEnemy: 20 CompletionBonus: 50 - NoLeaksBonus: 100 + NoLeaksBonus: 10 - Wave: {fileID: 11400000, guid: 6290df178cbd3144aa92a0f09833a7be, type: 2} GoldPerEnemy: 20 CompletionBonus: 55 - NoLeaksBonus: 100 + NoLeaksBonus: 20 - Wave: {fileID: 11400000, guid: 86736fd52c18fa84e8ced40f30b514fa, type: 2} GoldPerEnemy: 25 CompletionBonus: 60 - NoLeaksBonus: 100 + NoLeaksBonus: 20 - Wave: {fileID: 11400000, guid: 8fdf53cfc405a5f41a00f376198b8d84, type: 2} GoldPerEnemy: 25 CompletionBonus: 65 - NoLeaksBonus: 100 + NoLeaksBonus: 20 - Wave: {fileID: 11400000, guid: 4db677d2940202340841471f90a5b73a, type: 2} GoldPerEnemy: 25 CompletionBonus: 70 - NoLeaksBonus: 100 - - Wave: {fileID: 11400000, guid: fc1a55fab4ce81047b4925f53a3a8b8d, type: 2} - GoldPerEnemy: 50 - CompletionBonus: 75 - NoLeaksBonus: 500 + NoLeaksBonus: 20 diff --git a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs index 72bd7c3..23a1c05 100644 --- a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs +++ b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs @@ -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(); + 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; + } } } \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs b/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs index 49160da..745ebe8 100644 --- a/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs @@ -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; + + /// 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. + public bool IsAiming => activeSlot >= 0; + + /// True during the frame the confirming cast click was consumed. Aim mode exits + /// the same frame the cast is submitted, flipping 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. + 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; diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellDefinition.cs b/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellDefinition.cs index e1ca3b2..2bf7c9e 100644 --- a/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellDefinition.cs +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellDefinition.cs @@ -24,8 +24,9 @@ namespace TD.Gameplay.BuilderSpells /// Server-only resolution, client-only visual. runs on /// the server and applies damage/status via the same Physics.OverlapSphereNonAlloc + /// EnemyHealth/EnemyStatus pattern TowerCombat already uses. - /// runs on every peer (including the server) after a successful - /// cast, purely for presentation. + /// Presentation is split into (on cast) and + /// (on contact, seconds later for a + /// projectile spell), both running on every peer including the server. /// public abstract class BuilderSpellDefinition : ScriptableObject { @@ -82,10 +83,28 @@ namespace TD.Gameplay.BuilderSpells public abstract bool ServerCast(ulong clientId, Vector3 targetPoint); /// - /// Runs on every peer (via 's ClientRpc) after a - /// successful . 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 spawns the + /// visual on cast but holds (damage) and + /// (sound) until the projectile reaches the ground. Set it to match the VFX's fall time. /// - public virtual void ClientPlayVfx(Vector3 targetPoint) { } + public virtual float ImpactDelay => 0f; + + /// + /// Runs on every peer (via '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 . + /// Default no-op. + /// + public virtual void ClientSpawnVfx(Vector3 targetPoint) { } + + /// + /// Runs on every peer when the spell makes contact — immediately for an instant spell, or + /// 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. + /// + public virtual void ClientPlayImpact(Vector3 targetPoint) { } } } diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellPool.cs b/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellPool.cs index 2f44df6..1327be9 100644 --- a/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellPool.cs +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellPool.cs @@ -14,7 +14,8 @@ namespace TD.Gameplay.BuilderSpells /// /// 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 and to render the cast hotbar. Mirrors + /// cref="BuilderSpellDefinition.ClientSpawnVfx"/> / and to render the cast hotbar. Mirrors /// exactly. /// public class BuilderSpellPool : MonoBehaviour diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/FireballSpellDefinition.cs b/Assets/_Project/Scripts/Gameplay/BuilderSpells/FireballSpellDefinition.cs index 5f34a1c..b4917ca 100644 --- a/Assets/_Project/Scripts/Gameplay/BuilderSpells/FireballSpellDefinition.cs +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/FireballSpellDefinition.cs @@ -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); diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs index b992845..bd4cd8b 100644 --- a/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs @@ -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); diff --git a/Assets/_Project/Scripts/Gameplay/CameraController.cs b/Assets/_Project/Scripts/Gameplay/CameraController.cs index b80f33e..bd071d6 100644 --- a/Assets/_Project/Scripts/Gameplay/CameraController.cs +++ b/Assets/_Project/Scripts/Gameplay/CameraController.cs @@ -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. diff --git a/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs b/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs index 416fc03..7582ea2 100644 --- a/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs +++ b/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs @@ -37,5 +37,14 @@ namespace TD.Gameplay.Draft var loadout = PlayerSpellLoadout.GetForClient(clientId); return loadout != null && loadout.ServerGrantSpell(Kind); } + + /// Inherits the granted spell's icon (resolved from the pool by + /// ) unless this option assigns an override. + 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; + } } } diff --git a/Assets/_Project/Scripts/Gameplay/Draft/DraftOption.cs b/Assets/_Project/Scripts/Gameplay/Draft/DraftOption.cs index 8ae8e94..c098caf 100644 --- a/Assets/_Project/Scripts/Gameplay/Draft/DraftOption.cs +++ b/Assets/_Project/Scripts/Gameplay/Draft/DraftOption.cs @@ -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; + /// + /// The icon actually shown on the draft card. Returns the explicit + /// 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 + /// (which may be null). + /// + public virtual Sprite ResolveIcon() => Icon; + [Header("Generation")] [Tooltip("Relative draw weight. Higher = offered more often. Rarer rewards use " + "lower weights. Must be > 0.")] diff --git a/Assets/_Project/Scripts/Gameplay/Draft/NewTowerDraftOption.cs b/Assets/_Project/Scripts/Gameplay/Draft/NewTowerDraftOption.cs index aab2a98..e7af9f6 100644 --- a/Assets/_Project/Scripts/Gameplay/Draft/NewTowerDraftOption.cs +++ b/Assets/_Project/Scripts/Gameplay/Draft/NewTowerDraftOption.cs @@ -48,5 +48,9 @@ namespace TD.Gameplay.Draft return deck.ServerGrantTower(typeId); } + + /// Inherits the granted tower's icon unless this option assigns an override. + public override Sprite ResolveIcon() + => Icon != null ? Icon : (Tower != null ? Tower.Icon : null); } } diff --git a/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs b/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs index a6a1f68..ef994bb 100644 --- a/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs +++ b/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs @@ -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, ) since client-side checks in /// are UX-only, resolves the - /// via , and calls - /// . A successful cast starts the cooldown - /// and fires so every peer plays the same visual. + /// via . An instant + /// spell ( == 0) resolves on cast: a hit starts + /// the cooldown and fires ; a miss is a no-op. A delayed + /// 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 seconds later. /// public class PlayerSpellLoadout : NetworkBehaviour { @@ -142,7 +146,7 @@ namespace TD.Gameplay /// at . All validation happens /// here on the server; the client only uses this to trigger an attempt. /// - [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); } } } diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs index 7d17387..03cb3b0 100644 --- a/Assets/_Project/Scripts/UI/HUDController.cs +++ b/Assets/_Project/Scripts/UI/HUDController.cs @@ -116,13 +116,17 @@ namespace TD.UI private bool draftSubscribed; 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 // state changes continuously and has no change event, so it's polled every Update. private VisualElement spellHotbarFrame; private VisualElement spellHotbar; private bool spellLoadoutSubscribed; 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 spellSlotUis = new List(); private readonly struct SpellSlotUi @@ -419,9 +423,10 @@ namespace TD.UI title.style.marginBottom = 6; 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.height = 48; img.style.marginBottom = 6; @@ -475,23 +480,41 @@ namespace TD.UI // ----- Spell hotbar ------------------------------------------------- - // Rebuilds the hotbar cells from the local player's current loadout. Called on - // OnLoadoutChanged (a new spell granted) — spells are append-only, so this is rare, - // not a per-frame concern. + // Rebuilds the hotbar cells for the SELECTED builder's loadout. Called on selection + // change and on OnLoadoutChanged (a new spell granted). Spells are append-only, so + // 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() { if (spellHotbar == null) return; spellHotbar.Clear(); spellSlotUis.Clear(); - var loadout = PlayerSpellLoadout.Local; - int slotCount = loadout?.SlotCount ?? 0; + var loadout = GetSelectedBuilderLoadout(); + displayedSpellLoadout = loadout; 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; + int slotCount = loadout.SlotCount; var layout = SpellHotkeys.Layout; 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. // 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). @@ -538,7 +572,7 @@ namespace TD.UI { if (spellSlotUis.Count == 0) return; - var loadout = PlayerSpellLoadout.Local; + var loadout = displayedSpellLoadout; if (loadout == null) return; for (int i = 0; i < spellSlotUis.Count; i++) @@ -1403,6 +1437,10 @@ namespace TD.UI // hides via PopulateGridForSelection when there are no actions. PopulateInfoPanel(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(); } /// diff --git a/Assets/_Project/UI/HUD.uss b/Assets/_Project/UI/HUD.uss index 08f154b..34c2a1e 100644 --- a/Assets/_Project/UI/HUD.uss +++ b/Assets/_Project/UI/HUD.uss @@ -169,8 +169,11 @@ flex-shrink: 0; flex-direction: row; align-items: flex-end; /* short sections hug the bottom */ - justify-content: flex-start; - padding: 0 100px; /* margin on either side — the X regions */ + justify-content: center; /* keep the command bar centered at ANY resolution — the row + 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); } diff --git a/Assets/_Project/UI/HUD.uxml b/Assets/_Project/UI/HUD.uxml index f458a3b..d3164ce 100644 --- a/Assets/_Project/UI/HUD.uxml +++ b/Assets/_Project/UI/HUD.uxml @@ -76,7 +76,7 @@ -