From f05734e19bd4ad0303c80d78a2f3814d4291a5c4 Mon Sep 17 00:00:00 2001 From: Matt F Date: Tue, 5 May 2026 22:01:40 -0700 Subject: [PATCH] Adding a ton of funcitonality to the builder's movement and build queue --- .gitignore | 2 + Assets/DefaultNetworkPrefabs.asset | 5 + .../TowerDefinitions/BasicTower.asset | 2 +- .../Materials/M_BuildSite_Constructing.mat | 137 +++ .../M_BuildSite_Constructing.mat.meta | 8 + .../Art/Materials/M_BuildSite_Queued.mat | 141 ++++ .../Art/Materials/M_BuildSite_Queued.mat.meta | 8 + .../Art/Materials/M_SelectionRing.mat | 141 ++++ .../Art/Materials/M_SelectionRing.mat.meta | 8 + .../Art/Materials/M_TowerGhost_Invalid.mat | 4 +- .../Art/Materials/M_TowerGhost_Valid.mat | 4 +- .../Prefabs/Builders/Builder_Basic.prefab | 171 +++- Assets/_Project/Prefabs/Player/Player.prefab | 2 +- .../Prefabs/Towers/BuildSiteVisual.prefab | 247 ++++++ .../Towers/BuildSiteVisual.prefab.meta | 7 + Assets/_Project/Scenes/Levels/Main.unity | 347 ++++---- Assets/_Project/Scenes/Levels/TestLevel.asset | 2 +- Assets/_Project/Scripts/Gameplay/BuildJob.cs | 182 ++++ .../Scripts/Gameplay/BuildJob.cs.meta | 2 + .../Scripts/Gameplay/BuildSiteVisual.cs | 478 +++++++++++ .../Scripts/Gameplay/BuildSiteVisual.cs.meta | 2 + Assets/_Project/Scripts/Gameplay/Builder.cs | 786 +++++++++++++++++- .../Gameplay/BuilderInputController.cs | 184 +++- .../Scripts/Gameplay/SelectionRingVisual.cs | 126 +++ .../Gameplay/SelectionRingVisual.cs.meta | 2 + .../Scripts/Gameplay/SelectionState.cs | 90 ++ .../Scripts/Gameplay/SelectionState.cs.meta | 2 + .../Gameplay/TowerPlacementController.cs | 85 +- .../Scripts/Gameplay/TowerPlacementManager.cs | 239 ++++-- .../Gameplay/TowerPlacementSettings.cs | 25 +- ProjectSettings/TagManager.asset | 4 +- 31 files changed, 3104 insertions(+), 339 deletions(-) create mode 100644 Assets/_Project/Art/Materials/M_BuildSite_Constructing.mat create mode 100644 Assets/_Project/Art/Materials/M_BuildSite_Constructing.mat.meta create mode 100644 Assets/_Project/Art/Materials/M_BuildSite_Queued.mat create mode 100644 Assets/_Project/Art/Materials/M_BuildSite_Queued.mat.meta create mode 100644 Assets/_Project/Art/Materials/M_SelectionRing.mat create mode 100644 Assets/_Project/Art/Materials/M_SelectionRing.mat.meta create mode 100644 Assets/_Project/Prefabs/Towers/BuildSiteVisual.prefab create mode 100644 Assets/_Project/Prefabs/Towers/BuildSiteVisual.prefab.meta create mode 100644 Assets/_Project/Scripts/Gameplay/BuildJob.cs create mode 100644 Assets/_Project/Scripts/Gameplay/BuildJob.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs create mode 100644 Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs create mode 100644 Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/SelectionState.cs create mode 100644 Assets/_Project/Scripts/Gameplay/SelectionState.cs.meta diff --git a/.gitignore b/.gitignore index bd72224..a2ead00 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,5 @@ InitTestScene*.unity* # Auto-generated cache in Assets folder /[Aa]ssets/[Ss]ceneDependencyCache* /Assets/_Recovery +/Assets/Kevin Iglesias +Assets/Kevin Iglesias.meta diff --git a/Assets/DefaultNetworkPrefabs.asset b/Assets/DefaultNetworkPrefabs.asset index 7e48bb6..5991b62 100644 --- a/Assets/DefaultNetworkPrefabs.asset +++ b/Assets/DefaultNetworkPrefabs.asset @@ -29,3 +29,8 @@ MonoBehaviour: SourcePrefabToOverride: {fileID: 0} SourceHashToOverride: 0 OverridingTargetPrefab: {fileID: 0} + - Override: 0 + Prefab: {fileID: 7720770984308489338, guid: dff852699e2897b4494fcbc7f7e547d6, type: 3} + SourcePrefabToOverride: {fileID: 0} + SourceHashToOverride: 0 + OverridingTargetPrefab: {fileID: 0} diff --git a/Assets/Resources/TowerDefinitions/BasicTower.asset b/Assets/Resources/TowerDefinitions/BasicTower.asset index dd1f95f..02ff1a6 100644 --- a/Assets/Resources/TowerDefinitions/BasicTower.asset +++ b/Assets/Resources/TowerDefinitions/BasicTower.asset @@ -16,7 +16,7 @@ MonoBehaviour: Description: FootprintSize: {x: 2, y: 2} GoldCost: 25 - BuildTime: 0 + BuildTime: 4 TowerPrefab: {fileID: 6482414459531823157, guid: 1511641f145758b469e64376d2a0d434, type: 3} Damage: 0 Range: 0 diff --git a/Assets/_Project/Art/Materials/M_BuildSite_Constructing.mat b/Assets/_Project/Art/Materials/M_BuildSite_Constructing.mat new file mode 100644 index 0000000..474d3fb --- /dev/null +++ b/Assets/_Project/Art/Materials/M_BuildSite_Constructing.mat @@ -0,0 +1,137 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &-1223089589360192280 +MonoBehaviour: + m_ObjectHideFlags: 11 + 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: d0353a89b1f911e48b9e16bdc9f2e058, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion + version: 10 +--- !u!21 &2100000 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: M_BuildSite_Constructing + m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: 2000 + stringTagMap: + RenderType: Opaque + disabledShaderPasses: + - MOTIONVECTORS + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _BaseMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _BumpMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailAlbedoMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailMask: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailNormalMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _EmissionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MetallicGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _OcclusionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _ParallaxMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _SpecGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_Lightmaps: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_LightmapsInd: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_ShadowMasks: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _AddPrecomputedVelocity: 0 + - _AlphaClip: 0 + - _AlphaToMask: 0 + - _Blend: 0 + - _BlendModePreserveSpecular: 1 + - _BumpScale: 1 + - _ClearCoatMask: 0 + - _ClearCoatSmoothness: 0 + - _Cull: 2 + - _Cutoff: 0.5 + - _DetailAlbedoMapScale: 1 + - _DetailNormalMapScale: 1 + - _DstBlend: 0 + - _DstBlendAlpha: 0 + - _EnvironmentReflections: 1 + - _GlossMapScale: 0 + - _Glossiness: 0 + - _GlossyReflections: 0 + - _Metallic: 0 + - _OcclusionStrength: 1 + - _Parallax: 0.005 + - _QueueOffset: 0 + - _ReceiveShadows: 1 + - _Smoothness: 0.5 + - _SmoothnessTextureChannel: 0 + - _SpecularHighlights: 1 + - _SrcBlend: 1 + - _SrcBlendAlpha: 1 + - _Surface: 0 + - _WorkflowMode: 1 + - _XRMotionVectorsPass: 1 + - _ZWrite: 1 + m_Colors: + - _BaseColor: {r: 1, g: 1, b: 1, a: 1} + - _Color: {r: 1, g: 1, b: 1, a: 1} + - _EmissionColor: {r: 0, g: 0, b: 0, a: 1} + - _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 diff --git a/Assets/_Project/Art/Materials/M_BuildSite_Constructing.mat.meta b/Assets/_Project/Art/Materials/M_BuildSite_Constructing.mat.meta new file mode 100644 index 0000000..7b1467e --- /dev/null +++ b/Assets/_Project/Art/Materials/M_BuildSite_Constructing.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 88f1dd7b174716645953857b38fb6948 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Materials/M_BuildSite_Queued.mat b/Assets/_Project/Art/Materials/M_BuildSite_Queued.mat new file mode 100644 index 0000000..2272d4d --- /dev/null +++ b/Assets/_Project/Art/Materials/M_BuildSite_Queued.mat @@ -0,0 +1,141 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &-1223089589360192280 +MonoBehaviour: + m_ObjectHideFlags: 11 + 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: d0353a89b1f911e48b9e16bdc9f2e058, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion + version: 10 +--- !u!21 &2100000 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: M_BuildSite_Queued + m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: + - _ALPHAPREMULTIPLY_ON + - _SURFACE_TYPE_TRANSPARENT + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: 3000 + stringTagMap: + RenderType: Transparent + disabledShaderPasses: + - MOTIONVECTORS + - DepthOnly + - SHADOWCASTER + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _BaseMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _BumpMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailAlbedoMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailMask: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailNormalMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _EmissionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MetallicGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _OcclusionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _ParallaxMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _SpecGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_Lightmaps: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_LightmapsInd: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_ShadowMasks: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _AddPrecomputedVelocity: 0 + - _AlphaClip: 0 + - _AlphaToMask: 0 + - _Blend: 0 + - _BlendModePreserveSpecular: 1 + - _BumpScale: 1 + - _ClearCoatMask: 0 + - _ClearCoatSmoothness: 0 + - _Cull: 2 + - _Cutoff: 0.5 + - _DetailAlbedoMapScale: 1 + - _DetailNormalMapScale: 1 + - _DstBlend: 10 + - _DstBlendAlpha: 10 + - _EnvironmentReflections: 1 + - _GlossMapScale: 0 + - _Glossiness: 0 + - _GlossyReflections: 0 + - _Metallic: 0 + - _OcclusionStrength: 1 + - _Parallax: 0.005 + - _QueueOffset: 0 + - _ReceiveShadows: 1 + - _Smoothness: 0.5 + - _SmoothnessTextureChannel: 0 + - _SpecularHighlights: 1 + - _SrcBlend: 1 + - _SrcBlendAlpha: 1 + - _Surface: 1 + - _WorkflowMode: 1 + - _XRMotionVectorsPass: 1 + - _ZWrite: 0 + m_Colors: + - _BaseColor: {r: 0.068549484, g: 1, b: 0, a: 0.19607843} + - _Color: {r: 0.06854946, g: 1, b: 0, a: 0.19607843} + - _EmissionColor: {r: 0, g: 0, b: 0, a: 1} + - _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 diff --git a/Assets/_Project/Art/Materials/M_BuildSite_Queued.mat.meta b/Assets/_Project/Art/Materials/M_BuildSite_Queued.mat.meta new file mode 100644 index 0000000..b9b0c4b --- /dev/null +++ b/Assets/_Project/Art/Materials/M_BuildSite_Queued.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f8d951a6841d3f74098bb31255379774 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Materials/M_SelectionRing.mat b/Assets/_Project/Art/Materials/M_SelectionRing.mat new file mode 100644 index 0000000..f368921 --- /dev/null +++ b/Assets/_Project/Art/Materials/M_SelectionRing.mat @@ -0,0 +1,141 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &-5281183676241053304 +MonoBehaviour: + m_ObjectHideFlags: 11 + 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: d0353a89b1f911e48b9e16bdc9f2e058, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion + version: 10 +--- !u!21 &2100000 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: M_SelectionRing + m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: + - _ALPHAPREMULTIPLY_ON + - _SURFACE_TYPE_TRANSPARENT + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: 3000 + stringTagMap: + RenderType: Transparent + disabledShaderPasses: + - MOTIONVECTORS + - DepthOnly + - SHADOWCASTER + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _BaseMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _BumpMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailAlbedoMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailMask: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailNormalMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _EmissionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MetallicGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _OcclusionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _ParallaxMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _SpecGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_Lightmaps: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_LightmapsInd: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_ShadowMasks: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _AddPrecomputedVelocity: 0 + - _AlphaClip: 0 + - _AlphaToMask: 0 + - _Blend: 0 + - _BlendModePreserveSpecular: 1 + - _BumpScale: 1 + - _ClearCoatMask: 0 + - _ClearCoatSmoothness: 0 + - _Cull: 2 + - _Cutoff: 0.5 + - _DetailAlbedoMapScale: 1 + - _DetailNormalMapScale: 1 + - _DstBlend: 10 + - _DstBlendAlpha: 10 + - _EnvironmentReflections: 1 + - _GlossMapScale: 0 + - _Glossiness: 0 + - _GlossyReflections: 0 + - _Metallic: 0 + - _OcclusionStrength: 1 + - _Parallax: 0.005 + - _QueueOffset: 0 + - _ReceiveShadows: 1 + - _Smoothness: 0.5 + - _SmoothnessTextureChannel: 0 + - _SpecularHighlights: 1 + - _SrcBlend: 1 + - _SrcBlendAlpha: 1 + - _Surface: 1 + - _WorkflowMode: 1 + - _XRMotionVectorsPass: 1 + - _ZWrite: 0 + m_Colors: + - _BaseColor: {r: 0, g: 1, b: 0.026485443, a: 0.30588236} + - _Color: {r: 0, g: 1, b: 0.026485443, a: 0.30588236} + - _EmissionColor: {r: 0, g: 0, b: 0, a: 1} + - _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 diff --git a/Assets/_Project/Art/Materials/M_SelectionRing.mat.meta b/Assets/_Project/Art/Materials/M_SelectionRing.mat.meta new file mode 100644 index 0000000..b27ec43 --- /dev/null +++ b/Assets/_Project/Art/Materials/M_SelectionRing.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 81d0983426a4a31478788e89e22b0e80 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Materials/M_TowerGhost_Invalid.mat b/Assets/_Project/Art/Materials/M_TowerGhost_Invalid.mat index fe5b2c2..b746dd9 100644 --- a/Assets/_Project/Art/Materials/M_TowerGhost_Invalid.mat +++ b/Assets/_Project/Art/Materials/M_TowerGhost_Invalid.mat @@ -133,8 +133,8 @@ Material: - _XRMotionVectorsPass: 1 - _ZWrite: 0 m_Colors: - - _BaseColor: {r: 1, g: 0, b: 0, a: 0.47058824} - - _Color: {r: 1, g: 0, b: 0, a: 0.47058824} + - _BaseColor: {r: 1, g: 0, b: 0, a: 0.19607843} + - _Color: {r: 1, g: 0, b: 0, a: 0.19607843} - _EmissionColor: {r: 0, g: 0, b: 0, a: 1} - _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1} m_BuildTextureStacks: [] diff --git a/Assets/_Project/Art/Materials/M_TowerGhost_Valid.mat b/Assets/_Project/Art/Materials/M_TowerGhost_Valid.mat index f746cda..0ecd076 100644 --- a/Assets/_Project/Art/Materials/M_TowerGhost_Valid.mat +++ b/Assets/_Project/Art/Materials/M_TowerGhost_Valid.mat @@ -133,8 +133,8 @@ Material: - _XRMotionVectorsPass: 1 - _ZWrite: 0 m_Colors: - - _BaseColor: {r: 1, g: 1, b: 1, a: 0.47058824} - - _Color: {r: 1, g: 1, b: 1, a: 0.47058824} + - _BaseColor: {r: 1, g: 1, b: 1, a: 0.19607843} + - _Color: {r: 1, g: 1, b: 1, a: 0.19607843} - _EmissionColor: {r: 0, g: 0, b: 0, a: 1} - _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1} m_BuildTextureStacks: [] diff --git a/Assets/_Project/Prefabs/Builders/Builder_Basic.prefab b/Assets/_Project/Prefabs/Builders/Builder_Basic.prefab index ccf3cba..760b8b4 100644 --- a/Assets/_Project/Prefabs/Builders/Builder_Basic.prefab +++ b/Assets/_Project/Prefabs/Builders/Builder_Basic.prefab @@ -37,6 +37,8 @@ Transform: m_ConstrainProportionsScale: 0 m_Children: - {fileID: 2153758330548988791} + - {fileID: 5176306400449771234} + - {fileID: 6565619444702228235} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!33 &1354786839850046103 @@ -108,7 +110,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3} m_Name: m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject - GlobalObjectIdHash: 2050641840 + GlobalObjectIdHash: 472470935 InScenePlacedSourceGlobalObjectIdHash: 0 DeferredDespawnTick: 0 Ownership: 1 @@ -182,12 +184,17 @@ MonoBehaviour: ShowTopMostFoldoutHeaderGroup: 1 moveSpeed: 8 arrivalThreshold: 0.05 + turnRateDegPerSec: 540 heightOffset: 2 terrainRaycastMaxDistance: 100 terrainLayerMask: serializedVersion: 2 m_Bits: 128 buildRange: 6 + maxQueueDepth: 32 + buildSiteVisualPrefab: {fileID: 7720770984308489338, guid: dff852699e2897b4494fcbc7f7e547d6, type: 3} + tintedRenderers: + - {fileID: 4167417797825706430} --- !u!114 &4533726421250799861 MonoBehaviour: m_ObjectHideFlags: 0 @@ -204,6 +211,12 @@ MonoBehaviour: buildablePlaneLayerMask: serializedVersion: 2 m_Bits: 64 + selectionLayerMask: + serializedVersion: 2 + m_Bits: 256 + buildSiteLayerMask: + serializedVersion: 2 + m_Bits: 512 raycastMaxDistance: 500 --- !u!114 &6467759961575585905 MonoBehaviour: @@ -219,6 +232,109 @@ MonoBehaviour: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuildRangeIndicator projector: {fileID: 2082893476690950776} projectionDepth: 50 +--- !u!1 &2558028744543194000 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 6565619444702228235} + - component: {fileID: 1724910192658818315} + - component: {fileID: 6010362400907743827} + - component: {fileID: 6997342110466460015} + m_Layer: 0 + m_Name: SelectionRing + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &6565619444702228235 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2558028744543194000} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0.05, z: 0} + m_LocalScale: {x: 2, y: 0.02, z: 2} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 5490805221566030526} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &1724910192658818315 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2558028744543194000} + m_Mesh: {fileID: 10206, guid: 0000000000000000e000000000000000, type: 0} +--- !u!23 &6010362400907743827 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2558028744543194000} + 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: 81d0983426a4a31478788e89e22b0e80, 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!114 &6997342110466460015 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2558028744543194000} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 67895f626233fdc499dffbbfcc225530, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.SelectionRingVisual --- !u!1 &4357234114074764669 GameObject: m_ObjectHideFlags: 0 @@ -280,3 +396,56 @@ MonoBehaviour: m_VisibleInScene: 1 version: 1 m_DecalLayerMask: 1 +--- !u!1 &6563645777727655090 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 5176306400449771234} + - component: {fileID: 5557638594792396607} + m_Layer: 8 + m_Name: SelectionTrigger + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &5176306400449771234 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6563645777727655090} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 5490805221566030526} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!65 &5557638594792396607 +BoxCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6563645777727655090} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 1 + m_ProvidesContacts: 0 + m_Enabled: 1 + serializedVersion: 3 + m_Size: {x: 1, y: 2, z: 1} + m_Center: {x: 0, y: 0, z: 0} diff --git a/Assets/_Project/Prefabs/Player/Player.prefab b/Assets/_Project/Prefabs/Player/Player.prefab index 09405e5..1a8c604 100644 --- a/Assets/_Project/Prefabs/Player/Player.prefab +++ b/Assets/_Project/Prefabs/Player/Player.prefab @@ -72,7 +72,7 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerGoldManager ShowTopMostFoldoutHeaderGroup: 1 - startingGold: 100 + startingGold: 1000 --- !u!114 &7845089877743661692 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Prefabs/Towers/BuildSiteVisual.prefab b/Assets/_Project/Prefabs/Towers/BuildSiteVisual.prefab new file mode 100644 index 0000000..30298ab --- /dev/null +++ b/Assets/_Project/Prefabs/Towers/BuildSiteVisual.prefab @@ -0,0 +1,247 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &2381228775386571742 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2082013748313019226} + - component: {fileID: 7119837420184958461} + - component: {fileID: 8833246162440246558} + - component: {fileID: 1909402251021719060} + m_Layer: 0 + m_Name: Scale Target + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &2082013748313019226 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2381228775386571742} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 1531733800731892084} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!33 &7119837420184958461 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2381228775386571742} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!23 &8833246162440246558 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2381228775386571742} + 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 &1909402251021719060 +BoxCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2381228775386571742} + 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!1 &5570322131082283203 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1059634071631389929} + - component: {fileID: 1627450194292928283} + m_Layer: 9 + m_Name: ClickTarget + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1059634071631389929 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5570322131082283203} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 1531733800731892084} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!65 &1627450194292928283 +BoxCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5570322131082283203} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 1 + 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!1 &7720770984308489338 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1531733800731892084} + - component: {fileID: 9075933591925717035} + - component: {fileID: 7845454079079718139} + m_Layer: 0 + m_Name: BuildSiteVisual + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1531733800731892084 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7720770984308489338} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 10.09691, y: 0.5, z: 20.24222} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 2082013748313019226} + - {fileID: 1059634071631389929} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &9075933591925717035 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7720770984308489338} + 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: 3616792119 + 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 &7845454079079718139 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7720770984308489338} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a236b3b34c8dd784db3bed4e6b0f44f9, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuildSiteVisual + ShowTopMostFoldoutHeaderGroup: 1 + tintedRenderers: [] + scaleTarget: {fileID: 2082013748313019226} + queuedMaterial: {fileID: 2100000, guid: f8d951a6841d3f74098bb31255379774, type: 2} + constructingMaterial: {fileID: 2100000, guid: 88f1dd7b174716645953857b38fb6948, type: 2} + pausedMaterial: {fileID: 0} + stageCount: 4 + queuedYScale: 0.15 diff --git a/Assets/_Project/Prefabs/Towers/BuildSiteVisual.prefab.meta b/Assets/_Project/Prefabs/Towers/BuildSiteVisual.prefab.meta new file mode 100644 index 0000000..90ffa19 --- /dev/null +++ b/Assets/_Project/Prefabs/Towers/BuildSiteVisual.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: dff852699e2897b4494fcbc7f7e547d6 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scenes/Levels/Main.unity b/Assets/_Project/Scenes/Levels/Main.unity index aee9618..556cf36 100644 --- a/Assets/_Project/Scenes/Levels/Main.unity +++ b/Assets/_Project/Scenes/Levels/Main.unity @@ -237,118 +237,6 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &213124036 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 213124040} - - component: {fileID: 213124039} - - component: {fileID: 213124038} - - component: {fileID: 213124037} - m_Layer: 7 - m_Name: Cube (1) - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!65 &213124037 -BoxCollider: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 213124036} - 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!23 &213124038 -MeshRenderer: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 213124036} - 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!33 &213124039 -MeshFilter: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 213124036} - m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} ---- !u!4 &213124040 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 213124036} - serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 9, y: 2, z: 13} - m_LocalScale: {x: 2, y: 1, z: 2} - m_ConstrainProportionsScale: 0 - m_Children: [] - m_Father: {fileID: 0} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &304575571 GameObject: m_ObjectHideFlags: 0 @@ -745,7 +633,7 @@ Transform: - {fileID: 923592499} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &720114039 +--- !u!1 &611926972 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -753,24 +641,24 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 720114043} - - component: {fileID: 720114042} - - component: {fileID: 720114041} - - component: {fileID: 720114040} + - component: {fileID: 611926976} + - component: {fileID: 611926975} + - component: {fileID: 611926974} + - component: {fileID: 611926973} m_Layer: 7 - m_Name: Cube (2) + m_Name: Cube (5) m_TagString: Untagged m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!65 &720114040 +--- !u!65 &611926973 BoxCollider: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 720114039} + m_GameObject: {fileID: 611926972} m_Material: {fileID: 0} m_IncludeLayers: serializedVersion: 2 @@ -785,13 +673,13 @@ BoxCollider: serializedVersion: 3 m_Size: {x: 1, y: 1, z: 1} m_Center: {x: 0, y: 0, z: 0} ---- !u!23 &720114041 +--- !u!23 &611926974 MeshRenderer: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 720114039} + m_GameObject: {fileID: 611926972} m_Enabled: 1 m_CastShadows: 1 m_ReceiveShadows: 1 @@ -834,29 +722,29 @@ MeshRenderer: m_SortingOrder: 0 m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} ---- !u!33 &720114042 +--- !u!33 &611926975 MeshFilter: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 720114039} + m_GameObject: {fileID: 611926972} m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} ---- !u!4 &720114043 +--- !u!4 &611926976 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 720114039} + m_GameObject: {fileID: 611926972} serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 14, y: 3, z: 14} - m_LocalScale: {x: 2, y: 1, z: 2} + m_LocalRotation: {x: -0, y: 0.70710576, z: -0, w: 0.70710784} + m_LocalPosition: {x: 39, y: 2, z: 40} + m_LocalScale: {x: 100, y: 5, z: 20} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0} --- !u!1 &832575517 GameObject: m_ObjectHideFlags: 0 @@ -1112,6 +1000,50 @@ BoxCollider: serializedVersion: 3 m_Size: {x: 7, y: 1, z: 6} m_Center: {x: 0, y: 0, z: 2} +--- !u!1 &1222526236 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1222526238} + - component: {fileID: 1222526237} + m_Layer: 0 + m_Name: SelectionState + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1222526237 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1222526236} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: dc307e7e94967894584e8e6050fc38cf, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.SelectionState +--- !u!4 &1222526238 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1222526236} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 11.62873, y: 0.5, z: 54.78663} + 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 &1239994222 GameObject: m_ObjectHideFlags: 0 @@ -1640,7 +1572,7 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &1789340187 +--- !u!1 &1949204941 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -1648,24 +1580,24 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 1789340191} - - component: {fileID: 1789340190} - - component: {fileID: 1789340189} - - component: {fileID: 1789340188} + - component: {fileID: 1949204945} + - component: {fileID: 1949204944} + - component: {fileID: 1949204943} + - component: {fileID: 1949204942} m_Layer: 7 - m_Name: Cube + m_Name: Cube (4) m_TagString: Untagged m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!65 &1789340188 +--- !u!65 &1949204942 BoxCollider: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1789340187} + m_GameObject: {fileID: 1949204941} m_Material: {fileID: 0} m_IncludeLayers: serializedVersion: 2 @@ -1680,13 +1612,13 @@ BoxCollider: serializedVersion: 3 m_Size: {x: 1, y: 1, z: 1} m_Center: {x: 0, y: 0, z: 0} ---- !u!23 &1789340189 +--- !u!23 &1949204943 MeshRenderer: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1789340187} + m_GameObject: {fileID: 1949204941} m_Enabled: 1 m_CastShadows: 1 m_ReceiveShadows: 1 @@ -1729,29 +1661,29 @@ MeshRenderer: m_SortingOrder: 0 m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} ---- !u!33 &1789340190 +--- !u!33 &1949204944 MeshFilter: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1789340187} + m_GameObject: {fileID: 1949204941} m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} ---- !u!4 &1789340191 +--- !u!4 &1949204945 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1789340187} + m_GameObject: {fileID: 1949204941} serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 9, y: 0.5, z: 9} - m_LocalScale: {x: 2, y: 1, z: 2} + m_LocalRotation: {x: -0, y: 0.70710576, z: -0, w: 0.70710784} + m_LocalPosition: {x: -11, y: 2, z: 40} + m_LocalScale: {x: 100, y: 5, z: 20} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0} --- !u!1 &1975687919 GameObject: m_ObjectHideFlags: 0 @@ -1820,6 +1752,118 @@ BoxCollider: serializedVersion: 3 m_Size: {x: 19, y: 1, z: 34} m_Center: {x: -10.5, y: 0, z: -13.5} +--- !u!1 &2024858685 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2024858689} + - component: {fileID: 2024858688} + - component: {fileID: 2024858687} + - component: {fileID: 2024858686} + m_Layer: 7 + m_Name: Cube (3) + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!65 &2024858686 +BoxCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2024858685} + 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!23 &2024858687 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2024858685} + 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!33 &2024858688 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2024858685} + m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} +--- !u!4 &2024858689 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2024858685} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 14, y: 2, z: 89} + m_LocalScale: {x: 50, y: 5, z: 5} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1660057539 &9223372036854775807 SceneRoots: m_ObjectHideFlags: 0 @@ -1834,6 +1878,7 @@ SceneRoots: - {fileID: 1538763654} - {fileID: 1597884409} - {fileID: 1239994224} - - {fileID: 1789340191} - - {fileID: 213124040} - - {fileID: 720114043} + - {fileID: 2024858689} + - {fileID: 1949204945} + - {fileID: 611926976} + - {fileID: 1222526238} diff --git a/Assets/_Project/Scenes/Levels/TestLevel.asset b/Assets/_Project/Scenes/Levels/TestLevel.asset index 25478f1..4444e15 100644 --- a/Assets/_Project/Scenes/Levels/TestLevel.asset +++ b/Assets/_Project/Scenes/Levels/TestLevel.asset @@ -19,7 +19,7 @@ MonoBehaviour: MapThumbnail: {fileID: 21300000, guid: d2e652d3e1c53454d80d3c1ec7888998, type: 3} ScenePath: Assets/_Project/Scenes/Levels/Main.unity AuthoringHash: 18f981c8a12a79f122c2dad6fb2dab16c7921e01c9cd7bb6aed99d09d60ad2ac - LastBakeTimestamp: 2026-05-03T21:39:37.7056732Z + LastBakeTimestamp: 2026-05-06T02:41:47.3544992Z LastBakeOutcome: 1 LastBakeWarningCount: 2 GridOriginTile: {x: 0, y: 0} diff --git a/Assets/_Project/Scripts/Gameplay/BuildJob.cs b/Assets/_Project/Scripts/Gameplay/BuildJob.cs new file mode 100644 index 0000000..8e17eaf --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuildJob.cs @@ -0,0 +1,182 @@ +// Assets/_Project/Scripts/Gameplay/BuildJob.cs +using System; +using Unity.Netcode; +using UnityEngine; + +namespace TD.Gameplay +{ + /// + /// One entry in a 's build queue. Replicated as part of a + /// on the Builder so all clients can render queued + /// ghosts and progress without per-job RPCs. + /// + /// + /// Identity. is a server-assigned, monotonically + /// increasing identifier so cancel/lookup operations can target a specific job + /// regardless of its current index in the NetworkList. Index-based addressing + /// breaks the moment any job is removed. + /// + /// Stage transitions. Jobs progress + /// → + /// (removed when complete). Only the head of the queue can transition to + /// Constructing; tail jobs stay Queued until they reach the head. + /// + /// Time fields. is set on + /// the server using NetworkManager.ServerTime.TimeAsFloat at the moment + /// construction begins. Clients compute current stage as + /// floor(elapsed / (BuildTime / 4)) against the same server time, so all + /// peers see identical staging without per-stage RPC chatter. + /// + /// INetworkSerializable. Required for use in + /// . Serializes only the minimum needed fields; + /// derived data (footprint size, gold cost) is looked up from the + /// TowerDefinition by on each peer. + /// + [Serializable] + public struct BuildJob : INetworkSerializable, IEquatable + { + // ----- Persistent fields ------------------------------------------ + + /// Server-assigned unique ID. Stable across NetworkList reorderings. + public ulong JobId; + + /// Footprint anchor (SW corner, world-tile coords). + public Vector2Int Anchor; + + /// Index into TowerPlacementManager.towerDefinitions[]. + public int TowerTypeId; + + /// Current stage. See . + public BuildStage Stage; + + /// + /// Server time (seconds) at which the current Constructing run began. + /// -1 while the job is Queued or Paused (no active timer running). + /// Read by clients to compute the current visual stage locally as + /// (now - ConstructionStartServerTime) + AccumulatedConstructionTime. + /// + public float ConstructionStartServerTime; + + /// + /// Construction time accumulated across previous Constructing runs. + /// Used to preserve progress across pause/resume cycles. + /// At pause, set to elapsed_in_current_run + previous_accumulated. + /// At resume, ConstructionStartServerTime is reset to "now" and + /// total progress is computed as + /// (now - ConstructionStartServerTime) + AccumulatedConstructionTime. + /// 0 for jobs that have never been paused. + /// + public float AccumulatedConstructionTime; + + /// + /// Gold the player paid when this job was queued. Used to refund on + /// cancellation. Stored on the job (not looked up from the definition) + /// so that a future "tower price change mid-match" mechanic refunds + /// what was actually paid. + /// + public int GoldSpent; + + // ----- Convenience constructors ----------------------------------- + + public static BuildJob CreateQueued(ulong jobId, Vector2Int anchor, int towerTypeId, int goldSpent) + { + return new BuildJob + { + JobId = jobId, + Anchor = anchor, + TowerTypeId = towerTypeId, + Stage = BuildStage.Queued, + ConstructionStartServerTime = -1f, + AccumulatedConstructionTime = 0f, + GoldSpent = goldSpent, + }; + } + + // ----- INetworkSerializable --------------------------------------- + + public void NetworkSerialize(BufferSerializer serializer) where T : IReaderWriter + { + serializer.SerializeValue(ref JobId); + serializer.SerializeValue(ref Anchor); + serializer.SerializeValue(ref TowerTypeId); + + // BuildStage is a byte-backed enum; serialize as byte for forward-compat + // and to avoid relying on default enum serialization assumptions. + byte stageByte = (byte)Stage; + serializer.SerializeValue(ref stageByte); + Stage = (BuildStage)stageByte; + + serializer.SerializeValue(ref ConstructionStartServerTime); + serializer.SerializeValue(ref AccumulatedConstructionTime); + serializer.SerializeValue(ref GoldSpent); + } + + // ----- IEquatable ------------------------------------------------- + // + // NetworkList requires IEquatable in NGO 2.x. Critically, NGO's + // indexer setter (jobs[i] = newValue) short-circuits the write when + // Equals returns true — see NGO 2.7.0 changelog. If we only compared + // by JobId, mutating Stage or ConstructionStartServerTime on a job and + // writing it back would be silently dropped, leaving the list with the + // old struct values forever. Compare every field that we ever mutate + // after creation. + // + // GetHashCode uses JobId only because that's the unique identity and is + // sufficient for hashing — the full-field comparison only happens on + // hash collision (or where IEquatable bypasses the hash entirely, + // which is the case in NetworkList's indexer setter). + + public bool Equals(BuildJob other) + { + return JobId == other.JobId + && Anchor == other.Anchor + && TowerTypeId == other.TowerTypeId + && Stage == other.Stage + && ConstructionStartServerTime == other.ConstructionStartServerTime + && AccumulatedConstructionTime == other.AccumulatedConstructionTime + && GoldSpent == other.GoldSpent; + } + + public override bool Equals(object obj) => obj is BuildJob other && Equals(other); + + public override int GetHashCode() => JobId.GetHashCode(); + } + + /// + /// Lifecycle stage of a . + /// + /// + /// Backed by byte to keep the serialized payload small and to allow the byte + /// round-trip in . + /// + public enum BuildStage : byte + { + /// + /// Job is in the queue but the builder has not yet arrived at it. + /// Tile is occupied (no other tower can be queued/placed there) but + /// remains walkable — enemies pass through queued ghosts because the + /// ghost represents intent, not a structure. + /// + Queued = 0, + + /// + /// Builder has arrived and construction is in progress. Tile is + /// occupied AND non-walkable — this is the moment the maze actually + /// changes. Path re-validation runs at the transition into this stage. + /// + Constructing = 1, + + /// + /// Construction was interrupted by the builder being moved away. + /// Tile remains occupied and non-walkable (the half-built tower is + /// still physical, still blocks enemies). The cube's Y-scale is frozen + /// at the level it reached when paused. Resume returns to Constructing + /// stage with previously-accumulated progress preserved. + /// + /// While the head job is Paused, the queue contains exactly this one + /// job — all other queued jobs are refunded and removed at the moment + /// of pausing. + /// + Paused = 2, + } +} \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/BuildJob.cs.meta b/Assets/_Project/Scripts/Gameplay/BuildJob.cs.meta new file mode 100644 index 0000000..e155c7d --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuildJob.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5185b25b41381004293388438523c10b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs b/Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs new file mode 100644 index 0000000..d180657 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs @@ -0,0 +1,478 @@ +// Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs +using Unity.Collections; +using Unity.Netcode; +using UnityEngine; +using TD.Core; +using TD.Towers; + +namespace TD.Gameplay +{ + /// + /// Visual representation of an in-flight : the green + /// queued ghost, and the staged construction animation (4 stages of growing + /// height for the testing cube). One NetworkObject per active job. Despawned + /// when the job is cancelled or when the real + /// takes its place at construction-complete. + /// + /// + /// Why a separate prefab from TowerInstance. The build-site visual + /// has different rendering (transparent green or partial-height cube), no combat, + /// no grid-stamping (the Builder owns those state transitions), and a much + /// shorter lifecycle. Sharing a prefab with TowerInstance would mean adding + /// "am I real or a ghost" branching to every TowerInstance code path. Two + /// prefabs, two responsibilities. + /// + /// Stage replication. Stage and ConstructionStartServerTime are + /// replicated as NetworkVariables so all peers compute identical visuals locally + /// from NetworkManager.ServerTime.TimeAsFloat. Only the server writes; + /// clients read. + /// + /// Visual model. The prefab inspector points at a re-tinted copy + /// of the tower's mesh (the same testing cube). At Stage = Queued, the visual + /// is a translucent green cube at full footprint scale but reduced Y. At + /// Stage = Constructing, the cube grows in 4 sub-stages over BuildTime. + /// + /// No grid stamping here. Walkability and occupancy stamps are + /// driven by the Builder (queue-time stamps occupied=true / walkable=true, + /// construction-start stamps walkable=false, completion is unchanged because + /// TowerInstance takes over). Adding stamping here would create double-write + /// races with the Builder. See lessons in the project context doc. + /// + [RequireComponent(typeof(NetworkObject))] + public class BuildSiteVisual : NetworkBehaviour + { + // ----- Inspector -------------------------------------------------- + + [Header("Visuals")] + [Tooltip("Renderers tinted with the queued-ghost color. Typically every " + + "MeshRenderer on the prefab. Auto-populated from children if empty.")] + [SerializeField] private MeshRenderer[] tintedRenderers; + + [Tooltip("Transform that gets Y-scaled to represent construction progress. " + + "Typically the visual mesh's transform. The growth axis is local Y.")] + [SerializeField] private Transform scaleTarget; + + [Tooltip("Material applied while the job is Queued (translucent green).")] + [SerializeField] private Material queuedMaterial; + + [Tooltip("Material applied while the job is Constructing (opaque, tinted by owner).")] + [SerializeField] private Material constructingMaterial; + + [Tooltip("Material applied while the job is Paused. Visually distinct from " + + "Constructing so players can tell at a glance which builds need a builder. " + + "Suggested: muted/grey-tinted variant of the constructing material.")] + [SerializeField] private Material pausedMaterial; + + [Header("Construction stages")] + [Tooltip("Number of discrete growth stages while constructing. 4 matches the " + + "design doc (1/4 → 2/4 → 3/4 → 4/4 height).")] + [SerializeField] private int stageCount = 4; + + [Tooltip("Y-scale applied to scaleTarget when Stage == Queued. Visually " + + "distinct from any constructing height so the queued ghost reads as " + + "'intent, not progress'.")] + [SerializeField] private float queuedYScale = 0.15f; + + // ----- Networked state -------------------------------------------- + + // Replicated definition name so clients can resolve the source TowerDefinition + // (matches TowerInstance's pattern). Used for footprint size only — the visual + // prefab is the same regardless of tower type for now. + private readonly NetworkVariable definitionName = + new NetworkVariable( + default, + readPerm: NetworkVariableReadPermission.Everyone, + writePerm: NetworkVariableWritePermission.Server); + + // Replicated owner slot for color tinting. Mirrors TowerInstance. + private readonly NetworkVariable ownerSlot = + new NetworkVariable( + PlayerSlot.None, + readPerm: NetworkVariableReadPermission.Everyone, + writePerm: NetworkVariableWritePermission.Server); + + // Anchor tile (SW corner of the footprint, world-tile coords). Replicated so + // shelved visuals are self-describing — when a player clicks one to resume, + // the server can rebuild a BuildJob from these fields without consulting any + // separate registry. + private readonly NetworkVariable anchor = + new NetworkVariable( + Vector2Int.zero, + readPerm: NetworkVariableReadPermission.Everyone, + writePerm: NetworkVariableWritePermission.Server); + + // Tower type ID (index into TowerPlacementManager.towerDefinitions[]). Replicated + // for the same self-describing reason as Anchor. + private readonly NetworkVariable towerTypeId = + new NetworkVariable( + 0, + readPerm: NetworkVariableReadPermission.Everyone, + writePerm: NetworkVariableWritePermission.Server); + + // Gold the player paid to queue this build. Carried on the visual so resume + // can refund the correct amount if the player later cancels. (Cancellation + // gesture deferred to HUD; this field is the data dependency.) + private readonly NetworkVariable goldSpent = + new NetworkVariable( + 0, + readPerm: NetworkVariableReadPermission.Everyone, + writePerm: NetworkVariableWritePermission.Server); + + // True iff this build site has been "shelved" — removed from its owning Builder's + // queue and now a standalone object waiting to be resumed via right-click. + // Shelved visuals are responsible for their own grid-state cleanup on despawn + // (since the Builder no longer tracks them in jobIdToVisual). + private readonly NetworkVariable isShelved = + new NetworkVariable( + false, + readPerm: NetworkVariableReadPermission.Everyone, + writePerm: NetworkVariableWritePermission.Server); + + // Current stage. Drives material swap and Y-scale animation. + private readonly NetworkVariable currentStage = + new NetworkVariable( + BuildStage.Queued, + readPerm: NetworkVariableReadPermission.Everyone, + writePerm: NetworkVariableWritePermission.Server); + + // Server time at which the current Constructing run began. -1 while Queued or Paused. + // Used together with accumulatedConstructionTime to compute total progress: + // total = (now - constructionStartServerTime) + accumulatedConstructionTime. + private readonly NetworkVariable constructionStartServerTime = + new NetworkVariable( + -1f, + readPerm: NetworkVariableReadPermission.Everyone, + writePerm: NetworkVariableWritePermission.Server); + + // Construction time accumulated across previous Constructing runs (for pause/resume). + // 0 for jobs that have never been paused. At pause, set to the elapsed time of the + // current run added to whatever was already accumulated. + private readonly NetworkVariable accumulatedConstructionTime = + new NetworkVariable( + 0f, + readPerm: NetworkVariableReadPermission.Everyone, + writePerm: NetworkVariableWritePermission.Server); + + // BuildTime is replicated rather than looked up so clients don't need to + // resolve the TowerDefinition before they can render progress. + // (Resolution can race with the first stage update otherwise.) + private readonly NetworkVariable buildTime = + new NetworkVariable( + 0f, + readPerm: NetworkVariableReadPermission.Everyone, + writePerm: NetworkVariableWritePermission.Server); + + // ----- Public accessors (read by the input controller on click) -- + + // NOTE: ownership identity comes from the inherited NetworkBehaviour.OwnerClientId, + // which is correct because we use SpawnWithOwnership in Builder.SpawnBuildSiteVisual. + // No redundant NetworkVariable for ownership. + + /// The current build stage. Read by clients for click-target tests. + public BuildStage CurrentStage => currentStage.Value; + + /// True iff this visual has been shelved (no longer in its Builder's queue). + public bool IsShelved => isShelved.Value; + + /// Footprint anchor (SW corner). Used by server to rebuild a BuildJob on resume. + public Vector2Int Anchor => anchor.Value; + + /// Tower type ID. Used by server to rebuild a BuildJob on resume. + public int TowerTypeId => towerTypeId.Value; + + /// Gold paid for this build. Used by server to rebuild a BuildJob on resume. + public int GoldSpent => goldSpent.Value; + + /// Accumulated construction time (across pause/resume cycles). Used on resume. + public float AccumulatedConstructionTime => accumulatedConstructionTime.Value; + + // ----- Pre-spawn init data (server) ------------------------------- + + private string pendingDefName; + private PlayerSlot pendingOwner = PlayerSlot.None; + private float pendingBuildTime; + private Vector2Int pendingAnchor; + private int pendingTowerTypeId; + private int pendingGoldSpent; + private bool hasPendingInit; + + // ----- Lifecycle -------------------------------------------------- + + /// + /// Server-only: stores the data that will write + /// into NetworkVariables. Must be called between Instantiate and Spawn(). + /// + /// + /// Owner identity is conveyed through NGO ownership (via SpawnWithOwnership), + /// not through this method — see . + /// + public void InitializeServer(TowerDefinition def, PlayerSlot owner, + Vector2Int anchorTile, int towerTypeIdValue, + int goldSpentValue) + { + var nm = NetworkManager.Singleton; + if (nm == null || !nm.IsServer) + { + Debug.LogError("[BuildSiteVisual] InitializeServer called when not running as server."); + return; + } + + pendingDefName = def != null ? def.name : string.Empty; + pendingOwner = owner; + pendingBuildTime = def != null ? def.BuildTime : 0f; + pendingAnchor = anchorTile; + pendingTowerTypeId = towerTypeIdValue; + pendingGoldSpent = goldSpentValue; + hasPendingInit = true; + } + + public override void OnNetworkSpawn() + { + // Auto-populate tinted renderers if not configured in the inspector. + if (tintedRenderers == null || tintedRenderers.Length == 0) + tintedRenderers = GetComponentsInChildren(); + + // Server: now that the NetworkObject is spawned, write the pending init + // values into NetworkVariables. NGO captures these into the initial sync + // message so clients see correct values on their first OnNetworkSpawn. + if (IsServer && hasPendingInit) + { + definitionName.Value = new FixedString64Bytes(pendingDefName ?? string.Empty); + ownerSlot.Value = pendingOwner; + buildTime.Value = pendingBuildTime; + anchor.Value = pendingAnchor; + towerTypeId.Value = pendingTowerTypeId; + goldSpent.Value = pendingGoldSpent; + hasPendingInit = false; + } + + // Subscribe to value changes so visual updates are reactive. + currentStage.OnValueChanged += HandleStageChanged; + + // Apply initial visual state based on the (now-replicated) values. + ApplyStageVisual(currentStage.Value); + } + + public override void OnNetworkDespawn() + { + currentStage.OnValueChanged -= HandleStageChanged; + + // Server-only cleanup: if this visual was shelved at the time it was + // despawned (e.g., the player disconnected while a tower was shelved), + // restore walkability and occupancy on the footprint. Non-shelved + // visuals are owned by their Builder, which handles cleanup via + // jobIdToVisual; we mustn't double-free in that case. + if (IsServer && isShelved.Value) + { + RestoreFootprintGridState(); + } + } + + // Server-only: restore walkability=true and occupancy=false on this build site's + // footprint. Called when a shelved visual is despawned without going through a + // normal "resume → cancel" cycle (e.g., player disconnect cleanup). + private void RestoreFootprintGridState() + { + var loader = LevelLoader.Instance; + if (loader == null || !loader.IsLoaded) return; + + var def = TowerPlacementManager.GetDefinition(towerTypeId.Value); + if (def == null) return; + + foreach (var tile in GridCoordinates.GetFootprintTiles( + anchor.Value, def.FootprintSize)) + { + loader.SetOccupied(tile, false); + loader.SetWalkable(tile, true); + } + } + + // ----- Per-frame visual update (all peers) ------------------------ + + private void Update() + { + // While constructing, smoothly interpolate Y-scale through the stages + // based on server time. This runs on every peer (server + clients) so + // visuals stay synchronized regardless of who's looking. + // Paused stage does NOT update — Y-scale is frozen at the pause point. + if (currentStage.Value != BuildStage.Constructing) return; + + float yScale = ComputeConstructingYScale(); + ApplyYScale(yScale); + } + + // ----- Server API ------------------------------------------------- + + /// + /// Server-only: marks this visual as shelved. The visual remains in the world + /// but no longer belongs to the owning Builder's queue. Stage stays Paused; + /// this method just flips the IsShelved flag so the visual takes responsibility + /// for its own grid-state cleanup on despawn. + /// + public void ServerMarkShelved() + { + if (!IsServer) return; + isShelved.Value = true; + } + + /// + /// Server-only: marks this visual as unshelved (taken back into a Builder's queue). + /// Called when the player right-clicks a shelved visual to resume construction. + /// Grid-state cleanup is once again the Builder's responsibility via jobIdToVisual. + /// + public void ServerMarkUnshelved() + { + if (!IsServer) return; + isShelved.Value = false; + } + + /// + /// Server-only: transitions the visual from Queued (or Paused) to Constructing + /// and records the server time for stage progression. Caller is responsible + /// for setting from the BuildJob's + /// AccumulatedConstructionTime — non-zero values mean this is a resume. + /// + public void ServerBeginConstructing(float accumulatedTimeBeforeThisRun) + { + if (!IsServer) return; + if (currentStage.Value == BuildStage.Constructing) return; + + accumulatedConstructionTime.Value = accumulatedTimeBeforeThisRun; + constructionStartServerTime.Value = + (float)NetworkManager.Singleton.ServerTime.Time; + currentStage.Value = BuildStage.Constructing; + } + + /// + /// Server-only: transitions Constructing → Paused AND writes the new + /// accumulated construction time. Y-scale freezes at the level matching + /// the accumulated time. After this returns, the visual is fully self- + /// describing — it carries enough state to be shelved and later resumed. + /// + public void ServerPauseAndPersistAccumulated(float totalAccumulated) + { + if (!IsServer) return; + if (currentStage.Value != BuildStage.Constructing) return; + + accumulatedConstructionTime.Value = totalAccumulated; + // Reset constructionStartServerTime to -1 so any future progress reads + // know there's no active timer running. + constructionStartServerTime.Value = -1f; + currentStage.Value = BuildStage.Paused; + } + + // ----- Visual state machine --------------------------------------- + + private void HandleStageChanged(BuildStage previous, BuildStage current) + { + ApplyStageVisual(current); + } + + private void ApplyStageVisual(BuildStage stage) + { + switch (stage) + { + case BuildStage.Queued: + SwapMaterial(queuedMaterial); + ApplyYScale(queuedYScale); + break; + + case BuildStage.Constructing: + SwapMaterial(constructingMaterial); + ApplyOwnerTint(); + ApplyYScale(ComputeConstructingYScale()); + break; + + case BuildStage.Paused: + // Use paused material if assigned; fall back to constructing + // material if not (still readable, just less distinct). + SwapMaterial(pausedMaterial != null ? pausedMaterial : constructingMaterial); + ApplyOwnerTint(); + // Freeze Y-scale at whatever the accumulated progress represents. + // ComputeConstructingYScale uses accumulatedConstructionTime alone + // when constructionStartServerTime is -1 (the pause sentinel). + ApplyYScale(ComputePausedYScale()); + break; + } + } + + // Stage index 0..stageCount-1 based on elapsed server time PLUS any accumulated + // time from previous Constructing runs (resume support). + // Returned Y-scale is (stageIndex + 1) / stageCount, so stage 0 = 1/4, + // stage 1 = 2/4, ..., stage stageCount-1 = 4/4 = full height. + private float ComputeConstructingYScale() + { + float bt = buildTime.Value; + if (bt <= 0f || stageCount <= 0) return 1f; + + float currentRunElapsed = (float)NetworkManager.Singleton.ServerTime.Time + - constructionStartServerTime.Value; + float total = currentRunElapsed + accumulatedConstructionTime.Value; + + float perStage = bt / stageCount; + int stageIndex = Mathf.Clamp( + Mathf.FloorToInt(total / perStage), + 0, stageCount - 1); + + return (stageIndex + 1f) / stageCount; + } + + // While Paused, only the accumulated time matters (no current run is in flight). + private float ComputePausedYScale() + { + float bt = buildTime.Value; + if (bt <= 0f || stageCount <= 0) return 1f; + + float total = accumulatedConstructionTime.Value; + float perStage = bt / stageCount; + int stageIndex = Mathf.Clamp( + Mathf.FloorToInt(total / perStage), + 0, stageCount - 1); + + return (stageIndex + 1f) / stageCount; + } + + private void ApplyYScale(float y) + { + if (scaleTarget == null) return; + Vector3 s = scaleTarget.localScale; + scaleTarget.localScale = new Vector3(s.x, y, s.z); + } + + // ----- Material handling ------------------------------------------ + + private void SwapMaterial(Material mat) + { + if (mat == null || tintedRenderers == null) return; + foreach (var rend in tintedRenderers) + { + if (rend == null) continue; + rend.sharedMaterial = mat; + } + } + + // Reused per-instance to avoid GC. Lazy because Unity disallows + // construction in field initializers. + private MaterialPropertyBlock colorPropertyBlock; + private static readonly int ColorPropertyId = Shader.PropertyToID("_Color"); + private static readonly int BaseColorPropertyId = Shader.PropertyToID("_BaseColor"); + + private void ApplyOwnerTint() + { + if (tintedRenderers == null) return; + + Color c = PlayerColors.Get(ownerSlot.Value); + c.a = 1f; + + colorPropertyBlock ??= new MaterialPropertyBlock(); + colorPropertyBlock.SetColor(ColorPropertyId, c); + colorPropertyBlock.SetColor(BaseColorPropertyId, c); + + foreach (var rend in tintedRenderers) + { + if (rend == null) continue; + rend.SetPropertyBlock(colorPropertyBlock); + } + } + } +} \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs.meta b/Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs.meta new file mode 100644 index 0000000..682821a --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a236b3b34c8dd784db3bed4e6b0f44f9 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/Builder.cs b/Assets/_Project/Scripts/Gameplay/Builder.cs index 805bd81..b2e46c4 100644 --- a/Assets/_Project/Scripts/Gameplay/Builder.cs +++ b/Assets/_Project/Scripts/Gameplay/Builder.cs @@ -3,32 +3,46 @@ using System.Collections.Generic; using Unity.Netcode; using UnityEngine; using TD.Core; -using TD.Levels; +using TD.Towers; namespace TD.Gameplay { /// - /// Per-player avatar that gates tower placement by proximity. Server-authoritative - /// position; clients submit move requests via Rpc and the server validates and applies. + /// Per-player avatar that gates tower placement by proximity AND drives the build + /// queue. Server-authoritative position and queue; clients submit move requests + /// via Rpc and read replicated NetworkList state to render queued/constructing + /// visuals. /// /// - /// Pure visual avatar. Builders have no collider for gameplay purposes — - /// they don't block enemies, can't be attacked, and aren't selected as targets. They - /// are visible to all players but only their owner can move them. + /// Pure visual avatar. Builders have no collider for gameplay purposes + /// (no enemy blocking, not targetable). They DO have a small trigger collider on + /// the "Selection" physics layer so left-click can select them — that collider + /// must not be on layers that participate in placement raycasts or builder + /// height sampling. See the prefab setup notes in the project context doc. /// - /// Terrain-aware height. Each frame the server casts a ray straight down - /// from the builder against the and sets Y to - /// hit.point.y + heightOffset. If the ray misses, falls back to the buildable - /// plane Y. Towers are not on the terrain layer, so they don't influence height. + /// Terrain-aware height. Each frame the server casts a ray straight + /// down from the builder against and sets Y to + /// hit.point.y + heightOffset. Falls back to + /// if the ray misses. Towers must not be on the terrain layer. /// - /// Range gating. is the public query - /// that TowerPlacementManager uses to validate placement requests. Builder range - /// is measured center-of-builder to center-of-anchor-tile in world units. + /// Range gating. is the public + /// query that TowerPlacementManager uses to validate placement requests. + /// Range is measured center-of-builder to nearest-point-of-footprint in world units. /// - /// Static registry. Like , builders register - /// themselves in a static dictionary keyed by OwnerClientId on spawn, so server - /// gameplay code (notably TowerPlacementManager) can find a player's builder without - /// scene traversal. + /// Build queue. Each Builder owns a of + /// . The server appends jobs from + /// TowerPlacementManager.ProcessRequest via . + /// Each server tick, if the head job is Queued, the builder walks toward its + /// anchor; on arrival, the head job transitions to Constructing (path re-validated + /// here; refunded and dropped if the maze would now break). Stages advance by + /// time; on completion the visual is replaced with a real TowerInstance + /// and the head job is removed. Cancellation drops every job in the queue and + /// refunds 100% per the design doc. + /// + /// Static registry. Like , builders + /// register themselves in a static dictionary keyed by OwnerClientId on + /// spawn so server gameplay code can find a player's builder without scene + /// traversal. /// [RequireComponent(typeof(NetworkObject))] public class Builder : NetworkBehaviour @@ -74,6 +88,11 @@ namespace TD.Gameplay "but smoother.")] [SerializeField] private float arrivalThreshold = 0.05f; + [Tooltip("Degrees per second the builder rotates to face its movement direction. " + + "Lower = lazier turns; higher = snappier. The builder only rotates while " + + "moving; it keeps its last facing when idle.")] + [SerializeField] private float turnRateDegPerSec = 540f; + [Header("Height tracking")] [Tooltip("Vertical offset above the terrain at which the builder hovers. " + "Re-evaluated every server tick by raycasting straight down.")] @@ -93,6 +112,24 @@ namespace TD.Gameplay "for placement to be allowed, measured in world units (== tiles).")] [SerializeField] private float buildRange = 6f; + [Header("Build queue")] + [Tooltip("Maximum number of pending build jobs. Bounds memory and prevents a player " + + "from spamming queue entries faster than the server can process them.")] + [SerializeField] private int maxQueueDepth = 32; + + [Tooltip("Build-site visual prefab. Spawned at queue-time as a green ghost; " + + "transitions to staged-construction visuals on arrival; despawned on " + + "completion (replaced by the real TowerInstance) or cancellation.")] + [SerializeField] private GameObject buildSiteVisualPrefab; + + [Header("Visuals")] + [Tooltip("Mesh renderers that should be tinted with the owner's player color. " + + "Drag in only the builder body's renderers — exclude the SelectionRing, " + + "BuildRangeIndicator, or any other visual that has its own color rules. " + + "If left empty, the builder will not be tinted (other meshes' colors " + + "from the prefab are preserved).")] + [SerializeField] private MeshRenderer[] tintedRenderers; + // ----- Networked state -------------------------------------------- // Server-authoritative target position. The server moves the builder toward this @@ -104,6 +141,28 @@ namespace TD.Gameplay readPerm: NetworkVariableReadPermission.Everyone, writePerm: NetworkVariableWritePermission.Server); + // The build queue. Replicated as a NetworkList so all clients can render queued + // ghosts and progress without per-job RPCs. Server is the only writer. + private NetworkList jobs; + + /// + /// Read-only access to the current job queue. Clients use this to render + /// build-site visuals at queued anchors. Index 0 is the head (active) job. + /// + public NetworkList Jobs => jobs; + + // ----- Server-only state ------------------------------------------ + // + // Tracks the BuildSiteVisual NetworkObject for each active job so we can + // despawn it on completion or cancellation. NetworkList itself doesn't + // hold object references; we keep the side-table here. + private readonly Dictionary jobIdToVisual + = new Dictionary(); + + // Monotonic job-ID counter on the server. Resets per builder, which is fine — + // IDs only need to be unique within one builder's queue. + private ulong nextJobId = 1; + // ----- Public accessors ------------------------------------------- /// The builder's current world position (its actual transform position, @@ -122,8 +181,39 @@ namespace TD.Gameplay /// Build range in world units. public float BuildRange => buildRange; + /// Maximum jobs allowed in the queue. + public int MaxQueueDepth => maxQueueDepth; + + /// True if a tile is currently part of any queued or constructing job. + /// + /// Used by TowerPlacementManager to reject placement on tiles already + /// covered by another pending job in this builder's queue. Walks every job + /// and footprint — O(jobs × tiles) but bounded by . + /// + public bool IsTileInActiveJob(Vector2Int tile) + { + for (int i = 0; i < jobs.Count; i++) + { + var job = jobs[i]; + Vector2Int footprint = ResolveFootprint(job.TowerTypeId); + foreach (var t in GridCoordinates.GetFootprintTiles(job.Anchor, footprint)) + { + if (t == tile) return true; + } + } + return false; + } + // ----- Lifecycle -------------------------------------------------- + private void Awake() + { + // NetworkList must be allocated before NetworkObject.Spawn() (NGO 2.x + // discovers it during the spawn handshake). Awake runs before any + // network lifecycle method, so this is the right place. + jobs = new NetworkList(); + } + public override void OnNetworkSpawn() { s_byClientId[OwnerClientId] = this; @@ -132,15 +222,46 @@ namespace TD.Gameplay { // Set initial target = current position so the builder doesn't drift on spawn. // The spawner is responsible for placing this builder at a sensible position - // BEFORE Spawn() — see PlayerSpawnHelper / Player.OnNetworkSpawn. + // BEFORE Spawn() — see PlayerBuilderSpawner. targetPosition.Value = transform.position; Debug.Log($"[Builder] Spawned for client {OwnerClientId} at " + $"{transform.position}."); } ApplyOwnerColor(); + + // Auto-select on spawn for the local owner. RTS-standard "your unit is + // selected by default when it appears" — without this, the player has to + // click their own builder before any right-click command works, which is + // friction. Players can still deselect (left-click empty space, Escape) + // and re-select normally. Owner-gated so remote clients don't accidentally + // get someone else's builder selected. + if (IsOwner) + { + SelectionState.Instance?.Select(this); + } } + public override void OnNetworkDespawn() + { + if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this) + s_byClientId.Remove(OwnerClientId); + + // Server-only cleanup: despawn any remaining build-site visuals so they + // don't leak when a player disconnects mid-construction. + if (IsServer) + { + foreach (var kv in jobIdToVisual) + { + if (kv.Value != null && kv.Value.IsSpawned) + kv.Value.Despawn(destroy: true); + } + jobIdToVisual.Clear(); + } + } + + // (NetworkList is owned by NGO; no manual Dispose needed in NGO 2.x.) + // ----- Owner color tinting ---------------------------------------- // Lazily allocated; reused across renderers. Construction in a field initializer @@ -166,14 +287,16 @@ namespace TD.Gameplay colorPropertyBlock.SetColor(ColorPropertyId, c); colorPropertyBlock.SetColor(BaseColorPropertyId, c); - foreach (var rend in GetComponentsInChildren()) + // Tint only the renderers explicitly listed in the inspector. Avoids + // accidentally re-coloring the SelectionRing or BuildRangeIndicator, + // which need their own (non-player) colors. If the list is empty, + // skip — the builder shows whatever colors are baked into the prefab. + if (tintedRenderers == null) return; + foreach (var rend in tintedRenderers) + { + if (rend == null) continue; rend.SetPropertyBlock(colorPropertyBlock); - } - - public override void OnNetworkDespawn() - { - if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this) - s_byClientId.Remove(OwnerClientId); + } } // ----- Per-frame movement (server only) --------------------------- @@ -182,7 +305,15 @@ namespace TD.Gameplay { if (!IsServer) return; - // Move toward target on the XZ plane. + // Step 1: drive movement target from the queue head, if appropriate. + ServerDriveQueue(); + + // Step 2: move toward the target on XZ, sample terrain Y. + ServerStepMovement(); + } + + private void ServerStepMovement() + { Vector3 current = transform.position; Vector3 target = targetPosition.Value; @@ -191,18 +322,35 @@ namespace TD.Gameplay Vector3 targetXZ = new Vector3(target.x, 0f, target.z); Vector3 newXZ; + bool moving; if (Vector3.SqrMagnitude(currentXZ - targetXZ) <= arrivalThreshold * arrivalThreshold) { newXZ = targetXZ; + moving = false; } else { newXZ = Vector3.MoveTowards(currentXZ, targetXZ, moveSpeed * Time.deltaTime); + moving = true; } // Resolve Y from terrain. float groundY = SampleTerrainY(new Vector3(newXZ.x, 0f, newXZ.z)); transform.position = new Vector3(newXZ.x, groundY + heightOffset, newXZ.z); + + // Smoothly face the movement direction. We rotate on the server only; + // NetworkTransform replicates the rotation to clients alongside position. + // Skip rotation when stationary so the builder keeps its last facing. + if (moving) + { + Vector3 dir = targetXZ - currentXZ; + if (dir.sqrMagnitude > 0.0001f) + { + Quaternion desired = Quaternion.LookRotation(dir, Vector3.up); + transform.rotation = Quaternion.RotateTowards( + transform.rotation, desired, turnRateDegPerSec * Time.deltaTime); + } + } } /// @@ -228,8 +376,7 @@ namespace TD.Gameplay /// /// Server-side entry point: directly sets the move target. Called by the input - /// controller's Rpc handler after validation. Out-of-map positions are clamped - /// to the current position (no-op). + /// controller's Rpc handler after validation. Out-of-map positions are rejected. /// public void ServerSetMoveTarget(Vector3 worldPos) { @@ -246,8 +393,7 @@ namespace TD.Gameplay Vector2Int tile = GridCoordinates.WorldToGrid(worldPos); if (!loader.IsInMap(tile)) { - // Out-of-map move requests are rejected silently. Could log if useful for - // debugging client/server mismatch, but otherwise this is normal. + // Out-of-map move requests are rejected silently. return; } } @@ -268,7 +414,7 @@ namespace TD.Gameplay ServerSetMoveTarget(worldPos); } - // ----- Range query (used by TowerPlacementManager) ---------------- + // ----- Range query (used by Builder's own queue driver) ---------- /// /// True if the tower with the given anchor and footprint size is within build range @@ -276,9 +422,14 @@ namespace TD.Gameplay /// nearest point of the tower footprint, in world units. /// /// - /// "Nearest point of the footprint" rather than "footprint center" so that a tower - /// is reachable when ANY of its tiles is within range, even if the center is - /// slightly outside. Aligns with player intuition that "I can reach this tile." + /// Used by as the "have I arrived?" check — + /// the builder walks toward a queued job until it's in range, then begins + /// construction. NOT consulted at queue-time; players can queue any tile in their + /// zone regardless of where the builder currently is. + /// + /// "Nearest point of the footprint" rather than "footprint center" so that + /// a tower is reachable when ANY of its tiles is within range, even if the center + /// is slightly outside. Aligns with player intuition that "I can reach this tile." /// public bool IsTileWithinBuildRange(Vector2Int anchor, Vector2Int footprintSize) { @@ -299,5 +450,572 @@ namespace TD.Gameplay return Vector3.SqrMagnitude(builderXZ - nearestPoint) <= buildRange * buildRange; } + + // =================================================================== + // BUILD QUEUE (server-side) + // =================================================================== + + /// + /// Server-only: append a new job to the queue. Caller is responsible for having + /// already validated the placement (ownership, gold, range, path) and stamped + /// the footprint as occupied (walkable=true). Returns true on success, or false + /// if the queue is full. + /// + public bool ServerEnqueueJob(Vector2Int anchor, int towerTypeId, int goldSpent, + out ulong jobId) + { + jobId = 0; + if (!IsServer) return false; + if (jobs.Count >= maxQueueDepth) return false; + + jobId = nextJobId++; + var job = BuildJob.CreateQueued(jobId, anchor, towerTypeId, goldSpent); + jobs.Add(job); + + // Spawn a green-ghost build-site visual at the anchor. + SpawnBuildSiteVisual(job); + + return true; + } + + private void SpawnBuildSiteVisual(BuildJob job) + { + if (buildSiteVisualPrefab == null) + { + Debug.LogError("[Builder] No buildSiteVisualPrefab assigned. " + + "Queued ghost will not be visible."); + return; + } + + var def = TowerPlacementManager.GetDefinition(job.TowerTypeId); + if (def == null) + { + Debug.LogError($"[Builder] Could not resolve TowerDefinition " + + $"{job.TowerTypeId} when spawning build-site visual."); + return; + } + + Vector3 spawnPos = GridCoordinates.GetFootprintCenterWorld( + job.Anchor, def.FootprintSize); + // Sit on the buildable plane at the same elevation real towers will use. + spawnPos.y = 0f; + + var go = Instantiate(buildSiteVisualPrefab, spawnPos, Quaternion.identity); + var visual = go.GetComponent(); + if (visual == null) + { + Debug.LogError("[Builder] buildSiteVisualPrefab is missing a " + + "BuildSiteVisual component."); + Destroy(go); + return; + } + + // Owner slot: same stub mapping as elsewhere. + byte slotByte = (byte)(OwnerClientId + 1); + PlayerSlot owner = (slotByte >= 1 && slotByte <= 9) + ? (PlayerSlot)slotByte + : PlayerSlot.None; + + visual.InitializeServer(def, owner, job.Anchor, job.TowerTypeId, job.GoldSpent); + + var netObj = go.GetComponent(); + if (netObj == null) + { + Debug.LogError("[Builder] buildSiteVisualPrefab is missing a NetworkObject."); + Destroy(go); + return; + } + // SpawnWithOwnership rather than plain Spawn so the visual's inherited + // NetworkBehaviour.OwnerClientId correctly reports the player's client ID. + // This is purely for identity (used by client-side click tests in + // BuilderInputController) — the visual has no owner-only Rpcs, so granting + // ownership to the player has no security implications. As a bonus, NGO + // automatically cleans up owned NetworkObjects when a player disconnects. + netObj.SpawnWithOwnership(OwnerClientId, destroyWithScene: true); + + jobIdToVisual[job.JobId] = netObj; + } + + // ----- Per-tick queue drive (server) ------------------------------ + + private void ServerDriveQueue() + { + if (jobs.Count == 0) return; + + var head = jobs[0]; + + switch (head.Stage) + { + case BuildStage.Queued: + DriveHead_Queued(ref head); + break; + + case BuildStage.Constructing: + DriveHead_Constructing(ref head); + break; + + case BuildStage.Paused: + // Nothing to do — paused jobs wait for an explicit resume command. + // The builder is free to be moved around; targetPosition is not + // touched here so player right-click moves are honored. + break; + } + } + + // While the head job is Queued: walk toward the build site, checking each + // tick whether the builder is now in range. On reaching range, re-validate + // the path and either kick off construction or fail it. The builder doesn't + // need to walk *onto* the build site — being within build-range is enough, + // matching player intuition and the Wintermaul-style "reach to build" feel. + private void DriveHead_Queued(ref BuildJob head) + { + var def = TowerPlacementManager.GetDefinition(head.TowerTypeId); + if (def == null) + { + // Lost the definition somehow — drop the job and refund. + Debug.LogWarning($"[Builder] Job {head.JobId} has unknown tower type " + + $"{head.TowerTypeId}. Dropping and refunding."); + ServerCancelHead(refund: true); + return; + } + + // If we're already in range, kick off construction immediately. Otherwise + // walk toward the build-site center; we'll re-check range on subsequent + // ticks. The walk target is the footprint center even though we may stop + // before reaching it — Vector3.MoveTowards handles the early-stop naturally + // when the range check trips. + if (!IsTileWithinBuildRange(head.Anchor, def.FootprintSize)) + { + Vector3 siteCenter = GridCoordinates.GetFootprintCenterWorld( + head.Anchor, def.FootprintSize); + targetPosition.Value = new Vector3(siteCenter.x, 0f, siteCenter.z); + return; + } + + // In range. Stop here so the builder doesn't keep walking onto the + // build site after construction kicks off. Setting target = current + // position freezes the walk at this exact spot. + targetPosition.Value = new Vector3( + transform.position.x, 0f, transform.position.z); + + // Path-revalidation: the rules differ for fresh-queued vs resume-from-paused. + // + // FRESH QUEUE (AccumulatedConstructionTime == 0): footprint is currently + // walkable (queue stage doesn't stamp it). Stamp it non-walkable, run BFS, + // un-stamp on failure. + // + // RESUME (AccumulatedConstructionTime > 0): footprint is ALREADY non-walkable + // (has been since the original Constructing transition; pause didn't restore + // walkability because the half-built tower keeps blocking enemies). Don't + // stamp anything — just verify the path still works given current grid state. + // Path failure on resume is essentially impossible (the path was valid before + // pause, and only this player can build in their zone), but the defensive + // check is here in case future mechanics change that. + var placingSlot = OwnerToSlot(OwnerClientId); + bool isResume = head.AccumulatedConstructionTime > 0f; + bool pathOk; + if (isResume) + { + pathOk = TowerPlacementManager.Instance != null + && TowerPlacementManager.Instance.ServerVerifyPathStillValid(placingSlot); + } + else + { + pathOk = TowerPlacementManager.Instance != null + && TowerPlacementManager.Instance.ServerCommitConstructionStart( + head.Anchor, def.FootprintSize, placingSlot); + } + + if (!pathOk) + { + // Path would now break. Refund and drop the job. The walkability + // rules differ between fresh-queue and resume: + // - Fresh queue: ServerCommitConstructionStart already rolled + // walkable=true, so the tile is in the same state as a normal + // Queued job (occupied=true, walkable=true). Just clear occupancy. + // - Resume: walkable was never stamped this go-round but the tile + // IS non-walkable from the original construction. We need to + // restore walkability since the tower is being fully removed. + Debug.Log($"[Builder] Job {head.JobId} dropped at construction-start: " + + $"path would break. Refunding {head.GoldSpent} gold."); + + var loader = LevelLoader.Instance; + if (loader != null && loader.IsLoaded) + { + foreach (var tile in GridCoordinates.GetFootprintTiles(head.Anchor, def.FootprintSize)) + { + loader.SetOccupied(tile, false); + if (isResume) loader.SetWalkable(tile, true); + } + } + RefundJobGold(head); + DespawnVisualAndForget(head.JobId); + jobs.RemoveAt(0); + return; + } + + // Path OK; transition to Constructing. + head.Stage = BuildStage.Constructing; + head.ConstructionStartServerTime = (float)NetworkManager.Singleton.ServerTime.Time; + jobs[0] = head; + + // Tell the build-site visual to switch to the constructing animation. + // For a fresh transition AccumulatedConstructionTime is 0 (no previous progress); + // for a resume it carries the elapsed time from prior runs so the visual + // and the elapsed-time computation pick up where they left off. + if (jobIdToVisual.TryGetValue(head.JobId, out var visualObj) && visualObj != null) + { + var visual = visualObj.GetComponent(); + if (visual != null) visual.ServerBeginConstructing(head.AccumulatedConstructionTime); + } + } + + // While the head job is Constructing: wait for buildTime to elapse, then + // promote the build-site to a real TowerInstance and dequeue. + private void DriveHead_Constructing(ref BuildJob head) + { + var def = TowerPlacementManager.GetDefinition(head.TowerTypeId); + if (def == null) + { + // Should be impossible — definitions are validated at queue-time. + Debug.LogError($"[Builder] Constructing job {head.JobId} has unknown " + + $"tower type {head.TowerTypeId}. Dropping."); + ServerCancelHead(refund: true); + return; + } + + // Total construction progress = elapsed in current run + previously accumulated + // (across prior pause/resume cycles). Pause sets ConstructionStartServerTime to -1 + // so this code path only runs when the job is genuinely Constructing — but defensive + // check anyway. + float currentRunElapsed = head.ConstructionStartServerTime > 0f + ? (float)NetworkManager.Singleton.ServerTime.Time - head.ConstructionStartServerTime + : 0f; + float total = currentRunElapsed + head.AccumulatedConstructionTime; + if (total < def.BuildTime) return; + + // Construction complete. Spawn the real TowerInstance, despawn the visual, + // remove the job. The TowerInstance will stamp walkability=false / occupancy=true + // in its OnNetworkSpawn — but those bits are ALREADY non-walkable / occupied + // from the construction-start commit, so the stamp is a no-op-equivalent + // (idempotent writes; see TowerInstance.cs comments). + var placingSlot = OwnerToSlot(OwnerClientId); + TowerPlacementManager.Instance?.ServerSpawnCompletedTower( + def, head.Anchor, placingSlot); + + // Despawn the build-site visual. + if (jobIdToVisual.TryGetValue(head.JobId, out var visualObj)) + { + if (visualObj != null && visualObj.IsSpawned) + visualObj.Despawn(destroy: true); + jobIdToVisual.Remove(head.JobId); + } + + // Pop the head. + jobs.RemoveAt(0); + + Debug.Log($"[Builder] Job {head.JobId} complete at anchor {head.Anchor}."); + } + + // ----- Cancellation API ------------------------------------------- + + /// + /// Owner-only Rpc: cancel every job in this builder's queue. Each cancellation + /// refunds 100% of the gold paid (per the design doc) and frees the affected + /// tiles. Builder stops at its current position. + /// + [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] + public void RequestCancelAllJobsRpc() + { + if (!IsServer) return; + ServerCancelAllJobs(); + } + + /// + /// Server-side: cancel every job in the queue. Public so other server code + /// (disconnect cleanup, future "level reset" events) can invoke it directly. + /// + public void ServerCancelAllJobs() + { + // Cancel in reverse so RemoveAt indices stay valid as we go. + while (jobs.Count > 0) + { + ServerCancelJobAt(jobs.Count - 1); + } + + // Stop walking. The current position becomes the new target so the builder + // settles at the spot it cancelled at, rather than drifting toward the + // anchor of the (now-removed) head job. + targetPosition.Value = new Vector3( + transform.position.x, 0f, transform.position.z); + } + + // =================================================================== + // PAUSE / SHELVE / RESUME (D2 — paused builds are shelved off the queue) + // =================================================================== + + /// + /// Owner-only Rpc: the player issued a "move to here" command (right-click + /// on empty buildable plane). Server applies the new move target and: + /// + /// If head is Constructing → pause + shelve the head. The visual + /// remains in the world as a shelved standalone object; the queue + /// loses this entry. Tail jobs are refunded. + /// If head is Queued → refund the entire queue (no progress to preserve). + /// Empty queue → just a move command. No queue effects. + /// + /// + [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] + public void RequestMoveAndPauseRpc(Vector3 worldPos) + { + if (!IsServer) return; + + // Step 1: queue effects (if any). + if (jobs.Count > 0) + { + var head = jobs[0]; + if (head.Stage == BuildStage.Constructing) + { + // Pause + shelve the head, then refund tail jobs. + ServerPauseAndShelveHead(); + ServerRefundAllRemainingJobs(); + } + else if (head.Stage == BuildStage.Queued) + { + // Head never reached Constructing — refund everything. + ServerCancelAllJobs(); + } + // Note: Paused-in-queue is no longer a state we can be in (paused + // jobs are immediately shelved and removed from jobs[]). If we + // somehow get here, treat it like Constructing for safety. + } + + // Step 2: apply the move target. + ServerSetMoveTarget(worldPos); + } + + /// + /// Owner-only Rpc: the player right-clicked a shelved build site to resume it. + /// Server pre-empts the current queue (shelves any active head, refunds all + /// remaining jobs), then unshelves the clicked visual into a new BuildJob at + /// index 0. Builder will walk back into range and resume construction with + /// preserved accumulated time. + /// + [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] + public void RequestResumeShelvedRpc(NetworkObjectReference visualRef) + { + if (!IsServer) return; + + // Resolve the visual NetworkObject from the reference. + if (!visualRef.TryGet(out NetworkObject visualNetObj) || visualNetObj == null) + { + Debug.LogWarning("[Builder] RequestResumeShelvedRpc: visual not found."); + return; + } + var visual = visualNetObj.GetComponent(); + if (visual == null || !visual.IsShelved) + { + Debug.LogWarning("[Builder] RequestResumeShelvedRpc: visual is not shelved."); + return; + } + if (visualNetObj.OwnerClientId != OwnerClientId) + { + Debug.LogWarning($"[Builder] RequestResumeShelvedRpc: visual owner " + + $"{visualNetObj.OwnerClientId} != builder owner {OwnerClientId}."); + return; + } + + // Step 1: pre-empt the current queue. + // - If head is Constructing: pause+shelve it (it becomes a new shelved tower). + // - Otherwise (Queued head, or empty): refund everything. + // The clicked visual is NOT in the queue (it's shelved), so this loop + // doesn't touch it. + if (jobs.Count > 0) + { + var head = jobs[0]; + if (head.Stage == BuildStage.Constructing) + { + ServerPauseAndShelveHead(); + ServerRefundAllRemainingJobs(); + } + else + { + ServerCancelAllJobs(); + } + } + + // Step 2: unshelve the clicked visual. Reconstruct a BuildJob from its + // NetworkVariables, push it as the new head of the queue, and re-register + // it in jobIdToVisual so the queue driver finds it. + ulong jobId = nextJobId++; + var job = BuildJob.CreateQueued( + jobId, + visual.Anchor, + visual.TowerTypeId, + visual.GoldSpent); + // Carry the accumulated time forward — the resume keeps prior progress. + job.AccumulatedConstructionTime = visual.AccumulatedConstructionTime; + jobs.Add(job); + + jobIdToVisual[jobId] = visualNetObj; + visual.ServerMarkUnshelved(); + + // Note: The visual's currentStage is still Paused; the queue driver's + // DriveHead_Queued will call ServerBeginConstructing once the builder + // arrives, which transitions it to Constructing. + + Debug.Log($"[Builder] Resumed shelved tower at {visual.Anchor} as job " + + $"{jobId} (accumulated {visual.AccumulatedConstructionTime:F2}s)."); + } + + // ----- Pause/shelve/refund helpers -------------------------------- + + // Pauses + shelves the head job: Constructing → Paused, then removes from + // jobs[] and jobIdToVisual. The visual stays in the world as a standalone + // shelved object. Player can later right-click it to resume. + private void ServerPauseAndShelveHead() + { + if (jobs.Count == 0) return; + var head = jobs[0]; + if (head.Stage != BuildStage.Constructing) return; + + // Compute current run's elapsed and add to accumulated. + float currentRunElapsed = head.ConstructionStartServerTime > 0f + ? (float)NetworkManager.Singleton.ServerTime.Time - head.ConstructionStartServerTime + : 0f; + float totalAccumulated = head.AccumulatedConstructionTime + currentRunElapsed; + + // Tell the visual to enter Paused state, write the accumulated time, + // and mark itself as shelved. After this, the visual carries all the + // state that used to live on the BuildJob. + if (jobIdToVisual.TryGetValue(head.JobId, out var visualObj) && visualObj != null) + { + var visual = visualObj.GetComponent(); + if (visual != null) + { + visual.ServerPauseAndPersistAccumulated(totalAccumulated); + visual.ServerMarkShelved(); + } + // Remove from our visual tracking — the visual is now self-managing. + jobIdToVisual.Remove(head.JobId); + } + + // Pop the head from the queue. The visual stays in the world. + jobs.RemoveAt(0); + + Debug.Log($"[Builder] Job {head.JobId} paused and shelved at " + + $"{totalAccumulated:F2}s of construction."); + } + + // Refunds and removes EVERY job in the queue. Used after pause+shelve to + // clear out tail jobs (the shelved head is no longer in jobs[] at this point). + // Same as ServerCancelAllJobs but doesn't reset targetPosition (caller does that + // by setting an explicit move target afterward). + private void ServerRefundAllRemainingJobs() + { + for (int i = jobs.Count - 1; i >= 0; i--) + { + ServerCancelJobAt(i); + } + } + + // ----- Public state accessor (read by the input controller) ------- + + // Note: PausedHeadJobId was removed when paused jobs moved out of the queue + // entirely. The input controller now uses the BuildSiteVisual's own state + // (visual.IsShelved + ownership) to determine resumeability and sends the + // visual's NetworkObjectReference to RequestResumeShelvedRpc. + + // Cancels the head job specifically. Used for the "definition lost" defensive + // cases where the TowerDefinition can't be resolved. Restores walkability + // appropriately based on the current stage of the job being cancelled. + private void ServerCancelHead(bool refund) + { + if (jobs.Count == 0) return; + + var head = jobs[0]; + var def = TowerPlacementManager.GetDefinition(head.TowerTypeId); + if (def != null) + { + // Constructing or Paused → tile is non-walkable, restore it. + // Queued → tile was never made non-walkable, leave alone. + bool wasBlocking = head.Stage == BuildStage.Constructing + || head.Stage == BuildStage.Paused; + FreeFootprintTiles(head.Anchor, def.FootprintSize, + alsoMakeWalkable: wasBlocking); + } + if (refund) RefundJobGold(head); + DespawnVisualAndForget(head.JobId); + jobs.RemoveAt(0); + } + + // Cancels the job at index i. Used for cancel-all and any future targeted cancel. + private void ServerCancelJobAt(int index) + { + if (index < 0 || index >= jobs.Count) return; + + var job = jobs[index]; + var def = TowerPlacementManager.GetDefinition(job.TowerTypeId); + if (def != null) + { + // Walkability state by stage: + // - Queued: walkable (queue stage doesn't stamp walkability) → don't touch. + // - Constructing: non-walkable (stamped at construction-start) → restore. + // - Paused: non-walkable (stamped at original construction-start, never + // reverted because the half-built tower keeps blocking) → restore. + bool wasBlocking = job.Stage == BuildStage.Constructing + || job.Stage == BuildStage.Paused; + FreeFootprintTiles(job.Anchor, def.FootprintSize, + alsoMakeWalkable: wasBlocking); + } + + RefundJobGold(job); + DespawnVisualAndForget(job.JobId); + jobs.RemoveAt(index); + } + + private void FreeFootprintTiles(Vector2Int anchor, Vector2Int footprint, + bool alsoMakeWalkable) + { + var loader = LevelLoader.Instance; + if (loader == null || !loader.IsLoaded) return; + + foreach (var t in GridCoordinates.GetFootprintTiles(anchor, footprint)) + { + loader.SetOccupied(t, false); + if (alsoMakeWalkable) loader.SetWalkable(t, true); + } + } + + private void RefundJobGold(BuildJob job) + { + var goldManager = PlayerGoldManager.GetForClient(OwnerClientId); + if (goldManager == null) return; + goldManager.AwardGold(job.GoldSpent); + } + + private void DespawnVisualAndForget(ulong jobId) + { + if (!jobIdToVisual.TryGetValue(jobId, out var visualObj)) return; + if (visualObj != null && visualObj.IsSpawned) + visualObj.Despawn(destroy: true); + jobIdToVisual.Remove(jobId); + } + + // ----- Helpers ---------------------------------------------------- + + private static PlayerSlot OwnerToSlot(ulong clientId) + { + // STUB — replaced when MatchState lands. Same mapping as elsewhere. + byte slotByte = (byte)(clientId + 1); + if (slotByte < 1 || slotByte > 9) return PlayerSlot.None; + return (PlayerSlot)slotByte; + } + + private static Vector2Int ResolveFootprint(int towerTypeId) + { + var def = TowerPlacementManager.GetDefinition(towerTypeId); + return def != null ? def.FootprintSize : new Vector2Int(2, 2); + } } } \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs index e26313a..d84bf76 100644 --- a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs +++ b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs @@ -7,22 +7,48 @@ using TD.Core; namespace TD.Gameplay { /// - /// Owner-only client-side controller for builder input. Handles right-click-to-move, - /// deferring to placement mode (right-click cancels placement instead). + /// Owner-only client-side controller for builder input. Handles selection, + /// right-click-to-move (with side-effects: pause active construction and + /// refund tail jobs), right-click-on-paused-build-site (resume), and + /// Escape-to-deselect. /// /// /// Owner-only. This component lives on the same GameObject as - /// but only its owning client processes input. Non-owner clients - /// have this component but its Update is a no-op. The owner sends move requests via - /// . + /// but only its owning client processes input. Non-owner + /// clients have this component but its Update is a no-op. /// - /// Right-click priority. If TowerPlacementController.IsPlacing is - /// true, right-click cancels placement (handled by TowerPlacementController - /// itself). When NOT placing, right-click moves the builder. + /// Selection model. Left-click raycast against the + /// (the builder's selection trigger collider + /// sits on this layer). Hitting the local builder selects it; hitting empty + /// space or another collider clears the selection. Selection lives in the + /// singleton . /// - /// Raycast target. The cursor is raycast against the BuildablePlane layer - /// (same as placement). The hit point's XZ is sent as the target; Y is recomputed by - /// the server via terrain raycast. + /// Right-click — selection required. Without selection, right-click + /// is a no-op. With selection: + /// + /// Right-click on the local builder's PAUSED build site (raycast hits + /// ): resume that job. Builder walks back + /// into range; preserved progress picks up where it left off. + /// Right-click anywhere else on the buildable plane: move-and-pause. + /// Builder walks to the clicked location. Side effects: + /// + /// If currently Constructing → pause the active build, refund + /// tail jobs. + /// If head is Queued → refund the entire queue. + /// If head is Paused → no queue change (tail was already + /// refunded at pause time). + /// + /// + /// + /// + /// Escape. Clears selection. No queue cancellation in D2 — that's + /// deferred to the HUD path. + /// + /// Placement mode interaction. If the local TowerPlacementController + /// reports IsPlacing == true, all input handled here yields to it (placement-mode + /// right-click cancels placement, placement-mode left-click submits). Selection + /// raycasts are also suppressed so a placement click doesn't accidentally + /// deselect the builder. /// public class BuilderInputController : NetworkBehaviour { @@ -32,6 +58,18 @@ namespace TD.Gameplay "against this layer to determine the move target.")] [SerializeField] private LayerMask buildablePlaneLayerMask; + [Tooltip("Physics layer mask for the builder selection trigger collider. The " + + "builder prefab's child selection collider sits on this layer. The mask " + + "must NOT overlap with BuildablePlane or TerrainGeometry; selection is " + + "a separate concern.")] + [SerializeField] private LayerMask selectionLayerMask; + + [Tooltip("Physics layer mask for build-site visual click targets. The " + + "BuildSiteVisual prefab carries a small trigger collider on this layer " + + "so right-click can identify a paused build site for resume. Must NOT " + + "overlap with BuildablePlane, Selection, or TerrainGeometry.")] + [SerializeField] private LayerMask buildSiteLayerMask; + [Tooltip("Maximum raycast distance for cursor → world conversion.")] [SerializeField] private float raycastMaxDistance = 500f; @@ -71,21 +109,113 @@ namespace TD.Gameplay if (!IsOwner) return; var mouse = Mouse.current; + var keyboard = Keyboard.current; if (mouse == null) return; + bool isPlacing = IsLocalPlayerPlacing(); + + // Left-click: selection. Suppressed during placement mode (left-click is + // the placement-submit gesture there). + if (!isPlacing && mouse.leftButton.wasPressedThisFrame) + { + HandleLeftClickSelection(mouse.position.ReadValue()); + } + + // Escape: clear selection. Allowed during placement mode too — Escape never + // means anything else here, and clearing selection during placement is fine. + if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame) + { + SelectionState.Instance?.Clear(); + } + + // Right-click. Suppressed entirely during placement mode (TowerPlacementController + // handles right-click as cancel-placement there). + if (isPlacing) return; if (!mouse.rightButton.wasPressedThisFrame) return; - // Defer to placement mode: if the player is placing a tower, right-click cancels - // placement rather than moving the builder. TowerPlacementController handles - // the cancel itself; we just don't process the click here. - if (IsLocalPlayerPlacing()) return; + HandleRightClick(mouse.position.ReadValue()); + } - // Cursor → world. - if (!TryGetBuildablePlaneHit(mouse.position.ReadValue(), out Vector3 worldPoint)) + // ----- Selection (left-click) ------------------------------------- + + private void HandleLeftClickSelection(Vector2 screenPos) + { + var selection = SelectionState.Instance; + if (selection == null) return; + + var cam = Camera.main; + if (cam == null) return; + + Ray ray = cam.ScreenPointToRay(new Vector3(screenPos.x, screenPos.y, 0f)); + if (Physics.Raycast(ray, out RaycastHit hit, raycastMaxDistance, selectionLayerMask)) + { + // Walk up the hierarchy to find a Builder component (the selection + // collider may sit on a child of the Builder's root). + var hitBuilder = hit.collider.GetComponentInParent(); + + // Only allow selecting OUR builder. A click on someone else's builder + // collider clears our selection rather than selecting theirs. + if (hitBuilder != null && hitBuilder == builder) + { + selection.Select(builder); + return; + } + } + + // Clicked empty space or a non-selectable thing — clear selection. + selection.Clear(); + } + + // ----- Right-click dispatch --------------------------------------- + + private void HandleRightClick(Vector2 screenPos) + { + // Right-click is gated by selection: nothing happens unless the local + // builder is the selected one. + var selection = SelectionState.Instance; + if (selection == null || !selection.IsSelected(builder)) return; + + var cam = Camera.main; + if (cam == null) return; + Ray ray = cam.ScreenPointToRay(new Vector3(screenPos.x, screenPos.y, 0f)); + + // Test 1: did we hit a build-site visual? If so, and it belongs to OUR + // builder, AND it's currently shelved, this is a resume command. + // The BuildSite raycast is checked first because the build-site visual + // sits ABOVE the buildable plane visually; a click on the cube should + // be interpreted as a build-site click, not a "move to under the cube" click. + if (Physics.Raycast(ray, out RaycastHit buildSiteHit, raycastMaxDistance, + buildSiteLayerMask)) + { + var visual = buildSiteHit.collider.GetComponentInParent(); + if (visual != null + && visual.IsShelved + && visual.OwnerClientId == NetworkManager.Singleton.LocalClientId) + { + // Resume command. Send the visual's NetworkObjectReference so the + // server can identify which shelved tower the player clicked. + // The server handles pre-empting any in-progress queue work. + var visualNetObj = visual.GetComponent(); + if (visualNetObj != null) + { + builder.RequestResumeShelvedRpc(new NetworkObjectReference(visualNetObj)); + return; + } + } + // Hit a build-site visual that wasn't shelved-and-ours. Fall through + // to the move case so the click still does something useful (move + // the builder to that approximate world position). + } + + // Test 2: hit the buildable plane? Standard move-and-pause command. + if (Physics.Raycast(ray, out RaycastHit planeHit, raycastMaxDistance, + buildablePlaneLayerMask)) + { + builder.RequestMoveAndPauseRpc(planeHit.point); return; + } - // Submit to server. - builder.RequestMoveRpc(worldPoint); + // Hit nothing relevant — no-op. } // ----- Helpers ---------------------------------------------------- @@ -101,21 +231,5 @@ namespace TD.Gameplay } return cachedPlacementController.IsPlacing; } - - private bool TryGetBuildablePlaneHit(Vector2 screenPos, out Vector3 hitPoint) - { - hitPoint = Vector3.zero; - - var cam = Camera.main; - if (cam == null) return false; - - Ray ray = cam.ScreenPointToRay(new Vector3(screenPos.x, screenPos.y, 0f)); - if (Physics.Raycast(ray, out RaycastHit hit, raycastMaxDistance, buildablePlaneLayerMask)) - { - hitPoint = hit.point; - return true; - } - return false; - } } } \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs b/Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs new file mode 100644 index 0000000..ea5fad9 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs @@ -0,0 +1,126 @@ +// Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs +using UnityEngine; + +namespace TD.Gameplay +{ + /// + /// Local-only visual indicator for builder selection. Sits as a child of the + /// builder prefab. Subscribes to + /// and toggles the visibility of its own renderers (and any descendant + /// renderers) when the parent builder becomes selected/unselected. + /// + /// + /// Pure local visualization. No NetworkBehaviour. Selection is a + /// UI concept — every client renders selection state for its own player only. + /// This component has no networked state. + /// + /// Why a separate component. Keeps Builder focused on gameplay + /// state. The visual prefab structure can change independently + /// (disc → ring → decal projector → animated effect) without touching gameplay + /// code. The contract is just "renderers visible when selected." + /// + /// Why renderer-toggle, not GameObject.SetActive. Disabling our + /// own GameObject would prevent OnEnable/Update from running, which would + /// break the SelectionState subscription lifecycle (we'd never receive the + /// "you're selected again" event). Toggling the renderers' enabled state + /// achieves the same visual effect without breaking the event flow. + /// + /// Prefab setup. Attach this component to a child GameObject of + /// the Builder prefab. The child carries (or has descendants carrying) a + /// flattened cylinder mesh sitting just above the ground plane, with an + /// unlit transparent green material. The component handles initial visibility + /// — leave the renderer enabled in the prefab; we'll turn it off in Awake + /// until selection fires. + /// + public class SelectionRingVisual : MonoBehaviour + { + // Cached parent builder, resolved in Awake. + private Builder parentBuilder; + + // Cached renderers (this object plus all descendants). Captured once in + // Awake to avoid per-event GetComponentsInChildren allocations. + private Renderer[] cachedRenderers; + + // Tracks subscription state so OnDisable / OnDestroy unsubscribe correctly, + // and so Update can retry subscription if SelectionState wasn't ready at OnEnable. + private bool subscribed; + + private void Awake() + { + parentBuilder = GetComponentInParent(); + if (parentBuilder == null) + { + Debug.LogError("[SelectionRingVisual] No Builder component found on " + + "self or any parent. Disabling."); + enabled = false; + return; + } + + cachedRenderers = GetComponentsInChildren(includeInactive: true); + + // Start hidden. If selection fires later (including the auto-select + // in Builder.OnNetworkSpawn), HandleSelectionChanged will turn the + // renderers back on. + SetRenderersVisible(false); + } + + private void OnEnable() + { + TrySubscribe(); + } + + private void OnDisable() + { + Unsubscribe(); + } + + private void OnDestroy() + { + Unsubscribe(); + } + + // Per-frame fallback: if SelectionState wasn't available at OnEnable + // (scene load ordering), keep trying. Cost is one null-check per frame + // until subscription succeeds, then nothing. + private void Update() + { + if (!subscribed) TrySubscribe(); + } + + private void TrySubscribe() + { + if (subscribed) return; + var sel = SelectionState.Instance; + if (sel == null) return; + + sel.OnSelectionChanged += HandleSelectionChanged; + subscribed = true; + + // Sync to current state — selection may have happened before we subscribed + // (e.g., Builder.OnNetworkSpawn auto-selecting before this Awake runs). + HandleSelectionChanged(sel.SelectedBuilder); + } + + private void Unsubscribe() + { + if (!subscribed) return; + var sel = SelectionState.Instance; + if (sel != null) sel.OnSelectionChanged -= HandleSelectionChanged; + subscribed = false; + } + + private void HandleSelectionChanged(Builder newSelection) + { + SetRenderersVisible(newSelection == parentBuilder); + } + + private void SetRenderersVisible(bool visible) + { + if (cachedRenderers == null) return; + foreach (var rend in cachedRenderers) + { + if (rend != null) rend.enabled = visible; + } + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs.meta b/Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs.meta new file mode 100644 index 0000000..8295845 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 67895f626233fdc499dffbbfcc225530 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/SelectionState.cs b/Assets/_Project/Scripts/Gameplay/SelectionState.cs new file mode 100644 index 0000000..c7f8490 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/SelectionState.cs @@ -0,0 +1,90 @@ +// Assets/_Project/Scripts/Gameplay/SelectionState.cs +using UnityEngine; + +namespace TD.Gameplay +{ + /// + /// Minimal scene-local selection state. Holds a reference to whichever + /// the local player has selected, fires an event when + /// the selection changes, and exposes a query for "is this builder selected + /// right now?". + /// + /// + /// Scope. D2 only needs this for: "Escape with builder selected + /// cancels its queue", and "right-click with builder selected and queue + /// established cancels the queue (the right-click is consumed by selection + /// instead of issuing a move)". A full selection system that supports world + /// highlighting, multi-select, and HUD context panels is deferred to the HUD + /// path. + /// + /// Local-only. Selection is a UI concept, not a gameplay one. + /// Other clients have no business knowing whether you've selected your own + /// builder. The component is a plain MonoBehaviour and lives in the scene + /// alongside other client-side controllers. + /// + /// Singleton. One per scene, accessed via . + /// The selection consumer (BuilderInputController) and the selection driver + /// (mouse-click raycast) both go through this single source of truth. + /// + public class SelectionState : MonoBehaviour + { + // ----- Singleton -------------------------------------------------- + + /// + /// The active SelectionState. Null before the scene loads. Always null-check. + /// + public static SelectionState Instance { get; private set; } + + private void Awake() + { + if (Instance != null && Instance != this) + { + Debug.LogWarning("[SelectionState] Multiple instances detected. " + + "Only one SelectionState should exist per scene."); + } + Instance = this; + } + + private void OnDestroy() + { + if (Instance == this) Instance = null; + } + + // ----- Selection state -------------------------------------------- + + private Builder selectedBuilder; + + /// The currently selected builder, or null if nothing is selected. + public Builder SelectedBuilder => selectedBuilder; + + /// True if any builder is currently selected. + public bool HasSelection => selectedBuilder != null; + + /// True if is the currently selected builder. + public bool IsSelected(Builder b) => b != null && selectedBuilder == b; + + // ----- Events ----------------------------------------------------- + + /// + /// Fired when the selection changes. Argument is the new selection (may be null). + /// Subscribe to drive selection-aware UI: highlights, context panels, hotkey hints. + /// + public event System.Action OnSelectionChanged; + + // ----- Mutators --------------------------------------------------- + + /// + /// Sets the selected builder. Pass null to clear. + /// Fires only if the selection actually changes. + /// + public void Select(Builder builder) + { + if (selectedBuilder == builder) return; + selectedBuilder = builder; + OnSelectionChanged?.Invoke(selectedBuilder); + } + + /// Clears the selection. Equivalent to Select(null). + public void Clear() => Select(null); + } +} diff --git a/Assets/_Project/Scripts/Gameplay/SelectionState.cs.meta b/Assets/_Project/Scripts/Gameplay/SelectionState.cs.meta new file mode 100644 index 0000000..8505890 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/SelectionState.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dc307e7e94967894584e8e6050fc38cf \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/TowerPlacementController.cs b/Assets/_Project/Scripts/Gameplay/TowerPlacementController.cs index 5797da7..73d81b4 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerPlacementController.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerPlacementController.cs @@ -12,33 +12,29 @@ namespace TD.Gameplay /// requests to via RPC. /// /// - /// Plain MonoBehaviour. Placement visuals (ghost, cursor color) are + /// Plain MonoBehaviour. Placement visuals (cursor ghost, color) are /// purely cosmetic and local. This component does not need to be a NetworkBehaviour. /// All server-authoritative logic lives in . /// - /// Ghost validity check. The ghost checks ownership, placement state, - /// and tile occupancy only. It does NOT run a local path check. If the server rejects - /// because the tower would block the path, the ghost disappears and a rejection - /// message is shown. This avoids the complexity of maintaining a client-side BFS - /// that may be slightly stale. + /// Ghost validity check. The cursor ghost checks ownership, placement + /// state, and tile occupancy only. It does NOT run a local path check. If the server + /// rejects because the tower would block the path, the cursor ghost disappears and a + /// rejection message is shown. /// - /// Ghost colors. - /// - /// White — all local checks pass. - /// Red — any local check fails (wrong zone, not buildable, already occupied). - /// - /// Green "pending construction" ghost is a separate system implemented in Path D. + /// Two ghosts, two systems. The cursor ghost (handled here) + /// follows the mouse and turns white/red. The build-site visual (in D2, + /// handled by Builder + BuildSiteVisual) is the green queued ghost or staged + /// construction visual that appears at a confirmed placement site. They are + /// distinct prefabs and lifecycles. /// - /// Placement activation. The controller is idle until - /// is called (e.g., from a HUD tower button). The player - /// right-clicks or the placement is confirmed/rejected to return to idle. + /// Chained queueing. Holding Shift while left-clicking submits the + /// placement and stays in placement mode for another submission. Releasing Shift + /// before the click submits and exits placement (single-shot). Right-click always + /// cancels. /// - /// Input System. Uses the New Input System package. Mouse position and - /// button state are read from Mouse.current each frame. - /// - /// Player slot. The local player slot is currently a stub - /// (client 0 = Player1, etc.) matching TowerPlacementManager.ClientIdToPlayerSlot. - /// This will be replaced when MatchState carries the authoritative slot assignment. + /// Input System. Uses the New Input System package. Mouse and modifier + /// state are read directly from Mouse.current and Keyboard.current + /// each frame. No InputAction asset needed for these placement-specific bindings. /// public class TowerPlacementController : MonoBehaviour { @@ -62,7 +58,7 @@ namespace TD.Gameplay private TowerDefinition activeDef; private int activeTowerTypeId; - // The ghost GameObject: the tower prefab instantiated with transparent materials. + // The cursor ghost GameObject: the tower prefab instantiated with transparent materials. // Null when placement mode is inactive. private GameObject ghostGO; @@ -131,7 +127,7 @@ namespace TD.Gameplay // Compute the footprint anchor from the hit point. Vector2Int anchor = ComputeAnchor(hitPoint, activeDef.FootprintSize); - // Position the ghost at the footprint center. + // Position the cursor ghost at the footprint center. Vector3 ghostPos = GridCoordinates.GetFootprintCenterWorld(anchor, activeDef.FootprintSize); ghostPos.y = 0.5f; // lift off the plane so the cube base sits flush ghostGO.transform.position = ghostPos; @@ -151,7 +147,8 @@ namespace TD.Gameplay // Left-click to attempt placement. if (mouse.leftButton.wasPressedThisFrame) { - TrySubmitPlacement(anchor); + bool chained = IsShiftHeld(); + TrySubmitPlacement(anchor, chained); } } @@ -161,9 +158,6 @@ namespace TD.Gameplay /// Activates placement mode for the given tower type. The ghost appears /// immediately under the cursor. Call this from HUD tower buttons. /// - /// The TowerDefinition to place. - /// The type ID registered in - /// . public void BeginPlacement(TowerDefinition def, int towerTypeId) { if (def == null) @@ -199,6 +193,15 @@ namespace TD.Gameplay /// public bool IsPlacing => activeDef != null; + // ----- Modifier helpers ------------------------------------------- + + private static bool IsShiftHeld() + { + var kb = Keyboard.current; + if (kb == null) return false; + return kb.leftShiftKey.isPressed || kb.rightShiftKey.isPressed; + } + // ----- Ghost management ------------------------------------------- private void CreateGhost(TowerDefinition def) @@ -284,10 +287,6 @@ namespace TD.Gameplay foreach (var rend in ghostRenderers) { rend.sharedMaterial = ghostMat; - // Property block is set empty here — color comes from the material itself. - // If the ghost materials use _Color, override it via the block instead: - // ghostPropertyBlock.SetColor(ColorPropertyId, valid ? Color.white : Color.red); - // rend.SetPropertyBlock(ghostPropertyBlock); } } @@ -315,11 +314,6 @@ namespace TD.Gameplay /// Converts a world hit point to the footprint anchor tile (SW corner) such /// that the footprint center is as close as possible to the hit point. /// - /// - /// For a 2×2 footprint: anchor = (Round(hitX - 0.5), Round(hitZ - 0.5)) - /// For a 1×1 footprint: anchor = (Round(hitX), Round(hitZ)) - /// For a 3×3 footprint: anchor = (Round(hitX - 1.0), Round(hitZ - 1.0)) - /// private static Vector2Int ComputeAnchor(Vector3 hitPoint, Vector2Int footprintSize) { float t = GridCoordinates.TILE_SIZE; @@ -357,7 +351,7 @@ namespace TD.Gameplay // ----- Placement submission --------------------------------------- - private void TrySubmitPlacement(Vector2Int anchor) + private void TrySubmitPlacement(Vector2Int anchor, bool chained) { var manager = TowerPlacementManager.Instance; if (manager == null) @@ -369,13 +363,19 @@ namespace TD.Gameplay // Send the RPC regardless of local validity state — the server is // authoritative. The local check drives the ghost color only. - // The server will reject and send back a reason if invalid. manager.RequestPlaceTowerRpc(anchor.x, anchor.y, activeTowerTypeId); - // Exit placement mode immediately after submitting. If the server - // rejects, the rejection message fires via HandlePlacementRejected. - // If it accepts, the TowerInstance NetworkObject spawns and the - // placed tower appears — no ghost lingering is needed. + if (chained) + { + // Stay in placement mode. The server will stamp occupancy on success, + // so when EvaluateLocalValidity runs next frame it will correctly + // report the just-clicked tile as occupied (cursor turns red there). + // Force a re-evaluation by invalidating the cached anchor. + lastAnchorValid = false; + return; + } + + // Single-shot: exit placement mode immediately. CancelPlacement(); } @@ -395,7 +395,6 @@ namespace TD.Gameplay Debug.Log($"[TowerPlacementController] Placement rejected: {reason} → \"{message}\""); // Fire the event so HUD components can display the message on screen. - // The HUD that subscribes and renders this is implemented in a later path. OnRejectionMessageReady?.Invoke(message); } diff --git a/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs b/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs index f65af84..816a18b 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs @@ -10,39 +10,48 @@ namespace TD.Gameplay { /// /// Server-authoritative manager for tower placement requests. Receives placement - /// requests from clients via RPC, validates them in order, and either spawns the - /// tower or rejects the request with a reason code. + /// requests from clients via RPC, validates them in order, and either enqueues + /// the placement on the player's or rejects the request + /// with a reason code. /// /// - /// Queue-based processing. Incoming RPCs enqueue a + /// Queue-based RPC processing. Incoming RPCs enqueue a /// rather than validating inline. Each server - /// Update drains up to requests. At 60 fps this + /// Update drains up to requests. At 60 fps this /// gives ~180 validations/second, comfortably above the worst-case 90/second /// (9 players × 10 placements/second). Queuing keeps the server frame budget /// predictable regardless of burst traffic. /// /// Server-only logic. All validation and mutation runs on the server. - /// Clients learn about accepted placements when the - /// NetworkObject spawns (NGO replicates it automatically). Clients learn about - /// rejections via . + /// Clients learn about accepted placements when the build-site visual NetworkObject + /// spawns (queued ghost) and later when the spawns at + /// construction-complete. Clients learn about rejections via + /// . /// /// Validation order: /// + /// Tower type — must resolve to a valid TowerDefinition. /// Ownership — every footprint tile must be owned by the requesting player. - /// Placement state — every footprint tile must be Buildable and unoccupied. + /// Placement state + occupancy — every tile must be Buildable and not already occupied. + /// Occupancy includes both completed towers AND queued/constructing jobs in any + /// builder's queue. + /// Builder existence — the placing player must have a spawned builder. Build range + /// is NOT checked at queue-time — the queue is precisely the mechanism for deferring + /// "go there and build it." Range is enforced at construction-start in Builder. /// Gold — the placing player must have enough gold. + /// Queue capacity — the placing player's builder queue must have room. /// Path — a BFS confirms every spawner in the placing player's zone still reaches - /// an exit after the footprint is stamped as non-walkable. + /// an exit after the footprint is stamped as non-walkable. Note that QUEUED towers + /// do not participate in this BFS — only completed/constructing towers — because + /// queued ghosts are intent, not structures. /// /// - /// Path-check BFS. The server temporarily stamps the footprint, - /// runs BFS per spawner, then un-stamps if the check fails. This is O(tiles in zone) - /// per spawner per request — acceptable for low-frequency gameplay actions and the - /// queue-rate-limited processing model. - /// - /// Builder range check. Deliberately omitted in Path B. The builder - /// system does not exist yet. When Path D is implemented, add a range check between - /// steps 2 and 3 above, gated on the requesting player's Builder position. + /// D2 build-queue flow. On success, ProcessRequest does NOT spawn the tower. + /// Instead it deducts gold, stamps occupancy=true (walkable stays true), and appends a + /// BuildJob to the player's builder. The Builder owns walking to the site, transitioning + /// to Constructing (which re-validates the path and stamps walkable=false), running the + /// staged construction animation, and finally calling + /// to spawn the real TowerInstance. /// public class TowerPlacementManager : NetworkBehaviour { @@ -123,12 +132,10 @@ namespace TD.Gameplay /// /// Client entry point. Call this on the local client to request placing a tower. - /// The server will validate and either spawn the tower (visible to all clients) - /// or call back with . + /// The server will validate and either enqueue the placement (which spawns a + /// build-site visual visible to all clients) or call back with + /// . /// - /// X component of the footprint anchor tile (world-tile coords). - /// Y component of the footprint anchor tile (world-tile coords). - /// Index into the server's towerDefinitions array. [Rpc(SendTo.Server)] public void RequestPlaceTowerRpc(int anchorX, int anchorY, int towerTypeId, RpcParams rpcParams = default) @@ -210,7 +217,10 @@ namespace TD.Gameplay // ------------------------------------------------------------------ // Check 2: Placement state + occupancy - // Every footprint tile must be Buildable and not already occupied. + // Every footprint tile must be Buildable and not already occupied. Note + // that the occupancy grid was stamped at queue-time for ALL pending jobs + // in any builder's queue, so a single IsOccupied check correctly rejects + // overlap with a queued ghost (in this builder's queue OR another's). // ------------------------------------------------------------------ foreach (var tile in footprint) { @@ -227,10 +237,13 @@ namespace TD.Gameplay } // ------------------------------------------------------------------ - // Check 3: Build range - // Tower must be within the placing player's builder's build range. - // Cheap to check; runs before gold and path so we don't burn cycles - // on out-of-range placements. + // Check 3: Builder must exist + // The placing player needs a spawned builder to take ownership of the + // resulting BuildJob, but build range is NOT checked here. The whole + // point of a queue is to defer "go there and build it" — if the builder + // is out of range at queue-time, it will walk to the site when the job + // reaches the head of the queue. Range is enforced at construction-start + // (in Builder.DriveHead_Queued) which is the moment range actually matters. // ------------------------------------------------------------------ var builder = Builder.GetForClient(req.SenderClientId); if (builder == null) @@ -239,11 +252,6 @@ namespace TD.Gameplay Reject(req, PlacementRejectionReason.ServerError); return; } - if (!builder.IsTileWithinBuildRange(req.Anchor, def.FootprintSize)) - { - Reject(req, PlacementRejectionReason.OutOfRange); - return; - } // ------------------------------------------------------------------ // Check 4: Gold @@ -258,35 +266,135 @@ namespace TD.Gameplay } // ------------------------------------------------------------------ - // Check 5: Path validity - // Temporarily stamp the footprint, run BFS per spawner in the placing - // player's zone, then un-stamp if any spawner loses its exit route. + // Check 5: Queue capacity + // The placing player's builder must have room for one more job. + // Cheap check; runs before the path BFS. // ------------------------------------------------------------------ - StampFootprint(loader, footprint, walkable: false, occupied: true); + if (builder.Jobs.Count >= builder.MaxQueueDepth) + { + Reject(req, PlacementRejectionReason.JobLimitReached); + return; + } + + // ------------------------------------------------------------------ + // Check 6: Path validity (queue-time) + // Temporarily stamp the footprint non-walkable, run BFS per spawner + // in the placing player's zone, then un-stamp if any spawner loses + // its exit route. Importantly we do NOT stamp other queued (but not + // yet constructing) jobs as non-walkable — queued ghosts represent + // intent only and don't block enemies. The check is "could THIS + // tower be built right now if it were instantly complete?" — a + // coarse test that catches obvious blockers at queue-time. The + // construction-start re-check (in Builder.DriveHead_Queued) catches + // cases where the maze changed since queue-time. + // ------------------------------------------------------------------ + StampWalkable(loader, footprint, walkable: false); bool pathValid = CheckPathValidity(loader, placingSlot); + // Restore walkability — the queue stage leaves tiles walkable. + // Occupancy is stamped below as part of the commit. + StampWalkable(loader, footprint, walkable: true); + if (!pathValid) { - // Un-stamp — the placement is rejected, grid stays as it was. - StampFootprint(loader, footprint, walkable: true, occupied: false); Reject(req, PlacementRejectionReason.BlocksPath); return; } // ------------------------------------------------------------------ - // All checks passed — commit the placement. - // The footprint stamp is already applied (walkable=false, occupied=true). - // Deduct gold and spawn the tower NetworkObject. + // All checks passed — commit the queue entry. + // - Mark the footprint occupied (but keep it walkable; queued ghosts + // don't block enemies). + // - Deduct gold. + // - Append the BuildJob to the builder's queue. The Builder spawns + // the green-ghost build-site visual itself. // ------------------------------------------------------------------ + StampOccupied(loader, footprint, occupied: true); goldManager.DeductGold(def.GoldCost); - SpawnTower(def, req.Anchor, placingSlot); + if (!builder.ServerEnqueueJob(req.Anchor, req.TowerTypeId, def.GoldCost, + out ulong jobId)) + { + // Should not happen — we checked capacity above. Defensive: roll back. + StampOccupied(loader, footprint, occupied: false); + goldManager.AwardGold(def.GoldCost); + Reject(req, PlacementRejectionReason.JobLimitReached); + return; + } - Debug.Log($"[TowerPlacementManager] Placed '{def.DisplayName}' for " + + Debug.Log($"[TowerPlacementManager] Queued '{def.DisplayName}' (job {jobId}) for " + $"client {req.SenderClientId} ({placingSlot}) at anchor {req.Anchor}."); } + // ----- Server-side commit hooks called by Builder ------------------ + + /// + /// Server-only: called by Builder the moment a queued job's builder arrives + /// at the build site. Stamps the footprint non-walkable and re-runs the path BFS. + /// On success, the footprint stays non-walkable and the caller transitions the + /// job to Constructing. On failure, walkability is restored and false is returned; + /// caller must drop and refund the job. + /// + public bool ServerCommitConstructionStart(Vector2Int anchor, Vector2Int footprintSize, + PlayerSlot placingSlot) + { + if (!IsServer) return false; + + var loader = LevelLoader.Instance; + if (loader == null || !loader.IsLoaded) return false; + + var footprint = new List(footprintSize.x * footprintSize.y); + foreach (var tile in GridCoordinates.GetFootprintTiles(anchor, footprintSize)) + footprint.Add(tile); + + StampWalkable(loader, footprint, walkable: false); + + bool ok = CheckPathValidity(loader, placingSlot); + + if (!ok) + { + // Roll back — the maze would break. Caller refunds and drops the job. + StampWalkable(loader, footprint, walkable: true); + return false; + } + + // Footprint is now occupied (still) and non-walkable. Construction proceeds. + return true; + } + + /// + /// Server-only: read-only path validity check for the given player's zone. + /// Used by Builder when resuming a paused job — the footprint is + /// already non-walkable (has been since original construction-start), so + /// stamping/un-stamping would be a no-op at best and a bug at worst (an + /// un-stamp would un-block an actively-blocking tile). This method just + /// runs the BFS against the current grid state and returns the result. + /// + public bool ServerVerifyPathStillValid(PlayerSlot placingSlot) + { + if (!IsServer) return false; + + var loader = LevelLoader.Instance; + if (loader == null || !loader.IsLoaded) return false; + + return CheckPathValidity(loader, placingSlot); + } + + /// + /// Server-only: called by Builder when a constructing job completes. + /// Spawns the real NetworkObject at the anchor. + /// The footprint is already occupied and non-walkable from the construction-start + /// commit; TowerInstance.OnNetworkSpawn's footprint stamp is idempotent + /// and harmless. + /// + public void ServerSpawnCompletedTower(TowerDefinition def, Vector2Int anchor, + PlayerSlot owner) + { + if (!IsServer) return; + SpawnTower(def, anchor, owner); + } + // ----- Path-validity BFS ------------------------------------------ /// @@ -316,14 +424,9 @@ namespace TD.Gameplay } // Build the exit tile set: union of all leak exit tiles and all goal tiles. - // This is built fresh per call because it doesn't change within a match - // (tiles never move), but the allocation cost is small and correctness - // is more important than micro-optimization here. var exitTiles = BuildExitTileSet(levelData, slot); if (exitTiles.Count == 0) { - // Zone has no exits at all — this would have been caught at bake time (P5-8). - // Treat as valid so a bake-side error doesn't cause all placements to fail. Debug.LogWarning($"[TowerPlacementManager] Zone {slot} has no exit tiles. " + $"This should have been caught at bake time (P5-8)."); return true; @@ -406,17 +509,25 @@ namespace TD.Gameplay // ----- Helpers ---------------------------------------------------- /// - /// Stamps or un-stamps all tiles in on both the - /// walkability and occupancy grids simultaneously. Always update both together. + /// Stamps walkability on every tile in . + /// Independent of occupancy because the queue-time and construction-time + /// transitions touch them on different schedules. /// - private static void StampFootprint(LevelLoader loader, List footprint, - bool walkable, bool occupied) + private static void StampWalkable(LevelLoader loader, List footprint, + bool walkable) { foreach (var tile in footprint) - { loader.SetWalkable(tile, walkable); + } + + /// + /// Stamps occupancy on every tile in . + /// + private static void StampOccupied(LevelLoader loader, List footprint, + bool occupied) + { + foreach (var tile in footprint) loader.SetOccupied(tile, occupied); - } } /// @@ -468,14 +579,21 @@ namespace TD.Gameplay def = null; // typeId 0 is reserved; valid IDs start at 1. if (typeId <= 0) return false; - // Instance check for the static helper path — callers that have a - // direct reference use the instance array directly. if (Instance == null) return false; if (typeId >= Instance.towerDefinitions.Length) return false; def = Instance.towerDefinitions[typeId]; return def != null; } + /// + /// Public lookup used by to resolve a tower type ID + /// to its definition. Returns null if the ID is invalid. + /// + public static TowerDefinition GetDefinition(int typeId) + { + return TryGetDefinition(typeId, out var def) ? def : null; + } + /// /// Maps a client ID to the PlayerSlot assigned to that client. /// @@ -486,8 +604,6 @@ namespace TD.Gameplay /// private static PlayerSlot ClientIdToPlayerSlot(ulong clientId) { - // NGO client IDs start at 0 (host). PlayerSlot values start at 1. - // Cast is safe for up to 9 players; beyond that returns None. byte slotByte = (byte)(clientId + 1); if (slotByte < 1 || slotByte > 9) return PlayerSlot.None; return (PlayerSlot)slotByte; @@ -524,7 +640,8 @@ namespace TD.Gameplay /// (they are Restricted or Outside the map). TileNotBuildable, - /// One or more footprint tiles are already occupied by an existing tower. + /// One or more footprint tiles are already occupied by an existing tower + /// or by a queued/constructing build job. TileOccupied, /// The placing player does not have enough gold. @@ -537,6 +654,10 @@ namespace TD.Gameplay /// spawner to its exit. The maze must remain passable. BlocksPath, + /// The placing player's builder queue is at capacity. Cancel pending + /// jobs or wait for one to complete before queuing more. + JobLimitReached, + /// The requested tower type ID is not in the server's definition list. InvalidTowerType, diff --git a/Assets/_Project/Scripts/Gameplay/TowerPlacementSettings.cs b/Assets/_Project/Scripts/Gameplay/TowerPlacementSettings.cs index 6db99cd..74278c1 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerPlacementSettings.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerPlacementSettings.cs @@ -17,25 +17,31 @@ namespace TD.Gameplay /// Rejection messages. These are the strings shown on screen when the /// server rejects a placement. Kept here so designers can tune the wording without /// touching code. + /// + /// Note on build-site visuals. The green queued-ghost material and the + /// constructing-stage material live on the BuildSiteVisual prefab, not here. + /// Those materials are prefab-local (the build-site visual prefab references them + /// directly), so promoting them to a shared settings asset would just add an + /// indirection without simplifying anything. /// [CreateAssetMenu(fileName = "TowerPlacementSettings", menuName = "TD/Tower Placement Settings", order = 3)] public class TowerPlacementSettings : ScriptableObject { - // ----- Ghost visuals ----------------------------------------------- + // ----- Cursor ghost visuals --------------------------------------- - [Header("Ghost Materials")] - [Tooltip("Material applied to the placement ghost when placement is valid. " + + [Header("Cursor Ghost Materials")] + [Tooltip("Material applied to the cursor placement ghost when placement is valid. " + "Should be a transparent/unlit white material so the tower mesh is " + "recognizable but clearly distinct from a placed tower.")] public Material GhostValidMaterial; - [Tooltip("Material applied to the placement ghost when placement is invalid. " + + [Tooltip("Material applied to the cursor placement ghost when placement is invalid. " + "Should be a transparent/unlit red material.")] public Material GhostInvalidMaterial; - // ----- Rejection messages ------------------------------------------ + // ----- Rejection messages ----------------------------------------- [Header("Rejection Messages")] [Tooltip("Shown when the server rejects a placement because tiles belong to " + @@ -47,8 +53,8 @@ namespace TD.Gameplay public string MessageTileNotBuildable = "That location is not buildable."; [Tooltip("Shown when the server rejects because a tile is already occupied " + - "by an existing tower.")] - public string MessageTileOccupied = "A tower already occupies that location."; + "by an existing tower or by a queued/constructing build job.")] + public string MessageTileOccupied = "A tower is already there or queued there."; [Tooltip("Shown when the server rejects because the player cannot afford " + "the tower.")] @@ -62,6 +68,10 @@ namespace TD.Gameplay "all valid paths through the player's zone.")] public string MessageBlocksPath = "That placement would block the path."; + [Tooltip("Shown when the server rejects because the builder's queue is full. " + + "Player must cancel pending jobs or wait for one to complete.")] + public string MessageJobLimitReached = "Builder queue is full."; + [Tooltip("Shown for unexpected server-side errors (invalid tower type, etc.). " + "Should rarely appear in normal play.")] public string MessageServerError = "Placement failed. Please try again."; @@ -81,6 +91,7 @@ namespace TD.Gameplay case PlacementRejectionReason.InsufficientGold: return MessageInsufficientGold; case PlacementRejectionReason.OutOfRange: return MessageOutOfRange; case PlacementRejectionReason.BlocksPath: return MessageBlocksPath; + case PlacementRejectionReason.JobLimitReached: return MessageJobLimitReached; case PlacementRejectionReason.InvalidTowerType: case PlacementRejectionReason.ServerError: default: return MessageServerError; diff --git a/ProjectSettings/TagManager.asset b/ProjectSettings/TagManager.asset index cec8516..a503376 100644 --- a/ProjectSettings/TagManager.asset +++ b/ProjectSettings/TagManager.asset @@ -13,8 +13,8 @@ TagManager: - UI - BuildablePlane - TerrainGeometry - - - - + - Selection + - BuildSite - - -