Major updates to the HUD and selectable objects
This commit is contained in:
parent
5bc757b385
commit
c100db52e5
23 changed files with 1615 additions and 614 deletions
|
|
@ -36,7 +36,6 @@ Transform:
|
||||||
m_Children:
|
m_Children:
|
||||||
- {fileID: 2153758330548988791}
|
- {fileID: 2153758330548988791}
|
||||||
- {fileID: 5176306400449771234}
|
- {fileID: 5176306400449771234}
|
||||||
- {fileID: 6565619444702228235}
|
|
||||||
- {fileID: 6214765925043807804}
|
- {fileID: 6214765925043807804}
|
||||||
m_Father: {fileID: 0}
|
m_Father: {fileID: 0}
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
|
|
@ -166,109 +165,6 @@ MonoBehaviour:
|
||||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuildRangeIndicator
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.BuildRangeIndicator
|
||||||
projector: {fileID: 2082893476690950776}
|
projector: {fileID: 2082893476690950776}
|
||||||
projectionDepth: 50
|
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
|
--- !u!1 &4357234114074764669
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
|
||||||
92
Assets/_Project/Prefabs/SelectionRing.prefab
Normal file
92
Assets/_Project/Prefabs/SelectionRing.prefab
Normal file
|
|
@ -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}
|
||||||
7
Assets/_Project/Prefabs/SelectionRing.prefab.meta
Normal file
7
Assets/_Project/Prefabs/SelectionRing.prefab.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 74757e379bac0e444aabab5e388e17c6
|
||||||
|
PrefabImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -112,6 +112,59 @@ BoxCollider:
|
||||||
serializedVersion: 3
|
serializedVersion: 3
|
||||||
m_Size: {x: 1, y: 1, z: 1}
|
m_Size: {x: 1, y: 1, z: 1}
|
||||||
m_Center: {x: 0, y: 0, z: 0}
|
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
|
--- !u!1 &5570322131082283203
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -154,7 +207,7 @@ BoxCollider:
|
||||||
m_Material: {fileID: 0}
|
m_Material: {fileID: 0}
|
||||||
m_IncludeLayers:
|
m_IncludeLayers:
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_Bits: 0
|
m_Bits: 256
|
||||||
m_ExcludeLayers:
|
m_ExcludeLayers:
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_Bits: 0
|
m_Bits: 0
|
||||||
|
|
@ -176,6 +229,7 @@ GameObject:
|
||||||
- component: {fileID: 1531733800731892084}
|
- component: {fileID: 1531733800731892084}
|
||||||
- component: {fileID: 9075933591925717035}
|
- component: {fileID: 9075933591925717035}
|
||||||
- component: {fileID: 7845454079079718139}
|
- component: {fileID: 7845454079079718139}
|
||||||
|
- component: {fileID: 6259437393614329336}
|
||||||
m_Layer: 0
|
m_Layer: 0
|
||||||
m_Name: BuildSiteVisual
|
m_Name: BuildSiteVisual
|
||||||
m_TagString: Untagged
|
m_TagString: Untagged
|
||||||
|
|
@ -198,6 +252,7 @@ Transform:
|
||||||
m_Children:
|
m_Children:
|
||||||
- {fileID: 2082013748313019226}
|
- {fileID: 2082013748313019226}
|
||||||
- {fileID: 1059634071631389929}
|
- {fileID: 1059634071631389929}
|
||||||
|
- {fileID: 3705223059275757242}
|
||||||
m_Father: {fileID: 0}
|
m_Father: {fileID: 0}
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
--- !u!114 &9075933591925717035
|
--- !u!114 &9075933591925717035
|
||||||
|
|
@ -212,7 +267,7 @@ MonoBehaviour:
|
||||||
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
|
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
|
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
|
||||||
GlobalObjectIdHash: 3616792119
|
GlobalObjectIdHash: 800191742
|
||||||
InScenePlacedSourceGlobalObjectIdHash: 0
|
InScenePlacedSourceGlobalObjectIdHash: 0
|
||||||
DeferredDespawnTick: 0
|
DeferredDespawnTick: 0
|
||||||
Ownership: 1
|
Ownership: 1
|
||||||
|
|
@ -245,3 +300,24 @@ MonoBehaviour:
|
||||||
pausedMaterial: {fileID: 0}
|
pausedMaterial: {fileID: 0}
|
||||||
stageCount: 4
|
stageCount: 4
|
||||||
queuedYScale: 0.15
|
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}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,58 @@
|
||||||
%YAML 1.1
|
%YAML 1.1
|
||||||
%TAG !u! tag:unity3d.com,2011:
|
%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
|
--- !u!1 &6482414459531823157
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -30,10 +83,11 @@ Transform:
|
||||||
m_GameObject: {fileID: 6482414459531823157}
|
m_GameObject: {fileID: 6482414459531823157}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
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_LocalScale: {x: 2, y: 1, z: 2}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children: []
|
m_Children:
|
||||||
|
- {fileID: 5753294230586596248}
|
||||||
m_Father: {fileID: 0}
|
m_Father: {fileID: 0}
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
--- !u!33 &6869333096494165105
|
--- !u!33 &6869333096494165105
|
||||||
|
|
@ -126,7 +180,7 @@ MonoBehaviour:
|
||||||
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
|
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
|
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
|
||||||
GlobalObjectIdHash: 1472871091
|
GlobalObjectIdHash: 2767478135
|
||||||
InScenePlacedSourceGlobalObjectIdHash: 0
|
InScenePlacedSourceGlobalObjectIdHash: 0
|
||||||
DeferredDespawnTick: 0
|
DeferredDespawnTick: 0
|
||||||
Ownership: 1
|
Ownership: 1
|
||||||
|
|
@ -152,3 +206,5 @@ MonoBehaviour:
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.TowerInstance
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.TowerInstance
|
||||||
ShowTopMostFoldoutHeaderGroup: 1
|
ShowTopMostFoldoutHeaderGroup: 1
|
||||||
|
tintedRenderers:
|
||||||
|
- {fileID: 4028055828417179692}
|
||||||
|
|
|
||||||
|
|
@ -1184,6 +1184,55 @@ BoxCollider:
|
||||||
serializedVersion: 3
|
serializedVersion: 3
|
||||||
m_Size: {x: 7, y: 1, z: 7}
|
m_Size: {x: 7, y: 1, z: 7}
|
||||||
m_Center: {x: 0, y: 0, z: 0}
|
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
|
--- !u!1 &1222526236
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -2181,3 +2230,4 @@ SceneRoots:
|
||||||
- {fileID: 611926976}
|
- {fileID: 611926976}
|
||||||
- {fileID: 1222526238}
|
- {fileID: 1222526238}
|
||||||
- {fileID: 1058315976}
|
- {fileID: 1058315976}
|
||||||
|
- {fileID: 1168515846}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ using Unity.Netcode;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using TD.Core;
|
using TD.Core;
|
||||||
using TD.Towers;
|
using TD.Towers;
|
||||||
using TD.UI;
|
|
||||||
|
|
||||||
namespace TD.Gameplay
|
namespace TD.Gameplay
|
||||||
{
|
{
|
||||||
|
|
@ -40,7 +39,7 @@ namespace TD.Gameplay
|
||||||
/// races with the Builder. See lessons in the project context doc.</para>
|
/// races with the Builder. See lessons in the project context doc.</para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RequireComponent(typeof(NetworkObject))]
|
[RequireComponent(typeof(NetworkObject))]
|
||||||
public class BuildSiteVisual : NetworkBehaviour
|
public class BuildSiteVisual : NetworkBehaviour, ISelectable
|
||||||
{
|
{
|
||||||
// ----- Inspector --------------------------------------------------
|
// ----- Inspector --------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -201,6 +200,36 @@ namespace TD.Gameplay
|
||||||
return Mathf.Clamp01((currentRunElapsed + accumulatedConstructionTime.Value) / bt);
|
return Mathf.Clamp01((currentRunElapsed + accumulatedConstructionTime.Value) / bt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- ISelectable ------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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) -------------------------------
|
// ----- Pre-spawn init data (server) -------------------------------
|
||||||
|
|
||||||
private string pendingDefName;
|
private string pendingDefName;
|
||||||
|
|
@ -266,12 +295,6 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
// Apply initial visual state based on the (now-replicated) values.
|
// Apply initial visual state based on the (now-replicated) values.
|
||||||
ApplyStageVisual(currentStage.Value);
|
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<BuildProgressBar>().Initialize(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnNetworkDespawn()
|
public override void OnNetworkDespawn()
|
||||||
|
|
@ -287,6 +310,15 @@ namespace TD.Gameplay
|
||||||
{
|
{
|
||||||
RestoreFootprintGridState();
|
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
|
// Server-only: restore walkability=true and occupancy=false on this build site's
|
||||||
|
|
@ -347,6 +379,82 @@ namespace TD.Gameplay
|
||||||
isShelved.Value = false;
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owner-only RPC. Routes to <see cref="ServerCancel"/>. Hooked up to the
|
||||||
|
/// Cancel action button in the HUD's action menu.
|
||||||
|
/// </summary>
|
||||||
|
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
|
||||||
|
public void RequestCancelRpc()
|
||||||
|
{
|
||||||
|
ServerCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-only: cancel this build. Two paths depending on shelve state:
|
||||||
|
/// - <b>Shelved</b>: this visual is standalone (not in any builder's queue).
|
||||||
|
/// Refund gold ourselves and despawn — <see cref="OnNetworkDespawn"/>
|
||||||
|
/// restores the footprint's grid state because <c>isShelved</c> is true.
|
||||||
|
/// - <b>In a builder's queue</b>: route through the owning builder's
|
||||||
|
/// <see cref="Builder.ServerCancelJobAtAnchor"/>, which already handles
|
||||||
|
/// refund + grid restore + visual despawn through its job-cleanup path.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Server-only: transitions the visual from Queued (or Paused) to Constructing
|
/// Server-only: transitions the visual from Queued (or Paused) to Constructing
|
||||||
/// and records the server time for stage progression. Caller is responsible
|
/// and records the server time for stage progression. Caller is responsible
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ namespace TD.Gameplay
|
||||||
/// traversal.</para>
|
/// traversal.</para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RequireComponent(typeof(NetworkObject))]
|
[RequireComponent(typeof(NetworkObject))]
|
||||||
public class Builder : NetworkBehaviour, IMinimapEntity
|
public class Builder : NetworkBehaviour, IMinimapEntity, ISelectable
|
||||||
{
|
{
|
||||||
// ----- Static registry --------------------------------------------
|
// ----- Static registry --------------------------------------------
|
||||||
|
|
||||||
|
|
@ -156,6 +156,8 @@ namespace TD.Gameplay
|
||||||
/// <summary>Maximum jobs allowed in the queue.</summary>
|
/// <summary>Maximum jobs allowed in the queue.</summary>
|
||||||
public int MaxQueueDepth => settings.maxQueueDepth;
|
public int MaxQueueDepth => settings.maxQueueDepth;
|
||||||
|
|
||||||
|
// ----- ISelectable ------------------------------------------------
|
||||||
|
|
||||||
/// <summary>Display name shown in the HUD portrait. Stub until MatchState provides player names.</summary>
|
/// <summary>Display name shown in the HUD portrait. Stub until MatchState provides player names.</summary>
|
||||||
public string DisplayName
|
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;
|
||||||
|
|
||||||
/// <summary>True if a tile is currently part of any queued or constructing job.</summary>
|
/// <summary>True if a tile is currently part of any queued or constructing job.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Used by <c>TowerPlacementManager</c> to reject placement on tiles already
|
/// Used by <c>TowerPlacementManager</c> to reject placement on tiles already
|
||||||
|
|
@ -232,6 +243,14 @@ namespace TD.Gameplay
|
||||||
s_byClientId.Remove(OwnerClientId);
|
s_byClientId.Remove(OwnerClientId);
|
||||||
MinimapEntityRegistry.Deregister(this);
|
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
|
// Server-only cleanup: despawn any remaining build-site visuals so they
|
||||||
// don't leak when a player disconnects mid-construction.
|
// don't leak when a player disconnects mid-construction.
|
||||||
if (IsServer)
|
if (IsServer)
|
||||||
|
|
@ -980,7 +999,30 @@ namespace TD.Gameplay
|
||||||
jobs.RemoveAt(0);
|
jobs.RemoveAt(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancels the job at index i. Used for cancel-all and any future targeted cancel.
|
/// <summary>
|
||||||
|
/// Server-only: cancel the job in this builder's queue whose anchor matches
|
||||||
|
/// <paramref name="targetAnchor"/>. 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 <see cref="BuildSiteVisual.RequestCancelRpc"/> so the
|
||||||
|
/// player can cancel a specific in-progress build from the HUD without
|
||||||
|
/// affecting other queued/constructing builds.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
private void ServerCancelJobAt(int index)
|
||||||
{
|
{
|
||||||
if (index < 0 || index >= jobs.Count) return;
|
if (index < 0 || index >= jobs.Count) return;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using Unity.Netcode;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.InputSystem;
|
using UnityEngine.InputSystem;
|
||||||
using TD.Core;
|
using TD.Core;
|
||||||
|
using TD.UI;
|
||||||
|
|
||||||
namespace TD.Gameplay
|
namespace TD.Gameplay
|
||||||
{
|
{
|
||||||
|
|
@ -58,10 +59,11 @@ namespace TD.Gameplay
|
||||||
"against this layer to determine the move target.")]
|
"against this layer to determine the move target.")]
|
||||||
[SerializeField] private LayerMask buildablePlaneLayerMask;
|
[SerializeField] private LayerMask buildablePlaneLayerMask;
|
||||||
|
|
||||||
[Tooltip("Physics layer mask for the builder selection trigger collider. The " +
|
[Tooltip("Physics layer mask for selection trigger colliders. Builder selection " +
|
||||||
"builder prefab's child selection collider sits on this layer. The mask " +
|
"colliders AND tower selection colliders both sit on this layer. The " +
|
||||||
"must NOT overlap with BuildablePlane or TerrainGeometry; selection is " +
|
"raycast walks up the hit hierarchy to find an ISelectable component, so " +
|
||||||
"a separate concern.")]
|
"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;
|
[SerializeField] private LayerMask selectionLayerMask;
|
||||||
|
|
||||||
[Tooltip("Physics layer mask for build-site visual click targets. The " +
|
[Tooltip("Physics layer mask for build-site visual click targets. The " +
|
||||||
|
|
@ -113,12 +115,19 @@ namespace TD.Gameplay
|
||||||
if (mouse == null) return;
|
if (mouse == null) return;
|
||||||
|
|
||||||
bool isPlacing = IsLocalPlayerPlacing();
|
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
|
// Left-click: selection. Suppressed during placement mode (left-click is
|
||||||
// the placement-submit gesture there).
|
// the placement-submit gesture there) and when the pointer is over HUD.
|
||||||
if (!isPlacing && mouse.leftButton.wasPressedThisFrame)
|
if (!isPlacing && !pointerOverHud && mouse.leftButton.wasPressedThisFrame)
|
||||||
{
|
{
|
||||||
HandleLeftClickSelection(mouse.position.ReadValue());
|
HandleLeftClickSelection(mousePos);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape: clear selection. Allowed during placement mode too — Escape never
|
// 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
|
// 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 (isPlacing) return;
|
||||||
|
if (pointerOverHud) return;
|
||||||
if (!mouse.rightButton.wasPressedThisFrame) return;
|
if (!mouse.rightButton.wasPressedThisFrame) return;
|
||||||
|
|
||||||
HandleRightClick(mouse.position.ReadValue());
|
HandleRightClick(mousePos);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Selection (left-click) -------------------------------------
|
// ----- Selection (left-click) -------------------------------------
|
||||||
|
|
@ -147,17 +157,24 @@ namespace TD.Gameplay
|
||||||
if (cam == null) return;
|
if (cam == null) return;
|
||||||
|
|
||||||
Ray ray = cam.ScreenPointToRay(new Vector3(screenPos.x, screenPos.y, 0f));
|
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))
|
if (Physics.Raycast(ray, out RaycastHit hit, raycastMaxDistance, selectionLayerMask))
|
||||||
{
|
{
|
||||||
// Walk up the hierarchy to find a Builder component (the selection
|
var hitSelectable = hit.collider.GetComponentInParent<ISelectable>();
|
||||||
// collider may sit on a child of the Builder's root).
|
if (hitSelectable != null)
|
||||||
var hitBuilder = hit.collider.GetComponentInParent<Builder>();
|
|
||||||
|
|
||||||
// Only allow selecting OUR builder. A click on someone else's builder
|
|
||||||
// collider clears our selection rather than selecting theirs.
|
|
||||||
if (hitBuilder != null && hitBuilder == builder)
|
|
||||||
{
|
{
|
||||||
selection.Select(builder);
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,56 @@ namespace TD.Gameplay
|
||||||
/// <summary>Ends external drag mode. Normal input handling resumes.</summary>
|
/// <summary>Ends external drag mode. Normal input handling resumes.</summary>
|
||||||
public void EndDrag() => isExternalDragActive = false;
|
public void EndDrag() => isExternalDragActive = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes where the four screen corners project onto the buildable plane and
|
||||||
|
/// writes them into <paramref name="cornersOut"/> 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).
|
||||||
|
/// </summary>
|
||||||
|
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 --------------------------------------------------
|
// ----- Lifecycle --------------------------------------------------
|
||||||
|
|
||||||
private void Start()
|
private void Start()
|
||||||
|
|
|
||||||
46
Assets/_Project/Scripts/Gameplay/ISelectable.cs
Normal file
46
Assets/_Project/Scripts/Gameplay/ISelectable.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// Assets/_Project/Scripts/Gameplay/ISelectable.cs
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace TD.Gameplay
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public enum SelectableKind
|
||||||
|
{
|
||||||
|
Builder,
|
||||||
|
Tower,
|
||||||
|
Enemy,
|
||||||
|
BuildSite, // tower in queued / constructing / paused / shelved state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="SelectionVisualizer"/> needs to draw
|
||||||
|
/// the selection ring.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Selection is a local UI concept — implementers don't need to be
|
||||||
|
/// NetworkBehaviours (though Builder and TowerInstance happen to be).
|
||||||
|
/// </remarks>
|
||||||
|
public interface ISelectable
|
||||||
|
{
|
||||||
|
string DisplayName { get; }
|
||||||
|
SelectableKind Kind { get; }
|
||||||
|
|
||||||
|
/// <summary>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
|
||||||
|
/// <c>this.transform</c>.</summary>
|
||||||
|
Transform SelectionTransform { get; }
|
||||||
|
|
||||||
|
/// <summary>Half-width of the selection ring in world units. The visualizer
|
||||||
|
/// scales its base 1-unit-diameter ring mesh to <c>2 * SelectionRadius</c>.
|
||||||
|
/// Computed on every selection change, so implementers may derive it from
|
||||||
|
/// runtime state (e.g., tower footprint, collider bounds).</summary>
|
||||||
|
float SelectionRadius { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Scripts/Gameplay/ISelectable.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/ISelectable.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6570f6402d58acb48bd8f0e9202cb1d7
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
// Assets/_Project/Scripts/Gameplay/SelectionRingVisual.cs
|
|
||||||
using UnityEngine;
|
|
||||||
|
|
||||||
namespace TD.Gameplay
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Local-only visual indicator for builder selection. Sits as a child of the
|
|
||||||
/// builder prefab. Subscribes to <see cref="SelectionState.OnSelectionChanged"/>
|
|
||||||
/// and toggles the visibility of its own renderers (and any descendant
|
|
||||||
/// renderers) when the parent builder becomes selected/unselected.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para><b>Pure local visualization.</b> No NetworkBehaviour. Selection is a
|
|
||||||
/// UI concept — every client renders selection state for its own player only.
|
|
||||||
/// This component has no networked state.</para>
|
|
||||||
///
|
|
||||||
/// <para><b>Why a separate component.</b> Keeps Builder focused on gameplay
|
|
||||||
/// state. The visual prefab structure can change independently
|
|
||||||
/// (disc → ring → decal projector → animated effect) without touching gameplay
|
|
||||||
/// code. The contract is just "renderers visible when selected."</para>
|
|
||||||
///
|
|
||||||
/// <para><b>Why renderer-toggle, not GameObject.SetActive.</b> Disabling our
|
|
||||||
/// own GameObject would prevent OnEnable/Update from running, which would
|
|
||||||
/// break the SelectionState subscription lifecycle (we'd never receive the
|
|
||||||
/// "you're selected again" event). Toggling the renderers' enabled state
|
|
||||||
/// achieves the same visual effect without breaking the event flow.</para>
|
|
||||||
///
|
|
||||||
/// <para><b>Prefab setup.</b> Attach this component to a child GameObject of
|
|
||||||
/// the Builder prefab. The child carries (or has descendants carrying) a
|
|
||||||
/// flattened cylinder mesh sitting just above the ground plane, with an
|
|
||||||
/// unlit transparent green material. The component handles initial visibility
|
|
||||||
/// — leave the renderer enabled in the prefab; we'll turn it off in Awake
|
|
||||||
/// until selection fires.</para>
|
|
||||||
/// </remarks>
|
|
||||||
public class SelectionRingVisual : MonoBehaviour
|
|
||||||
{
|
|
||||||
// Cached parent builder, resolved in Awake.
|
|
||||||
private Builder parentBuilder;
|
|
||||||
|
|
||||||
// Cached renderers (this object plus all descendants). Captured once in
|
|
||||||
// Awake to avoid per-event GetComponentsInChildren allocations.
|
|
||||||
private Renderer[] cachedRenderers;
|
|
||||||
|
|
||||||
// Tracks subscription state so OnDisable / OnDestroy unsubscribe correctly,
|
|
||||||
// and so Update can retry subscription if SelectionState wasn't ready at OnEnable.
|
|
||||||
private bool subscribed;
|
|
||||||
|
|
||||||
private void Awake()
|
|
||||||
{
|
|
||||||
parentBuilder = GetComponentInParent<Builder>();
|
|
||||||
if (parentBuilder == null)
|
|
||||||
{
|
|
||||||
Debug.LogError("[SelectionRingVisual] No Builder component found on " +
|
|
||||||
"self or any parent. Disabling.");
|
|
||||||
enabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cachedRenderers = GetComponentsInChildren<Renderer>(includeInactive: true);
|
|
||||||
|
|
||||||
// Start hidden. If selection fires later (including the auto-select
|
|
||||||
// in Builder.OnNetworkSpawn), HandleSelectionChanged will turn the
|
|
||||||
// renderers back on.
|
|
||||||
SetRenderersVisible(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnEnable()
|
|
||||||
{
|
|
||||||
TrySubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDisable()
|
|
||||||
{
|
|
||||||
Unsubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDestroy()
|
|
||||||
{
|
|
||||||
Unsubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-frame fallback: if SelectionState wasn't available at OnEnable
|
|
||||||
// (scene load ordering), keep trying. Cost is one null-check per frame
|
|
||||||
// until subscription succeeds, then nothing.
|
|
||||||
private void Update()
|
|
||||||
{
|
|
||||||
if (!subscribed) TrySubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TrySubscribe()
|
|
||||||
{
|
|
||||||
if (subscribed) return;
|
|
||||||
var sel = SelectionState.Instance;
|
|
||||||
if (sel == null) return;
|
|
||||||
|
|
||||||
sel.OnSelectionChanged += HandleSelectionChanged;
|
|
||||||
subscribed = true;
|
|
||||||
|
|
||||||
// Sync to current state — selection may have happened before we subscribed
|
|
||||||
// (e.g., Builder.OnNetworkSpawn auto-selecting before this Awake runs).
|
|
||||||
HandleSelectionChanged(sel.SelectedBuilder);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Unsubscribe()
|
|
||||||
{
|
|
||||||
if (!subscribed) return;
|
|
||||||
var sel = SelectionState.Instance;
|
|
||||||
if (sel != null) sel.OnSelectionChanged -= HandleSelectionChanged;
|
|
||||||
subscribed = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleSelectionChanged(Builder newSelection)
|
|
||||||
{
|
|
||||||
SetRenderersVisible(newSelection == parentBuilder);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetRenderersVisible(bool visible)
|
|
||||||
{
|
|
||||||
if (cachedRenderers == null) return;
|
|
||||||
foreach (var rend in cachedRenderers)
|
|
||||||
{
|
|
||||||
if (rend != null) rend.enabled = visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
fileFormatVersion: 2
|
|
||||||
guid: 67895f626233fdc499dffbbfcc225530
|
|
||||||
|
|
@ -4,35 +4,28 @@ using UnityEngine;
|
||||||
namespace TD.Gameplay
|
namespace TD.Gameplay
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Minimal scene-local selection state. Holds a reference to whichever
|
/// Scene-local selection state. Holds a single <see cref="ISelectable"/> (the
|
||||||
/// <see cref="Builder"/> the local player has selected, fires an event when
|
/// builder, a tower, or any future selectable type) and fires
|
||||||
/// the selection changes, and exposes a query for "is this builder selected
|
/// <see cref="OnSelectionChanged"/> when it changes.
|
||||||
/// right now?".
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para><b>Scope.</b> D2 only needs this for: "Escape with builder selected
|
/// <para><b>Local-only.</b> Selection is a UI concept — other clients have no
|
||||||
/// cancels its queue", and "right-click with builder selected and queue
|
/// business knowing whether you've selected something. The component is a plain
|
||||||
/// established cancels the queue (the right-click is consumed by selection
|
/// MonoBehaviour and lives in the scene alongside other client-side controllers.</para>
|
||||||
/// instead of issuing a move)". A full selection system that supports world
|
|
||||||
/// highlighting, multi-select, and HUD context panels is deferred to the HUD
|
|
||||||
/// path.</para>
|
|
||||||
///
|
|
||||||
/// <para><b>Local-only.</b> Selection is a UI concept, not a gameplay one.
|
|
||||||
/// Other clients have no business knowing whether you've selected your own
|
|
||||||
/// builder. The component is a plain MonoBehaviour and lives in the scene
|
|
||||||
/// alongside other client-side controllers.</para>
|
|
||||||
///
|
///
|
||||||
/// <para><b>Singleton.</b> One per scene, accessed via <see cref="Instance"/>.
|
/// <para><b>Singleton.</b> One per scene, accessed via <see cref="Instance"/>.
|
||||||
/// The selection consumer (BuilderInputController) and the selection driver
|
/// Selection drivers (input controller, minimap) and selection consumers
|
||||||
/// (mouse-click raycast) both go through this single source of truth.</para>
|
/// (HUD, selection-ring visuals) all go through this single source of truth.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Builder convenience.</b> <see cref="SelectedBuilder"/> 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.</para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public class SelectionState : MonoBehaviour
|
public class SelectionState : MonoBehaviour
|
||||||
{
|
{
|
||||||
// ----- Singleton --------------------------------------------------
|
// ----- Singleton --------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The active SelectionState. Null before the scene loads. Always null-check.
|
|
||||||
/// </summary>
|
|
||||||
public static SelectionState Instance { get; private set; }
|
public static SelectionState Instance { get; private set; }
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
|
|
@ -52,16 +45,22 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
// ----- Selection state --------------------------------------------
|
// ----- Selection state --------------------------------------------
|
||||||
|
|
||||||
private Builder selectedBuilder;
|
private ISelectable selected;
|
||||||
|
|
||||||
/// <summary>The currently selected builder, or null if nothing is selected.</summary>
|
/// <summary>The currently selected object, or null if nothing is selected.</summary>
|
||||||
public Builder SelectedBuilder => selectedBuilder;
|
public ISelectable SelectedObject => selected;
|
||||||
|
|
||||||
/// <summary>True if any builder is currently selected.</summary>
|
/// <summary>Convenience: the selected object if it's a Builder, else null.</summary>
|
||||||
public bool HasSelection => selectedBuilder != null;
|
public Builder SelectedBuilder => selected as Builder;
|
||||||
|
|
||||||
/// <summary>True if <paramref name="b"/> is the currently selected builder.</summary>
|
/// <summary>True if any object is currently selected.</summary>
|
||||||
public bool IsSelected(Builder b) => b != null && selectedBuilder == b;
|
public bool HasSelection => selected != null;
|
||||||
|
|
||||||
|
/// <summary>True if <paramref name="s"/> is the currently selected object.</summary>
|
||||||
|
public bool IsSelected(ISelectable s) => s != null && (object)selected == (object)s;
|
||||||
|
|
||||||
|
/// <summary>Builder overload — same semantics as <see cref="IsSelected(ISelectable)"/>.</summary>
|
||||||
|
public bool IsSelected(Builder b) => b != null && (object)selected == (object)b;
|
||||||
|
|
||||||
// ----- Events -----------------------------------------------------
|
// ----- Events -----------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -69,22 +68,22 @@ namespace TD.Gameplay
|
||||||
/// Fired when the selection changes. Argument is the new selection (may be null).
|
/// Fired when the selection changes. Argument is the new selection (may be null).
|
||||||
/// Subscribe to drive selection-aware UI: highlights, context panels, hotkey hints.
|
/// Subscribe to drive selection-aware UI: highlights, context panels, hotkey hints.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public event System.Action<Builder> OnSelectionChanged;
|
public event System.Action<ISelectable> OnSelectionChanged;
|
||||||
|
|
||||||
// ----- Mutators ---------------------------------------------------
|
// ----- Mutators ---------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the selected builder. Pass null to clear.
|
/// Sets the selected object. Pass null to clear.
|
||||||
/// Fires <see cref="OnSelectionChanged"/> only if the selection actually changes.
|
/// Fires <see cref="OnSelectionChanged"/> only if the selection actually changes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Select(Builder builder)
|
public void Select(ISelectable s)
|
||||||
{
|
{
|
||||||
if (selectedBuilder == builder) return;
|
if ((object)selected == (object)s) return;
|
||||||
selectedBuilder = builder;
|
selected = s;
|
||||||
OnSelectionChanged?.Invoke(selectedBuilder);
|
OnSelectionChanged?.Invoke(selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Clears the selection. Equivalent to Select(null).</summary>
|
/// <summary>Clears the selection. Equivalent to Select(null).</summary>
|
||||||
public void Clear() => Select(null);
|
public void Clear() => Select((ISelectable)null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
275
Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs
Normal file
275
Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
// Assets/_Project/Scripts/Gameplay/SelectionVisualizer.cs
|
||||||
|
using UnityEngine;
|
||||||
|
using TD.Core;
|
||||||
|
|
||||||
|
namespace TD.Gameplay
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Scene-wide selection ring renderer. One per scene. Listens to
|
||||||
|
/// <see cref="SelectionState.OnSelectionChanged"/> and drives a single pooled
|
||||||
|
/// ring GameObject — sized from the new selection's <see cref="ISelectable.SelectionRadius"/>,
|
||||||
|
/// positioned each <see cref="LateUpdate"/> from <see cref="ISelectable.SelectionTransform"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Why scene-wide instead of per-prefab.</b> 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.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Color isolation.</b> Because the ring is instantiated as a child of
|
||||||
|
/// THIS visualizer (not of the selectable), <c>TowerInstance.ApplyOwnerColor</c>'s
|
||||||
|
/// MaterialPropertyBlock writes can never reach it. The ring's authored material
|
||||||
|
/// (green) shows through regardless of the selectable's own tinting.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Single instance, recycled.</b> 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.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Prefab requirements.</b> 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 <c>2 * SelectionRadius</c> so the rendered ring matches the
|
||||||
|
/// selectable's intended size. Y scale is preserved so the disc stays flat.</para>
|
||||||
|
/// </remarks>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e28d10549c8d2ac4585eded3ad8d2198
|
||||||
|
|
@ -43,8 +43,18 @@ namespace TD.Gameplay
|
||||||
/// <c>TowerCombat</c> component added to the same prefab.</para>
|
/// <c>TowerCombat</c> component added to the same prefab.</para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RequireComponent(typeof(NetworkObject))]
|
[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 ------------------------------------------------
|
// ----- Networked state ------------------------------------------------
|
||||||
|
|
||||||
// The name of the TowerDefinition asset for this tower. Replicated so all
|
// The name of the TowerDefinition asset for this tower. Replicated so all
|
||||||
|
|
@ -107,6 +117,40 @@ namespace TD.Gameplay
|
||||||
/// <summary>The footprint anchor tile (SW corner, world-tile coords).</summary>
|
/// <summary>The footprint anchor tile (SW corner, world-tile coords).</summary>
|
||||||
public Vector2Int AnchorTile => anchorTile.Value;
|
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;
|
||||||
|
|
||||||
|
/// <summary>Display name shown in the HUD portrait when this tower is selected.</summary>
|
||||||
|
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 -------------------------------------
|
// ----- Server-only initialization -------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -171,6 +215,20 @@ namespace TD.Gameplay
|
||||||
// Register for minimap rendering.
|
// Register for minimap rendering.
|
||||||
MinimapEntityRegistry.Register(this);
|
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)
|
if (resolvedDefinition != null)
|
||||||
{
|
{
|
||||||
Debug.Log($"[TowerInstance] Spawned '{resolvedDefinition.DisplayName}' " +
|
Debug.Log($"[TowerInstance] Spawned '{resolvedDefinition.DisplayName}' " +
|
||||||
|
|
@ -186,6 +244,13 @@ namespace TD.Gameplay
|
||||||
StampFootprint(walkable: true, occupied: false);
|
StampFootprint(walkable: true, occupied: false);
|
||||||
|
|
||||||
MinimapEntityRegistry.Deregister(this);
|
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 -------------------------------------------------
|
// ----- IMinimapEntity -------------------------------------------------
|
||||||
|
|
@ -285,19 +350,20 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
// MaterialPropertyBlock sets per-renderer properties without allocating
|
// MaterialPropertyBlock sets per-renderer properties without allocating
|
||||||
// a new Material object. Safe to reuse across calls on the same instance.
|
// 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 ??= new MaterialPropertyBlock();
|
||||||
colorPropertyBlock.SetColor(ColorPropertyId, ownerColor);
|
colorPropertyBlock.SetColor(ColorPropertyId, ownerColor);
|
||||||
colorPropertyBlock.SetColor(BaseColorPropertyId, ownerColor);
|
colorPropertyBlock.SetColor(BaseColorPropertyId, ownerColor);
|
||||||
|
|
||||||
var renderers = GetComponentsInChildren<MeshRenderer>();
|
// Tint only the renderers explicitly listed in the inspector. Avoids
|
||||||
foreach (var rend in renderers)
|
// accidentally re-coloring decorative children, FX, etc. (Mirrors
|
||||||
rend.SetPropertyBlock(colorPropertyBlock);
|
// Builder.tintedRenderers — same rationale.)
|
||||||
|
if (tintedRenderers == null) return;
|
||||||
if (renderers.Length == 0)
|
foreach (var rend in tintedRenderers)
|
||||||
{
|
{
|
||||||
Debug.LogWarning($"[TowerInstance] NetworkObject {NetworkObjectId}: " +
|
if (rend == null) continue;
|
||||||
$"No MeshRenderers found for owner color tinting.");
|
rend.SetPropertyBlock(colorPropertyBlock);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
|
||||||
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<Image>();
|
|
||||||
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<Image>();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// Assets/_Project/Scripts/UI/HUDController.cs
|
// Assets/_Project/Scripts/UI/HUDController.cs
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
using UnityEngine.InputSystem;
|
||||||
using UnityEngine.UIElements;
|
using UnityEngine.UIElements;
|
||||||
using TD.Gameplay;
|
using TD.Gameplay;
|
||||||
using TD.Towers;
|
using TD.Towers;
|
||||||
|
|
@ -37,7 +39,13 @@ namespace TD.UI
|
||||||
private Label goldLabel;
|
private Label goldLabel;
|
||||||
private Label waveLabel;
|
private Label waveLabel;
|
||||||
private Label portraitName;
|
private Label portraitName;
|
||||||
|
private Label levelLabel;
|
||||||
|
private VisualElement statLines;
|
||||||
private VisualElement commandGrid;
|
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 ttTitle;
|
||||||
private Label ttDesc;
|
private Label ttDesc;
|
||||||
private Label ttStats;
|
private Label ttStats;
|
||||||
|
|
@ -47,11 +55,38 @@ namespace TD.UI
|
||||||
// ----- State ------------------------------------------------------
|
// ----- State ------------------------------------------------------
|
||||||
|
|
||||||
private Coroutine rejectionFadeCoroutine;
|
private Coroutine rejectionFadeCoroutine;
|
||||||
private bool gridPopulated;
|
private bool placementManagerReady; // true once TowerPlacementManager.Instance is non-null
|
||||||
private bool uiInitialized;
|
private bool uiInitialized;
|
||||||
|
private bool selectionSubscribed; // true once we've successfully hooked SelectionState.OnSelectionChanged
|
||||||
private MinimapView minimapView;
|
private MinimapView minimapView;
|
||||||
private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us
|
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<HotkeyBinding> hotkeyBindings = new List<HotkeyBinding>();
|
||||||
|
|
||||||
|
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 --------------------------------------
|
// ----- Static hit-test probe --------------------------------------
|
||||||
|
|
||||||
// Set when InitializeUI succeeds; cleared on OnDestroy. Non-UI systems (camera,
|
// 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.
|
// Awake() calls. Accessing rootVisualElement in Awake() is too early.
|
||||||
// Start() is safe because all OnEnable() calls have completed by then.
|
// Start() is safe because all OnEnable() calls have completed by then.
|
||||||
InitializeUI();
|
InitializeUI();
|
||||||
TryPopulateCommandGrid();
|
TryReadyPlacementManager();
|
||||||
// Seed portrait/grid state in case the builder already auto-selected before Start.
|
|
||||||
HandleSelectionChanged(SelectionState.Instance?.SelectedBuilder);
|
// 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()
|
private void InitializeUI()
|
||||||
|
|
@ -131,7 +173,13 @@ namespace TD.UI
|
||||||
goldLabel = Require<Label>(root, "gold-label");
|
goldLabel = Require<Label>(root, "gold-label");
|
||||||
waveLabel = Require<Label>(root, "wave-label");
|
waveLabel = Require<Label>(root, "wave-label");
|
||||||
portraitName = Require<Label>(root, "portrait-name");
|
portraitName = Require<Label>(root, "portrait-name");
|
||||||
|
levelLabel = Require<Label>(root, "level-label");
|
||||||
|
statLines = Require<VisualElement>(root, "stat-lines");
|
||||||
commandGrid = Require<VisualElement>(root, "command-grid");
|
commandGrid = Require<VisualElement>(root, "command-grid");
|
||||||
|
actionFrame = Require<VisualElement>(root, "action-frame");
|
||||||
|
buildProgressContainer = Require<VisualElement>(root, "build-progress");
|
||||||
|
buildProgressFill = Require<VisualElement>(root, "build-progress-fill");
|
||||||
|
buildProgressPercent = Require<Label>(root, "build-progress-percent");
|
||||||
ttTitle = Require<Label>(root, "tt-title");
|
ttTitle = Require<Label>(root, "tt-title");
|
||||||
ttDesc = Require<Label>(root, "tt-desc");
|
ttDesc = Require<Label>(root, "tt-desc");
|
||||||
ttStats = Require<Label>(root, "tt-stats");
|
ttStats = Require<Label>(root, "tt-stats");
|
||||||
|
|
@ -139,17 +187,17 @@ namespace TD.UI
|
||||||
rejectionLabel = Require<Label>(root, "rejection-label");
|
rejectionLabel = Require<Label>(root, "rejection-label");
|
||||||
|
|
||||||
// Map area and its transparent ancestors must not consume pointer
|
// Map area and its transparent ancestors must not consume pointer
|
||||||
// events so clicks reach the 3D scene underneath.
|
// events so clicks reach the 3D scene underneath. The bottom-ui is now
|
||||||
|
// a transparent strip too — the *individual frames* are the opaque
|
||||||
|
// interactive surfaces, so the empty margins on either side click
|
||||||
|
// through to the world.
|
||||||
SetPickIgnore(root, "hud-root");
|
SetPickIgnore(root, "hud-root");
|
||||||
SetPickIgnore(root, "main-area");
|
SetPickIgnore(root, "main-area");
|
||||||
SetPickIgnore(root, "map-area");
|
SetPickIgnore(root, "map-area");
|
||||||
|
SetPickIgnore(root, "bottom-ui");
|
||||||
if (rejectionLabel != null)
|
if (rejectionLabel != null)
|
||||||
rejectionLabel.pickingMode = PickingMode.Ignore;
|
rejectionLabel.pickingMode = PickingMode.Ignore;
|
||||||
|
|
||||||
// Upgrade/sell have no backing system yet.
|
|
||||||
SetEnabled(root, "upgrade-btn", false);
|
|
||||||
SetEnabled(root, "sell-btn", false);
|
|
||||||
|
|
||||||
// Minimap. The MinimapView owns the two sub-elements (terrain + entity overlay)
|
// Minimap. The MinimapView owns the two sub-elements (terrain + entity overlay)
|
||||||
// and drives them; we just hand it the host container and the camera controller.
|
// and drives them; we just hand it the host container and the camera controller.
|
||||||
// Bake is deferred until LevelLoader is ready — view tries each frame in Tick().
|
// Bake is deferred until LevelLoader is ready — view tries each frame in Tick().
|
||||||
|
|
@ -174,28 +222,95 @@ namespace TD.UI
|
||||||
private void OnEnable()
|
private void OnEnable()
|
||||||
{
|
{
|
||||||
TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage;
|
TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage;
|
||||||
if (SelectionState.Instance != null)
|
// Try to subscribe now; if SelectionState.Awake hasn't run yet (Unity does
|
||||||
SelectionState.Instance.OnSelectionChanged += HandleSelectionChanged;
|
// not guarantee Awake/OnEnable ordering across objects), Start will retry.
|
||||||
|
TrySubscribeSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDisable()
|
private void OnDisable()
|
||||||
{
|
{
|
||||||
TowerPlacementController.OnRejectionMessageReady -= ShowRejectionMessage;
|
TowerPlacementController.OnRejectionMessageReady -= ShowRejectionMessage;
|
||||||
if (SelectionState.Instance != null)
|
if (selectionSubscribed && SelectionState.Instance != null)
|
||||||
|
{
|
||||||
SelectionState.Instance.OnSelectionChanged -= HandleSelectionChanged;
|
SelectionState.Instance.OnSelectionChanged -= HandleSelectionChanged;
|
||||||
|
selectionSubscribed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TrySubscribeSelection()
|
||||||
|
{
|
||||||
|
if (selectionSubscribed) return;
|
||||||
|
if (SelectionState.Instance == null) return;
|
||||||
|
SelectionState.Instance.OnSelectionChanged += HandleSelectionChanged;
|
||||||
|
selectionSubscribed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
{
|
{
|
||||||
if (!uiInitialized) return;
|
if (!uiInitialized) return;
|
||||||
|
|
||||||
if (!gridPopulated)
|
if (!placementManagerReady)
|
||||||
TryPopulateCommandGrid();
|
TryReadyPlacementManager();
|
||||||
|
|
||||||
RefreshGoldDisplay();
|
RefreshGoldDisplay();
|
||||||
|
UpdateBuildProgressIfShown();
|
||||||
|
HandleHotkeys();
|
||||||
minimapView?.Tick();
|
minimapView?.Tick();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// While a BuildSiteVisual is selected, refresh the progress bar's fill
|
||||||
|
/// width and the percent label. Runs every frame so the bar tracks server
|
||||||
|
/// time as construction advances. Cheap enough not to throttle (one width
|
||||||
|
/// assignment, one string allocation per frame while selected).
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateBuildProgressIfShown()
|
||||||
|
{
|
||||||
|
if (buildProgressContainer == null) return;
|
||||||
|
|
||||||
|
var sel = SelectionState.Instance?.SelectedObject;
|
||||||
|
// Use Unity's overloaded equality via cast — a destroyed BuildSiteVisual
|
||||||
|
// still passes `is` but blows up on member access. Match the same
|
||||||
|
// backstop pattern SelectionVisualizer uses.
|
||||||
|
if (sel is BuildSiteVisual bsv && (UnityEngine.Object)bsv != null)
|
||||||
|
{
|
||||||
|
float progress = bsv.ComputeProgressNormalized();
|
||||||
|
|
||||||
|
if (buildProgressFill != null)
|
||||||
|
{
|
||||||
|
// Width is a percent of the parent bar background; multiplying
|
||||||
|
// by 100 keeps the StyleLength in percent units.
|
||||||
|
buildProgressFill.style.width =
|
||||||
|
new StyleLength(new Length(progress * 100f, LengthUnit.Percent));
|
||||||
|
}
|
||||||
|
if (buildProgressPercent != null)
|
||||||
|
{
|
||||||
|
buildProgressPercent.text = $"{Mathf.RoundToInt(progress * 100f)}%";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads raw keyboard state via the New Input System and fires the matching
|
||||||
|
/// action for any bound hotkey pressed this frame. Mirrors the disabled-button
|
||||||
|
/// behaviour: a SetEnabled(false) button is skipped (so Upgrade/Sell don't
|
||||||
|
/// trigger from the keyboard while their backing systems are still stubbed).
|
||||||
|
/// </summary>
|
||||||
|
private void HandleHotkeys()
|
||||||
|
{
|
||||||
|
var kb = Keyboard.current;
|
||||||
|
if (kb == null) return;
|
||||||
|
|
||||||
|
// Iterate by index — foreach over a struct list would copy each entry.
|
||||||
|
for (int i = 0; i < hotkeyBindings.Count; i++)
|
||||||
|
{
|
||||||
|
var binding = hotkeyBindings[i];
|
||||||
|
if (!kb[binding.Key].wasPressedThisFrame) continue;
|
||||||
|
if (binding.Button == null || !binding.Button.enabledSelf) continue;
|
||||||
|
binding.Action?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
minimapView?.Dispose();
|
minimapView?.Dispose();
|
||||||
|
|
@ -217,51 +332,100 @@ namespace TD.UI
|
||||||
|
|
||||||
// ----- Command grid -----------------------------------------------
|
// ----- Command grid -----------------------------------------------
|
||||||
|
|
||||||
private void TryPopulateCommandGrid()
|
private const int GRID_COLS = 5;
|
||||||
{
|
private const int GRID_ROWS = 3;
|
||||||
if (commandGrid == null) return;
|
private const int GRID_MAX = GRID_COLS * GRID_ROWS;
|
||||||
|
|
||||||
|
// First-time check that TowerPlacementManager exists. Once ready, populates the
|
||||||
|
// grid for the current selection. Subsequent populates flow through
|
||||||
|
// HandleSelectionChanged → PopulateGridForSelection.
|
||||||
|
private void TryReadyPlacementManager()
|
||||||
|
{
|
||||||
if (placementManager == null)
|
if (placementManager == null)
|
||||||
placementManager = TowerPlacementManager.Instance;
|
placementManager = TowerPlacementManager.Instance;
|
||||||
|
|
||||||
if (placementManager == null) return; // not spawned yet — retry next frame
|
if (placementManager == null) return; // not spawned yet — retry next frame
|
||||||
|
|
||||||
commandGrid.Clear();
|
placementManagerReady = true;
|
||||||
|
PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
|
||||||
const int COLS = 4;
|
|
||||||
const int ROWS = 3;
|
|
||||||
const int MAX = COLS * ROWS;
|
|
||||||
|
|
||||||
var defs = new System.Collections.Generic.List<(TowerDefinition def, int typeId)>();
|
|
||||||
foreach (var entry in placementManager.GetAvailableDefinitions())
|
|
||||||
{
|
|
||||||
defs.Add(entry);
|
|
||||||
if (defs.Count >= MAX) break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int row = 0; row < ROWS; row++)
|
/// <summary>
|
||||||
|
/// Rebuilds the command grid based on the current selection AND toggles the
|
||||||
|
/// action frame visibility. Builder → tower-build buttons in the early slots.
|
||||||
|
/// Tower → Upgrade in slot 0, Sell in the last slot. Anything else (Enemy,
|
||||||
|
/// null) → action frame hidden entirely (display:none).
|
||||||
|
/// </summary>
|
||||||
|
private void PopulateGridForSelection(ISelectable selection)
|
||||||
|
{
|
||||||
|
if (commandGrid == null) return;
|
||||||
|
if (!placementManagerReady) return; // deferred to TryReadyPlacementManager
|
||||||
|
|
||||||
|
// Selection changed — invalidate the previous frame's hotkey bindings
|
||||||
|
// before creating new ones. Without this, stale buttons from a previous
|
||||||
|
// selection would keep responding to their hotkey.
|
||||||
|
hotkeyBindings.Clear();
|
||||||
|
|
||||||
|
// Decide whether any actions exist for this selection. The action frame
|
||||||
|
// is hidden entirely when there are none — matches the WC3-style UX.
|
||||||
|
bool hasActions = selection is Builder
|
||||||
|
|| selection is TowerInstance
|
||||||
|
|| selection is BuildSiteVisual;
|
||||||
|
if (actionFrame != null)
|
||||||
|
actionFrame.style.display = hasActions ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
|
|
||||||
|
commandGrid.Clear();
|
||||||
|
if (!hasActions) return; // grid stays empty; frame is hidden anyway
|
||||||
|
|
||||||
|
// Build the 15-cell action layout for the active selection kind.
|
||||||
|
var cells = new VisualElement[GRID_MAX];
|
||||||
|
|
||||||
|
if (selection is Builder)
|
||||||
|
{
|
||||||
|
int i = 0;
|
||||||
|
foreach (var (def, typeId) in placementManager.GetAvailableDefinitions())
|
||||||
|
{
|
||||||
|
if (i >= GRID_MAX) break;
|
||||||
|
cells[i] = CreateTowerButton(def, typeId, HotkeyLayout[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (selection is TowerInstance tower)
|
||||||
|
{
|
||||||
|
// WC3 layout convention: primary action top-left (Q), sell bottom-right (B).
|
||||||
|
cells[0] = CreateUpgradeButton(tower, HotkeyLayout[0]);
|
||||||
|
cells[GRID_MAX - 1] = CreateSellButton(tower, HotkeyLayout[GRID_MAX - 1]);
|
||||||
|
}
|
||||||
|
else if (selection is BuildSiteVisual bsv)
|
||||||
|
{
|
||||||
|
// Cancel is the only action available on an in-progress build.
|
||||||
|
// Placed at top-left (Q) — primary slot for a single-action menu.
|
||||||
|
cells[0] = CreateCancelButton(bsv, HotkeyLayout[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining cells become empty slots so the 5×3 grid layout is preserved.
|
||||||
|
for (int i = 0; i < GRID_MAX; i++)
|
||||||
|
{
|
||||||
|
if (cells[i] == null) cells[i] = CreateEmptySlot();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int row = 0; row < GRID_ROWS; row++)
|
||||||
{
|
{
|
||||||
var rowEl = new VisualElement();
|
var rowEl = new VisualElement();
|
||||||
rowEl.AddToClassList("cmd-row");
|
rowEl.AddToClassList("cmd-row");
|
||||||
|
for (int col = 0; col < GRID_COLS; col++)
|
||||||
for (int col = 0; col < COLS; col++)
|
rowEl.Add(cells[row * GRID_COLS + col]);
|
||||||
{
|
|
||||||
int index = row * COLS + col;
|
|
||||||
VisualElement btn = index < defs.Count
|
|
||||||
? CreateTowerButton(defs[index].def, defs[index].typeId)
|
|
||||||
: CreateEmptySlot();
|
|
||||||
rowEl.Add(btn);
|
|
||||||
}
|
|
||||||
|
|
||||||
commandGrid.Add(rowEl);
|
commandGrid.Add(rowEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
gridPopulated = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private VisualElement CreateTowerButton(TowerDefinition def, int typeId)
|
// Tower button: clicking begins placement; hovering drives the Tool Tip.
|
||||||
|
private VisualElement CreateTowerButton(TowerDefinition def, int typeId, Key hotkey)
|
||||||
{
|
{
|
||||||
var btn = new Button(() =>
|
var btn = CreateActionButton(
|
||||||
|
costText: $"{def.GoldCost}g",
|
||||||
|
hotkey: hotkey,
|
||||||
|
onClick: () =>
|
||||||
{
|
{
|
||||||
if (placementController != null)
|
if (placementController != null)
|
||||||
placementController.BeginPlacement(def, typeId);
|
placementController.BeginPlacement(def, typeId);
|
||||||
|
|
@ -269,23 +433,8 @@ namespace TD.UI
|
||||||
Debug.LogWarning("[HUDController] No TowerPlacementController assigned.");
|
Debug.LogWarning("[HUDController] No TowerPlacementController assigned.");
|
||||||
});
|
});
|
||||||
|
|
||||||
btn.AddToClassList("cmd-btn");
|
|
||||||
|
|
||||||
// Icon placeholder — swap for background-image on btn when art exists.
|
|
||||||
var iconPlaceholder = new VisualElement();
|
|
||||||
iconPlaceholder.AddToClassList("cmd-icon-placeholder");
|
|
||||||
iconPlaceholder.pickingMode = PickingMode.Ignore;
|
|
||||||
|
|
||||||
var costLabel = new Label($"{def.GoldCost}g");
|
|
||||||
costLabel.AddToClassList("cmd-cost");
|
|
||||||
costLabel.pickingMode = PickingMode.Ignore;
|
|
||||||
|
|
||||||
btn.Add(iconPlaceholder);
|
|
||||||
btn.Add(costLabel);
|
|
||||||
|
|
||||||
btn.RegisterCallback<MouseEnterEvent>(_ => ShowTooltip(def));
|
btn.RegisterCallback<MouseEnterEvent>(_ => ShowTooltip(def));
|
||||||
btn.RegisterCallback<MouseLeaveEvent>(_ => ClearTooltip());
|
btn.RegisterCallback<MouseLeaveEvent>(_ => ClearTooltip());
|
||||||
|
|
||||||
return btn;
|
return btn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -298,6 +447,92 @@ namespace TD.UI
|
||||||
return slot;
|
return slot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the standard action button: a Button containing a centered icon
|
||||||
|
/// placeholder, plus optional hotkey badge (top-left) and cost badge
|
||||||
|
/// (bottom-left). Both badges are pure visual overlays — click events still
|
||||||
|
/// reach the Button. The hotkey, when non-None, is registered in
|
||||||
|
/// <see cref="hotkeyBindings"/> so Update can fire <paramref name="onClick"/>
|
||||||
|
/// on keypress (gated on the button's <c>enabledSelf</c>).
|
||||||
|
/// </summary>
|
||||||
|
private Button CreateActionButton(string costText, Key hotkey, System.Action onClick)
|
||||||
|
{
|
||||||
|
var btn = new Button(() => onClick?.Invoke());
|
||||||
|
btn.AddToClassList("cmd-btn");
|
||||||
|
|
||||||
|
// Icon — added FIRST so it sits underneath the absolute-positioned badges.
|
||||||
|
var iconPlaceholder = new VisualElement();
|
||||||
|
iconPlaceholder.AddToClassList("cmd-icon-placeholder");
|
||||||
|
iconPlaceholder.pickingMode = PickingMode.Ignore;
|
||||||
|
btn.Add(iconPlaceholder);
|
||||||
|
|
||||||
|
// Hotkey badge — top-left.
|
||||||
|
if (hotkey != Key.None)
|
||||||
|
{
|
||||||
|
var hkLabel = new Label(KeyToDisplay(hotkey));
|
||||||
|
hkLabel.AddToClassList("cmd-hotkey");
|
||||||
|
hkLabel.pickingMode = PickingMode.Ignore;
|
||||||
|
btn.Add(hkLabel);
|
||||||
|
hotkeyBindings.Add(new HotkeyBinding(hotkey, btn, onClick));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cost badge — bottom-left. Omitted when no cost is meaningful (e.g., Upgrade
|
||||||
|
// before the upgrade system has a tier-cost lookup).
|
||||||
|
if (!string.IsNullOrEmpty(costText))
|
||||||
|
{
|
||||||
|
var costLabel = new Label(costText);
|
||||||
|
costLabel.AddToClassList("cmd-cost");
|
||||||
|
costLabel.pickingMode = PickingMode.Ignore;
|
||||||
|
btn.Add(costLabel);
|
||||||
|
}
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade and Sell — visuals + hotkeys wired; click is a no-op because the
|
||||||
|
// upgrade/sell systems aren't built yet. Buttons are SetEnabled(false) so the
|
||||||
|
// hotkey handler also skips them (it gates on enabledSelf).
|
||||||
|
private VisualElement CreateUpgradeButton(TowerInstance tower, Key hotkey)
|
||||||
|
{
|
||||||
|
var btn = CreateActionButton(
|
||||||
|
costText: "", // tier cost unknown until upgrade system lands
|
||||||
|
hotkey: hotkey,
|
||||||
|
onClick: () => { /* TODO: upgrade flow */ });
|
||||||
|
btn.SetEnabled(false);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VisualElement CreateSellButton(TowerInstance tower, Key hotkey)
|
||||||
|
{
|
||||||
|
int sellValue = tower.Definition != null
|
||||||
|
? Mathf.RoundToInt(tower.Definition.GoldCost * 0.7f)
|
||||||
|
: 0;
|
||||||
|
var btn = CreateActionButton(
|
||||||
|
costText: sellValue > 0 ? $"+{sellValue}g" : "",
|
||||||
|
hotkey: hotkey,
|
||||||
|
onClick: () => { /* TODO: sell flow */ });
|
||||||
|
btn.SetEnabled(false);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel action for an in-progress build. Fires the owner-only RPC; the
|
||||||
|
// server cancels the matching job (or, for shelved sites, refunds + despawns
|
||||||
|
// directly), full gold is refunded, the BuildSiteVisual is despawned, and
|
||||||
|
// OnSelectionChanged fires with null — HUD/visualizer/ring all clear
|
||||||
|
// automatically.
|
||||||
|
private VisualElement CreateCancelButton(BuildSiteVisual bsv, Key hotkey)
|
||||||
|
{
|
||||||
|
int refund = bsv.GoldSpent;
|
||||||
|
return CreateActionButton(
|
||||||
|
costText: refund > 0 ? $"+{refund}g" : "",
|
||||||
|
hotkey: hotkey,
|
||||||
|
onClick: () => bsv.RequestCancelRpc());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders a Key as a single-character badge ("Q", "1", etc.). Letter and number
|
||||||
|
// keys produce their own glyph via ToString; if we ever bind non-letter keys
|
||||||
|
// (e.g., F1 or Space), extend this with a mapping table.
|
||||||
|
private static string KeyToDisplay(Key key) => key.ToString();
|
||||||
|
|
||||||
// ----- Portrait / selection context ----------------------------------
|
// ----- Portrait / selection context ----------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -310,11 +545,68 @@ namespace TD.UI
|
||||||
portraitName.text = string.IsNullOrEmpty(unitName) ? "" : unitName;
|
portraitName.text = string.IsNullOrEmpty(unitName) ? "" : unitName;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleSelectionChanged(Builder builder)
|
private void HandleSelectionChanged(ISelectable selection)
|
||||||
{
|
{
|
||||||
SetSelectedUnitName(builder != null ? builder.DisplayName : null);
|
// Sections 2/3/4 (portrait, info, tooltip) stay visible regardless;
|
||||||
if (commandGrid != null)
|
// their *contents* update based on selection. Section 5 (action menu)
|
||||||
commandGrid.SetEnabled(builder != null);
|
// hides via PopulateGridForSelection when there are no actions.
|
||||||
|
PopulateInfoPanel(selection);
|
||||||
|
PopulateGridForSelection(selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives the portrait name (section 3 header), level (section 2 footer),
|
||||||
|
/// contextual stat lines (section 3 body), and the build-progress sub-view
|
||||||
|
/// (section 3 — shown only when a BuildSiteVisual is selected).
|
||||||
|
/// </summary>
|
||||||
|
private void PopulateInfoPanel(ISelectable selection)
|
||||||
|
{
|
||||||
|
// Name
|
||||||
|
SetSelectedUnitName(selection?.DisplayName);
|
||||||
|
|
||||||
|
// Level — placeholder until upgrade system lands.
|
||||||
|
// Builder → "Lv. 1" (per design; may carry experience later)
|
||||||
|
// Tower → "Lv. 1" (will reflect tower upgrade tier when implemented)
|
||||||
|
// Nothing → blank
|
||||||
|
if (levelLabel != null)
|
||||||
|
levelLabel.text = selection != null ? "Lv. 1" : "";
|
||||||
|
|
||||||
|
// Build-progress block visibility: shown only for BuildSiteVisual.
|
||||||
|
// Stat lines remain underneath and just stay empty for that case.
|
||||||
|
bool isBuildSite = selection is BuildSiteVisual;
|
||||||
|
if (buildProgressContainer != null)
|
||||||
|
buildProgressContainer.style.display =
|
||||||
|
isBuildSite ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
|
|
||||||
|
// Stat lines — clear and rebuild based on selection kind.
|
||||||
|
if (statLines != null)
|
||||||
|
{
|
||||||
|
statLines.Clear();
|
||||||
|
if (selection is Builder builder)
|
||||||
|
{
|
||||||
|
AddStatLine($"Build range: {builder.BuildRange:0.0}");
|
||||||
|
}
|
||||||
|
else if (selection is TowerInstance tower)
|
||||||
|
{
|
||||||
|
var def = tower.Definition;
|
||||||
|
if (def != null)
|
||||||
|
{
|
||||||
|
if (def.Damage > 0) AddStatLine($"Damage: {def.Damage}");
|
||||||
|
if (def.Range > 0) AddStatLine($"Range: {def.Range:0.0}");
|
||||||
|
if (def.FireRate > 0) AddStatLine($"Fire rate: {def.FireRate:0.0}/s");
|
||||||
|
if (def.SlowFactor < 1f)
|
||||||
|
AddStatLine($"Slow: {(1f - def.SlowFactor) * 100f:0}%");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// BuildSiteVisual: no stat lines — progress bar conveys the state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddStatLine(string text)
|
||||||
|
{
|
||||||
|
var label = new Label(text);
|
||||||
|
label.AddToClassList("stat-line");
|
||||||
|
statLines.Add(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Tooltip ----------------------------------------------------
|
// ----- Tooltip ----------------------------------------------------
|
||||||
|
|
@ -391,11 +683,5 @@ namespace TD.UI
|
||||||
var el = root.Q<VisualElement>(name);
|
var el = root.Q<VisualElement>(name);
|
||||||
if (el != null) el.pickingMode = PickingMode.Ignore;
|
if (el != null) el.pickingMode = PickingMode.Ignore;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SetEnabled(VisualElement root, string name, bool enabled)
|
|
||||||
{
|
|
||||||
var el = root.Q<Button>(name);
|
|
||||||
if (el != null) el.SetEnabled(enabled);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,15 @@ namespace TD.UI.Minimap
|
||||||
// Outline added to builder icons so they read against same-color zone fill.
|
// Outline added to builder icons so they read against same-color zone fill.
|
||||||
private static readonly Color BuilderOutline = new Color(1f, 1f, 1f, 0.85f);
|
private static readonly Color BuilderOutline = new Color(1f, 1f, 1f, 0.85f);
|
||||||
|
|
||||||
|
// Viewport trapezoid (the "what the player sees" rectangle on the minimap).
|
||||||
|
// Drawn on top of all entities so it's always readable; matches WC3 visual style.
|
||||||
|
private static readonly Color ViewportColor = new Color(1f, 1f, 1f, 0.85f);
|
||||||
|
private const float ViewportLineWidth = 1.5f;
|
||||||
|
|
||||||
|
// Reused buffer for the camera's four world-space view corners. Heap-allocated
|
||||||
|
// once and refilled every repaint to avoid per-frame GC.
|
||||||
|
private readonly Vector3[] viewCornersBuf = new Vector3[4];
|
||||||
|
|
||||||
// ----- Refs -------------------------------------------------------
|
// ----- Refs -------------------------------------------------------
|
||||||
|
|
||||||
private readonly VisualElement container;
|
private readonly VisualElement container;
|
||||||
|
|
@ -309,12 +318,15 @@ namespace TD.UI.Minimap
|
||||||
|
|
||||||
private void HandleRightClickMove(Vector2 uiLocal)
|
private void HandleRightClickMove(Vector2 uiLocal)
|
||||||
{
|
{
|
||||||
|
// Right-click on the minimap = "send my builder there". Only meaningful when
|
||||||
|
// the LOCAL BUILDER is selected; a tower or enemy selection has no move action.
|
||||||
var selection = SelectionState.Instance;
|
var selection = SelectionState.Instance;
|
||||||
if (selection == null || !selection.HasSelection) return;
|
var builder = selection?.SelectedBuilder;
|
||||||
|
if (builder == null) return;
|
||||||
|
|
||||||
Vector3 worldTarget = UIToWorld(uiLocal);
|
Vector3 worldTarget = UIToWorld(uiLocal);
|
||||||
// Same RPC the world right-click uses; server validates and side-effects the queue.
|
// Same RPC the world right-click uses; server validates and side-effects the queue.
|
||||||
selection.SelectedBuilder.RequestMoveAndPauseRpc(worldTarget);
|
builder.RequestMoveAndPauseRpc(worldTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Zoom -------------------------------------------------------
|
// ----- Zoom -------------------------------------------------------
|
||||||
|
|
@ -436,6 +448,40 @@ namespace TD.UI.Minimap
|
||||||
// (e.g., on a dedicated server or before the local client's builder arrives).
|
// (e.g., on a dedicated server or before the local client's builder arrives).
|
||||||
if (localBuilder != null)
|
if (localBuilder != null)
|
||||||
DrawOneEntity(painter, localBuilder, pxPerWorld);
|
DrawOneEntity(painter, localBuilder, pxPerWorld);
|
||||||
|
|
||||||
|
// Pass 3: the camera-viewport trapezoid sits on top of everything so the
|
||||||
|
// player can always see where they're looking, regardless of zone tint or
|
||||||
|
// unit density underneath.
|
||||||
|
DrawViewportRect(painter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draws a thin white outline matching the camera's footprint on the buildable
|
||||||
|
// plane. Because the camera is angled, the on-plane footprint is a TRAPEZOID
|
||||||
|
// (far edge wider than near edge) — that's the visual we want, since it tells
|
||||||
|
// the player how much of the world they're actually seeing at the current pitch.
|
||||||
|
private void DrawViewportRect(Painter2D painter)
|
||||||
|
{
|
||||||
|
if (cameraController == null) return;
|
||||||
|
// Even when one or more corners can't be projected onto the plane (camera at
|
||||||
|
// the horizon), the fallback puts them at a far point along the ray. The
|
||||||
|
// resulting UI coords land outside the container and get clipped by
|
||||||
|
// overflow:hidden — perfectly acceptable visual.
|
||||||
|
cameraController.TryGetViewportWorldCorners(viewCornersBuf);
|
||||||
|
|
||||||
|
Vector2 a = WorldToUI(viewCornersBuf[0]);
|
||||||
|
Vector2 b = WorldToUI(viewCornersBuf[1]);
|
||||||
|
Vector2 c = WorldToUI(viewCornersBuf[2]);
|
||||||
|
Vector2 d = WorldToUI(viewCornersBuf[3]);
|
||||||
|
|
||||||
|
painter.strokeColor = ViewportColor;
|
||||||
|
painter.lineWidth = ViewportLineWidth;
|
||||||
|
painter.BeginPath();
|
||||||
|
painter.MoveTo(a);
|
||||||
|
painter.LineTo(b);
|
||||||
|
painter.LineTo(c);
|
||||||
|
painter.LineTo(d);
|
||||||
|
painter.ClosePath();
|
||||||
|
painter.Stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawOneEntity(Painter2D p, IMinimapEntity entity, float pxPerWorld)
|
private void DrawOneEntity(Painter2D p, IMinimapEntity entity, float pxPerWorld)
|
||||||
|
|
|
||||||
|
|
@ -154,108 +154,255 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- BOTTOM UI STRIP ---------------------------------------- */
|
/* ---- BOTTOM UI STRIP ---------------------------------------- */
|
||||||
|
/* Five separate framed sections, left to right:
|
||||||
|
1. Minimap — tall frame, 180w x 220h
|
||||||
|
2. Portrait — short frame, 90w x 165h (bottom-aligned)
|
||||||
|
3. Info Panel — short frame, 280w x 165h
|
||||||
|
4. Tool Tip — short frame, 120w x 165h
|
||||||
|
5. Action Menu — tall frame, 320w x 220h (hides via display:none
|
||||||
|
when no actions are available for the selection)
|
||||||
|
Sections sit on a transparent strip with 100px margins on each side; the
|
||||||
|
3D scene shows through above and beside them. */
|
||||||
|
|
||||||
.bottom-ui {
|
.bottom-ui {
|
||||||
height: 110px;
|
height: 220px; /* matches the tall sections */
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: stretch;
|
align-items: flex-end; /* short sections hug the bottom */
|
||||||
background-color: rgb(19, 15, 4);
|
justify-content: flex-start;
|
||||||
border-top-width: 2px;
|
padding: 0 100px; /* margin on either side — the X regions */
|
||||||
border-top-color: rgba(90, 74, 16, 1);
|
background-color: rgba(0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Minimap — host container. MinimapView injects two stacked sub-elements
|
/* ----- Section 1: Minimap (tall) ----- */
|
||||||
(.minimap-terrain and .minimap-entities) at runtime. */
|
.minimap-frame {
|
||||||
.minimap {
|
width: 180px;
|
||||||
width: 110px;
|
height: 220px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
padding: 4px;
|
||||||
|
background-color: rgb(35, 30, 15);
|
||||||
|
border-width: 3px;
|
||||||
|
border-color: rgb(120, 100, 50);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MinimapView injects .minimap-terrain and .minimap-entities into this. */
|
||||||
|
.minimap {
|
||||||
|
flex: 1;
|
||||||
background-color: rgb(8, 10, 12);
|
background-color: rgb(8, 10, 12);
|
||||||
border-right-width: 1px;
|
|
||||||
border-right-color: rgba(42, 58, 26, 1);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Static baked terrain. Background-image assigned by MinimapView. */
|
|
||||||
.minimap-terrain {
|
.minimap-terrain {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 0; right: 0; bottom: 0;
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
-unity-background-scale-mode: stretch-to-fill;
|
-unity-background-scale-mode: stretch-to-fill;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dynamic entity overlay. Drawn via Painter2D in generateVisualContent. */
|
|
||||||
.minimap-entities {
|
.minimap-entities {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 0; right: 0; bottom: 0;
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Portrait */
|
/* ----- Section 2: Portrait (short, bottom-aligned) ----- */
|
||||||
.portrait-box {
|
.portrait-frame {
|
||||||
width: 84px;
|
width: 90px;
|
||||||
|
height: 165px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
padding: 6px;
|
||||||
padding: 4px;
|
background-color: rgb(35, 30, 15);
|
||||||
border-right-width: 1px;
|
border-width: 2px;
|
||||||
border-right-color: rgba(58, 48, 16, 1);
|
border-color: rgb(80, 65, 35);
|
||||||
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portrait-image {
|
.portrait-image {
|
||||||
width: 58px;
|
width: 74px;
|
||||||
height: 72px;
|
height: 110px;
|
||||||
margin-bottom: 3px;
|
|
||||||
background-color: rgb(13, 26, 13);
|
background-color: rgb(13, 26, 13);
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
border-color: rgba(58, 90, 58, 1);
|
border-color: rgb(58, 90, 58);
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgb(220, 220, 200);
|
||||||
|
-unity-text-align: upper-center;
|
||||||
|
margin-top: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Section 3: Info Panel (short, name + stat lines) ----- */
|
||||||
|
.info-frame {
|
||||||
|
width: 280px;
|
||||||
|
height: 165px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background-color: rgb(15, 15, 15);
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: rgb(80, 65, 35);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portrait-name {
|
.portrait-name {
|
||||||
font-size: 9px;
|
font-size: 14px;
|
||||||
color: rgb(255, 224, 102);
|
color: rgb(255, 224, 102);
|
||||||
-unity-text-align: upper-center;
|
-unity-font-style: bold;
|
||||||
|
-unity-text-align: upper-left;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Command grid: 3 row-containers injected at runtime, each holding 4 buttons */
|
.stat-lines {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-line {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgb(220, 220, 200);
|
||||||
|
-unity-text-align: upper-left;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Build-progress block — shown in the info panel only when a BuildSiteVisual
|
||||||
|
is selected. Thin horizontal bar with a left-anchored fill, percent below. */
|
||||||
|
.build-progress {
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-progress-bg {
|
||||||
|
height: 10px;
|
||||||
|
background-color: rgb(30, 30, 24);
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: rgb(80, 65, 35);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The fill is anchored to the LEFT side via absolute positioning + 0 right
|
||||||
|
margin set in code (width is driven each frame from progress). Color is the
|
||||||
|
same green family as the selection ring for visual consistency. */
|
||||||
|
.build-progress-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 0%;
|
||||||
|
background-color: rgb(120, 220, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-progress-percent {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgb(220, 220, 200);
|
||||||
|
-unity-text-align: upper-left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Section 4: Tool Tip (short, hover-driven) ----- */
|
||||||
|
.tooltip-frame {
|
||||||
|
width: 120px;
|
||||||
|
height: 165px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: rgb(15, 15, 15);
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: rgb(80, 65, 35);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tt-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgb(255, 224, 102);
|
||||||
|
-unity-font-style: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tt-desc {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgb(170, 168, 144);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tt-stats {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgb(136, 170, 136);
|
||||||
|
margin-top: 3px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tt-cost {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgb(255, 224, 102);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Section 5: Action Menu (tall, hides when no actions are available) ----- */
|
||||||
|
.action-frame {
|
||||||
|
width: 320px;
|
||||||
|
height: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px;
|
||||||
|
background-color: rgb(35, 30, 15);
|
||||||
|
border-width: 3px;
|
||||||
|
border-color: rgb(120, 100, 50);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Command grid: 3 row-containers injected at runtime, each holding 5 buttons. */
|
||||||
.command-grid {
|
.command-grid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cmd-row {
|
.cmd-row {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Individual command buttons */
|
/* Button is the cell. The icon-placeholder fills the cell; hotkey and cost
|
||||||
|
labels are absolute-positioned overlays in the top-left and bottom-left
|
||||||
|
corners. (Name label was removed — name lives in the Tool Tip box on hover.) */
|
||||||
.cmd-btn {
|
.cmd-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
align-items: stretch;
|
||||||
align-items: center;
|
justify-content: flex-start;
|
||||||
justify-content: center;
|
padding: 3px;
|
||||||
padding: 2px;
|
|
||||||
background-color: rgb(26, 26, 10);
|
background-color: rgb(26, 26, 10);
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
border-color: rgba(58, 58, 26, 1);
|
border-color: rgb(58, 58, 26);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
margin-right: 2px;
|
margin-right: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Icon area placeholder — replaced by background-image when art exists */
|
/* Placeholder icon — flat grey square with embossed borders (light top/left,
|
||||||
|
dark bottom/right) to suggest a raised 3D button. Swap for background-image
|
||||||
|
when art exists. */
|
||||||
.cmd-icon-placeholder {
|
.cmd-icon-placeholder {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
background-color: rgb(140, 140, 140);
|
||||||
background-color: rgba(255, 255, 255, 0.06);
|
border-top-width: 2px;
|
||||||
|
border-left-width: 2px;
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-right-width: 2px;
|
||||||
|
border-top-color: rgb(220, 220, 220);
|
||||||
|
border-left-color: rgb(220, 220, 220);
|
||||||
|
border-bottom-color: rgb(70, 70, 70);
|
||||||
|
border-right-color: rgb(70, 70, 70);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.cmd-btn:hover {
|
.cmd-btn:hover {
|
||||||
background-color: rgb(42, 42, 21);
|
background-color: rgb(42, 42, 21);
|
||||||
border-color: rgba(106, 106, 48, 1);
|
border-color: rgb(106, 106, 48);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cmd-btn.active {
|
.cmd-btn.active {
|
||||||
|
|
@ -271,87 +418,29 @@
|
||||||
opacity: 0.12;
|
opacity: 0.12;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cmd-icon {
|
/* Hotkey badge — top-left corner of the button, overlapping the icon. */
|
||||||
font-size: 14px;
|
.cmd-hotkey {
|
||||||
-unity-text-align: upper-center;
|
position: absolute;
|
||||||
color: rgb(255, 255, 255);
|
top: 2px;
|
||||||
/* Icon is set as text on a Label; swap for background-image when art exists */
|
left: 4px;
|
||||||
}
|
|
||||||
|
|
||||||
.cmd-name {
|
|
||||||
font-size: 7px;
|
|
||||||
color: rgb(170, 170, 170);
|
|
||||||
-unity-text-align: upper-center;
|
|
||||||
white-space: normal;
|
|
||||||
overflow: hidden;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmd-cost {
|
|
||||||
font-size: 7px;
|
|
||||||
color: rgb(255, 224, 102);
|
|
||||||
-unity-text-align: upper-center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Side column: Upgrade / (empty) / Sell */
|
|
||||||
.side-col {
|
|
||||||
width: 66px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 4px;
|
|
||||||
border-left-width: 2px;
|
|
||||||
border-left-color: rgba(58, 48, 16, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-col > * {
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.side-btn {
|
|
||||||
flex: 1;
|
|
||||||
/* inherits .cmd-btn styles */
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-empty {
|
|
||||||
flex: 1;
|
|
||||||
opacity: 0.12;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tooltip panel */
|
|
||||||
.tooltip-box {
|
|
||||||
width: 164px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 8px;
|
|
||||||
border-left-width: 1px;
|
|
||||||
border-left-color: rgba(58, 48, 16, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tt-title {
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
color: rgb(255, 255, 255);
|
||||||
|
-unity-font-style: bold;
|
||||||
|
/* Drop-shadow-ish backdrop via semi-opaque text background for legibility. */
|
||||||
|
background-color: rgba(0, 0, 0, 0.55);
|
||||||
|
padding: 0 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cost badge — bottom-left corner of the icon. */
|
||||||
|
.cmd-cost {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
left: 4px;
|
||||||
|
font-size: 10px;
|
||||||
color: rgb(255, 224, 102);
|
color: rgb(255, 224, 102);
|
||||||
-unity-font-style: bold;
|
-unity-font-style: bold;
|
||||||
margin-bottom: 4px;
|
background-color: rgba(0, 0, 0, 0.55);
|
||||||
white-space: normal;
|
padding: 0 3px;
|
||||||
}
|
border-radius: 2px;
|
||||||
|
|
||||||
.tt-desc {
|
|
||||||
font-size: 9px;
|
|
||||||
color: rgb(170, 168, 144);
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tt-stats {
|
|
||||||
font-size: 9px;
|
|
||||||
color: rgb(136, 170, 136);
|
|
||||||
margin-top: 3px;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tt-cost {
|
|
||||||
font-size: 9px;
|
|
||||||
color: rgb(255, 224, 102);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,29 +43,43 @@
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
<ui:VisualElement name="bottom-ui" class="bottom-ui">
|
<ui:VisualElement name="bottom-ui" class="bottom-ui">
|
||||||
|
<!-- Section 1: Minimap (tall frame) -->
|
||||||
|
<ui:VisualElement name="minimap-frame" class="minimap-frame">
|
||||||
<ui:VisualElement name="minimap" class="minimap"/>
|
<ui:VisualElement name="minimap" class="minimap"/>
|
||||||
<ui:VisualElement name="portrait-box" class="portrait-box">
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Section 2: Portrait (short frame) -->
|
||||||
|
<ui:VisualElement name="portrait-frame" class="portrait-frame">
|
||||||
<ui:VisualElement name="portrait-image" class="portrait-image"/>
|
<ui:VisualElement name="portrait-image" class="portrait-image"/>
|
||||||
|
<ui:Label name="level-label" text="" class="level-label"/>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Section 3: Info Panel — name + contextual stat lines (short frame).
|
||||||
|
The build-progress block is hidden by default and only shown when
|
||||||
|
a BuildSiteVisual is selected (HUDController toggles via style.display). -->
|
||||||
|
<ui:VisualElement name="info-frame" class="info-frame">
|
||||||
<ui:Label name="portrait-name" text="" class="portrait-name"/>
|
<ui:Label name="portrait-name" text="" class="portrait-name"/>
|
||||||
|
<ui:VisualElement name="stat-lines" class="stat-lines"/>
|
||||||
|
<ui:VisualElement name="build-progress" class="build-progress" style="display: none;">
|
||||||
|
<ui:VisualElement name="build-progress-bg" class="build-progress-bg">
|
||||||
|
<ui:VisualElement name="build-progress-fill" class="build-progress-fill"/>
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
<ui:VisualElement name="command-grid" class="command-grid"/>
|
<ui:Label name="build-progress-percent" text="0%" class="build-progress-percent"/>
|
||||||
<ui:VisualElement name="side-col" class="side-col">
|
|
||||||
<ui:Button name="upgrade-btn" class="cmd-btn side-btn">
|
|
||||||
<ui:Label text="↑" class="cmd-icon"/>
|
|
||||||
<ui:Label text="Upgrade" class="cmd-name"/>
|
|
||||||
</ui:Button>
|
|
||||||
<ui:VisualElement class="side-empty"/>
|
|
||||||
<ui:Button name="sell-btn" class="cmd-btn side-btn">
|
|
||||||
<ui:Label text="✕" class="cmd-icon"/>
|
|
||||||
<ui:Label text="Sell" class="cmd-name"/>
|
|
||||||
</ui:Button>
|
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
<ui:VisualElement name="tooltip-box" class="tooltip-box">
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Section 4: Tool Tip — shows info for whatever the player hovers in the action menu (short frame) -->
|
||||||
|
<ui:VisualElement name="tooltip-frame" class="tooltip-frame">
|
||||||
<ui:Label name="tt-title" text="" class="tt-title"/>
|
<ui:Label name="tt-title" text="" class="tt-title"/>
|
||||||
<ui:Label name="tt-desc" text="" class="tt-desc"/>
|
<ui:Label name="tt-desc" text="" class="tt-desc"/>
|
||||||
<ui:Label name="tt-stats" text="" class="tt-stats"/>
|
<ui:Label name="tt-stats" text="" class="tt-stats"/>
|
||||||
<ui:Label name="tt-cost" text="" class="tt-cost"/>
|
<ui:Label name="tt-cost" text="" class="tt-cost"/>
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- Section 5: Action Menu (tall frame). Hides entirely when nothing actionable is selected. -->
|
||||||
|
<ui:VisualElement name="action-frame" class="action-frame">
|
||||||
|
<ui:VisualElement name="command-grid" class="command-grid"/>
|
||||||
|
</ui:VisualElement>
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
</ui:UXML>
|
</ui:UXML>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue