From c100db52e584311662df63de0791248082390bf8 Mon Sep 17 00:00:00 2001 From: Matt F Date: Mon, 11 May 2026 23:57:35 -0700 Subject: [PATCH] Major updates to the HUD and selectable objects --- .../Prefabs/Builders/Builder_Basic.prefab | 104 ---- Assets/_Project/Prefabs/SelectionRing.prefab | 92 ++++ .../Prefabs/SelectionRing.prefab.meta | 7 + .../Prefabs/Towers/BuildSiteVisual.prefab | 80 +++- .../Prefabs/Towers/Tower_Basic.prefab | 62 ++- Assets/_Project/Scenes/Levels/Main.unity | 50 ++ .../Scripts/Gameplay/BuildSiteVisual.cs | 124 ++++- Assets/_Project/Scripts/Gameplay/Builder.cs | 46 +- .../Gameplay/BuilderInputController.cs | 51 +- .../Scripts/Gameplay/CameraController.cs | 50 ++ .../_Project/Scripts/Gameplay/ISelectable.cs | 46 ++ .../Scripts/Gameplay/ISelectable.cs.meta | 2 + .../Scripts/Gameplay/SelectionRingVisual.cs | 126 ----- .../Gameplay/SelectionRingVisual.cs.meta | 2 - .../Scripts/Gameplay/SelectionState.cs | 67 ++- .../Scripts/Gameplay/SelectionVisualizer.cs | 275 +++++++++++ .../Gameplay/SelectionVisualizer.cs.meta | 2 + .../Scripts/Gameplay/TowerInstance.cs | 84 +++- .../_Project/Scripts/UI/BuildProgressBar.cs | 90 ---- Assets/_Project/Scripts/UI/HUDController.cs | 444 ++++++++++++++---- .../Scripts/UI/Minimap/MinimapView.cs | 50 +- Assets/_Project/UI/HUD.uss | 331 ++++++++----- Assets/_Project/UI/HUD.uxml | 44 +- 23 files changed, 1615 insertions(+), 614 deletions(-) create mode 100644 Assets/_Project/Prefabs/SelectionRing.prefab create mode 100644 Assets/_Project/Prefabs/SelectionRing.prefab.meta create mode 100644 Assets/_Project/Scripts/Gameplay/ISelectable.cs create mode 100644 Assets/_Project/Scripts/Gameplay/ISelectable.cs.meta delete mode 100644 Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs delete mode 100644 Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs create mode 100644 Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs.meta delete mode 100644 Assets/_Project/Scripts/UI/BuildProgressBar.cs 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(); - canvas.renderMode = RenderMode.WorldSpace; - canvas.sortingOrder = 10; - - var rt = (RectTransform)canvasGO.transform; - rt.sizeDelta = new Vector2(BarWorldWidth * 100f, BarWorldHeight * 100f); - rt.localPosition = new Vector3(0f, HeightAboveSite, 0f); - rt.localScale = Vector3.one * 0.01f; - - // Background - var bgGO = new GameObject("Background"); - bgGO.transform.SetParent(canvasGO.transform, false); - var bgImg = bgGO.AddComponent(); - bgImg.color = new Color(0.05f, 0.05f, 0.05f, 0.85f); - Stretch((RectTransform)bgGO.transform); - - // Fill (rendered on top; fillAmount drives visible width) - var fillGO = new GameObject("Fill"); - fillGO.transform.SetParent(canvasGO.transform, false); - fillImage = fillGO.AddComponent(); - fillImage.color = ColorConstructing; - fillImage.type = Image.Type.Filled; - fillImage.fillMethod = Image.FillMethod.Horizontal; - fillImage.fillOrigin = 0; // left to right - fillImage.fillAmount = 0f; - Stretch((RectTransform)fillGO.transform); - } - - private static void Stretch(RectTransform rt) - { - rt.anchorMin = Vector2.zero; - rt.anchorMax = Vector2.one; - rt.offsetMin = Vector2.zero; - rt.offsetMax = Vector2.zero; - } - - private void LateUpdate() - { - if (source == null) return; - - var stage = source.CurrentStage; - bool show = stage == BuildStage.Constructing || stage == BuildStage.Paused; - canvasGO.SetActive(show); - if (!show) return; - - fillImage.color = stage == BuildStage.Paused ? ColorPaused : ColorConstructing; - fillImage.fillAmount = source.ComputeProgressNormalized(); - - var cam = Camera.main; - if (cam != null) - canvasGO.transform.rotation = cam.transform.rotation; - } - } -} diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs index b89bba2..a433efe 100644 --- a/Assets/_Project/Scripts/UI/HUDController.cs +++ b/Assets/_Project/Scripts/UI/HUDController.cs @@ -1,6 +1,8 @@ // Assets/_Project/Scripts/UI/HUDController.cs using System.Collections; +using System.Collections.Generic; using UnityEngine; +using UnityEngine.InputSystem; using UnityEngine.UIElements; using TD.Gameplay; using TD.Towers; @@ -37,7 +39,13 @@ namespace TD.UI private Label goldLabel; private Label waveLabel; private Label portraitName; + private Label levelLabel; + private VisualElement statLines; private VisualElement commandGrid; + private VisualElement actionFrame; // hidden via display:none when no actions are available + private VisualElement buildProgressContainer; // info-panel sub-view, shown for BuildSiteVisual selections + private VisualElement buildProgressFill; // width driven each frame from progress + private Label buildProgressPercent; private Label ttTitle; private Label ttDesc; private Label ttStats; @@ -47,11 +55,38 @@ namespace TD.UI // ----- State ------------------------------------------------------ private Coroutine rejectionFadeCoroutine; - private bool gridPopulated; + private bool placementManagerReady; // true once TowerPlacementManager.Instance is non-null private bool uiInitialized; + private bool selectionSubscribed; // true once we've successfully hooked SelectionState.OnSelectionChanged private MinimapView minimapView; private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us + // ----- Hotkeys ---------------------------------------------------- + // + // Per-slot hotkey layout matching the WC3 / Wintermaul Reforged convention. + // Slot index 0..14 corresponds to row-major position in the 5×3 action grid. + // To rebind, edit this array. (Per-tower hotkeys could live on TowerDefinition + // later; for now the position-based layout is enough and predictable.) + private static readonly Key[] HotkeyLayout = + { + Key.Q, Key.W, Key.E, Key.R, Key.T, + Key.A, Key.S, Key.D, Key.F, Key.G, + Key.Z, Key.X, Key.C, Key.V, Key.B, + }; + + // Active hotkey bindings — rebuilt on every selection change inside + // PopulateGridForSelection. HandleHotkeys reads this every Update. + private readonly List hotkeyBindings = new List(); + + private readonly struct HotkeyBinding + { + public readonly Key Key; + public readonly VisualElement Button; // for enabledSelf gating + public readonly System.Action Action; + public HotkeyBinding(Key k, VisualElement b, System.Action a) + { Key = k; Button = b; Action = a; } + } + // ----- Static hit-test probe -------------------------------------- // Set when InitializeUI succeeds; cleared on OnDestroy. Non-UI systems (camera, @@ -104,9 +139,16 @@ namespace TD.UI // Awake() calls. Accessing rootVisualElement in Awake() is too early. // Start() is safe because all OnEnable() calls have completed by then. InitializeUI(); - TryPopulateCommandGrid(); - // Seed portrait/grid state in case the builder already auto-selected before Start. - HandleSelectionChanged(SelectionState.Instance?.SelectedBuilder); + TryReadyPlacementManager(); + + // Subscription fallback: if OnEnable couldn't subscribe (SelectionState.Awake + // hadn't run yet), Start() is guaranteed to be after all Awake calls in the + // scene. Retry here. Without this fallback, the HUD silently misses every + // selection event for the rest of the session. + TrySubscribeSelection(); + + // Seed portrait/grid in case the builder already auto-selected before Start. + HandleSelectionChanged(SelectionState.Instance?.SelectedObject); } private void InitializeUI() @@ -128,28 +170,34 @@ namespace TD.UI // Cache element references — log a warning for any that are missing // so UXML/USS mismatches surface immediately. - goldLabel = Require