From 7cf15dcaa63969acea4250fe5649154290a7a228 Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Tue, 14 Jul 2026 20:56:50 -0700 Subject: [PATCH 01/34] 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