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
-
-
-