diff --git a/Assets/_Project/Prefabs/Builders/Builder_Basic.prefab b/Assets/_Project/Prefabs/Builders/Builder_Basic.prefab
index 7324504..8569ea1 100644
--- a/Assets/_Project/Prefabs/Builders/Builder_Basic.prefab
+++ b/Assets/_Project/Prefabs/Builders/Builder_Basic.prefab
@@ -36,7 +36,6 @@ Transform:
m_Children:
- {fileID: 2153758330548988791}
- {fileID: 5176306400449771234}
- - {fileID: 6565619444702228235}
- {fileID: 6214765925043807804}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
@@ -166,109 +165,6 @@ 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
diff --git a/Assets/_Project/Prefabs/SelectionRing.prefab b/Assets/_Project/Prefabs/SelectionRing.prefab
new file mode 100644
index 0000000..44c36b2
--- /dev/null
+++ b/Assets/_Project/Prefabs/SelectionRing.prefab
@@ -0,0 +1,92 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!1 &8648063358813763958
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 28748107664170477}
+ - component: {fileID: 5515778108557826029}
+ - component: {fileID: 581807244764405941}
+ m_Layer: 0
+ m_Name: SelectionRing
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &28748107664170477
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 8648063358813763958}
+ 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: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!33 &5515778108557826029
+MeshFilter:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 8648063358813763958}
+ m_Mesh: {fileID: 10206, guid: 0000000000000000e000000000000000, type: 0}
+--- !u!23 &581807244764405941
+MeshRenderer:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 8648063358813763958}
+ 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}
diff --git a/Assets/_Project/Prefabs/SelectionRing.prefab.meta b/Assets/_Project/Prefabs/SelectionRing.prefab.meta
new file mode 100644
index 0000000..e3543a3
--- /dev/null
+++ b/Assets/_Project/Prefabs/SelectionRing.prefab.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 74757e379bac0e444aabab5e388e17c6
+PrefabImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Project/Prefabs/Towers/BuildSiteVisual.prefab b/Assets/_Project/Prefabs/Towers/BuildSiteVisual.prefab
index 30298ab..5334e24 100644
--- a/Assets/_Project/Prefabs/Towers/BuildSiteVisual.prefab
+++ b/Assets/_Project/Prefabs/Towers/BuildSiteVisual.prefab
@@ -112,6 +112,59 @@ BoxCollider:
serializedVersion: 3
m_Size: {x: 1, y: 1, z: 1}
m_Center: {x: 0, y: 0, z: 0}
+--- !u!1 &4243270647055755528
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 3705223059275757242}
+ - component: {fileID: 8475846269398360663}
+ m_Layer: 8
+ m_Name: SelectionTarget
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &3705223059275757242
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 4243270647055755528}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0.5, 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 &8475846269398360663
+BoxCollider:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 4243270647055755528}
+ 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: 2, y: 1, z: 2}
+ m_Center: {x: 0, y: 0, z: 0}
--- !u!1 &5570322131082283203
GameObject:
m_ObjectHideFlags: 0
@@ -154,7 +207,7 @@ BoxCollider:
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
- m_Bits: 0
+ m_Bits: 256
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
@@ -176,6 +229,7 @@ GameObject:
- component: {fileID: 1531733800731892084}
- component: {fileID: 9075933591925717035}
- component: {fileID: 7845454079079718139}
+ - component: {fileID: 6259437393614329336}
m_Layer: 0
m_Name: BuildSiteVisual
m_TagString: Untagged
@@ -198,6 +252,7 @@ Transform:
m_Children:
- {fileID: 2082013748313019226}
- {fileID: 1059634071631389929}
+ - {fileID: 3705223059275757242}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &9075933591925717035
@@ -212,7 +267,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
- GlobalObjectIdHash: 3616792119
+ GlobalObjectIdHash: 800191742
InScenePlacedSourceGlobalObjectIdHash: 0
DeferredDespawnTick: 0
Ownership: 1
@@ -245,3 +300,24 @@ MonoBehaviour:
pausedMaterial: {fileID: 0}
stageCount: 4
queuedYScale: 0.15
+--- !u!65 &6259437393614329336
+BoxCollider:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 7720770984308489338}
+ 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}
diff --git a/Assets/_Project/Prefabs/Towers/Tower_Basic.prefab b/Assets/_Project/Prefabs/Towers/Tower_Basic.prefab
index fa5ff31..511931b 100644
--- a/Assets/_Project/Prefabs/Towers/Tower_Basic.prefab
+++ b/Assets/_Project/Prefabs/Towers/Tower_Basic.prefab
@@ -1,5 +1,58 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
+--- !u!1 &39352475684176306
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 5753294230586596248}
+ - component: {fileID: 5360241685303413176}
+ m_Layer: 8
+ m_Name: SelectionVolume
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &5753294230586596248
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 39352475684176306}
+ 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: 1283036264165444500}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!65 &5360241685303413176
+BoxCollider:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 39352475684176306}
+ 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 &6482414459531823157
GameObject:
m_ObjectHideFlags: 0
@@ -30,10 +83,11 @@ Transform:
m_GameObject: {fileID: 6482414459531823157}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
- m_LocalPosition: {x: 50.53879, y: 0.5, z: 5.77106}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 2, y: 1, z: 2}
m_ConstrainProportionsScale: 0
- m_Children: []
+ m_Children:
+ - {fileID: 5753294230586596248}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!33 &6869333096494165105
@@ -126,7 +180,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
- GlobalObjectIdHash: 1472871091
+ GlobalObjectIdHash: 2767478135
InScenePlacedSourceGlobalObjectIdHash: 0
DeferredDespawnTick: 0
Ownership: 1
@@ -152,3 +206,5 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.TowerInstance
ShowTopMostFoldoutHeaderGroup: 1
+ tintedRenderers:
+ - {fileID: 4028055828417179692}
diff --git a/Assets/_Project/Scenes/Levels/Main.unity b/Assets/_Project/Scenes/Levels/Main.unity
index d8a2e6d..e064af5 100644
--- a/Assets/_Project/Scenes/Levels/Main.unity
+++ b/Assets/_Project/Scenes/Levels/Main.unity
@@ -1184,6 +1184,55 @@ BoxCollider:
serializedVersion: 3
m_Size: {x: 7, y: 1, z: 7}
m_Center: {x: 0, y: 0, z: 0}
+--- !u!1 &1168515844
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1168515846}
+ - component: {fileID: 1168515845}
+ m_Layer: 0
+ m_Name: SelectionVisualizer
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!114 &1168515845
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1168515844}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: e28d10549c8d2ac4585eded3ad8d2198, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.SelectionVisualizer
+ selectionRingPrefab: {fileID: 8648063358813763958, guid: 74757e379bac0e444aabab5e388e17c6, type: 3}
+ yOffset: 0.02
+ projectionLayerMask:
+ serializedVersion: 2
+ m_Bits: 960
+--- !u!4 &1168515846
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1168515844}
+ 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: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1222526236
GameObject:
m_ObjectHideFlags: 0
@@ -2181,3 +2230,4 @@ SceneRoots:
- {fileID: 611926976}
- {fileID: 1222526238}
- {fileID: 1058315976}
+ - {fileID: 1168515846}
diff --git a/Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs b/Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs
index b558e2e..b0e6e55 100644
--- a/Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs
+++ b/Assets/_Project/Scripts/Gameplay/BuildSiteVisual.cs
@@ -4,7 +4,6 @@ using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Towers;
-using TD.UI;
namespace TD.Gameplay
{
@@ -40,7 +39,7 @@ namespace TD.Gameplay
/// races with the Builder. See lessons in the project context doc.
///
[RequireComponent(typeof(NetworkObject))]
- public class BuildSiteVisual : NetworkBehaviour
+ public class BuildSiteVisual : NetworkBehaviour, ISelectable
{
// ----- Inspector --------------------------------------------------
@@ -201,6 +200,36 @@ namespace TD.Gameplay
return Mathf.Clamp01((currentRunElapsed + accumulatedConstructionTime.Value) / bt);
}
+ // ----- ISelectable ------------------------------------------------
+
+ /// Name shown in the HUD portrait. Pulls from the resolved
+ /// TowerDefinition; falls back to a generic label if the registry hasn't
+ /// resolved the def yet on this client.
+ public string DisplayName
+ {
+ get
+ {
+ var def = TowerPlacementManager.GetDefinition(towerTypeId.Value);
+ return def != null ? def.DisplayName : "Tower (building)";
+ }
+ }
+
+ public SelectableKind Kind => SelectableKind.BuildSite;
+
+ public Transform SelectionTransform => transform;
+
+ // Match the TowerInstance's radius formula so the selection ring is the
+ // same size before vs. after the build completes — no visual pop on transition.
+ public float SelectionRadius
+ {
+ get
+ {
+ var def = TowerPlacementManager.GetDefinition(towerTypeId.Value);
+ Vector2Int fp = def != null ? def.FootprintSize : new Vector2Int(1, 1);
+ return Mathf.Max(fp.x, fp.y) * 0.5f * GridCoordinates.TILE_SIZE + 0.5f;
+ }
+ }
+
// ----- Pre-spawn init data (server) -------------------------------
private string pendingDefName;
@@ -266,12 +295,6 @@ namespace TD.Gameplay
// Apply initial visual state based on the (now-replicated) values.
ApplyStageVisual(currentStage.Value);
-
- // Attach a local (non-networked) progress bar — each client creates its own.
- // Destroyed automatically when this NetworkObject is despawned (it's a child).
- var barHost = new GameObject("ProgressBar");
- barHost.transform.SetParent(transform, false);
- barHost.AddComponent().Initialize(this);
}
public override void OnNetworkDespawn()
@@ -287,6 +310,15 @@ namespace TD.Gameplay
{
RestoreFootprintGridState();
}
+
+ // Local-only selection hygiene. If this visual was the active selection
+ // AND nothing has transferred selection to a replacement (e.g., a
+ // TowerInstance that just spawned at the same anchor), clear so HUD/
+ // visualizer don't hold a destroyed reference. The completion path
+ // spawns TowerInstance BEFORE this despawn arrives on the client; that
+ // spawn already transferred selection, so this check passes through.
+ if (SelectionState.Instance != null && SelectionState.Instance.IsSelected(this))
+ SelectionState.Instance.Clear();
}
// Server-only: restore walkability=true and occupancy=false on this build site's
@@ -347,6 +379,82 @@ namespace TD.Gameplay
isShelved.Value = false;
}
+ // ----- Player-initiated cancel (HUD action) -----------------------
+
+ // Server-only guard against double cancel — Cancel RPC could arrive twice
+ // if the player clicks quickly before the visual finishes despawning.
+ private bool serverCancelled;
+
+ ///
+ /// Owner-only RPC. Routes to . Hooked up to the
+ /// Cancel action button in the HUD's action menu.
+ ///
+ [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
+ public void RequestCancelRpc()
+ {
+ ServerCancel();
+ }
+
+ ///
+ /// Server-only: cancel this build. Two paths depending on shelve state:
+ /// - Shelved: this visual is standalone (not in any builder's queue).
+ /// Refund gold ourselves and despawn —
+ /// restores the footprint's grid state because isShelved is true.
+ /// - In a builder's queue: route through the owning builder's
+ /// , which already handles
+ /// refund + grid restore + visual despawn through its job-cleanup path.
+ ///
+ public void ServerCancel()
+ {
+ if (!IsServer) return;
+ if (serverCancelled) return; // idempotent guard
+ serverCancelled = true;
+
+ if (isShelved.Value)
+ {
+ RefundOwner();
+ if (NetworkObject.IsSpawned)
+ NetworkObject.Despawn(destroy: true);
+ return;
+ }
+
+ // Queued / Constructing / Paused — owned by a builder's job queue.
+ var builder = Builder.GetForClient(OwnerClientId);
+ if (builder != null)
+ {
+ bool found = builder.ServerCancelJobAtAnchor(anchor.Value);
+ if (found) return;
+ // Race: visual exists but no matching job (e.g., job just completed
+ // and the visual is mid-despawn). Fall through to manual cleanup.
+ Debug.LogWarning($"[BuildSiteVisual] ServerCancel: no matching job " +
+ $"at anchor {anchor.Value} on builder for client " +
+ $"{OwnerClientId}. Performing manual refund+despawn.");
+ }
+ else
+ {
+ Debug.LogWarning("[BuildSiteVisual] ServerCancel: owning builder " +
+ "not found. Performing manual refund+despawn.");
+ }
+
+ // Manual fallback for the race / no-builder cases. Restore grid since
+ // the builder isn't going to do it for us.
+ RefundOwner();
+ if (currentStage.Value == BuildStage.Constructing
+ || currentStage.Value == BuildStage.Paused)
+ {
+ RestoreFootprintGridState();
+ }
+ if (NetworkObject.IsSpawned)
+ NetworkObject.Despawn(destroy: true);
+ }
+
+ private void RefundOwner()
+ {
+ var goldManager = PlayerGoldManager.GetForClient(OwnerClientId);
+ if (goldManager == null) return;
+ goldManager.AwardGold(goldSpent.Value);
+ }
+
///
/// Server-only: transitions the visual from Queued (or Paused) to Constructing
/// and records the server time for stage progression. Caller is responsible
diff --git a/Assets/_Project/Scripts/Gameplay/Builder.cs b/Assets/_Project/Scripts/Gameplay/Builder.cs
index a2d0c27..5975391 100644
--- a/Assets/_Project/Scripts/Gameplay/Builder.cs
+++ b/Assets/_Project/Scripts/Gameplay/Builder.cs
@@ -46,7 +46,7 @@ namespace TD.Gameplay
/// traversal.
///
[RequireComponent(typeof(NetworkObject))]
- public class Builder : NetworkBehaviour, IMinimapEntity
+ public class Builder : NetworkBehaviour, IMinimapEntity, ISelectable
{
// ----- Static registry --------------------------------------------
@@ -156,6 +156,8 @@ namespace TD.Gameplay
/// Maximum jobs allowed in the queue.
public int MaxQueueDepth => settings.maxQueueDepth;
+ // ----- ISelectable ------------------------------------------------
+
/// Display name shown in the HUD portrait. Stub until MatchState provides player names.
public string DisplayName
{
@@ -167,6 +169,15 @@ namespace TD.Gameplay
}
}
+ public SelectableKind Kind => SelectableKind.Builder;
+
+ public Transform SelectionTransform => transform;
+
+ // Builders are point units; the visible silhouette is roughly 1 unit wide.
+ // 0.6 puts a small visible gap between the silhouette and the ring.
+ // Bump up if BuilderSettings later exposes a width or selection radius.
+ public float SelectionRadius => 0.6f;
+
/// True if a tile is currently part of any queued or constructing job.
///
/// Used by TowerPlacementManager to reject placement on tiles already
@@ -232,6 +243,14 @@ namespace TD.Gameplay
s_byClientId.Remove(OwnerClientId);
MinimapEntityRegistry.Deregister(this);
+ // Clear local selection if THIS builder was selected. Without this,
+ // SelectionState (and any subscriber holding our reference — HUD,
+ // SelectionVisualizer) keeps pointing at a soon-to-be-destroyed Unity
+ // object and throws MissingReferenceException on the next access.
+ // Local-only state, so safe to touch from any peer.
+ if (SelectionState.Instance != null && SelectionState.Instance.IsSelected(this))
+ SelectionState.Instance.Clear();
+
// Server-only cleanup: despawn any remaining build-site visuals so they
// don't leak when a player disconnects mid-construction.
if (IsServer)
@@ -980,7 +999,30 @@ namespace TD.Gameplay
jobs.RemoveAt(0);
}
- // Cancels the job at index i. Used for cancel-all and any future targeted cancel.
+ ///
+ /// Server-only: cancel the job in this builder's queue whose anchor matches
+ /// . Refunds gold, frees the footprint tiles
+ /// (restoring walkability if the stage was blocking), and despawns the
+ /// build-site visual. Returns true if a matching job was found and
+ /// cancelled. Used by so the
+ /// player can cancel a specific in-progress build from the HUD without
+ /// affecting other queued/constructing builds.
+ ///
+ public bool ServerCancelJobAtAnchor(Vector2Int targetAnchor)
+ {
+ if (!IsServer) return false;
+ for (int i = 0; i < jobs.Count; i++)
+ {
+ if (jobs[i].Anchor == targetAnchor)
+ {
+ ServerCancelJobAt(i);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // Cancels the job at index i. Used for cancel-all and targeted cancel paths.
private void ServerCancelJobAt(int index)
{
if (index < 0 || index >= jobs.Count) return;
diff --git a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs
index d84bf76..7273ce8 100644
--- a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs
+++ b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs
@@ -3,6 +3,7 @@ using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;
using TD.Core;
+using TD.UI;
namespace TD.Gameplay
{
@@ -58,10 +59,11 @@ 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.")]
+ [Tooltip("Physics layer mask for selection trigger colliders. Builder selection " +
+ "colliders AND tower selection colliders both sit on this layer. The " +
+ "raycast walks up the hit hierarchy to find an ISelectable component, so " +
+ "any selectable kind on this layer Just Works. 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 " +
@@ -113,12 +115,19 @@ namespace TD.Gameplay
if (mouse == null) return;
bool isPlacing = IsLocalPlayerPlacing();
+ Vector2 mousePos = mouse.position.ReadValue();
+
+ // UI Toolkit dispatches button click events AFTER Update runs, but raw mouse
+ // input is already true this frame. Without this gate, clicking a HUD button
+ // also fires HandleLeftClickSelection — the raycast misses the builder collider
+ // and deselects before the button's action fires. Same risk on right-click.
+ bool pointerOverHud = HUDController.IsPointerOverInteractiveHud(mousePos);
// Left-click: selection. Suppressed during placement mode (left-click is
- // the placement-submit gesture there).
- if (!isPlacing && mouse.leftButton.wasPressedThisFrame)
+ // the placement-submit gesture there) and when the pointer is over HUD.
+ if (!isPlacing && !pointerOverHud && mouse.leftButton.wasPressedThisFrame)
{
- HandleLeftClickSelection(mouse.position.ReadValue());
+ HandleLeftClickSelection(mousePos);
}
// Escape: clear selection. Allowed during placement mode too — Escape never
@@ -129,11 +138,12 @@ namespace TD.Gameplay
}
// Right-click. Suppressed entirely during placement mode (TowerPlacementController
- // handles right-click as cancel-placement there).
+ // handles right-click as cancel-placement there) and when over HUD.
if (isPlacing) return;
+ if (pointerOverHud) return;
if (!mouse.rightButton.wasPressedThisFrame) return;
- HandleRightClick(mouse.position.ReadValue());
+ HandleRightClick(mousePos);
}
// ----- Selection (left-click) -------------------------------------
@@ -147,17 +157,24 @@ namespace TD.Gameplay
if (cam == null) return;
Ray ray = cam.ScreenPointToRay(new Vector3(screenPos.x, screenPos.y, 0f));
+
+ // Single raycast against the unified Selection layer. Whatever we hit, walk
+ // up its hierarchy to find an ISelectable component (Builder, Tower, or
+ // any future kind). The closest hit wins automatically — no priority logic
+ // needed because builders and towers don't visually overlap in practice.
if (Physics.Raycast(ray, out RaycastHit hit, raycastMaxDistance, selectionLayerMask))
{
- // Walk up the hierarchy to find a Builder component (the selection
- // collider may sit on a child of the Builder's root).
- var hitBuilder = hit.collider.GetComponentInParent();
-
- // Only allow selecting OUR builder. A click on someone else's builder
- // collider clears our selection rather than selecting theirs.
- if (hitBuilder != null && hitBuilder == builder)
+ var hitSelectable = hit.collider.GetComponentInParent();
+ if (hitSelectable != null)
{
- selection.Select(builder);
+ // Don't let players select someone else's builder. Treat that as
+ // "clicked empty" so we clear, rather than steal their selection.
+ if (hitSelectable is Builder b && b != builder)
+ {
+ selection.Clear();
+ return;
+ }
+ selection.Select(hitSelectable);
return;
}
}
diff --git a/Assets/_Project/Scripts/Gameplay/CameraController.cs b/Assets/_Project/Scripts/Gameplay/CameraController.cs
index 8504ec0..c3ecec4 100644
--- a/Assets/_Project/Scripts/Gameplay/CameraController.cs
+++ b/Assets/_Project/Scripts/Gameplay/CameraController.cs
@@ -135,6 +135,56 @@ namespace TD.Gameplay
/// Ends external drag mode. Normal input handling resumes.
public void EndDrag() => isExternalDragActive = false;
+ ///
+ /// Computes where the four screen corners project onto the buildable plane and
+ /// writes them into in the order BL, BR, TR, TL.
+ /// Returns true when every ray hits the plane in front of the camera. When the
+ /// camera angles above the horizon for one or more corners (rare in our pitch
+ /// range), those corner(s) fall back to a far point along the ray and the method
+ /// returns false — the caller may still use the result; the off-plane corners
+ /// just sit far outside the map. Used by the minimap to draw the viewport
+ /// trapezoid (the "what the player sees" rectangle).
+ ///
+ public bool TryGetViewportWorldCorners(Vector3[] cornersOut)
+ {
+ if (cornersOut == null || cornersOut.Length < 4) return false;
+ if (cameraChild == null) return false;
+
+ // Buildable plane is Y = BUILDABLE_PLANE_Y, normal = up.
+ var plane = new Plane(Vector3.up,
+ new Vector3(0f, GridCoordinates.BUILDABLE_PLANE_Y, 0f));
+
+ // Screen-space order: BL, BR, TR, TL (origin at bottom-left, Y up).
+ // Resulting world points form a trapezoid on the buildable plane (camera
+ // is angled, so the far edge — top of screen — projects wider than the
+ // near edge — bottom of screen).
+ float w = Screen.width;
+ float h = Screen.height;
+ cornersOut[0] = ProjectScreenToPlane(new Vector2(0f, 0f), plane, out bool a);
+ cornersOut[1] = ProjectScreenToPlane(new Vector2(w, 0f), plane, out bool b);
+ cornersOut[2] = ProjectScreenToPlane(new Vector2(w, h ), plane, out bool c);
+ cornersOut[3] = ProjectScreenToPlane(new Vector2(0f, h ), plane, out bool d);
+ return a && b && c && d;
+ }
+
+ // Helper: ray from screen point, intersect plane. If it doesn't hit in front of
+ // the camera (rare — only at horizon-or-above pitches), use a far fallback so
+ // the caller still gets a usable value.
+ private Vector3 ProjectScreenToPlane(Vector2 screenPoint, Plane plane, out bool hit)
+ {
+ Ray ray = cameraChild.ScreenPointToRay(new Vector3(screenPoint.x, screenPoint.y, 0f));
+ if (plane.Raycast(ray, out float dist) && dist > 0f)
+ {
+ hit = true;
+ return ray.GetPoint(dist);
+ }
+ hit = false;
+ // Far point along the ray as a graceful fallback. Distance picked large enough
+ // that the resulting UI coord lands well outside the minimap bounds and gets
+ // clipped by overflow:hidden.
+ return ray.GetPoint(1000f);
+ }
+
// ----- Lifecycle --------------------------------------------------
private void Start()
diff --git a/Assets/_Project/Scripts/Gameplay/ISelectable.cs b/Assets/_Project/Scripts/Gameplay/ISelectable.cs
new file mode 100644
index 0000000..9d8423b
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/ISelectable.cs
@@ -0,0 +1,46 @@
+// Assets/_Project/Scripts/Gameplay/ISelectable.cs
+using UnityEngine;
+
+namespace TD.Gameplay
+{
+ ///
+ /// Categorizes the kind of selectable object so HUD can decide which command
+ /// buttons (tower-build vs upgrade/sell vs none) to show without doing
+ /// type-tests against every concrete component.
+ ///
+ public enum SelectableKind
+ {
+ Builder,
+ Tower,
+ Enemy,
+ BuildSite, // tower in queued / constructing / paused / shelved state
+ }
+
+ ///
+ /// Anything the local player can click to select. Implementers expose a
+ /// display name for the HUD portrait, a kind for context-aware UI, and the
+ /// position + size hints the needs to draw
+ /// the selection ring.
+ ///
+ ///
+ /// Selection is a local UI concept — implementers don't need to be
+ /// NetworkBehaviours (though Builder and TowerInstance happen to be).
+ ///
+ public interface ISelectable
+ {
+ string DisplayName { get; }
+ SelectableKind Kind { get; }
+
+ /// Transform whose XZ position the scene-wide selection ring
+ /// follows. The visualizer projects Y to the buildable plane regardless
+ /// of where this transform sits, so implementers can simply return
+ /// this.transform.
+ Transform SelectionTransform { get; }
+
+ /// Half-width of the selection ring in world units. The visualizer
+ /// scales its base 1-unit-diameter ring mesh to 2 * SelectionRadius.
+ /// Computed on every selection change, so implementers may derive it from
+ /// runtime state (e.g., tower footprint, collider bounds).
+ float SelectionRadius { get; }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/ISelectable.cs.meta b/Assets/_Project/Scripts/Gameplay/ISelectable.cs.meta
new file mode 100644
index 0000000..fba7263
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/ISelectable.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 6570f6402d58acb48bd8f0e9202cb1d7
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs b/Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs
deleted file mode 100644
index ea5fad9..0000000
--- a/Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs
+++ /dev/null
@@ -1,126 +0,0 @@
-// Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs
-using UnityEngine;
-
-namespace TD.Gameplay
-{
- ///
- /// Local-only visual indicator for builder selection. Sits as a child of the
- /// builder prefab. Subscribes to
- /// and toggles the visibility of its own renderers (and any descendant
- /// renderers) when the parent builder becomes selected/unselected.
- ///
- ///
- /// Pure local visualization. No NetworkBehaviour. Selection is a
- /// UI concept — every client renders selection state for its own player only.
- /// This component has no networked state.
- ///
- /// Why a separate component. Keeps Builder focused on gameplay
- /// state. The visual prefab structure can change independently
- /// (disc → ring → decal projector → animated effect) without touching gameplay
- /// code. The contract is just "renderers visible when selected."
- ///
- /// Why renderer-toggle, not GameObject.SetActive. Disabling our
- /// own GameObject would prevent OnEnable/Update from running, which would
- /// break the SelectionState subscription lifecycle (we'd never receive the
- /// "you're selected again" event). Toggling the renderers' enabled state
- /// achieves the same visual effect without breaking the event flow.
- ///
- /// Prefab setup. Attach this component to a child GameObject of
- /// the Builder prefab. The child carries (or has descendants carrying) a
- /// flattened cylinder mesh sitting just above the ground plane, with an
- /// unlit transparent green material. The component handles initial visibility
- /// — leave the renderer enabled in the prefab; we'll turn it off in Awake
- /// until selection fires.
- ///
- public class SelectionRingVisual : MonoBehaviour
- {
- // Cached parent builder, resolved in Awake.
- private Builder parentBuilder;
-
- // Cached renderers (this object plus all descendants). Captured once in
- // Awake to avoid per-event GetComponentsInChildren allocations.
- private Renderer[] cachedRenderers;
-
- // Tracks subscription state so OnDisable / OnDestroy unsubscribe correctly,
- // and so Update can retry subscription if SelectionState wasn't ready at OnEnable.
- private bool subscribed;
-
- private void Awake()
- {
- parentBuilder = GetComponentInParent();
- if (parentBuilder == null)
- {
- Debug.LogError("[SelectionRingVisual] No Builder component found on " +
- "self or any parent. Disabling.");
- enabled = false;
- return;
- }
-
- cachedRenderers = GetComponentsInChildren(includeInactive: true);
-
- // Start hidden. If selection fires later (including the auto-select
- // in Builder.OnNetworkSpawn), HandleSelectionChanged will turn the
- // renderers back on.
- SetRenderersVisible(false);
- }
-
- private void OnEnable()
- {
- TrySubscribe();
- }
-
- private void OnDisable()
- {
- Unsubscribe();
- }
-
- private void OnDestroy()
- {
- Unsubscribe();
- }
-
- // Per-frame fallback: if SelectionState wasn't available at OnEnable
- // (scene load ordering), keep trying. Cost is one null-check per frame
- // until subscription succeeds, then nothing.
- private void Update()
- {
- if (!subscribed) TrySubscribe();
- }
-
- private void TrySubscribe()
- {
- if (subscribed) return;
- var sel = SelectionState.Instance;
- if (sel == null) return;
-
- sel.OnSelectionChanged += HandleSelectionChanged;
- subscribed = true;
-
- // Sync to current state — selection may have happened before we subscribed
- // (e.g., Builder.OnNetworkSpawn auto-selecting before this Awake runs).
- HandleSelectionChanged(sel.SelectedBuilder);
- }
-
- private void Unsubscribe()
- {
- if (!subscribed) return;
- var sel = SelectionState.Instance;
- if (sel != null) sel.OnSelectionChanged -= HandleSelectionChanged;
- subscribed = false;
- }
-
- private void HandleSelectionChanged(Builder newSelection)
- {
- SetRenderersVisible(newSelection == parentBuilder);
- }
-
- private void SetRenderersVisible(bool visible)
- {
- if (cachedRenderers == null) return;
- foreach (var rend in cachedRenderers)
- {
- if (rend != null) rend.enabled = visible;
- }
- }
- }
-}
diff --git a/Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs.meta b/Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs.meta
deleted file mode 100644
index 8295845..0000000
--- a/Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: 67895f626233fdc499dffbbfcc225530
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/SelectionState.cs b/Assets/_Project/Scripts/Gameplay/SelectionState.cs
index c7f8490..1dab839 100644
--- a/Assets/_Project/Scripts/Gameplay/SelectionState.cs
+++ b/Assets/_Project/Scripts/Gameplay/SelectionState.cs
@@ -4,35 +4,28 @@ using UnityEngine;
namespace TD.Gameplay
{
///
- /// Minimal scene-local selection state. Holds a reference to whichever
- /// the local player has selected, fires an event when
- /// the selection changes, and exposes a query for "is this builder selected
- /// right now?".
+ /// Scene-local selection state. Holds a single (the
+ /// builder, a tower, or any future selectable type) and fires
+ /// when it changes.
///
///
- /// Scope. D2 only needs this for: "Escape with builder selected
- /// cancels its queue", and "right-click with builder selected and queue
- /// established cancels the queue (the right-click is consumed by selection
- /// instead of issuing a move)". A full selection system that supports world
- /// highlighting, multi-select, and HUD context panels is deferred to the HUD
- /// path.
- ///
- /// Local-only. Selection is a UI concept, not a gameplay one.
- /// Other clients have no business knowing whether you've selected your own
- /// builder. The component is a plain MonoBehaviour and lives in the scene
- /// alongside other client-side controllers.
+ /// Local-only. Selection is a UI concept — other clients have no
+ /// business knowing whether you've selected something. The component is a plain
+ /// MonoBehaviour and lives in the scene alongside other client-side controllers.
///
/// Singleton. One per scene, accessed via .
- /// The selection consumer (BuilderInputController) and the selection driver
- /// (mouse-click raycast) both go through this single source of truth.
+ /// Selection drivers (input controller, minimap) and selection consumers
+ /// (HUD, selection-ring visuals) all go through this single source of truth.
+ ///
+ /// Builder convenience. returns the
+ /// current selection cast to Builder (or null if non-builder). Lets the input
+ /// controller and minimap keep the "is the local builder selected?" check
+ /// short without re-type-testing.
///
public class SelectionState : MonoBehaviour
{
// ----- Singleton --------------------------------------------------
- ///
- /// The active SelectionState. Null before the scene loads. Always null-check.
- ///
public static SelectionState Instance { get; private set; }
private void Awake()
@@ -52,16 +45,22 @@ namespace TD.Gameplay
// ----- Selection state --------------------------------------------
- private Builder selectedBuilder;
+ private ISelectable selected;
- /// The currently selected builder, or null if nothing is selected.
- public Builder SelectedBuilder => selectedBuilder;
+ /// The currently selected object, or null if nothing is selected.
+ public ISelectable SelectedObject => selected;
- /// True if any builder is currently selected.
- public bool HasSelection => selectedBuilder != null;
+ /// Convenience: the selected object if it's a Builder, else null.
+ public Builder SelectedBuilder => selected as Builder;
- /// True if is the currently selected builder.
- public bool IsSelected(Builder b) => b != null && selectedBuilder == b;
+ /// True if any object is currently selected.
+ public bool HasSelection => selected != null;
+
+ /// True if is the currently selected object.
+ public bool IsSelected(ISelectable s) => s != null && (object)selected == (object)s;
+
+ /// Builder overload — same semantics as .
+ public bool IsSelected(Builder b) => b != null && (object)selected == (object)b;
// ----- Events -----------------------------------------------------
@@ -69,22 +68,22 @@ namespace TD.Gameplay
/// Fired when the selection changes. Argument is the new selection (may be null).
/// Subscribe to drive selection-aware UI: highlights, context panels, hotkey hints.
///
- public event System.Action OnSelectionChanged;
+ public event System.Action OnSelectionChanged;
// ----- Mutators ---------------------------------------------------
///
- /// Sets the selected builder. Pass null to clear.
+ /// Sets the selected object. Pass null to clear.
/// Fires only if the selection actually changes.
///
- public void Select(Builder builder)
+ public void Select(ISelectable s)
{
- if (selectedBuilder == builder) return;
- selectedBuilder = builder;
- OnSelectionChanged?.Invoke(selectedBuilder);
+ if ((object)selected == (object)s) return;
+ selected = s;
+ OnSelectionChanged?.Invoke(selected);
}
/// Clears the selection. Equivalent to Select(null).
- public void Clear() => Select(null);
+ public void Clear() => Select((ISelectable)null);
}
}
diff --git a/Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs b/Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs
new file mode 100644
index 0000000..d60864b
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs
@@ -0,0 +1,275 @@
+// Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs
+using UnityEngine;
+using TD.Core;
+
+namespace TD.Gameplay
+{
+ ///
+ /// Scene-wide selection ring renderer. One per scene. Listens to
+ /// and drives a single pooled
+ /// ring GameObject — sized from the new selection's ,
+ /// positioned each from .
+ ///
+ ///
+ /// Why scene-wide instead of per-prefab. A per-prefab selection ring
+ /// child requires authoring every new selectable (towers, enemies, future races)
+ /// with the right mesh, material, scale, and child script. That doesn't scale.
+ /// A single scene-wide visualizer reuses one prefab, derives size and position
+ /// from the ISelectable hints, and adding a new selectable type costs ~2 lines
+ /// of interface implementation rather than a whole prefab subtree.
+ ///
+ /// Color isolation. Because the ring is instantiated as a child of
+ /// THIS visualizer (not of the selectable), TowerInstance.ApplyOwnerColor's
+ /// MaterialPropertyBlock writes can never reach it. The ring's authored material
+ /// (green) shows through regardless of the selectable's own tinting.
+ ///
+ /// Single instance, recycled. One ring GameObject is instantiated on
+ /// the first selection and reused for every subsequent one (SetActive toggles
+ /// visibility). Avoids the GC churn of Instantiate/Destroy on every click.
+ /// When multi-select lands, pool to N instances here.
+ ///
+ /// Prefab requirements. Assign a prefab whose mesh is authored at
+ /// 1-unit DIAMETER (i.e., radius = 0.5). The visualizer multiplies localScale's
+ /// X and Z by 2 * SelectionRadius so the rendered ring matches the
+ /// selectable's intended size. Y scale is preserved so the disc stays flat.
+ ///
+ public class SelectionVisualizer : MonoBehaviour
+ {
+ [Tooltip("Flat ground-decal disc/ring prefab with the green selection " +
+ "material. Mesh must be authored at 1-unit base diameter — the " +
+ "visualizer scales by 2 * ISelectable.SelectionRadius to match " +
+ "the selectable's intended size.")]
+ [SerializeField] private GameObject selectionRingPrefab;
+
+ [Tooltip("Offset along the projected surface's normal to keep the ring " +
+ "from z-fighting with whatever surface it lands on.")]
+ [SerializeField] private float yOffset = 0.02f;
+
+ [Tooltip("Physics layers the selection ring projects onto. Typically " +
+ "BuildablePlane + TerrainGeometry + Selection (the last so the " +
+ "ring lands on top of a tower the selectable is standing on). " +
+ "The visualizer automatically filters out hits on the selectable's " +
+ "own hierarchy. Default ~0 = everything.")]
+ [SerializeField] private LayerMask projectionLayerMask = ~0;
+
+ // How far above the selectable we start the downward raycast. Must be tall
+ // enough that the origin is OUTSIDE any collider the selectable might be
+ // sitting on or inside; 50 covers any reasonable tower/terrain stack.
+ private const float RaycastOriginUpOffset = 50f;
+
+ // How far down we cast PAST the selectable's position. 100 covers any
+ // map's terrain depth; deeper hits we don't care about anyway.
+ private const float MaxProjectionDistance = 100f;
+
+ // Reused buffer for downward raycasts so the per-frame projection is GC-free.
+ // 8 is plenty: typical case has 1-3 hits (selectable's own collider, a
+ // tower below, the buildable plane).
+ private static readonly RaycastHit[] s_raycastBuffer = new RaycastHit[8];
+
+ // The single pooled ring instance. Created lazily on the first non-null
+ // selection and reused thereafter. Toggled via SetActive on selection
+ // change so we don't churn through Instantiate/Destroy.
+ private GameObject ringInstance;
+
+ // Cached current selection so LateUpdate can keep position in sync as the
+ // selectable moves. Cleared (and ring hidden) when selection is null.
+ private ISelectable currentSelection;
+
+ // Standard deferred-subscribe pattern — see HUDController. SelectionState.Awake
+ // may not have run by the time our OnEnable fires; Update retries until it has.
+ private bool subscribed;
+
+ private void OnEnable() => TrySubscribe();
+ private void OnDisable() => Unsubscribe();
+ private void OnDestroy() => Unsubscribe();
+
+ private void Update()
+ {
+ if (!subscribed) TrySubscribe();
+ }
+
+ // Position sync runs in LateUpdate so any earlier-frame movement (server
+ // tick on the builder, NetworkTransform interpolation, etc.) is already
+ // applied before we read it. Otherwise the ring lags one frame.
+ private void LateUpdate()
+ {
+ if (currentSelection == null || ringInstance == null) return;
+
+ // The interface reference doesn't go through Unity's overloaded == null,
+ // so a destroyed Unity object still reads as non-null on the C# side
+ // and throws MissingReferenceException when we access any inherited
+ // member (e.g., transform). Detect destruction explicitly and treat
+ // it as a deselect. This is a backstop — Builder/TowerInstance.OnNetworkDespawn
+ // also call SelectionState.Clear so we normally see the event first.
+ if (IsDestroyedUnityObject(currentSelection))
+ {
+ currentSelection = null;
+ ringInstance.SetActive(false);
+ return;
+ }
+
+ var t = currentSelection.SelectionTransform;
+ if (t == null)
+ {
+ // SelectionTransform legitimately returned null (the implementer
+ // chose to suppress its own visual). Hide and bail.
+ ringInstance.SetActive(false);
+ return;
+ }
+
+ ProjectRingDown(t);
+ }
+
+ // True iff the selectable is a Unity object that has been destroyed. Plain
+ // `s == null` against the interface ref doesn't invoke Unity's overloaded
+ // equality — it does C# reference equality and returns false for destroyed
+ // objects. Cast first; then Unity's overload kicks in.
+ private static bool IsDestroyedUnityObject(ISelectable s)
+ {
+ return s is UnityEngine.Object uo && uo == null;
+ }
+
+ ///
+ /// Raycasts straight down from above the selectable and places the ring
+ /// at the first hit that isn't on the selectable's own hierarchy. The
+ /// ring's up vector is aligned to the surface normal so it sits flat on
+ /// slanted terrain or on top of towers below the selectable. When no
+ /// surface is hit (rare — selectable floating in space), falls back to
+ /// the buildable plane Y so the ring stays visible.
+ ///
+ private void ProjectRingDown(Transform selectableTransform)
+ {
+ Vector3 origin = selectableTransform.position
+ + Vector3.up * RaycastOriginUpOffset;
+ float maxDist = RaycastOriginUpOffset + MaxProjectionDistance;
+
+ int count = Physics.RaycastNonAlloc(
+ origin, Vector3.down, s_raycastBuffer, maxDist,
+ projectionLayerMask, QueryTriggerInteraction.Collide);
+
+ if (count > 0)
+ {
+ // RaycastNonAlloc doesn't promise order; sort by distance ascending
+ // so the first non-self hit is the closest one BELOW the origin
+ // (and therefore the topmost surface beneath the selectable).
+ SortBufferByDistance(count);
+
+ Transform selfRoot = selectableTransform.root;
+ for (int i = 0; i < count; i++)
+ {
+ var hit = s_raycastBuffer[i];
+ // Skip hits on the selectable itself (its selection collider,
+ // its visual collider if any). Hierarchy comparison via
+ // transform.root catches all children of the same root.
+ if (hit.collider.transform.root == selfRoot) continue;
+
+ ringInstance.transform.position = hit.point + hit.normal * yOffset;
+ ringInstance.transform.rotation =
+ Quaternion.FromToRotation(Vector3.up, hit.normal);
+ return;
+ }
+ }
+
+ // Fallback: no usable surface found. Park on the buildable plane
+ // directly under the selectable so the ring stays visible.
+ Vector3 fallback = selectableTransform.position;
+ fallback.y = GridCoordinates.BUILDABLE_PLANE_Y + yOffset;
+ ringInstance.transform.position = fallback;
+ ringInstance.transform.rotation = Quaternion.identity;
+ }
+
+ // Insertion sort — count is bounded by buffer size (8). Typical case has
+ // 1–3 hits. No GC, no allocation, no LINQ.
+ private static void SortBufferByDistance(int count)
+ {
+ for (int i = 1; i < count; i++)
+ {
+ var current = s_raycastBuffer[i];
+ int j = i - 1;
+ while (j >= 0 && s_raycastBuffer[j].distance > current.distance)
+ {
+ s_raycastBuffer[j + 1] = s_raycastBuffer[j];
+ j--;
+ }
+ s_raycastBuffer[j + 1] = current;
+ }
+ }
+
+ // ----- Subscription -----------------------------------------------
+
+ private void TrySubscribe()
+ {
+ if (subscribed) return;
+ var sel = SelectionState.Instance;
+ if (sel == null) return;
+
+ sel.OnSelectionChanged += HandleSelectionChanged;
+ subscribed = true;
+
+ // Pick up whatever was selected before we managed to subscribe.
+ HandleSelectionChanged(sel.SelectedObject);
+ }
+
+ private void Unsubscribe()
+ {
+ if (!subscribed) return;
+ var sel = SelectionState.Instance;
+ if (sel != null) sel.OnSelectionChanged -= HandleSelectionChanged;
+ subscribed = false;
+ }
+
+ // ----- Selection handling -----------------------------------------
+
+ private void HandleSelectionChanged(ISelectable newSelection)
+ {
+ // Treat a destroyed Unity object the same as null. Same reasoning as
+ // the LateUpdate guard — interface refs don't trigger Unity's == null.
+ if (IsDestroyedUnityObject(newSelection)) newSelection = null;
+
+ currentSelection = newSelection;
+
+ if (newSelection == null)
+ {
+ if (ringInstance != null) ringInstance.SetActive(false);
+ return;
+ }
+
+ if (!EnsureRingInstance()) return;
+
+ // Scale the ring to 2 * radius (mesh is authored at 1-unit diameter).
+ // Y is preserved so the disc's flatness from the prefab is kept.
+ float diameter = newSelection.SelectionRadius * 2f;
+ Vector3 ls = ringInstance.transform.localScale;
+ ringInstance.transform.localScale = new Vector3(diameter, ls.y, diameter);
+
+ // Position will be re-projected in LateUpdate, but project now too so
+ // the first frame doesn't flash at the previous selection's position
+ // (or at the origin when the instance is first created).
+ var t = newSelection.SelectionTransform;
+ if (t != null) ProjectRingDown(t);
+
+ ringInstance.SetActive(true);
+ }
+
+ // Lazy-initializes the ring instance on first use. Returns true if the
+ // instance is usable; false (and logs) if the prefab field is empty.
+ private bool EnsureRingInstance()
+ {
+ if (ringInstance != null) return true;
+ if (selectionRingPrefab == null)
+ {
+ Debug.LogError("[SelectionVisualizer] selectionRingPrefab is not " +
+ "assigned. Selection rings will not render.");
+ return false;
+ }
+
+ // Parent under this visualizer so the ring travels with us in the
+ // hierarchy and is auto-destroyed when the scene unloads. It still
+ // moves freely in world space — we drive its position absolutely.
+ ringInstance = Instantiate(selectionRingPrefab, transform);
+ ringInstance.name = "ActiveSelectionRing";
+ ringInstance.SetActive(false); // hidden until HandleSelectionChanged turns it on
+ return true;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs.meta b/Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs.meta
new file mode 100644
index 0000000..0d09ddb
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e28d10549c8d2ac4585eded3ad8d2198
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs
index 9a47002..9e682b1 100644
--- a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs
+++ b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs
@@ -43,8 +43,18 @@ namespace TD.Gameplay
/// TowerCombat component added to the same prefab.
///
[RequireComponent(typeof(NetworkObject))]
- public class TowerInstance : NetworkBehaviour, IMinimapEntity
+ public class TowerInstance : NetworkBehaviour, IMinimapEntity, ISelectable
{
+ // ----- Inspector --------------------------------------------------
+
+ [Header("Visuals")]
+ [Tooltip("Mesh renderers tinted with the owner's player color. " +
+ "Drag in only the tower body's renderers — exclude anything " +
+ "that has its own color rules (selection rings, range " +
+ "indicators, FX). If left empty, the tower is NOT tinted " +
+ "and the prefab's baked materials show through.")]
+ [SerializeField] private MeshRenderer[] tintedRenderers;
+
// ----- Networked state ------------------------------------------------
// The name of the TowerDefinition asset for this tower. Replicated so all
@@ -107,6 +117,40 @@ namespace TD.Gameplay
/// The footprint anchor tile (SW corner, world-tile coords).
public Vector2Int AnchorTile => anchorTile.Value;
+ // ----- ISelectable ----------------------------------------------------
+
+ // Absolute world-unit margin that the selection ring extends beyond the
+ // tower's footprint edges. Tuned for visibility — the tower body sits ON
+ // the ring at ground level, so only the area outside the footprint is
+ // actually rendered. Too small (was 0.15) and the ring is invisible under
+ // anything taller than a paving stone. 0.5 gives a half-tile-wide visible
+ // band around the tower at any footprint size.
+ private const float SelectionRingPadding = 0.5f;
+
+ /// Display name shown in the HUD portrait when this tower is selected.
+ public string DisplayName =>
+ resolvedDefinition != null ? resolvedDefinition.DisplayName : "Tower";
+
+ public SelectableKind Kind => SelectableKind.Tower;
+
+ public Transform SelectionTransform => transform;
+
+ // Ring radius derived from footprint: max axis * 0.5 * tile size, plus a
+ // small padding so the ring is visible outside the tower's edges. Falls
+ // back to 1×1 if the definition hasn't resolved yet (transient, harmless —
+ // the HUD won't allow selection until OnNetworkSpawn finishes anyway).
+ public float SelectionRadius
+ {
+ get
+ {
+ Vector2Int fp = resolvedDefinition != null
+ ? resolvedDefinition.FootprintSize
+ : new Vector2Int(1, 1);
+ return Mathf.Max(fp.x, fp.y) * 0.5f * GridCoordinates.TILE_SIZE
+ + SelectionRingPadding;
+ }
+ }
+
// ----- Server-only initialization -------------------------------------
///
@@ -171,6 +215,20 @@ namespace TD.Gameplay
// Register for minimap rendering.
MinimapEntityRegistry.Register(this);
+ // Selection auto-transfer: if a BuildSiteVisual at our anchor is the
+ // active local selection, the player was watching this tower complete —
+ // hand selection off to the new TowerInstance so the HUD/visualizer
+ // transition smoothly. Server's completion order (spawn THEN despawn)
+ // means we get here BEFORE the BuildSiteVisual's OnNetworkDespawn,
+ // so the old reference is still valid and selected.
+ var selState = SelectionState.Instance;
+ if (selState != null
+ && selState.SelectedObject is BuildSiteVisual bsv
+ && bsv.Anchor == anchorTile.Value)
+ {
+ selState.Select(this);
+ }
+
if (resolvedDefinition != null)
{
Debug.Log($"[TowerInstance] Spawned '{resolvedDefinition.DisplayName}' " +
@@ -186,6 +244,13 @@ namespace TD.Gameplay
StampFootprint(walkable: true, occupied: false);
MinimapEntityRegistry.Deregister(this);
+
+ // Clear local selection if THIS tower was selected. Without this,
+ // SelectionState (and any subscriber holding our reference — HUD,
+ // SelectionVisualizer) keeps pointing at a soon-to-be-destroyed Unity
+ // object and throws MissingReferenceException on the next access.
+ if (SelectionState.Instance != null && SelectionState.Instance.IsSelected(this))
+ SelectionState.Instance.Clear();
}
// ----- IMinimapEntity -------------------------------------------------
@@ -285,19 +350,20 @@ namespace TD.Gameplay
// MaterialPropertyBlock sets per-renderer properties without allocating
// a new Material object. Safe to reuse across calls on the same instance.
- // All Unity standard/URP shaders expose _Color or _BaseColor, so no shader changes needed.
+ // All Unity standard/URP shaders expose _Color or _BaseColor, so writing
+ // both lets the tint apply regardless of which shader the prefab uses.
colorPropertyBlock ??= new MaterialPropertyBlock();
colorPropertyBlock.SetColor(ColorPropertyId, ownerColor);
colorPropertyBlock.SetColor(BaseColorPropertyId, ownerColor);
- var renderers = GetComponentsInChildren();
- foreach (var rend in renderers)
- rend.SetPropertyBlock(colorPropertyBlock);
-
- if (renderers.Length == 0)
+ // Tint only the renderers explicitly listed in the inspector. Avoids
+ // accidentally re-coloring decorative children, FX, etc. (Mirrors
+ // Builder.tintedRenderers — same rationale.)
+ if (tintedRenderers == null) return;
+ foreach (var rend in tintedRenderers)
{
- Debug.LogWarning($"[TowerInstance] NetworkObject {NetworkObjectId}: " +
- $"No MeshRenderers found for owner color tinting.");
+ if (rend == null) continue;
+ rend.SetPropertyBlock(colorPropertyBlock);
}
}
}
diff --git a/Assets/_Project/Scripts/UI/BuildProgressBar.cs b/Assets/_Project/Scripts/UI/BuildProgressBar.cs
deleted file mode 100644
index d0caefe..0000000
--- a/Assets/_Project/Scripts/UI/BuildProgressBar.cs
+++ /dev/null
@@ -1,90 +0,0 @@
-// Assets/_Project/Scripts/UI/BuildProgressBar.cs
-using UnityEngine;
-using UnityEngine.UI;
-using TD.Gameplay;
-
-namespace TD.UI
-{
- // Local (non-networked) world-space progress bar that tracks a BuildSiteVisual.
- // Visible while Constructing (green) or Paused (yellow). Hidden while Queued.
- // Billboards to face Camera.main each LateUpdate.
- // Destroyed automatically when its parent BuildSiteVisual is despawned.
- public class BuildProgressBar : MonoBehaviour
- {
- private BuildSiteVisual source;
- private GameObject canvasGO;
- private Image fillImage;
-
- private const float BarWorldWidth = 1.8f;
- private const float BarWorldHeight = 0.15f;
- private const float HeightAboveSite = 1.5f;
-
- private static readonly Color ColorConstructing = new Color(0.15f, 0.85f, 0.15f, 1f);
- private static readonly Color ColorPaused = new Color(0.90f, 0.75f, 0.10f, 1f);
-
- public void Initialize(BuildSiteVisual visual)
- {
- source = visual;
- BuildHierarchy();
- }
-
- private void BuildHierarchy()
- {
- // World-space Canvas — 100 canvas units = 1 world unit via localScale 0.01.
- canvasGO = new GameObject("Canvas");
- canvasGO.transform.SetParent(transform, false);
-
- var canvas = canvasGO.AddComponent