From 42ee0bf65defdbda6331315b21d9c3b2ac9f8431 Mon Sep 17 00:00:00 2001 From: Matt F Date: Tue, 12 May 2026 21:31:10 -0700 Subject: [PATCH] Adding major combat changes and features --- .claude/settings.local.json | 8 + Assets/DefaultNetworkPrefabs.asset | 10 + Assets/{Resources.meta => _Project/Data.meta} | 0 .../Data}/TowerDefinitions.meta | 0 .../Data}/TowerDefinitions/BasicTower.asset | 20 +- .../TowerDefinitions/BasicTower.asset.meta | 0 .../Prefabs/Enemies/EnemyPlaceholder.prefab | 216 +++++++++ .../Enemies/EnemyPlaceholder.prefab.meta | 7 + .../Projectiles/ProjectilePlaceholder.prefab | 203 +++++++++ .../ProjectilePlaceholder.prefab.meta | 7 + .../Prefabs/Towers/Tower_Basic.prefab | 94 ++++ Assets/_Project/Scenes/Levels/Main.unity | 16 - Assets/_Project/Scripts/Combat.meta | 8 + Assets/_Project/Scripts/Combat/Projectile.cs | 177 ++++++++ .../Scripts/Combat/Projectile.cs.meta | 2 + Assets/_Project/Scripts/Combat/TowerCombat.cs | 409 ++++++++++++++++++ .../Scripts/Combat/TowerCombat.cs.meta | 2 + .../Scripts/Combat/TowerRangeIndicator.cs | 135 ++++++ .../Combat/TowerRangeIndicator.cs.meta | 2 + Assets/_Project/Scripts/Core/Enums.cs | 43 ++ Assets/_Project/Scripts/Core/_testScript.cs | 12 - .../_Project/Scripts/Core/_testScript.cs.meta | 2 - .../_Project/Scripts/Gameplay/EnemyHealth.cs | 90 ++++ .../Scripts/Gameplay/EnemyHealth.cs.meta | 2 + .../_Project/Scripts/Gameplay/EnemyStatus.cs | 160 +++++++ .../Scripts/Gameplay/EnemyStatus.cs.meta | 2 + .../Scripts/Towers/TowerDefinition.cs | 70 ++- ProjectSettings/TagManager.asset | 2 +- 28 files changed, 1653 insertions(+), 46 deletions(-) create mode 100644 .claude/settings.local.json rename Assets/{Resources.meta => _Project/Data.meta} (100%) rename Assets/{Resources => _Project/Data}/TowerDefinitions.meta (100%) rename Assets/{Resources => _Project/Data}/TowerDefinitions/BasicTower.asset (66%) rename Assets/{Resources => _Project/Data}/TowerDefinitions/BasicTower.asset.meta (100%) create mode 100644 Assets/_Project/Prefabs/Enemies/EnemyPlaceholder.prefab create mode 100644 Assets/_Project/Prefabs/Enemies/EnemyPlaceholder.prefab.meta create mode 100644 Assets/_Project/Prefabs/Projectiles/ProjectilePlaceholder.prefab create mode 100644 Assets/_Project/Prefabs/Projectiles/ProjectilePlaceholder.prefab.meta create mode 100644 Assets/_Project/Scripts/Combat.meta create mode 100644 Assets/_Project/Scripts/Combat/Projectile.cs create mode 100644 Assets/_Project/Scripts/Combat/Projectile.cs.meta create mode 100644 Assets/_Project/Scripts/Combat/TowerCombat.cs create mode 100644 Assets/_Project/Scripts/Combat/TowerCombat.cs.meta create mode 100644 Assets/_Project/Scripts/Combat/TowerRangeIndicator.cs create mode 100644 Assets/_Project/Scripts/Combat/TowerRangeIndicator.cs.meta delete mode 100644 Assets/_Project/Scripts/Core/_testScript.cs delete mode 100644 Assets/_Project/Scripts/Core/_testScript.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyHealth.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyHealth.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyStatus.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyStatus.cs.meta diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..910fa09 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(Get-ChildItem -Path \"C:\\\\Users\\\\catos\\\\UnityTowerDefense\\\\Assets\\\\Scripts\" -Recurse -File -Filter \"*.cs\")", + "Bash(Select-Object -ExpandProperty FullName)" + ] + } +} diff --git a/Assets/DefaultNetworkPrefabs.asset b/Assets/DefaultNetworkPrefabs.asset index 31969b6..82b70f9 100644 --- a/Assets/DefaultNetworkPrefabs.asset +++ b/Assets/DefaultNetworkPrefabs.asset @@ -39,3 +39,13 @@ MonoBehaviour: SourcePrefabToOverride: {fileID: 0} SourceHashToOverride: 0 OverridingTargetPrefab: {fileID: 0} + - Override: 0 + Prefab: {fileID: 1455822126534880203, guid: 0854f339a1958d343a6cb16cd3f907ff, type: 3} + SourcePrefabToOverride: {fileID: 0} + SourceHashToOverride: 0 + OverridingTargetPrefab: {fileID: 0} + - Override: 0 + Prefab: {fileID: 2664719039363295382, guid: dc2e4a4108e03874a8b2dab88dcc8fba, type: 3} + SourcePrefabToOverride: {fileID: 0} + SourceHashToOverride: 0 + OverridingTargetPrefab: {fileID: 0} diff --git a/Assets/Resources.meta b/Assets/_Project/Data.meta similarity index 100% rename from Assets/Resources.meta rename to Assets/_Project/Data.meta diff --git a/Assets/Resources/TowerDefinitions.meta b/Assets/_Project/Data/TowerDefinitions.meta similarity index 100% rename from Assets/Resources/TowerDefinitions.meta rename to Assets/_Project/Data/TowerDefinitions.meta diff --git a/Assets/Resources/TowerDefinitions/BasicTower.asset b/Assets/_Project/Data/TowerDefinitions/BasicTower.asset similarity index 66% rename from Assets/Resources/TowerDefinitions/BasicTower.asset rename to Assets/_Project/Data/TowerDefinitions/BasicTower.asset index 02ff1a6..12cf1f5 100644 --- a/Assets/Resources/TowerDefinitions/BasicTower.asset +++ b/Assets/_Project/Data/TowerDefinitions/BasicTower.asset @@ -18,9 +18,19 @@ MonoBehaviour: GoldCost: 25 BuildTime: 4 TowerPrefab: {fileID: 6482414459531823157, guid: 1511641f145758b469e64376d2a0d434, type: 3} - Damage: 0 - Range: 0 - FireRate: 0 + DamageType: 0 + TargetPriority: 0 + TargetType: 0 + GroundedOnly: 0 + Damage: 10 + Range: 20 + FireRate: 5 SplashRadius: 0 - SlowFactor: 1 - ProjectilePrefab: {fileID: 0} + ChainCount: 0 + ChainRange: 0 + SlowFactor: 0 + DotDamagePerSecond: 0 + EffectDuration: 0 + ProjectilePrefab: {fileID: 2664719039363295382, guid: dc2e4a4108e03874a8b2dab88dcc8fba, type: 3} + ProjectileSpeed: 10 + UpgradePaths: [] diff --git a/Assets/Resources/TowerDefinitions/BasicTower.asset.meta b/Assets/_Project/Data/TowerDefinitions/BasicTower.asset.meta similarity index 100% rename from Assets/Resources/TowerDefinitions/BasicTower.asset.meta rename to Assets/_Project/Data/TowerDefinitions/BasicTower.asset.meta diff --git a/Assets/_Project/Prefabs/Enemies/EnemyPlaceholder.prefab b/Assets/_Project/Prefabs/Enemies/EnemyPlaceholder.prefab new file mode 100644 index 0000000..5cd3c2d --- /dev/null +++ b/Assets/_Project/Prefabs/Enemies/EnemyPlaceholder.prefab @@ -0,0 +1,216 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &1455822126534880203 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 3374291962137961512} + - component: {fileID: 6900825049490657874} + - component: {fileID: 3838554347222657907} + - component: {fileID: 2827184357573667590} + - component: {fileID: 3180894108125880274} + - component: {fileID: 5830540397649648793} + - component: {fileID: 2892684246239657319} + - component: {fileID: 8213527798879671990} + m_Layer: 10 + m_Name: EnemyPlaceholder + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &3374291962137961512 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1455822126534880203} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 19.2967, y: 0.5, z: 17.39775} + 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!33 &6900825049490657874 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1455822126534880203} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!23 &3838554347222657907 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1455822126534880203} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!65 &2827184357573667590 +BoxCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1455822126534880203} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 0 + m_ProvidesContacts: 0 + m_Enabled: 1 + serializedVersion: 3 + m_Size: {x: 1, y: 1, z: 1} + m_Center: {x: 0, y: 0, z: 0} +--- !u!114 &3180894108125880274 +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: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject + GlobalObjectIdHash: 4022812445 + 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!114 &5830540397649648793 +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: e96cb6065543e43c4a752faaa1468eb1, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.Components.NetworkTransform + ShowTopMostFoldoutHeaderGroup: 1 + NetworkTransformExpanded: 0 + AutoOwnerAuthorityTickOffset: 1 + PositionInterpolationType: 0 + RotationInterpolationType: 0 + ScaleInterpolationType: 0 + PositionLerpSmoothing: 1 + PositionMaxInterpolationTime: 0.1 + RotationLerpSmoothing: 1 + RotationMaxInterpolationTime: 0.1 + ScaleLerpSmoothing: 1 + ScaleMaxInterpolationTime: 0.1 + AuthorityMode: 0 + TickSyncChildren: 0 + UseUnreliableDeltas: 0 + SyncPositionX: 1 + SyncPositionY: 1 + SyncPositionZ: 1 + SyncRotAngleX: 1 + SyncRotAngleY: 1 + SyncRotAngleZ: 1 + SyncScaleX: 1 + SyncScaleY: 1 + SyncScaleZ: 1 + PositionThreshold: 0.001 + RotAngleThreshold: 0.01 + ScaleThreshold: 0.01 + UseQuaternionSynchronization: 0 + UseQuaternionCompression: 0 + UseHalfFloatPrecision: 0 + InLocalSpace: 0 + SwitchTransformSpaceWhenParented: 0 + Interpolate: 1 + SlerpPosition: 0 +--- !u!114 &2892684246239657319 +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: 21dea26768768b8449a8924f638557be, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth + ShowTopMostFoldoutHeaderGroup: 1 + maxHp: 100 +--- !u!114 &8213527798879671990 +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: 292d1b92cd49dc74f8dfd74cdbe4ece7, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyStatus + ShowTopMostFoldoutHeaderGroup: 1 diff --git a/Assets/_Project/Prefabs/Enemies/EnemyPlaceholder.prefab.meta b/Assets/_Project/Prefabs/Enemies/EnemyPlaceholder.prefab.meta new file mode 100644 index 0000000..815a1e2 --- /dev/null +++ b/Assets/_Project/Prefabs/Enemies/EnemyPlaceholder.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 0854f339a1958d343a6cb16cd3f907ff +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Prefabs/Projectiles/ProjectilePlaceholder.prefab b/Assets/_Project/Prefabs/Projectiles/ProjectilePlaceholder.prefab new file mode 100644 index 0000000..1d664d8 --- /dev/null +++ b/Assets/_Project/Prefabs/Projectiles/ProjectilePlaceholder.prefab @@ -0,0 +1,203 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &2664719039363295382 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 5346505748924138806} + - component: {fileID: 2441523409650755752} + - component: {fileID: 3004458703758247920} + - component: {fileID: 7079697957481585344} + - component: {fileID: 6722677248442714376} + - component: {fileID: 3956702135205686426} + - component: {fileID: 6538425601827344639} + m_Layer: 0 + m_Name: ProjectilePlaceholder + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &5346505748924138806 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2664719039363295382} + serializedVersion: 2 + m_LocalRotation: {x: 0.7071068, y: 0, z: 0, w: 0.7071068} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 0.2, y: 0.2, z: 0.2} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 90, y: 0, z: 0} +--- !u!33 &2441523409650755752 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2664719039363295382} + m_Mesh: {fileID: 10208, guid: 0000000000000000e000000000000000, type: 0} +--- !u!23 &3004458703758247920 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2664719039363295382} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!136 &7079697957481585344 +CapsuleCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2664719039363295382} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 0 + m_ProvidesContacts: 0 + m_Enabled: 1 + serializedVersion: 2 + m_Radius: 0.5 + m_Height: 2 + m_Direction: 1 + m_Center: {x: 0, y: 0, z: 0} +--- !u!114 &6722677248442714376 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2664719039363295382} + 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: 0 + InScenePlacedSourceGlobalObjectIdHash: 0 + DeferredDespawnTick: 0 + Ownership: 1 + AlwaysReplicateAsRoot: 0 + SynchronizeTransform: 1 + ActiveSceneSynchronization: 0 + SceneMigrationSynchronization: 1 + SpawnWithObservers: 1 + DontDestroyWithOwner: 0 + AutoObjectParentSync: 1 + SyncOwnerTransformWhenParented: 1 + AllowOwnerToParent: 0 +--- !u!114 &3956702135205686426 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2664719039363295382} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e96cb6065543e43c4a752faaa1468eb1, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.Components.NetworkTransform + ShowTopMostFoldoutHeaderGroup: 1 + NetworkTransformExpanded: 0 + AutoOwnerAuthorityTickOffset: 1 + PositionInterpolationType: 0 + RotationInterpolationType: 0 + ScaleInterpolationType: 0 + PositionLerpSmoothing: 1 + PositionMaxInterpolationTime: 0.1 + RotationLerpSmoothing: 1 + RotationMaxInterpolationTime: 0.1 + ScaleLerpSmoothing: 1 + ScaleMaxInterpolationTime: 0.1 + AuthorityMode: 0 + TickSyncChildren: 0 + UseUnreliableDeltas: 0 + SyncPositionX: 1 + SyncPositionY: 1 + SyncPositionZ: 1 + SyncRotAngleX: 1 + SyncRotAngleY: 1 + SyncRotAngleZ: 1 + SyncScaleX: 1 + SyncScaleY: 1 + SyncScaleZ: 1 + PositionThreshold: 0.001 + RotAngleThreshold: 0.01 + ScaleThreshold: 0.01 + UseQuaternionSynchronization: 0 + UseQuaternionCompression: 0 + UseHalfFloatPrecision: 0 + InLocalSpace: 0 + SwitchTransformSpaceWhenParented: 0 + Interpolate: 1 + SlerpPosition: 0 +--- !u!114 &6538425601827344639 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2664719039363295382} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a6854f71c9fdcda42b297c397f96c8be, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Combat.Projectile + ShowTopMostFoldoutHeaderGroup: 1 diff --git a/Assets/_Project/Prefabs/Projectiles/ProjectilePlaceholder.prefab.meta b/Assets/_Project/Prefabs/Projectiles/ProjectilePlaceholder.prefab.meta new file mode 100644 index 0000000..24f2981 --- /dev/null +++ b/Assets/_Project/Prefabs/Projectiles/ProjectilePlaceholder.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: dc2e4a4108e03874a8b2dab88dcc8fba +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Prefabs/Towers/Tower_Basic.prefab b/Assets/_Project/Prefabs/Towers/Tower_Basic.prefab index 511931b..42defa0 100644 --- a/Assets/_Project/Prefabs/Towers/Tower_Basic.prefab +++ b/Assets/_Project/Prefabs/Towers/Tower_Basic.prefab @@ -67,6 +67,8 @@ GameObject: - component: {fileID: 5594214090440991794} - component: {fileID: 7630870068340451557} - component: {fileID: 9137031893466587143} + - component: {fileID: 805962841523123163} + - component: {fileID: 8853488620519990682} m_Layer: 0 m_Name: Tower_Basic m_TagString: Untagged @@ -88,6 +90,7 @@ Transform: m_ConstrainProportionsScale: 0 m_Children: - {fileID: 5753294230586596248} + - {fileID: 7547850274173754846} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!33 &6869333096494165105 @@ -208,3 +211,94 @@ MonoBehaviour: ShowTopMostFoldoutHeaderGroup: 1 tintedRenderers: - {fileID: 4028055828417179692} +--- !u!114 &805962841523123163 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6482414459531823157} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 7eb6cce6cd96b23478b7d2173cebf74d, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Combat.TowerCombat + ShowTopMostFoldoutHeaderGroup: 1 + enemyLayerMask: + serializedVersion: 2 + m_Bits: 1024 +--- !u!114 &8853488620519990682 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6482414459531823157} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c0edb0c5206ca454bbd7c300c6cc7574, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Combat.TowerRangeIndicator + rangeProjector: {fileID: 8255517343120954594} + projectionDepth: 50 +--- !u!1 &7580197837852108944 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 7547850274173754846} + - component: {fileID: 8255517343120954594} + m_Layer: 0 + m_Name: RangeIndicator + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &7547850274173754846 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7580197837852108944} + serializedVersion: 2 + m_LocalRotation: {x: 0.7071068, y: -0, z: -0, w: 0.7071068} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 1283036264165444500} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &8255517343120954594 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7580197837852108944} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 0777d029ed3dffa4692f417d4aba19ca, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.DecalProjector + m_Material: {fileID: 2100000, guid: f99227cbde481ce47a2527e6bca709d2, type: 2} + m_DrawDistance: 1000 + m_FadeScale: 0.9 + m_StartAngleFade: 180 + m_EndAngleFade: 180 + m_UVScale: {x: 1, y: 1} + m_UVBias: {x: 0, y: 0} + m_RenderingLayerMask: + serializedVersion: 0 + m_Bits: 1 + m_ScaleMode: 0 + m_Offset: {x: 0, y: 0, z: 0.5} + m_Size: {x: 1, y: 1, z: 1} + m_FadeFactor: 1 + m_VisibleInScene: 1 + version: 1 + m_DecalLayerMask: 1 diff --git a/Assets/_Project/Scenes/Levels/Main.unity b/Assets/_Project/Scenes/Levels/Main.unity index d4be64c..5e9d01f 100644 --- a/Assets/_Project/Scenes/Levels/Main.unity +++ b/Assets/_Project/Scenes/Levels/Main.unity @@ -1715,7 +1715,6 @@ GameObject: m_Component: - component: {fileID: 1597884409} - component: {fileID: 1597884411} - - component: {fileID: 1597884410} m_Layer: 0 m_Name: TowerPlacementController m_TagString: Untagged @@ -1738,21 +1737,6 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!114 &1597884410 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1597884408} - m_Enabled: 0 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: ea0e3a4681be19e4e9c359c1123bf68d, type: 3} - m_Name: - m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.TowerPlacementTestTrigger - towerToPlace: {fileID: 11400000, guid: 0f693e29ca953e1439e10cb8f12e4b30, type: 2} - towerTypeId: 1 - controller: {fileID: 1597884411} --- !u!114 &1597884411 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Scripts/Combat.meta b/Assets/_Project/Scripts/Combat.meta new file mode 100644 index 0000000..6aca6f9 --- /dev/null +++ b/Assets/_Project/Scripts/Combat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 07a063da1d1c0b549b61d11d723e2930 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Combat/Projectile.cs b/Assets/_Project/Scripts/Combat/Projectile.cs new file mode 100644 index 0000000..a645963 --- /dev/null +++ b/Assets/_Project/Scripts/Combat/Projectile.cs @@ -0,0 +1,177 @@ +// Assets/_Project/Scripts/Combat/Projectile.cs +using Unity.Netcode; +using UnityEngine; +using TD.Core; +using TD.Gameplay; + +namespace TD.Combat +{ + /// + /// A traveling projectile spawned by when a tower has + /// a non-null ProjectilePrefab configured. + /// + /// + /// 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. + /// + /// Initialization: Mirrors the TowerInstance.InitializeServer pattern — + /// is called by TowerCombat immediately after + /// Instantiate and before NetworkObject.Spawn(), which avoids writing + /// to NetworkVariables before spawn. + /// + /// Target loss: If the target dies or is destroyed before the projectile + /// arrives, the projectile despawns silently (no hit, no damage). + /// + /// 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. + /// + /// Prefab requirements: Must have NetworkObject, NetworkTransform, + /// and this Projectile 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; + + // All fields are server-local. Set by InitializeServer before Spawn. + private EnemyHealth target; + private float damage; + private DamageType damageType; + private TargetType targetType; + private float splashRadius; + private float slowFactor; + private float dotDamagePerSecond; + private float effectDuration; + private float speed; + private LayerMask enemyLayerMask; + 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) ----------- + + /// + /// Stores all data this projectile needs to travel and apply damage. + /// Call this immediately after Instantiate and before + /// NetworkObject.Spawn(). + /// + public void InitializeServer( + EnemyHealth target, + float damage, + DamageType damageType, + TargetType targetType, + float splashRadius, + float slowFactor, + float dotDamagePerSecond, + float effectDuration, + float speed, + LayerMask enemyLayerMask) + { + this.target = target; + this.damage = damage; + this.damageType = damageType; + this.targetType = targetType; + this.splashRadius = splashRadius; + this.slowFactor = slowFactor; + this.dotDamagePerSecond = dotDamagePerSecond; + this.effectDuration = effectDuration; + this.speed = speed; + this.enemyLayerMask = enemyLayerMask; + 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."); + } + + // ----- 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) + { + NetworkObject.Despawn(); + return; + } + + Vector3 toTarget = target.transform.position - transform.position; + + if (toTarget.sqrMagnitude <= HitThresholdSq) + { + ApplyHit(); + NetworkObject.Despawn(); + 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 --------------------------------------------- + + private void ApplyHit() + { + switch (targetType) + { + case TargetType.Single: + case TargetType.Chain: // chain falls back to single-target on projectiles + HitEnemy(target); + break; + + case TargetType.Splash: + HitEnemy(target); + if (splashRadius > 0f) + { + int count = Physics.OverlapSphereNonAlloc( + transform.position, splashRadius, + s_overlapBuffer, enemyLayerMask); + + for (int i = 0; i < count; i++) + { + var eh = s_overlapBuffer[i].GetComponent(); + if (eh == null || eh.IsDead || (object)eh == (object)target) continue; + HitEnemy(eh); + } + } + break; + } + } + + private void HitEnemy(EnemyHealth eh) + { + eh.TakeDamage(damage, damageType); + + if (effectDuration > 0f) + { + float magnitude = damageType switch + { + DamageType.Cold => slowFactor, + DamageType.Fire => dotDamagePerSecond, + DamageType.Poison => dotDamagePerSecond, + _ => 0f, + }; + + if (magnitude > 0f) + eh.GetComponent() + ?.ApplyEffect(damageType, magnitude, effectDuration); + } + } + } +} diff --git a/Assets/_Project/Scripts/Combat/Projectile.cs.meta b/Assets/_Project/Scripts/Combat/Projectile.cs.meta new file mode 100644 index 0000000..61b0f3e --- /dev/null +++ b/Assets/_Project/Scripts/Combat/Projectile.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a6854f71c9fdcda42b297c397f96c8be \ No newline at end of file diff --git a/Assets/_Project/Scripts/Combat/TowerCombat.cs b/Assets/_Project/Scripts/Combat/TowerCombat.cs new file mode 100644 index 0000000..8d5d6fb --- /dev/null +++ b/Assets/_Project/Scripts/Combat/TowerCombat.cs @@ -0,0 +1,409 @@ +// Assets/_Project/Scripts/Combat/TowerCombat.cs +using System.Collections.Generic; +using Unity.Netcode; +using UnityEngine; +using TD.Core; +using TD.Gameplay; +using TD.Towers; + +namespace TD.Combat +{ + /// + /// Per-tower combat controller. Handles target acquisition, attack timing, + /// damage application, and projectile spawning. + /// + /// + /// Authority: All combat logic (targeting, damage, projectile spawn) runs + /// on the server only. Clients receive two signals for visual feedback: + /// + /// — NetworkVariable clients can read to know + /// what the tower is aiming at (drives future rotation/lean visuals). + /// — one-shot broadcast at each attack, carrying + /// the target world position for muzzle flash, tracer FX, etc. + /// + /// + /// Component coupling: Reads all stats from + /// on the same GameObject. Does not modify TowerInstance. + /// + /// Inspector setup required: + /// + /// Assign to the "Enemy" physics layer. + /// + /// + [RequireComponent(typeof(TowerInstance))] + public class TowerCombat : NetworkBehaviour + { + [Tooltip("Physics layer(s) that enemies occupy. " + + "OverlapSphere uses this to find targets efficiently without " + + "touching non-enemy colliders.")] + [SerializeField] private LayerMask enemyLayerMask; + + // Replicated so all peers know the current aim target. + // Visual consumers (barrel rotation, etc.) subscribe to OnValueChanged. + // Default (NetworkObjectId = 0) = no target. + private readonly NetworkVariable replicatedTarget = + new NetworkVariable( + default, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server); + + // Server-local reference — cleared when the target dies or leaves range. + // Clients should use replicatedTarget.Value instead. + private EnemyHealth currentTarget; + + // Attack cooldown decrements each server frame. When ≤ 0 and a target + // is in range, the tower fires and the timer resets to 1 / FireRate. + private float attackCooldown; + + // Cached on OnNetworkSpawn — avoids GetComponent every Update. + private TowerInstance towerInstance; + + // Shared OverlapSphere result buffer. 32 covers any realistic enemy + // density; size up if profiling reveals overflow. + private static readonly Collider[] s_overlapBuffer = new Collider[32]; + + // ----- Public API -------------------------------------------------- + + /// + /// Server-local reference to the current attack target. + /// Null on clients — use there. + /// + public EnemyHealth CurrentTarget => currentTarget; + + /// + /// Fired locally on ALL peers when the tower acquires a new target. + /// Driven by .OnValueChanged, and also + /// fired in for late-joining clients so visual + /// consumers always initialise from the correct state. + /// + public event System.Action OnTargetAcquired; + + /// Fired locally on ALL peers when the tower loses its target. + public event System.Action OnTargetLost; + + /// + /// Fired locally on ALL peers each time the tower attacks. + /// Argument is the world-space position of the primary target at the moment + /// of fire — use it to aim muzzle flash, spawn a tracer, etc. + /// + public event System.Action OnFire; + + // ----- NGO lifecycle ----------------------------------------------- + + public override void OnNetworkSpawn() + { + towerInstance = GetComponent(); + replicatedTarget.OnValueChanged += HandleReplicatedTargetChanged; + + // Late-joining clients receive the NV's current value in the initial + // sync message but won't get an OnValueChanged callback for it. + // Fire OnTargetAcquired here so visual consumers initialise correctly + // regardless of when this client connected. + if (replicatedTarget.Value.TryGet(out _)) + OnTargetAcquired?.Invoke(replicatedTarget.Value); + } + + public override void OnNetworkDespawn() + { + replicatedTarget.OnValueChanged -= HandleReplicatedTargetChanged; + + // Unsubscribe from the current target's death event to prevent + // a dangling delegate on the enemy after this tower despawns. + if (currentTarget != null) + currentTarget.OnDied -= HandleTargetDied; + } + + // ----- Server update ----------------------------------------------- + + private void Update() + { + if (!IsServer) return; + + TowerDefinition def = towerInstance?.Definition; + if (def == null || def.Range <= 0f || def.FireRate <= 0f) return; + + // Only fire during active gameplay. + var ms = MatchState.Instance; + if (ms == null || ms.Phase != MatchPhase.Playing) + { + if (currentTarget != null) ClearTarget(); + return; + } + + ValidateTarget(def); + if (currentTarget == null) + AcquireTarget(def); + if (currentTarget != null) + TickAttack(def); + } + + // ----- Target management ------------------------------------------- + + private void ValidateTarget(TowerDefinition def) + { + if (currentTarget == null) return; + + bool dead = currentTarget.IsDead; + bool destroyed = (currentTarget as UnityEngine.Object) == null; + bool outOfRange = Vector3.Distance( + transform.position, + currentTarget.transform.position) > def.Range; + + if (dead || destroyed || outOfRange) + ClearTarget(); + } + + private void AcquireTarget(TowerDefinition def) + { + int count = Physics.OverlapSphereNonAlloc( + transform.position, def.Range, s_overlapBuffer, enemyLayerMask); + + EnemyHealth best = null; + float bestScore = 0f; + + for (int i = 0; i < count; i++) + { + var eh = s_overlapBuffer[i].GetComponent(); + if (eh == null || eh.IsDead) continue; + if (def.GroundedOnly && eh.IsFlying) continue; + + float score = ScoreTarget(eh, def.TargetPriority); + bool isBetter = best == null + || (def.TargetPriority == TargetPriority.Strongest + ? score > bestScore // higher HP wins + : score < bestScore); // lower score wins for Closest/Weakest + + if (isBetter) { best = eh; bestScore = score; } + } + + if (best != null) + SetTarget(best); + } + + // Returns a scalar used to rank candidates. + // Closest → sqrMagnitude (lower = better). + // Weakest → CurrentHp (lower = better). + // Strongest → CurrentHp (higher = better — caller inverts the comparison). + private float ScoreTarget(EnemyHealth eh, TargetPriority priority) + { + return priority switch + { + TargetPriority.Closest => (transform.position - eh.transform.position).sqrMagnitude, + TargetPriority.Weakest => eh.CurrentHp, + TargetPriority.Strongest => eh.CurrentHp, + _ => 0f, + }; + } + + private void SetTarget(EnemyHealth eh) + { + currentTarget = eh; + currentTarget.OnDied += HandleTargetDied; + replicatedTarget.Value = new NetworkObjectReference(eh.NetworkObject); + } + + private void ClearTarget() + { + if (currentTarget != null) + currentTarget.OnDied -= HandleTargetDied; + + currentTarget = null; + replicatedTarget.Value = default; + } + + private void HandleTargetDied(EnemyHealth dead) + { + if ((object)dead == (object)currentTarget) + ClearTarget(); + } + + // ----- Attack tick ------------------------------------------------- + + private void TickAttack(TowerDefinition def) + { + attackCooldown -= Time.deltaTime; + if (attackCooldown > 0f) return; + + attackCooldown = 1f / def.FireRate; + Fire(def); + } + + private void Fire(TowerDefinition def) + { + Vector3 targetPos = currentTarget.transform.position; + + if (def.ProjectilePrefab == null) + { + // Hitscan — apply damage this frame. + ApplyDamageToTarget(def, currentTarget, targetPos); + } + else + { + SpawnProjectile(def, currentTarget, targetPos); + } + + FireClientRpc(targetPos); + } + + // ----- Damage application ------------------------------------------ + + private void ApplyDamageToTarget(TowerDefinition def, EnemyHealth primary, Vector3 primaryPos) + { + switch (def.TargetType) + { + case TargetType.Single: + HitEnemy(def, primary); + break; + + case TargetType.Splash: + HitEnemy(def, primary); + ApplySplash(def, primary, primaryPos); + break; + + case TargetType.Chain: + ApplyChain(def, primary); + break; + } + } + + private void ApplySplash(TowerDefinition def, EnemyHealth primary, Vector3 origin) + { + if (def.SplashRadius <= 0f) return; + + int count = Physics.OverlapSphereNonAlloc( + origin, def.SplashRadius, s_overlapBuffer, enemyLayerMask); + + for (int i = 0; i < count; i++) + { + var eh = s_overlapBuffer[i].GetComponent(); + if (eh == null || eh.IsDead || (object)eh == (object)primary) continue; + HitEnemy(def, eh); + } + } + + private void ApplyChain(TowerDefinition def, EnemyHealth primary) + { + // 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); + + EnemyHealth current = primary; + for (int jump = 0; jump < def.ChainCount; jump++) + { + EnemyHealth next = null; + float bestSqr = float.MaxValue; + + int count = Physics.OverlapSphereNonAlloc( + current.transform.position, def.ChainRange, s_overlapBuffer, enemyLayerMask); + + for (int i = 0; i < count; i++) + { + var eh = s_overlapBuffer[i].GetComponent(); + if (eh == null || eh.IsDead || alreadyHit.Contains(eh)) continue; + + float sqr = (current.transform.position - eh.transform.position).sqrMagnitude; + if (sqr < bestSqr) { next = eh; bestSqr = sqr; } + } + + if (next == null) break; + + alreadyHit.Add(next); + hitPositions.Add(next.transform.position); + HitEnemy(def, next); + current = next; + } + + ChainFiredClientRpc(hitPositions.ToArray()); + } + + private void HitEnemy(TowerDefinition def, EnemyHealth target) + { + target.TakeDamage(def.Damage, def.DamageType); + ApplyStatusEffect(def, target); + } + + private void ApplyStatusEffect(TowerDefinition def, EnemyHealth target) + { + if (def.EffectDuration <= 0f) return; + + float magnitude = def.DamageType switch + { + DamageType.Cold => def.SlowFactor, + DamageType.Fire => def.DotDamagePerSecond, + DamageType.Poison => def.DotDamagePerSecond, + _ => 0f, + }; + + if (magnitude <= 0f) return; + + target.GetComponent() + ?.ApplyEffect(def.DamageType, magnitude, def.EffectDuration); + } + + // ----- Projectile spawning ----------------------------------------- + + private void SpawnProjectile(TowerDefinition def, EnemyHealth target, Vector3 targetPos) + { + var go = Instantiate(def.ProjectilePrefab, transform.position, Quaternion.identity); + + var proj = go.GetComponent(); + if (proj == null) + { + Debug.LogError($"[TowerCombat] ProjectilePrefab '{def.ProjectilePrefab.name}' " + + $"has no Projectile component. The prefab must have Projectile, " + + $"NetworkObject, and NetworkTransform at its root."); + Destroy(go); + return; + } + + proj.InitializeServer( + target, + def.Damage, + def.DamageType, + def.TargetType, + def.SplashRadius, + def.SlowFactor, + def.DotDamagePerSecond, + def.EffectDuration, + def.ProjectileSpeed, + enemyLayerMask); + + go.GetComponent().Spawn(); + } + + // ----- ClientRpcs -------------------------------------------------- + + [ClientRpc] + private void FireClientRpc(Vector3 targetPos) + { + // Visual consumers subscribe to OnFire for muzzle flash, tracers, etc. + OnFire?.Invoke(targetPos); + } + + [ClientRpc] + private void ChainFiredClientRpc(Vector3[] hitPositions) + { + // STUB: lightning-arc visual between hitPositions goes here. + // A future visual component will subscribe to an OnChainFired event + // and draw a line renderer through these world positions. + } + + // ----- NV callback (fires on all peers) ---------------------------- + + private void HandleReplicatedTargetChanged( + NetworkObjectReference prev, NetworkObjectReference next) + { + bool hadTarget = prev.TryGet(out _); + bool hasTarget = next.TryGet(out _); + + if (hasTarget) + OnTargetAcquired?.Invoke(next); + else if (hadTarget) + OnTargetLost?.Invoke(); + } + } +} diff --git a/Assets/_Project/Scripts/Combat/TowerCombat.cs.meta b/Assets/_Project/Scripts/Combat/TowerCombat.cs.meta new file mode 100644 index 0000000..c64476a --- /dev/null +++ b/Assets/_Project/Scripts/Combat/TowerCombat.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7eb6cce6cd96b23478b7d2173cebf74d \ No newline at end of file diff --git a/Assets/_Project/Scripts/Combat/TowerRangeIndicator.cs b/Assets/_Project/Scripts/Combat/TowerRangeIndicator.cs new file mode 100644 index 0000000..efb1191 --- /dev/null +++ b/Assets/_Project/Scripts/Combat/TowerRangeIndicator.cs @@ -0,0 +1,135 @@ +// Assets/_Project/Scripts/Combat/TowerRangeIndicator.cs +using UnityEngine; +using UnityEngine.Rendering.Universal; +using TD.Gameplay; + +namespace TD.Combat +{ + /// + /// Displays a translucent decal circle representing this tower's attack range + /// when the local player selects the tower. + /// + /// + /// Visual only. No networking — selection is a local UI concept. + /// All clients independently show the range indicator for whatever tower + /// they have selected. + /// + /// Prefab setup: + /// + /// Add this component to the tower prefab root (alongside TowerInstance). + /// Add a child GameObject named "RangeIndicator". + /// Add a DecalProjector to that child and assign it to + /// (or leave it unassigned — auto-found + /// via GetComponentInChildren in Start). + /// Assign a translucent range-circle material to the DecalProjector. + /// + /// + /// Sizing: The projector diameter is set once in Start from + /// TowerDefinition.Range. Towers are static, so no per-frame resize is needed. + /// + /// Subscription timing: Follows the same deferred-subscribe pattern as + /// — retries until + /// is available, then stops polling. + /// + public class TowerRangeIndicator : MonoBehaviour + { + [Tooltip("DecalProjector used to render the range circle on the ground. " + + "Auto-found via GetComponentInChildren if left empty.")] + [SerializeField] private DecalProjector rangeProjector; + + [Tooltip("Vertical extent of the decal projection volume in world units. " + + "Must be tall enough to project onto terrain at any height in your map.")] + [SerializeField] private float projectionDepth = 50f; + + private TowerInstance towerInstance; + private bool subscribed; + + // ----- Lifecycle --------------------------------------------------- + + private void Start() + { + towerInstance = GetComponent(); + if (towerInstance == null) + { + Debug.LogError("[TowerRangeIndicator] No TowerInstance found on this " + + "GameObject. TowerRangeIndicator must sit on the same " + + "prefab root as TowerInstance."); + enabled = false; + return; + } + + if (rangeProjector == null) + rangeProjector = GetComponentInChildren(); + if (rangeProjector == null) + { + Debug.LogError("[TowerRangeIndicator] No DecalProjector found. " + + "Add one as a child of this GameObject and assign it " + + "to the rangeProjector field."); + enabled = false; + return; + } + + // TowerInstance resolves its Definition in OnNetworkSpawn, which runs + // before Start, so Definition is guaranteed available here. + float range = towerInstance.Definition != null ? towerInstance.Definition.Range : 0f; + float diameter = range * 2f; + + rangeProjector.size = new Vector3(diameter, diameter, projectionDepth); + rangeProjector.pivot = Vector3.zero; + + // DecalProjector projects along its local +Z axis; rotate 90° around X + // to project downward onto the ground plane. + rangeProjector.transform.localRotation = Quaternion.Euler(90f, 0f, 0f); + rangeProjector.transform.localPosition = Vector3.zero; + + rangeProjector.enabled = false; + + TrySubscribe(); + } + + private void OnDestroy() + { + Unsubscribe(); + } + + private void Update() + { + // Retry subscription each frame until SelectionState is ready. + // Once subscribed the branch short-circuits immediately. + if (!subscribed) TrySubscribe(); + } + + // ----- Subscription ----------------------------------------------- + + private void TrySubscribe() + { + if (subscribed) return; + var sel = SelectionState.Instance; + if (sel == null) return; + + sel.OnSelectionChanged += HandleSelectionChanged; + subscribed = true; + + // Catch whatever is already selected before we subscribed. + HandleSelectionChanged(sel.SelectedObject); + } + + private void Unsubscribe() + { + if (!subscribed) return; + var sel = SelectionState.Instance; + if (sel != null) sel.OnSelectionChanged -= HandleSelectionChanged; + subscribed = false; + } + + // ----- Selection handler ------------------------------------------ + + private void HandleSelectionChanged(ISelectable newSelection) + { + if (rangeProjector == null) return; + + // Show only when THIS tower's TowerInstance is the selected object. + rangeProjector.enabled = (object)newSelection == (object)towerInstance; + } + } +} diff --git a/Assets/_Project/Scripts/Combat/TowerRangeIndicator.cs.meta b/Assets/_Project/Scripts/Combat/TowerRangeIndicator.cs.meta new file mode 100644 index 0000000..035a7f0 --- /dev/null +++ b/Assets/_Project/Scripts/Combat/TowerRangeIndicator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c0edb0c5206ca454bbd7c300c6cc7574 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Core/Enums.cs b/Assets/_Project/Scripts/Core/Enums.cs index d84c1b4..80f9df8 100644 --- a/Assets/_Project/Scripts/Core/Enums.cs +++ b/Assets/_Project/Scripts/Core/Enums.cs @@ -1,5 +1,48 @@ namespace TD.Core { + /// + /// How a tower's attack damage is typed. Determines which lingering effect is applied + /// on hit and how enemy resistances/weaknesses are calculated (Phase 1.5+). + /// + public enum DamageType : byte + { + Physical = 0, + Piercing = 1, + Cold = 2, + Fire = 3, + Poison = 4, + Holy = 5, + } + + /// + /// How a tower selects which enemy in range to attack. + /// + public enum TargetPriority : byte + { + /// Nearest enemy to the tower's center. + Closest = 0, + /// Enemy with the lowest current HP. + Weakest = 1, + /// Enemy with the highest current HP. + Strongest = 2, + } + + /// + /// How a tower's damage is distributed across enemies. + /// Orthogonal to : any target type + /// can be delivered by a projectile or hitscan. + /// + public enum TargetType : byte + { + /// Hits one enemy for full damage. + Single = 0, + /// Hits the primary target, then all enemies within SplashRadius. + Splash = 1, + /// Chains damage to up to ChainCount additional nearby enemies. + Chain = 2, + } + + /// /// Identifies a player slot in a match. Backed by byte to keep grid arrays compact. /// diff --git a/Assets/_Project/Scripts/Core/_testScript.cs b/Assets/_Project/Scripts/Core/_testScript.cs deleted file mode 100644 index c945653..0000000 --- a/Assets/_Project/Scripts/Core/_testScript.cs +++ /dev/null @@ -1,12 +0,0 @@ -using UnityEngine; -using TD.Core; - -public class GridCoordsTest : MonoBehaviour -{ - void Start() - { - Debug.Log($"Tile (5,7) world center: {GridCoordinates.GridToWorld(new Vector2Int(5, 7))}"); - Debug.Log($"World (5.3, 0, 6.8) maps to tile: {GridCoordinates.WorldToGrid(new Vector3(5.3f, 0, 6.8f))}"); - Debug.Log($"2x2 footprint at (5,7) center: {GridCoordinates.GetFootprintCenterWorld(new Vector2Int(5, 7), new Vector2Int(2, 2))}"); - } -} \ No newline at end of file diff --git a/Assets/_Project/Scripts/Core/_testScript.cs.meta b/Assets/_Project/Scripts/Core/_testScript.cs.meta deleted file mode 100644 index 14d66fb..0000000 --- a/Assets/_Project/Scripts/Core/_testScript.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 9c2af3b2731bd314e8502ac9db3a8bc5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs b/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs new file mode 100644 index 0000000..05e38e4 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs @@ -0,0 +1,90 @@ +// Assets/_Project/Scripts/Gameplay/EnemyHealth.cs +using Unity.Netcode; +using UnityEngine; +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. + /// + /// + /// 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. + /// + /// Death flow (server-only): + /// TakeDamage clamps HP to 0, fires , then calls + /// NetworkObject.Despawn. Subscribers must not touch the NetworkObject + /// after OnDied returns. + /// + [RequireComponent(typeof(NetworkObject))] + public class EnemyHealth : NetworkBehaviour + { + [SerializeField] private float maxHp = 100f; + + private readonly NetworkVariable hp = new NetworkVariable( + 0f, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server); + + // ----- Public state ----------------------------------------------- + + public float CurrentHp => hp.Value; + public float MaxHp => maxHp; + 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; + + // ----- Events ----------------------------------------------------- + + /// + /// Fired on the server immediately before the enemy NetworkObject is despawned. + /// subscribes to clear its target reference. + /// Do not access the NetworkObject after this event returns. + /// + public event System.Action OnDied; + + // ----- NGO lifecycle ---------------------------------------------- + + public override void OnNetworkSpawn() + { + if (IsServer) + hp.Value = maxHp; + } + + // ----- 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+). + /// + public void TakeDamage(float damage, DamageType type) + { + if (!IsServer) return; + if (IsDead) return; + + // STUB — resistance table slot: + // float modified = ResistanceTable.Apply(damage, type, this); + float modified = damage; + + hp.Value = Mathf.Max(0f, hp.Value - modified); + + if (hp.Value <= 0f) + HandleDeath(); + } + + // ----- Private ---------------------------------------------------- + + private void HandleDeath() + { + OnDied?.Invoke(this); + NetworkObject.Despawn(); + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs.meta new file mode 100644 index 0000000..e418834 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 21dea26768768b8449a8924f638557be \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyStatus.cs b/Assets/_Project/Scripts/Gameplay/EnemyStatus.cs new file mode 100644 index 0000000..7827d70 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyStatus.cs @@ -0,0 +1,160 @@ +// Assets/_Project/Scripts/Gameplay/EnemyStatus.cs +using System.Collections.Generic; +using Unity.Netcode; +using UnityEngine; +using TD.Core; + +namespace TD.Gameplay +{ + /// + /// A single active lingering effect on an enemy. + /// + /// + /// Magnitude semantics by source: + /// + /// Cold — fraction of speed retained (0.5 = half speed) + /// Fire — damage per second applied as a DoT tick + /// Poison — damage per second applied as a DoT tick + /// Others — unused (magnitude = 0) + /// + /// + public struct StatusEffect + { + public DamageType Source; + public float Magnitude; + public float RemainingDuration; + } + + /// + /// 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. + /// + /// 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. + /// + /// DoT damage is applied by calling each + /// tick so resistance lookups remain in one place. + /// + [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 ----------------------------------------------- + + public override void OnNetworkSpawn() + { + health = GetComponent(); + } + + // ----- Public API -------------------------------------------------- + + /// Current speed fraction (0–1). 1 = full speed, 0.5 = half speed, etc. + public float GetSpeedMultiplier() => speedMultiplier.Value; + + /// True if an effect of the given type is currently active on this enemy. + public bool HasEffect(DamageType type) + { + for (int i = 0; i < activeEffects.Count; i++) + if (activeEffects[i].Source == type) return true; + return false; + } + + /// + /// Applies or refreshes a lingering effect. Server-only; no-op on clients. + /// Re-hitting with the same damage type refreshes duration and magnitude. + /// + public void ApplyEffect(DamageType source, float magnitude, float duration) + { + if (!IsServer) return; + + for (int i = 0; i < activeEffects.Count; i++) + { + if (activeEffects[i].Source != source) continue; + + var e = activeEffects[i]; + e.Magnitude = magnitude; + e.RemainingDuration = duration; + activeEffects[i] = e; + RecalculateSpeedMultiplier(); + return; + } + + activeEffects.Add(new StatusEffect + { + Source = source, + Magnitude = magnitude, + RemainingDuration = duration, + }); + RecalculateSpeedMultiplier(); + } + + // ----- Server tick ------------------------------------------------ + + private void Update() + { + if (!IsServer || activeEffects.Count == 0) return; + TickEffects(Time.deltaTime); + } + + private void TickEffects(float dt) + { + bool anyExpired = false; + + for (int i = activeEffects.Count - 1; i >= 0; i--) + { + var e = activeEffects[i]; + + // Apply DoT for Fire and Poison. + if (e.Source == DamageType.Fire || e.Source == DamageType.Poison) + { + if (health != null && !health.IsDead) + health.TakeDamage(e.Magnitude * dt, e.Source); + } + + e.RemainingDuration -= dt; + if (e.RemainingDuration <= 0f) + { + activeEffects.RemoveAt(i); + anyExpired = true; + } + else + { + activeEffects[i] = e; + } + } + + if (anyExpired) + RecalculateSpeedMultiplier(); + } + + private void RecalculateSpeedMultiplier() + { + float mult = 1f; + for (int i = 0; i < activeEffects.Count; i++) + { + if (activeEffects[i].Source == DamageType.Cold) + mult = Mathf.Min(mult, activeEffects[i].Magnitude); + } + speedMultiplier.Value = mult; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyStatus.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyStatus.cs.meta new file mode 100644 index 0000000..ad10ab2 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyStatus.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 292d1b92cd49dc74f8dfd74cdbe4ece7 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Towers/TowerDefinition.cs b/Assets/_Project/Scripts/Towers/TowerDefinition.cs index 2634f72..a106ebf 100644 --- a/Assets/_Project/Scripts/Towers/TowerDefinition.cs +++ b/Assets/_Project/Scripts/Towers/TowerDefinition.cs @@ -1,4 +1,5 @@ using UnityEngine; +using TD.Core; namespace TD.Towers { @@ -68,29 +69,78 @@ namespace TD.Towers public GameObject TowerPrefab; // ------------------------------------------------------------------- - // Combat — STUBBED + // Combat // ------------------------------------------------------------------- - [Header("Combat (Stubbed — not consumed until combat system is implemented)")] - [Tooltip("STUBBED. Damage dealt per hit to a single target.")] + [Header("Combat — Targeting")] + [Tooltip("How the tower's damage is typed. Determines lingering effects and enemy " + + "resistance/weakness interactions.")] + public DamageType DamageType; + + [Tooltip("Which enemy in range the tower prioritizes.")] + public TargetPriority TargetPriority; + + [Tooltip("How damage is distributed: Single (one enemy), Splash (primary + radius), " + + "Chain (primary then N nearest neighbours).")] + public TargetType TargetType; + + [Tooltip("When true, this tower can only target grounded enemies. " + + "Default false = targets both grounded and flying units.")] + public bool GroundedOnly; + + [Header("Combat — Attack")] + [Tooltip("Damage dealt per hit to the primary target, before resistances.")] public float Damage; - [Tooltip("STUBBED. Attack range in world units. Enemies within this radius are targetable.")] + [Tooltip("Attack range in world units. Enemies within this radius are targetable. " + + "Displayed as a translucent decal circle when the tower is selected.")] public float Range; - [Tooltip("STUBBED. Attacks per second.")] + [Tooltip("Attacks per second.")] public float FireRate; - [Tooltip("STUBBED. Radius of splash damage around the impact point. 0 = single target.")] + [Header("Combat — Area")] + [Tooltip("Radius of splash damage around the primary impact point. " + + "Only used when TargetType = Splash.")] public float SplashRadius; - [Tooltip("STUBBED. Fraction by which enemy movement speed is multiplied on hit. " + - "1.0 = no slow. 0.5 = 50% slow.")] + [Tooltip("Number of additional enemies damage chains to after the primary target. " + + "Only used when TargetType = Chain.")] + public int ChainCount; + + [Tooltip("Maximum world-unit distance each chain jump can travel. " + + "Only used when TargetType = Chain.")] + public float ChainRange; + + [Header("Combat — Effects")] + [Tooltip("For Cold towers: fraction of movement speed retained on hit. " + + "1.0 = no slow, 0.5 = half speed. Ignored for other damage types.")] [Range(0f, 1f)] public float SlowFactor = 1f; - [Tooltip("STUBBED. Projectile prefab fired at targets. Null = hitscan (instant hit, no " + - "projectile travel).")] + [Tooltip("For Fire / Poison towers: damage per second applied as a damage-over-time tick. " + + "Ignored for other damage types.")] + public float DotDamagePerSecond; + + [Tooltip("Duration in seconds that any lingering effect (slow, burn, poison) persists " + + "after the hit. 0 = no lingering effect.")] + public float EffectDuration; + + [Header("Combat — Projectile")] + [Tooltip("Prefab fired at targets. Must have a Projectile component, NetworkObject, " + + "and NetworkTransform at its root. Null = hitscan (instant hit, no travel).")] public GameObject ProjectilePrefab; + + [Tooltip("World-units per second the projectile travels. Ignored when ProjectilePrefab is null.")] + public float ProjectileSpeed = 10f; + + // ------------------------------------------------------------------- + // Upgrades + // ------------------------------------------------------------------- + + [Header("Upgrades")] + [Tooltip("Tower definitions this tower can upgrade into. Leave empty for no upgrades. " + + "Multiple entries create branching paths — the player chooses one.")] + public TowerDefinition[] UpgradePaths; } } \ No newline at end of file diff --git a/ProjectSettings/TagManager.asset b/ProjectSettings/TagManager.asset index a503376..627ee5e 100644 --- a/ProjectSettings/TagManager.asset +++ b/ProjectSettings/TagManager.asset @@ -15,7 +15,7 @@ TagManager: - TerrainGeometry - Selection - BuildSite - - + - Enemy - - -