From 05671e8b0c009850b27f24d6d09f0a55d7548022 Mon Sep 17 00:00:00 2001 From: Matt F Date: Tue, 7 Jul 2026 20:45:38 -0700 Subject: [PATCH 01/41] Removing unnecessary Terrain asset and moving 9 player terrain to art folder --- Assets/New Terrain.asset | Bin 557360 -> 0 bytes Assets/New Terrain.asset.meta | 8 -------- .../Art/9PlayerTerrain.asset} | Bin 1958960 -> 1958960 bytes .../Art/9PlayerTerrain.asset.meta} | 0 4 files changed, 8 deletions(-) delete mode 100644 Assets/New Terrain.asset delete mode 100644 Assets/New Terrain.asset.meta rename Assets/{New Terrain 1.asset => _Project/Art/9PlayerTerrain.asset} (99%) rename Assets/{New Terrain 1.asset.meta => _Project/Art/9PlayerTerrain.asset.meta} (100%) diff --git a/Assets/New Terrain.asset b/Assets/New Terrain.asset deleted file mode 100644 index e41b51bad195087a40c25dd9ac9810664b28f141..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 557360 zcmeI*d#q(wT?g=e?qffr8pHVe?Z=(7@4ojo zA;o6OXC-Ideb)EvwbuTu{W!OenWES#FW=fKZn&e`e!;tMuiI|eXtUSfe*5jW-F4et zkKR#y@$%)%gFysOXL$MYKfLXcKf3kS$Nu9}zw_i@Km8YnKlW?UddFN*Z7Ytn+yA~QpKN_#d;iz;@A`FBG!Z$JK=|0kk7=l{ygCs8$JP;}@2b&32rnKw-nRuW1 z{&-E#Z$94reYE{+yz=;O&U_M8Q`+_Y?|7eiUgzEOyX*6fnSXlv{O+#rEtyZ9-!n0* z*_crJWdE<}^*>*oUoVc&Wd2RPd|&7Mx;698^>t0>{QBl?;}42!iciG)x7XLV^zwTA z8=LXJ^%=&```ljRe|xY0?)b0m<@NRTnr8gls_~<0%J%c~JEDEB^E00*(bZN-|2ITK z9{)I6<382N^ZdIj?k`8;GkN`eXD{E^x&H2~@=4Tn85H|E*I(Z2yXWtGb$vY4^#8gl zZ+5P>yZyhuw|}?)c+U0uzZA!|5dG)*|As1$swsJVnXfeYcSn0{Zk0Aa*W_>9C4X0w zziEd&ug?!O`Q1C@mm>dYlYh@H`L8$mJ-g(8(B${-kbhtF|Eb9H-hZIV_eWiqT%Svk z|5>yDebGE9_BUy6*ZF6f{LQ=Mf8OK=cga87ra zx-M~l>YQJvqPgo^=k5AEyi4A$UyQZm{_Xlb(#vP6@t2zUzrD(%YDzxe()XQB{{7J& zOR-hj{3A{N13Too{=e1aKe$7l-!J|+@_c^%Q09~A=VSNz`NNrao?qSP=Z{qVPwzih zd^Y;d`*%KD^Sj#g^EJ=E&&T+G7oW-d_dBXQpMrI1ukX(_^B+d@py=oS+~j9=$^WIv z7kYVpfBk%uFIM^V{OkM+OzaINTi)(} zeUmS5%eU_z&OP5;|C^fqAM54y^ZS;_bNyE`pG4J^?a%N0o;^6wra4x3{ns+T5?A@_ zqVHV){BFJ8%j@U&!T3zB|3)wGK3{*V$|q6RC69ml_qS0rZ-0L0_48o#pU>CLDzEp= zy1oBB+~oNkqdWiF=N*x!&$-MeQ8i_|&-rK{9B9+*tLyW4=2zk>&)<*ICy)O^FYkW; zzF6gxsOysRpFaL4qIvrG^ZWA?G505%`Tuz4lX~B*+wadGZt|bVwmq)@{5@)~>;Iiq zpVQaRT(Q6T{u-A^@vdH8`##l-KgoQL@!y^K9`~R3^!lIce1HC_US5y?8_oDnKEwF$ zeTMPh*XzGK{`-6ROmQsM{`X`2JTHE_>OZQcwCnR%&HO$P?R)wBOy+y|{A}h$cYQ9! z_<8>2_3?APy!QF0X8inJcdzk3l=&Xx|9r3ix$61Wj{o6aUO(UeE5^_H{X&&T)s*zf z``b)}nSUf+-Rogj>Tu(sa&vv<+&JbKjc<hjtn%ZuZsVs&_-*;rmXQZAoe8fR0pwYI*zS)MG*#iHKY7?s1tVrhM) zJXLOv9~v$$pW6)NSd{U_VyPUijF&DRTwhrq6{F&8ai$m+i=(!oTw5$h$JZCj;@oC= zqMh0Ca&svgnl)QqJDcwg)m@%k7_MYX)MwXDtv@hYKex76Y^=v{A^hO-cwCN(@o;pu z93Q;GQlTA;kH(SY*|D&6a=5y&50BQ9Pdfd4y%irF*>{FvIHnS*RV) zo*5Q#_)FtByK36yfV>dHXXjgv^;lbPW9josD9(iX{lkU0c#5^*`NTf9o>PwtWo3Q1 z*qjabjaP=7)zwz7dkhty>2K$mek2xu>*ukz<`sB&ZFpv-tb(S)gX^m?fz7;D>%kVH z;oP`fJUkl3R8~(PUtT*tyihGpJxq4lxp-{0H{cJ3j+ZR8yC3~(E&LW0t5&UAV7cs0RjXF5FkL{%OWs0`=@XGvUIFd0tB9O zf#00{x38${`;)W({v{pn^nSVhyDxmsm+m>8z_iD6IuU~tAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5;&t60Flf4_1T7`Mvc2-|M^Ec-cOfDT?}! S0r;PLi{j;niXwj&#s2~N6t1EG diff --git a/Assets/New Terrain.asset.meta b/Assets/New Terrain.asset.meta deleted file mode 100644 index 830694d..0000000 --- a/Assets/New Terrain.asset.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: e75cb6747dd151148b0f1bf06123e9cf -NativeFormatImporter: - externalObjects: {} - mainObjectFileID: 15600000 - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/New Terrain 1.asset b/Assets/_Project/Art/9PlayerTerrain.asset similarity index 99% rename from Assets/New Terrain 1.asset rename to Assets/_Project/Art/9PlayerTerrain.asset index 1c948f89673d435e1a0e0a5565732492a9b3377c..050e58b73bc209b574cc86194d08b6c50345453b 100644 GIT binary patch delta 108 zcmdmRqI|=N@`e`17N!>F7M2#)7Pc1l7LFFq7OocV7M>Q~7QPn#7J(MQ7NHj57LhHY z5!FI`3=9mG0Xd14sYM~FMMa63dF@%%qChML#Nt3K0mPC(ECs~UKrFL8t6KJ01_0H( BCBpy! delta 108 zcmdmRqI|=N@`e`17N!>F7M2#)7Pc1l7LFFq7OocV7M>Q~7QPn#7J(MQ7NHj57LhHY z5!FJx3=9l@spSeGsYOMJnRyC^4DDIfqChML#Nt3K0mPC(ECs~UKrFL8t6KJ01^}x( BB)0$n diff --git a/Assets/New Terrain 1.asset.meta b/Assets/_Project/Art/9PlayerTerrain.asset.meta similarity index 100% rename from Assets/New Terrain 1.asset.meta rename to Assets/_Project/Art/9PlayerTerrain.asset.meta From 9be31b16455f57b9a3011d35d44d38f2b55eade4 Mon Sep 17 00:00:00 2001 From: Matt F Date: Tue, 14 Jul 2026 20:05:06 -0700 Subject: [PATCH 02/41] Add tower Sell action with refund + coin VFX/SFX Owner-validated sell RPC refunds SellRefundPercent of invested gold (Wall refunds 100% while un-upgraded), despawns the tower, and broadcasts a coin-burst VFX + rustle SFX. Refunds do not count as per-wave income. Investment/upgrade tracking added as the seam for the future upgrade system. Co-Authored-By: Claude Opus 4.8 --- .../Scripts/Gameplay/PlayerGoldManager.cs | 16 ++- .../Scripts/Gameplay/TowerInstance.cs | 107 ++++++++++++++++++ .../Scripts/Gameplay/TowerPlacementManager.cs | 21 ++++ .../Scripts/Towers/TowerDefinition.cs | 16 +++ Assets/_Project/Scripts/UI/HUDController.cs | 29 +++-- Assets/_Project/Scripts/VFX/CoinBurstVfx.cs | 101 +++++++++++++++++ .../_Project/Scripts/VFX/SellEffectSpawner.cs | 83 ++++++++++++++ 7 files changed, 361 insertions(+), 12 deletions(-) create mode 100644 Assets/_Project/Scripts/VFX/CoinBurstVfx.cs create mode 100644 Assets/_Project/Scripts/VFX/SellEffectSpawner.cs diff --git a/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs b/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs index 1cb3170..4cbd3a4 100644 --- a/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs +++ b/Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs @@ -143,11 +143,17 @@ namespace TD.Gameplay /// /// Server-side entry point for awarding gold (wave clear, enemy kill). /// Direct call — not Rpc-wrapped — because awards always originate - /// from server-authoritative game events. Also increments - /// so the HUD's per-wave counter reflects - /// it; spending does not decrement that counter (it tracks earnings, not balance). + /// from server-authoritative game events. When + /// is true (the default) it also increments so the + /// HUD's per-wave counter reflects it; spending does not decrement that counter (it + /// tracks earnings, not balance). /// - public void AwardGold(int amount) + /// + /// Pass false for gold returned to the player that isn't income — e.g. a tower-sell + /// refund. The player's balance still rises, but the per-wave "earned" counter does not, + /// so refunds don't inflate round-income stats. + /// + public void AwardGold(int amount, bool countAsEarned = true) { if (!IsServer) { @@ -158,7 +164,7 @@ namespace TD.Gameplay if (amount <= 0) return; currentGold.Value += amount; - goldEarnedThisWave.Value += amount; + if (countAsEarned) goldEarnedThisWave.Value += amount; } /// diff --git a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs index 519ff04..1b01a4a 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs @@ -105,6 +105,24 @@ namespace TD.Gameplay readPerm: NetworkVariableReadPermission.Everyone, writePerm: NetworkVariableWritePermission.Server); + // Total gold sunk into this tower: its placement cost plus any gold later spent + // upgrading it. Set on the server at spawn (= placement cost) and grown by the + // (future) upgrade system via ServerAddUpgradeInvestment. Replicated so the HUD's + // Sell button can preview the exact refund without a server round-trip. + private readonly NetworkVariable goldInvested = + new NetworkVariable( + 0, + readPerm: NetworkVariableReadPermission.Everyone, + writePerm: NetworkVariableWritePermission.Server); + + // Number of upgrades applied to this tower. 0 = never upgraded. Drives the Wall's + // "full refund only while un-upgraded" rule and will back tier display later. + private readonly NetworkVariable upgradeCount = + new NetworkVariable( + 0, + readPerm: NetworkVariableReadPermission.Everyone, + writePerm: NetworkVariableWritePermission.Server); + // ----- Local resolved state ------------------------------------------- // Resolved on every client in OnNetworkSpawn from definitionTypeId via the catalog. @@ -144,6 +162,12 @@ namespace TD.Gameplay /// The footprint anchor tile (SW corner, world-tile coords). public Vector2Int AnchorTile => anchorTile.Value; + /// Total gold sunk into this tower so far (placement + upgrades). + public int GoldInvested => goldInvested.Value; + + /// How many upgrades have been applied to this tower (0 = never upgraded). + public int UpgradeCount => upgradeCount.Value; + /// World-unit height the post-construction drop animation falls from. public float DropHeight => dropHeight; @@ -254,6 +278,11 @@ namespace TD.Gameplay anchorTile.Value = pendingAnchor; ownerSlot.Value = pendingOwner; + // Seed invested gold with the placement cost — the same amount + // TowerPlacementManager deducted to build this tower. The upgrade system + // grows this later via ServerAddUpgradeInvestment. + goldInvested.Value = pendingDefinition != null ? pendingDefinition.GoldCost : 0; + // Clear the pending data — it's now committed to NetworkVariables. hasPendingInit = false; } @@ -345,6 +374,84 @@ namespace TD.Gameplay // Re-tint on every client (and the server) when the replicated paint color changes. private void HandlePaintColorChanged(PaintColor previous, PaintColor current) => ApplyTint(); + // ----- Selling -------------------------------------------------------- + + // Server-only guard: a fast double-click could deliver two sell RPCs before the + // despawn propagates. Mirrors BuildSiteVisual.serverCancelled. + private bool serverSold; + + /// + /// Gold returned if this tower is sold right now. Single source of truth for both + /// the server (which awards it) and the HUD (which labels the Sell button). By + /// default this is of everything + /// invested; a tower flagged + /// (the Wall) returns the full amount while it has never been upgraded. + /// + public int ComputeSellRefund() + { + int invested = goldInvested.Value; + if (resolvedDefinition == null) + return Mathf.RoundToInt(invested * 0.75f); + + if (resolvedDefinition.FullRefundIfUnupgraded && upgradeCount.Value == 0) + return invested; + + return Mathf.RoundToInt(invested * resolvedDefinition.SellRefundPercent); + } + + /// + /// Server-only: records gold spent upgrading this tower (so a later sell refunds a + /// share of it) and marks the tower upgraded — which forfeits any + /// full-refund-while-unupgraded rule. The upgrade system calls this when it lands. + /// + public void ServerAddUpgradeInvestment(int cost) + { + if (!IsServer) return; + if (cost > 0) goldInvested.Value += cost; + upgradeCount.Value += 1; + } + + /// + /// Client → server request to sell this tower. Accepted only from the tower's owner + /// (same ownership rule as placement and paint). The server refunds gold, broadcasts + /// the sell VFX/SFX from a persistent object, then despawns the tower — + /// restores the footprint's grid state and clears + /// selection on every peer. + /// + [Rpc(SendTo.Server)] + public void RequestSellServerRpc(RpcParams rpcParams = default) + { + if (!IsServer) return; + if (serverSold) return; // idempotent guard against a double-click + + ulong senderClientId = rpcParams.Receive.SenderClientId; + PlayerSlot senderSlot = PlayerMatchState.SlotForClient(senderClientId); + if (senderSlot == PlayerSlot.None || senderSlot != ownerSlot.Value) + { + Debug.Log($"[TowerInstance] Sell rejected: client {senderClientId} " + + $"({senderSlot}) does not own tower owned by {ownerSlot.Value}."); + return; + } + + serverSold = true; + + int refund = ComputeSellRefund(); + var goldManager = PlayerGoldManager.GetForClient(senderClientId); + if (goldManager != null && refund > 0) + // countAsEarned: false — a sell refund returns spent gold, it is not round + // income, so it must not inflate the per-wave "earned" counter. + goldManager.AwardGold(refund, countAsEarned: false); + + // Broadcast VFX/SFX from the persistent placement manager, capturing the world + // position NOW — this NetworkObject despawns below, so it can't carry the RPC to + // remote peers itself (same reason WaveManager routes kill/leak popups). + var pm = TowerPlacementManager.Instance; + if (pm != null) pm.BroadcastSellEffect(transform.position); + + if (NetworkObject != null && NetworkObject.IsSpawned) + NetworkObject.Despawn(destroy: true); + } + // ----- IMinimapEntity ------------------------------------------------- // // Towers are static, so WorldPosition is cheap (no movement to track). Color reflects diff --git a/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs b/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs index 7c4662d..92b349d 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs @@ -5,6 +5,7 @@ using UnityEngine; using TD.Core; using TD.Levels; using TD.Towers; +using TD.VFX; namespace TD.Gameplay { @@ -350,6 +351,26 @@ namespace TD.Gameplay $"client {req.SenderClientId} ({placingSlot}) at anchor {req.Anchor}."); } + // ----- Sell effects (persistent broadcaster) ---------------------- + + /// + /// Server-only: tells every peer to play the tower-sold VFX/SFX at + /// . Called by as it sells, + /// because the tower's own NetworkObject despawns the same frame — a persistent + /// object must carry the broadcast so it isn't dropped. + /// + public void BroadcastSellEffect(Vector3 worldPos) + { + if (!IsServer) return; + PlaySellEffectRpc(worldPos); + } + + [Rpc(SendTo.Everyone)] + private void PlaySellEffectRpc(Vector3 worldPos) + { + SellEffectSpawner.Instance?.Play(worldPos); + } + // ----- Server-side commit hooks called by Builder ------------------ /// diff --git a/Assets/_Project/Scripts/Towers/TowerDefinition.cs b/Assets/_Project/Scripts/Towers/TowerDefinition.cs index 3ce1632..8163490 100644 --- a/Assets/_Project/Scripts/Towers/TowerDefinition.cs +++ b/Assets/_Project/Scripts/Towers/TowerDefinition.cs @@ -47,6 +47,22 @@ namespace TD.Towers "successful server-side placement validation.")] public int GoldCost; + // ------------------------------------------------------------------- + // Selling + // ------------------------------------------------------------------- + + [Header("Selling")] + [Tooltip("Portion of the gold INVESTED in this tower (placement cost plus any gold " + + "later spent upgrading it) that is refunded when it is sold, as a 0–1 factor. " + + "Default 0.75 = 75%.")] + [Range(0f, 1f)] + public float SellRefundPercent = 0.75f; + + [Tooltip("When true, this tower refunds 100% of its invested gold while it has never " + + "been upgraded (upgradeCount == 0); once upgraded it falls back to " + + "SellRefundPercent. Set on the Wall so a fresh wall can be re-mazed for free.")] + public bool FullRefundIfUnupgraded = false; + // ------------------------------------------------------------------- // Construction // ------------------------------------------------------------------- diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs index 96f50ec..f581be9 100644 --- a/Assets/_Project/Scripts/UI/HUDController.cs +++ b/Assets/_Project/Scripts/UI/HUDController.cs @@ -1178,19 +1178,30 @@ namespace TD.UI return btn; } + // Sell action for a completed tower. Refund is computed on the TowerInstance (single + // source of truth shared with the server) so the badge shows exactly what the player + // gets back. Hotkey is Key.None: the bottom-right slot's letter (B) is already the + // global buff-menu toggle — see CreateBuffMenuButton — so binding it here would fire + // both. The button stays click-only. private VisualElement CreateSellButton(TowerInstance tower, Key hotkey) { - int sellValue = tower.Definition != null - ? Mathf.RoundToInt(tower.Definition.GoldCost * 0.7f) - : 0; + // Only the tower's owner can sell it (the server enforces this too). A non-owner + // may have this tower view-selected — show the slot disabled with no refund badge + // so the action reads as unavailable rather than misleading. + bool ownedByLocal = tower != null + && PlayerMatchState.Local != null + && tower.Owner == PlayerMatchState.Local.Slot; + + int sellValue = ownedByLocal ? tower.ComputeSellRefund() : 0; var btn = CreateActionButton( costText: sellValue > 0 ? $"+{sellValue}g" : "", - hotkey: hotkey, + hotkey: Key.None, onClick: () => { - /* TODO: sell flow */ + if (tower != null) + tower.RequestSellServerRpc(); }); - btn.SetEnabled(false); + btn.SetEnabled(ownedByLocal); return btn; } @@ -1437,7 +1448,11 @@ namespace TD.UI ttStats.text = "(stats pending)"; } - int sellValue = Mathf.RoundToInt(def.GoldCost * 0.7f); + // Sell preview for a freshly placed (un-upgraded) tower: mirrors + // TowerInstance.ComputeSellRefund for invested == GoldCost, upgradeCount == 0. + int sellValue = def.FullRefundIfUnupgraded + ? def.GoldCost + : Mathf.RoundToInt(def.GoldCost * def.SellRefundPercent); ttCost.text = $"Cost: {def.GoldCost}g · Sell: {sellValue}g"; } diff --git a/Assets/_Project/Scripts/VFX/CoinBurstVfx.cs b/Assets/_Project/Scripts/VFX/CoinBurstVfx.cs new file mode 100644 index 0000000..e9d71eb --- /dev/null +++ b/Assets/_Project/Scripts/VFX/CoinBurstVfx.cs @@ -0,0 +1,101 @@ +// Assets/_Project/Scripts/VFX/CoinBurstVfx.cs +using UnityEngine; + +namespace TD.VFX +{ + /// + /// Self-contained, code-configured gold-coin burst used as the zero-art placeholder for the + /// tower-sell effect. Configures its own for a short radial + /// spray of gold specks thrown up in random arcs under gravity, then destroys itself. + /// + /// + /// Instantiated at runtime by when no authored VFX prefab is + /// assigned. It can also be dropped on a prefab in the editor and tuned. Replace with an + /// authored particle / VFX-graph prefab (assigned on the spawner) when art is ready — nothing + /// else needs to change. + /// + [RequireComponent(typeof(ParticleSystem))] + public class CoinBurstVfx : MonoBehaviour + { + [Tooltip("How many coin specks to throw.")] + [SerializeField] private int coinCount = 14; + + [Tooltip("Longest a speck lives (seconds). The whole effect self-destructs shortly after.")] + [SerializeField] private float lifetime = 0.6f; + + [Tooltip("Gold tint applied to the specks.")] + [SerializeField] private Color coinColor = new Color(1f, 0.84f, 0.2f, 1f); + + private void Awake() + { + var ps = GetComponent(); + if (ps == null) ps = gameObject.AddComponent(); + Configure(ps); + } + + private void Configure(ParticleSystem ps) + { + // Reconfiguring modules requires the system to be stopped first. + ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); + + var main = ps.main; + main.duration = 0.1f; + main.loop = false; + main.playOnAwake = false; + main.startLifetime = new ParticleSystem.MinMaxCurve(lifetime * 0.6f, lifetime); + main.startSpeed = new ParticleSystem.MinMaxCurve(2.5f, 5f); + main.startSize = new ParticleSystem.MinMaxCurve(0.07f, 0.16f); + main.startRotation = new ParticleSystem.MinMaxCurve(0f, Mathf.PI * 2f); + main.startColor = coinColor; + main.gravityModifier = 2.5f; // arc up then fall back down quickly + main.simulationSpace = ParticleSystemSimulationSpace.World; + main.maxParticles = 64; + main.stopAction = ParticleSystemStopAction.None; + + var emission = ps.emission; + emission.enabled = true; + emission.rateOverTime = 0f; // burst only, no continuous stream + emission.SetBursts(new[] { new ParticleSystem.Burst(0f, (short)coinCount) }); + + // Hemisphere pointing up → specks spray outward and upward in random directions. + var shape = ps.shape; + shape.enabled = true; + shape.shapeType = ParticleSystemShapeType.Hemisphere; + shape.radius = 0.12f; + + // Fade the specks out over the back half of their life so they "decay quickly". + var col = ps.colorOverLifetime; + col.enabled = true; + var grad = new Gradient(); + grad.SetKeys( + new[] { new GradientColorKey(coinColor, 0f), new GradientColorKey(coinColor, 1f) }, + new[] + { + new GradientAlphaKey(1f, 0f), + new GradientAlphaKey(1f, 0.55f), + new GradientAlphaKey(0f, 1f), + }); + col.color = new ParticleSystem.MinMaxGradient(grad); + + // Give the specks a visible, transparent-capable material even with no assigned art. + var renderer = GetComponent(); + if (renderer != null) + { + renderer.renderMode = ParticleSystemRenderMode.Billboard; + if (renderer.sharedMaterial == null) + { + Shader shader = Shader.Find("Universal Render Pipeline/Particles/Unlit") + ?? Shader.Find("Sprites/Default") + ?? Shader.Find("Legacy Shaders/Particles/Alpha Blended Premultiply"); + if (shader != null) + renderer.material = new Material(shader) { color = coinColor }; + } + } + + ps.Play(); + + // Tear down after the last speck has died (plus a small margin). + Destroy(gameObject, lifetime + 0.25f); + } + } +} diff --git a/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs b/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs new file mode 100644 index 0000000..0b67fc3 --- /dev/null +++ b/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs @@ -0,0 +1,83 @@ +// Assets/_Project/Scripts/VFX/SellEffectSpawner.cs +using UnityEngine; +using TD.Audio; + +namespace TD.VFX +{ + /// + /// Scene singleton that plays the "tower sold" feedback — a burst of gold coins plus a + /// coin-rustle sound — at a world position. Mirrors : + /// visual-only, plain MonoBehaviour, invoked on every peer via a ClientRpc so all + /// players see and hear a sale locally. + /// + /// + /// Who calls this: + /// routes a ClientRpc here when a tower is sold. + /// + /// Inspector setup: drop this on a SellEffectSpawner GameObject in each Match + /// scene. The VFX prefab is OPTIONAL — leave it empty to use the built-in + /// placeholder, or assign an authored particle/VFX-graph prefab. + /// Assign the coin-rustle clip on . + /// + public class SellEffectSpawner : MonoBehaviour + { + // ----- Singleton -------------------------------------------------- + + public static SellEffectSpawner Instance { get; private set; } + + // ----- Inspector -------------------------------------------------- + + [Tooltip("Optional authored VFX prefab spawned at the sale position. Leave empty to " + + "fall back to the built-in code-generated CoinBurstVfx placeholder.")] + [SerializeField] private GameObject coinBurstPrefab; + + [Tooltip("Coin-rustle sound played (2D, via AudioManager) when a tower is sold.")] + [SerializeField] private SoundConfig sellSound; + + [Tooltip("Vertical offset above the sale position where the coin burst originates, so " + + "it reads as coming from the tower body rather than the ground.")] + [SerializeField] private float verticalOffset = 0.75f; + + // ----- Lifecycle -------------------------------------------------- + + private void Awake() + { + if (Instance != null && Instance != this) + { + Debug.LogWarning("[SellEffectSpawner] Duplicate instance detected. " + + "Only one should exist per scene."); + return; + } + Instance = this; + } + + private void OnDestroy() + { + if (Instance == this) Instance = null; + } + + // ----- Public API ------------------------------------------------- + + /// Plays the coin burst + rustle sound at . + public void Play(Vector3 worldPos) + { + Vector3 spawnPos = worldPos + Vector3.up * verticalOffset; + + if (coinBurstPrefab != null) + { + Instantiate(coinBurstPrefab, spawnPos, Quaternion.identity); + } + else + { + // Zero-art fallback: a self-configuring, self-destroying particle burst. + var go = new GameObject("CoinBurst"); + go.transform.position = spawnPos; + go.AddComponent(); + } + + if (sellSound.clip != null) + AudioManager.Instance?.Play(sellSound.clip, AudioCategory.UI, + sellSound.RandomPitch(), sellSound.volume); + } + } +} From 86bc916c4936d8cbd463fab842cda3629967cc4a Mon Sep 17 00:00:00 2001 From: Matt F Date: Tue, 14 Jul 2026 20:22:00 -0700 Subject: [PATCH 03/41] Fix NGO RequireOwnership deprecation warnings; add VFX script metas Replace deprecated [Rpc(..., RequireOwnership = true)] with InvokePermission = RpcInvokePermission.Owner on the owner-submitted draft/buff RPCs (PlayerDraft, PlayerBuffManager), matching the pattern already used elsewhere. Also commits the .meta files Unity generated for the new sell-VFX scripts so their GUIDs are stable across machines. Co-Authored-By: Claude Opus 4.8 --- Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs | 4 ++-- Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs | 2 +- Assets/_Project/Scripts/VFX/CoinBurstVfx.cs.meta | 2 ++ Assets/_Project/Scripts/VFX/SellEffectSpawner.cs.meta | 2 ++ 4 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 Assets/_Project/Scripts/VFX/CoinBurstVfx.cs.meta create mode 100644 Assets/_Project/Scripts/VFX/SellEffectSpawner.cs.meta diff --git a/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs b/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs index 3fd7350..41a7c04 100644 --- a/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs +++ b/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs @@ -137,7 +137,7 @@ namespace TD.Gameplay.Draft // ----- Owner → server RPCs ---------------------------------------- /// Owning client: pick one of the offered options by id. - [Rpc(SendTo.Server, RequireOwnership = true)] + [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] public void RequestPickRpc(int optionId) { ServerResolve(optionId); @@ -148,7 +148,7 @@ namespace TD.Gameplay.Draft /// afford it or already has an unresolved draft (resolve the current one first so a /// free pick is never silently overwritten). /// - [Rpc(SendTo.Server, RequireOwnership = true)] + [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] public void RequestBuyRerollRpc() { if (HasActiveDraft) return; // resolve the pending draft before buying another diff --git a/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs b/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs index 506791e..52efbdc 100644 --- a/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs +++ b/Assets/_Project/Scripts/Gameplay/PlayerBuffManager.cs @@ -146,7 +146,7 @@ namespace TD.Gameplay /// a random buff from the category at . /// The server validates gold and adds the buff if the purchase succeeds. /// - [Rpc(SendTo.Server, RequireOwnership = true)] + [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] public void RequestPurchaseBuffRpc(int categoryIndex) { if (categories == null || categoryIndex < 0 || categoryIndex >= categories.Length) diff --git a/Assets/_Project/Scripts/VFX/CoinBurstVfx.cs.meta b/Assets/_Project/Scripts/VFX/CoinBurstVfx.cs.meta new file mode 100644 index 0000000..58bc699 --- /dev/null +++ b/Assets/_Project/Scripts/VFX/CoinBurstVfx.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bdff685029f23004796671150c7cf39b \ No newline at end of file diff --git a/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs.meta b/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs.meta new file mode 100644 index 0000000..7ae5f2a --- /dev/null +++ b/Assets/_Project/Scripts/VFX/SellEffectSpawner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e2708c6bac8136f45b48c899d3d40630 \ No newline at end of file From 1794f65b19d7a9df31683b473e8e6427b65699a9 Mon Sep 17 00:00:00 2001 From: Matt F Date: Tue, 14 Jul 2026 20:44:11 -0700 Subject: [PATCH 04/41] Fix sell hitch: batch footprint un-stamp; warn on missing SellEffectSpawner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TowerInstance.StampFootprint now writes walkability via SetWalkableBatch instead of per-tile SetWalkable, so despawning (selling) a 2x2 tower fires one OnWalkabilityChanged / enemy re-path instead of four — matching placement and removing the frame hitch on sell. PlaySellEffectRpc now logs a one-time warning when no SellEffectSpawner is in the scene, since a missing spawner silently suppressed the coin VFX and sound. Co-Authored-By: Claude Opus 4.8 --- .../Scripts/Gameplay/TowerInstance.cs | 12 +++++++++++- .../Scripts/Gameplay/TowerPlacementManager.cs | 19 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs index 1b01a4a..dfd73a8 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs @@ -1,4 +1,5 @@ // Assets/_Project/Scripts/Gameplay/TowerInstance.cs +using System.Collections.Generic; using Unity.Netcode; using UnityEngine; using TD.Core; @@ -511,11 +512,20 @@ namespace TD.Gameplay ? resolvedDefinition.FootprintSize : new Vector2Int(2, 2); + // Collect the footprint, then stamp walkability as a BATCH so a single + // OnWalkabilityChanged fires for the whole footprint. Per-tile SetWalkable fires + // that event once PER TILE — on despawn (sell) that meant up to 4 full enemy A* + // re-paths for a 2×2 tower in one frame, which was the sell hitch. Placement + // batches for the same reason (TowerPlacementManager.StampWalkable). Occupancy + // doesn't fire walkability events, so it stays per-tile. + var footprint = new List(footprintSize.x * footprintSize.y); foreach (var tile in GridCoordinates.GetFootprintTiles(anchorTile.Value, footprintSize)) { - loader.SetWalkable(tile, walkable); + footprint.Add(tile); loader.SetOccupied(tile, occupied); } + + loader.SetWalkableBatch(footprint, walkable); } // Reused per-instance across color updates to avoid per-call GC allocation. diff --git a/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs b/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs index 92b349d..9943dc5 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs @@ -365,10 +365,27 @@ namespace TD.Gameplay PlaySellEffectRpc(worldPos); } + // One-time guard so a missing spawner warns once per peer instead of on every sale. + private static bool s_warnedNoSellSpawner; + [Rpc(SendTo.Everyone)] private void PlaySellEffectRpc(Vector3 worldPos) { - SellEffectSpawner.Instance?.Play(worldPos); + var spawner = SellEffectSpawner.Instance; + if (spawner == null) + { + if (!s_warnedNoSellSpawner) + { + Debug.LogWarning("[TowerPlacementManager] A tower was sold, but there is no " + + "SellEffectSpawner in the scene — no coin VFX or sell sound " + + "will play. Add a SellEffectSpawner GameObject to each Match " + + "scene (assign its Sell Sound clip; the coin burst works even " + + "with no VFX prefab)."); + s_warnedNoSellSpawner = true; + } + return; + } + spawner.Play(worldPos); } // ----- Server-side commit hooks called by Builder ------------------ From 7cf15dcaa63969acea4250fe5649154290a7a228 Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Tue, 14 Jul 2026 20:56:50 -0700 Subject: [PATCH 05/41] draft options working; spells won't cast though --- .../_Project/Definitions/BuilderSpells.meta | 8 + .../BuilderSpells/FireballSpell.asset | 24 ++ .../BuilderSpells/FireballSpell.asset.meta | 8 + .../BuilderSpells/SlowAreaSpell.asset | 25 ++ .../BuilderSpells/SlowAreaSpell.asset.meta | 8 + .../Draft_BuilderSpellOption_Fireball.asset | 19 ++ ...aft_BuilderSpellOption_Fireball.asset.meta | 8 + .../Draft_BuilderSpellOption_SlowArea.asset | 19 ++ ...aft_BuilderSpellOption_SlowArea.asset.meta | 8 + Assets/_Project/Prefabs/Player/Player.prefab | 14 ++ Assets/_Project/Scenes/Levels/9Player.unity | 103 ++++++++ .../_Project/Scripts/Core/BuilderSpellKind.cs | 15 ++ .../Scripts/Core/BuilderSpellKind.cs.meta | 2 + .../_Project/Scripts/Core/SpellTargetType.cs | 23 ++ .../Scripts/Core/SpellTargetType.cs.meta | 2 + .../Gameplay/BuilderSpellCastController.cs | 226 ++++++++++++++++++ .../BuilderSpellCastController.cs.meta | 2 + .../Scripts/Gameplay/BuilderSpells.meta | 8 + .../BuilderSpells/BuilderSpellDefinition.cs | 91 +++++++ .../BuilderSpellDefinition.cs.meta | 2 + .../BuilderSpells/BuilderSpellPool.cs | 68 ++++++ .../BuilderSpells/BuilderSpellPool.cs.meta | 2 + .../BuilderSpells/FireballSpellDefinition.cs | 47 ++++ .../FireballSpellDefinition.cs.meta | 2 + .../BuilderSpells/SlowAreaSpellDefinition.cs | 55 +++++ .../SlowAreaSpellDefinition.cs.meta | 2 + .../Gameplay/Draft/BuilderSpellDraftOption.cs | 41 ++++ .../Draft/BuilderSpellDraftOption.cs.meta | 2 + .../Scripts/Gameplay/PlayerSpellLoadout.cs | 177 ++++++++++++++ .../Gameplay/PlayerSpellLoadout.cs.meta | 2 + .../_Project/Scripts/Gameplay/SpellHotkeys.cs | 29 +++ .../Scripts/Gameplay/SpellHotkeys.cs.meta | 2 + Assets/_Project/Scripts/Gameplay/SpellSlot.cs | 63 +++++ .../Scripts/Gameplay/SpellSlot.cs.meta | 2 + Assets/_Project/Scripts/UI/HUDController.cs | 149 ++++++++++++ Assets/_Project/UI/HUD.uss | 74 ++++++ Assets/_Project/UI/HUD.uxml | 8 + 37 files changed, 1340 insertions(+) create mode 100644 Assets/_Project/Definitions/BuilderSpells.meta create mode 100644 Assets/_Project/Definitions/BuilderSpells/FireballSpell.asset create mode 100644 Assets/_Project/Definitions/BuilderSpells/FireballSpell.asset.meta create mode 100644 Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset create mode 100644 Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset.meta create mode 100644 Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_Fireball.asset create mode 100644 Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_Fireball.asset.meta create mode 100644 Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowArea.asset create mode 100644 Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowArea.asset.meta create mode 100644 Assets/_Project/Scripts/Core/BuilderSpellKind.cs create mode 100644 Assets/_Project/Scripts/Core/BuilderSpellKind.cs.meta create mode 100644 Assets/_Project/Scripts/Core/SpellTargetType.cs create mode 100644 Assets/_Project/Scripts/Core/SpellTargetType.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs create mode 100644 Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/BuilderSpells.meta create mode 100644 Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellDefinition.cs create mode 100644 Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellDefinition.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellPool.cs create mode 100644 Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellPool.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/BuilderSpells/FireballSpellDefinition.cs create mode 100644 Assets/_Project/Scripts/Gameplay/BuilderSpells/FireballSpellDefinition.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs create mode 100644 Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs create mode 100644 Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs create mode 100644 Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/SpellHotkeys.cs create mode 100644 Assets/_Project/Scripts/Gameplay/SpellHotkeys.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/SpellSlot.cs create mode 100644 Assets/_Project/Scripts/Gameplay/SpellSlot.cs.meta diff --git a/Assets/_Project/Definitions/BuilderSpells.meta b/Assets/_Project/Definitions/BuilderSpells.meta new file mode 100644 index 0000000..15f3140 --- /dev/null +++ b/Assets/_Project/Definitions/BuilderSpells.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f6f56fe562e9b8c229442088bb6ce11c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/BuilderSpells/FireballSpell.asset b/Assets/_Project/Definitions/BuilderSpells/FireballSpell.asset new file mode 100644 index 0000000..31db12b --- /dev/null +++ b/Assets/_Project/Definitions/BuilderSpells/FireballSpell.asset @@ -0,0 +1,24 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 22b36bfcd0ae4af4098d908138e874e1, type: 3} + m_Name: FireballSpell + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuilderSpells.FireballSpellDefinition + DisplayName: + Description: + Icon: {fileID: 0} + Cooldown: 5 + TargetType: 0 + Radius: 0 + enemyLayerMask: + serializedVersion: 2 + m_Bits: 0 + Damage: 50 diff --git a/Assets/_Project/Definitions/BuilderSpells/FireballSpell.asset.meta b/Assets/_Project/Definitions/BuilderSpells/FireballSpell.asset.meta new file mode 100644 index 0000000..61581ca --- /dev/null +++ b/Assets/_Project/Definitions/BuilderSpells/FireballSpell.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 051c48f416e5366b59d49b0e062333ac +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset b/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset new file mode 100644 index 0000000..9df48bc --- /dev/null +++ b/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset @@ -0,0 +1,25 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 249c65067c3bac80693255e594d844c7, type: 3} + m_Name: SlowAreaSpell + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuilderSpells.SlowAreaSpellDefinition + DisplayName: + Description: + Icon: {fileID: 0} + Cooldown: 5 + TargetType: 0 + Radius: 0 + enemyLayerMask: + serializedVersion: 2 + m_Bits: 0 + SlowFactor: 0.5 + EffectDuration: 3 diff --git a/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset.meta b/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset.meta new file mode 100644 index 0000000..1518d59 --- /dev/null +++ b/Assets/_Project/Definitions/BuilderSpells/SlowAreaSpell.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d893f46b037536b4ba91307e184cecfe +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_Fireball.asset b/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_Fireball.asset new file mode 100644 index 0000000..5ec8c55 --- /dev/null +++ b/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_Fireball.asset @@ -0,0 +1,19 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a574e8aa74d8787f2b949300932af485, type: 3} + m_Name: Draft_BuilderSpellOption_Fireball + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.Draft.BuilderSpellDraftOption + DisplayName: Fireball + Description: Shoot a FIREBALL out of your COCK + Icon: {fileID: 0} + Weight: 1 + Kind: 0 diff --git a/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_Fireball.asset.meta b/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_Fireball.asset.meta new file mode 100644 index 0000000..f9e25b5 --- /dev/null +++ b/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_Fireball.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5f390de69ba43b2a6a0d89cf09580320 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowArea.asset b/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowArea.asset new file mode 100644 index 0000000..5dd615e --- /dev/null +++ b/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowArea.asset @@ -0,0 +1,19 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a574e8aa74d8787f2b949300932af485, type: 3} + m_Name: Draft_BuilderSpellOption_SlowArea + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.Draft.BuilderSpellDraftOption + DisplayName: Slow Area + Description: Slows enemies in the target area + Icon: {fileID: 0} + Weight: 1 + Kind: 1 diff --git a/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowArea.asset.meta b/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowArea.asset.meta new file mode 100644 index 0000000..40f9145 --- /dev/null +++ b/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowArea.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 29b35841f7e5db454903798cb5d83434 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Prefabs/Player/Player.prefab b/Assets/_Project/Prefabs/Player/Player.prefab index 33c1316..08614f3 100644 --- a/Assets/_Project/Prefabs/Player/Player.prefab +++ b/Assets/_Project/Prefabs/Player/Player.prefab @@ -17,6 +17,7 @@ GameObject: - component: {fileID: 2806524246861401799} - component: {fileID: 2806524246861401801} - component: {fileID: 5683786710272852339} + - component: {fileID: 6856236869205671849} m_Layer: 0 m_Name: Player m_TagString: Untagged @@ -159,3 +160,16 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuilderUpgradeManager ShowTopMostFoldoutHeaderGroup: 1 +--- !u!114 &6856236869205671849 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3493329038866903420} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: dfa83208b02c50b40866bb1846240c4a, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerSpellLoadout + ShowTopMostFoldoutHeaderGroup: 1 diff --git a/Assets/_Project/Scenes/Levels/9Player.unity b/Assets/_Project/Scenes/Levels/9Player.unity index 3264d21..98a6d5e 100644 --- a/Assets/_Project/Scenes/Levels/9Player.unity +++ b/Assets/_Project/Scenes/Levels/9Player.unity @@ -13345,6 +13345,53 @@ Transform: m_Children: [] m_Father: {fileID: 1097152085} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &914832724 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 914832726} + - component: {fileID: 914832725} + m_Layer: 0 + m_Name: BuilderSpellPool + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &914832725 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 914832724} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 36a7be1b3824682b8a183fbcf8564652, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuilderSpells.BuilderSpellPool + spells: + - {fileID: 11400000, guid: 051c48f416e5366b59d49b0e062333ac, type: 2} + - {fileID: 11400000, guid: d893f46b037536b4ba91307e184cecfe, type: 2} +--- !u!4 &914832726 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 914832724} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: -18.90215, y: 6.17317, z: 0.5013} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &921503905 GameObject: m_ObjectHideFlags: 0 @@ -13727,6 +13774,57 @@ BoxCollider: serializedVersion: 3 m_Size: {x: 17, y: 1, z: 13} m_Center: {x: 0, y: 0, z: 0} +--- !u!1 &993870443 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 993870445} + - component: {fileID: 993870444} + m_Layer: 0 + m_Name: BuilderSpellCastController + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &993870444 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 993870443} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: cc99a2b3d3e8d8178a74e1d68a34553c, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuilderSpellCastController + groundLayerMask: + serializedVersion: 2 + m_Bits: 128 + raycastMaxDistance: 500 + aimDecal: {fileID: 0} + pointReticleDiameter: 1.5 + projectionDepth: 50 +--- !u!4 &993870445 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 993870443} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: -18.90215, y: 6.17317, z: 0.5013} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &997315214 GameObject: m_ObjectHideFlags: 0 @@ -28565,6 +28663,9 @@ MonoBehaviour: - {fileID: 11400000, guid: 6b1d3f8a2c5e4097b8a1d0c3e6f9b240, type: 2} - {fileID: 11400000, guid: f4a2b8d1c6e93f05a7b2d4c8e1f6a930, type: 2} - {fileID: 11400000, guid: 65ff25c1c8a89f7df8e88f71968c1c98, type: 2} + - {fileID: 11400000, guid: 5f390de69ba43b2a6a0d89cf09580320, type: 2} + - {fileID: 11400000, guid: 65ff25c1c8a89f7df8e88f71968c1c98, type: 2} + - {fileID: 11400000, guid: 29b35841f7e5db454903798cb5d83434, type: 2} --- !u!4 &2139601601 Transform: m_ObjectHideFlags: 0 @@ -28962,3 +29063,5 @@ SceneRoots: - {fileID: 176580462} - {fileID: 516125087} - {fileID: 2079088113} + - {fileID: 914832726} + - {fileID: 993870445} diff --git a/Assets/_Project/Scripts/Core/BuilderSpellKind.cs b/Assets/_Project/Scripts/Core/BuilderSpellKind.cs new file mode 100644 index 0000000..58fcb5d --- /dev/null +++ b/Assets/_Project/Scripts/Core/BuilderSpellKind.cs @@ -0,0 +1,15 @@ +namespace TD.Core +{ + /// + /// Identifies which builder spell a + /// represents. Mirrors + /// — a stable, enum-indexed identifier used by + /// and + /// instead of an asset reference. + /// + public enum BuilderSpellKind : byte + { + Fireball = 0, + SlowArea = 1, + } +} diff --git a/Assets/_Project/Scripts/Core/BuilderSpellKind.cs.meta b/Assets/_Project/Scripts/Core/BuilderSpellKind.cs.meta new file mode 100644 index 0000000..af62b64 --- /dev/null +++ b/Assets/_Project/Scripts/Core/BuilderSpellKind.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a043765d6aa295a5397226e3e32d2c37 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Core/SpellTargetType.cs b/Assets/_Project/Scripts/Core/SpellTargetType.cs new file mode 100644 index 0000000..7ebb6ff --- /dev/null +++ b/Assets/_Project/Scripts/Core/SpellTargetType.cs @@ -0,0 +1,23 @@ +namespace TD.Core +{ + /// + /// How a is aimed. + /// + /// + /// Deliberately not named TargetType — that name is already taken in this + /// namespace by (Single/Splash/Chain/AllInRange, used by + /// TowerDefinition/TowerCombat), and every combat-adjacent file already + /// has using TD.Core;. + /// + public enum SpellTargetType : byte + { + /// Aimed at a single ground point. No radius preview while aiming, even + /// if the spell has an internal splash radius. + PointTarget = 0, + + /// Aimed at a ground point, affecting everything within + /// . Previewed + /// as a scaled radius decal while aiming. + AreaOfEffect = 1, + } +} diff --git a/Assets/_Project/Scripts/Core/SpellTargetType.cs.meta b/Assets/_Project/Scripts/Core/SpellTargetType.cs.meta new file mode 100644 index 0000000..0722838 --- /dev/null +++ b/Assets/_Project/Scripts/Core/SpellTargetType.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0ebeda6f965b64f7ca82ecd6f849142a \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs b/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs new file mode 100644 index 0000000..49160da --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs @@ -0,0 +1,226 @@ +// Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.Rendering.Universal; +using TD.Core; +using TD.UI; +using TD.Gameplay.BuilderSpells; + +namespace TD.Gameplay +{ + /// + /// Per-client controller for the builder-spell casting UX. Watches + /// to enter aim mode, raycasts against a broad ground layer + /// (must cover the enemy path, not just buildable tiles), previews the aim point, and + /// dispatches the cast to via RPC on confirm. + /// + /// + /// Plain MonoBehaviour. Aiming visuals are purely cosmetic and local, same + /// rationale as . All server-authoritative + /// resolution lives in . + /// + /// Point vs. area preview. shows a + /// small fixed-size reticle, even if the spell has an internal splash radius. + /// scales the same decal to the spell's + /// , mirroring 's + /// diameter-sizing idiom. + /// + /// Single-shot only. Unlike tower placement, there is no chained/shift-held + /// casting — one keypress aims one cast. + /// + public class BuilderSpellCastController : MonoBehaviour + { + // ----- Inspector -------------------------------------------------- + + [Tooltip("Physics layer(s) the aim raycast hits. Must cover the whole level's ground " + + "(including over the enemy path), not just buildable tiles — set this to " + + "'TerrainGeometry', not 'BuildablePlane'.")] + [SerializeField] private LayerMask groundLayerMask; + + [Tooltip("Maximum raycast distance from the camera to the ground.")] + [SerializeField] private float raycastMaxDistance = 500f; + + [Tooltip("Ground decal used for both the point reticle and the area-of-effect " + + "radius preview. Auto-found in children if empty.")] + [SerializeField] private DecalProjector aimDecal; + + [Tooltip("Fixed diameter of the reticle shown for PointTarget spells, regardless of " + + "any internal splash radius the spell may use server-side.")] + [SerializeField] private float pointReticleDiameter = 1.5f; + + [Tooltip("Vertical thickness of the decal projector's projection volume. Should " + + "exceed the map's vertical range so it projects onto terrain at any height.")] + [SerializeField] private float projectionDepth = 50f; + + // ----- Active-aim state --------------------------------------------- + + // -1 when not aiming. + private int activeSlot = -1; + private BuilderSpellDefinition activeDefinition; + + private bool lastHitValid; + private Vector3 lastHitPoint; + + // ----- Lifecycle ---------------------------------------------------- + + private void Awake() + { + if (aimDecal == null) aimDecal = GetComponentInChildren(); + if (aimDecal != null) + { + aimDecal.transform.localRotation = Quaternion.Euler(90f, 0f, 0f); + aimDecal.enabled = false; + } + } + + private void OnDisable() + { + ExitAimMode(); + } + + private void Update() + { + if (!HUDController.IsTextInputActive) + ScanHotkeys(); + + if (activeSlot < 0) return; // idle — nothing to aim + + var keyboard = Keyboard.current; + if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame) + { + ExitAimMode(); + return; + } + + var mouse = Mouse.current; + if (mouse == null) return; + + if (mouse.rightButton.wasPressedThisFrame) + { + ExitAimMode(); + return; + } + + if (TryGetGroundHit(mouse.position.ReadValue(), out Vector3 hitPoint)) + { + lastHitValid = true; + lastHitPoint = hitPoint; + ShowAimDecal(hitPoint); + } + else + { + lastHitValid = false; + HideAimDecal(); + } + + if (mouse.leftButton.wasPressedThisFrame && lastHitValid) + { + TrySubmitCast(); + } + } + + // ----- Hotkey scan -------------------------------------------------- + + private void ScanHotkeys() + { + if (activeSlot >= 0) return; // already aiming — hotkeys re-scanned only when idle + + var loadout = PlayerSpellLoadout.Local; + if (loadout == null) return; + + var keyboard = Keyboard.current; + if (keyboard == null) return; + + var layout = SpellHotkeys.Layout; + int slotCount = Mathf.Min(loadout.SlotCount, layout.Length); + + for (int i = 0; i < slotCount; i++) + { + if (!keyboard[layout[i]].wasPressedThisFrame) continue; + if (loadout.IsSlotOnCooldown(i)) continue; + + BeginAiming(i, loadout); + break; + } + } + + private void BeginAiming(int slot, PlayerSpellLoadout loadout) + { + var kind = loadout.GetKind(slot); + if (kind == null) return; + + var definition = BuilderSpellPool.Instance?.Get(kind.Value); + if (definition == null) + { + Debug.LogWarning($"[BuilderSpellCastController] No BuilderSpellDefinition for {kind.Value}."); + return; + } + + activeSlot = slot; + activeDefinition = definition; + lastHitValid = false; + } + + private void ExitAimMode() + { + activeSlot = -1; + activeDefinition = null; + lastHitValid = false; + HideAimDecal(); + } + + // ----- Raycasting --------------------------------------------------- + + private bool TryGetGroundHit(Vector2 screenPos, out Vector3 hitPoint) + { + hitPoint = Vector3.zero; + + var cam = Camera.main; + if (cam == null) return false; + + Ray ray = cam.ScreenPointToRay(new Vector3(screenPos.x, screenPos.y, 0f)); + if (Physics.Raycast(ray, out RaycastHit hit, raycastMaxDistance, groundLayerMask)) + { + hitPoint = hit.point; + return true; + } + return false; + } + + // ----- Aim decal ----------------------------------------------------- + + private void ShowAimDecal(Vector3 point) + { + if (aimDecal == null) return; + + float diameter = activeDefinition.TargetType == SpellTargetType.AreaOfEffect + ? activeDefinition.Radius * 2f + : pointReticleDiameter; + + aimDecal.size = new Vector3(diameter, diameter, projectionDepth); + aimDecal.pivot = Vector3.zero; + aimDecal.transform.position = point; + aimDecal.enabled = true; + } + + private void HideAimDecal() + { + if (aimDecal != null) aimDecal.enabled = false; + } + + // ----- Cast submission ------------------------------------------------ + + private void TrySubmitCast() + { + var loadout = PlayerSpellLoadout.Local; + if (loadout == null) + { + ExitAimMode(); + return; + } + + loadout.RequestCastSpellRpc(activeSlot, lastHitPoint); + ExitAimMode(); + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs.meta b/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs.meta new file mode 100644 index 0000000..ffe509e --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cc99a2b3d3e8d8178a74e1d68a34553c \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells.meta b/Assets/_Project/Scripts/Gameplay/BuilderSpells.meta new file mode 100644 index 0000000..fb42554 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7f64edca739cf31bfbdf58e2403b4a74 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellDefinition.cs b/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellDefinition.cs new file mode 100644 index 0000000..e1ca3b2 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellDefinition.cs @@ -0,0 +1,91 @@ +// Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellDefinition.cs +using UnityEngine; +using TD.Core; + +namespace TD.Gameplay.BuilderSpells +{ + /// + /// Base class for one builder spell — a player-cast, key-triggered ability (e.g. "drop a + /// fireball on a point", "slow enemies in a radius"). Granted via the draft () and tracked per-player by , which owns the hotkey slot and cooldown state. + /// + /// + /// Contrast with . Effects + /// are passive and queried by consumers; spells are active and resolve themselves. + /// is that resolution — it IS the "apply the effect" method, + /// replacing the effect system's IsValidFor-only gate pattern (grant-time validity + /// still lives on ). + /// + /// One asset per kind. is fixed per subclass, same as + /// . uses + /// it to build a fixed-size, enum-indexed lookup table. + /// + /// 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. + /// + public abstract class BuilderSpellDefinition : ScriptableObject + { + /// Which builder spell this asset's data belongs to. + public abstract BuilderSpellKind Kind { get; } + + [Header("Presentation")] + [Tooltip("Name shown on the draft card and the cast hotbar.")] + public string DisplayName; + + [Tooltip("Short description shown on the draft card.")] + [TextArea(2, 4)] + public string Description; + + [Tooltip("Icon shown on the draft card and the cast hotbar.")] + public Sprite Icon; + + [Header("Casting")] + [Tooltip("Seconds before this spell can be cast again after a successful cast.")] + [Min(0f)] + public float Cooldown = 5f; + + [Tooltip("How the cast controller aims this spell. AreaOfEffect previews Radius as a " + + "decal while aiming; PointTarget does not, even if Radius is used internally " + + "(e.g. a splash radius).")] + public SpellTargetType TargetType; + + [Tooltip("AreaOfEffect: the resolution radius AND the client-side preview size. " + + "PointTarget: optional internal-only radius (e.g. splash) with no preview.")] + [Min(0f)] + public float Radius; + + [Tooltip("Physics layer(s) enemies occupy, queried by this spell's own OverlapSphere " + + "call. Each spell asset authors its own mask — same convention as " + + "TowerCombat/Projectile's per-instance enemyLayerMask.")] + [SerializeField] + protected LayerMask enemyLayerMask; + + // Shared scratch buffer for OverlapSphereNonAlloc queries. The server processes casts + // sequentially (one RPC handler at a time), so a static buffer shared across all spell + // assets is safe — mirrors TowerCombat.s_overlapBuffer, but sized larger (64 vs. 32): + // OverlapSphereNonAlloc silently truncates past the buffer length with no way to + // detect the truncation, and spells are more likely than a single tower's splash/chain + // radius to catch a dense horde. TowerCombat's buffer is intentionally left at 32 — + // out of scope here, flagged separately for the team to revisit. + protected static readonly Collider[] s_overlapBuffer = new Collider[64]; + + /// + /// Server-only: resolve this spell's effect at for the + /// casting player. Returns false if the cast could not be applied (e.g. hit nothing) — + /// treats a false return as a no-op and does not start + /// the cooldown. + /// + 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. + /// + public virtual void ClientPlayVfx(Vector3 targetPoint) { } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellDefinition.cs.meta new file mode 100644 index 0000000..57deb3e --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9fca3191d98aa2652a9c5475d5c6b153 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellPool.cs b/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellPool.cs new file mode 100644 index 0000000..2f44df6 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellPool.cs @@ -0,0 +1,68 @@ +// Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellPool.cs +using System; +using UnityEngine; +using TD.Core; + +namespace TD.Gameplay.BuilderSpells +{ + /// + /// Scene singleton holding every available this match, + /// authored as a flat array in the inspector for convenience. Internally it builds a + /// fixed-size table indexed by so lookups are a single array + /// access, not a scan. + /// + /// + /// 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 + /// exactly. + /// + public class BuilderSpellPool : MonoBehaviour + { + public static BuilderSpellPool Instance { get; private set; } + + [Tooltip("Every BuilderSpellDefinition asset available this match. One entry per " + + "BuilderSpellKind — order doesn't matter, Kind on the asset itself decides " + + "its slot.")] + [SerializeField] private BuilderSpellDefinition[] spells; + + // Fixed-size, enum-indexed lookup built once in Awake. Sized to the enum's entry count, + // not authored count, so an out-of-range Kind is a compile-time impossibility rather + // than a bounds check we'd otherwise need on every Get(). + private BuilderSpellDefinition[] byKind; + + private void Awake() + { + if (Instance != null && Instance != this) + { + Debug.LogError("[BuilderSpellPool] Multiple instances detected. Only one per scene."); + return; + } + Instance = this; + + int kindCount = Enum.GetValues(typeof(BuilderSpellKind)).Length; + byKind = new BuilderSpellDefinition[kindCount]; + if (spells == null) return; + + for (int i = 0; i < spells.Length; i++) + { + var def = spells[i]; + if (def == null) continue; + byKind[(int)def.Kind] = def; + } + } + + private void OnDestroy() + { + if (Instance == this) Instance = null; + } + + /// Returns the spell asset for , or null if none is + /// authored in this pool. + public BuilderSpellDefinition Get(BuilderSpellKind kind) + { + int i = (int)kind; + return (byKind != null && i >= 0 && i < byKind.Length) ? byKind[i] : null; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellPool.cs.meta b/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellPool.cs.meta new file mode 100644 index 0000000..52803c7 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellPool.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 36a7be1b3824682b8a183fbcf8564652 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/FireballSpellDefinition.cs b/Assets/_Project/Scripts/Gameplay/BuilderSpells/FireballSpellDefinition.cs new file mode 100644 index 0000000..b21e70c --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/FireballSpellDefinition.cs @@ -0,0 +1,47 @@ +// Assets/_Project/Scripts/Gameplay/BuilderSpells/FireballSpellDefinition.cs +using UnityEngine; +using TD.Core; + +namespace TD.Gameplay.BuilderSpells +{ + /// + /// Builder spell #1 — drop a fireball on a point, dealing direct damage to whatever's + /// there plus splash damage to nearby enemies. + /// + /// + /// is + /// — the cast controller shows only a fixed-size reticle, even though + /// is still used internally here as the splash + /// radius (0 = no splash, direct hit only). + /// + [CreateAssetMenu(fileName = "FireballSpell", menuName = "TD/Builder Spells/Fireball")] + public class FireballSpellDefinition : BuilderSpellDefinition + { + public override BuilderSpellKind Kind => BuilderSpellKind.Fireball; + + [Header("Fireball")] + [Tooltip("Damage dealt to every enemy within Radius of the target point.")] + [Min(0f)] + public float Damage = 50f; + + public override bool ServerCast(ulong clientId, Vector3 targetPoint) + { + PlayerSlot owner = PlayerMatchState.SlotForClient(clientId); + + int count = Physics.OverlapSphereNonAlloc( + targetPoint, Mathf.Max(Radius, 0.01f), s_overlapBuffer, enemyLayerMask); + + bool hitAny = false; + for (int i = 0; i < count; i++) + { + var enemyHealth = s_overlapBuffer[i].GetComponent(); + if (enemyHealth == null || enemyHealth.IsDead) continue; + + enemyHealth.TakeDamage(Damage, DamageType.Fire, owner); + hitAny = true; + } + + return hitAny; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/FireballSpellDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/BuilderSpells/FireballSpellDefinition.cs.meta new file mode 100644 index 0000000..0871524 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/FireballSpellDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 22b36bfcd0ae4af4098d908138e874e1 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs new file mode 100644 index 0000000..4bee6bd --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs @@ -0,0 +1,55 @@ +// Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs +using UnityEngine; +using TD.Core; + +namespace TD.Gameplay.BuilderSpells +{ + /// + /// Builder spell #2 — slow every enemy within an area for a duration. + /// + /// + /// is + /// — the cast controller previews as a decal + /// while aiming, and this spell resolves against that same radius (no separate field). + /// Reuses the existing slow/DoT system () rather than + /// introducing a new status mechanism. + /// + [CreateAssetMenu(fileName = "SlowAreaSpell", menuName = "TD/Builder Spells/Slow Area")] + public class SlowAreaSpellDefinition : BuilderSpellDefinition + { + public override BuilderSpellKind Kind => BuilderSpellKind.SlowArea; + + [Header("Slow Area")] + [Tooltip("Speed multiplier applied to affected enemies for EffectDuration " + + "(e.g. 0.5 = half speed).")] + [Range(0f, 1f)] + public float SlowFactor = 0.5f; + + [Tooltip("Seconds the slow lasts. Re-casting on an already-slowed enemy refreshes it.")] + [Min(0f)] + public float EffectDuration = 3f; + + public override bool ServerCast(ulong clientId, Vector3 targetPoint) + { + PlayerSlot owner = PlayerMatchState.SlotForClient(clientId); + + int count = Physics.OverlapSphereNonAlloc( + targetPoint, Mathf.Max(Radius, 0.01f), s_overlapBuffer, enemyLayerMask); + + bool hitAny = false; + for (int i = 0; i < count; i++) + { + var enemyHealth = s_overlapBuffer[i].GetComponent(); + if (enemyHealth == null || enemyHealth.IsDead) continue; + + var enemyStatus = s_overlapBuffer[i].GetComponent(); + if (enemyStatus == null) continue; + + enemyStatus.ApplyEffect(DamageType.Cold, SlowFactor, EffectDuration, owner); + hitAny = true; + } + + return hitAny; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs.meta new file mode 100644 index 0000000..1ba03fa --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 249c65067c3bac80693255e594d844c7 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs b/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs new file mode 100644 index 0000000..416fc03 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs @@ -0,0 +1,41 @@ +// Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs +using UnityEngine; +using TD.Core; +using TD.Gameplay.BuilderSpells; + +namespace TD.Gameplay.Draft +{ + /// + /// Draft choice — "gain a builder spell". Grants the player a key-triggered active ability, + /// adding it to their . + /// + /// + /// Mirrors : carries the + /// directly rather than an asset reference, since the enum value is the stable identifier + /// and already key off of. + /// also checks slot capacity — once + /// is reached, no further spell options are + /// offered. + /// + [CreateAssetMenu(fileName = "BuilderSpellOption", menuName = "TD/Draft/Builder Spell Option")] + public class BuilderSpellDraftOption : DraftOption + { + [Header("Payload")] + [Tooltip("The builder spell this option grants.")] + public BuilderSpellKind Kind; + + public override bool IsValidFor(ulong clientId) + { + var loadout = PlayerSpellLoadout.GetForClient(clientId); + if (loadout == null) return false; + if (loadout.SlotCount >= PlayerSpellLoadout.MaxSpellSlots) return false; + return !loadout.PlayerHasSpell(Kind); + } + + public override bool ServerApply(ulong clientId) + { + var loadout = PlayerSpellLoadout.GetForClient(clientId); + return loadout != null && loadout.ServerGrantSpell(Kind); + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs.meta b/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs.meta new file mode 100644 index 0000000..a45da95 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a574e8aa74d8787f2b949300932af485 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs b/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs new file mode 100644 index 0000000..a6a1f68 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs @@ -0,0 +1,177 @@ +// Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs +using System.Collections.Generic; +using Unity.Netcode; +using UnityEngine; +using TD.Core; +using TD.Gameplay.BuilderSpells; + +namespace TD.Gameplay +{ + /// + /// Per-player set of granted builder spells and their cooldowns. Lives on the Player + /// prefab alongside and . + /// + /// + /// Slot == grant order == hotkey. is append-only; a + /// slot's index in the list is both its identity and the index into + /// . There is no player-configurable rebinding. + /// + /// Cast flow. The owning client calls with a + /// 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. + /// + public class PlayerSpellLoadout : NetworkBehaviour + { + /// Hard cap on granted spells, matching the number of hotkey slots available. + public const int MaxSpellSlots = 4; + + // ----- Static registry (mirrors BuilderUpgradeManager) ----- + + private static readonly Dictionary s_byClientId + = new Dictionary(); + + /// Returns the loadout owned by the given client, or null. + public static PlayerSpellLoadout GetForClient(ulong clientId) + { + s_byClientId.TryGetValue(clientId, out var loadout); + return loadout; + } + + /// Convenience: the local client's own loadout. + public static PlayerSpellLoadout Local + { + get + { + var nm = NetworkManager.Singleton; + if (nm == null || !nm.IsClient) return null; + return GetForClient(nm.LocalClientId); + } + } + + // ----- Networked state -------------------------------------------- + + private readonly NetworkList spells = new NetworkList(); + + /// Fired on every peer when a spell is granted or a cooldown changes. + public event System.Action OnLoadoutChanged; + + // ----- NGO lifecycle ------------------------------------------------ + + public override void OnNetworkSpawn() + { + s_byClientId[OwnerClientId] = this; + spells.OnListChanged += HandleSpellsChanged; + } + + public override void OnNetworkDespawn() + { + spells.OnListChanged -= HandleSpellsChanged; + + if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this) + s_byClientId.Remove(OwnerClientId); + } + + private void HandleSpellsChanged(NetworkListEvent change) => OnLoadoutChanged?.Invoke(); + + // ----- Read API ----------------------------------------------------- + + /// Number of spells currently granted. + public int SlotCount => spells.Count; + + /// True if this player has already been granted the given spell. + public bool PlayerHasSpell(BuilderSpellKind kind) + { + for (int i = 0; i < spells.Count; i++) + { + if (spells[i].Kind == kind) return true; + } + return false; + } + + /// The spell kind occupying , or null if out of range. + public BuilderSpellKind? GetKind(int slot) + => (slot >= 0 && slot < spells.Count) ? spells[slot].Kind : (BuilderSpellKind?)null; + + /// True if is out of range, empty, or still cooling down. + public bool IsSlotOnCooldown(int slot) + { + if (slot < 0 || slot >= spells.Count) return true; + + var nm = NetworkManager.Singleton; + if (nm == null) return false; + + return spells[slot].CooldownEndServerTime > nm.ServerTime.Time; + } + + /// Seconds remaining on 's cooldown, or 0 if ready/invalid. + public float GetCooldownRemaining(int slot) + { + if (slot < 0 || slot >= spells.Count) return 0f; + + var nm = NetworkManager.Singleton; + if (nm == null) return 0f; + + double remaining = spells[slot].CooldownEndServerTime - nm.ServerTime.Time; + return remaining > 0d ? (float)remaining : 0f; + } + + // ----- Server mutation ----------------------------------------------- + + /// + /// Server-only: grants the given spell (a draft pick) into the next free slot. + /// No-op if already granted or slots are full. Returns true if actually granted. + /// + public bool ServerGrantSpell(BuilderSpellKind kind) + { + if (!IsServer) return false; + if (spells.Count >= MaxSpellSlots) return false; + if (PlayerHasSpell(kind)) return false; + + spells.Add(SpellSlot.CreateReady(kind)); + return true; + } + + // ----- Cast RPC ----------------------------------------------------- + + /// + /// Owning-client entry point. Requests the server resolve a cast of the spell in + /// at . All validation happens + /// here on the server; the client only uses this to trigger an attempt. + /// + [Rpc(SendTo.Server, RequireOwnership = true)] + public void RequestCastSpellRpc(int slot, Vector3 targetPoint) + { + if (MatchState.Instance == null || MatchState.Instance.Phase != MatchPhase.Playing) + return; + + if (slot < 0 || slot >= spells.Count) return; + + var slotValue = spells[slot]; + if (slotValue.CooldownEndServerTime > NetworkManager.ServerTime.Time) return; + + var definition = BuilderSpellPool.Instance?.Get(slotValue.Kind); + if (definition == null) + { + Debug.LogWarning($"[PlayerSpellLoadout] No BuilderSpellDefinition for {slotValue.Kind}."); + return; + } + + if (!definition.ServerCast(OwnerClientId, targetPoint)) return; + + slotValue.CooldownEndServerTime = NetworkManager.ServerTime.Time + definition.Cooldown; + spells[slot] = slotValue; + + PlayVfxClientRpc(slotValue.Kind, targetPoint); + } + + [ClientRpc] + private void PlayVfxClientRpc(BuilderSpellKind kind, Vector3 targetPoint) + { + BuilderSpellPool.Instance?.Get(kind)?.ClientPlayVfx(targetPoint); + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs.meta b/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs.meta new file mode 100644 index 0000000..559df0e --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dfa83208b02c50b40866bb1846240c4a \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/SpellHotkeys.cs b/Assets/_Project/Scripts/Gameplay/SpellHotkeys.cs new file mode 100644 index 0000000..2f162d1 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/SpellHotkeys.cs @@ -0,0 +1,29 @@ +// Assets/_Project/Scripts/Gameplay/SpellHotkeys.cs +using UnityEngine.InputSystem; + +namespace TD.Gameplay +{ + /// + /// The fixed key bound to each spell hotkey slot (slot index == grant order == + /// list index). Shared by + /// (reads input) and the HUD hotbar (renders key + /// labels) so the two can never drift apart. + /// + /// + /// 's HotkeyLayout already claims the entire + /// Q/W/E/R/T/A/S/D/F/G/Z/X/C/V/B block for the build command grid, so spell casting uses + /// the number row instead. + /// + public static class SpellHotkeys + { + public static readonly Key[] Layout = + { + Key.Digit1, + Key.Digit2, + Key.Digit3, + Key.Digit4, + }; + + public static int MaxSpellSlots => Layout.Length; + } +} diff --git a/Assets/_Project/Scripts/Gameplay/SpellHotkeys.cs.meta b/Assets/_Project/Scripts/Gameplay/SpellHotkeys.cs.meta new file mode 100644 index 0000000..62da374 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/SpellHotkeys.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 360482837f8c85d5abe29870dcf9cf1a \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/SpellSlot.cs b/Assets/_Project/Scripts/Gameplay/SpellSlot.cs new file mode 100644 index 0000000..c84e687 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/SpellSlot.cs @@ -0,0 +1,63 @@ +// Assets/_Project/Scripts/Gameplay/SpellSlot.cs +using System; +using Unity.Netcode; +using TD.Core; + +namespace TD.Gameplay +{ + /// + /// One granted builder spell in a . Replicated as part of + /// a ; the entry's index in that list IS the hotkey slot + /// (grant order == key order), so no separate slot index field is needed. + /// + /// + /// One struct, not parallel lists. is set once at grant time; + /// is rewritten on every cast. NetworkList{T}'s + /// indexer setter (spells[i] = newValue) short-circuits the write when + /// Equals on the old value returns true (see for the + /// documented case that first surfaced this). Comparing only would mean + /// a cooldown update — same Kind, new CooldownEndServerTime — never actually replicates. + /// therefore compares every field. + /// + [Serializable] + public struct SpellSlot : INetworkSerializable, IEquatable + { + /// Which spell occupies this slot. Fixed for the slot's lifetime. + public BuilderSpellKind Kind; + + /// + /// NetworkManager.ServerTime.Time at which this slot is next ready to cast. + /// 0 (or any value <= current server time) means ready now. + /// + public double CooldownEndServerTime; + + public static SpellSlot CreateReady(BuilderSpellKind kind) + { + return new SpellSlot { Kind = kind, CooldownEndServerTime = 0d }; + } + + // ----- INetworkSerializable --------------------------------------- + + public void NetworkSerialize(BufferSerializer serializer) where T : IReaderWriter + { + byte kindByte = (byte)Kind; + serializer.SerializeValue(ref kindByte); + Kind = (BuilderSpellKind)kindByte; + + serializer.SerializeValue(ref CooldownEndServerTime); + } + + // ----- IEquatable ------------------------------------------------- + // + // Full-field comparison — see remarks above. Without this, cooldown writes + // (Kind unchanged, only CooldownEndServerTime updated) would be silently dropped + // by NetworkList's indexer setter. + + public bool Equals(SpellSlot other) => + Kind == other.Kind && CooldownEndServerTime == other.CooldownEndServerTime; + + public override bool Equals(object obj) => obj is SpellSlot other && Equals(other); + + public override int GetHashCode() => (int)Kind; + } +} diff --git a/Assets/_Project/Scripts/Gameplay/SpellSlot.cs.meta b/Assets/_Project/Scripts/Gameplay/SpellSlot.cs.meta new file mode 100644 index 0000000..d9c0bee --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/SpellSlot.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9c8fdabdb3c89204ab28f9e031678db4 \ No newline at end of file diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs index 96f50ec..504ac50 100644 --- a/Assets/_Project/Scripts/UI/HUDController.cs +++ b/Assets/_Project/Scripts/UI/HUDController.cs @@ -8,6 +8,7 @@ using UnityEngine.SceneManagement; using UnityEngine.UIElements; using TD.Core; using TD.Gameplay; +using TD.Gameplay.BuilderSpells; using TD.Gameplay.Draft; using TD.Towers; using TD.UI.Minimap; @@ -115,6 +116,27 @@ namespace TD.UI private bool draftSubscribed; private PlayerDraft subscribedDraft; + // Spell hotbar (bottom-ui Section 6). Frame is display:none 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; + private readonly List spellSlotUis = new List(); + + private readonly struct SpellSlotUi + { + public readonly VisualElement Cell; + public readonly Label CooldownLabel; + + public SpellSlotUi(VisualElement cell, Label cooldownLabel) + { + Cell = cell; + CooldownLabel = cooldownLabel; + } + } + // Chat panel (bottom-left, above portrait) — programmatic. The container // holds both the scrollable feed and the input. Highlight + scroll // interactivity are toggled on the container when typing. @@ -294,6 +316,23 @@ namespace TD.UI RebuildDraftCards(); } + // Hook the local player's spell loadout so the hotbar rebuilds when a new spell is + // granted. Retried each Update until the local PlayerSpellLoadout exists. + private void TrySubscribeSpellLoadout() + { + var loadout = PlayerSpellLoadout.Local; + if (loadout == null) return; + loadout.OnLoadoutChanged += HandleSpellLoadoutChanged; + subscribedSpellLoadout = loadout; + spellLoadoutSubscribed = true; + RebuildSpellHotbar(); + } + + private void HandleSpellLoadoutChanged() + { + RebuildSpellHotbar(); + } + // ----- Draft overlay ---------------------------------------------- // Non-modal draft UI: a card row floats at top-center while a draft is active, and @@ -434,6 +473,103 @@ namespace TD.UI draftPanel.style.display = (hasDraft || canShowBuy) ? DisplayStyle.Flex : DisplayStyle.None; } + // ----- 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. + private void RebuildSpellHotbar() + { + if (spellHotbar == null) return; + spellHotbar.Clear(); + spellSlotUis.Clear(); + + var loadout = PlayerSpellLoadout.Local; + int slotCount = loadout?.SlotCount ?? 0; + + if (spellHotbarFrame != null) + spellHotbarFrame.style.display = slotCount > 0 ? DisplayStyle.Flex : DisplayStyle.None; + + if (loadout == null) return; + + var layout = SpellHotkeys.Layout; + for (int i = 0; i < slotCount; i++) + { + var kind = loadout.GetKind(i); + if (kind == null) continue; + + var definition = BuilderSpellPool.Instance?.Get(kind.Value); + + var cell = new VisualElement(); + cell.AddToClassList("spell-slot"); + + var icon = new VisualElement(); + icon.AddToClassList("spell-slot-icon"); + icon.pickingMode = PickingMode.Ignore; + if (definition?.Icon != null) + icon.style.backgroundImage = new StyleBackground(definition.Icon); + cell.Add(icon); + + if (i < layout.Length) + { + var hkLabel = new Label(SpellKeyToDisplay(layout[i])); + hkLabel.AddToClassList("spell-slot-hotkey"); + hkLabel.pickingMode = PickingMode.Ignore; + cell.Add(hkLabel); + } + + var cooldownLabel = new Label(""); + cooldownLabel.AddToClassList("spell-slot-cooldown-label"); + cooldownLabel.pickingMode = PickingMode.Ignore; + cell.Add(cooldownLabel); + + cell.RegisterCallback(_ => ShowSpellTooltip(definition)); + cell.RegisterCallback(_ => ClearTooltip()); + + spellHotbar.Add(cell); + spellSlotUis.Add(new SpellSlotUi(cell, cooldownLabel)); + } + } + + // 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). + private void UpdateSpellCooldowns() + { + if (spellSlotUis.Count == 0) return; + + var loadout = PlayerSpellLoadout.Local; + if (loadout == null) return; + + for (int i = 0; i < spellSlotUis.Count; i++) + { + var ui = spellSlotUis[i]; + bool onCooldown = loadout.IsSlotOnCooldown(i); + ui.Cell.EnableInClassList("on-cooldown", onCooldown); + ui.CooldownLabel.text = onCooldown + ? $"{Mathf.CeilToInt(loadout.GetCooldownRemaining(i))}" + : ""; + } + } + + // Renders a spell hotkey as its display glyph. SpellHotkeys.Layout uses Key.DigitN, + // whose ToString() is "DigitN" — strip the prefix so the badge shows "1", not "Digit1". + private static string SpellKeyToDisplay(Key key) + { + string s = key.ToString(); + return s.StartsWith("Digit") ? s.Substring("Digit".Length) : s; + } + + // Lightweight tooltip for spell slots — reuses the tooltip box (title + desc + cooldown). + private void ShowSpellTooltip(BuilderSpellDefinition def) + { + if (ttTitle == null || def == null) return; + ttTitle.text = def.DisplayName; + ttDesc.text = def.Description ?? ""; + ttStats.text = $"Cooldown: {def.Cooldown:0.#}s"; + ttCost.text = ""; + } + private void InitializeUI() { var doc = GetComponent(); @@ -481,6 +617,8 @@ namespace TD.UI ttStats = Require