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