Everything related to chat functionality, and updating the projectile prefab to rotate properly
This commit is contained in:
parent
d92d00c83f
commit
66f84652dc
14 changed files with 1133 additions and 121 deletions
|
|
@ -9,9 +9,6 @@ GameObject:
|
||||||
serializedVersion: 6
|
serializedVersion: 6
|
||||||
m_Component:
|
m_Component:
|
||||||
- component: {fileID: 5346505748924138806}
|
- component: {fileID: 5346505748924138806}
|
||||||
- component: {fileID: 2441523409650755752}
|
|
||||||
- component: {fileID: 3004458703758247920}
|
|
||||||
- component: {fileID: 7079697957481585344}
|
|
||||||
- component: {fileID: 6722677248442714376}
|
- component: {fileID: 6722677248442714376}
|
||||||
- component: {fileID: 3956702135205686426}
|
- component: {fileID: 3956702135205686426}
|
||||||
- component: {fileID: 6538425601827344639}
|
- component: {fileID: 6538425601827344639}
|
||||||
|
|
@ -30,93 +27,14 @@ Transform:
|
||||||
m_PrefabAsset: {fileID: 0}
|
m_PrefabAsset: {fileID: 0}
|
||||||
m_GameObject: {fileID: 2664719039363295382}
|
m_GameObject: {fileID: 2664719039363295382}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_LocalRotation: {x: 0.7071068, y: 0, z: 0, w: 0.7071068}
|
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||||
m_LocalScale: {x: 0.2, y: 0.2, z: 0.2}
|
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children: []
|
m_Children:
|
||||||
|
- {fileID: 7632848354235619530}
|
||||||
m_Father: {fileID: 0}
|
m_Father: {fileID: 0}
|
||||||
m_LocalEulerAnglesHint: {x: 90, y: 0, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
--- !u!33 &2441523409650755752
|
|
||||||
MeshFilter:
|
|
||||||
m_ObjectHideFlags: 0
|
|
||||||
m_CorrespondingSourceObject: {fileID: 0}
|
|
||||||
m_PrefabInstance: {fileID: 0}
|
|
||||||
m_PrefabAsset: {fileID: 0}
|
|
||||||
m_GameObject: {fileID: 2664719039363295382}
|
|
||||||
m_Mesh: {fileID: 10208, guid: 0000000000000000e000000000000000, type: 0}
|
|
||||||
--- !u!23 &3004458703758247920
|
|
||||||
MeshRenderer:
|
|
||||||
m_ObjectHideFlags: 0
|
|
||||||
m_CorrespondingSourceObject: {fileID: 0}
|
|
||||||
m_PrefabInstance: {fileID: 0}
|
|
||||||
m_PrefabAsset: {fileID: 0}
|
|
||||||
m_GameObject: {fileID: 2664719039363295382}
|
|
||||||
m_Enabled: 1
|
|
||||||
m_CastShadows: 1
|
|
||||||
m_ReceiveShadows: 1
|
|
||||||
m_DynamicOccludee: 1
|
|
||||||
m_StaticShadowCaster: 0
|
|
||||||
m_MotionVectors: 1
|
|
||||||
m_LightProbeUsage: 1
|
|
||||||
m_ReflectionProbeUsage: 1
|
|
||||||
m_RayTracingMode: 2
|
|
||||||
m_RayTraceProcedural: 0
|
|
||||||
m_RayTracingAccelStructBuildFlagsOverride: 0
|
|
||||||
m_RayTracingAccelStructBuildFlags: 1
|
|
||||||
m_SmallMeshCulling: 1
|
|
||||||
m_ForceMeshLod: -1
|
|
||||||
m_MeshLodSelectionBias: 0
|
|
||||||
m_RenderingLayerMask: 1
|
|
||||||
m_RendererPriority: 0
|
|
||||||
m_Materials:
|
|
||||||
- {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2}
|
|
||||||
m_StaticBatchInfo:
|
|
||||||
firstSubMesh: 0
|
|
||||||
subMeshCount: 0
|
|
||||||
m_StaticBatchRoot: {fileID: 0}
|
|
||||||
m_ProbeAnchor: {fileID: 0}
|
|
||||||
m_LightProbeVolumeOverride: {fileID: 0}
|
|
||||||
m_ScaleInLightmap: 1
|
|
||||||
m_ReceiveGI: 1
|
|
||||||
m_PreserveUVs: 0
|
|
||||||
m_IgnoreNormalsForChartDetection: 0
|
|
||||||
m_ImportantGI: 0
|
|
||||||
m_StitchLightmapSeams: 1
|
|
||||||
m_SelectedEditorRenderState: 3
|
|
||||||
m_MinimumChartSize: 4
|
|
||||||
m_AutoUVMaxDistance: 0.5
|
|
||||||
m_AutoUVMaxAngle: 89
|
|
||||||
m_LightmapParameters: {fileID: 0}
|
|
||||||
m_GlobalIlluminationMeshLod: 0
|
|
||||||
m_SortingLayerID: 0
|
|
||||||
m_SortingLayer: 0
|
|
||||||
m_SortingOrder: 0
|
|
||||||
m_MaskInteraction: 0
|
|
||||||
m_AdditionalVertexStreams: {fileID: 0}
|
|
||||||
--- !u!136 &7079697957481585344
|
|
||||||
CapsuleCollider:
|
|
||||||
m_ObjectHideFlags: 0
|
|
||||||
m_CorrespondingSourceObject: {fileID: 0}
|
|
||||||
m_PrefabInstance: {fileID: 0}
|
|
||||||
m_PrefabAsset: {fileID: 0}
|
|
||||||
m_GameObject: {fileID: 2664719039363295382}
|
|
||||||
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: 2
|
|
||||||
m_Radius: 0.5
|
|
||||||
m_Height: 2
|
|
||||||
m_Direction: 1
|
|
||||||
m_Center: {x: 0, y: 0, z: 0}
|
|
||||||
--- !u!114 &6722677248442714376
|
--- !u!114 &6722677248442714376
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -129,7 +47,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: 0
|
GlobalObjectIdHash: 1313099294
|
||||||
InScenePlacedSourceGlobalObjectIdHash: 0
|
InScenePlacedSourceGlobalObjectIdHash: 0
|
||||||
DeferredDespawnTick: 0
|
DeferredDespawnTick: 0
|
||||||
Ownership: 1
|
Ownership: 1
|
||||||
|
|
@ -201,3 +119,117 @@ MonoBehaviour:
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Combat.Projectile
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Combat.Projectile
|
||||||
ShowTopMostFoldoutHeaderGroup: 1
|
ShowTopMostFoldoutHeaderGroup: 1
|
||||||
|
--- !u!1 &7506836928445288017
|
||||||
|
GameObject:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
serializedVersion: 6
|
||||||
|
m_Component:
|
||||||
|
- component: {fileID: 7632848354235619530}
|
||||||
|
- component: {fileID: 4551024979539679785}
|
||||||
|
- component: {fileID: 5049500473503041581}
|
||||||
|
- component: {fileID: 2048189613272339759}
|
||||||
|
m_Layer: 0
|
||||||
|
m_Name: BulletVisual
|
||||||
|
m_TagString: Untagged
|
||||||
|
m_Icon: {fileID: 0}
|
||||||
|
m_NavMeshLayer: 0
|
||||||
|
m_StaticEditorFlags: 0
|
||||||
|
m_IsActive: 1
|
||||||
|
--- !u!4 &7632848354235619530
|
||||||
|
Transform:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 7506836928445288017}
|
||||||
|
serializedVersion: 2
|
||||||
|
m_LocalRotation: {x: 0.7071068, y: 0, z: 0, w: 0.7071068}
|
||||||
|
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||||
|
m_LocalScale: {x: 0.2, y: 0.2, z: 0.2}
|
||||||
|
m_ConstrainProportionsScale: 0
|
||||||
|
m_Children: []
|
||||||
|
m_Father: {fileID: 5346505748924138806}
|
||||||
|
m_LocalEulerAnglesHint: {x: 90, y: 0, z: 0}
|
||||||
|
--- !u!33 &4551024979539679785
|
||||||
|
MeshFilter:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 7506836928445288017}
|
||||||
|
m_Mesh: {fileID: 10208, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
|
--- !u!23 &5049500473503041581
|
||||||
|
MeshRenderer:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 7506836928445288017}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_CastShadows: 1
|
||||||
|
m_ReceiveShadows: 1
|
||||||
|
m_DynamicOccludee: 1
|
||||||
|
m_StaticShadowCaster: 0
|
||||||
|
m_MotionVectors: 1
|
||||||
|
m_LightProbeUsage: 1
|
||||||
|
m_ReflectionProbeUsage: 1
|
||||||
|
m_RayTracingMode: 2
|
||||||
|
m_RayTraceProcedural: 0
|
||||||
|
m_RayTracingAccelStructBuildFlagsOverride: 0
|
||||||
|
m_RayTracingAccelStructBuildFlags: 1
|
||||||
|
m_SmallMeshCulling: 1
|
||||||
|
m_ForceMeshLod: -1
|
||||||
|
m_MeshLodSelectionBias: 0
|
||||||
|
m_RenderingLayerMask: 1
|
||||||
|
m_RendererPriority: 0
|
||||||
|
m_Materials:
|
||||||
|
- {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2}
|
||||||
|
m_StaticBatchInfo:
|
||||||
|
firstSubMesh: 0
|
||||||
|
subMeshCount: 0
|
||||||
|
m_StaticBatchRoot: {fileID: 0}
|
||||||
|
m_ProbeAnchor: {fileID: 0}
|
||||||
|
m_LightProbeVolumeOverride: {fileID: 0}
|
||||||
|
m_ScaleInLightmap: 1
|
||||||
|
m_ReceiveGI: 1
|
||||||
|
m_PreserveUVs: 0
|
||||||
|
m_IgnoreNormalsForChartDetection: 0
|
||||||
|
m_ImportantGI: 0
|
||||||
|
m_StitchLightmapSeams: 1
|
||||||
|
m_SelectedEditorRenderState: 3
|
||||||
|
m_MinimumChartSize: 4
|
||||||
|
m_AutoUVMaxDistance: 0.5
|
||||||
|
m_AutoUVMaxAngle: 89
|
||||||
|
m_LightmapParameters: {fileID: 0}
|
||||||
|
m_GlobalIlluminationMeshLod: 0
|
||||||
|
m_SortingLayerID: 0
|
||||||
|
m_SortingLayer: 0
|
||||||
|
m_SortingOrder: 0
|
||||||
|
m_MaskInteraction: 0
|
||||||
|
m_AdditionalVertexStreams: {fileID: 0}
|
||||||
|
--- !u!136 &2048189613272339759
|
||||||
|
CapsuleCollider:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 7506836928445288017}
|
||||||
|
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: 2
|
||||||
|
m_Radius: 0.5
|
||||||
|
m_Height: 2
|
||||||
|
m_Direction: 1
|
||||||
|
m_Center: {x: 0, y: 0, z: 0}
|
||||||
|
|
|
||||||
|
|
@ -1077,6 +1077,10 @@ MonoBehaviour:
|
||||||
placementManager: {fileID: 1507514108}
|
placementManager: {fileID: 1507514108}
|
||||||
cameraController: {fileID: 1239994223}
|
cameraController: {fileID: 1239994223}
|
||||||
rejectionMessageDuration: 2.5
|
rejectionMessageDuration: 2.5
|
||||||
|
chatMaxHeight: 280
|
||||||
|
chatMaxMessages: 2147483647
|
||||||
|
chatSystemColor: {r: 1, g: 0.7, b: 0.2, a: 1}
|
||||||
|
chatPlayerColor: {r: 0.92, g: 0.92, b: 0.92, a: 1}
|
||||||
--- !u!114 &1058315975
|
--- !u!114 &1058315975
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -1255,6 +1259,77 @@ 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 &1119106649
|
||||||
|
GameObject:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
serializedVersion: 6
|
||||||
|
m_Component:
|
||||||
|
- component: {fileID: 1119106651}
|
||||||
|
- component: {fileID: 1119106650}
|
||||||
|
- component: {fileID: 1119106652}
|
||||||
|
m_Layer: 0
|
||||||
|
m_Name: ChatService
|
||||||
|
m_TagString: Untagged
|
||||||
|
m_Icon: {fileID: 0}
|
||||||
|
m_NavMeshLayer: 0
|
||||||
|
m_StaticEditorFlags: 0
|
||||||
|
m_IsActive: 1
|
||||||
|
--- !u!114 &1119106650
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 1119106649}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
|
||||||
|
GlobalObjectIdHash: 4256228569
|
||||||
|
InScenePlacedSourceGlobalObjectIdHash: 0
|
||||||
|
DeferredDespawnTick: 0
|
||||||
|
Ownership: 1
|
||||||
|
AlwaysReplicateAsRoot: 0
|
||||||
|
SynchronizeTransform: 1
|
||||||
|
ActiveSceneSynchronization: 0
|
||||||
|
SceneMigrationSynchronization: 0
|
||||||
|
SpawnWithObservers: 1
|
||||||
|
DontDestroyWithOwner: 0
|
||||||
|
AutoObjectParentSync: 1
|
||||||
|
SyncOwnerTransformWhenParented: 1
|
||||||
|
AllowOwnerToParent: 0
|
||||||
|
--- !u!4 &1119106651
|
||||||
|
Transform:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 1119106649}
|
||||||
|
serializedVersion: 2
|
||||||
|
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||||
|
m_LocalPosition: {x: 19.24981, y: 0.5, z: 16.48685}
|
||||||
|
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!114 &1119106652
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 1119106649}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: 816890bb20c416b419e38d3d4b91ffca, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.ChatService
|
||||||
|
ShowTopMostFoldoutHeaderGroup: 1
|
||||||
--- !u!1 &1149980839
|
--- !u!1 &1149980839
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -1427,7 +1502,7 @@ MonoBehaviour:
|
||||||
edgePanEnabled: 1
|
edgePanEnabled: 1
|
||||||
minDollyDistance: 5
|
minDollyDistance: 5
|
||||||
maxDollyDistance: 50
|
maxDollyDistance: 50
|
||||||
startDollyDistance: 20
|
startDollyDistance: 35
|
||||||
zoomSpeed: 3
|
zoomSpeed: 3
|
||||||
cursorAnchoredZoom: 1
|
cursorAnchoredZoom: 1
|
||||||
minPitchDegrees: 30
|
minPitchDegrees: 30
|
||||||
|
|
@ -2539,3 +2614,4 @@ SceneRoots:
|
||||||
- {fileID: 1149980841}
|
- {fileID: 1149980841}
|
||||||
- {fileID: 1731269687}
|
- {fileID: 1731269687}
|
||||||
- {fileID: 1755074870}
|
- {fileID: 1755074870}
|
||||||
|
- {fileID: 1119106651}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ MonoBehaviour:
|
||||||
MapThumbnail: {fileID: 21300000, guid: d2e652d3e1c53454d80d3c1ec7888998, type: 3}
|
MapThumbnail: {fileID: 21300000, guid: d2e652d3e1c53454d80d3c1ec7888998, type: 3}
|
||||||
ScenePath: Assets/_Project/Scenes/Levels/Main.unity
|
ScenePath: Assets/_Project/Scenes/Levels/Main.unity
|
||||||
AuthoringHash: 23fbb3b3dc3b0e03aa6f52cd2607c08275b33919120b2d96ea68b69ade0656f9
|
AuthoringHash: 23fbb3b3dc3b0e03aa6f52cd2607c08275b33919120b2d96ea68b69ade0656f9
|
||||||
LastBakeTimestamp: 2026-05-11T05:20:39.4889478Z
|
LastBakeTimestamp: 2026-05-14T17:48:33.8501744Z
|
||||||
LastBakeOutcome: 1
|
LastBakeOutcome: 1
|
||||||
LastBakeWarningCount: 2
|
LastBakeWarningCount: 2
|
||||||
GridOriginTile: {x: 0, y: 0}
|
GridOriginTile: {x: 0, y: 0}
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,13 @@ namespace TD.Core
|
||||||
/// Yields the four cardinal neighbors of the given tile (N, E, S, W).
|
/// Yields the four cardinal neighbors of the given tile (N, E, S, W).
|
||||||
/// Does NOT check walkability or map bounds — that is the caller's job.
|
/// Does NOT check walkability or map bounds — that is the caller's job.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is the "edge-adjacency" primitive — two tiles are 4-connected if and
|
||||||
|
/// only if they share an edge. Used by bake-time checks that test "is this
|
||||||
|
/// volume adjacent to that volume" (goal-adjacency, leak-exit adjacency, etc.)
|
||||||
|
/// where edge-sharing is the semantic — NOT for pathfinding. For pathing use
|
||||||
|
/// <see cref="GetNeighbors8"/>.
|
||||||
|
/// </remarks>
|
||||||
public static IEnumerable<Vector2Int> GetNeighbors(Vector2Int tile)
|
public static IEnumerable<Vector2Int> GetNeighbors(Vector2Int tile)
|
||||||
{
|
{
|
||||||
yield return new Vector2Int(tile.x, tile.y + 1); // North (+z)
|
yield return new Vector2Int(tile.x, tile.y + 1); // North (+z)
|
||||||
|
|
@ -98,6 +105,77 @@ namespace TD.Core
|
||||||
yield return new Vector2Int(tile.x - 1, tile.y); // West (-x)
|
yield return new Vector2Int(tile.x - 1, tile.y); // West (-x)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Yields all eight neighbors of the given tile — the four cardinals plus
|
||||||
|
/// the four diagonals (NE, SE, SW, NW). Cardinals are yielded first so
|
||||||
|
/// equal-cost ordering favors cardinal moves at tie-breaks. Does NOT check
|
||||||
|
/// walkability or map bounds.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Used by pathfinding (A*, BFS) for 8-connected movement. Callers must
|
||||||
|
/// check <see cref="IsDiagonal"/> for each yielded neighbor and reject the
|
||||||
|
/// move when either of the two "shoulder" cardinal tiles is non-walkable
|
||||||
|
/// (corner-cut prevention) — see <see cref="GetCornerShoulders"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public static IEnumerable<Vector2Int> GetNeighbors8(Vector2Int tile)
|
||||||
|
{
|
||||||
|
// Cardinals first.
|
||||||
|
yield return new Vector2Int(tile.x, tile.y + 1); // N
|
||||||
|
yield return new Vector2Int(tile.x + 1, tile.y); // E
|
||||||
|
yield return new Vector2Int(tile.x, tile.y - 1); // S
|
||||||
|
yield return new Vector2Int(tile.x - 1, tile.y); // W
|
||||||
|
// Diagonals.
|
||||||
|
yield return new Vector2Int(tile.x + 1, tile.y + 1); // NE
|
||||||
|
yield return new Vector2Int(tile.x + 1, tile.y - 1); // SE
|
||||||
|
yield return new Vector2Int(tile.x - 1, tile.y - 1); // SW
|
||||||
|
yield return new Vector2Int(tile.x - 1, tile.y + 1); // NW
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if <paramref name="from"/> → <paramref name="to"/> is a single diagonal
|
||||||
|
/// step (both X and Y differ by exactly 1). Both tiles assumed to be 8-neighbors.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsDiagonal(Vector2Int from, Vector2Int to)
|
||||||
|
{
|
||||||
|
return Mathf.Abs(from.x - to.x) == 1 && Mathf.Abs(from.y - to.y) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For a diagonal step <paramref name="from"/> → <paramref name="to"/>, returns the
|
||||||
|
/// two cardinal "shoulder" tiles. To prevent corner-cutting (squeezing through a
|
||||||
|
/// 1-tile diagonal gap between walls), pathfinders should reject the diagonal step
|
||||||
|
/// if either shoulder is non-walkable.
|
||||||
|
/// </summary>
|
||||||
|
public static void GetCornerShoulders(Vector2Int from, Vector2Int to,
|
||||||
|
out Vector2Int shoulderA, out Vector2Int shoulderB)
|
||||||
|
{
|
||||||
|
shoulderA = new Vector2Int(to.x, from.y); // step in X first
|
||||||
|
shoulderB = new Vector2Int(from.x, to.y); // step in Y first
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cost of stepping from <paramref name="from"/> to its 8-neighbor <paramref name="to"/>.
|
||||||
|
/// Returns 1.0 for cardinal moves, √2 for diagonals. Used by 8-connected A*.
|
||||||
|
/// </summary>
|
||||||
|
public static float StepCost(Vector2Int from, Vector2Int to)
|
||||||
|
{
|
||||||
|
return IsDiagonal(from, to) ? 1.41421356f : 1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Octile distance between two tiles. Admissible heuristic for 8-connected A* on
|
||||||
|
/// a uniform-cost grid (cardinal cost 1, diagonal cost √2). Equivalent to:
|
||||||
|
/// <c>max(|dx|, |dy|) + (√2 - 1) * min(|dx|, |dy|)</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static float OctileDistance(Vector2Int a, Vector2Int b)
|
||||||
|
{
|
||||||
|
int dx = Mathf.Abs(a.x - b.x);
|
||||||
|
int dy = Mathf.Abs(a.y - b.y);
|
||||||
|
int max = Mathf.Max(dx, dy);
|
||||||
|
int min = Mathf.Min(dx, dy);
|
||||||
|
return max + 0.41421356f * min;
|
||||||
|
}
|
||||||
|
|
||||||
// ----- Footprint helpers ---------------------------------------------------
|
// ----- Footprint helpers ---------------------------------------------------
|
||||||
//
|
//
|
||||||
// Towers occupy a footprint of N x M tiles anchored at a single tile coordinate.
|
// Towers occupy a footprint of N x M tiles anchored at a single tile coordinate.
|
||||||
|
|
|
||||||
|
|
@ -694,10 +694,21 @@ namespace TD.Levels.Editor
|
||||||
var t = bfsQueue.Dequeue();
|
var t = bfsQueue.Dequeue();
|
||||||
if (exitTiles.Contains(t)) { reachedExit = true; break; }
|
if (exitTiles.Contains(t)) { reachedExit = true; break; }
|
||||||
|
|
||||||
foreach (var n in GridCoordinates.GetNeighbors(t))
|
// 8-connected with corner-cut prevention — must match the
|
||||||
|
// runtime PathfindingService / TowerPlacementManager rules.
|
||||||
|
foreach (var n in GridCoordinates.GetNeighbors8(t))
|
||||||
{
|
{
|
||||||
if (bfsVisited.Contains(n)) continue;
|
if (bfsVisited.Contains(n)) continue;
|
||||||
if (!walkableSet.Contains(n)) continue;
|
if (!walkableSet.Contains(n)) continue;
|
||||||
|
|
||||||
|
if (GridCoordinates.IsDiagonal(t, n))
|
||||||
|
{
|
||||||
|
GridCoordinates.GetCornerShoulders(t, n,
|
||||||
|
out var shoulderA, out var shoulderB);
|
||||||
|
if (!walkableSet.Contains(shoulderA) || !walkableSet.Contains(shoulderB))
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
bfsVisited.Add(n);
|
bfsVisited.Add(n);
|
||||||
bfsQueue.Enqueue(n);
|
bfsQueue.Enqueue(n);
|
||||||
}
|
}
|
||||||
|
|
@ -871,10 +882,21 @@ namespace TD.Levels.Editor
|
||||||
while (queue.Count > 0)
|
while (queue.Count > 0)
|
||||||
{
|
{
|
||||||
var t = queue.Dequeue();
|
var t = queue.Dequeue();
|
||||||
foreach (var n in GridCoordinates.GetNeighbors(t))
|
// 8-connected with corner-cut prevention — keeps component
|
||||||
|
// counting consistent with pathfinding semantics.
|
||||||
|
foreach (var n in GridCoordinates.GetNeighbors8(t))
|
||||||
{
|
{
|
||||||
if (visited.Contains(n)) continue;
|
if (visited.Contains(n)) continue;
|
||||||
if (!walkable.Contains(n)) continue;
|
if (!walkable.Contains(n)) continue;
|
||||||
|
|
||||||
|
if (GridCoordinates.IsDiagonal(t, n))
|
||||||
|
{
|
||||||
|
GridCoordinates.GetCornerShoulders(t, n,
|
||||||
|
out var shoulderA, out var shoulderB);
|
||||||
|
if (!walkable.Contains(shoulderA) || !walkable.Contains(shoulderB))
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
visited.Add(n);
|
visited.Add(n);
|
||||||
queue.Enqueue(n);
|
queue.Enqueue(n);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,10 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
// Escape: clear selection. Allowed during placement mode too — Escape never
|
// Escape: clear selection. Allowed during placement mode too — Escape never
|
||||||
// means anything else here, and clearing selection during placement is fine.
|
// means anything else here, and clearing selection during placement is fine.
|
||||||
if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame)
|
// Suppressed while chat (or any HUD text field) has focus, since Escape there
|
||||||
|
// means "cancel typing" and should not also clear the unit selection.
|
||||||
|
if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame
|
||||||
|
&& !HUDController.IsTextInputActive)
|
||||||
{
|
{
|
||||||
SelectionState.Instance?.Clear();
|
SelectionState.Instance?.Clear();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -227,8 +227,10 @@ namespace TD.Gameplay
|
||||||
{
|
{
|
||||||
Vector2 dir = Vector2.zero;
|
Vector2 dir = Vector2.zero;
|
||||||
|
|
||||||
// Keyboard: WASD + arrow keys
|
// Keyboard: WASD + arrow keys. Suppressed entirely while the player
|
||||||
var kb = Keyboard.current;
|
// is typing — pressing 'a' or 'w' into chat should not pan the camera.
|
||||||
|
// (Edge-pan below stays active since it's mouse-driven.)
|
||||||
|
var kb = HUDController.IsTextInputActive ? null : Keyboard.current;
|
||||||
if (kb != null)
|
if (kb != null)
|
||||||
{
|
{
|
||||||
if (kb.aKey.isPressed || kb.leftArrowKey.isPressed) dir.x -= 1f;
|
if (kb.aKey.isPressed || kb.leftArrowKey.isPressed) dir.x -= 1f;
|
||||||
|
|
|
||||||
159
Assets/_Project/Scripts/Gameplay/ChatService.cs
Normal file
159
Assets/_Project/Scripts/Gameplay/ChatService.cs
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
// Assets/_Project/Scripts/Gameplay/ChatService.cs
|
||||||
|
using Unity.Collections;
|
||||||
|
using Unity.Netcode;
|
||||||
|
using UnityEngine;
|
||||||
|
using TD.Core;
|
||||||
|
|
||||||
|
namespace TD.Gameplay
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Networked chat singleton. Carries player-typed messages between peers and
|
||||||
|
/// exposes a local-only entry point for system messages (life lost, income
|
||||||
|
/// changes, etc.). UI consumers subscribe to <see cref="OnMessageReceived"/>
|
||||||
|
/// to display the feed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Authority model.</b> Player messages go client → server (via
|
||||||
|
/// <see cref="SubmitMessage"/> + <see cref="SubmitMessageServerRpc"/>) so the
|
||||||
|
/// server gets a chance to validate/filter, then the server broadcasts to
|
||||||
|
/// every peer via <see cref="BroadcastMessageClientRpc"/>. The host receives
|
||||||
|
/// its own broadcast like any other client, so a single subscription path
|
||||||
|
/// handles every message type uniformly.
|
||||||
|
///
|
||||||
|
/// <b>System messages.</b> <see cref="PostLocalSystem"/> is local-only and
|
||||||
|
/// does NOT cross the network. Callers typically invoke it from inside an
|
||||||
|
/// already-replicated event (e.g. <see cref="WaveManager.OnLifeLost"/>, which
|
||||||
|
/// fires on every peer via its own ClientRpc) so each peer posts the system
|
||||||
|
/// message itself. This avoids paying a second round-trip for events that
|
||||||
|
/// are inherently broadcast already.
|
||||||
|
///
|
||||||
|
/// <b>Scene setup.</b> Drop a <c>ChatService</c> GameObject (with a
|
||||||
|
/// <c>NetworkObject</c>) into the gameplay scene. NGO 2.x auto-discovers
|
||||||
|
/// the prefab — no manual registration needed.
|
||||||
|
/// </remarks>
|
||||||
|
[RequireComponent(typeof(NetworkObject))]
|
||||||
|
public class ChatService : NetworkBehaviour
|
||||||
|
{
|
||||||
|
// ----- Singleton --------------------------------------------------
|
||||||
|
|
||||||
|
public static ChatService Instance { get; private set; }
|
||||||
|
|
||||||
|
// ----- Message types ----------------------------------------------
|
||||||
|
|
||||||
|
public enum MessageKind
|
||||||
|
{
|
||||||
|
Player,
|
||||||
|
System,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One chat feed entry. <see cref="SenderName"/> is empty for system messages.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct ChatEntry
|
||||||
|
{
|
||||||
|
public readonly MessageKind Kind;
|
||||||
|
public readonly string SenderName;
|
||||||
|
public readonly string Text;
|
||||||
|
|
||||||
|
public ChatEntry(MessageKind kind, string senderName, string text)
|
||||||
|
{
|
||||||
|
Kind = kind;
|
||||||
|
SenderName = senderName;
|
||||||
|
Text = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Local events -----------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires on every peer when a player message arrives (after the
|
||||||
|
/// server's ClientRpc) OR when a local system message is posted via
|
||||||
|
/// <see cref="PostLocalSystem"/>. HUD subscribes to render the feed.
|
||||||
|
/// </summary>
|
||||||
|
public static event System.Action<ChatEntry> OnMessageReceived;
|
||||||
|
|
||||||
|
// ----- NGO lifecycle ----------------------------------------------
|
||||||
|
|
||||||
|
public override void OnNetworkSpawn()
|
||||||
|
{
|
||||||
|
if (Instance != null && Instance != this)
|
||||||
|
{
|
||||||
|
Debug.LogError("[ChatService] Duplicate instance detected. " +
|
||||||
|
"Only one ChatService should exist per scene.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnNetworkDespawn()
|
||||||
|
{
|
||||||
|
if (Instance == this) Instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Player messages (network round-trip) -----------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Submits a chat message authored by the local player. Empty / whitespace
|
||||||
|
/// strings are dropped. Text is trimmed and truncated to fit the wire
|
||||||
|
/// payload (~120 chars).
|
||||||
|
/// </summary>
|
||||||
|
public void SubmitMessage(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return;
|
||||||
|
text = text.Trim();
|
||||||
|
if (text.Length > 120) text = text.Substring(0, 120);
|
||||||
|
|
||||||
|
FixedString128Bytes payload = default;
|
||||||
|
payload.Append(text);
|
||||||
|
SubmitMessageRpc(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NGO 2.x unified RPC attribute. SendTo.Server + InvokePermission.Everyone
|
||||||
|
// is the modern replacement for [ServerRpc(RequireOwnership = false)] —
|
||||||
|
// any client (not just the NetworkObject's owner) may invoke it.
|
||||||
|
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
|
||||||
|
private void SubmitMessageRpc(FixedString128Bytes text, RpcParams rpcParams = default)
|
||||||
|
{
|
||||||
|
// Validation / filtering hook — drop spam, profanity, etc. For now we
|
||||||
|
// just broadcast unchanged. The server origin tag means whatever
|
||||||
|
// appears in clients' chat is what the server allowed through.
|
||||||
|
BroadcastMessageRpc(rpcParams.Receive.SenderClientId, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTo.Everyone matches the old [ClientRpc] behavior under host mode —
|
||||||
|
// the body runs on every peer including the host's local client, so the
|
||||||
|
// host sees its own messages in the feed without a separate local call.
|
||||||
|
[Rpc(SendTo.Everyone)]
|
||||||
|
private void BroadcastMessageRpc(ulong senderClientId, FixedString128Bytes text)
|
||||||
|
{
|
||||||
|
string senderName = ResolveSenderName(senderClientId);
|
||||||
|
OnMessageReceived?.Invoke(
|
||||||
|
new ChatEntry(MessageKind.Player, senderName, text.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- System messages (local only) -------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Posts a system message to the local chat feed only. Does NOT cross the
|
||||||
|
/// network. Callers should invoke this from a code path that's already
|
||||||
|
/// replicated on every peer (e.g. a ClientRpc handler or an event that's
|
||||||
|
/// fired on every peer) so each peer sees the message exactly once.
|
||||||
|
/// </summary>
|
||||||
|
public static void PostLocalSystem(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return;
|
||||||
|
OnMessageReceived?.Invoke(
|
||||||
|
new ChatEntry(MessageKind.System, "", text));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Helpers ----------------------------------------------------
|
||||||
|
|
||||||
|
private static string ResolveSenderName(ulong clientId)
|
||||||
|
{
|
||||||
|
var pms = PlayerMatchState.GetForClient(clientId);
|
||||||
|
if (pms != null && pms.Slot != PlayerSlot.None)
|
||||||
|
return $"P{(int)pms.Slot}";
|
||||||
|
return $"Client {clientId}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Scripts/Gameplay/ChatService.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/ChatService.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 816890bb20c416b419e38d3d4b91ffca
|
||||||
|
|
@ -61,6 +61,7 @@ namespace TD.Gameplay
|
||||||
private PlayerSlot currentZone = PlayerSlot.None;
|
private PlayerSlot currentZone = PlayerSlot.None;
|
||||||
private EnemyStatus status;
|
private EnemyStatus status;
|
||||||
private bool hasReachedGoal;
|
private bool hasReachedGoal;
|
||||||
|
private bool wasStuck; // dedupes the "no path" warning
|
||||||
|
|
||||||
// ----- Events ---------------------------------------------------------
|
// ----- Events ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -164,6 +165,12 @@ namespace TD.Gameplay
|
||||||
if (toTarget.sqrMagnitude > 0.0001f)
|
if (toTarget.sqrMagnitude > 0.0001f)
|
||||||
transform.rotation = Quaternion.LookRotation(toTarget);
|
transform.rotation = Quaternion.LookRotation(toTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-frame zone tracking. With path smoothing, waypoints can be
|
||||||
|
// multiple tiles apart and a straight-line segment may cross zone
|
||||||
|
// boundaries. Checking the current tile every frame ensures
|
||||||
|
// OnZoneLeaked fires the moment the enemy enters a new zone.
|
||||||
|
CheckZoneTransition(GridCoordinates.WorldToGrid(transform.position));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Path management ------------------------------------------------
|
// ----- Path management ------------------------------------------------
|
||||||
|
|
@ -231,9 +238,25 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
remainingPath = service.ComputePath(fromTile);
|
remainingPath = service.ComputePath(fromTile);
|
||||||
|
|
||||||
|
// Dedupe the "no path" log: only emit on the transition into stuck
|
||||||
|
// state, not every frame a walkability change re-fires the recompute.
|
||||||
|
// Stuck enemies are a legitimate edge case (tower placement disconnected
|
||||||
|
// a pocket the enemy was in — placement BFS only checks spawner→exit
|
||||||
|
// reachability, not every enemy's current tile). The enemy will just
|
||||||
|
// stand still until a placement change re-opens a route.
|
||||||
if (remainingPath.Count == 0)
|
if (remainingPath.Count == 0)
|
||||||
Debug.LogWarning($"[EnemyMovement] No path found from {fromTile}. " +
|
{
|
||||||
"TowerPlacementManager should have prevented a full block.");
|
if (!wasStuck)
|
||||||
|
{
|
||||||
|
Debug.Log($"[EnemyMovement] No path from {fromTile} — enemy is " +
|
||||||
|
"stuck in a disconnected pocket. Will retry on next walkability change.");
|
||||||
|
wasStuck = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
wasStuck = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,17 @@ namespace TD.Gameplay
|
||||||
/// nearest goal tile using A* on the runtime walkability grid.
|
/// nearest goal tile using A* on the runtime walkability grid.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <b>Algorithm:</b> A* with Manhattan-distance heuristic. Grid cost is uniform
|
/// <b>Algorithm:</b> A* with the octile-distance heuristic on an 8-connected grid.
|
||||||
/// (1 per step, 4-connected, no diagonals), so A* is optimal and significantly
|
/// Cardinal steps cost 1.0, diagonal steps cost √2. Octile distance is the admissible
|
||||||
/// faster than plain BFS on large grids thanks to the heuristic.
|
/// heuristic for this cost model and yields optimal paths. Diagonal moves require
|
||||||
|
/// both "shoulder" cardinals to be walkable (corner-cut prevention) — preserves
|
||||||
|
/// maze-building, since a 1-tile diagonal gap between two walls won't admit enemies.
|
||||||
|
///
|
||||||
|
/// <b>Path smoothing:</b> After A* produces a tile-by-tile path, <see cref="SmoothPath"/>
|
||||||
|
/// greedily collapses intermediate waypoints whenever a grid line-of-sight is clear
|
||||||
|
/// between the current anchor and a later waypoint. This produces the any-angle
|
||||||
|
/// straight-line movement seen in WC3/Wintermaul — enemies walk directly across open
|
||||||
|
/// space rather than hugging 45° grid steps.
|
||||||
///
|
///
|
||||||
/// <b>Who calls this:</b>
|
/// <b>Who calls this:</b>
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
|
|
@ -55,8 +63,9 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
// A* scratch collections — allocated once and cleared per run to avoid GC.
|
// A* scratch collections — allocated once and cleared per run to avoid GC.
|
||||||
// PathfindingService is a singleton, so single-instance scratch is safe.
|
// PathfindingService is a singleton, so single-instance scratch is safe.
|
||||||
|
// gScore is float to support diagonal cost √2; the priority queue matches.
|
||||||
private readonly Dictionary<Vector2Int, Vector2Int> cameFrom = new Dictionary<Vector2Int, Vector2Int>();
|
private readonly Dictionary<Vector2Int, Vector2Int> cameFrom = new Dictionary<Vector2Int, Vector2Int>();
|
||||||
private readonly Dictionary<Vector2Int, int> gScore = new Dictionary<Vector2Int, int>();
|
private readonly Dictionary<Vector2Int, float> gScore = new Dictionary<Vector2Int, float>();
|
||||||
private readonly SimplePriorityQueue openSet = new SimplePriorityQueue();
|
private readonly SimplePriorityQueue openSet = new SimplePriorityQueue();
|
||||||
|
|
||||||
// ----- Lifecycle --------------------------------------------------
|
// ----- Lifecycle --------------------------------------------------
|
||||||
|
|
@ -114,9 +123,50 @@ namespace TD.Gameplay
|
||||||
return new List<Vector2Int>();
|
return new List<Vector2Int>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the requested start is non-walkable, the enemy was caught
|
||||||
|
// standing inside a freshly-stamped tower footprint (or any tile
|
||||||
|
// that just became blocked). Nudge to the nearest walkable tile so
|
||||||
|
// the enemy can resume routing instead of repeatedly failing.
|
||||||
|
// Search outward in Chebyshev rings — closest 8-neighbor wins.
|
||||||
|
if (!loader.IsWalkable(startTile))
|
||||||
|
{
|
||||||
|
if (TryFindNearestWalkable(startTile, loader, maxRadius: 4, out var nudged))
|
||||||
|
startTile = nudged;
|
||||||
|
else
|
||||||
|
return new List<Vector2Int>();
|
||||||
|
}
|
||||||
|
|
||||||
return RunAStar(startTile, loader);
|
return RunAStar(startTile, loader);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expanding ring search (Chebyshev / 8-connected) for the nearest
|
||||||
|
// walkable tile around <paramref name="origin"/>. Caps at maxRadius to
|
||||||
|
// keep this O(maxRadius²) in the worst case. Used by ComputePath when
|
||||||
|
// an enemy's start tile becomes non-walkable mid-flight.
|
||||||
|
private static bool TryFindNearestWalkable(Vector2Int origin, LevelLoader loader,
|
||||||
|
int maxRadius, out Vector2Int found)
|
||||||
|
{
|
||||||
|
for (int r = 1; r <= maxRadius; r++)
|
||||||
|
{
|
||||||
|
for (int dy = -r; dy <= r; dy++)
|
||||||
|
{
|
||||||
|
for (int dx = -r; dx <= r; dx++)
|
||||||
|
{
|
||||||
|
// Only walk the OUTER ring at distance r (skip interior — already checked at smaller r).
|
||||||
|
if (Mathf.Abs(dx) != r && Mathf.Abs(dy) != r) continue;
|
||||||
|
var tile = new Vector2Int(origin.x + dx, origin.y + dy);
|
||||||
|
if (loader.IsWalkable(tile))
|
||||||
|
{
|
||||||
|
found = tile;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// ----- A* implementation ------------------------------------------
|
// ----- A* implementation ------------------------------------------
|
||||||
|
|
||||||
private List<Vector2Int> RunAStar(Vector2Int start, LevelLoader loader)
|
private List<Vector2Int> RunAStar(Vector2Int start, LevelLoader loader)
|
||||||
|
|
@ -125,7 +175,7 @@ namespace TD.Gameplay
|
||||||
gScore.Clear();
|
gScore.Clear();
|
||||||
openSet.Clear();
|
openSet.Clear();
|
||||||
|
|
||||||
gScore[start] = 0;
|
gScore[start] = 0f;
|
||||||
openSet.Enqueue(start, Heuristic(start));
|
openSet.Enqueue(start, Heuristic(start));
|
||||||
|
|
||||||
while (openSet.Count > 0)
|
while (openSet.Count > 0)
|
||||||
|
|
@ -133,21 +183,36 @@ namespace TD.Gameplay
|
||||||
Vector2Int current = openSet.Dequeue();
|
Vector2Int current = openSet.Dequeue();
|
||||||
|
|
||||||
if (goalTiles.Contains(current))
|
if (goalTiles.Contains(current))
|
||||||
return ReconstructPath(start, current);
|
{
|
||||||
|
var tilePath = ReconstructPath(start, current);
|
||||||
|
return SmoothPath(start, tilePath, loader);
|
||||||
|
}
|
||||||
|
|
||||||
int currentG = gScore.TryGetValue(current, out int g) ? g : int.MaxValue;
|
float currentG = gScore.TryGetValue(current, out float g) ? g : float.MaxValue;
|
||||||
|
|
||||||
foreach (var neighbor in GridCoordinates.GetNeighbors(current))
|
foreach (var neighbor in GridCoordinates.GetNeighbors8(current))
|
||||||
{
|
{
|
||||||
if (!loader.IsWalkable(neighbor)) continue;
|
if (!loader.IsWalkable(neighbor)) continue;
|
||||||
|
|
||||||
int tentativeG = currentG + 1;
|
// Corner-cut prevention: for a diagonal step, both cardinal
|
||||||
if (gScore.TryGetValue(neighbor, out int existingG)
|
// shoulder tiles must also be walkable. Otherwise enemies could
|
||||||
|
// squeeze through 1-tile diagonal gaps between walls — that
|
||||||
|
// would break the maze-building design intent.
|
||||||
|
if (GridCoordinates.IsDiagonal(current, neighbor))
|
||||||
|
{
|
||||||
|
GridCoordinates.GetCornerShoulders(current, neighbor,
|
||||||
|
out var shoulderA, out var shoulderB);
|
||||||
|
if (!loader.IsWalkable(shoulderA) || !loader.IsWalkable(shoulderB))
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
float tentativeG = currentG + GridCoordinates.StepCost(current, neighbor);
|
||||||
|
if (gScore.TryGetValue(neighbor, out float existingG)
|
||||||
&& tentativeG >= existingG) continue;
|
&& tentativeG >= existingG) continue;
|
||||||
|
|
||||||
cameFrom[neighbor] = current;
|
cameFrom[neighbor] = current;
|
||||||
gScore[neighbor] = tentativeG;
|
gScore[neighbor] = tentativeG;
|
||||||
int f = tentativeG + Heuristic(neighbor);
|
float f = tentativeG + Heuristic(neighbor);
|
||||||
|
|
||||||
// Re-enqueue with updated priority. SimplePriorityQueue handles
|
// Re-enqueue with updated priority. SimplePriorityQueue handles
|
||||||
// duplicate entries by ignoring higher-cost duplicates on dequeue.
|
// duplicate entries by ignoring higher-cost duplicates on dequeue.
|
||||||
|
|
@ -155,20 +220,23 @@ namespace TD.Gameplay
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No path found — TowerPlacementManager should have prevented this.
|
// No path found. This can legitimately happen during normal play
|
||||||
Debug.LogWarning($"[PathfindingService] A* found no path from {start}. " +
|
// when an enemy is in a disconnected pocket created by a tower
|
||||||
"Check that TowerPlacementManager BFS is validating correctly.");
|
// placement (the placement BFS only verifies spawner→exit, not
|
||||||
|
// every enemy's current tile). The EnemyMovement caller dedupes
|
||||||
|
// its own warning so this doesn't spam the console on every
|
||||||
|
// walkability change while an enemy is stuck.
|
||||||
return new List<Vector2Int>();
|
return new List<Vector2Int>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manhattan distance to the nearest goal tile. Admissible heuristic for
|
// Octile distance to the nearest goal tile. Admissible heuristic for an
|
||||||
// a 4-connected uniform-cost grid.
|
// 8-connected uniform-cost grid (cardinal 1, diagonal √2).
|
||||||
private int Heuristic(Vector2Int tile)
|
private float Heuristic(Vector2Int tile)
|
||||||
{
|
{
|
||||||
int best = int.MaxValue;
|
float best = float.MaxValue;
|
||||||
foreach (var goal in goalTiles)
|
foreach (var goal in goalTiles)
|
||||||
{
|
{
|
||||||
int d = GridCoordinates.ManhattanDistance(tile, goal);
|
float d = GridCoordinates.OctileDistance(tile, goal);
|
||||||
if (d < best) best = d;
|
if (d < best) best = d;
|
||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
|
|
@ -190,6 +258,105 @@ namespace TD.Gameplay
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- Path smoothing ---------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Greedy line-of-sight path simplification. Walks the input tile path and
|
||||||
|
/// collapses runs of tiles into single waypoints whenever a straight grid
|
||||||
|
/// line-of-sight is clear. Produces any-angle paths from the otherwise
|
||||||
|
/// 45°-stepped 8-connected A* output.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Algorithm: maintain an "anchor" (initially the start tile). For each
|
||||||
|
/// position in the path, find the FARTHEST subsequent waypoint that has clear
|
||||||
|
/// LOS from the anchor. Add it to the smoothed result and make it the new
|
||||||
|
/// anchor. Repeat. O(n²) in path length, acceptable for typical TD path
|
||||||
|
/// lengths (< 100 tiles).
|
||||||
|
///
|
||||||
|
/// LOS check uses Bresenham line walk with the same corner-cut rules as A*
|
||||||
|
/// — a smoothed segment never crosses through a non-walkable tile and never
|
||||||
|
/// squeezes through a diagonal corner.
|
||||||
|
/// </remarks>
|
||||||
|
private List<Vector2Int> SmoothPath(Vector2Int start, List<Vector2Int> path,
|
||||||
|
LevelLoader loader)
|
||||||
|
{
|
||||||
|
if (path.Count <= 1) return path;
|
||||||
|
|
||||||
|
var result = new List<Vector2Int>(path.Count);
|
||||||
|
Vector2Int anchor = start;
|
||||||
|
int i = 0;
|
||||||
|
|
||||||
|
while (i < path.Count)
|
||||||
|
{
|
||||||
|
// Find the farthest waypoint we can directly reach from the anchor.
|
||||||
|
int farthest = i;
|
||||||
|
for (int j = path.Count - 1; j > i; j--)
|
||||||
|
{
|
||||||
|
if (HasLineOfSight(anchor, path[j], loader))
|
||||||
|
{
|
||||||
|
farthest = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(path[farthest]);
|
||||||
|
anchor = path[farthest];
|
||||||
|
i = farthest + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Grid line-of-sight test from <paramref name="a"/> to <paramref name="b"/>.
|
||||||
|
/// Returns true if a straight Bresenham line between the two tiles only
|
||||||
|
/// crosses walkable tiles, with no diagonal corner-cuts. Used by
|
||||||
|
/// <see cref="SmoothPath"/> to decide whether two waypoints can be collapsed.
|
||||||
|
/// </summary>
|
||||||
|
private static bool HasLineOfSight(Vector2Int a, Vector2Int b, LevelLoader loader)
|
||||||
|
{
|
||||||
|
int x0 = a.x, y0 = a.y;
|
||||||
|
int x1 = b.x, y1 = b.y;
|
||||||
|
int dx = Mathf.Abs(x1 - x0);
|
||||||
|
int dy = Mathf.Abs(y1 - y0);
|
||||||
|
int sx = (x0 < x1) ? 1 : -1;
|
||||||
|
int sy = (y0 < y1) ? 1 : -1;
|
||||||
|
int err = dx - dy;
|
||||||
|
|
||||||
|
int x = x0, y = y0;
|
||||||
|
|
||||||
|
while (x != x1 || y != y1)
|
||||||
|
{
|
||||||
|
int e2 = 2 * err;
|
||||||
|
bool steppedX = false, steppedY = false;
|
||||||
|
|
||||||
|
if (e2 > -dy)
|
||||||
|
{
|
||||||
|
err -= dy;
|
||||||
|
x += sx;
|
||||||
|
steppedX = true;
|
||||||
|
}
|
||||||
|
if (e2 < dx)
|
||||||
|
{
|
||||||
|
err += dx;
|
||||||
|
y += sy;
|
||||||
|
steppedY = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagonal step: enforce corner-cut prevention against the two
|
||||||
|
// shoulder tiles (same rule A* uses).
|
||||||
|
if (steppedX && steppedY)
|
||||||
|
{
|
||||||
|
if (!loader.IsWalkable(new Vector2Int(x - sx, y))) return false;
|
||||||
|
if (!loader.IsWalkable(new Vector2Int(x, y - sy))) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loader.IsWalkable(new Vector2Int(x, y))) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ----- Helpers ----------------------------------------------------
|
// ----- Helpers ----------------------------------------------------
|
||||||
|
|
||||||
private void BuildGoalTileSet()
|
private void BuildGoalTileSet()
|
||||||
|
|
@ -226,14 +393,15 @@ namespace TD.Gameplay
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
internal sealed class SimplePriorityQueue
|
internal sealed class SimplePriorityQueue
|
||||||
{
|
{
|
||||||
private readonly List<(int priority, Vector2Int tile)> heap
|
// Float priority to support diagonal cost (√2) in 8-connected A*.
|
||||||
= new List<(int, Vector2Int)>();
|
private readonly List<(float priority, Vector2Int tile)> heap
|
||||||
|
= new List<(float, Vector2Int)>();
|
||||||
|
|
||||||
public int Count => heap.Count;
|
public int Count => heap.Count;
|
||||||
|
|
||||||
public void Clear() => heap.Clear();
|
public void Clear() => heap.Clear();
|
||||||
|
|
||||||
public void Enqueue(Vector2Int tile, int priority)
|
public void Enqueue(Vector2Int tile, float priority)
|
||||||
{
|
{
|
||||||
heap.Add((priority, tile));
|
heap.Add((priority, tile));
|
||||||
SiftUp(heap.Count - 1);
|
SiftUp(heap.Count - 1);
|
||||||
|
|
|
||||||
|
|
@ -494,10 +494,25 @@ namespace TD.Gameplay
|
||||||
if (exitTiles.Contains(current))
|
if (exitTiles.Contains(current))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
foreach (var neighbor in GridCoordinates.GetNeighbors(current))
|
// 8-connected expansion to match enemy pathfinding. A 4-connected
|
||||||
|
// BFS here would reject placements enemies could actually navigate
|
||||||
|
// around via diagonals, OR accept placements that diagonally squeeze
|
||||||
|
// through corners. Corner-cut prevention keeps the maze rule consistent
|
||||||
|
// with PathfindingService: a diagonal step requires both shoulder
|
||||||
|
// cardinal tiles to be walkable.
|
||||||
|
foreach (var neighbor in GridCoordinates.GetNeighbors8(current))
|
||||||
{
|
{
|
||||||
if (bfsVisited.Contains(neighbor)) continue;
|
if (bfsVisited.Contains(neighbor)) continue;
|
||||||
if (!loader.IsWalkable(neighbor)) continue;
|
if (!loader.IsWalkable(neighbor)) continue;
|
||||||
|
|
||||||
|
if (GridCoordinates.IsDiagonal(current, neighbor))
|
||||||
|
{
|
||||||
|
GridCoordinates.GetCornerShoulders(current, neighbor,
|
||||||
|
out var shoulderA, out var shoulderB);
|
||||||
|
if (!loader.IsWalkable(shoulderA) || !loader.IsWalkable(shoulderB))
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
bfsVisited.Add(neighbor);
|
bfsVisited.Add(neighbor);
|
||||||
bfsQueue.Enqueue(neighbor);
|
bfsQueue.Enqueue(neighbor);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -387,8 +387,20 @@ namespace TD.Gameplay
|
||||||
private void ShowLifeLossClientRpc(Vector3 worldPos, int amount)
|
private void ShowLifeLossClientRpc(Vector3 worldPos, int amount)
|
||||||
{
|
{
|
||||||
FloatingTextSpawner.Instance?.SpawnLifeLoss(worldPos, amount);
|
FloatingTextSpawner.Instance?.SpawnLifeLoss(worldPos, amount);
|
||||||
|
OnLifeLost?.Invoke(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- Local-only notification events -----------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired on every peer immediately after a life-loss popup spawns.
|
||||||
|
/// HUD subscribes to flash a centered banner; gameplay code can also
|
||||||
|
/// hook this for audio cues, screen-shake, etc. Argument is the number
|
||||||
|
/// of lives lost (usually 1, but boss enemies with LivesCost > 1
|
||||||
|
/// fire a single event carrying the larger value).
|
||||||
|
/// </summary>
|
||||||
|
public static event System.Action<int> OnLifeLost;
|
||||||
|
|
||||||
// ----- Helpers ----------------------------------------------------
|
// ----- Helpers ----------------------------------------------------
|
||||||
|
|
||||||
private void UnsubscribeEnemy(EnemyHealth health)
|
private void UnsubscribeEnemy(EnemyHealth health)
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,23 @@ namespace TD.UI
|
||||||
[Header("Settings")]
|
[Header("Settings")]
|
||||||
[SerializeField] private float rejectionMessageDuration = 2.5f;
|
[SerializeField] private float rejectionMessageDuration = 2.5f;
|
||||||
|
|
||||||
|
[Tooltip("Maximum visible height of the chat feed in pixels. Content past this " +
|
||||||
|
"height is clipped — older messages scroll off the top of the visible area " +
|
||||||
|
"but stay in history (scroll up while chat is open to view).")]
|
||||||
|
[SerializeField] private float chatMaxHeight = 280f;
|
||||||
|
|
||||||
|
[Tooltip("Maximum messages kept in chat history. Defaults to effectively unlimited " +
|
||||||
|
"(int.MaxValue) — every message sent during a match stays scrollable. " +
|
||||||
|
"Lower the value if a long match ever shows DOM perf issues; this field " +
|
||||||
|
"is the safety valve, not a normal-play limit.")]
|
||||||
|
[SerializeField] private int chatMaxMessages = int.MaxValue;
|
||||||
|
|
||||||
|
[Tooltip("Color used for SYSTEM chat messages (e.g. 'Life Lost', income changes).")]
|
||||||
|
[SerializeField] private Color chatSystemColor = new Color(1f, 0.7f, 0.2f);
|
||||||
|
|
||||||
|
[Tooltip("Color used for PLAYER chat message bodies. Sender prefix uses the player's slot color.")]
|
||||||
|
[SerializeField] private Color chatPlayerColor = new Color(0.92f, 0.92f, 0.92f);
|
||||||
|
|
||||||
// ----- Cached UI element references -------------------------------
|
// ----- Cached UI element references -------------------------------
|
||||||
|
|
||||||
private Label goldLabel;
|
private Label goldLabel;
|
||||||
|
|
@ -68,6 +85,25 @@ namespace TD.UI
|
||||||
private VisualElement matchEndOverlay;
|
private VisualElement matchEndOverlay;
|
||||||
private Label matchEndTitle;
|
private Label matchEndTitle;
|
||||||
|
|
||||||
|
// Chat panel (bottom-left, above portrait) — programmatic. The container
|
||||||
|
// holds both the scrollable feed and the input. Highlight + scroll
|
||||||
|
// interactivity are toggled on the container when typing.
|
||||||
|
private VisualElement chatContainer;
|
||||||
|
private ScrollView chatFeed;
|
||||||
|
private TextField chatInput;
|
||||||
|
private bool chatInputOpen;
|
||||||
|
|
||||||
|
// Frame on which the chat input was opened or closed. Enter on that frame
|
||||||
|
// and the next one is ignored to prevent the open/close-triggering keypress
|
||||||
|
// from also being consumed by the input or the open-toggle. Without this,
|
||||||
|
// pressing Enter to open chat would immediately submit an empty message.
|
||||||
|
private int chatToggleSuppressFrame = -1;
|
||||||
|
|
||||||
|
// Set true whenever the chat input or any other text field on the HUD has
|
||||||
|
// keyboard focus. Camera, builder input, and hotkey handlers all gate on
|
||||||
|
// this to keep typing from driving gameplay.
|
||||||
|
public static bool IsTextInputActive { get; private set; }
|
||||||
|
|
||||||
// ----- State ------------------------------------------------------
|
// ----- State ------------------------------------------------------
|
||||||
|
|
||||||
private Coroutine rejectionFadeCoroutine;
|
private Coroutine rejectionFadeCoroutine;
|
||||||
|
|
@ -247,6 +283,11 @@ namespace TD.UI
|
||||||
// MatchState.OnPhaseChanged fires Victory or Defeat.
|
// MatchState.OnPhaseChanged fires Victory or Defeat.
|
||||||
BuildMatchEndOverlay(root);
|
BuildMatchEndOverlay(root);
|
||||||
|
|
||||||
|
// Chat feed + input. Anchored bottom-left, just above the portrait/bottom-ui bar.
|
||||||
|
// Player typing toggled with Enter; system messages (e.g. life lost) post via
|
||||||
|
// ChatService.PostLocalSystem on every peer.
|
||||||
|
BuildChatPanel(root);
|
||||||
|
|
||||||
// Publish the panel so non-UI systems can query "is pointer over the HUD".
|
// Publish the panel so non-UI systems can query "is pointer over the HUD".
|
||||||
// Stored on `myPanel` too so OnDestroy only clears the static if it still
|
// Stored on `myPanel` too so OnDestroy only clears the static if it still
|
||||||
// points at this instance (defensive against re-creation overlap).
|
// points at this instance (defensive against re-creation overlap).
|
||||||
|
|
@ -259,6 +300,8 @@ namespace TD.UI
|
||||||
private void OnEnable()
|
private void OnEnable()
|
||||||
{
|
{
|
||||||
TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage;
|
TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage;
|
||||||
|
WaveManager.OnLifeLost += HandleLifeLost;
|
||||||
|
ChatService.OnMessageReceived += HandleChatMessage;
|
||||||
// Try to subscribe now; if SelectionState.Awake hasn't run yet (Unity does
|
// Try to subscribe now; if SelectionState.Awake hasn't run yet (Unity does
|
||||||
// not guarantee Awake/OnEnable ordering across objects), Start will retry.
|
// not guarantee Awake/OnEnable ordering across objects), Start will retry.
|
||||||
TrySubscribeSelection();
|
TrySubscribeSelection();
|
||||||
|
|
@ -267,6 +310,8 @@ namespace TD.UI
|
||||||
private void OnDisable()
|
private void OnDisable()
|
||||||
{
|
{
|
||||||
TowerPlacementController.OnRejectionMessageReady -= ShowRejectionMessage;
|
TowerPlacementController.OnRejectionMessageReady -= ShowRejectionMessage;
|
||||||
|
WaveManager.OnLifeLost -= HandleLifeLost;
|
||||||
|
ChatService.OnMessageReceived -= HandleChatMessage;
|
||||||
if (selectionSubscribed && SelectionState.Instance != null)
|
if (selectionSubscribed && SelectionState.Instance != null)
|
||||||
{
|
{
|
||||||
SelectionState.Instance.OnSelectionChanged -= HandleSelectionChanged;
|
SelectionState.Instance.OnSelectionChanged -= HandleSelectionChanged;
|
||||||
|
|
@ -301,7 +346,13 @@ namespace TD.UI
|
||||||
RefreshMatchStateDisplays();
|
RefreshMatchStateDisplays();
|
||||||
UpdateBuildProgressIfShown();
|
UpdateBuildProgressIfShown();
|
||||||
UpdateEnemyInfoIfShown();
|
UpdateEnemyInfoIfShown();
|
||||||
HandleHotkeys();
|
HandleChatInput();
|
||||||
|
|
||||||
|
// Skip gameplay hotkeys while the chat input is focused — letters
|
||||||
|
// typed into chat should not also fire Q/W/E/R tower builds.
|
||||||
|
if (!IsTextInputActive)
|
||||||
|
HandleHotkeys();
|
||||||
|
|
||||||
minimapView?.Tick();
|
minimapView?.Tick();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -863,6 +914,375 @@ namespace TD.UI
|
||||||
cameraController.JumpTo(t.position);
|
cameraController.JumpTo(t.position);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- Chat feed + input -----------------------------------------
|
||||||
|
|
||||||
|
// Bottom-left chat panel. Anchored 12px from the left edge, with the
|
||||||
|
// bottom edge sitting above the 220px bottom-ui. Layout uses a flex
|
||||||
|
// column: scrollable feed on top, input below. The feed clips at
|
||||||
|
// chatMaxHeight; messages above that scroll off the top of the visible
|
||||||
|
// area but stay in history (visible by scrolling once chat is open).
|
||||||
|
private void BuildChatPanel(VisualElement root)
|
||||||
|
{
|
||||||
|
const float bottomUiHeight = 220f;
|
||||||
|
const float gap = 8f;
|
||||||
|
const float chatWidth = 380f;
|
||||||
|
const float inputMinHeight = 32f;
|
||||||
|
const float inputFeedGap = 6f; // breathing room between feed and input
|
||||||
|
|
||||||
|
// Container anchored to the bottom-left, growing upward as content
|
||||||
|
// grows (no top/height set → height auto-fits content).
|
||||||
|
chatContainer = new VisualElement();
|
||||||
|
chatContainer.pickingMode = PickingMode.Ignore; // closed = wheel falls through
|
||||||
|
chatContainer.style.position = Position.Absolute;
|
||||||
|
chatContainer.style.left = 12;
|
||||||
|
chatContainer.style.bottom = bottomUiHeight + gap;
|
||||||
|
chatContainer.style.width = chatWidth;
|
||||||
|
chatContainer.style.flexDirection = FlexDirection.Column;
|
||||||
|
chatContainer.style.paddingTop = 4;
|
||||||
|
chatContainer.style.paddingBottom = 4;
|
||||||
|
chatContainer.style.paddingLeft = 6;
|
||||||
|
chatContainer.style.paddingRight = 6;
|
||||||
|
|
||||||
|
// Feed: ScrollView so older messages can scroll off the top of the
|
||||||
|
// visible window while staying in history. Vertical-only.
|
||||||
|
chatFeed = new ScrollView(ScrollViewMode.Vertical);
|
||||||
|
chatFeed.style.maxHeight = chatMaxHeight;
|
||||||
|
chatFeed.style.flexShrink = 1;
|
||||||
|
chatFeed.pickingMode = PickingMode.Ignore;
|
||||||
|
chatFeed.contentViewport.pickingMode = PickingMode.Ignore;
|
||||||
|
chatFeed.contentContainer.pickingMode = PickingMode.Ignore;
|
||||||
|
chatFeed.verticalScrollerVisibility = ScrollerVisibility.Hidden;
|
||||||
|
|
||||||
|
// Belt-and-suspenders wheel interceptor. The recursive .hierarchy
|
||||||
|
// pickingMode walk should already prevent wheel events from reaching
|
||||||
|
// chatFeed when chat is closed; this handler is defense-in-depth
|
||||||
|
// against future Unity versions reorganizing ScrollView internals.
|
||||||
|
chatFeed.RegisterCallback<WheelEvent>(evt =>
|
||||||
|
{
|
||||||
|
if (!chatInputOpen)
|
||||||
|
evt.StopImmediatePropagation();
|
||||||
|
}, TrickleDown.TrickleDown);
|
||||||
|
// Track-style — make the scrollbar slim and dark so it doesn't compete with messages.
|
||||||
|
chatFeed.style.marginBottom = inputFeedGap;
|
||||||
|
chatContainer.Add(chatFeed);
|
||||||
|
|
||||||
|
// Input field. Hidden by default. We use minHeight (not a fixed height)
|
||||||
|
// because the inner unity-text-input element needs vertical padding
|
||||||
|
// for the font to render fully — pinning to 28px clipped descenders
|
||||||
|
// and ascenders. We also style the inner child directly because the
|
||||||
|
// TextField's visible white background lives on it, not on the root.
|
||||||
|
chatInput = new TextField();
|
||||||
|
chatInput.style.minHeight = inputMinHeight;
|
||||||
|
chatInput.style.width = Length.Percent(100);
|
||||||
|
chatInput.maxLength = 120;
|
||||||
|
chatInput.style.display = DisplayStyle.None;
|
||||||
|
chatInput.isDelayed = false;
|
||||||
|
StyleChatInputDark(chatInput);
|
||||||
|
|
||||||
|
// Focus tracking — gameplay input gates on IsTextInputActive.
|
||||||
|
chatInput.RegisterCallback<FocusInEvent>(_ => IsTextInputActive = true);
|
||||||
|
chatInput.RegisterCallback<FocusOutEvent>(_ => IsTextInputActive = false);
|
||||||
|
|
||||||
|
// Submit (Enter) and cancel (Escape) are handled in Update via
|
||||||
|
// direct Input System reads — not via UI Toolkit's NavigationSubmit
|
||||||
|
// event, which fires inconsistently relative to TextField.value
|
||||||
|
// commit timing. See HandleChatInput.
|
||||||
|
|
||||||
|
chatContainer.Add(chatInput);
|
||||||
|
|
||||||
|
root.Add(chatContainer);
|
||||||
|
|
||||||
|
// Initial state is closed → every chat descendant (including the
|
||||||
|
// ScrollView's internal scroll-container wrapper) starts Ignore so
|
||||||
|
// panel.Pick falls through to nothing and the camera owns the wheel.
|
||||||
|
SetPickingModeRecursive(chatContainer, PickingMode.Ignore);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively sets pickingMode on every visual descendant of root.
|
||||||
|
//
|
||||||
|
// IMPORTANT: this uses .hierarchy[i] / .hierarchy.childCount, NOT the
|
||||||
|
// root[i] indexer. VisualElement has two hierarchies:
|
||||||
|
//
|
||||||
|
// * Logical hierarchy (root[i] / root.childCount) — children added by
|
||||||
|
// user code via Add(). For elements with a redirected contentContainer
|
||||||
|
// (ScrollView, Foldout, etc.), this only enumerates the user content,
|
||||||
|
// not the internal scaffolding.
|
||||||
|
//
|
||||||
|
// * Visual hierarchy (root.hierarchy[i]) — the actual visual tree
|
||||||
|
// including internal scaffolding (ScrollView's
|
||||||
|
// unity-content-and-vertical-scroll-container, viewport, scrollers,
|
||||||
|
// etc.).
|
||||||
|
//
|
||||||
|
// We need the visual hierarchy because the wrapper element
|
||||||
|
// unity-content-and-vertical-scroll-container is what panel.Pick was
|
||||||
|
// landing on. Walking the logical hierarchy would visit ScrollView's
|
||||||
|
// user-added chat lines but skip the internal wrapper entirely.
|
||||||
|
private static void SetPickingModeRecursive(VisualElement root, PickingMode mode)
|
||||||
|
{
|
||||||
|
if (root == null) return;
|
||||||
|
root.pickingMode = mode;
|
||||||
|
int count = root.hierarchy.childCount;
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
SetPickingModeRecursive(root.hierarchy[i], mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The TextField root color isn't where the visible background lives — it's
|
||||||
|
// on the inner "unity-text-input" element. Style both: the visible
|
||||||
|
// background goes dark, the text goes white so what the player types is
|
||||||
|
// legible against it, and the inner element gets a few px of vertical
|
||||||
|
// padding so characters with ascenders/descenders (capital letters,
|
||||||
|
// lowercase y/g/p/q/j, "?") don't get clipped.
|
||||||
|
private static void StyleChatInputDark(TextField field)
|
||||||
|
{
|
||||||
|
var darkBg = new Color(0.10f, 0.10f, 0.10f, 0.95f);
|
||||||
|
var borderClr = new Color(0.45f, 0.45f, 0.5f);
|
||||||
|
|
||||||
|
field.style.backgroundColor = darkBg;
|
||||||
|
field.style.color = Color.white;
|
||||||
|
field.style.borderTopWidth = field.style.borderBottomWidth =
|
||||||
|
field.style.borderLeftWidth = field.style.borderRightWidth = 1;
|
||||||
|
field.style.borderTopColor = field.style.borderBottomColor =
|
||||||
|
field.style.borderLeftColor = field.style.borderRightColor = borderClr;
|
||||||
|
|
||||||
|
// The actual editable area child. It exists immediately on construction.
|
||||||
|
var inner = field.Q("unity-text-input");
|
||||||
|
if (inner != null)
|
||||||
|
{
|
||||||
|
inner.style.backgroundColor = darkBg;
|
||||||
|
inner.style.color = Color.white;
|
||||||
|
inner.style.paddingTop = 4;
|
||||||
|
inner.style.paddingBottom = 4;
|
||||||
|
inner.style.paddingLeft = 6;
|
||||||
|
inner.style.paddingRight = 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called every frame from Update. Handles all three chat key bindings
|
||||||
|
// via direct Input System reads:
|
||||||
|
// - Enter (chat closed) → open input
|
||||||
|
// - Enter (chat open) → submit + close
|
||||||
|
// - Escape (chat open) → close without submit
|
||||||
|
//
|
||||||
|
// Reading the Input System directly avoids two UI Toolkit quirks:
|
||||||
|
// 1. The first Enter inside a focused TextField is sometimes consumed
|
||||||
|
// by the field's internal edit-mode handler before NavigationSubmit
|
||||||
|
// can fire, requiring a SECOND Enter to trigger the user-visible
|
||||||
|
// submit. Going through Keyboard.current sidesteps that pipeline.
|
||||||
|
// 2. KeyDownEvent / NavigationSubmitEvent fire timing isn't aligned
|
||||||
|
// with TextField.value commit on every Unity version. Reading the
|
||||||
|
// Input System happens at a deterministic point in Update where
|
||||||
|
// TextField.value is already up to date (since isDelayed = false).
|
||||||
|
private void HandleChatInput()
|
||||||
|
{
|
||||||
|
if (chatInput == null) return;
|
||||||
|
|
||||||
|
var kb = Keyboard.current;
|
||||||
|
if (kb == null) return;
|
||||||
|
|
||||||
|
// Suppression window covers the frame after open/close so the same
|
||||||
|
// keypress that toggled chat doesn't immediately fire the opposite path.
|
||||||
|
if (Time.frameCount <= chatToggleSuppressFrame) return;
|
||||||
|
|
||||||
|
bool enterDown = kb.enterKey.wasPressedThisFrame
|
||||||
|
|| kb.numpadEnterKey.wasPressedThisFrame;
|
||||||
|
bool escDown = kb.escapeKey.wasPressedThisFrame;
|
||||||
|
|
||||||
|
if (!chatInputOpen)
|
||||||
|
{
|
||||||
|
if (enterDown) OpenChatInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat is open.
|
||||||
|
if (enterDown) SubmitChatInput();
|
||||||
|
else if (escDown) CloseChatInput(submit: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenChatInput()
|
||||||
|
{
|
||||||
|
if (chatInput == null) return;
|
||||||
|
chatInput.style.display = DisplayStyle.Flex;
|
||||||
|
chatInput.SetValueWithoutNotify(string.Empty);
|
||||||
|
|
||||||
|
// Switch the chat panel from "passive display" to "interactive":
|
||||||
|
// - Container gets a dark translucent background as a visual cue
|
||||||
|
// AND becomes pointer-active so clicks on the highlight don't
|
||||||
|
// fall through and place towers / deselect units underneath.
|
||||||
|
// - Feed becomes pointer-active so wheel events scroll it (and
|
||||||
|
// so IsPointerOverInteractiveHud picks the chat for camera-gate).
|
||||||
|
// - Scrollbar becomes visible.
|
||||||
|
if (chatContainer != null)
|
||||||
|
chatContainer.style.backgroundColor = new Color(0f, 0f, 0f, 0.40f);
|
||||||
|
if (chatFeed != null)
|
||||||
|
chatFeed.verticalScrollerVisibility = ScrollerVisibility.Auto;
|
||||||
|
|
||||||
|
// Make EVERY descendant of the chat panel interactive. Setting Position
|
||||||
|
// recursively covers ScrollView's internal scroll-container wrapper
|
||||||
|
// (which our previous explicit list of three pickingMode targets
|
||||||
|
// missed), so panel.Pick will reliably land on a chat element and
|
||||||
|
// wheel events will reach the ScrollView's manipulator.
|
||||||
|
SetPickingModeRecursive(chatContainer, PickingMode.Position);
|
||||||
|
|
||||||
|
// Suppress Enter for this frame and the next so the keypress that
|
||||||
|
// opened chat doesn't also submit it empty. Focus is deferred a frame
|
||||||
|
// for the same reason — UI Toolkit would otherwise route the open-Enter
|
||||||
|
// to the freshly-focused TextField immediately.
|
||||||
|
chatToggleSuppressFrame = Time.frameCount + 1;
|
||||||
|
chatInputOpen = true;
|
||||||
|
StartCoroutine(FocusChatNextFrame());
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerator FocusChatNextFrame()
|
||||||
|
{
|
||||||
|
yield return null;
|
||||||
|
if (chatInputOpen && chatInput != null)
|
||||||
|
{
|
||||||
|
chatInput.Focus();
|
||||||
|
// Focus alone doesn't always activate the text caret immediately.
|
||||||
|
// SelectAll forces the field into edit mode reliably across versions.
|
||||||
|
chatInput.SelectAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseChatInput(bool submit)
|
||||||
|
{
|
||||||
|
if (chatInput == null) return;
|
||||||
|
if (submit) SubmitChatInput();
|
||||||
|
RevertChatPanelToPassive();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SubmitChatInput()
|
||||||
|
{
|
||||||
|
string text = chatInput?.value ?? string.Empty;
|
||||||
|
if (!string.IsNullOrWhiteSpace(text) && ChatService.Instance != null)
|
||||||
|
ChatService.Instance.SubmitMessage(text);
|
||||||
|
|
||||||
|
RevertChatPanelToPassive();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single source of truth for "chat is no longer in typing mode" — clears the
|
||||||
|
// input, hides it, restores pickingMode = Ignore on every layer that flipped
|
||||||
|
// to Position on Open, drops the highlight background, and flips the debug
|
||||||
|
// border back to red. Both Submit and Close route through here so the two
|
||||||
|
// close paths can't drift out of sync (which is exactly what caused the
|
||||||
|
// "border stays green / wheel keeps scrolling" bug).
|
||||||
|
private void RevertChatPanelToPassive()
|
||||||
|
{
|
||||||
|
if (chatInput != null)
|
||||||
|
{
|
||||||
|
chatInput.SetValueWithoutNotify(string.Empty);
|
||||||
|
chatInput.style.display = DisplayStyle.None;
|
||||||
|
chatInput.Blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chatContainer != null)
|
||||||
|
chatContainer.style.backgroundColor = StyleKeyword.Initial;
|
||||||
|
if (chatFeed != null)
|
||||||
|
chatFeed.verticalScrollerVisibility = ScrollerVisibility.Hidden;
|
||||||
|
|
||||||
|
// Same as Open's recursive Position, but Ignore — covers ScrollView's
|
||||||
|
// internal scroll-container wrapper so panel.Pick falls through to
|
||||||
|
// whatever's behind chat (which is nothing, so IsPointerOverInteractiveHud
|
||||||
|
// returns false and the camera processes wheel events normally).
|
||||||
|
SetPickingModeRecursive(chatContainer, PickingMode.Ignore);
|
||||||
|
|
||||||
|
chatInputOpen = false;
|
||||||
|
chatToggleSuppressFrame = Time.frameCount + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribed to ChatService.OnMessageReceived. Appends a new line to
|
||||||
|
// the feed, prunes the oldest if we exceed the history cap, and
|
||||||
|
// auto-scrolls only if the player was already viewing the latest
|
||||||
|
// messages — preserving their position if they've scrolled up to
|
||||||
|
// read older history.
|
||||||
|
private void HandleChatMessage(ChatService.ChatEntry entry)
|
||||||
|
{
|
||||||
|
if (chatFeed == null) return;
|
||||||
|
|
||||||
|
// Capture "was the user at the bottom" BEFORE we append, while the
|
||||||
|
// layout still reflects the pre-message state. If they were, we
|
||||||
|
// re-pin to the new bottom after the message lays out. If they
|
||||||
|
// weren't (scrolled up reading history), we leave their position
|
||||||
|
// alone so new messages don't yank them back.
|
||||||
|
bool wasAtBottom = !chatInputOpen || IsChatScrolledToBottom();
|
||||||
|
|
||||||
|
var line = BuildChatLine(entry);
|
||||||
|
chatFeed.Add(line);
|
||||||
|
|
||||||
|
// Bound history. ScrollView.contentContainer is what actually holds
|
||||||
|
// child elements (the ScrollView root has its own layout chrome).
|
||||||
|
// Default chatMaxMessages is effectively unlimited so this loop is
|
||||||
|
// a no-op in normal play; the cap exists as a safety valve.
|
||||||
|
var content = chatFeed.contentContainer;
|
||||||
|
while (content.childCount > chatMaxMessages)
|
||||||
|
content.RemoveAt(0);
|
||||||
|
|
||||||
|
if (!wasAtBottom) return;
|
||||||
|
|
||||||
|
// Defer scroll-to-bottom until layout has resolved the new line's
|
||||||
|
// height — without the delay, contentContainer.layout.height is
|
||||||
|
// stale and we'd scroll to the previous max.
|
||||||
|
chatFeed.schedule.Execute(() =>
|
||||||
|
{
|
||||||
|
if (chatFeed == null) return;
|
||||||
|
var height = chatFeed.contentContainer.layout.height;
|
||||||
|
chatFeed.scrollOffset = new Vector2(0, Mathf.Max(0, height));
|
||||||
|
}).ExecuteLater(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if the feed isn't overflowing yet, or the player's scroll
|
||||||
|
// position is within a few pixels of the bottom edge of the content.
|
||||||
|
// Used to decide whether to auto-scroll on new messages.
|
||||||
|
private bool IsChatScrolledToBottom()
|
||||||
|
{
|
||||||
|
if (chatFeed == null) return true;
|
||||||
|
|
||||||
|
float viewportHeight = chatFeed.contentViewport.layout.height;
|
||||||
|
float contentHeight = chatFeed.contentContainer.layout.height;
|
||||||
|
|
||||||
|
// Not overflowing → effectively "at the bottom" (everything visible).
|
||||||
|
if (contentHeight <= viewportHeight + 0.5f) return true;
|
||||||
|
|
||||||
|
float maxScroll = contentHeight - viewportHeight;
|
||||||
|
const float tolerance = 4f;
|
||||||
|
return chatFeed.scrollOffset.y >= maxScroll - tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Label BuildChatLine(ChatService.ChatEntry entry)
|
||||||
|
{
|
||||||
|
var line = new Label();
|
||||||
|
line.pickingMode = PickingMode.Ignore;
|
||||||
|
line.style.marginBottom = 2;
|
||||||
|
line.style.color = entry.Kind == ChatService.MessageKind.System
|
||||||
|
? chatSystemColor
|
||||||
|
: chatPlayerColor;
|
||||||
|
line.style.whiteSpace = WhiteSpace.Normal; // wrap long messages
|
||||||
|
|
||||||
|
// Soft drop shadow so messages stay readable over varied backgrounds.
|
||||||
|
line.style.textShadow = new TextShadow
|
||||||
|
{
|
||||||
|
offset = new Vector2(1, 1),
|
||||||
|
blurRadius = 2,
|
||||||
|
color = new Color(0f, 0f, 0f, 0.85f),
|
||||||
|
};
|
||||||
|
|
||||||
|
line.text = entry.Kind == ChatService.MessageKind.System
|
||||||
|
? entry.Text
|
||||||
|
: $"[{entry.SenderName}] {entry.Text}";
|
||||||
|
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fires every time WaveManager.OnLifeLost is invoked (one event per leak).
|
||||||
|
// Routes the notification through ChatService as a SYSTEM message so it
|
||||||
|
// shares the feed with player chat. The lives counter in the top bar
|
||||||
|
// still updates independently via MatchState replication.
|
||||||
|
private void HandleLifeLost(int amount)
|
||||||
|
{
|
||||||
|
string text = amount == 1 ? "1 Life Lost" : $"{amount} Lives Lost";
|
||||||
|
ChatService.PostLocalSystem(text);
|
||||||
|
}
|
||||||
|
|
||||||
// ----- Match-end overlay (Victory / Defeat + Retry) ---------------
|
// ----- Match-end overlay (Victory / Defeat + Retry) ---------------
|
||||||
|
|
||||||
private void BuildMatchEndOverlay(VisualElement root)
|
private void BuildMatchEndOverlay(VisualElement root)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue