From 66f84652dc8aad599779cf0a38da436a8ddbf36a Mon Sep 17 00:00:00 2001 From: Matt F Date: Fri, 15 May 2026 13:40:13 -0700 Subject: [PATCH] Everything related to chat functionality, and updating the projectile prefab to rotate properly --- .../Projectiles/ProjectilePlaceholder.prefab | 208 +++++---- Assets/_Project/Scenes/Levels/Main.unity | 78 +++- Assets/_Project/Scenes/Levels/TestLevel.asset | 2 +- .../_Project/Scripts/Core/GridCoordinates.cs | 78 ++++ .../Editor/Levels/LevelBakePipeline.cs | 26 +- .../Gameplay/BuilderInputController.cs | 5 +- .../Scripts/Gameplay/CameraController.cs | 6 +- .../_Project/Scripts/Gameplay/ChatService.cs | 159 +++++++ .../Scripts/Gameplay/ChatService.cs.meta | 2 + .../Scripts/Gameplay/EnemyMovement.cs | 27 +- .../Scripts/Gameplay/PathfindingService.cs | 212 ++++++++- .../Scripts/Gameplay/TowerPlacementManager.cs | 17 +- .../_Project/Scripts/Gameplay/WaveManager.cs | 12 + Assets/_Project/Scripts/UI/HUDController.cs | 422 +++++++++++++++++- 14 files changed, 1133 insertions(+), 121 deletions(-) create mode 100644 Assets/_Project/Scripts/Gameplay/ChatService.cs create mode 100644 Assets/_Project/Scripts/Gameplay/ChatService.cs.meta diff --git a/Assets/_Project/Prefabs/Projectiles/ProjectilePlaceholder.prefab b/Assets/_Project/Prefabs/Projectiles/ProjectilePlaceholder.prefab index 1d664d8..be6ee64 100644 --- a/Assets/_Project/Prefabs/Projectiles/ProjectilePlaceholder.prefab +++ b/Assets/_Project/Prefabs/Projectiles/ProjectilePlaceholder.prefab @@ -9,9 +9,6 @@ GameObject: serializedVersion: 6 m_Component: - component: {fileID: 5346505748924138806} - - component: {fileID: 2441523409650755752} - - component: {fileID: 3004458703758247920} - - component: {fileID: 7079697957481585344} - component: {fileID: 6722677248442714376} - component: {fileID: 3956702135205686426} - component: {fileID: 6538425601827344639} @@ -30,93 +27,14 @@ Transform: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 2664719039363295382} 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_LocalScale: {x: 0.2, y: 0.2, z: 0.2} + m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 - m_Children: [] + m_Children: + - {fileID: 7632848354235619530} m_Father: {fileID: 0} - m_LocalEulerAnglesHint: {x: 90, 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} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &6722677248442714376 MonoBehaviour: m_ObjectHideFlags: 0 @@ -129,7 +47,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3} m_Name: m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject - GlobalObjectIdHash: 0 + GlobalObjectIdHash: 1313099294 InScenePlacedSourceGlobalObjectIdHash: 0 DeferredDespawnTick: 0 Ownership: 1 @@ -201,3 +119,117 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Combat.Projectile 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} diff --git a/Assets/_Project/Scenes/Levels/Main.unity b/Assets/_Project/Scenes/Levels/Main.unity index 07674e5..a5c86a7 100644 --- a/Assets/_Project/Scenes/Levels/Main.unity +++ b/Assets/_Project/Scenes/Levels/Main.unity @@ -1077,6 +1077,10 @@ MonoBehaviour: placementManager: {fileID: 1507514108} cameraController: {fileID: 1239994223} 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 MonoBehaviour: m_ObjectHideFlags: 0 @@ -1255,6 +1259,77 @@ BoxCollider: serializedVersion: 3 m_Size: {x: 7, y: 1, z: 7} 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 GameObject: m_ObjectHideFlags: 0 @@ -1427,7 +1502,7 @@ MonoBehaviour: edgePanEnabled: 1 minDollyDistance: 5 maxDollyDistance: 50 - startDollyDistance: 20 + startDollyDistance: 35 zoomSpeed: 3 cursorAnchoredZoom: 1 minPitchDegrees: 30 @@ -2539,3 +2614,4 @@ SceneRoots: - {fileID: 1149980841} - {fileID: 1731269687} - {fileID: 1755074870} + - {fileID: 1119106651} diff --git a/Assets/_Project/Scenes/Levels/TestLevel.asset b/Assets/_Project/Scenes/Levels/TestLevel.asset index 169ec4f..1d35f0f 100644 --- a/Assets/_Project/Scenes/Levels/TestLevel.asset +++ b/Assets/_Project/Scenes/Levels/TestLevel.asset @@ -19,7 +19,7 @@ MonoBehaviour: MapThumbnail: {fileID: 21300000, guid: d2e652d3e1c53454d80d3c1ec7888998, type: 3} ScenePath: Assets/_Project/Scenes/Levels/Main.unity AuthoringHash: 23fbb3b3dc3b0e03aa6f52cd2607c08275b33919120b2d96ea68b69ade0656f9 - LastBakeTimestamp: 2026-05-11T05:20:39.4889478Z + LastBakeTimestamp: 2026-05-14T17:48:33.8501744Z LastBakeOutcome: 1 LastBakeWarningCount: 2 GridOriginTile: {x: 0, y: 0} diff --git a/Assets/_Project/Scripts/Core/GridCoordinates.cs b/Assets/_Project/Scripts/Core/GridCoordinates.cs index bc93bf0..6789d97 100644 --- a/Assets/_Project/Scripts/Core/GridCoordinates.cs +++ b/Assets/_Project/Scripts/Core/GridCoordinates.cs @@ -90,6 +90,13 @@ namespace TD.Core /// 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. /// + /// + /// 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 + /// . + /// public static IEnumerable GetNeighbors(Vector2Int tile) { 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) } + /// + /// 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. + /// + /// + /// Used by pathfinding (A*, BFS) for 8-connected movement. Callers must + /// check for each yielded neighbor and reject the + /// move when either of the two "shoulder" cardinal tiles is non-walkable + /// (corner-cut prevention) — see . + /// + public static IEnumerable 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 + } + + /// + /// True if is a single diagonal + /// step (both X and Y differ by exactly 1). Both tiles assumed to be 8-neighbors. + /// + public static bool IsDiagonal(Vector2Int from, Vector2Int to) + { + return Mathf.Abs(from.x - to.x) == 1 && Mathf.Abs(from.y - to.y) == 1; + } + + /// + /// For a diagonal step , 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. + /// + 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 + } + + /// + /// Cost of stepping from to its 8-neighbor . + /// Returns 1.0 for cardinal moves, √2 for diagonals. Used by 8-connected A*. + /// + public static float StepCost(Vector2Int from, Vector2Int to) + { + return IsDiagonal(from, to) ? 1.41421356f : 1f; + } + + /// + /// Octile distance between two tiles. Admissible heuristic for 8-connected A* on + /// a uniform-cost grid (cardinal cost 1, diagonal cost √2). Equivalent to: + /// max(|dx|, |dy|) + (√2 - 1) * min(|dx|, |dy|). + /// + 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 --------------------------------------------------- // // Towers occupy a footprint of N x M tiles anchored at a single tile coordinate. diff --git a/Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs b/Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs index 2c4dc05..ad9f354 100644 --- a/Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs +++ b/Assets/_Project/Scripts/Editor/Levels/LevelBakePipeline.cs @@ -694,10 +694,21 @@ namespace TD.Levels.Editor var t = bfsQueue.Dequeue(); 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 (!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); bfsQueue.Enqueue(n); } @@ -871,10 +882,21 @@ namespace TD.Levels.Editor while (queue.Count > 0) { 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 (!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); queue.Enqueue(n); } diff --git a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs index 7273ce8..a37bc00 100644 --- a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs +++ b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs @@ -132,7 +132,10 @@ namespace TD.Gameplay // Escape: clear selection. Allowed during placement mode too — Escape never // means anything else here, and clearing selection during placement is fine. - if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame) + // 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(); } diff --git a/Assets/_Project/Scripts/Gameplay/CameraController.cs b/Assets/_Project/Scripts/Gameplay/CameraController.cs index d9c6197..b80f33e 100644 --- a/Assets/_Project/Scripts/Gameplay/CameraController.cs +++ b/Assets/_Project/Scripts/Gameplay/CameraController.cs @@ -227,8 +227,10 @@ namespace TD.Gameplay { Vector2 dir = Vector2.zero; - // Keyboard: WASD + arrow keys - var kb = Keyboard.current; + // Keyboard: WASD + arrow keys. Suppressed entirely while the player + // 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.aKey.isPressed || kb.leftArrowKey.isPressed) dir.x -= 1f; diff --git a/Assets/_Project/Scripts/Gameplay/ChatService.cs b/Assets/_Project/Scripts/Gameplay/ChatService.cs new file mode 100644 index 0000000..8df55ce --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/ChatService.cs @@ -0,0 +1,159 @@ +// Assets/_Project/Scripts/Gameplay/ChatService.cs +using Unity.Collections; +using Unity.Netcode; +using UnityEngine; +using TD.Core; + +namespace TD.Gameplay +{ + /// + /// 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 + /// to display the feed. + /// + /// + /// Authority model. Player messages go client → server (via + /// + ) so the + /// server gets a chance to validate/filter, then the server broadcasts to + /// every peer via . The host receives + /// its own broadcast like any other client, so a single subscription path + /// handles every message type uniformly. + /// + /// System messages. is local-only and + /// does NOT cross the network. Callers typically invoke it from inside an + /// already-replicated event (e.g. , 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. + /// + /// Scene setup. Drop a ChatService GameObject (with a + /// NetworkObject) into the gameplay scene. NGO 2.x auto-discovers + /// the prefab — no manual registration needed. + /// + [RequireComponent(typeof(NetworkObject))] + public class ChatService : NetworkBehaviour + { + // ----- Singleton -------------------------------------------------- + + public static ChatService Instance { get; private set; } + + // ----- Message types ---------------------------------------------- + + public enum MessageKind + { + Player, + System, + } + + /// + /// One chat feed entry. is empty for system messages. + /// + 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 ----------------------------------------------- + + /// + /// Fires on every peer when a player message arrives (after the + /// server's ClientRpc) OR when a local system message is posted via + /// . HUD subscribes to render the feed. + /// + public static event System.Action 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) ----------------------- + + /// + /// 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). + /// + 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) ------------------------------- + + /// + /// 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. + /// + 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}"; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/ChatService.cs.meta b/Assets/_Project/Scripts/Gameplay/ChatService.cs.meta new file mode 100644 index 0000000..be35a59 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/ChatService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 816890bb20c416b419e38d3d4b91ffca \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs index acaf510..9b730e2 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs @@ -61,6 +61,7 @@ namespace TD.Gameplay private PlayerSlot currentZone = PlayerSlot.None; private EnemyStatus status; private bool hasReachedGoal; + private bool wasStuck; // dedupes the "no path" warning // ----- Events --------------------------------------------------------- @@ -164,6 +165,12 @@ namespace TD.Gameplay if (toTarget.sqrMagnitude > 0.0001f) 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 ------------------------------------------------ @@ -231,9 +238,25 @@ namespace TD.Gameplay 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) - 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; + } } } } diff --git a/Assets/_Project/Scripts/Gameplay/PathfindingService.cs b/Assets/_Project/Scripts/Gameplay/PathfindingService.cs index d0a2216..092bd54 100644 --- a/Assets/_Project/Scripts/Gameplay/PathfindingService.cs +++ b/Assets/_Project/Scripts/Gameplay/PathfindingService.cs @@ -11,9 +11,17 @@ namespace TD.Gameplay /// nearest goal tile using A* on the runtime walkability grid. /// /// - /// Algorithm: A* with Manhattan-distance heuristic. Grid cost is uniform - /// (1 per step, 4-connected, no diagonals), so A* is optimal and significantly - /// faster than plain BFS on large grids thanks to the heuristic. + /// Algorithm: A* with the octile-distance heuristic on an 8-connected grid. + /// Cardinal steps cost 1.0, diagonal steps cost √2. Octile distance is the admissible + /// 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. + /// + /// Path smoothing: After A* produces a tile-by-tile path, + /// 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. /// /// Who calls this: /// @@ -55,8 +63,9 @@ namespace TD.Gameplay // A* scratch collections — allocated once and cleared per run to avoid GC. // 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 cameFrom = new Dictionary(); - private readonly Dictionary gScore = new Dictionary(); + private readonly Dictionary gScore = new Dictionary(); private readonly SimplePriorityQueue openSet = new SimplePriorityQueue(); // ----- Lifecycle -------------------------------------------------- @@ -114,9 +123,50 @@ namespace TD.Gameplay return new List(); } + // 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(); + } + return RunAStar(startTile, loader); } + // Expanding ring search (Chebyshev / 8-connected) for the nearest + // walkable tile around . 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 ------------------------------------------ private List RunAStar(Vector2Int start, LevelLoader loader) @@ -125,7 +175,7 @@ namespace TD.Gameplay gScore.Clear(); openSet.Clear(); - gScore[start] = 0; + gScore[start] = 0f; openSet.Enqueue(start, Heuristic(start)); while (openSet.Count > 0) @@ -133,21 +183,36 @@ namespace TD.Gameplay Vector2Int current = openSet.Dequeue(); 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; - int tentativeG = currentG + 1; - if (gScore.TryGetValue(neighbor, out int existingG) + // Corner-cut prevention: for a diagonal step, both cardinal + // 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; cameFrom[neighbor] = current; gScore[neighbor] = tentativeG; - int f = tentativeG + Heuristic(neighbor); + float f = tentativeG + Heuristic(neighbor); // Re-enqueue with updated priority. SimplePriorityQueue handles // duplicate entries by ignoring higher-cost duplicates on dequeue. @@ -155,20 +220,23 @@ namespace TD.Gameplay } } - // No path found — TowerPlacementManager should have prevented this. - Debug.LogWarning($"[PathfindingService] A* found no path from {start}. " + - "Check that TowerPlacementManager BFS is validating correctly."); + // No path found. This can legitimately happen during normal play + // when an enemy is in a disconnected pocket created by a tower + // 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(); } - // Manhattan distance to the nearest goal tile. Admissible heuristic for - // a 4-connected uniform-cost grid. - private int Heuristic(Vector2Int tile) + // Octile distance to the nearest goal tile. Admissible heuristic for an + // 8-connected uniform-cost grid (cardinal 1, diagonal √2). + private float Heuristic(Vector2Int tile) { - int best = int.MaxValue; + float best = float.MaxValue; foreach (var goal in goalTiles) { - int d = GridCoordinates.ManhattanDistance(tile, goal); + float d = GridCoordinates.OctileDistance(tile, goal); if (d < best) best = d; } return best; @@ -190,6 +258,105 @@ namespace TD.Gameplay return path; } + // ----- Path smoothing --------------------------------------------- + + /// + /// 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. + /// + /// + /// 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. + /// + private List SmoothPath(Vector2Int start, List path, + LevelLoader loader) + { + if (path.Count <= 1) return path; + + var result = new List(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; + } + + /// + /// Grid line-of-sight test from to . + /// Returns true if a straight Bresenham line between the two tiles only + /// crosses walkable tiles, with no diagonal corner-cuts. Used by + /// to decide whether two waypoints can be collapsed. + /// + 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 ---------------------------------------------------- private void BuildGoalTileSet() @@ -226,14 +393,15 @@ namespace TD.Gameplay // ------------------------------------------------------------------------- internal sealed class SimplePriorityQueue { - private readonly List<(int priority, Vector2Int tile)> heap - = new List<(int, Vector2Int)>(); + // Float priority to support diagonal cost (√2) in 8-connected A*. + private readonly List<(float priority, Vector2Int tile)> heap + = new List<(float, Vector2Int)>(); public int Count => heap.Count; public void Clear() => heap.Clear(); - public void Enqueue(Vector2Int tile, int priority) + public void Enqueue(Vector2Int tile, float priority) { heap.Add((priority, tile)); SiftUp(heap.Count - 1); diff --git a/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs b/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs index b2f51ce..81c3b47 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs @@ -494,10 +494,25 @@ namespace TD.Gameplay if (exitTiles.Contains(current)) 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 (!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); bfsQueue.Enqueue(neighbor); } diff --git a/Assets/_Project/Scripts/Gameplay/WaveManager.cs b/Assets/_Project/Scripts/Gameplay/WaveManager.cs index 3bdb735..e3ce617 100644 --- a/Assets/_Project/Scripts/Gameplay/WaveManager.cs +++ b/Assets/_Project/Scripts/Gameplay/WaveManager.cs @@ -387,8 +387,20 @@ namespace TD.Gameplay private void ShowLifeLossClientRpc(Vector3 worldPos, int amount) { FloatingTextSpawner.Instance?.SpawnLifeLoss(worldPos, amount); + OnLifeLost?.Invoke(amount); } + // ----- Local-only notification events ----------------------------- + + /// + /// 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). + /// + public static event System.Action OnLifeLost; + // ----- Helpers ---------------------------------------------------- private void UnsubscribeEnemy(EnemyHealth health) diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs index b323000..647aeb0 100644 --- a/Assets/_Project/Scripts/UI/HUDController.cs +++ b/Assets/_Project/Scripts/UI/HUDController.cs @@ -37,6 +37,23 @@ namespace TD.UI [Header("Settings")] [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 ------------------------------- private Label goldLabel; @@ -68,6 +85,25 @@ namespace TD.UI private VisualElement matchEndOverlay; 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 ------------------------------------------------------ private Coroutine rejectionFadeCoroutine; @@ -247,6 +283,11 @@ namespace TD.UI // MatchState.OnPhaseChanged fires Victory or Defeat. 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". // Stored on `myPanel` too so OnDestroy only clears the static if it still // points at this instance (defensive against re-creation overlap). @@ -259,6 +300,8 @@ namespace TD.UI private void OnEnable() { TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage; + WaveManager.OnLifeLost += HandleLifeLost; + ChatService.OnMessageReceived += HandleChatMessage; // Try to subscribe now; if SelectionState.Awake hasn't run yet (Unity does // not guarantee Awake/OnEnable ordering across objects), Start will retry. TrySubscribeSelection(); @@ -267,6 +310,8 @@ namespace TD.UI private void OnDisable() { TowerPlacementController.OnRejectionMessageReady -= ShowRejectionMessage; + WaveManager.OnLifeLost -= HandleLifeLost; + ChatService.OnMessageReceived -= HandleChatMessage; if (selectionSubscribed && SelectionState.Instance != null) { SelectionState.Instance.OnSelectionChanged -= HandleSelectionChanged; @@ -301,7 +346,13 @@ namespace TD.UI RefreshMatchStateDisplays(); UpdateBuildProgressIfShown(); 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(); } @@ -863,6 +914,375 @@ namespace TD.UI 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(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(_ => IsTextInputActive = true); + chatInput.RegisterCallback(_ => 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) --------------- private void BuildMatchEndOverlay(VisualElement root)