draft options working; spells won't cast though
This commit is contained in:
parent
2429efbf6a
commit
7cf15dcaa6
37 changed files with 1340 additions and 0 deletions
8
Assets/_Project/Definitions/BuilderSpells.meta
Normal file
8
Assets/_Project/Definitions/BuilderSpells.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f6f56fe562e9b8c229442088bb6ce11c
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 051c48f416e5366b59d49b0e062333ac
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d893f46b037536b4ba91307e184cecfe
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5f390de69ba43b2a6a0d89cf09580320
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 29b35841f7e5db454903798cb5d83434
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -17,6 +17,7 @@ GameObject:
|
||||||
- component: {fileID: 2806524246861401799}
|
- component: {fileID: 2806524246861401799}
|
||||||
- component: {fileID: 2806524246861401801}
|
- component: {fileID: 2806524246861401801}
|
||||||
- component: {fileID: 5683786710272852339}
|
- component: {fileID: 5683786710272852339}
|
||||||
|
- component: {fileID: 6856236869205671849}
|
||||||
m_Layer: 0
|
m_Layer: 0
|
||||||
m_Name: Player
|
m_Name: Player
|
||||||
m_TagString: Untagged
|
m_TagString: Untagged
|
||||||
|
|
@ -159,3 +160,16 @@ MonoBehaviour:
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuilderUpgradeManager
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuilderUpgradeManager
|
||||||
ShowTopMostFoldoutHeaderGroup: 1
|
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
|
||||||
|
|
|
||||||
|
|
@ -13345,6 +13345,53 @@ Transform:
|
||||||
m_Children: []
|
m_Children: []
|
||||||
m_Father: {fileID: 1097152085}
|
m_Father: {fileID: 1097152085}
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
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
|
--- !u!1 &921503905
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -13727,6 +13774,57 @@ BoxCollider:
|
||||||
serializedVersion: 3
|
serializedVersion: 3
|
||||||
m_Size: {x: 17, y: 1, z: 13}
|
m_Size: {x: 17, y: 1, z: 13}
|
||||||
m_Center: {x: 0, y: 0, z: 0}
|
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
|
--- !u!1 &997315214
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -28565,6 +28663,9 @@ MonoBehaviour:
|
||||||
- {fileID: 11400000, guid: 6b1d3f8a2c5e4097b8a1d0c3e6f9b240, type: 2}
|
- {fileID: 11400000, guid: 6b1d3f8a2c5e4097b8a1d0c3e6f9b240, type: 2}
|
||||||
- {fileID: 11400000, guid: f4a2b8d1c6e93f05a7b2d4c8e1f6a930, type: 2}
|
- {fileID: 11400000, guid: f4a2b8d1c6e93f05a7b2d4c8e1f6a930, type: 2}
|
||||||
- {fileID: 11400000, guid: 65ff25c1c8a89f7df8e88f71968c1c98, 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
|
--- !u!4 &2139601601
|
||||||
Transform:
|
Transform:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -28962,3 +29063,5 @@ SceneRoots:
|
||||||
- {fileID: 176580462}
|
- {fileID: 176580462}
|
||||||
- {fileID: 516125087}
|
- {fileID: 516125087}
|
||||||
- {fileID: 2079088113}
|
- {fileID: 2079088113}
|
||||||
|
- {fileID: 914832726}
|
||||||
|
- {fileID: 993870445}
|
||||||
|
|
|
||||||
15
Assets/_Project/Scripts/Core/BuilderSpellKind.cs
Normal file
15
Assets/_Project/Scripts/Core/BuilderSpellKind.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
namespace TD.Core
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies which builder spell a
|
||||||
|
/// <see cref="TD.Gameplay.BuilderSpells.BuilderSpellDefinition"/> represents. Mirrors
|
||||||
|
/// <see cref="BuilderEffectKind"/> — a stable, enum-indexed identifier used by
|
||||||
|
/// <see cref="TD.Gameplay.BuilderSpells.BuilderSpellPool"/> and
|
||||||
|
/// <see cref="TD.Gameplay.SpellSlot"/> instead of an asset reference.
|
||||||
|
/// </summary>
|
||||||
|
public enum BuilderSpellKind : byte
|
||||||
|
{
|
||||||
|
Fireball = 0,
|
||||||
|
SlowArea = 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Scripts/Core/BuilderSpellKind.cs.meta
Normal file
2
Assets/_Project/Scripts/Core/BuilderSpellKind.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a043765d6aa295a5397226e3e32d2c37
|
||||||
23
Assets/_Project/Scripts/Core/SpellTargetType.cs
Normal file
23
Assets/_Project/Scripts/Core/SpellTargetType.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
namespace TD.Core
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// How a <see cref="TD.Gameplay.BuilderSpells.BuilderSpellDefinition"/> is aimed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Deliberately not named <c>TargetType</c> — that name is already taken in this
|
||||||
|
/// namespace by <see cref="TargetType"/> (Single/Splash/Chain/AllInRange, used by
|
||||||
|
/// <c>TowerDefinition</c>/<c>TowerCombat</c>), and every combat-adjacent file already
|
||||||
|
/// has <c>using TD.Core;</c>.
|
||||||
|
/// </remarks>
|
||||||
|
public enum SpellTargetType : byte
|
||||||
|
{
|
||||||
|
/// <summary>Aimed at a single ground point. No radius preview while aiming, even
|
||||||
|
/// if the spell has an internal splash radius.</summary>
|
||||||
|
PointTarget = 0,
|
||||||
|
|
||||||
|
/// <summary>Aimed at a ground point, affecting everything within
|
||||||
|
/// <see cref="TD.Gameplay.BuilderSpells.BuilderSpellDefinition.Radius"/>. Previewed
|
||||||
|
/// as a scaled radius decal while aiming.</summary>
|
||||||
|
AreaOfEffect = 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Scripts/Core/SpellTargetType.cs.meta
Normal file
2
Assets/_Project/Scripts/Core/SpellTargetType.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0ebeda6f965b64f7ca82ecd6f849142a
|
||||||
226
Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs
Normal file
226
Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs
Normal file
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Per-client controller for the builder-spell casting UX. Watches
|
||||||
|
/// <see cref="SpellHotkeys"/> 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 <see cref="PlayerSpellLoadout"/> via RPC on confirm.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Plain MonoBehaviour.</b> Aiming visuals are purely cosmetic and local, same
|
||||||
|
/// rationale as <see cref="TowerPlacementController"/>. All server-authoritative
|
||||||
|
/// resolution lives in <see cref="PlayerSpellLoadout.RequestCastSpellRpc"/>.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Point vs. area preview.</b> <see cref="SpellTargetType.PointTarget"/> shows a
|
||||||
|
/// small fixed-size reticle, even if the spell has an internal splash radius.
|
||||||
|
/// <see cref="SpellTargetType.AreaOfEffect"/> scales the same decal to the spell's
|
||||||
|
/// <see cref="BuilderSpellDefinition.Radius"/>, mirroring <see cref="BuildRangeIndicator"/>'s
|
||||||
|
/// diameter-sizing idiom.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Single-shot only.</b> Unlike tower placement, there is no chained/shift-held
|
||||||
|
/// casting — one keypress aims one cast.</para>
|
||||||
|
/// </remarks>
|
||||||
|
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<DecalProjector>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: cc99a2b3d3e8d8178a74e1d68a34553c
|
||||||
8
Assets/_Project/Scripts/Gameplay/BuilderSpells.meta
Normal file
8
Assets/_Project/Scripts/Gameplay/BuilderSpells.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7f64edca739cf31bfbdf58e2403b4a74
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
// Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellDefinition.cs
|
||||||
|
using UnityEngine;
|
||||||
|
using TD.Core;
|
||||||
|
|
||||||
|
namespace TD.Gameplay.BuilderSpells
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 (<see
|
||||||
|
/// cref="TD.Gameplay.Draft.BuilderSpellDraftOption"/>) and tracked per-player by <see
|
||||||
|
/// cref="PlayerSpellLoadout"/>, which owns the hotkey slot and cooldown state.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Contrast with <see cref="BuilderEffects.BuilderEffectDefinition"/>.</b> Effects
|
||||||
|
/// are passive and queried by consumers; spells are active and resolve themselves.
|
||||||
|
/// <see cref="ServerCast"/> is that resolution — it IS the "apply the effect" method,
|
||||||
|
/// replacing the effect system's <c>IsValidFor</c>-only gate pattern (grant-time validity
|
||||||
|
/// still lives on <see cref="TD.Gameplay.Draft.BuilderSpellDraftOption.IsValidFor"/>).</para>
|
||||||
|
///
|
||||||
|
/// <para><b>One asset per kind.</b> <see cref="Kind"/> is fixed per subclass, same as
|
||||||
|
/// <see cref="BuilderEffects.BuilderEffectDefinition"/>. <see cref="BuilderSpellPool"/> uses
|
||||||
|
/// it to build a fixed-size, enum-indexed lookup table.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Server-only resolution, client-only visual.</b> <see cref="ServerCast"/> runs on
|
||||||
|
/// the server and applies damage/status via the same <c>Physics.OverlapSphereNonAlloc</c> +
|
||||||
|
/// <c>EnemyHealth</c>/<c>EnemyStatus</c> pattern <c>TowerCombat</c> already uses.
|
||||||
|
/// <see cref="ClientPlayVfx"/> runs on every peer (including the server) after a successful
|
||||||
|
/// cast, purely for presentation.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public abstract class BuilderSpellDefinition : ScriptableObject
|
||||||
|
{
|
||||||
|
/// <summary>Which builder spell this asset's data belongs to.</summary>
|
||||||
|
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];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-only: resolve this spell's effect at <paramref name="targetPoint"/> for the
|
||||||
|
/// casting player. Returns false if the cast could not be applied (e.g. hit nothing) —
|
||||||
|
/// <see cref="PlayerSpellLoadout"/> treats a false return as a no-op and does not start
|
||||||
|
/// the cooldown.
|
||||||
|
/// </summary>
|
||||||
|
public abstract bool ServerCast(ulong clientId, Vector3 targetPoint);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs on every peer (via <see cref="PlayerSpellLoadout"/>'s ClientRpc) after a
|
||||||
|
/// successful <see cref="ServerCast"/>. Default no-op; override to spawn an impact VFX
|
||||||
|
/// prefab and self-destroy it, the same idiom used elsewhere for one-off visuals.
|
||||||
|
/// </summary>
|
||||||
|
public virtual void ClientPlayVfx(Vector3 targetPoint) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9fca3191d98aa2652a9c5475d5c6b153
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
// Assets/_Project/Scripts/Gameplay/BuilderSpells/BuilderSpellPool.cs
|
||||||
|
using System;
|
||||||
|
using UnityEngine;
|
||||||
|
using TD.Core;
|
||||||
|
|
||||||
|
namespace TD.Gameplay.BuilderSpells
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Scene singleton holding every <see cref="BuilderSpellDefinition"/> available this match,
|
||||||
|
/// authored as a flat array in the inspector for convenience. Internally it builds a
|
||||||
|
/// fixed-size table indexed by <see cref="BuilderSpellKind"/> so lookups are a single array
|
||||||
|
/// access, not a scan.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Plain MonoBehaviour: identical on every peer (same assets), so there is nothing to sync.
|
||||||
|
/// The server reads it to resolve casts; clients read it to resolve <see
|
||||||
|
/// cref="BuilderSpellDefinition.ClientPlayVfx"/> and to render the cast hotbar. Mirrors
|
||||||
|
/// <see cref="BuilderEffects.BuilderEffectPool"/> exactly.
|
||||||
|
/// </remarks>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the spell asset for <paramref name="kind"/>, or null if none is
|
||||||
|
/// authored in this pool.</summary>
|
||||||
|
public BuilderSpellDefinition Get(BuilderSpellKind kind)
|
||||||
|
{
|
||||||
|
int i = (int)kind;
|
||||||
|
return (byKind != null && i >= 0 && i < byKind.Length) ? byKind[i] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 36a7be1b3824682b8a183fbcf8564652
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
// Assets/_Project/Scripts/Gameplay/BuilderSpells/FireballSpellDefinition.cs
|
||||||
|
using UnityEngine;
|
||||||
|
using TD.Core;
|
||||||
|
|
||||||
|
namespace TD.Gameplay.BuilderSpells
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builder spell #1 — drop a fireball on a point, dealing direct damage to whatever's
|
||||||
|
/// there plus splash damage to nearby enemies.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="BuilderSpellDefinition.TargetType"/> is <see cref="SpellTargetType.PointTarget"/>
|
||||||
|
/// — the cast controller shows only a fixed-size reticle, even though
|
||||||
|
/// <see cref="BuilderSpellDefinition.Radius"/> is still used internally here as the splash
|
||||||
|
/// radius (0 = no splash, direct hit only).
|
||||||
|
/// </remarks>
|
||||||
|
[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<EnemyHealth>();
|
||||||
|
if (enemyHealth == null || enemyHealth.IsDead) continue;
|
||||||
|
|
||||||
|
enemyHealth.TakeDamage(Damage, DamageType.Fire, owner);
|
||||||
|
hitAny = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hitAny;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 22b36bfcd0ae4af4098d908138e874e1
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
// Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs
|
||||||
|
using UnityEngine;
|
||||||
|
using TD.Core;
|
||||||
|
|
||||||
|
namespace TD.Gameplay.BuilderSpells
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builder spell #2 — slow every enemy within an area for a duration.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="BuilderSpellDefinition.TargetType"/> is <see cref="SpellTargetType.AreaOfEffect"/>
|
||||||
|
/// — the cast controller previews <see cref="BuilderSpellDefinition.Radius"/> as a decal
|
||||||
|
/// while aiming, and this spell resolves against that same radius (no separate field).
|
||||||
|
/// Reuses the existing slow/DoT system (<see cref="EnemyStatus.ApplyEffect"/>) rather than
|
||||||
|
/// introducing a new status mechanism.
|
||||||
|
/// </remarks>
|
||||||
|
[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<EnemyHealth>();
|
||||||
|
if (enemyHealth == null || enemyHealth.IsDead) continue;
|
||||||
|
|
||||||
|
var enemyStatus = s_overlapBuffer[i].GetComponent<EnemyStatus>();
|
||||||
|
if (enemyStatus == null) continue;
|
||||||
|
|
||||||
|
enemyStatus.ApplyEffect(DamageType.Cold, SlowFactor, EffectDuration, owner);
|
||||||
|
hitAny = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hitAny;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 249c65067c3bac80693255e594d844c7
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
// Assets/_Project/Scripts/Gameplay/Draft/BuilderSpellDraftOption.cs
|
||||||
|
using UnityEngine;
|
||||||
|
using TD.Core;
|
||||||
|
using TD.Gameplay.BuilderSpells;
|
||||||
|
|
||||||
|
namespace TD.Gameplay.Draft
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Draft choice — "gain a builder spell". Grants the player a key-triggered active ability,
|
||||||
|
/// adding it to their <see cref="PlayerSpellLoadout"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Mirrors <see cref="BuilderEffectDraftOption"/>: carries the <see cref="BuilderSpellKind"/>
|
||||||
|
/// directly rather than an asset reference, since the enum value is the stable identifier
|
||||||
|
/// <see cref="PlayerSpellLoadout"/> and <see cref="BuilderSpellPool"/> already key off of.
|
||||||
|
/// <see cref="IsValidFor"/> also checks slot capacity — once
|
||||||
|
/// <see cref="PlayerSpellLoadout.MaxSpellSlots"/> is reached, no further spell options are
|
||||||
|
/// offered.
|
||||||
|
/// </remarks>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a574e8aa74d8787f2b949300932af485
|
||||||
177
Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs
Normal file
177
Assets/_Project/Scripts/Gameplay/PlayerSpellLoadout.cs
Normal file
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Per-player set of granted builder spells and their cooldowns. Lives on the Player
|
||||||
|
/// prefab alongside <see cref="BuilderUpgradeManager"/> and <see cref="PlayerDraft"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Slot == grant order == hotkey.</b> <see cref="spells"/> is append-only; a
|
||||||
|
/// slot's index in the list is both its identity and the index into
|
||||||
|
/// <see cref="SpellHotkeys.Layout"/>. There is no player-configurable rebinding.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Cast flow.</b> The owning client calls <see cref="RequestCastSpellRpc"/> with a
|
||||||
|
/// slot index and an aimed world point. The server re-validates everything (slot exists,
|
||||||
|
/// off cooldown, <see cref="MatchPhase.Playing"/>) since client-side checks in
|
||||||
|
/// <see cref="BuilderSpellCastController"/> are UX-only, resolves the
|
||||||
|
/// <see cref="BuilderSpellDefinition"/> via <see cref="BuilderSpellPool"/>, and calls
|
||||||
|
/// <see cref="BuilderSpellDefinition.ServerCast"/>. A successful cast starts the cooldown
|
||||||
|
/// and fires <see cref="PlayVfxClientRpc"/> so every peer plays the same visual.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public class PlayerSpellLoadout : NetworkBehaviour
|
||||||
|
{
|
||||||
|
/// <summary>Hard cap on granted spells, matching the number of hotkey slots available.</summary>
|
||||||
|
public const int MaxSpellSlots = 4;
|
||||||
|
|
||||||
|
// ----- Static registry (mirrors BuilderUpgradeManager) -----
|
||||||
|
|
||||||
|
private static readonly Dictionary<ulong, PlayerSpellLoadout> s_byClientId
|
||||||
|
= new Dictionary<ulong, PlayerSpellLoadout>();
|
||||||
|
|
||||||
|
/// <summary>Returns the loadout owned by the given client, or null.</summary>
|
||||||
|
public static PlayerSpellLoadout GetForClient(ulong clientId)
|
||||||
|
{
|
||||||
|
s_byClientId.TryGetValue(clientId, out var loadout);
|
||||||
|
return loadout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Convenience: the local client's own loadout.</summary>
|
||||||
|
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<SpellSlot> spells = new NetworkList<SpellSlot>();
|
||||||
|
|
||||||
|
/// <summary>Fired on every peer when a spell is granted or a cooldown changes.</summary>
|
||||||
|
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<SpellSlot> change) => OnLoadoutChanged?.Invoke();
|
||||||
|
|
||||||
|
// ----- Read API -----------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Number of spells currently granted.</summary>
|
||||||
|
public int SlotCount => spells.Count;
|
||||||
|
|
||||||
|
/// <summary>True if this player has already been granted the given spell.</summary>
|
||||||
|
public bool PlayerHasSpell(BuilderSpellKind kind)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < spells.Count; i++)
|
||||||
|
{
|
||||||
|
if (spells[i].Kind == kind) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The spell kind occupying <paramref name="slot"/>, or null if out of range.</summary>
|
||||||
|
public BuilderSpellKind? GetKind(int slot)
|
||||||
|
=> (slot >= 0 && slot < spells.Count) ? spells[slot].Kind : (BuilderSpellKind?)null;
|
||||||
|
|
||||||
|
/// <summary>True if <paramref name="slot"/> is out of range, empty, or still cooling down.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Seconds remaining on <paramref name="slot"/>'s cooldown, or 0 if ready/invalid.</summary>
|
||||||
|
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 -----------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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 -----------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owning-client entry point. Requests the server resolve a cast of the spell in
|
||||||
|
/// <paramref name="slot"/> at <paramref name="targetPoint"/>. All validation happens
|
||||||
|
/// here on the server; the client only uses this to trigger an attempt.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: dfa83208b02c50b40866bb1846240c4a
|
||||||
29
Assets/_Project/Scripts/Gameplay/SpellHotkeys.cs
Normal file
29
Assets/_Project/Scripts/Gameplay/SpellHotkeys.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
// Assets/_Project/Scripts/Gameplay/SpellHotkeys.cs
|
||||||
|
using UnityEngine.InputSystem;
|
||||||
|
|
||||||
|
namespace TD.Gameplay
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The fixed key bound to each spell hotkey slot (slot index == grant order ==
|
||||||
|
/// <see cref="PlayerSpellLoadout"/> list index). Shared by
|
||||||
|
/// <see cref="BuilderSpellCastController"/> (reads input) and the HUD hotbar (renders key
|
||||||
|
/// labels) so the two can never drift apart.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="TD.UI.HUDController"/>'s <c>HotkeyLayout</c> 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.
|
||||||
|
/// </remarks>
|
||||||
|
public static class SpellHotkeys
|
||||||
|
{
|
||||||
|
public static readonly Key[] Layout =
|
||||||
|
{
|
||||||
|
Key.Digit1,
|
||||||
|
Key.Digit2,
|
||||||
|
Key.Digit3,
|
||||||
|
Key.Digit4,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static int MaxSpellSlots => Layout.Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Scripts/Gameplay/SpellHotkeys.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/SpellHotkeys.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 360482837f8c85d5abe29870dcf9cf1a
|
||||||
63
Assets/_Project/Scripts/Gameplay/SpellSlot.cs
Normal file
63
Assets/_Project/Scripts/Gameplay/SpellSlot.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
// Assets/_Project/Scripts/Gameplay/SpellSlot.cs
|
||||||
|
using System;
|
||||||
|
using Unity.Netcode;
|
||||||
|
using TD.Core;
|
||||||
|
|
||||||
|
namespace TD.Gameplay
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One granted builder spell in a <see cref="PlayerSpellLoadout"/>. Replicated as part of
|
||||||
|
/// a <see cref="NetworkList{T}"/>; the entry's index in that list IS the hotkey slot
|
||||||
|
/// (grant order == key order), so no separate slot index field is needed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>One struct, not parallel lists.</b> <see cref="Kind"/> is set once at grant time;
|
||||||
|
/// <see cref="CooldownEndServerTime"/> is rewritten on every cast. <c>NetworkList{T}</c>'s
|
||||||
|
/// indexer setter (<c>spells[i] = newValue</c>) short-circuits the write when
|
||||||
|
/// <c>Equals</c> on the old value returns true (see <see cref="BuildJob"/> for the
|
||||||
|
/// documented case that first surfaced this). Comparing only <see cref="Kind"/> would mean
|
||||||
|
/// a cooldown update — same Kind, new CooldownEndServerTime — never actually replicates.
|
||||||
|
/// <see cref="Equals"/> therefore compares every field.
|
||||||
|
/// </remarks>
|
||||||
|
[Serializable]
|
||||||
|
public struct SpellSlot : INetworkSerializable, IEquatable<SpellSlot>
|
||||||
|
{
|
||||||
|
/// <summary>Which spell occupies this slot. Fixed for the slot's lifetime.</summary>
|
||||||
|
public BuilderSpellKind Kind;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>NetworkManager.ServerTime.Time</c> at which this slot is next ready to cast.
|
||||||
|
/// 0 (or any value <= current server time) means ready now.
|
||||||
|
/// </summary>
|
||||||
|
public double CooldownEndServerTime;
|
||||||
|
|
||||||
|
public static SpellSlot CreateReady(BuilderSpellKind kind)
|
||||||
|
{
|
||||||
|
return new SpellSlot { Kind = kind, CooldownEndServerTime = 0d };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- INetworkSerializable ---------------------------------------
|
||||||
|
|
||||||
|
public void NetworkSerialize<T>(BufferSerializer<T> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Scripts/Gameplay/SpellSlot.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/SpellSlot.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9c8fdabdb3c89204ab28f9e031678db4
|
||||||
|
|
@ -8,6 +8,7 @@ using UnityEngine.SceneManagement;
|
||||||
using UnityEngine.UIElements;
|
using UnityEngine.UIElements;
|
||||||
using TD.Core;
|
using TD.Core;
|
||||||
using TD.Gameplay;
|
using TD.Gameplay;
|
||||||
|
using TD.Gameplay.BuilderSpells;
|
||||||
using TD.Gameplay.Draft;
|
using TD.Gameplay.Draft;
|
||||||
using TD.Towers;
|
using TD.Towers;
|
||||||
using TD.UI.Minimap;
|
using TD.UI.Minimap;
|
||||||
|
|
@ -115,6 +116,27 @@ namespace TD.UI
|
||||||
private bool draftSubscribed;
|
private bool draftSubscribed;
|
||||||
private PlayerDraft subscribedDraft;
|
private PlayerDraft subscribedDraft;
|
||||||
|
|
||||||
|
// Spell hotbar (bottom-ui Section 6). Frame is display:none while the local player
|
||||||
|
// 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<SpellSlotUi> spellSlotUis = new List<SpellSlotUi>();
|
||||||
|
|
||||||
|
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
|
// Chat panel (bottom-left, above portrait) — programmatic. The container
|
||||||
// holds both the scrollable feed and the input. Highlight + scroll
|
// holds both the scrollable feed and the input. Highlight + scroll
|
||||||
// interactivity are toggled on the container when typing.
|
// interactivity are toggled on the container when typing.
|
||||||
|
|
@ -294,6 +316,23 @@ namespace TD.UI
|
||||||
RebuildDraftCards();
|
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 ----------------------------------------------
|
// ----- Draft overlay ----------------------------------------------
|
||||||
|
|
||||||
// Non-modal draft UI: a card row floats at top-center while a draft is active, and
|
// 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;
|
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<MouseEnterEvent>(_ => ShowSpellTooltip(definition));
|
||||||
|
cell.RegisterCallback<MouseLeaveEvent>(_ => 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()
|
private void InitializeUI()
|
||||||
{
|
{
|
||||||
var doc = GetComponent<UIDocument>();
|
var doc = GetComponent<UIDocument>();
|
||||||
|
|
@ -481,6 +617,8 @@ namespace TD.UI
|
||||||
ttStats = Require<Label>(root, "tt-stats");
|
ttStats = Require<Label>(root, "tt-stats");
|
||||||
ttCost = Require<Label>(root, "tt-cost");
|
ttCost = Require<Label>(root, "tt-cost");
|
||||||
rejectionLabel = Require<Label>(root, "rejection-label");
|
rejectionLabel = Require<Label>(root, "rejection-label");
|
||||||
|
spellHotbarFrame = Require<VisualElement>(root, "spell-hotbar-frame");
|
||||||
|
spellHotbar = Require<VisualElement>(root, "spell-hotbar");
|
||||||
|
|
||||||
// Map area and its transparent ancestors must not consume pointer
|
// Map area and its transparent ancestors must not consume pointer
|
||||||
// events so clicks reach the 3D scene underneath. The bottom-ui is now
|
// events so clicks reach the 3D scene underneath. The bottom-ui is now
|
||||||
|
|
@ -581,6 +719,13 @@ namespace TD.UI
|
||||||
}
|
}
|
||||||
draftSubscribed = false;
|
draftSubscribed = false;
|
||||||
subscribedDraft = null;
|
subscribedDraft = null;
|
||||||
|
|
||||||
|
if (spellLoadoutSubscribed && subscribedSpellLoadout != null)
|
||||||
|
{
|
||||||
|
subscribedSpellLoadout.OnLoadoutChanged -= HandleSpellLoadoutChanged;
|
||||||
|
}
|
||||||
|
spellLoadoutSubscribed = false;
|
||||||
|
subscribedSpellLoadout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TrySubscribeSelection()
|
private void TrySubscribeSelection()
|
||||||
|
|
@ -607,11 +752,15 @@ namespace TD.UI
|
||||||
if (!draftSubscribed)
|
if (!draftSubscribed)
|
||||||
TrySubscribeDraft();
|
TrySubscribeDraft();
|
||||||
|
|
||||||
|
if (!spellLoadoutSubscribed)
|
||||||
|
TrySubscribeSpellLoadout();
|
||||||
|
|
||||||
RefreshGoldDisplay();
|
RefreshGoldDisplay();
|
||||||
RefreshMatchStateDisplays();
|
RefreshMatchStateDisplays();
|
||||||
UpdateBuildProgressIfShown();
|
UpdateBuildProgressIfShown();
|
||||||
UpdateEnemyInfoIfShown();
|
UpdateEnemyInfoIfShown();
|
||||||
UpdateDraftVisibility();
|
UpdateDraftVisibility();
|
||||||
|
UpdateSpellCooldowns();
|
||||||
HandleChatInput();
|
HandleChatInput();
|
||||||
|
|
||||||
// Skip gameplay hotkeys while the chat input is focused — letters
|
// Skip gameplay hotkeys while the chat input is focused — letters
|
||||||
|
|
|
||||||
|
|
@ -477,3 +477,77 @@
|
||||||
padding: 0 3px;
|
padding: 0 3px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----- Section 6: Spell Hotbar (short, hidden until the player has spells) ----- */
|
||||||
|
.spell-hotbar-frame {
|
||||||
|
width: 220px;
|
||||||
|
height: 165px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background-color: rgb(15, 15, 15);
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: rgb(80, 65, 35);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spell-hotbar {
|
||||||
|
flex-direction: row;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One granted spell. Reuses the command-grid icon/badge look (embossed grey
|
||||||
|
placeholder + top-left hotkey badge) so spells read as the same visual
|
||||||
|
language as tower/build buttons. */
|
||||||
|
.spell-slot {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spell-slot-icon {
|
||||||
|
flex: 1;
|
||||||
|
background-color: rgb(140, 140, 140);
|
||||||
|
border-top-width: 2px;
|
||||||
|
border-left-width: 2px;
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-right-width: 2px;
|
||||||
|
border-top-color: rgb(220, 220, 220);
|
||||||
|
border-left-color: rgb(220, 220, 220);
|
||||||
|
border-bottom-color: rgb(70, 70, 70);
|
||||||
|
border-right-color: rgb(70, 70, 70);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spell-slot-hotkey {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgb(255, 255, 255);
|
||||||
|
-unity-font-style: bold;
|
||||||
|
background-color: rgba(0, 0, 0, 0.55);
|
||||||
|
padding: 0 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remaining-cooldown seconds, bottom-left — same slot as .cmd-cost. Empty
|
||||||
|
string (no text) while the spell is ready. */
|
||||||
|
.spell-slot-cooldown-label {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
left: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgb(255, 224, 102);
|
||||||
|
-unity-font-style: bold;
|
||||||
|
background-color: rgba(0, 0, 0, 0.55);
|
||||||
|
padding: 0 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Whole-slot dim while on cooldown — same opacity convention as .cmd-btn.unaffordable. */
|
||||||
|
.spell-slot.on-cooldown {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,14 @@
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
<ui:VisualElement name="command-grid" class="command-grid"/>
|
<ui:VisualElement name="command-grid" class="command-grid"/>
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Section 6: Spell Hotbar (short frame) — builder spells bound to fixed
|
||||||
|
hotkeys (grant order = slot order). Hidden entirely until the player has
|
||||||
|
been granted at least one spell. -->
|
||||||
|
<ui:VisualElement name="spell-hotbar-frame" class="spell-hotbar-frame" style="display: none;">
|
||||||
|
<ui:Label text="spells" class="section-label"/>
|
||||||
|
<ui:VisualElement name="spell-hotbar" class="spell-hotbar"/>
|
||||||
|
</ui:VisualElement>
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
</ui:UXML>
|
</ui:UXML>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue