From 3287e8ea432eec53a34ec30fb0f5ab2d665a8703 Mon Sep 17 00:00:00 2001 From: Matt F Date: Tue, 12 May 2026 22:18:23 -0700 Subject: [PATCH] We've got enemies and movement!! --- .claude/settings.local.json | 3 +- Assets/_Project/Data/EnemyDefinitions.meta | 8 + .../EnemyDefinitions/EnemyDefinition.asset | 21 ++ .../EnemyDefinition.asset.meta | 8 + Assets/_Project/Data/WaveDefinitions.meta | 8 + .../WaveDefinitions/Wave1Definition.asset | 19 + .../Wave1Definition.asset.meta | 8 + .../Prefabs/Enemies/EnemyPlaceholder.prefab | 17 +- Assets/_Project/Scenes/Levels/Main.unity | 194 ++++++++-- Assets/_Project/Scripts/Combat/Projectile.cs | 68 ++-- Assets/_Project/Scripts/Combat/TowerCombat.cs | 41 +-- .../Scripts/Gameplay/EnemyDefinition.cs | 50 +++ .../Scripts/Gameplay/EnemyDefinition.cs.meta | 2 + .../_Project/Scripts/Gameplay/EnemyHealth.cs | 121 +++++-- .../Scripts/Gameplay/EnemyMovement.cs | 225 ++++++++++++ .../Scripts/Gameplay/EnemyMovement.cs.meta | 2 + .../_Project/Scripts/Gameplay/EnemyStatus.cs | 52 +-- .../_Project/Scripts/Gameplay/LevelLoader.cs | 10 + .../Scripts/Gameplay/PathfindingService.cs | 279 +++++++++++++++ .../Gameplay/PathfindingService.cs.meta | 2 + .../Scripts/Gameplay/PlayerMatchState.cs | 13 + .../Scripts/Gameplay/TowerRegistry.cs | 39 +-- .../Scripts/Gameplay/WaveDefinition.cs | 45 +++ .../Scripts/Gameplay/WaveDefinition.cs.meta | 2 + .../_Project/Scripts/Gameplay/WaveManager.cs | 331 ++++++++++++++++++ .../Scripts/Gameplay/WaveManager.cs.meta | 2 + 26 files changed, 1409 insertions(+), 161 deletions(-) create mode 100644 Assets/_Project/Data/EnemyDefinitions.meta create mode 100644 Assets/_Project/Data/EnemyDefinitions/EnemyDefinition.asset create mode 100644 Assets/_Project/Data/EnemyDefinitions/EnemyDefinition.asset.meta create mode 100644 Assets/_Project/Data/WaveDefinitions.meta create mode 100644 Assets/_Project/Data/WaveDefinitions/Wave1Definition.asset create mode 100644 Assets/_Project/Data/WaveDefinitions/Wave1Definition.asset.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyMovement.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyMovement.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/PathfindingService.cs create mode 100644 Assets/_Project/Scripts/Gameplay/PathfindingService.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/WaveDefinition.cs create mode 100644 Assets/_Project/Scripts/Gameplay/WaveDefinition.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/WaveManager.cs create mode 100644 Assets/_Project/Scripts/Gameplay/WaveManager.cs.meta 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