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)