Adding a ton of funcitonality to the builder's movement and build queue

This commit is contained in:
Matt F 2026-05-05 22:01:40 -07:00
parent a63cce53e2
commit f05734e19b
31 changed files with 3104 additions and 339 deletions

2
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 88f1dd7b174716645953857b38fb6948
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:

View file

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

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f8d951a6841d3f74098bb31255379774
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:

View file

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

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 81d0983426a4a31478788e89e22b0e80
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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: []

View file

@ -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: []

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: dff852699e2897b4494fcbc7f7e547d6
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

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

View file

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

View file

@ -0,0 +1,182 @@
// Assets/_Project/Scripts/Gameplay/BuildJob.cs
using System;
using Unity.Netcode;
using UnityEngine;
namespace TD.Gameplay
{
/// <summary>
/// One entry in a <see cref="Builder"/>'s build queue. Replicated as part of a
/// <see cref="NetworkList{T}"/> on the Builder so all clients can render queued
/// ghosts and progress without per-job RPCs.
/// </summary>
/// <remarks>
/// <para><b>Identity.</b> <see cref="JobId"/> 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.</para>
///
/// <para><b>Stage transitions.</b> Jobs progress
/// <see cref="BuildStage.Queued"/> → <see cref="BuildStage.Constructing"/> →
/// (removed when complete). Only the head of the queue can transition to
/// Constructing; tail jobs stay Queued until they reach the head.</para>
///
/// <para><b>Time fields.</b> <see cref="ConstructionStartServerTime"/> is set on
/// the server using <c>NetworkManager.ServerTime.TimeAsFloat</c> at the moment
/// construction begins. Clients compute current stage as
/// <c>floor(elapsed / (BuildTime / 4))</c> against the same server time, so all
/// peers see identical staging without per-stage RPC chatter.</para>
///
/// <para><b>INetworkSerializable.</b> Required for use in
/// <see cref="NetworkList{T}"/>. Serializes only the minimum needed fields;
/// derived data (footprint size, gold cost) is looked up from the
/// TowerDefinition by <see cref="TowerTypeId"/> on each peer.</para>
/// </remarks>
[Serializable]
public struct BuildJob : INetworkSerializable, IEquatable<BuildJob>
{
// ----- Persistent fields ------------------------------------------
/// <summary>Server-assigned unique ID. Stable across NetworkList reorderings.</summary>
public ulong JobId;
/// <summary>Footprint anchor (SW corner, world-tile coords).</summary>
public Vector2Int Anchor;
/// <summary>Index into <c>TowerPlacementManager.towerDefinitions[]</c>.</summary>
public int TowerTypeId;
/// <summary>Current stage. See <see cref="BuildStage"/>.</summary>
public BuildStage Stage;
/// <summary>
/// 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
/// <c>(now - ConstructionStartServerTime) + AccumulatedConstructionTime</c>.
/// </summary>
public float ConstructionStartServerTime;
/// <summary>
/// Construction time accumulated across previous Constructing runs.
/// Used to preserve progress across pause/resume cycles.
/// At pause, set to <c>elapsed_in_current_run + previous_accumulated</c>.
/// At resume, <c>ConstructionStartServerTime</c> is reset to "now" and
/// total progress is computed as
/// <c>(now - ConstructionStartServerTime) + AccumulatedConstructionTime</c>.
/// 0 for jobs that have never been paused.
/// </summary>
public float AccumulatedConstructionTime;
/// <summary>
/// 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.
/// </summary>
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<T>(BufferSerializer<T> 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<T> requires IEquatable<T> 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();
}
/// <summary>
/// Lifecycle stage of a <see cref="BuildJob"/>.
/// </summary>
/// <remarks>
/// Backed by byte to keep the serialized payload small and to allow the byte
/// round-trip in <see cref="BuildJob.NetworkSerialize{T}"/>.
/// </remarks>
public enum BuildStage : byte
{
/// <summary>
/// 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.
/// </summary>
Queued = 0,
/// <summary>
/// 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.
/// </summary>
Constructing = 1,
/// <summary>
/// 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.
/// </summary>
Paused = 2,
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5185b25b41381004293388438523c10b

View file

@ -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
{
/// <summary>
/// Visual representation of an in-flight <see cref="BuildJob"/>: 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 <see cref="TowerInstance"/>
/// takes its place at construction-complete.
/// </summary>
/// <remarks>
/// <para><b>Why a separate prefab from TowerInstance.</b> 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.</para>
///
/// <para><b>Stage replication.</b> Stage and ConstructionStartServerTime are
/// replicated as NetworkVariables so all peers compute identical visuals locally
/// from <c>NetworkManager.ServerTime.TimeAsFloat</c>. Only the server writes;
/// clients read.</para>
///
/// <para><b>Visual model.</b> 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.</para>
///
/// <para><b>No grid stamping here.</b> 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.</para>
/// </remarks>
[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<FixedString64Bytes> definitionName =
new NetworkVariable<FixedString64Bytes>(
default,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// Replicated owner slot for color tinting. Mirrors TowerInstance.
private readonly NetworkVariable<PlayerSlot> ownerSlot =
new NetworkVariable<PlayerSlot>(
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<Vector2Int> anchor =
new NetworkVariable<Vector2Int>(
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<int> towerTypeId =
new NetworkVariable<int>(
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<int> goldSpent =
new NetworkVariable<int>(
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<bool> isShelved =
new NetworkVariable<bool>(
false,
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// Current stage. Drives material swap and Y-scale animation.
private readonly NetworkVariable<BuildStage> currentStage =
new NetworkVariable<BuildStage>(
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<float> constructionStartServerTime =
new NetworkVariable<float>(
-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<float> accumulatedConstructionTime =
new NetworkVariable<float>(
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<float> buildTime =
new NetworkVariable<float>(
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.
/// <summary>The current build stage. Read by clients for click-target tests.</summary>
public BuildStage CurrentStage => currentStage.Value;
/// <summary>True iff this visual has been shelved (no longer in its Builder's queue).</summary>
public bool IsShelved => isShelved.Value;
/// <summary>Footprint anchor (SW corner). Used by server to rebuild a BuildJob on resume.</summary>
public Vector2Int Anchor => anchor.Value;
/// <summary>Tower type ID. Used by server to rebuild a BuildJob on resume.</summary>
public int TowerTypeId => towerTypeId.Value;
/// <summary>Gold paid for this build. Used by server to rebuild a BuildJob on resume.</summary>
public int GoldSpent => goldSpent.Value;
/// <summary>Accumulated construction time (across pause/resume cycles). Used on resume.</summary>
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 --------------------------------------------------
/// <summary>
/// Server-only: stores the data that <see cref="OnNetworkSpawn"/> will write
/// into NetworkVariables. Must be called between Instantiate and Spawn().
/// </summary>
/// <remarks>
/// Owner identity is conveyed through NGO ownership (via SpawnWithOwnership),
/// not through this method — see <see cref="NetworkBehaviour.OwnerClientId"/>.
/// </remarks>
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<MeshRenderer>();
// 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 -------------------------------------------------
/// <summary>
/// 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.
/// </summary>
public void ServerMarkShelved()
{
if (!IsServer) return;
isShelved.Value = true;
}
/// <summary>
/// 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.
/// </summary>
public void ServerMarkUnshelved()
{
if (!IsServer) return;
isShelved.Value = false;
}
/// <summary>
/// Server-only: transitions the visual from Queued (or Paused) to Constructing
/// and records the server time for stage progression. Caller is responsible
/// for setting <paramref name="accumulatedTimeBeforeThisRun"/> from the BuildJob's
/// AccumulatedConstructionTime — non-zero values mean this is a resume.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a236b3b34c8dd784db3bed4e6b0f44f9

View file

@ -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
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para><b>Pure visual avatar.</b> 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.</para>
/// <para><b>Pure visual avatar.</b> 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.</para>
///
/// <para><b>Terrain-aware height.</b> Each frame the server casts a ray straight down
/// from the builder against the <see cref="terrainLayerMask"/> and sets Y to
/// <c>hit.point.y + heightOffset</c>. If the ray misses, falls back to the buildable
/// plane Y. Towers are not on the terrain layer, so they don't influence height.</para>
/// <para><b>Terrain-aware height.</b> Each frame the server casts a ray straight
/// down from the builder against <see cref="terrainLayerMask"/> and sets Y to
/// <c>hit.point.y + heightOffset</c>. Falls back to <see cref="GridCoordinates.BUILDABLE_PLANE_Y"/>
/// if the ray misses. Towers must not be on the terrain layer.</para>
///
/// <para><b>Range gating.</b> <see cref="IsTileWithinBuildRange"/> is the public query
/// that <c>TowerPlacementManager</c> uses to validate placement requests. Builder range
/// is measured center-of-builder to center-of-anchor-tile in world units.</para>
/// <para><b>Range gating.</b> <see cref="IsTileWithinBuildRange"/> is the public
/// query that <c>TowerPlacementManager</c> uses to validate placement requests.
/// Range is measured center-of-builder to nearest-point-of-footprint in world units.</para>
///
/// <para><b>Static registry.</b> Like <see cref="PlayerGoldManager"/>, builders register
/// themselves in a static dictionary keyed by <c>OwnerClientId</c> on spawn, so server
/// gameplay code (notably <c>TowerPlacementManager</c>) can find a player's builder without
/// scene traversal.</para>
/// <para><b>Build queue.</b> Each Builder owns a <see cref="NetworkList{T}"/> of
/// <see cref="BuildJob"/>. The server appends jobs from
/// <c>TowerPlacementManager.ProcessRequest</c> via <see cref="ServerEnqueueJob"/>.
/// 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 <c>TowerInstance</c>
/// and the head job is removed. Cancellation drops every job in the queue and
/// refunds 100% per the design doc.</para>
///
/// <para><b>Static registry.</b> Like <see cref="PlayerGoldManager"/>, builders
/// register themselves in a static dictionary keyed by <c>OwnerClientId</c> on
/// spawn so server gameplay code can find a player's builder without scene
/// traversal.</para>
/// </remarks>
[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<BuildJob> jobs;
/// <summary>
/// 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.
/// </summary>
public NetworkList<BuildJob> 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<ulong, NetworkObject> jobIdToVisual
= new Dictionary<ulong, NetworkObject>();
// 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 -------------------------------------------
/// <summary>The builder's current world position (its actual transform position,
@ -122,8 +181,39 @@ namespace TD.Gameplay
/// <summary>Build range in world units.</summary>
public float BuildRange => buildRange;
/// <summary>Maximum jobs allowed in the queue.</summary>
public int MaxQueueDepth => maxQueueDepth;
/// <summary>True if a tile is currently part of any queued or constructing job.</summary>
/// <remarks>
/// Used by <c>TowerPlacementManager</c> 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 <see cref="MaxQueueDepth"/>.
/// </remarks>
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<BuildJob>();
}
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<MeshRenderer>())
// 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);
}
}
}
/// <summary>
@ -228,8 +376,7 @@ namespace TD.Gameplay
/// <summary>
/// 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.
/// </summary>
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) ----------
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// "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."
/// <para>Used by <see cref="DriveHead_Queued"/> 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.</para>
///
/// <para>"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."</para>
/// </remarks>
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)
// ===================================================================
/// <summary>
/// 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.
/// </summary>
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<BuildSiteVisual>();
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<NetworkObject>();
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<BuildSiteVisual>();
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 -------------------------------------------
/// <summary>
/// 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.
/// </summary>
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
public void RequestCancelAllJobsRpc()
{
if (!IsServer) return;
ServerCancelAllJobs();
}
/// <summary>
/// Server-side: cancel every job in the queue. Public so other server code
/// (disconnect cleanup, future "level reset" events) can invoke it directly.
/// </summary>
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)
// ===================================================================
/// <summary>
/// Owner-only Rpc: the player issued a "move to here" command (right-click
/// on empty buildable plane). Server applies the new move target and:
/// <list type="bullet">
/// <item>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.</item>
/// <item>If head is Queued → refund the entire queue (no progress to preserve).</item>
/// <item>Empty queue → just a move command. No queue effects.</item>
/// </list>
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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<BuildSiteVisual>();
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<BuildSiteVisual>();
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);
}
}
}

View file

@ -7,22 +7,48 @@ using TD.Core;
namespace TD.Gameplay
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para><b>Owner-only.</b> This component lives on the same GameObject as
/// <see cref="Builder"/> 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
/// <see cref="Builder.RequestMoveRpc"/>.</para>
/// <see cref="Builder"/> but only its owning client processes input. Non-owner
/// clients have this component but its Update is a no-op.</para>
///
/// <para><b>Right-click priority.</b> If <c>TowerPlacementController.IsPlacing</c> is
/// true, right-click cancels placement (handled by <c>TowerPlacementController</c>
/// itself). When NOT placing, right-click moves the builder.</para>
/// <para><b>Selection model.</b> Left-click raycast against the
/// <see cref="selectionLayerMask"/> (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 <see cref="SelectionState"/>.</para>
///
/// <para><b>Raycast target.</b> 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.</para>
/// <para><b>Right-click — selection required.</b> Without selection, right-click
/// is a no-op. With selection:
/// <list type="bullet">
/// <item>Right-click on the local builder's PAUSED build site (raycast hits
/// <see cref="buildSiteLayerMask"/>): resume that job. Builder walks back
/// into range; preserved progress picks up where it left off.</item>
/// <item>Right-click anywhere else on the buildable plane: move-and-pause.
/// Builder walks to the clicked location. Side effects:
/// <list type="bullet">
/// <item>If currently Constructing → pause the active build, refund
/// tail jobs.</item>
/// <item>If head is Queued → refund the entire queue.</item>
/// <item>If head is Paused → no queue change (tail was already
/// refunded at pause time).</item>
/// </list>
/// </item>
/// </list></para>
///
/// <para><b>Escape.</b> Clears selection. No queue cancellation in D2 — that's
/// deferred to the HUD path.</para>
///
/// <para><b>Placement mode interaction.</b> 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.</para>
/// </remarks>
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<Builder>();
// 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<BuildSiteVisual>();
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<NetworkObject>();
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;
}
}
}

View file

@ -0,0 +1,126 @@
// Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs
using UnityEngine;
namespace TD.Gameplay
{
/// <summary>
/// Local-only visual indicator for builder selection. Sits as a child of the
/// builder prefab. Subscribes to <see cref="SelectionState.OnSelectionChanged"/>
/// and toggles the visibility of its own renderers (and any descendant
/// renderers) when the parent builder becomes selected/unselected.
/// </summary>
/// <remarks>
/// <para><b>Pure local visualization.</b> No NetworkBehaviour. Selection is a
/// UI concept — every client renders selection state for its own player only.
/// This component has no networked state.</para>
///
/// <para><b>Why a separate component.</b> 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."</para>
///
/// <para><b>Why renderer-toggle, not GameObject.SetActive.</b> 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.</para>
///
/// <para><b>Prefab setup.</b> 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.</para>
/// </remarks>
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<Builder>();
if (parentBuilder == null)
{
Debug.LogError("[SelectionRingVisual] No Builder component found on " +
"self or any parent. Disabling.");
enabled = false;
return;
}
cachedRenderers = GetComponentsInChildren<Renderer>(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;
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 67895f626233fdc499dffbbfcc225530

View file

@ -0,0 +1,90 @@
// Assets/_Project/Scripts/Gameplay/SelectionState.cs
using UnityEngine;
namespace TD.Gameplay
{
/// <summary>
/// Minimal scene-local selection state. Holds a reference to whichever
/// <see cref="Builder"/> the local player has selected, fires an event when
/// the selection changes, and exposes a query for "is this builder selected
/// right now?".
/// </summary>
/// <remarks>
/// <para><b>Scope.</b> 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.</para>
///
/// <para><b>Local-only.</b> 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.</para>
///
/// <para><b>Singleton.</b> One per scene, accessed via <see cref="Instance"/>.
/// The selection consumer (BuilderInputController) and the selection driver
/// (mouse-click raycast) both go through this single source of truth.</para>
/// </remarks>
public class SelectionState : MonoBehaviour
{
// ----- Singleton --------------------------------------------------
/// <summary>
/// The active SelectionState. Null before the scene loads. Always null-check.
/// </summary>
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;
/// <summary>The currently selected builder, or null if nothing is selected.</summary>
public Builder SelectedBuilder => selectedBuilder;
/// <summary>True if any builder is currently selected.</summary>
public bool HasSelection => selectedBuilder != null;
/// <summary>True if <paramref name="b"/> is the currently selected builder.</summary>
public bool IsSelected(Builder b) => b != null && selectedBuilder == b;
// ----- Events -----------------------------------------------------
/// <summary>
/// Fired when the selection changes. Argument is the new selection (may be null).
/// Subscribe to drive selection-aware UI: highlights, context panels, hotkey hints.
/// </summary>
public event System.Action<Builder> OnSelectionChanged;
// ----- Mutators ---------------------------------------------------
/// <summary>
/// Sets the selected builder. Pass null to clear.
/// Fires <see cref="OnSelectionChanged"/> only if the selection actually changes.
/// </summary>
public void Select(Builder builder)
{
if (selectedBuilder == builder) return;
selectedBuilder = builder;
OnSelectionChanged?.Invoke(selectedBuilder);
}
/// <summary>Clears the selection. Equivalent to Select(null).</summary>
public void Clear() => Select(null);
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dc307e7e94967894584e8e6050fc38cf

View file

@ -12,33 +12,29 @@ namespace TD.Gameplay
/// requests to <see cref="TowerPlacementManager"/> via RPC.
/// </summary>
/// <remarks>
/// <para><b>Plain MonoBehaviour.</b> Placement visuals (ghost, cursor color) are
/// <para><b>Plain MonoBehaviour.</b> 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 <see cref="TowerPlacementManager"/>.</para>
///
/// <para><b>Ghost validity check.</b> 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.</para>
/// <para><b>Ghost validity check.</b> 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.</para>
///
/// <para><b>Ghost colors.</b>
/// <list type="bullet">
/// <item>White — all local checks pass.</item>
/// <item>Red — any local check fails (wrong zone, not buildable, already occupied).</item>
/// </list>
/// Green "pending construction" ghost is a separate system implemented in Path D.</para>
/// <para><b>Two ghosts, two systems.</b> The <i>cursor ghost</i> (handled here)
/// follows the mouse and turns white/red. The <i>build-site visual</i> (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.</para>
///
/// <para><b>Placement activation.</b> The controller is idle until
/// <see cref="BeginPlacement"/> is called (e.g., from a HUD tower button). The player
/// right-clicks or the placement is confirmed/rejected to return to idle.</para>
/// <para><b>Chained queueing.</b> 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.</para>
///
/// <para><b>Input System.</b> Uses the New Input System package. Mouse position and
/// button state are read from <c>Mouse.current</c> each frame.</para>
///
/// <para><b>Player slot.</b> The local player slot is currently a stub
/// (client 0 = Player1, etc.) matching <c>TowerPlacementManager.ClientIdToPlayerSlot</c>.
/// This will be replaced when MatchState carries the authoritative slot assignment.</para>
/// <para><b>Input System.</b> Uses the New Input System package. Mouse and modifier
/// state are read directly from <c>Mouse.current</c> and <c>Keyboard.current</c>
/// each frame. No InputAction asset needed for these placement-specific bindings.</para>
/// </remarks>
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.
/// </summary>
/// <param name="def">The TowerDefinition to place.</param>
/// <param name="towerTypeId">The type ID registered in
/// <see cref="TowerPlacementManager"/>.</param>
public void BeginPlacement(TowerDefinition def, int towerTypeId)
{
if (def == null)
@ -199,6 +193,15 @@ namespace TD.Gameplay
/// </summary>
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.
/// </summary>
/// <remarks>
/// 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))
/// </remarks>
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);
}

View file

@ -10,39 +10,48 @@ namespace TD.Gameplay
{
/// <summary>
/// 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 <see cref="Builder"/> or rejects the request
/// with a reason code.
/// </summary>
/// <remarks>
/// <para><b>Queue-based processing.</b> Incoming RPCs enqueue a
/// <para><b>Queue-based RPC processing.</b> Incoming RPCs enqueue a
/// <see cref="PlacementRequest"/> rather than validating inline. Each server
/// Update drains up to <see cref="RequestsPerFrame"/> requests. At 60 fps this
/// Update drains up to <see cref="requestsPerFrame"/> 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.</para>
///
/// <para><b>Server-only logic.</b> All validation and mutation runs on the server.
/// Clients learn about accepted placements when the <see cref="TowerInstance"/>
/// NetworkObject spawns (NGO replicates it automatically). Clients learn about
/// rejections via <see cref="PlacementRejectedRpc"/>.</para>
/// Clients learn about accepted placements when the build-site visual NetworkObject
/// spawns (queued ghost) and later when the <see cref="TowerInstance"/> spawns at
/// construction-complete. Clients learn about rejections via
/// <see cref="PlacementRejectedRpc"/>.</para>
///
/// <para><b>Validation order:</b>
/// <list type="number">
/// <item>Tower type — must resolve to a valid TowerDefinition.</item>
/// <item>Ownership — every footprint tile must be owned by the requesting player.</item>
/// <item>Placement state — every footprint tile must be <c>Buildable</c> and unoccupied.</item>
/// <item>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.</item>
/// <item>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.</item>
/// <item>Gold — the placing player must have enough gold.</item>
/// <item>Queue capacity — the placing player's builder queue must have room.</item>
/// <item>Path — a BFS confirms every spawner in the placing player's zone still reaches
/// an exit after the footprint is stamped as non-walkable.</item>
/// 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.</item>
/// </list></para>
///
/// <para><b>Path-check BFS.</b> 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.</para>
///
/// <para><b>Builder range check.</b> 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.</para>
/// <para><b>D2 build-queue flow.</b> 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
/// <see cref="ServerSpawnCompletedTower"/> to spawn the real TowerInstance.</para>
/// </remarks>
public class TowerPlacementManager : NetworkBehaviour
{
@ -123,12 +132,10 @@ namespace TD.Gameplay
/// <summary>
/// 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 <see cref="PlacementRejectedRpc"/>.
/// The server will validate and either enqueue the placement (which spawns a
/// build-site visual visible to all clients) or call back with
/// <see cref="PlacementRejectedRpc"/>.
/// </summary>
/// <param name="anchorX">X component of the footprint anchor tile (world-tile coords).</param>
/// <param name="anchorY">Y component of the footprint anchor tile (world-tile coords).</param>
/// <param name="towerTypeId">Index into the server's towerDefinitions array.</param>
[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 ------------------
/// <summary>
/// Server-only: called by <c>Builder</c> 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.
/// </summary>
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<Vector2Int>(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;
}
/// <summary>
/// Server-only: read-only path validity check for the given player's zone.
/// Used by <c>Builder</c> 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.
/// </summary>
public bool ServerVerifyPathStillValid(PlayerSlot placingSlot)
{
if (!IsServer) return false;
var loader = LevelLoader.Instance;
if (loader == null || !loader.IsLoaded) return false;
return CheckPathValidity(loader, placingSlot);
}
/// <summary>
/// Server-only: called by <c>Builder</c> when a constructing job completes.
/// Spawns the real <see cref="TowerInstance"/> NetworkObject at the anchor.
/// The footprint is already occupied and non-walkable from the construction-start
/// commit; <c>TowerInstance.OnNetworkSpawn</c>'s footprint stamp is idempotent
/// and harmless.
/// </summary>
public void ServerSpawnCompletedTower(TowerDefinition def, Vector2Int anchor,
PlayerSlot owner)
{
if (!IsServer) return;
SpawnTower(def, anchor, owner);
}
// ----- Path-validity BFS ------------------------------------------
/// <summary>
@ -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 ----------------------------------------------------
/// <summary>
/// Stamps or un-stamps all tiles in <paramref name="footprint"/> on both the
/// walkability and occupancy grids simultaneously. Always update both together.
/// Stamps walkability on every tile in <paramref name="footprint"/>.
/// Independent of occupancy because the queue-time and construction-time
/// transitions touch them on different schedules.
/// </summary>
private static void StampFootprint(LevelLoader loader, List<Vector2Int> footprint,
bool walkable, bool occupied)
private static void StampWalkable(LevelLoader loader, List<Vector2Int> footprint,
bool walkable)
{
foreach (var tile in footprint)
{
loader.SetWalkable(tile, walkable);
}
/// <summary>
/// Stamps occupancy on every tile in <paramref name="footprint"/>.
/// </summary>
private static void StampOccupied(LevelLoader loader, List<Vector2Int> footprint,
bool occupied)
{
foreach (var tile in footprint)
loader.SetOccupied(tile, occupied);
}
}
/// <summary>
@ -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;
}
/// <summary>
/// Public lookup used by <see cref="Builder"/> to resolve a tower type ID
/// to its definition. Returns null if the ID is invalid.
/// </summary>
public static TowerDefinition GetDefinition(int typeId)
{
return TryGetDefinition(typeId, out var def) ? def : null;
}
/// <summary>
/// Maps a client ID to the PlayerSlot assigned to that client.
/// </summary>
@ -486,8 +604,6 @@ namespace TD.Gameplay
/// </remarks>
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).</summary>
TileNotBuildable,
/// <summary>One or more footprint tiles are already occupied by an existing tower.</summary>
/// <summary>One or more footprint tiles are already occupied by an existing tower
/// or by a queued/constructing build job.</summary>
TileOccupied,
/// <summary>The placing player does not have enough gold.</summary>
@ -537,6 +654,10 @@ namespace TD.Gameplay
/// spawner to its exit. The maze must remain passable.</summary>
BlocksPath,
/// <summary>The placing player's builder queue is at capacity. Cancel pending
/// jobs or wait for one to complete before queuing more.</summary>
JobLimitReached,
/// <summary>The requested tower type ID is not in the server's definition list.</summary>
InvalidTowerType,

View file

@ -17,25 +17,31 @@ namespace TD.Gameplay
/// <para><b>Rejection messages.</b> These are the strings shown on screen when the
/// server rejects a placement. Kept here so designers can tune the wording without
/// touching code.</para>
///
/// <para><b>Note on build-site visuals.</b> The green queued-ghost material and the
/// constructing-stage material live on the <c>BuildSiteVisual</c> 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.</para>
/// </remarks>
[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;

View file

@ -13,8 +13,8 @@ TagManager:
- UI
- BuildablePlane
- TerrainGeometry
-
-
- Selection
- BuildSite
-
-
-