We've got enemies and movement!!
This commit is contained in:
parent
42ee0bf65d
commit
3287e8ea43
26 changed files with 1409 additions and 161 deletions
|
|
@ -2,7 +2,8 @@
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(Get-ChildItem -Path \"C:\\\\Users\\\\catos\\\\UnityTowerDefense\\\\Assets\\\\Scripts\" -Recurse -File -Filter \"*.cs\")",
|
"Bash(Get-ChildItem -Path \"C:\\\\Users\\\\catos\\\\UnityTowerDefense\\\\Assets\\\\Scripts\" -Recurse -File -Filter \"*.cs\")",
|
||||||
"Bash(Select-Object -ExpandProperty FullName)"
|
"Bash(Select-Object -ExpandProperty FullName)",
|
||||||
|
"Bash(powershell -Command \"Get-ChildItem -Recurse -Path 'C:\\\\Users\\\\catos\\\\UnityTowerDefense\\\\Assets\\\\_Project\\\\Scripts' -Filter '*.cs' | Select-String -Pattern 'class LevelData' | Select-Object -First 5\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
Assets/_Project/Data/EnemyDefinitions.meta
Normal file
8
Assets/_Project/Data/EnemyDefinitions.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 78e277155464e3f4da5f2de1acdc178a
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
21
Assets/_Project/Data/EnemyDefinitions/EnemyDefinition.asset
Normal file
21
Assets/_Project/Data/EnemyDefinitions/EnemyDefinition.asset
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
%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: c0d2521d49d21fe4380434f5951944d1, type: 3}
|
||||||
|
m_Name: EnemyDefinition
|
||||||
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyDefinition
|
||||||
|
DisplayName: Basic Bitch
|
||||||
|
MaxHp: 100
|
||||||
|
MoveSpeed: 3
|
||||||
|
IsFlying: 0
|
||||||
|
GoldReward: 10
|
||||||
|
LivesCost: 1
|
||||||
|
EnemyPrefab: {fileID: 1455822126534880203, guid: 0854f339a1958d343a6cb16cd3f907ff, type: 3}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4e85a539eac1ed64cbd972db4914ca3d
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Assets/_Project/Data/WaveDefinitions.meta
Normal file
8
Assets/_Project/Data/WaveDefinitions.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e7fd2a054c9dc4c4e96bb1ac4877eda3
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
19
Assets/_Project/Data/WaveDefinitions/Wave1Definition.asset
Normal file
19
Assets/_Project/Data/WaveDefinitions/Wave1Definition.asset
Normal file
|
|
@ -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: 48e93688dc0fb5b4cbe7be9a241b4421, type: 3}
|
||||||
|
m_Name: Wave1Definition
|
||||||
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.WaveDefinition
|
||||||
|
PrepTime: 10
|
||||||
|
Entries:
|
||||||
|
- EnemyType: {fileID: 11400000, guid: 4e85a539eac1ed64cbd972db4914ca3d, type: 2}
|
||||||
|
Count: 10
|
||||||
|
SpawnInterval: 0.5
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 65f66289ea1233b4897f46cd997d9c7a
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -16,6 +16,7 @@ GameObject:
|
||||||
- component: {fileID: 5830540397649648793}
|
- component: {fileID: 5830540397649648793}
|
||||||
- component: {fileID: 2892684246239657319}
|
- component: {fileID: 2892684246239657319}
|
||||||
- component: {fileID: 8213527798879671990}
|
- component: {fileID: 8213527798879671990}
|
||||||
|
- component: {fileID: 3283904430289710888}
|
||||||
m_Layer: 10
|
m_Layer: 10
|
||||||
m_Name: EnemyPlaceholder
|
m_Name: EnemyPlaceholder
|
||||||
m_TagString: Untagged
|
m_TagString: Untagged
|
||||||
|
|
@ -128,7 +129,7 @@ MonoBehaviour:
|
||||||
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
|
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
|
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
|
||||||
GlobalObjectIdHash: 4022812445
|
GlobalObjectIdHash: 1257430264
|
||||||
InScenePlacedSourceGlobalObjectIdHash: 0
|
InScenePlacedSourceGlobalObjectIdHash: 0
|
||||||
DeferredDespawnTick: 0
|
DeferredDespawnTick: 0
|
||||||
Ownership: 1
|
Ownership: 1
|
||||||
|
|
@ -200,7 +201,6 @@ MonoBehaviour:
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
|
||||||
ShowTopMostFoldoutHeaderGroup: 1
|
ShowTopMostFoldoutHeaderGroup: 1
|
||||||
maxHp: 100
|
|
||||||
--- !u!114 &8213527798879671990
|
--- !u!114 &8213527798879671990
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -214,3 +214,16 @@ MonoBehaviour:
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyStatus
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyStatus
|
||||||
ShowTopMostFoldoutHeaderGroup: 1
|
ShowTopMostFoldoutHeaderGroup: 1
|
||||||
|
--- !u!114 &3283904430289710888
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 1455822126534880203}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: fd6c02bbcc13fb14a9d596d9a2544dcc, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyMovement
|
||||||
|
ShowTopMostFoldoutHeaderGroup: 1
|
||||||
|
|
|
||||||
|
|
@ -739,11 +739,11 @@ Transform:
|
||||||
m_GameObject: {fileID: 611926972}
|
m_GameObject: {fileID: 611926972}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_LocalRotation: {x: -0, y: 0.70710576, z: -0, w: 0.70710784}
|
m_LocalRotation: {x: -0, y: 0.70710576, z: -0, w: 0.70710784}
|
||||||
m_LocalPosition: {x: 43, y: 2, z: 41}
|
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||||
m_LocalScale: {x: 100, y: 5, z: 20}
|
m_LocalScale: {x: 100, y: 5, z: 20}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children: []
|
m_Children: []
|
||||||
m_Father: {fileID: 0}
|
m_Father: {fileID: 1994440963}
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
|
||||||
--- !u!1 &643505902
|
--- !u!1 &643505902
|
||||||
GameObject:
|
GameObject:
|
||||||
|
|
@ -851,11 +851,11 @@ Transform:
|
||||||
m_GameObject: {fileID: 643505902}
|
m_GameObject: {fileID: 643505902}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_LocalRotation: {x: -0, y: 0.70710576, z: -0, w: 0.70710784}
|
m_LocalRotation: {x: -0, y: 0.70710576, z: -0, w: 0.70710784}
|
||||||
m_LocalPosition: {x: 8, y: 2, z: 42}
|
m_LocalPosition: {x: -35, y: 0, z: 1}
|
||||||
m_LocalScale: {x: 8, y: 5, z: 12.6126}
|
m_LocalScale: {x: 8, y: 5, z: 12.6126}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children: []
|
m_Children: []
|
||||||
m_Father: {fileID: 0}
|
m_Father: {fileID: 1994440963}
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
|
||||||
--- !u!1 &832575517
|
--- !u!1 &832575517
|
||||||
GameObject:
|
GameObject:
|
||||||
|
|
@ -1255,6 +1255,50 @@ BoxCollider:
|
||||||
serializedVersion: 3
|
serializedVersion: 3
|
||||||
m_Size: {x: 7, y: 1, z: 7}
|
m_Size: {x: 7, y: 1, z: 7}
|
||||||
m_Center: {x: 0, y: 0, z: 0}
|
m_Center: {x: 0, y: 0, z: 0}
|
||||||
|
--- !u!1 &1149980839
|
||||||
|
GameObject:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
serializedVersion: 6
|
||||||
|
m_Component:
|
||||||
|
- component: {fileID: 1149980841}
|
||||||
|
- component: {fileID: 1149980840}
|
||||||
|
m_Layer: 0
|
||||||
|
m_Name: PathfindingService
|
||||||
|
m_TagString: Untagged
|
||||||
|
m_Icon: {fileID: 0}
|
||||||
|
m_NavMeshLayer: 0
|
||||||
|
m_StaticEditorFlags: 0
|
||||||
|
m_IsActive: 1
|
||||||
|
--- !u!114 &1149980840
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 1149980839}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: de7d013503af0f74c950f215f8dae1c0, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PathfindingService
|
||||||
|
--- !u!4 &1149980841
|
||||||
|
Transform:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 1149980839}
|
||||||
|
serializedVersion: 2
|
||||||
|
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||||
|
m_LocalPosition: {x: 15.54693, y: 0.5, z: 44.19135}
|
||||||
|
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 &1168515844
|
--- !u!1 &1168515844
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -1473,6 +1517,80 @@ BoxCollider:
|
||||||
serializedVersion: 3
|
serializedVersion: 3
|
||||||
m_Size: {x: 7, y: 1, z: 4}
|
m_Size: {x: 7, y: 1, z: 4}
|
||||||
m_Center: {x: 0, y: 0, z: 1.5}
|
m_Center: {x: 0, y: 0, z: 1.5}
|
||||||
|
--- !u!1 &1380211460
|
||||||
|
GameObject:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
serializedVersion: 6
|
||||||
|
m_Component:
|
||||||
|
- component: {fileID: 1380211462}
|
||||||
|
- component: {fileID: 1380211461}
|
||||||
|
- component: {fileID: 1380211463}
|
||||||
|
m_Layer: 0
|
||||||
|
m_Name: WaveManager
|
||||||
|
m_TagString: Untagged
|
||||||
|
m_Icon: {fileID: 0}
|
||||||
|
m_NavMeshLayer: 0
|
||||||
|
m_StaticEditorFlags: 0
|
||||||
|
m_IsActive: 1
|
||||||
|
--- !u!114 &1380211461
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 1380211460}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
|
||||||
|
GlobalObjectIdHash: 3675697031
|
||||||
|
InScenePlacedSourceGlobalObjectIdHash: 0
|
||||||
|
DeferredDespawnTick: 0
|
||||||
|
Ownership: 1
|
||||||
|
AlwaysReplicateAsRoot: 0
|
||||||
|
SynchronizeTransform: 1
|
||||||
|
ActiveSceneSynchronization: 0
|
||||||
|
SceneMigrationSynchronization: 0
|
||||||
|
SpawnWithObservers: 1
|
||||||
|
DontDestroyWithOwner: 0
|
||||||
|
AutoObjectParentSync: 1
|
||||||
|
SyncOwnerTransformWhenParented: 1
|
||||||
|
AllowOwnerToParent: 0
|
||||||
|
--- !u!4 &1380211462
|
||||||
|
Transform:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 1380211460}
|
||||||
|
serializedVersion: 2
|
||||||
|
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||||
|
m_LocalPosition: {x: 15.54693, y: 0.5, z: 44.19135}
|
||||||
|
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!114 &1380211463
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 1380211460}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: 81d8e215d8419404ea4d959196cd9cc3, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.WaveManager
|
||||||
|
ShowTopMostFoldoutHeaderGroup: 1
|
||||||
|
waveDefinitions:
|
||||||
|
- {fileID: 11400000, guid: 65f66289ea1233b4897f46cd997d9c7a, type: 2}
|
||||||
|
startingLives: 20
|
||||||
--- !u!1 &1464027360
|
--- !u!1 &1464027360
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -1579,12 +1697,12 @@ Transform:
|
||||||
m_PrefabAsset: {fileID: 0}
|
m_PrefabAsset: {fileID: 0}
|
||||||
m_GameObject: {fileID: 1464027360}
|
m_GameObject: {fileID: 1464027360}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_LocalRotation: {x: 0, y: 0.7071068, z: 0, w: 0.7071068}
|
m_LocalRotation: {x: -0, y: 0.7071068, z: -0, w: 0.7071068}
|
||||||
m_LocalPosition: {x: 14, y: 0, z: 41}
|
m_LocalPosition: {x: -29, y: -2, z: 0}
|
||||||
m_LocalScale: {x: 10, y: 1, z: 5}
|
m_LocalScale: {x: 10, y: 1, z: 5}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children: []
|
m_Children: []
|
||||||
m_Father: {fileID: 0}
|
m_Father: {fileID: 1994440963}
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
|
||||||
--- !u!1 &1507514106
|
--- !u!1 &1507514106
|
||||||
GameObject:
|
GameObject:
|
||||||
|
|
@ -1705,6 +1823,8 @@ MonoBehaviour:
|
||||||
m_Script: {fileID: 11500000, guid: a9dc0fbbe4422bc479ab8db7658c082b, type: 3}
|
m_Script: {fileID: 11500000, guid: a9dc0fbbe4422bc479ab8db7658c082b, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.TowerRegistry
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.TowerRegistry
|
||||||
|
definitions:
|
||||||
|
- {fileID: 11400000, guid: 0f693e29ca953e1439e10cb8f12e4b30, type: 2}
|
||||||
--- !u!1 &1597884408
|
--- !u!1 &1597884408
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -1966,11 +2086,11 @@ Transform:
|
||||||
m_GameObject: {fileID: 1949204941}
|
m_GameObject: {fileID: 1949204941}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_LocalRotation: {x: -0, y: 0.70710576, z: -0, w: 0.70710784}
|
m_LocalRotation: {x: -0, y: 0.70710576, z: -0, w: 0.70710784}
|
||||||
m_LocalPosition: {x: -7, y: 2, z: 41}
|
m_LocalPosition: {x: -50, y: 0, z: 0}
|
||||||
m_LocalScale: {x: 100, y: 5, z: 20}
|
m_LocalScale: {x: 100, y: 5, z: 20}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children: []
|
m_Children: []
|
||||||
m_Father: {fileID: 0}
|
m_Father: {fileID: 1994440963}
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
|
||||||
--- !u!1 &1975687919
|
--- !u!1 &1975687919
|
||||||
GameObject:
|
GameObject:
|
||||||
|
|
@ -2040,6 +2160,43 @@ BoxCollider:
|
||||||
serializedVersion: 3
|
serializedVersion: 3
|
||||||
m_Size: {x: 29, y: 1, z: 34}
|
m_Size: {x: 29, y: 1, z: 34}
|
||||||
m_Center: {x: -10.5, y: 0, z: -13.5}
|
m_Center: {x: -10.5, y: 0, z: -13.5}
|
||||||
|
--- !u!1 &1994440962
|
||||||
|
GameObject:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
serializedVersion: 6
|
||||||
|
m_Component:
|
||||||
|
- component: {fileID: 1994440963}
|
||||||
|
m_Layer: 0
|
||||||
|
m_Name: Geometry
|
||||||
|
m_TagString: Untagged
|
||||||
|
m_Icon: {fileID: 0}
|
||||||
|
m_NavMeshLayer: 0
|
||||||
|
m_StaticEditorFlags: 0
|
||||||
|
m_IsActive: 1
|
||||||
|
--- !u!4 &1994440963
|
||||||
|
Transform:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 1994440962}
|
||||||
|
serializedVersion: 2
|
||||||
|
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||||
|
m_LocalPosition: {x: 43, y: 2, z: 41}
|
||||||
|
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||||
|
m_ConstrainProportionsScale: 0
|
||||||
|
m_Children:
|
||||||
|
- {fileID: 2024858689}
|
||||||
|
- {fileID: 1949204945}
|
||||||
|
- {fileID: 643505906}
|
||||||
|
- {fileID: 2105067738}
|
||||||
|
- {fileID: 611926976}
|
||||||
|
- {fileID: 1464027364}
|
||||||
|
m_Father: {fileID: 0}
|
||||||
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
--- !u!1 &2024858685
|
--- !u!1 &2024858685
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -2145,12 +2302,12 @@ Transform:
|
||||||
m_PrefabAsset: {fileID: 0}
|
m_PrefabAsset: {fileID: 0}
|
||||||
m_GameObject: {fileID: 2024858685}
|
m_GameObject: {fileID: 2024858685}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
|
||||||
m_LocalPosition: {x: 18, y: 2, z: 90}
|
m_LocalPosition: {x: -25, y: 0, z: 49}
|
||||||
m_LocalScale: {x: 50, y: 5, z: 5}
|
m_LocalScale: {x: 50, y: 5, z: 5}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children: []
|
m_Children: []
|
||||||
m_Father: {fileID: 0}
|
m_Father: {fileID: 1994440963}
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
--- !u!1 &2105067734
|
--- !u!1 &2105067734
|
||||||
GameObject:
|
GameObject:
|
||||||
|
|
@ -2258,11 +2415,11 @@ Transform:
|
||||||
m_GameObject: {fileID: 2105067734}
|
m_GameObject: {fileID: 2105067734}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_LocalRotation: {x: -0, y: 0.70710576, z: -0, w: 0.70710784}
|
m_LocalRotation: {x: -0, y: 0.70710576, z: -0, w: 0.70710784}
|
||||||
m_LocalPosition: {x: 28, y: 2, z: 42}
|
m_LocalPosition: {x: -15, y: 0, z: 1}
|
||||||
m_LocalScale: {x: 8, y: 5, z: 12.6126}
|
m_LocalScale: {x: 8, y: 5, z: 12.6126}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children: []
|
m_Children: []
|
||||||
m_Father: {fileID: 0}
|
m_Father: {fileID: 1994440963}
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
|
||||||
--- !u!1660057539 &9223372036854775807
|
--- !u!1660057539 &9223372036854775807
|
||||||
SceneRoots:
|
SceneRoots:
|
||||||
|
|
@ -2272,18 +2429,15 @@ SceneRoots:
|
||||||
- {fileID: 832575519}
|
- {fileID: 832575519}
|
||||||
- {fileID: 1682341402}
|
- {fileID: 1682341402}
|
||||||
- {fileID: 441239881}
|
- {fileID: 441239881}
|
||||||
- {fileID: 1464027364}
|
|
||||||
- {fileID: 167151709}
|
- {fileID: 167151709}
|
||||||
- {fileID: 1507514109}
|
- {fileID: 1507514109}
|
||||||
- {fileID: 1538763654}
|
- {fileID: 1538763654}
|
||||||
- {fileID: 1597884409}
|
- {fileID: 1597884409}
|
||||||
- {fileID: 1239994224}
|
- {fileID: 1239994224}
|
||||||
- {fileID: 2024858689}
|
- {fileID: 1994440963}
|
||||||
- {fileID: 1949204945}
|
|
||||||
- {fileID: 643505906}
|
|
||||||
- {fileID: 2105067738}
|
|
||||||
- {fileID: 611926976}
|
|
||||||
- {fileID: 1222526238}
|
- {fileID: 1222526238}
|
||||||
- {fileID: 1058315976}
|
- {fileID: 1058315976}
|
||||||
- {fileID: 1168515846}
|
- {fileID: 1168515846}
|
||||||
- {fileID: 902199262}
|
- {fileID: 902199262}
|
||||||
|
- {fileID: 1380211462}
|
||||||
|
- {fileID: 1149980841}
|
||||||
|
|
|
||||||
|
|
@ -12,35 +12,28 @@ namespace TD.Combat
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <b>Authority:</b> Movement and hit detection run server-only.
|
/// <b>Authority:</b> Movement and hit detection run server-only.
|
||||||
/// <c>NetworkTransform</c> (required on the prefab) replicates the position to
|
/// <c>NetworkTransform</c> (required on the prefab) replicates position to clients
|
||||||
/// clients so the projectile is visible on all peers.
|
/// so the projectile is visible on all peers.
|
||||||
///
|
///
|
||||||
/// <b>Initialization:</b> Mirrors the <c>TowerInstance.InitializeServer</c> pattern —
|
/// <b>Initialization:</b> Call <see cref="InitializeServer"/> after
|
||||||
/// <see cref="InitializeServer"/> is called by <c>TowerCombat</c> immediately after
|
/// <c>Instantiate</c> and before <c>NetworkObject.Spawn()</c>.
|
||||||
/// <c>Instantiate</c> and before <c>NetworkObject.Spawn()</c>, which avoids writing
|
|
||||||
/// to NetworkVariables before spawn.
|
|
||||||
///
|
///
|
||||||
/// <b>Target loss:</b> If the target dies or is destroyed before the projectile
|
/// <b>Kill attribution:</b> <paramref name="sourceOwner"/> is the
|
||||||
/// arrives, the projectile despawns silently (no hit, no damage).
|
/// <see cref="PlayerSlot"/> of the firing tower. It is passed to
|
||||||
|
/// <see cref="EnemyHealth.TakeDamage"/> and <see cref="EnemyStatus.ApplyEffect"/>
|
||||||
|
/// so kill gold credits the correct player even for projectile kills.
|
||||||
///
|
///
|
||||||
/// <b>Chain + Projectile:</b> By design, TargetType.Chain is hitscan. If a designer
|
/// <b>Chain + Projectile:</b> <see cref="TargetType.Chain"/> is hitscan-only.
|
||||||
/// sets TargetType = Chain on a tower that has a ProjectilePrefab, the projectile
|
/// A projectile with Chain type will hit the primary target only and log a warning.
|
||||||
/// will hit the primary target only and ignore the chain. Log a warning to surface
|
|
||||||
/// the misconfiguration.
|
|
||||||
///
|
///
|
||||||
/// <b>Prefab requirements:</b> Must have <c>NetworkObject</c>, <c>NetworkTransform</c>,
|
/// <b>Prefab requirements:</b> <c>NetworkObject</c>, <c>NetworkTransform</c>,
|
||||||
/// and this <c>Projectile</c> component at the root.
|
/// and this component at the root.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RequireComponent(typeof(NetworkObject))]
|
[RequireComponent(typeof(NetworkObject))]
|
||||||
public class Projectile : NetworkBehaviour
|
public class Projectile : NetworkBehaviour
|
||||||
{
|
{
|
||||||
// Hit threshold: squared distance at which the projectile considers itself
|
private const float HitThresholdSq = 0.09f; // 0.3 world units
|
||||||
// to have reached the target. 0.09 = 0.3 world units; small enough to
|
|
||||||
// feel accurate, large enough to survive a high-speed frame where the
|
|
||||||
// projectile could skip past the target's transform in one step.
|
|
||||||
private const float HitThresholdSq = 0.09f;
|
|
||||||
|
|
||||||
// All fields are server-local. Set by InitializeServer before Spawn.
|
|
||||||
private EnemyHealth target;
|
private EnemyHealth target;
|
||||||
private float damage;
|
private float damage;
|
||||||
private DamageType damageType;
|
private DamageType damageType;
|
||||||
|
|
@ -51,18 +44,17 @@ namespace TD.Combat
|
||||||
private float effectDuration;
|
private float effectDuration;
|
||||||
private float speed;
|
private float speed;
|
||||||
private LayerMask enemyLayerMask;
|
private LayerMask enemyLayerMask;
|
||||||
|
private PlayerSlot sourceOwner;
|
||||||
private bool initialized;
|
private bool initialized;
|
||||||
|
|
||||||
// Shared with TowerCombat's overlap calls. Both components run on the
|
|
||||||
// server main thread so there is no concurrent access.
|
|
||||||
private static readonly Collider[] s_overlapBuffer = new Collider[32];
|
private static readonly Collider[] s_overlapBuffer = new Collider[32];
|
||||||
|
|
||||||
// ----- Initialization (server-only, called before Spawn) -----------
|
// ----- Pre-spawn init ---------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stores all data this projectile needs to travel and apply damage.
|
/// Called by <see cref="TowerCombat"/> after <c>Instantiate</c> and before
|
||||||
/// Call this immediately after <c>Instantiate</c> and before
|
/// <c>NetworkObject.Spawn()</c>. <paramref name="sourceOwner"/> is the firing
|
||||||
/// <c>NetworkObject.Spawn()</c>.
|
/// tower's <see cref="PlayerSlot"/> for kill-gold attribution.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void InitializeServer(
|
public void InitializeServer(
|
||||||
EnemyHealth target,
|
EnemyHealth target,
|
||||||
|
|
@ -74,7 +66,8 @@ namespace TD.Combat
|
||||||
float dotDamagePerSecond,
|
float dotDamagePerSecond,
|
||||||
float effectDuration,
|
float effectDuration,
|
||||||
float speed,
|
float speed,
|
||||||
LayerMask enemyLayerMask)
|
LayerMask enemyLayerMask,
|
||||||
|
PlayerSlot sourceOwner)
|
||||||
{
|
{
|
||||||
this.target = target;
|
this.target = target;
|
||||||
this.damage = damage;
|
this.damage = damage;
|
||||||
|
|
@ -86,24 +79,21 @@ namespace TD.Combat
|
||||||
this.effectDuration = effectDuration;
|
this.effectDuration = effectDuration;
|
||||||
this.speed = speed;
|
this.speed = speed;
|
||||||
this.enemyLayerMask = enemyLayerMask;
|
this.enemyLayerMask = enemyLayerMask;
|
||||||
|
this.sourceOwner = sourceOwner;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
|
||||||
if (targetType == TargetType.Chain)
|
if (targetType == TargetType.Chain)
|
||||||
Debug.LogWarning("[Projectile] TargetType.Chain is hitscan-only. " +
|
Debug.LogWarning("[Projectile] TargetType.Chain is hitscan-only. " +
|
||||||
"This projectile will hit the primary target only. " +
|
"This projectile will hit the primary target only.");
|
||||||
"Consider using hitscan (null ProjectilePrefab) for chain towers.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Server movement + hit detection -----------------------------
|
// ----- Server movement + hit detection ----------------------------
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
{
|
{
|
||||||
if (!IsServer || !initialized) return;
|
if (!IsServer || !initialized) return;
|
||||||
|
|
||||||
// Target gone — silently despawn, no damage applied.
|
if (target == null || target.IsDead || (target as UnityEngine.Object) == null)
|
||||||
if (target == null
|
|
||||||
|| target.IsDead
|
|
||||||
|| (target as UnityEngine.Object) == null)
|
|
||||||
{
|
{
|
||||||
NetworkObject.Despawn();
|
NetworkObject.Despawn();
|
||||||
return;
|
return;
|
||||||
|
|
@ -118,20 +108,18 @@ namespace TD.Combat
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rotate to face the target so the projectile mesh looks correct
|
|
||||||
// on all clients (NetworkTransform replicates both position and rotation).
|
|
||||||
transform.rotation = Quaternion.LookRotation(toTarget);
|
transform.rotation = Quaternion.LookRotation(toTarget);
|
||||||
transform.position += toTarget.normalized * (speed * Time.deltaTime);
|
transform.position += toTarget.normalized * (speed * Time.deltaTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Hit application ---------------------------------------------
|
// ----- Hit application --------------------------------------------
|
||||||
|
|
||||||
private void ApplyHit()
|
private void ApplyHit()
|
||||||
{
|
{
|
||||||
switch (targetType)
|
switch (targetType)
|
||||||
{
|
{
|
||||||
case TargetType.Single:
|
case TargetType.Single:
|
||||||
case TargetType.Chain: // chain falls back to single-target on projectiles
|
case TargetType.Chain:
|
||||||
HitEnemy(target);
|
HitEnemy(target);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -156,7 +144,7 @@ namespace TD.Combat
|
||||||
|
|
||||||
private void HitEnemy(EnemyHealth eh)
|
private void HitEnemy(EnemyHealth eh)
|
||||||
{
|
{
|
||||||
eh.TakeDamage(damage, damageType);
|
eh.TakeDamage(damage, damageType, sourceOwner);
|
||||||
|
|
||||||
if (effectDuration > 0f)
|
if (effectDuration > 0f)
|
||||||
{
|
{
|
||||||
|
|
@ -170,7 +158,7 @@ namespace TD.Combat
|
||||||
|
|
||||||
if (magnitude > 0f)
|
if (magnitude > 0f)
|
||||||
eh.GetComponent<EnemyStatus>()
|
eh.GetComponent<EnemyStatus>()
|
||||||
?.ApplyEffect(damageType, magnitude, effectDuration);
|
?.ApplyEffect(damageType, magnitude, effectDuration, sourceOwner);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -249,24 +249,27 @@ namespace TD.Combat
|
||||||
|
|
||||||
private void ApplyDamageToTarget(TowerDefinition def, EnemyHealth primary, Vector3 primaryPos)
|
private void ApplyDamageToTarget(TowerDefinition def, EnemyHealth primary, Vector3 primaryPos)
|
||||||
{
|
{
|
||||||
|
PlayerSlot owner = towerInstance.Owner;
|
||||||
|
|
||||||
switch (def.TargetType)
|
switch (def.TargetType)
|
||||||
{
|
{
|
||||||
case TargetType.Single:
|
case TargetType.Single:
|
||||||
HitEnemy(def, primary);
|
HitEnemy(def, primary, owner);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TargetType.Splash:
|
case TargetType.Splash:
|
||||||
HitEnemy(def, primary);
|
HitEnemy(def, primary, owner);
|
||||||
ApplySplash(def, primary, primaryPos);
|
ApplySplash(def, primary, primaryPos, owner);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TargetType.Chain:
|
case TargetType.Chain:
|
||||||
ApplyChain(def, primary);
|
ApplyChain(def, primary, owner);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplySplash(TowerDefinition def, EnemyHealth primary, Vector3 origin)
|
private void ApplySplash(TowerDefinition def, EnemyHealth primary,
|
||||||
|
Vector3 origin, PlayerSlot owner)
|
||||||
{
|
{
|
||||||
if (def.SplashRadius <= 0f) return;
|
if (def.SplashRadius <= 0f) return;
|
||||||
|
|
||||||
|
|
@ -277,19 +280,16 @@ namespace TD.Combat
|
||||||
{
|
{
|
||||||
var eh = s_overlapBuffer[i].GetComponent<EnemyHealth>();
|
var eh = s_overlapBuffer[i].GetComponent<EnemyHealth>();
|
||||||
if (eh == null || eh.IsDead || (object)eh == (object)primary) continue;
|
if (eh == null || eh.IsDead || (object)eh == (object)primary) continue;
|
||||||
HitEnemy(def, eh);
|
HitEnemy(def, eh, owner);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyChain(TowerDefinition def, EnemyHealth primary)
|
private void ApplyChain(TowerDefinition def, EnemyHealth primary, PlayerSlot owner)
|
||||||
{
|
{
|
||||||
// Chain hit positions are collected and sent to clients for the
|
|
||||||
// future lightning-arc visual. The list is per-fire, so allocation
|
|
||||||
// here is acceptable; optimise to a pool if chain towers become hot.
|
|
||||||
var hitPositions = new List<Vector3> { primary.transform.position };
|
var hitPositions = new List<Vector3> { primary.transform.position };
|
||||||
var alreadyHit = new HashSet<EnemyHealth> { primary };
|
var alreadyHit = new HashSet<EnemyHealth> { primary };
|
||||||
|
|
||||||
HitEnemy(def, primary);
|
HitEnemy(def, primary, owner);
|
||||||
|
|
||||||
EnemyHealth current = primary;
|
EnemyHealth current = primary;
|
||||||
for (int jump = 0; jump < def.ChainCount; jump++)
|
for (int jump = 0; jump < def.ChainCount; jump++)
|
||||||
|
|
@ -313,20 +313,20 @@ namespace TD.Combat
|
||||||
|
|
||||||
alreadyHit.Add(next);
|
alreadyHit.Add(next);
|
||||||
hitPositions.Add(next.transform.position);
|
hitPositions.Add(next.transform.position);
|
||||||
HitEnemy(def, next);
|
HitEnemy(def, next, owner);
|
||||||
current = next;
|
current = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
ChainFiredClientRpc(hitPositions.ToArray());
|
ChainFiredClientRpc(hitPositions.ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HitEnemy(TowerDefinition def, EnemyHealth target)
|
private void HitEnemy(TowerDefinition def, EnemyHealth target, PlayerSlot owner)
|
||||||
{
|
{
|
||||||
target.TakeDamage(def.Damage, def.DamageType);
|
target.TakeDamage(def.Damage, def.DamageType, owner);
|
||||||
ApplyStatusEffect(def, target);
|
ApplyStatusEffect(def, target, owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyStatusEffect(TowerDefinition def, EnemyHealth target)
|
private void ApplyStatusEffect(TowerDefinition def, EnemyHealth target, PlayerSlot owner)
|
||||||
{
|
{
|
||||||
if (def.EffectDuration <= 0f) return;
|
if (def.EffectDuration <= 0f) return;
|
||||||
|
|
||||||
|
|
@ -341,7 +341,7 @@ namespace TD.Combat
|
||||||
if (magnitude <= 0f) return;
|
if (magnitude <= 0f) return;
|
||||||
|
|
||||||
target.GetComponent<EnemyStatus>()
|
target.GetComponent<EnemyStatus>()
|
||||||
?.ApplyEffect(def.DamageType, magnitude, def.EffectDuration);
|
?.ApplyEffect(def.DamageType, magnitude, def.EffectDuration, owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Projectile spawning -----------------------------------------
|
// ----- Projectile spawning -----------------------------------------
|
||||||
|
|
@ -370,7 +370,8 @@ namespace TD.Combat
|
||||||
def.DotDamagePerSecond,
|
def.DotDamagePerSecond,
|
||||||
def.EffectDuration,
|
def.EffectDuration,
|
||||||
def.ProjectileSpeed,
|
def.ProjectileSpeed,
|
||||||
enemyLayerMask);
|
enemyLayerMask,
|
||||||
|
towerInstance.Owner);
|
||||||
|
|
||||||
go.GetComponent<NetworkObject>().Spawn();
|
go.GetComponent<NetworkObject>().Spawn();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
50
Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs
Normal file
50
Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
// Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace TD.Gameplay
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Data definition for a single enemy type. One asset per type; shared across all
|
||||||
|
/// instances spawned in a match. Consumed by <see cref="WaveManager"/> at spawn time.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Follows the same ScriptableObject pattern as <c>TowerDefinition</c>: data lives
|
||||||
|
/// in project assets, only the asset reference (or its fields) crosses runtime code.
|
||||||
|
/// Replace <see cref="EnemyPrefab"/> with a real mesh/animator when art is ready —
|
||||||
|
/// no code changes required.
|
||||||
|
/// </remarks>
|
||||||
|
[CreateAssetMenu(fileName = "EnemyDefinition", menuName = "TD/Enemy Definition", order = 3)]
|
||||||
|
public class EnemyDefinition : ScriptableObject
|
||||||
|
{
|
||||||
|
[Header("Identity")]
|
||||||
|
[Tooltip("Human-readable name shown in debug logs and future enemy-info UI.")]
|
||||||
|
public string DisplayName;
|
||||||
|
|
||||||
|
[Header("Stats")]
|
||||||
|
[Tooltip("Maximum hit points for this enemy type.")]
|
||||||
|
public float MaxHp = 100f;
|
||||||
|
|
||||||
|
[Tooltip("Movement speed in world units per second along the A* path.")]
|
||||||
|
public float MoveSpeed = 3f;
|
||||||
|
|
||||||
|
[Tooltip("When true this enemy flies over tower footprints. " +
|
||||||
|
"Towers with GroundedOnly=true will not target it. " +
|
||||||
|
"Flying enemies follow the same A* path but are not physically " +
|
||||||
|
"blocked by tower colliders (handled in EnemyMovement).")]
|
||||||
|
public bool IsFlying;
|
||||||
|
|
||||||
|
[Header("Rewards / Costs")]
|
||||||
|
[Tooltip("Gold awarded to the player whose tower lands the killing blow.")]
|
||||||
|
public int GoldReward = 10;
|
||||||
|
|
||||||
|
[Tooltip("Number of lives deducted from the shared pool when this enemy " +
|
||||||
|
"reaches the Goal. Boss enemies might cost 2 or more lives.")]
|
||||||
|
public int LivesCost = 1;
|
||||||
|
|
||||||
|
[Header("Visuals")]
|
||||||
|
[Tooltip("Prefab spawned in the world for this enemy. Must have NetworkObject, " +
|
||||||
|
"NetworkTransform, EnemyHealth, EnemyStatus, and EnemyMovement at its root. " +
|
||||||
|
"Place it on the Enemy physics layer.")]
|
||||||
|
public GameObject EnemyPrefab;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c0d2521d49d21fe4380434f5951944d1
|
||||||
|
|
@ -6,65 +6,129 @@ using TD.Core;
|
||||||
namespace TD.Gameplay
|
namespace TD.Gameplay
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-enemy HP component. Holds replicated HP and is the single point
|
/// Per-enemy HP component. Single point through which all damage flows so
|
||||||
/// through which all damage flows, so resistance lookups (Phase 1.5+) can
|
/// resistance lookups (Phase 1.5+) and kill attribution remain in one place.
|
||||||
/// be added in one place without touching every damage source.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Lives on the enemy prefab root alongside <see cref="EnemyStatus"/> and the
|
/// <b>Initialization:</b> Call <see cref="InitializeServer"/> on the server
|
||||||
/// future <c>EnemyMovement</c> component (Phase 1.5/1.6). HP is server-written
|
/// immediately after <c>Instantiate</c> and before <c>NetworkObject.Spawn()</c>,
|
||||||
/// and replicated to all clients so health bars can render on any peer.
|
/// following the same pattern as <c>TowerInstance.InitializeServer</c>.
|
||||||
///
|
///
|
||||||
/// <b>Death flow (server-only):</b>
|
/// <b>Kill attribution:</b> <see cref="LastHitOwner"/> tracks the
|
||||||
/// <c>TakeDamage</c> clamps HP to 0, fires <see cref="OnDied"/>, then calls
|
/// <see cref="PlayerSlot"/> of the tower that most recently dealt direct damage.
|
||||||
/// <c>NetworkObject.Despawn</c>. Subscribers must not touch the NetworkObject
|
/// DoT ticks from <c>EnemyStatus</c> also carry an owner so the credit
|
||||||
/// after <c>OnDied</c> returns.
|
/// follows the source tower, not the DoT applicator.
|
||||||
|
///
|
||||||
|
/// <b>Death flow (server-only):</b> <see cref="TakeDamage"/> clamps HP to 0,
|
||||||
|
/// fires <see cref="OnDied"/>, then calls <c>NetworkObject.Despawn</c>.
|
||||||
|
/// Subscribers must not touch the NetworkObject after <see cref="OnDied"/> returns.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RequireComponent(typeof(NetworkObject))]
|
[RequireComponent(typeof(NetworkObject))]
|
||||||
public class EnemyHealth : NetworkBehaviour
|
public class EnemyHealth : NetworkBehaviour
|
||||||
{
|
{
|
||||||
[SerializeField] private float maxHp = 100f;
|
// ----- Pre-spawn init (server-local) ----------------------------------
|
||||||
|
|
||||||
|
private float pendingMaxHp = 100f;
|
||||||
|
private int pendingGoldReward;
|
||||||
|
private int pendingLivesCost = 1;
|
||||||
|
private bool pendingIsFlying;
|
||||||
|
private bool hasPendingInit;
|
||||||
|
|
||||||
|
// ----- Server-local runtime state -------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Gold awarded to the killing player when this enemy dies.</summary>
|
||||||
|
public int GoldReward { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Lives deducted from the shared pool when this enemy reaches the goal.</summary>
|
||||||
|
public int LivesCost { get; private set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <see cref="PlayerSlot"/> of the tower that last dealt direct damage.
|
||||||
|
/// Used by <c>WaveManager</c> to award kill gold to the correct player.
|
||||||
|
/// Updated on every <see cref="TakeDamage"/> call, including DoT ticks whose
|
||||||
|
/// source owner is tracked on <see cref="EnemyStatus.StatusEffect"/>.
|
||||||
|
/// </summary>
|
||||||
|
public PlayerSlot LastHitOwner { get; private set; } = PlayerSlot.None;
|
||||||
|
|
||||||
|
// ----- Networked state ------------------------------------------------
|
||||||
|
|
||||||
private readonly NetworkVariable<float> hp = new NetworkVariable<float>(
|
private readonly NetworkVariable<float> hp = new NetworkVariable<float>(
|
||||||
0f,
|
0f,
|
||||||
NetworkVariableReadPermission.Everyone,
|
NetworkVariableReadPermission.Everyone,
|
||||||
NetworkVariableWritePermission.Server);
|
NetworkVariableWritePermission.Server);
|
||||||
|
|
||||||
// ----- Public state -----------------------------------------------
|
private readonly NetworkVariable<bool> isFlying = new NetworkVariable<bool>(
|
||||||
|
false,
|
||||||
|
NetworkVariableReadPermission.Everyone,
|
||||||
|
NetworkVariableWritePermission.Server);
|
||||||
|
|
||||||
|
// ----- Public state ---------------------------------------------------
|
||||||
|
|
||||||
public float CurrentHp => hp.Value;
|
public float CurrentHp => hp.Value;
|
||||||
public float MaxHp => maxHp;
|
public float MaxHp { get; private set; } = 100f;
|
||||||
public bool IsDead => hp.Value <= 0f;
|
public bool IsDead => hp.Value <= 0f;
|
||||||
|
|
||||||
// Stub: set by EnemyMovement or spawner in Phase 1.5/1.6.
|
/// <summary>
|
||||||
// TowerCombat reads this to honour the GroundedOnly tower flag.
|
/// True if this enemy flies over tower footprints.
|
||||||
public bool IsFlying => false;
|
/// Replicated so client visuals can adjust altitude.
|
||||||
|
/// Grounded towers with GroundedOnly=true will not target flying enemies.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsFlying => isFlying.Value;
|
||||||
|
|
||||||
// ----- Events -----------------------------------------------------
|
// ----- Events ---------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fired on the server immediately before the enemy NetworkObject is despawned.
|
/// Fired on the server immediately before the enemy NetworkObject is despawned.
|
||||||
/// <see cref="TD.Combat.TowerCombat"/> subscribes to clear its target reference.
|
/// <c>WaveManager</c> subscribes to credit kill gold and decrement wave count.
|
||||||
/// Do not access the NetworkObject after this event returns.
|
/// Do not access the NetworkObject after this event returns.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public event System.Action<EnemyHealth> OnDied;
|
public event System.Action<EnemyHealth> OnDied;
|
||||||
|
|
||||||
// ----- NGO lifecycle ----------------------------------------------
|
// ----- Server-only pre-spawn init -------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by <c>WaveManager</c> on the server after <c>Instantiate</c>
|
||||||
|
/// and before <c>NetworkObject.Spawn()</c>. Mirrors the
|
||||||
|
/// <c>TowerInstance.InitializeServer</c> pattern.
|
||||||
|
/// </summary>
|
||||||
|
public void InitializeServer(float maxHp, int goldReward, int livesCost, bool flying)
|
||||||
|
{
|
||||||
|
pendingMaxHp = maxHp;
|
||||||
|
pendingGoldReward = goldReward;
|
||||||
|
pendingLivesCost = livesCost;
|
||||||
|
pendingIsFlying = flying;
|
||||||
|
hasPendingInit = true;
|
||||||
|
|
||||||
|
// Cache locally on the server immediately — clients resolve via NV.
|
||||||
|
MaxHp = maxHp;
|
||||||
|
GoldReward = goldReward;
|
||||||
|
LivesCost = livesCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- NGO lifecycle --------------------------------------------------
|
||||||
|
|
||||||
public override void OnNetworkSpawn()
|
public override void OnNetworkSpawn()
|
||||||
{
|
{
|
||||||
if (IsServer)
|
if (IsServer && hasPendingInit)
|
||||||
hp.Value = maxHp;
|
{
|
||||||
|
hp.Value = pendingMaxHp;
|
||||||
|
isFlying.Value = pendingIsFlying;
|
||||||
|
hasPendingInit = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Server API -------------------------------------------------
|
// Non-server clients resolve MaxHp from the replicated hp initial value.
|
||||||
|
if (!IsServer)
|
||||||
|
MaxHp = hp.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Server API -----------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies <paramref name="damage"/> to this enemy. Server-only; no-op on clients.
|
/// Applies damage to this enemy. Server-only; silently no-ops on clients.
|
||||||
/// <paramref name="type"/> is recorded for future resistance/weakness lookups —
|
/// <paramref name="type"/> is accepted for future resistance lookups (Phase 1.5+).
|
||||||
/// all damage is full-value until the resistance table is implemented (Phase 1.5+).
|
/// <paramref name="attackerSlot"/> identifies the tower owner for kill attribution.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void TakeDamage(float damage, DamageType type)
|
public void TakeDamage(float damage, DamageType type, PlayerSlot attackerSlot)
|
||||||
{
|
{
|
||||||
if (!IsServer) return;
|
if (!IsServer) return;
|
||||||
if (IsDead) return;
|
if (IsDead) return;
|
||||||
|
|
@ -73,13 +137,14 @@ namespace TD.Gameplay
|
||||||
// float modified = ResistanceTable.Apply(damage, type, this);
|
// float modified = ResistanceTable.Apply(damage, type, this);
|
||||||
float modified = damage;
|
float modified = damage;
|
||||||
|
|
||||||
|
LastHitOwner = attackerSlot;
|
||||||
hp.Value = Mathf.Max(0f, hp.Value - modified);
|
hp.Value = Mathf.Max(0f, hp.Value - modified);
|
||||||
|
|
||||||
if (hp.Value <= 0f)
|
if (hp.Value <= 0f)
|
||||||
HandleDeath();
|
HandleDeath();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Private ----------------------------------------------------
|
// ----- Private --------------------------------------------------------
|
||||||
|
|
||||||
private void HandleDeath()
|
private void HandleDeath()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
225
Assets/_Project/Scripts/Gameplay/EnemyMovement.cs
Normal file
225
Assets/_Project/Scripts/Gameplay/EnemyMovement.cs
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
// Assets/_Project/Scripts/Gameplay/EnemyMovement.cs
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Unity.Netcode;
|
||||||
|
using UnityEngine;
|
||||||
|
using TD.Core;
|
||||||
|
using TD.Levels;
|
||||||
|
|
||||||
|
namespace TD.Gameplay
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Server-authoritative enemy movement along a dynamically computed A* path.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Initialization:</b> Call <see cref="InitializeServer"/> after <c>Instantiate</c>
|
||||||
|
/// and before <c>NetworkObject.Spawn()</c>. Provides the base move speed (from
|
||||||
|
/// <see cref="EnemyDefinition.MoveSpeed"/>) and the tile the enemy spawned on.
|
||||||
|
///
|
||||||
|
/// <b>Path lifecycle:</b>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="OnNetworkSpawn"/> (server): queries <see cref="PathfindingService"/>
|
||||||
|
/// and stores the tile waypoint list.</item>
|
||||||
|
/// <item>Each frame: moves toward the world center of <c>remainingPath[0]</c>.
|
||||||
|
/// When within snap distance, pops the waypoint and checks for zone transitions.</item>
|
||||||
|
/// <item>When <see cref="PathfindingService.OnPathsInvalidated"/> fires (tower placed /
|
||||||
|
/// sold), <see cref="RecomputePath"/> reruns A* from the current tile.</item>
|
||||||
|
/// <item>When <c>remainingPath</c> is empty after a pop, the enemy has reached the
|
||||||
|
/// goal — <see cref="OnReachedGoal"/> fires and the enemy is despawned.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <b>Zone leak tracking:</b> Each time a waypoint is popped, <see cref="LevelLoader.GetOwner"/>
|
||||||
|
/// is compared against <c>currentZone</c>. A change means the enemy has crossed a zone
|
||||||
|
/// boundary. <see cref="OnZoneLeaked"/> fires with the zone being LEFT so <c>WaveManager</c>
|
||||||
|
/// can debit the correct player's life pool.
|
||||||
|
///
|
||||||
|
/// <b>Speed:</b> Effective speed = <c>moveSpeed * EnemyStatus.GetSpeedMultiplier()</c>.
|
||||||
|
/// <c>EnemyStatus</c> replicates the multiplier as a NetworkVariable so movement looks
|
||||||
|
/// correct on all peers.
|
||||||
|
///
|
||||||
|
/// <b>Movement replication:</b> Requires a <c>NetworkTransform</c> on the prefab.
|
||||||
|
/// Position is written by the server; <c>NetworkTransform</c> interpolates on clients.
|
||||||
|
/// </remarks>
|
||||||
|
[RequireComponent(typeof(NetworkObject))]
|
||||||
|
[RequireComponent(typeof(EnemyHealth))]
|
||||||
|
[RequireComponent(typeof(EnemyStatus))]
|
||||||
|
public class EnemyMovement : NetworkBehaviour
|
||||||
|
{
|
||||||
|
// Snap threshold in world units. When sqrMagnitude to the waypoint center
|
||||||
|
// drops below this, we count the tile as reached and pop it.
|
||||||
|
private const float WaypointSnapSq = 0.04f; // 0.2 world units
|
||||||
|
|
||||||
|
// ----- Pre-spawn init (server-local) ----------------------------------
|
||||||
|
|
||||||
|
private float pendingMoveSpeed;
|
||||||
|
private Vector2Int pendingSpawnerTile;
|
||||||
|
private bool hasPendingInit;
|
||||||
|
|
||||||
|
// ----- Server-local runtime state -------------------------------------
|
||||||
|
|
||||||
|
private float moveSpeed;
|
||||||
|
private List<Vector2Int> remainingPath = new List<Vector2Int>();
|
||||||
|
private PlayerSlot currentZone = PlayerSlot.None;
|
||||||
|
private EnemyStatus status;
|
||||||
|
|
||||||
|
// ----- Events ---------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired on the server when this enemy crosses from one player zone into another
|
||||||
|
/// (or from a neutral zone into a player zone). The argument is the zone being
|
||||||
|
/// LEFT — the zone that should be debited a life.
|
||||||
|
/// <c>WaveManager</c> subscribes to deduct from the correct player's life pool.
|
||||||
|
/// </summary>
|
||||||
|
public event System.Action<PlayerSlot> OnZoneLeaked;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired on the server when the enemy reaches the goal tile.
|
||||||
|
/// Carries this component and the enemy's <see cref="EnemyHealth.LivesCost"/>
|
||||||
|
/// so <c>WaveManager</c> can deduct the right number of lives in one call.
|
||||||
|
/// The NetworkObject is despawned immediately after subscribers return.
|
||||||
|
/// </summary>
|
||||||
|
public event System.Action<EnemyMovement, int> OnReachedGoal;
|
||||||
|
|
||||||
|
// ----- Server-only pre-spawn init -------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by <c>WaveManager</c> on the server after <c>Instantiate</c> and
|
||||||
|
/// before <c>NetworkObject.Spawn()</c>. <paramref name="speed"/> comes from
|
||||||
|
/// <see cref="EnemyDefinition.MoveSpeed"/>; <paramref name="spawnerTile"/> is
|
||||||
|
/// the tile the enemy spawns on (used as the A* start node).
|
||||||
|
/// </summary>
|
||||||
|
public void InitializeServer(float speed, Vector2Int spawnerTile)
|
||||||
|
{
|
||||||
|
pendingMoveSpeed = speed;
|
||||||
|
pendingSpawnerTile = spawnerTile;
|
||||||
|
hasPendingInit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- NGO lifecycle --------------------------------------------------
|
||||||
|
|
||||||
|
public override void OnNetworkSpawn()
|
||||||
|
{
|
||||||
|
status = GetComponent<EnemyStatus>();
|
||||||
|
|
||||||
|
if (!IsServer) return;
|
||||||
|
|
||||||
|
if (!hasPendingInit)
|
||||||
|
{
|
||||||
|
Debug.LogError("[EnemyMovement] OnNetworkSpawn reached without InitializeServer " +
|
||||||
|
"having been called. Enemy will not move.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
moveSpeed = pendingMoveSpeed;
|
||||||
|
|
||||||
|
// Resolve starting zone from the spawner tile.
|
||||||
|
var loader = LevelLoader.Instance;
|
||||||
|
if (loader != null)
|
||||||
|
currentZone = loader.GetOwner(pendingSpawnerTile);
|
||||||
|
|
||||||
|
// Compute the initial path from the spawn tile to the nearest goal.
|
||||||
|
ComputeAndStorePath(pendingSpawnerTile);
|
||||||
|
|
||||||
|
// Recompute when a tower is placed or sold.
|
||||||
|
if (PathfindingService.Instance != null)
|
||||||
|
PathfindingService.Instance.OnPathsInvalidated += RecomputePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnNetworkDespawn()
|
||||||
|
{
|
||||||
|
if (PathfindingService.Instance != null)
|
||||||
|
PathfindingService.Instance.OnPathsInvalidated -= RecomputePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Server update --------------------------------------------------
|
||||||
|
|
||||||
|
private void Update()
|
||||||
|
{
|
||||||
|
if (!IsServer) return;
|
||||||
|
if (remainingPath.Count == 0) return;
|
||||||
|
|
||||||
|
float effectiveSpeed = moveSpeed * (status != null ? status.GetSpeedMultiplier() : 1f);
|
||||||
|
Vector3 targetWorld = GridCoordinates.GridToWorld(remainingPath[0]);
|
||||||
|
Vector3 toTarget = targetWorld - transform.position;
|
||||||
|
|
||||||
|
if (toTarget.sqrMagnitude <= WaypointSnapSq)
|
||||||
|
{
|
||||||
|
// Snap to the tile center then handle the waypoint transition.
|
||||||
|
transform.position = targetWorld;
|
||||||
|
AdvanceWaypoint();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
transform.position += toTarget.normalized * (effectiveSpeed * Time.deltaTime);
|
||||||
|
|
||||||
|
// Face the direction of travel.
|
||||||
|
if (toTarget.sqrMagnitude > 0.0001f)
|
||||||
|
transform.rotation = Quaternion.LookRotation(toTarget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Path management ------------------------------------------------
|
||||||
|
|
||||||
|
private void AdvanceWaypoint()
|
||||||
|
{
|
||||||
|
Vector2Int arrivedTile = remainingPath[0];
|
||||||
|
remainingPath.RemoveAt(0);
|
||||||
|
|
||||||
|
CheckZoneTransition(arrivedTile);
|
||||||
|
|
||||||
|
if (remainingPath.Count == 0)
|
||||||
|
HandleGoalReached();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CheckZoneTransition(Vector2Int tile)
|
||||||
|
{
|
||||||
|
var loader = LevelLoader.Instance;
|
||||||
|
if (loader == null) return;
|
||||||
|
|
||||||
|
PlayerSlot newZone = loader.GetOwner(tile);
|
||||||
|
if (newZone == currentZone) return;
|
||||||
|
|
||||||
|
// The enemy is leaving currentZone — debit that player's life pool.
|
||||||
|
if (currentZone != PlayerSlot.None)
|
||||||
|
OnZoneLeaked?.Invoke(currentZone);
|
||||||
|
|
||||||
|
currentZone = newZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleGoalReached()
|
||||||
|
{
|
||||||
|
var health = GetComponent<EnemyHealth>();
|
||||||
|
int livesCost = health != null ? health.LivesCost : 1;
|
||||||
|
|
||||||
|
OnReachedGoal?.Invoke(this, livesCost);
|
||||||
|
NetworkObject.Despawn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Path invalidation ----------------------------------------------
|
||||||
|
|
||||||
|
// Called on server when LevelLoader.OnWalkabilityChanged fires (tower placed/sold).
|
||||||
|
private void RecomputePath()
|
||||||
|
{
|
||||||
|
if (!IsServer) return;
|
||||||
|
|
||||||
|
// Recompute from the enemy's current tile position.
|
||||||
|
Vector2Int currentTile = GridCoordinates.WorldToGrid(transform.position);
|
||||||
|
ComputeAndStorePath(currentTile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ComputeAndStorePath(Vector2Int fromTile)
|
||||||
|
{
|
||||||
|
var service = PathfindingService.Instance;
|
||||||
|
if (service == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[EnemyMovement] PathfindingService not found. Enemy will not move.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingPath = service.ComputePath(fromTile);
|
||||||
|
|
||||||
|
if (remainingPath.Count == 0)
|
||||||
|
Debug.LogWarning($"[EnemyMovement] No path found from {fromTile}. " +
|
||||||
|
"TowerPlacementManager should have prevented a full block.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Scripts/Gameplay/EnemyMovement.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/EnemyMovement.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: fd6c02bbcc13fb14a9d596d9a2544dcc
|
||||||
|
|
@ -17,45 +17,42 @@ namespace TD.Gameplay
|
||||||
/// <item>Poison — damage per second applied as a DoT tick</item>
|
/// <item>Poison — damage per second applied as a DoT tick</item>
|
||||||
/// <item>Others — unused (magnitude = 0)</item>
|
/// <item>Others — unused (magnitude = 0)</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
|
/// <b>SourceOwner</b> carries the <see cref="PlayerSlot"/> of the tower that
|
||||||
|
/// applied this effect so DoT kill credit goes to the right player.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public struct StatusEffect
|
public struct StatusEffect
|
||||||
{
|
{
|
||||||
public DamageType Source;
|
public DamageType Source;
|
||||||
public float Magnitude;
|
public float Magnitude;
|
||||||
public float RemainingDuration;
|
public float RemainingDuration;
|
||||||
|
public PlayerSlot SourceOwner;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tracks and ticks lingering status effects (slow, burn, poison) on an enemy.
|
/// Tracks and ticks lingering status effects (slow, burn, poison) on an enemy.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <b>Authority:</b> The active-effect list is server-local (not replicated).
|
/// <b>Authority:</b> The active-effect list is server-local. Only the derived
|
||||||
/// Only the derived <see cref="speedMultiplier"/> NetworkVariable is replicated,
|
/// <see cref="speedMultiplier"/> NetworkVariable is replicated so
|
||||||
/// so <c>EnemyMovement</c> (Phase 1.5/1.6) can scale speed on all peers without
|
/// <c>EnemyMovement</c> can scale speed on all peers.
|
||||||
/// re-broadcasting the full effect list.
|
|
||||||
///
|
///
|
||||||
/// <b>Stacking rule:</b> A second hit of the same <see cref="DamageType"/> refreshes
|
/// <b>Stacking rule:</b> Re-hitting with the same <see cref="DamageType"/>
|
||||||
/// the duration and magnitude rather than stacking. Cross-type interactions (e.g.
|
/// refreshes duration and magnitude; it does not stack. Cross-type interactions
|
||||||
/// Cold + Fire) are not yet implemented; <see cref="HasEffect"/> is the hook for
|
/// are not implemented; <see cref="HasEffect"/> is the hook for future work.
|
||||||
/// when that design is worked out.
|
|
||||||
///
|
///
|
||||||
/// <b>DoT damage</b> is applied by calling <see cref="EnemyHealth.TakeDamage"/> each
|
/// <b>DoT damage</b> calls <see cref="EnemyHealth.TakeDamage"/> with the original
|
||||||
/// tick so resistance lookups remain in one place.
|
/// <see cref="StatusEffect.SourceOwner"/> so kill attribution stays correct.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RequireComponent(typeof(NetworkObject))]
|
[RequireComponent(typeof(NetworkObject))]
|
||||||
public class EnemyStatus : NetworkBehaviour
|
public class EnemyStatus : NetworkBehaviour
|
||||||
{
|
{
|
||||||
// Replicated so EnemyMovement can read it on all clients without
|
|
||||||
// knowing anything about which effects are active.
|
|
||||||
private readonly NetworkVariable<float> speedMultiplier = new NetworkVariable<float>(
|
private readonly NetworkVariable<float> speedMultiplier = new NetworkVariable<float>(
|
||||||
1f,
|
1f,
|
||||||
NetworkVariableReadPermission.Everyone,
|
NetworkVariableReadPermission.Everyone,
|
||||||
NetworkVariableWritePermission.Server);
|
NetworkVariableWritePermission.Server);
|
||||||
|
|
||||||
// Server-local — only the derived speedMultiplier NV crosses the wire.
|
|
||||||
private readonly List<StatusEffect> activeEffects = new List<StatusEffect>();
|
private readonly List<StatusEffect> activeEffects = new List<StatusEffect>();
|
||||||
|
|
||||||
// Resolved once; used by Tick for DoT TakeDamage calls.
|
|
||||||
private EnemyHealth health;
|
private EnemyHealth health;
|
||||||
|
|
||||||
// ----- NGO lifecycle -----------------------------------------------
|
// ----- NGO lifecycle -----------------------------------------------
|
||||||
|
|
@ -67,10 +64,10 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
// ----- Public API --------------------------------------------------
|
// ----- Public API --------------------------------------------------
|
||||||
|
|
||||||
/// <summary>Current speed fraction (0–1). 1 = full speed, 0.5 = half speed, etc.</summary>
|
/// <summary>Current speed fraction (0–1). 1 = full speed, 0.5 = half speed.</summary>
|
||||||
public float GetSpeedMultiplier() => speedMultiplier.Value;
|
public float GetSpeedMultiplier() => speedMultiplier.Value;
|
||||||
|
|
||||||
/// <summary>True if an effect of the given type is currently active on this enemy.</summary>
|
/// <summary>True if an effect of the given type is currently active.</summary>
|
||||||
public bool HasEffect(DamageType type)
|
public bool HasEffect(DamageType type)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < activeEffects.Count; i++)
|
for (int i = 0; i < activeEffects.Count; i++)
|
||||||
|
|
@ -80,9 +77,12 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies or refreshes a lingering effect. Server-only; no-op on clients.
|
/// Applies or refreshes a lingering effect. Server-only; no-op on clients.
|
||||||
/// Re-hitting with the same damage type refreshes duration and magnitude.
|
/// Re-hitting with the same source type refreshes duration and magnitude.
|
||||||
|
/// <paramref name="owner"/> is the tower's <see cref="PlayerSlot"/> — carried
|
||||||
|
/// on the effect so DoT ticks credit the right player on a kill.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void ApplyEffect(DamageType source, float magnitude, float duration)
|
public void ApplyEffect(DamageType source, float magnitude, float duration,
|
||||||
|
PlayerSlot owner)
|
||||||
{
|
{
|
||||||
if (!IsServer) return;
|
if (!IsServer) return;
|
||||||
|
|
||||||
|
|
@ -93,6 +93,7 @@ namespace TD.Gameplay
|
||||||
var e = activeEffects[i];
|
var e = activeEffects[i];
|
||||||
e.Magnitude = magnitude;
|
e.Magnitude = magnitude;
|
||||||
e.RemainingDuration = duration;
|
e.RemainingDuration = duration;
|
||||||
|
e.SourceOwner = owner;
|
||||||
activeEffects[i] = e;
|
activeEffects[i] = e;
|
||||||
RecalculateSpeedMultiplier();
|
RecalculateSpeedMultiplier();
|
||||||
return;
|
return;
|
||||||
|
|
@ -103,6 +104,7 @@ namespace TD.Gameplay
|
||||||
Source = source,
|
Source = source,
|
||||||
Magnitude = magnitude,
|
Magnitude = magnitude,
|
||||||
RemainingDuration = duration,
|
RemainingDuration = duration,
|
||||||
|
SourceOwner = owner,
|
||||||
});
|
});
|
||||||
RecalculateSpeedMultiplier();
|
RecalculateSpeedMultiplier();
|
||||||
}
|
}
|
||||||
|
|
@ -123,11 +125,11 @@ namespace TD.Gameplay
|
||||||
{
|
{
|
||||||
var e = activeEffects[i];
|
var e = activeEffects[i];
|
||||||
|
|
||||||
// Apply DoT for Fire and Poison.
|
// DoT tick — pass the original source owner so kill credit is correct.
|
||||||
if (e.Source == DamageType.Fire || e.Source == DamageType.Poison)
|
if ((e.Source == DamageType.Fire || e.Source == DamageType.Poison)
|
||||||
|
&& health != null && !health.IsDead)
|
||||||
{
|
{
|
||||||
if (health != null && !health.IsDead)
|
health.TakeDamage(e.Magnitude * dt, e.Source, e.SourceOwner);
|
||||||
health.TakeDamage(e.Magnitude * dt, e.Source);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
e.RemainingDuration -= dt;
|
e.RemainingDuration -= dt;
|
||||||
|
|
|
||||||
|
|
@ -365,6 +365,14 @@ namespace TD.Gameplay
|
||||||
// NetworkObject spawns and its Start/OnNetworkSpawn stamps its own
|
// NetworkObject spawns and its Start/OnNetworkSpawn stamps its own
|
||||||
// footprint locally.
|
// footprint locally.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired on every peer whenever <see cref="SetWalkable"/> changes a tile's
|
||||||
|
/// walkability. <see cref="TD.Gameplay.PathfindingService"/> subscribes to
|
||||||
|
/// invalidate cached paths so in-flight enemies reroute after a tower is
|
||||||
|
/// placed or removed.
|
||||||
|
/// </summary>
|
||||||
|
public event System.Action OnWalkabilityChanged;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the runtime walkability of <paramref name="tile"/>. Called by
|
/// Sets the runtime walkability of <paramref name="tile"/>. Called by
|
||||||
/// <c>TowerPlacementManager</c> on the server when a tower is accepted (pass
|
/// <c>TowerPlacementManager</c> on the server when a tower is accepted (pass
|
||||||
|
|
@ -374,7 +382,9 @@ namespace TD.Gameplay
|
||||||
public void SetWalkable(Vector2Int tile, bool walkable)
|
public void SetWalkable(Vector2Int tile, bool walkable)
|
||||||
{
|
{
|
||||||
if (!TryFlatIndex(tile, out int idx)) return;
|
if (!TryFlatIndex(tile, out int idx)) return;
|
||||||
|
if (runtimeWalkability[idx] == walkable) return; // no change — don't fire event
|
||||||
runtimeWalkability[idx] = walkable;
|
runtimeWalkability[idx] = walkable;
|
||||||
|
OnWalkabilityChanged?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
279
Assets/_Project/Scripts/Gameplay/PathfindingService.cs
Normal file
279
Assets/_Project/Scripts/Gameplay/PathfindingService.cs
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
// Assets/_Project/Scripts/Gameplay/PathfindingService.cs
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
using TD.Core;
|
||||||
|
using TD.Levels;
|
||||||
|
|
||||||
|
namespace TD.Gameplay
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Scene singleton that computes shortest-path routes from any tile to the
|
||||||
|
/// nearest goal tile using A* on the runtime walkability grid.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Algorithm:</b> A* with Manhattan-distance heuristic. Grid cost is uniform
|
||||||
|
/// (1 per step, 4-connected, no diagonals), so A* is optimal and significantly
|
||||||
|
/// faster than plain BFS on large grids thanks to the heuristic.
|
||||||
|
///
|
||||||
|
/// <b>Who calls this:</b>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="EnemyMovement"/> calls <see cref="ComputePath"/> once on
|
||||||
|
/// spawn and again whenever <see cref="OnPathsInvalidated"/> fires.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <b>Invalidation:</b> Subscribes to <see cref="LevelLoader.OnWalkabilityChanged"/>.
|
||||||
|
/// When a tower is placed or sold, <c>LevelLoader.SetWalkable</c> fires that event
|
||||||
|
/// and <see cref="OnPathsInvalidated"/> is relayed to all active enemies, which
|
||||||
|
/// each recompute their own path from their current tile.
|
||||||
|
///
|
||||||
|
/// <b>Goal tile set:</b> Built once on <c>Start</c> from
|
||||||
|
/// <c>LevelLoader.LevelData.Goals[].TileArea</c>. Goal tiles never change at
|
||||||
|
/// runtime (they are baked into the level), so there is no need to rebuild the set.
|
||||||
|
///
|
||||||
|
/// <b>No caching:</b> Paths are computed on demand per enemy. On typical TD grid
|
||||||
|
/// sizes (50×50 or smaller) a single A* run takes <1 ms. If profiling shows
|
||||||
|
/// otherwise, add a per-startTile cache here.
|
||||||
|
/// </remarks>
|
||||||
|
public class PathfindingService : MonoBehaviour
|
||||||
|
{
|
||||||
|
// ----- Singleton --------------------------------------------------
|
||||||
|
|
||||||
|
public static PathfindingService Instance { get; private set; }
|
||||||
|
|
||||||
|
// ----- Events -----------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired on every peer when the walkability grid changes (tower placed/sold).
|
||||||
|
/// <see cref="EnemyMovement"/> subscribes per-instance to recompute its path.
|
||||||
|
/// </summary>
|
||||||
|
public event System.Action OnPathsInvalidated;
|
||||||
|
|
||||||
|
// ----- State ------------------------------------------------------
|
||||||
|
|
||||||
|
// Built once on Start from LevelData.Goals[].TileArea.
|
||||||
|
private HashSet<Vector2Int> goalTiles;
|
||||||
|
|
||||||
|
// A* scratch collections — allocated once and cleared per run to avoid GC.
|
||||||
|
// PathfindingService is a singleton, so single-instance scratch is safe.
|
||||||
|
private readonly Dictionary<Vector2Int, Vector2Int> cameFrom = new Dictionary<Vector2Int, Vector2Int>();
|
||||||
|
private readonly Dictionary<Vector2Int, int> gScore = new Dictionary<Vector2Int, int>();
|
||||||
|
private readonly SimplePriorityQueue openSet = new SimplePriorityQueue();
|
||||||
|
|
||||||
|
// ----- Lifecycle --------------------------------------------------
|
||||||
|
|
||||||
|
private void Awake()
|
||||||
|
{
|
||||||
|
if (Instance != null && Instance != this)
|
||||||
|
{
|
||||||
|
Debug.LogError("[PathfindingService] Duplicate instance detected. " +
|
||||||
|
"Only one PathfindingService should exist per scene.");
|
||||||
|
Destroy(gameObject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Start()
|
||||||
|
{
|
||||||
|
BuildGoalTileSet();
|
||||||
|
|
||||||
|
var loader = LevelLoader.Instance;
|
||||||
|
if (loader != null)
|
||||||
|
loader.OnWalkabilityChanged += HandleWalkabilityChanged;
|
||||||
|
else
|
||||||
|
Debug.LogWarning("[PathfindingService] LevelLoader not found in Start. " +
|
||||||
|
"Path invalidation will not work.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDestroy()
|
||||||
|
{
|
||||||
|
if (Instance == this) Instance = null;
|
||||||
|
|
||||||
|
var loader = LevelLoader.Instance;
|
||||||
|
if (loader != null)
|
||||||
|
loader.OnWalkabilityChanged -= HandleWalkabilityChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Public API -------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the shortest walkable path from <paramref name="startTile"/> to
|
||||||
|
/// the nearest goal tile. Returns an ordered list of tiles to visit, starting
|
||||||
|
/// with the first step AFTER <paramref name="startTile"/> and ending with the
|
||||||
|
/// reached goal tile. Returns an empty list if no path exists or LevelLoader
|
||||||
|
/// is unavailable (should not occur — TowerPlacementManager guarantees a path
|
||||||
|
/// always exists after any placement).
|
||||||
|
/// </summary>
|
||||||
|
public List<Vector2Int> ComputePath(Vector2Int startTile)
|
||||||
|
{
|
||||||
|
var loader = LevelLoader.Instance;
|
||||||
|
if (loader == null || !loader.IsLoaded || goalTiles == null || goalTiles.Count == 0)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[PathfindingService] Cannot compute path: " +
|
||||||
|
"LevelLoader unavailable or no goal tiles baked.");
|
||||||
|
return new List<Vector2Int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return RunAStar(startTile, loader);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- A* implementation ------------------------------------------
|
||||||
|
|
||||||
|
private List<Vector2Int> RunAStar(Vector2Int start, LevelLoader loader)
|
||||||
|
{
|
||||||
|
cameFrom.Clear();
|
||||||
|
gScore.Clear();
|
||||||
|
openSet.Clear();
|
||||||
|
|
||||||
|
gScore[start] = 0;
|
||||||
|
openSet.Enqueue(start, Heuristic(start));
|
||||||
|
|
||||||
|
while (openSet.Count > 0)
|
||||||
|
{
|
||||||
|
Vector2Int current = openSet.Dequeue();
|
||||||
|
|
||||||
|
if (goalTiles.Contains(current))
|
||||||
|
return ReconstructPath(start, current);
|
||||||
|
|
||||||
|
int currentG = gScore.TryGetValue(current, out int g) ? g : int.MaxValue;
|
||||||
|
|
||||||
|
foreach (var neighbor in GridCoordinates.GetNeighbors(current))
|
||||||
|
{
|
||||||
|
if (!loader.IsWalkable(neighbor)) continue;
|
||||||
|
|
||||||
|
int tentativeG = currentG + 1;
|
||||||
|
if (gScore.TryGetValue(neighbor, out int existingG)
|
||||||
|
&& tentativeG >= existingG) continue;
|
||||||
|
|
||||||
|
cameFrom[neighbor] = current;
|
||||||
|
gScore[neighbor] = tentativeG;
|
||||||
|
int f = tentativeG + Heuristic(neighbor);
|
||||||
|
|
||||||
|
// Re-enqueue with updated priority. SimplePriorityQueue handles
|
||||||
|
// duplicate entries by ignoring higher-cost duplicates on dequeue.
|
||||||
|
openSet.Enqueue(neighbor, f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No path found — TowerPlacementManager should have prevented this.
|
||||||
|
Debug.LogWarning($"[PathfindingService] A* found no path from {start}. " +
|
||||||
|
"Check that TowerPlacementManager BFS is validating correctly.");
|
||||||
|
return new List<Vector2Int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manhattan distance to the nearest goal tile. Admissible heuristic for
|
||||||
|
// a 4-connected uniform-cost grid.
|
||||||
|
private int Heuristic(Vector2Int tile)
|
||||||
|
{
|
||||||
|
int best = int.MaxValue;
|
||||||
|
foreach (var goal in goalTiles)
|
||||||
|
{
|
||||||
|
int d = GridCoordinates.ManhattanDistance(tile, goal);
|
||||||
|
if (d < best) best = d;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Vector2Int> ReconstructPath(Vector2Int start, Vector2Int goal)
|
||||||
|
{
|
||||||
|
var path = new List<Vector2Int>();
|
||||||
|
Vector2Int current = goal;
|
||||||
|
|
||||||
|
while (current != start)
|
||||||
|
{
|
||||||
|
path.Add(current);
|
||||||
|
if (!cameFrom.TryGetValue(current, out current))
|
||||||
|
break; // shouldn't happen
|
||||||
|
}
|
||||||
|
|
||||||
|
path.Reverse();
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Helpers ----------------------------------------------------
|
||||||
|
|
||||||
|
private void BuildGoalTileSet()
|
||||||
|
{
|
||||||
|
goalTiles = new HashSet<Vector2Int>();
|
||||||
|
var loader = LevelLoader.Instance;
|
||||||
|
if (loader == null || loader.LevelData == null || loader.LevelData.Goals == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[PathfindingService] No LevelData or Goals found. " +
|
||||||
|
"Enemies will have no destination.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var goal in loader.LevelData.Goals)
|
||||||
|
{
|
||||||
|
if (goal.TileArea == null) continue;
|
||||||
|
foreach (var tile in goal.TileArea)
|
||||||
|
goalTiles.Add(tile);
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug.Log($"[PathfindingService] Goal tile set built: {goalTiles.Count} tiles.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleWalkabilityChanged()
|
||||||
|
{
|
||||||
|
OnPathsInvalidated?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Minimal priority queue for A*. Uses a sorted list of (priority, tile) pairs.
|
||||||
|
// Suitable for the grid sizes in this project (typically < 10,000 tiles).
|
||||||
|
// Replace with a binary heap if profiling shows this as a bottleneck.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
internal sealed class SimplePriorityQueue
|
||||||
|
{
|
||||||
|
private readonly List<(int priority, Vector2Int tile)> heap
|
||||||
|
= new List<(int, Vector2Int)>();
|
||||||
|
|
||||||
|
public int Count => heap.Count;
|
||||||
|
|
||||||
|
public void Clear() => heap.Clear();
|
||||||
|
|
||||||
|
public void Enqueue(Vector2Int tile, int priority)
|
||||||
|
{
|
||||||
|
heap.Add((priority, tile));
|
||||||
|
SiftUp(heap.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vector2Int Dequeue()
|
||||||
|
{
|
||||||
|
Vector2Int result = heap[0].tile;
|
||||||
|
int last = heap.Count - 1;
|
||||||
|
heap[0] = heap[last];
|
||||||
|
heap.RemoveAt(last);
|
||||||
|
if (heap.Count > 0) SiftDown(0);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SiftUp(int i)
|
||||||
|
{
|
||||||
|
while (i > 0)
|
||||||
|
{
|
||||||
|
int parent = (i - 1) / 2;
|
||||||
|
if (heap[parent].priority <= heap[i].priority) break;
|
||||||
|
(heap[i], heap[parent]) = (heap[parent], heap[i]);
|
||||||
|
i = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SiftDown(int i)
|
||||||
|
{
|
||||||
|
int count = heap.Count;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int smallest = i;
|
||||||
|
int left = 2 * i + 1;
|
||||||
|
int right = 2 * i + 2;
|
||||||
|
if (left < count && heap[left].priority < heap[smallest].priority) smallest = left;
|
||||||
|
if (right < count && heap[right].priority < heap[smallest].priority) smallest = right;
|
||||||
|
if (smallest == i) break;
|
||||||
|
(heap[i], heap[smallest]) = (heap[smallest], heap[i]);
|
||||||
|
i = smallest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: de7d013503af0f74c950f215f8dae1c0
|
||||||
|
|
@ -48,6 +48,19 @@ namespace TD.Gameplay
|
||||||
public static PlayerSlot SlotForClient(ulong clientId)
|
public static PlayerSlot SlotForClient(ulong clientId)
|
||||||
=> GetForClient(clientId)?.Slot ?? PlayerSlot.None;
|
=> GetForClient(clientId)?.Slot ?? PlayerSlot.None;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the <see cref="PlayerMatchState"/> whose assigned slot matches
|
||||||
|
/// <paramref name="slot"/>, or null if no connected client holds that slot.
|
||||||
|
/// O(n) over connected players (max 9) — acceptable for server-side use.
|
||||||
|
/// Used by <c>WaveManager</c> to resolve kill-gold recipients.
|
||||||
|
/// </summary>
|
||||||
|
public static PlayerMatchState GetForSlot(PlayerSlot slot)
|
||||||
|
{
|
||||||
|
foreach (var pms in s_byClientId.Values)
|
||||||
|
if (pms.Slot == slot) return pms;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The local client's own state. Null on a dedicated server or before the
|
/// The local client's own state. Null on a dedicated server or before the
|
||||||
/// local player has spawned.
|
/// local player has spawned.
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,13 @@ namespace TD.Gameplay
|
||||||
/// the full ScriptableObject locally on every client. TowerRegistry is the lookup
|
/// the full ScriptableObject locally on every client. TowerRegistry is the lookup
|
||||||
/// table that makes that resolution possible without hard-coding asset paths.</para>
|
/// table that makes that resolution possible without hard-coding asset paths.</para>
|
||||||
///
|
///
|
||||||
/// <para><b>Auto-discovery.</b> On Awake, all <see cref="TowerDefinition"/> assets
|
/// <para><b>Registration.</b> Drag every <see cref="TowerDefinition"/> asset into the
|
||||||
/// under <c>Resources/TowerDefinitions/</c> are loaded automatically. No inspector
|
/// <c>Definitions</c> list on this component in the inspector. Assets can live anywhere
|
||||||
/// drag-and-drop required — add a new asset to that folder and it is registered at
|
/// in the project — no special folder required.</para>
|
||||||
/// runtime with no other changes needed. This scales cleanly to 100+ tower types.</para>
|
|
||||||
///
|
///
|
||||||
/// <para><b>Path E upgrade path.</b> In Path E the registry will filter to only the
|
/// <para><b>Path E upgrade path.</b> In Path E the registry will filter to only the
|
||||||
/// definitions belonging to the active match's <c>RaceDefinition</c> rosters. For now
|
/// definitions belonging to the active match's <c>RaceDefinition</c> rosters. For now
|
||||||
/// all assets in the Resources folder are registered.</para>
|
/// all assigned assets are registered.</para>
|
||||||
///
|
///
|
||||||
/// <para><b>Plain MonoBehaviour.</b> Not a NetworkBehaviour — the registry is
|
/// <para><b>Plain MonoBehaviour.</b> Not a NetworkBehaviour — the registry is
|
||||||
/// identical on every peer (same assets, same names), so there is nothing to sync.</para>
|
/// identical on every peer (same assets, same names), so there is nothing to sync.</para>
|
||||||
|
|
@ -37,14 +36,12 @@ namespace TD.Gameplay
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static TowerRegistry Instance { get; private set; }
|
public static TowerRegistry Instance { get; private set; }
|
||||||
|
|
||||||
// ----- Constants --------------------------------------------------
|
// ----- Inspector --------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
[Tooltip("All TowerDefinition assets available in this match. " +
|
||||||
/// Resources-relative folder path that TowerDefinition assets must live under
|
"Drag assets here from Assets/_Project/Data/TowerDefinitions/ " +
|
||||||
/// to be auto-discovered. Create this folder if it doesn't exist.
|
"(or wherever they live). Asset name is used as the registry key.")]
|
||||||
/// Full path: Assets/Resources/TowerDefinitions/
|
[SerializeField] private TowerDefinition[] definitions;
|
||||||
/// </summary>
|
|
||||||
private const string ResourcesFolder = "TowerDefinitions";
|
|
||||||
|
|
||||||
// ----- Internal lookup table --------------------------------------
|
// ----- Internal lookup table --------------------------------------
|
||||||
|
|
||||||
|
|
@ -96,21 +93,14 @@ namespace TD.Gameplay
|
||||||
{
|
{
|
||||||
byName.Clear();
|
byName.Clear();
|
||||||
|
|
||||||
// Resources.LoadAll finds every TowerDefinition asset anywhere under
|
if (definitions == null || definitions.Length == 0)
|
||||||
// Assets/Resources/TowerDefinitions/ (including sub-folders).
|
|
||||||
// No manual registration needed — drop an asset in the folder and it
|
|
||||||
// is available on the next play session.
|
|
||||||
var loaded = Resources.LoadAll<TowerDefinition>(ResourcesFolder);
|
|
||||||
|
|
||||||
if (loaded.Length == 0)
|
|
||||||
{
|
{
|
||||||
Debug.LogWarning($"[TowerRegistry] No TowerDefinition assets found under " +
|
Debug.LogWarning("[TowerRegistry] No TowerDefinition assets assigned. " +
|
||||||
$"Resources/{ResourcesFolder}/. " +
|
"Drag assets into the Definitions list on the TowerRegistry component.");
|
||||||
$"Create the folder and add TowerDefinition assets to it.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var def in loaded)
|
foreach (var def in definitions)
|
||||||
{
|
{
|
||||||
if (def == null) continue;
|
if (def == null) continue;
|
||||||
|
|
||||||
|
|
@ -124,8 +114,7 @@ namespace TD.Gameplay
|
||||||
byName[def.name] = def;
|
byName[def.name] = def;
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug.Log($"[TowerRegistry] Auto-discovered and registered " +
|
Debug.Log($"[TowerRegistry] Registered {byName.Count} tower definition(s).");
|
||||||
$"{byName.Count} tower definition(s) from Resources/{ResourcesFolder}/.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
45
Assets/_Project/Scripts/Gameplay/WaveDefinition.cs
Normal file
45
Assets/_Project/Scripts/Gameplay/WaveDefinition.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Assets/_Project/Scripts/Gameplay/WaveDefinition.cs
|
||||||
|
using System;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace TD.Gameplay
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A single spawn group within a wave: one enemy type, how many of them,
|
||||||
|
/// and how long to wait between each spawn.
|
||||||
|
/// </summary>
|
||||||
|
[Serializable]
|
||||||
|
public struct WaveEntry
|
||||||
|
{
|
||||||
|
[Tooltip("The enemy type to spawn for this group.")]
|
||||||
|
public EnemyDefinition EnemyType;
|
||||||
|
|
||||||
|
[Tooltip("How many enemies of this type to spawn.")]
|
||||||
|
public int Count;
|
||||||
|
|
||||||
|
[Tooltip("Seconds between each individual spawn within this group. " +
|
||||||
|
"0 = all spawn simultaneously.")]
|
||||||
|
public float SpawnInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the composition of a single wave. One asset per wave; referenced in
|
||||||
|
/// order by <see cref="WaveManager.waveDefinitions"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Entries are processed in array order. Multiple entries let designers mix enemy
|
||||||
|
/// types within one wave (e.g. 10 fast scouts followed by 3 armoured brutes).
|
||||||
|
/// The wave is not considered complete until all spawned enemies are dead or have
|
||||||
|
/// leaked — not just until all entries are spawned.
|
||||||
|
/// </remarks>
|
||||||
|
[CreateAssetMenu(fileName = "WaveDefinition", menuName = "TD/Wave Definition", order = 4)]
|
||||||
|
public class WaveDefinition : ScriptableObject
|
||||||
|
{
|
||||||
|
[Tooltip("Seconds between the wave-number advancing (start of prep) and the " +
|
||||||
|
"first enemy spawning. Gives players time to build before the wave hits.")]
|
||||||
|
public float PrepTime = 10f;
|
||||||
|
|
||||||
|
[Tooltip("Enemy groups that make up this wave. Processed in order.")]
|
||||||
|
public WaveEntry[] Entries;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Scripts/Gameplay/WaveDefinition.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/WaveDefinition.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 48e93688dc0fb5b4cbe7be9a241b4421
|
||||||
331
Assets/_Project/Scripts/Gameplay/WaveManager.cs
Normal file
331
Assets/_Project/Scripts/Gameplay/WaveManager.cs
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
// Assets/_Project/Scripts/Gameplay/WaveManager.cs
|
||||||
|
using System.Collections;
|
||||||
|
using Unity.Netcode;
|
||||||
|
using UnityEngine;
|
||||||
|
using TD.Core;
|
||||||
|
using TD.Levels;
|
||||||
|
|
||||||
|
namespace TD.Gameplay
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Server-authoritative wave controller. Spawns enemies across all player zones,
|
||||||
|
/// tracks wave completion, awards kill gold, and manages the shared lives pool.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Wave lifecycle:</b>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>When <see cref="MatchPhase.Playing"/> is entered,
|
||||||
|
/// <see cref="StartNextWave"/> advances <see cref="MatchState.CurrentWave"/>
|
||||||
|
/// immediately (so the HUD shows the wave number during prep), then waits
|
||||||
|
/// <see cref="WaveDefinition.PrepTime"/> before spawning.</item>
|
||||||
|
/// <item>Each <see cref="WaveEntry"/> spawns <c>Count</c> enemies per player zone,
|
||||||
|
/// one zone per frame-group, with <c>SpawnInterval</c> seconds between
|
||||||
|
/// individual enemies in the group.</item>
|
||||||
|
/// <item>After all entries are spawned, the wave is considered complete only when
|
||||||
|
/// every active enemy is either killed or has reached the goal.</item>
|
||||||
|
/// <item>All waves exhausted → <see cref="MatchPhase.Victory"/>.</item>
|
||||||
|
/// <item>Lives drop to 0 → <see cref="MatchPhase.Defeat"/>.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <b>Kill gold:</b> When an enemy dies, <see cref="EnemyHealth.LastHitOwner"/> names
|
||||||
|
/// the tower's player. <see cref="PlayerMatchState.GetForSlot"/> resolves the
|
||||||
|
/// <c>OwnerClientId</c>, and <see cref="PlayerGoldManager.GetForClient"/> awards
|
||||||
|
/// the gold.
|
||||||
|
///
|
||||||
|
/// <b>Zone leak counts:</b> <see cref="zoneLeakCounts"/> is a <c>NetworkList</c>
|
||||||
|
/// indexed by <c>(int)PlayerSlot</c> (indices 0–8). It is incremented when an enemy
|
||||||
|
/// crosses from one player zone into another, giving the HUD a per-player leak score.
|
||||||
|
/// Index 0 corresponds to <see cref="PlayerSlot.None"/> and is unused.
|
||||||
|
///
|
||||||
|
/// <b>Inspector setup:</b>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Assign <see cref="waveDefinitions"/> in order (Wave 1 at index 0).</item>
|
||||||
|
/// <item>Set <see cref="startingLives"/> to match your level design intent.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
public class WaveManager : NetworkBehaviour
|
||||||
|
{
|
||||||
|
// ----- Singleton --------------------------------------------------
|
||||||
|
|
||||||
|
public static WaveManager Instance { get; private set; }
|
||||||
|
|
||||||
|
// ----- Inspector --------------------------------------------------
|
||||||
|
|
||||||
|
[Tooltip("Wave definitions in order. Index 0 = Wave 1.")]
|
||||||
|
[SerializeField] private WaveDefinition[] waveDefinitions;
|
||||||
|
|
||||||
|
[Tooltip("Shared lives pool at the start of a match.")]
|
||||||
|
[SerializeField] private int startingLives = 20;
|
||||||
|
|
||||||
|
// ----- Networked state --------------------------------------------
|
||||||
|
|
||||||
|
// Per-slot zone-leak counters. Index = (int)PlayerSlot; size = 10 (0-9).
|
||||||
|
// Index 0 (PlayerSlot.None) is allocated but never written.
|
||||||
|
// Replicated so the HUD can show per-player leak scores on all peers.
|
||||||
|
private readonly NetworkList<int> zoneLeakCounts = new NetworkList<int>();
|
||||||
|
|
||||||
|
// ----- Server-local runtime state ---------------------------------
|
||||||
|
|
||||||
|
private int remainingLives;
|
||||||
|
private int activeEnemyCount;
|
||||||
|
private bool spawningComplete;
|
||||||
|
private int currentWaveIndex = -1; // -1 = not yet started
|
||||||
|
|
||||||
|
// ----- NGO lifecycle ----------------------------------------------
|
||||||
|
|
||||||
|
public override void OnNetworkSpawn()
|
||||||
|
{
|
||||||
|
if (Instance != null && Instance != this)
|
||||||
|
{
|
||||||
|
Debug.LogError("[WaveManager] Duplicate WaveManager detected. " +
|
||||||
|
"Only one may exist per scene.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Instance = this;
|
||||||
|
|
||||||
|
if (!IsServer) return;
|
||||||
|
|
||||||
|
// Populate the NetworkList with 10 zeros (indices 0-9 for PlayerSlot.None..Player9).
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
zoneLeakCounts.Add(0);
|
||||||
|
|
||||||
|
remainingLives = startingLives;
|
||||||
|
|
||||||
|
// NGO's scene-object spawn sweep calls all OnNetworkSpawn methods
|
||||||
|
// synchronously in a single call stack. Yielding one frame guarantees
|
||||||
|
// every sibling NetworkBehaviour (including MatchState) has finished
|
||||||
|
// its own OnNetworkSpawn before we try to read MatchState.Instance.
|
||||||
|
StartCoroutine(InitAfterSpawn());
|
||||||
|
}
|
||||||
|
|
||||||
|
private System.Collections.IEnumerator InitAfterSpawn()
|
||||||
|
{
|
||||||
|
yield return null; // wait one frame
|
||||||
|
|
||||||
|
var ms = MatchState.Instance;
|
||||||
|
if (ms == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[WaveManager] MatchState not found after spawn. " +
|
||||||
|
"Waves will not start automatically.");
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
ms.SetLives(remainingLives);
|
||||||
|
ms.OnPhaseChanged += HandlePhaseChanged;
|
||||||
|
|
||||||
|
if (ms.Phase == MatchPhase.Playing)
|
||||||
|
StartNextWave();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnNetworkDespawn()
|
||||||
|
{
|
||||||
|
if (Instance == this) Instance = null;
|
||||||
|
|
||||||
|
if (MatchState.Instance != null)
|
||||||
|
MatchState.Instance.OnPhaseChanged -= HandlePhaseChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Public accessors -------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of times enemies have leaked out of the given player's zone.
|
||||||
|
/// Replicated — safe to call on any peer.
|
||||||
|
/// </summary>
|
||||||
|
public int GetZoneLeakCount(PlayerSlot slot)
|
||||||
|
{
|
||||||
|
int idx = (int)slot;
|
||||||
|
return (idx >= 0 && idx < zoneLeakCounts.Count) ? zoneLeakCounts[idx] : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Phase handling ---------------------------------------------
|
||||||
|
|
||||||
|
private void HandlePhaseChanged(MatchPhase previous, MatchPhase next)
|
||||||
|
{
|
||||||
|
if (!IsServer) return;
|
||||||
|
if (next == MatchPhase.Playing && currentWaveIndex < 0)
|
||||||
|
StartNextWave();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Wave coroutine ---------------------------------------------
|
||||||
|
|
||||||
|
private void StartNextWave()
|
||||||
|
{
|
||||||
|
currentWaveIndex++;
|
||||||
|
|
||||||
|
if (waveDefinitions == null || currentWaveIndex >= waveDefinitions.Length)
|
||||||
|
{
|
||||||
|
Debug.Log("[WaveManager] All waves complete. Victory.");
|
||||||
|
MatchState.Instance?.SetPhase(MatchPhase.Victory);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance the replicated wave counter at the START of prep so the HUD
|
||||||
|
// shows the upcoming wave number during the countdown.
|
||||||
|
MatchState.Instance?.SetCurrentWave(currentWaveIndex + 1); // 1-based
|
||||||
|
|
||||||
|
activeEnemyCount = 0;
|
||||||
|
spawningComplete = false;
|
||||||
|
|
||||||
|
StartCoroutine(RunWave(waveDefinitions[currentWaveIndex]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerator RunWave(WaveDefinition def)
|
||||||
|
{
|
||||||
|
// Prep phase — players build while the countdown ticks.
|
||||||
|
yield return new WaitForSeconds(def.PrepTime);
|
||||||
|
|
||||||
|
// Spawn phase.
|
||||||
|
if (def.Entries != null)
|
||||||
|
{
|
||||||
|
foreach (var entry in def.Entries)
|
||||||
|
{
|
||||||
|
if (entry.EnemyType == null || entry.Count <= 0) continue;
|
||||||
|
|
||||||
|
for (int i = 0; i < entry.Count; i++)
|
||||||
|
{
|
||||||
|
SpawnEnemyInAllZones(entry.EnemyType);
|
||||||
|
|
||||||
|
if (entry.SpawnInterval > 0f)
|
||||||
|
yield return new WaitForSeconds(entry.SpawnInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spawningComplete = true;
|
||||||
|
|
||||||
|
// If every spawned enemy was already resolved before this coroutine finished
|
||||||
|
// (edge case: SpawnInterval = 0 and enemies die instantly), complete the wave now.
|
||||||
|
CheckWaveComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Spawn helpers ----------------------------------------------
|
||||||
|
|
||||||
|
private void SpawnEnemyInAllZones(EnemyDefinition def)
|
||||||
|
{
|
||||||
|
var loader = LevelLoader.Instance;
|
||||||
|
if (loader?.LevelData?.PlayerZones == null) return;
|
||||||
|
|
||||||
|
foreach (var zone in loader.LevelData.PlayerZones)
|
||||||
|
{
|
||||||
|
if (zone.Spawners == null || zone.Spawners.Length == 0) continue;
|
||||||
|
|
||||||
|
// Use the first spawner in the zone. Future: round-robin through Spawners.
|
||||||
|
SpawnEnemy(def, zone.Spawners[0].TilePosition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile)
|
||||||
|
{
|
||||||
|
if (def.EnemyPrefab == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"[WaveManager] EnemyDefinition '{def.name}' has no EnemyPrefab assigned.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var go = Instantiate(
|
||||||
|
def.EnemyPrefab,
|
||||||
|
GridCoordinates.GridToWorld(spawnerTile),
|
||||||
|
Quaternion.identity);
|
||||||
|
|
||||||
|
var health = go.GetComponent<EnemyHealth>();
|
||||||
|
var movement = go.GetComponent<EnemyMovement>();
|
||||||
|
|
||||||
|
if (health == null || movement == null)
|
||||||
|
{
|
||||||
|
Debug.LogError($"[WaveManager] Enemy prefab '{def.EnemyPrefab.name}' is missing " +
|
||||||
|
$"EnemyHealth or EnemyMovement. Enemy will not be spawned.");
|
||||||
|
Destroy(go);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
health.InitializeServer(def.MaxHp, def.GoldReward, def.LivesCost, def.IsFlying);
|
||||||
|
movement.InitializeServer(def.MoveSpeed, spawnerTile);
|
||||||
|
|
||||||
|
health.OnDied += HandleEnemyKilled;
|
||||||
|
movement.OnZoneLeaked += HandleZoneLeak;
|
||||||
|
movement.OnReachedGoal += HandleEnemyReachedGoal;
|
||||||
|
|
||||||
|
activeEnemyCount++;
|
||||||
|
|
||||||
|
go.GetComponent<NetworkObject>().Spawn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Enemy event handlers (server-only) -------------------------
|
||||||
|
|
||||||
|
private void HandleEnemyKilled(EnemyHealth health)
|
||||||
|
{
|
||||||
|
// Award kill gold to the tower owner that landed the killing blow.
|
||||||
|
PlayerSlot killerSlot = health.LastHitOwner;
|
||||||
|
if (killerSlot != PlayerSlot.None)
|
||||||
|
{
|
||||||
|
var pms = PlayerMatchState.GetForSlot(killerSlot);
|
||||||
|
if (pms != null)
|
||||||
|
PlayerGoldManager.GetForClient(pms.OwnerClientId)
|
||||||
|
?.AwardGold(health.GoldReward);
|
||||||
|
}
|
||||||
|
|
||||||
|
UnsubscribeEnemy(health);
|
||||||
|
DecrementAndCheckComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleZoneLeak(PlayerSlot leavingZone)
|
||||||
|
{
|
||||||
|
// Increment the per-slot leak counter for the zone the enemy is leaving.
|
||||||
|
int idx = (int)leavingZone;
|
||||||
|
if (idx >= 0 && idx < zoneLeakCounts.Count)
|
||||||
|
zoneLeakCounts[idx]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleEnemyReachedGoal(EnemyMovement movement, int livesCost)
|
||||||
|
{
|
||||||
|
UnsubscribeEnemy(movement.GetComponent<EnemyHealth>());
|
||||||
|
|
||||||
|
remainingLives = Mathf.Max(0, remainingLives - livesCost);
|
||||||
|
MatchState.Instance?.SetLives(remainingLives);
|
||||||
|
|
||||||
|
if (remainingLives <= 0)
|
||||||
|
{
|
||||||
|
Debug.Log("[WaveManager] Lives depleted. Defeat.");
|
||||||
|
MatchState.Instance?.SetPhase(MatchPhase.Defeat);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DecrementAndCheckComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Helpers ----------------------------------------------------
|
||||||
|
|
||||||
|
private void UnsubscribeEnemy(EnemyHealth health)
|
||||||
|
{
|
||||||
|
if (health == null) return;
|
||||||
|
health.OnDied -= HandleEnemyKilled;
|
||||||
|
|
||||||
|
var movement = health.GetComponent<EnemyMovement>();
|
||||||
|
if (movement != null)
|
||||||
|
{
|
||||||
|
movement.OnZoneLeaked -= HandleZoneLeak;
|
||||||
|
movement.OnReachedGoal -= HandleEnemyReachedGoal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DecrementAndCheckComplete()
|
||||||
|
{
|
||||||
|
activeEnemyCount--;
|
||||||
|
CheckWaveComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CheckWaveComplete()
|
||||||
|
{
|
||||||
|
if (!spawningComplete) return;
|
||||||
|
if (activeEnemyCount > 0) return;
|
||||||
|
|
||||||
|
// Guard: don't start the next wave if the match is already decided.
|
||||||
|
var ms = MatchState.Instance;
|
||||||
|
if (ms == null || ms.Phase == MatchPhase.Defeat || ms.Phase == MatchPhase.Victory)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Debug.Log($"[WaveManager] Wave {currentWaveIndex + 1} complete. Starting next wave.");
|
||||||
|
StartNextWave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Scripts/Gameplay/WaveManager.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/WaveManager.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 81d8e215d8419404ea4d959196cd9cc3
|
||||||
Loading…
Add table
Add a link
Reference in a new issue