diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 910fa09..370766c 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -2,7 +2,8 @@
"permissions": {
"allow": [
"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\")"
]
}
}
diff --git a/Assets/_Project/Data/EnemyDefinitions.meta b/Assets/_Project/Data/EnemyDefinitions.meta
new file mode 100644
index 0000000..9360874
--- /dev/null
+++ b/Assets/_Project/Data/EnemyDefinitions.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 78e277155464e3f4da5f2de1acdc178a
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Data/EnemyDefinitions/EnemyDefinition.asset b/Assets/_Project/Data/EnemyDefinitions/EnemyDefinition.asset
new file mode 100644
index 0000000..358ce73
--- /dev/null
+++ b/Assets/_Project/Data/EnemyDefinitions/EnemyDefinition.asset
@@ -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}
diff --git a/Assets/_Project/Data/EnemyDefinitions/EnemyDefinition.asset.meta b/Assets/_Project/Data/EnemyDefinitions/EnemyDefinition.asset.meta
new file mode 100644
index 0000000..7ef84a9
--- /dev/null
+++ b/Assets/_Project/Data/EnemyDefinitions/EnemyDefinition.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 4e85a539eac1ed64cbd972db4914ca3d
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Data/WaveDefinitions.meta b/Assets/_Project/Data/WaveDefinitions.meta
new file mode 100644
index 0000000..cf94da6
--- /dev/null
+++ b/Assets/_Project/Data/WaveDefinitions.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: e7fd2a054c9dc4c4e96bb1ac4877eda3
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Data/WaveDefinitions/Wave1Definition.asset b/Assets/_Project/Data/WaveDefinitions/Wave1Definition.asset
new file mode 100644
index 0000000..4cb34df
--- /dev/null
+++ b/Assets/_Project/Data/WaveDefinitions/Wave1Definition.asset
@@ -0,0 +1,19 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 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
diff --git a/Assets/_Project/Data/WaveDefinitions/Wave1Definition.asset.meta b/Assets/_Project/Data/WaveDefinitions/Wave1Definition.asset.meta
new file mode 100644
index 0000000..5360190
--- /dev/null
+++ b/Assets/_Project/Data/WaveDefinitions/Wave1Definition.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 65f66289ea1233b4897f46cd997d9c7a
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Prefabs/Enemies/EnemyPlaceholder.prefab b/Assets/_Project/Prefabs/Enemies/EnemyPlaceholder.prefab
index 5cd3c2d..e2f608c 100644
--- a/Assets/_Project/Prefabs/Enemies/EnemyPlaceholder.prefab
+++ b/Assets/_Project/Prefabs/Enemies/EnemyPlaceholder.prefab
@@ -16,6 +16,7 @@ GameObject:
- component: {fileID: 5830540397649648793}
- component: {fileID: 2892684246239657319}
- component: {fileID: 8213527798879671990}
+ - component: {fileID: 3283904430289710888}
m_Layer: 10
m_Name: EnemyPlaceholder
m_TagString: Untagged
@@ -128,7 +129,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
- GlobalObjectIdHash: 4022812445
+ GlobalObjectIdHash: 1257430264
InScenePlacedSourceGlobalObjectIdHash: 0
DeferredDespawnTick: 0
Ownership: 1
@@ -200,7 +201,6 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
ShowTopMostFoldoutHeaderGroup: 1
- maxHp: 100
--- !u!114 &8213527798879671990
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -214,3 +214,16 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyStatus
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
diff --git a/Assets/_Project/Scenes/Levels/Main.unity b/Assets/_Project/Scenes/Levels/Main.unity
index 5e9d01f..1ef8918 100644
--- a/Assets/_Project/Scenes/Levels/Main.unity
+++ b/Assets/_Project/Scenes/Levels/Main.unity
@@ -739,11 +739,11 @@ Transform:
m_GameObject: {fileID: 611926972}
serializedVersion: 2
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_ConstrainProportionsScale: 0
m_Children: []
- m_Father: {fileID: 0}
+ m_Father: {fileID: 1994440963}
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
--- !u!1 &643505902
GameObject:
@@ -851,11 +851,11 @@ Transform:
m_GameObject: {fileID: 643505902}
serializedVersion: 2
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_ConstrainProportionsScale: 0
m_Children: []
- m_Father: {fileID: 0}
+ m_Father: {fileID: 1994440963}
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
--- !u!1 &832575517
GameObject:
@@ -1255,6 +1255,50 @@ BoxCollider:
serializedVersion: 3
m_Size: {x: 7, y: 1, z: 7}
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
GameObject:
m_ObjectHideFlags: 0
@@ -1473,6 +1517,80 @@ BoxCollider:
serializedVersion: 3
m_Size: {x: 7, y: 1, z: 4}
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
GameObject:
m_ObjectHideFlags: 0
@@ -1579,12 +1697,12 @@ Transform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1464027360}
serializedVersion: 2
- m_LocalRotation: {x: 0, y: 0.7071068, z: 0, w: 0.7071068}
- m_LocalPosition: {x: 14, y: 0, z: 41}
+ m_LocalRotation: {x: -0, y: 0.7071068, z: -0, w: 0.7071068}
+ m_LocalPosition: {x: -29, y: -2, z: 0}
m_LocalScale: {x: 10, y: 1, z: 5}
m_ConstrainProportionsScale: 0
m_Children: []
- m_Father: {fileID: 0}
+ m_Father: {fileID: 1994440963}
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
--- !u!1 &1507514106
GameObject:
@@ -1705,6 +1823,8 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: a9dc0fbbe4422bc479ab8db7658c082b, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.TowerRegistry
+ definitions:
+ - {fileID: 11400000, guid: 0f693e29ca953e1439e10cb8f12e4b30, type: 2}
--- !u!1 &1597884408
GameObject:
m_ObjectHideFlags: 0
@@ -1966,11 +2086,11 @@ Transform:
m_GameObject: {fileID: 1949204941}
serializedVersion: 2
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_ConstrainProportionsScale: 0
m_Children: []
- m_Father: {fileID: 0}
+ m_Father: {fileID: 1994440963}
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
--- !u!1 &1975687919
GameObject:
@@ -2040,6 +2160,43 @@ BoxCollider:
serializedVersion: 3
m_Size: {x: 29, y: 1, z: 34}
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
GameObject:
m_ObjectHideFlags: 0
@@ -2145,12 +2302,12 @@ Transform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2024858685}
serializedVersion: 2
- m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
- m_LocalPosition: {x: 18, y: 2, z: 90}
+ m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
+ m_LocalPosition: {x: -25, y: 0, z: 49}
m_LocalScale: {x: 50, y: 5, z: 5}
m_ConstrainProportionsScale: 0
m_Children: []
- m_Father: {fileID: 0}
+ m_Father: {fileID: 1994440963}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &2105067734
GameObject:
@@ -2258,11 +2415,11 @@ Transform:
m_GameObject: {fileID: 2105067734}
serializedVersion: 2
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_ConstrainProportionsScale: 0
m_Children: []
- m_Father: {fileID: 0}
+ m_Father: {fileID: 1994440963}
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
--- !u!1660057539 &9223372036854775807
SceneRoots:
@@ -2272,18 +2429,15 @@ SceneRoots:
- {fileID: 832575519}
- {fileID: 1682341402}
- {fileID: 441239881}
- - {fileID: 1464027364}
- {fileID: 167151709}
- {fileID: 1507514109}
- {fileID: 1538763654}
- {fileID: 1597884409}
- {fileID: 1239994224}
- - {fileID: 2024858689}
- - {fileID: 1949204945}
- - {fileID: 643505906}
- - {fileID: 2105067738}
- - {fileID: 611926976}
+ - {fileID: 1994440963}
- {fileID: 1222526238}
- {fileID: 1058315976}
- {fileID: 1168515846}
- {fileID: 902199262}
+ - {fileID: 1380211462}
+ - {fileID: 1149980841}
diff --git a/Assets/_Project/Scripts/Combat/Projectile.cs b/Assets/_Project/Scripts/Combat/Projectile.cs
index a645963..c22e8fb 100644
--- a/Assets/_Project/Scripts/Combat/Projectile.cs
+++ b/Assets/_Project/Scripts/Combat/Projectile.cs
@@ -12,35 +12,28 @@ namespace TD.Combat
///
///
/// Authority: Movement and hit detection run server-only.
- /// NetworkTransform (required on the prefab) replicates the position to
- /// clients so the projectile is visible on all peers.
+ /// NetworkTransform (required on the prefab) replicates position to clients
+ /// so the projectile is visible on all peers.
///
- /// Initialization: Mirrors the TowerInstance.InitializeServer pattern —
- /// is called by TowerCombat immediately after
- /// Instantiate and before NetworkObject.Spawn(), which avoids writing
- /// to NetworkVariables before spawn.
+ /// Initialization: Call after
+ /// Instantiate and before NetworkObject.Spawn().
///
- /// Target loss: If the target dies or is destroyed before the projectile
- /// arrives, the projectile despawns silently (no hit, no damage).
+ /// Kill attribution: is the
+ /// of the firing tower. It is passed to
+ /// and
+ /// so kill gold credits the correct player even for projectile kills.
///
- /// Chain + Projectile: By design, TargetType.Chain is hitscan. If a designer
- /// sets TargetType = Chain on a tower that has a ProjectilePrefab, the projectile
- /// will hit the primary target only and ignore the chain. Log a warning to surface
- /// the misconfiguration.
+ /// Chain + Projectile: is hitscan-only.
+ /// A projectile with Chain type will hit the primary target only and log a warning.
///
- /// Prefab requirements: Must have NetworkObject, NetworkTransform,
- /// and this Projectile component at the root.
+ /// Prefab requirements: NetworkObject, NetworkTransform,
+ /// and this component at the root.
///
[RequireComponent(typeof(NetworkObject))]
public class Projectile : NetworkBehaviour
{
- // Hit threshold: squared distance at which the projectile considers itself
- // 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;
+ private const float HitThresholdSq = 0.09f; // 0.3 world units
- // All fields are server-local. Set by InitializeServer before Spawn.
private EnemyHealth target;
private float damage;
private DamageType damageType;
@@ -51,18 +44,17 @@ namespace TD.Combat
private float effectDuration;
private float speed;
private LayerMask enemyLayerMask;
+ private PlayerSlot sourceOwner;
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];
- // ----- Initialization (server-only, called before Spawn) -----------
+ // ----- Pre-spawn init ---------------------------------------------
///
- /// Stores all data this projectile needs to travel and apply damage.
- /// Call this immediately after Instantiate and before
- /// NetworkObject.Spawn().
+ /// Called by after Instantiate and before
+ /// NetworkObject.Spawn(). is the firing
+ /// tower's for kill-gold attribution.
///
public void InitializeServer(
EnemyHealth target,
@@ -74,7 +66,8 @@ namespace TD.Combat
float dotDamagePerSecond,
float effectDuration,
float speed,
- LayerMask enemyLayerMask)
+ LayerMask enemyLayerMask,
+ PlayerSlot sourceOwner)
{
this.target = target;
this.damage = damage;
@@ -86,24 +79,21 @@ namespace TD.Combat
this.effectDuration = effectDuration;
this.speed = speed;
this.enemyLayerMask = enemyLayerMask;
+ this.sourceOwner = sourceOwner;
initialized = true;
if (targetType == TargetType.Chain)
Debug.LogWarning("[Projectile] TargetType.Chain is hitscan-only. " +
- "This projectile will hit the primary target only. " +
- "Consider using hitscan (null ProjectilePrefab) for chain towers.");
+ "This projectile will hit the primary target only.");
}
- // ----- Server movement + hit detection -----------------------------
+ // ----- Server movement + hit detection ----------------------------
private void Update()
{
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();
return;
@@ -118,20 +108,18 @@ namespace TD.Combat
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.position += toTarget.normalized * (speed * Time.deltaTime);
}
- // ----- Hit application ---------------------------------------------
+ // ----- Hit application --------------------------------------------
private void ApplyHit()
{
switch (targetType)
{
case TargetType.Single:
- case TargetType.Chain: // chain falls back to single-target on projectiles
+ case TargetType.Chain:
HitEnemy(target);
break;
@@ -156,7 +144,7 @@ namespace TD.Combat
private void HitEnemy(EnemyHealth eh)
{
- eh.TakeDamage(damage, damageType);
+ eh.TakeDamage(damage, damageType, sourceOwner);
if (effectDuration > 0f)
{
@@ -170,7 +158,7 @@ namespace TD.Combat
if (magnitude > 0f)
eh.GetComponent()
- ?.ApplyEffect(damageType, magnitude, effectDuration);
+ ?.ApplyEffect(damageType, magnitude, effectDuration, sourceOwner);
}
}
}
diff --git a/Assets/_Project/Scripts/Combat/TowerCombat.cs b/Assets/_Project/Scripts/Combat/TowerCombat.cs
index 8d5d6fb..168a892 100644
--- a/Assets/_Project/Scripts/Combat/TowerCombat.cs
+++ b/Assets/_Project/Scripts/Combat/TowerCombat.cs
@@ -249,24 +249,27 @@ namespace TD.Combat
private void ApplyDamageToTarget(TowerDefinition def, EnemyHealth primary, Vector3 primaryPos)
{
+ PlayerSlot owner = towerInstance.Owner;
+
switch (def.TargetType)
{
case TargetType.Single:
- HitEnemy(def, primary);
+ HitEnemy(def, primary, owner);
break;
case TargetType.Splash:
- HitEnemy(def, primary);
- ApplySplash(def, primary, primaryPos);
+ HitEnemy(def, primary, owner);
+ ApplySplash(def, primary, primaryPos, owner);
break;
case TargetType.Chain:
- ApplyChain(def, primary);
+ ApplyChain(def, primary, owner);
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;
@@ -277,25 +280,22 @@ namespace TD.Combat
{
var eh = s_overlapBuffer[i].GetComponent();
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 { primary.transform.position };
var alreadyHit = new HashSet { primary };
- HitEnemy(def, primary);
+ HitEnemy(def, primary, owner);
EnemyHealth current = primary;
for (int jump = 0; jump < def.ChainCount; jump++)
{
- EnemyHealth next = null;
- float bestSqr = float.MaxValue;
+ EnemyHealth next = null;
+ float bestSqr = float.MaxValue;
int count = Physics.OverlapSphereNonAlloc(
current.transform.position, def.ChainRange, s_overlapBuffer, enemyLayerMask);
@@ -313,20 +313,20 @@ namespace TD.Combat
alreadyHit.Add(next);
hitPositions.Add(next.transform.position);
- HitEnemy(def, next);
+ HitEnemy(def, next, owner);
current = next;
}
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);
- ApplyStatusEffect(def, target);
+ target.TakeDamage(def.Damage, def.DamageType, owner);
+ 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;
@@ -341,7 +341,7 @@ namespace TD.Combat
if (magnitude <= 0f) return;
target.GetComponent()
- ?.ApplyEffect(def.DamageType, magnitude, def.EffectDuration);
+ ?.ApplyEffect(def.DamageType, magnitude, def.EffectDuration, owner);
}
// ----- Projectile spawning -----------------------------------------
@@ -370,7 +370,8 @@ namespace TD.Combat
def.DotDamagePerSecond,
def.EffectDuration,
def.ProjectileSpeed,
- enemyLayerMask);
+ enemyLayerMask,
+ towerInstance.Owner);
go.GetComponent().Spawn();
}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs
new file mode 100644
index 0000000..776ed75
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs
@@ -0,0 +1,50 @@
+// Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs
+using UnityEngine;
+
+namespace TD.Gameplay
+{
+ ///
+ /// Data definition for a single enemy type. One asset per type; shared across all
+ /// instances spawned in a match. Consumed by at spawn time.
+ ///
+ ///
+ /// Follows the same ScriptableObject pattern as TowerDefinition: data lives
+ /// in project assets, only the asset reference (or its fields) crosses runtime code.
+ /// Replace with a real mesh/animator when art is ready —
+ /// no code changes required.
+ ///
+ [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;
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs.meta
new file mode 100644
index 0000000..66b58b6
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c0d2521d49d21fe4380434f5951944d1
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs b/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs
index 05e38e4..10b84c3 100644
--- a/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs
+++ b/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs
@@ -6,65 +6,129 @@ using TD.Core;
namespace TD.Gameplay
{
///
- /// Per-enemy HP component. Holds replicated HP and is the single point
- /// through which all damage flows, so resistance lookups (Phase 1.5+) can
- /// be added in one place without touching every damage source.
+ /// Per-enemy HP component. Single point through which all damage flows so
+ /// resistance lookups (Phase 1.5+) and kill attribution remain in one place.
///
///
- /// Lives on the enemy prefab root alongside and the
- /// future EnemyMovement component (Phase 1.5/1.6). HP is server-written
- /// and replicated to all clients so health bars can render on any peer.
+ /// Initialization: Call on the server
+ /// immediately after Instantiate and before NetworkObject.Spawn(),
+ /// following the same pattern as TowerInstance.InitializeServer.
///
- /// Death flow (server-only):
- /// TakeDamage clamps HP to 0, fires , then calls
- /// NetworkObject.Despawn. Subscribers must not touch the NetworkObject
- /// after OnDied returns.
+ /// Kill attribution: tracks the
+ /// of the tower that most recently dealt direct damage.
+ /// DoT ticks from EnemyStatus also carry an owner so the credit
+ /// follows the source tower, not the DoT applicator.
+ ///
+ /// Death flow (server-only): clamps HP to 0,
+ /// fires , then calls NetworkObject.Despawn.
+ /// Subscribers must not touch the NetworkObject after returns.
///
[RequireComponent(typeof(NetworkObject))]
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 -------------------------------------
+
+ /// Gold awarded to the killing player when this enemy dies.
+ public int GoldReward { get; private set; }
+
+ /// Lives deducted from the shared pool when this enemy reaches the goal.
+ public int LivesCost { get; private set; } = 1;
+
+ ///
+ /// The of the tower that last dealt direct damage.
+ /// Used by WaveManager to award kill gold to the correct player.
+ /// Updated on every call, including DoT ticks whose
+ /// source owner is tracked on .
+ ///
+ public PlayerSlot LastHitOwner { get; private set; } = PlayerSlot.None;
+
+ // ----- Networked state ------------------------------------------------
private readonly NetworkVariable hp = new NetworkVariable(
0f,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
- // ----- Public state -----------------------------------------------
+ private readonly NetworkVariable isFlying = new NetworkVariable(
+ false,
+ NetworkVariableReadPermission.Everyone,
+ NetworkVariableWritePermission.Server);
+
+ // ----- Public state ---------------------------------------------------
public float CurrentHp => hp.Value;
- public float MaxHp => maxHp;
+ public float MaxHp { get; private set; } = 100f;
public bool IsDead => hp.Value <= 0f;
- // Stub: set by EnemyMovement or spawner in Phase 1.5/1.6.
- // TowerCombat reads this to honour the GroundedOnly tower flag.
- public bool IsFlying => false;
+ ///
+ /// True if this enemy flies over tower footprints.
+ /// Replicated so client visuals can adjust altitude.
+ /// Grounded towers with GroundedOnly=true will not target flying enemies.
+ ///
+ public bool IsFlying => isFlying.Value;
- // ----- Events -----------------------------------------------------
+ // ----- Events ---------------------------------------------------------
///
/// Fired on the server immediately before the enemy NetworkObject is despawned.
- /// subscribes to clear its target reference.
+ /// WaveManager subscribes to credit kill gold and decrement wave count.
/// Do not access the NetworkObject after this event returns.
///
public event System.Action OnDied;
- // ----- NGO lifecycle ----------------------------------------------
+ // ----- Server-only pre-spawn init -------------------------------------
+
+ ///
+ /// Called by WaveManager on the server after Instantiate
+ /// and before NetworkObject.Spawn(). Mirrors the
+ /// TowerInstance.InitializeServer pattern.
+ ///
+ 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()
{
- if (IsServer)
- hp.Value = maxHp;
+ if (IsServer && hasPendingInit)
+ {
+ hp.Value = pendingMaxHp;
+ isFlying.Value = pendingIsFlying;
+ hasPendingInit = false;
+ }
+
+ // Non-server clients resolve MaxHp from the replicated hp initial value.
+ if (!IsServer)
+ MaxHp = hp.Value;
}
- // ----- Server API -------------------------------------------------
+ // ----- Server API -----------------------------------------------------
///
- /// Applies to this enemy. Server-only; no-op on clients.
- /// is recorded for future resistance/weakness lookups —
- /// all damage is full-value until the resistance table is implemented (Phase 1.5+).
+ /// Applies damage to this enemy. Server-only; silently no-ops on clients.
+ /// is accepted for future resistance lookups (Phase 1.5+).
+ /// identifies the tower owner for kill attribution.
///
- public void TakeDamage(float damage, DamageType type)
+ public void TakeDamage(float damage, DamageType type, PlayerSlot attackerSlot)
{
if (!IsServer) return;
if (IsDead) return;
@@ -73,13 +137,14 @@ namespace TD.Gameplay
// float modified = ResistanceTable.Apply(damage, type, this);
float modified = damage;
- hp.Value = Mathf.Max(0f, hp.Value - modified);
+ LastHitOwner = attackerSlot;
+ hp.Value = Mathf.Max(0f, hp.Value - modified);
if (hp.Value <= 0f)
HandleDeath();
}
- // ----- Private ----------------------------------------------------
+ // ----- Private --------------------------------------------------------
private void HandleDeath()
{
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs
new file mode 100644
index 0000000..5700408
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs
@@ -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
+{
+ ///
+ /// Server-authoritative enemy movement along a dynamically computed A* path.
+ ///
+ ///
+ /// Initialization: Call after Instantiate
+ /// and before NetworkObject.Spawn(). Provides the base move speed (from
+ /// ) and the tile the enemy spawned on.
+ ///
+ /// Path lifecycle:
+ ///
+ /// - (server): queries
+ /// and stores the tile waypoint list.
+ /// - Each frame: moves toward the world center of remainingPath[0].
+ /// When within snap distance, pops the waypoint and checks for zone transitions.
+ /// - When fires (tower placed /
+ /// sold), reruns A* from the current tile.
+ /// - When remainingPath is empty after a pop, the enemy has reached the
+ /// goal — fires and the enemy is despawned.
+ ///
+ ///
+ /// Zone leak tracking: Each time a waypoint is popped,
+ /// is compared against currentZone. A change means the enemy has crossed a zone
+ /// boundary. fires with the zone being LEFT so WaveManager
+ /// can debit the correct player's life pool.
+ ///
+ /// Speed: Effective speed = moveSpeed * EnemyStatus.GetSpeedMultiplier().
+ /// EnemyStatus replicates the multiplier as a NetworkVariable so movement looks
+ /// correct on all peers.
+ ///
+ /// Movement replication: Requires a NetworkTransform on the prefab.
+ /// Position is written by the server; NetworkTransform interpolates on clients.
+ ///
+ [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 remainingPath = new List();
+ private PlayerSlot currentZone = PlayerSlot.None;
+ private EnemyStatus status;
+
+ // ----- Events ---------------------------------------------------------
+
+ ///
+ /// 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.
+ /// WaveManager subscribes to deduct from the correct player's life pool.
+ ///
+ public event System.Action OnZoneLeaked;
+
+ ///
+ /// Fired on the server when the enemy reaches the goal tile.
+ /// Carries this component and the enemy's
+ /// so WaveManager can deduct the right number of lives in one call.
+ /// The NetworkObject is despawned immediately after subscribers return.
+ ///
+ public event System.Action OnReachedGoal;
+
+ // ----- Server-only pre-spawn init -------------------------------------
+
+ ///
+ /// Called by WaveManager on the server after Instantiate and
+ /// before NetworkObject.Spawn(). comes from
+ /// ; is
+ /// the tile the enemy spawns on (used as the A* start node).
+ ///
+ public void InitializeServer(float speed, Vector2Int spawnerTile)
+ {
+ pendingMoveSpeed = speed;
+ pendingSpawnerTile = spawnerTile;
+ hasPendingInit = true;
+ }
+
+ // ----- NGO lifecycle --------------------------------------------------
+
+ public override void OnNetworkSpawn()
+ {
+ status = GetComponent();
+
+ 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();
+ 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.");
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs.meta
new file mode 100644
index 0000000..fd2c7eb
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: fd6c02bbcc13fb14a9d596d9a2544dcc
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/EnemyStatus.cs b/Assets/_Project/Scripts/Gameplay/EnemyStatus.cs
index 7827d70..b11c31c 100644
--- a/Assets/_Project/Scripts/Gameplay/EnemyStatus.cs
+++ b/Assets/_Project/Scripts/Gameplay/EnemyStatus.cs
@@ -17,45 +17,42 @@ namespace TD.Gameplay
/// - Poison — damage per second applied as a DoT tick
/// - Others — unused (magnitude = 0)
///
+ /// SourceOwner carries the of the tower that
+ /// applied this effect so DoT kill credit goes to the right player.
///
public struct StatusEffect
{
public DamageType Source;
public float Magnitude;
public float RemainingDuration;
+ public PlayerSlot SourceOwner;
}
///
/// Tracks and ticks lingering status effects (slow, burn, poison) on an enemy.
///
///
- /// Authority: The active-effect list is server-local (not replicated).
- /// Only the derived NetworkVariable is replicated,
- /// so EnemyMovement (Phase 1.5/1.6) can scale speed on all peers without
- /// re-broadcasting the full effect list.
+ /// Authority: The active-effect list is server-local. Only the derived
+ /// NetworkVariable is replicated so
+ /// EnemyMovement can scale speed on all peers.
///
- /// Stacking rule: A second hit of the same refreshes
- /// the duration and magnitude rather than stacking. Cross-type interactions (e.g.
- /// Cold + Fire) are not yet implemented; is the hook for
- /// when that design is worked out.
+ /// Stacking rule: Re-hitting with the same
+ /// refreshes duration and magnitude; it does not stack. Cross-type interactions
+ /// are not implemented; is the hook for future work.
///
- /// DoT damage is applied by calling each
- /// tick so resistance lookups remain in one place.
+ /// DoT damage calls with the original
+ /// so kill attribution stays correct.
///
[RequireComponent(typeof(NetworkObject))]
public class EnemyStatus : NetworkBehaviour
{
- // Replicated so EnemyMovement can read it on all clients without
- // knowing anything about which effects are active.
private readonly NetworkVariable speedMultiplier = new NetworkVariable(
1f,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
- // Server-local — only the derived speedMultiplier NV crosses the wire.
private readonly List activeEffects = new List();
- // Resolved once; used by Tick for DoT TakeDamage calls.
private EnemyHealth health;
// ----- NGO lifecycle -----------------------------------------------
@@ -67,10 +64,10 @@ namespace TD.Gameplay
// ----- Public API --------------------------------------------------
- /// Current speed fraction (0–1). 1 = full speed, 0.5 = half speed, etc.
+ /// Current speed fraction (0–1). 1 = full speed, 0.5 = half speed.
public float GetSpeedMultiplier() => speedMultiplier.Value;
- /// True if an effect of the given type is currently active on this enemy.
+ /// True if an effect of the given type is currently active.
public bool HasEffect(DamageType type)
{
for (int i = 0; i < activeEffects.Count; i++)
@@ -80,9 +77,12 @@ namespace TD.Gameplay
///
/// 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.
+ /// is the tower's — carried
+ /// on the effect so DoT ticks credit the right player on a kill.
///
- public void ApplyEffect(DamageType source, float magnitude, float duration)
+ public void ApplyEffect(DamageType source, float magnitude, float duration,
+ PlayerSlot owner)
{
if (!IsServer) return;
@@ -91,9 +91,10 @@ namespace TD.Gameplay
if (activeEffects[i].Source != source) continue;
var e = activeEffects[i];
- e.Magnitude = magnitude;
- e.RemainingDuration = duration;
- activeEffects[i] = e;
+ e.Magnitude = magnitude;
+ e.RemainingDuration = duration;
+ e.SourceOwner = owner;
+ activeEffects[i] = e;
RecalculateSpeedMultiplier();
return;
}
@@ -103,6 +104,7 @@ namespace TD.Gameplay
Source = source,
Magnitude = magnitude,
RemainingDuration = duration,
+ SourceOwner = owner,
});
RecalculateSpeedMultiplier();
}
@@ -123,11 +125,11 @@ namespace TD.Gameplay
{
var e = activeEffects[i];
- // Apply DoT for Fire and Poison.
- if (e.Source == DamageType.Fire || e.Source == DamageType.Poison)
+ // DoT tick — pass the original source owner so kill credit is correct.
+ 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);
+ health.TakeDamage(e.Magnitude * dt, e.Source, e.SourceOwner);
}
e.RemainingDuration -= dt;
diff --git a/Assets/_Project/Scripts/Gameplay/LevelLoader.cs b/Assets/_Project/Scripts/Gameplay/LevelLoader.cs
index bfa8fe0..9827bf3 100644
--- a/Assets/_Project/Scripts/Gameplay/LevelLoader.cs
+++ b/Assets/_Project/Scripts/Gameplay/LevelLoader.cs
@@ -365,6 +365,14 @@ namespace TD.Gameplay
// NetworkObject spawns and its Start/OnNetworkSpawn stamps its own
// footprint locally.
+ ///
+ /// Fired on every peer whenever changes a tile's
+ /// walkability. subscribes to
+ /// invalidate cached paths so in-flight enemies reroute after a tower is
+ /// placed or removed.
+ ///
+ public event System.Action OnWalkabilityChanged;
+
///
/// Sets the runtime walkability of . Called by
/// TowerPlacementManager on the server when a tower is accepted (pass
@@ -374,7 +382,9 @@ namespace TD.Gameplay
public void SetWalkable(Vector2Int tile, bool walkable)
{
if (!TryFlatIndex(tile, out int idx)) return;
+ if (runtimeWalkability[idx] == walkable) return; // no change — don't fire event
runtimeWalkability[idx] = walkable;
+ OnWalkabilityChanged?.Invoke();
}
///
diff --git a/Assets/_Project/Scripts/Gameplay/PathfindingService.cs b/Assets/_Project/Scripts/Gameplay/PathfindingService.cs
new file mode 100644
index 0000000..d0a2216
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/PathfindingService.cs
@@ -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
+{
+ ///
+ /// Scene singleton that computes shortest-path routes from any tile to the
+ /// nearest goal tile using A* on the runtime walkability grid.
+ ///
+ ///
+ /// Algorithm: 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.
+ ///
+ /// Who calls this:
+ ///
+ /// - calls once on
+ /// spawn and again whenever fires.
+ ///
+ ///
+ /// Invalidation: Subscribes to .
+ /// When a tower is placed or sold, LevelLoader.SetWalkable fires that event
+ /// and is relayed to all active enemies, which
+ /// each recompute their own path from their current tile.
+ ///
+ /// Goal tile set: Built once on Start from
+ /// LevelLoader.LevelData.Goals[].TileArea. Goal tiles never change at
+ /// runtime (they are baked into the level), so there is no need to rebuild the set.
+ ///
+ /// No caching: 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.
+ ///
+ public class PathfindingService : MonoBehaviour
+ {
+ // ----- Singleton --------------------------------------------------
+
+ public static PathfindingService Instance { get; private set; }
+
+ // ----- Events -----------------------------------------------------
+
+ ///
+ /// Fired on every peer when the walkability grid changes (tower placed/sold).
+ /// subscribes per-instance to recompute its path.
+ ///
+ public event System.Action OnPathsInvalidated;
+
+ // ----- State ------------------------------------------------------
+
+ // Built once on Start from LevelData.Goals[].TileArea.
+ private HashSet 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 cameFrom = new Dictionary();
+ private readonly Dictionary gScore = new Dictionary();
+ 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 -------------------------------------------------
+
+ ///
+ /// Computes the shortest walkable path from to
+ /// the nearest goal tile. Returns an ordered list of tiles to visit, starting
+ /// with the first step AFTER 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).
+ ///
+ public List 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();
+ }
+
+ return RunAStar(startTile, loader);
+ }
+
+ // ----- A* implementation ------------------------------------------
+
+ private List 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();
+ }
+
+ // 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 ReconstructPath(Vector2Int start, Vector2Int goal)
+ {
+ var path = new List();
+ 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();
+ 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;
+ }
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/PathfindingService.cs.meta b/Assets/_Project/Scripts/Gameplay/PathfindingService.cs.meta
new file mode 100644
index 0000000..7c21daa
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/PathfindingService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: de7d013503af0f74c950f215f8dae1c0
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs b/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs
index 3507d31..884a5c8 100644
--- a/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs
+++ b/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs
@@ -48,6 +48,19 @@ namespace TD.Gameplay
public static PlayerSlot SlotForClient(ulong clientId)
=> GetForClient(clientId)?.Slot ?? PlayerSlot.None;
+ ///
+ /// Returns the whose assigned slot matches
+ /// , or null if no connected client holds that slot.
+ /// O(n) over connected players (max 9) — acceptable for server-side use.
+ /// Used by WaveManager to resolve kill-gold recipients.
+ ///
+ public static PlayerMatchState GetForSlot(PlayerSlot slot)
+ {
+ foreach (var pms in s_byClientId.Values)
+ if (pms.Slot == slot) return pms;
+ return null;
+ }
+
///
/// The local client's own state. Null on a dedicated server or before the
/// local player has spawned.
diff --git a/Assets/_Project/Scripts/Gameplay/TowerRegistry.cs b/Assets/_Project/Scripts/Gameplay/TowerRegistry.cs
index 9761603..389df11 100644
--- a/Assets/_Project/Scripts/Gameplay/TowerRegistry.cs
+++ b/Assets/_Project/Scripts/Gameplay/TowerRegistry.cs
@@ -15,14 +15,13 @@ namespace TD.Gameplay
/// the full ScriptableObject locally on every client. TowerRegistry is the lookup
/// table that makes that resolution possible without hard-coding asset paths.
///
- /// Auto-discovery. On Awake, all assets
- /// under Resources/TowerDefinitions/ are loaded automatically. No inspector
- /// drag-and-drop required — add a new asset to that folder and it is registered at
- /// runtime with no other changes needed. This scales cleanly to 100+ tower types.
+ /// Registration. Drag every asset into the
+ /// Definitions list on this component in the inspector. Assets can live anywhere
+ /// in the project — no special folder required.
///
/// Path E upgrade path. In Path E the registry will filter to only the
/// definitions belonging to the active match's RaceDefinition rosters. For now
- /// all assets in the Resources folder are registered.
+ /// all assigned assets are registered.
///
/// Plain MonoBehaviour. Not a NetworkBehaviour — the registry is
/// identical on every peer (same assets, same names), so there is nothing to sync.
@@ -37,14 +36,12 @@ namespace TD.Gameplay
///
public static TowerRegistry Instance { get; private set; }
- // ----- Constants --------------------------------------------------
+ // ----- Inspector --------------------------------------------------
- ///
- /// Resources-relative folder path that TowerDefinition assets must live under
- /// to be auto-discovered. Create this folder if it doesn't exist.
- /// Full path: Assets/Resources/TowerDefinitions/
- ///
- private const string ResourcesFolder = "TowerDefinitions";
+ [Tooltip("All TowerDefinition assets available in this match. " +
+ "Drag assets here from Assets/_Project/Data/TowerDefinitions/ " +
+ "(or wherever they live). Asset name is used as the registry key.")]
+ [SerializeField] private TowerDefinition[] definitions;
// ----- Internal lookup table --------------------------------------
@@ -96,21 +93,14 @@ namespace TD.Gameplay
{
byName.Clear();
- // Resources.LoadAll finds every TowerDefinition asset anywhere under
- // 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(ResourcesFolder);
-
- if (loaded.Length == 0)
+ if (definitions == null || definitions.Length == 0)
{
- Debug.LogWarning($"[TowerRegistry] No TowerDefinition assets found under " +
- $"Resources/{ResourcesFolder}/. " +
- $"Create the folder and add TowerDefinition assets to it.");
+ Debug.LogWarning("[TowerRegistry] No TowerDefinition assets assigned. " +
+ "Drag assets into the Definitions list on the TowerRegistry component.");
return;
}
- foreach (var def in loaded)
+ foreach (var def in definitions)
{
if (def == null) continue;
@@ -124,8 +114,7 @@ namespace TD.Gameplay
byName[def.name] = def;
}
- Debug.Log($"[TowerRegistry] Auto-discovered and registered " +
- $"{byName.Count} tower definition(s) from Resources/{ResourcesFolder}/.");
+ Debug.Log($"[TowerRegistry] Registered {byName.Count} tower definition(s).");
}
}
}
diff --git a/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs b/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs
new file mode 100644
index 0000000..16a9e4c
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs
@@ -0,0 +1,45 @@
+// Assets/_Project/Scripts/Gameplay/WaveDefinition.cs
+using System;
+using UnityEngine;
+
+namespace TD.Gameplay
+{
+ ///
+ /// A single spawn group within a wave: one enemy type, how many of them,
+ /// and how long to wait between each spawn.
+ ///
+ [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;
+ }
+
+ ///
+ /// Defines the composition of a single wave. One asset per wave; referenced in
+ /// order by .
+ ///
+ ///
+ /// 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.
+ ///
+ [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;
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs.meta
new file mode 100644
index 0000000..60f1e2a
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 48e93688dc0fb5b4cbe7be9a241b4421
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/WaveManager.cs b/Assets/_Project/Scripts/Gameplay/WaveManager.cs
new file mode 100644
index 0000000..7383305
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/WaveManager.cs
@@ -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
+{
+ ///
+ /// Server-authoritative wave controller. Spawns enemies across all player zones,
+ /// tracks wave completion, awards kill gold, and manages the shared lives pool.
+ ///
+ ///
+ /// Wave lifecycle:
+ ///
+ /// - When is entered,
+ /// advances
+ /// immediately (so the HUD shows the wave number during prep), then waits
+ /// before spawning.
+ /// - Each spawns Count enemies per player zone,
+ /// one zone per frame-group, with SpawnInterval seconds between
+ /// individual enemies in the group.
+ /// - After all entries are spawned, the wave is considered complete only when
+ /// every active enemy is either killed or has reached the goal.
+ /// - All waves exhausted → .
+ /// - Lives drop to 0 → .
+ ///
+ ///
+ /// Kill gold: When an enemy dies, names
+ /// the tower's player. resolves the
+ /// OwnerClientId, and awards
+ /// the gold.
+ ///
+ /// Zone leak counts: is a NetworkList
+ /// indexed by (int)PlayerSlot (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 and is unused.
+ ///
+ /// Inspector setup:
+ ///
+ /// - Assign in order (Wave 1 at index 0).
+ /// - Set to match your level design intent.
+ ///
+ ///
+ 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 zoneLeakCounts = new NetworkList();
+
+ // ----- 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 -------------------------------------------
+
+ ///
+ /// Number of times enemies have leaked out of the given player's zone.
+ /// Replicated — safe to call on any peer.
+ ///
+ 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();
+ var movement = go.GetComponent();
+
+ 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().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());
+
+ 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();
+ 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();
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/WaveManager.cs.meta b/Assets/_Project/Scripts/Gameplay/WaveManager.cs.meta
new file mode 100644
index 0000000..c88971e
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/WaveManager.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 81d8e215d8419404ea4d959196cd9cc3
\ No newline at end of file