From d6858948780fbe8b970dc894650ee2f04402ffa7 Mon Sep 17 00:00:00 2001 From: Matt F Date: Tue, 23 Jun 2026 22:27:35 -0700 Subject: [PATCH 1/3] Adding flying functionality for enemies --- .../10_Enemy_UndeadDrakeBone_Definition.asset | 1 + .../Scripts/Gameplay/EnemyDefinition.cs | 12 +++-- .../Scripts/Gameplay/EnemyMovement.cs | 18 +++++-- .../_Project/Scripts/Gameplay/LevelLoader.cs | 17 +++++++ .../Scripts/Gameplay/PathfindingService.cs | 47 ++++++++++++------- .../_Project/Scripts/Gameplay/WaveManager.cs | 14 +++++- 6 files changed, 83 insertions(+), 26 deletions(-) diff --git a/Assets/_Project/Definitions/Enemies/10_Enemy_UndeadDrakeBone_Definition.asset b/Assets/_Project/Definitions/Enemies/10_Enemy_UndeadDrakeBone_Definition.asset index e3c2ac8..31d1eec 100644 --- a/Assets/_Project/Definitions/Enemies/10_Enemy_UndeadDrakeBone_Definition.asset +++ b/Assets/_Project/Definitions/Enemies/10_Enemy_UndeadDrakeBone_Definition.asset @@ -16,5 +16,6 @@ MonoBehaviour: MaxHp: 800 MoveSpeed: 4 IsFlying: 1 + FlightHeight: 3 LivesCost: 1 EnemyPrefab: {fileID: 1455822126534880203, guid: 738e5730a03623540aa1513639d0baf8, type: 3} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs index ae8bd5d..15f45f8 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs @@ -27,12 +27,16 @@ namespace TD.Gameplay [Tooltip("Movement speed in world units per second along the A* path.")] public float MoveSpeed = 3f; - [Tooltip("When true this enemy flies over tower footprints. " + - "Towers with GroundedOnly=true will not target it. " + - "Flying enemies follow the same A* path but are not physically " + - "blocked by tower colliders (handled in EnemyMovement).")] + [Tooltip("When true this enemy flies: it paths on the baked terrain grid, ignoring " + + "towers, so it soars directly over the maze instead of following it. " + + "Towers with GroundedOnly=true will not target it.")] public bool IsFlying; + [Tooltip("Height (world units) above the ground a flying enemy hovers, so it " + + "visually clears towers. Only used when IsFlying is true. Keep modest — " + + "tower targeting uses 3D range, so large values eat into effective range.")] + public float FlightHeight = 3f; + [Header("Costs")] [Tooltip("Number of lives deducted from the shared pool when this enemy " + "reaches the Goal. Boss enemies might cost 2 or more lives.")] diff --git a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs index 2ee92d4..06db968 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs @@ -53,11 +53,16 @@ namespace TD.Gameplay private float pendingMoveSpeed; private Vector2Int pendingSpawnerTile; private PlayerSlot pendingOwnerSlot; + private bool pendingIsFlying; private bool hasPendingInit; // ----- Server-local runtime state ------------------------------------- private float moveSpeed; + // When true this enemy flies: it paths on the baked terrain grid (ignoring + // towers) and never re-paths, since the baked grid doesn't change. Grounded + // enemies path on the runtime grid and re-path on every walkability change. + private bool isFlying; private List remainingPath = new List(); private PlayerSlot currentZone = PlayerSlot.None; // Zone the enemy was spawned in — i.e., which player "owns" this enemy as part @@ -106,11 +111,13 @@ namespace TD.Gameplay /// tiles sit inside SpawnerVolume, not PlayerZoneVolume, so /// their owner-grid entry is . /// - public void InitializeServer(float speed, Vector2Int spawnerTile, PlayerSlot ownerSlot) + public void InitializeServer(float speed, Vector2Int spawnerTile, PlayerSlot ownerSlot, + bool flying) { pendingMoveSpeed = speed; pendingSpawnerTile = spawnerTile; pendingOwnerSlot = ownerSlot; + pendingIsFlying = flying; hasPendingInit = true; } @@ -137,6 +144,7 @@ namespace TD.Gameplay } moveSpeed = pendingMoveSpeed; + isFlying = pendingIsFlying; // Resolve starting zone from the spawner tile. This is what the enemy // observes as "the zone I am currently in." For SpawnerVolume tiles that @@ -156,8 +164,10 @@ namespace TD.Gameplay // Compute the initial path from the spawn tile to the nearest goal. ComputeAndStorePath(pendingSpawnerTile); - // Recompute when a tower is placed or sold. - if (PathfindingService.Instance != null) + // Recompute when a tower is placed or sold — grounded enemies only. + // Flyers path on the static baked grid, so tower changes can never affect + // their route; subscribing would just trigger needless recomputes. + if (!isFlying && PathfindingService.Instance != null) PathfindingService.Instance.OnPathsInvalidated += RecomputePath; } @@ -280,7 +290,7 @@ namespace TD.Gameplay return; } - remainingPath = service.ComputePath(fromTile); + remainingPath = service.ComputePath(fromTile, ignoreTowers: isFlying); // Dedupe the "no path" log: only emit on the transition into stuck // state, not every frame a walkability change re-fires the recompute. diff --git a/Assets/_Project/Scripts/Gameplay/LevelLoader.cs b/Assets/_Project/Scripts/Gameplay/LevelLoader.cs index 89c8f6b..3a29dfb 100644 --- a/Assets/_Project/Scripts/Gameplay/LevelLoader.cs +++ b/Assets/_Project/Scripts/Gameplay/LevelLoader.cs @@ -316,6 +316,23 @@ namespace TD.Gameplay return runtimeWalkability[idx]; } + /// + /// True if is walkable in the BAKED terrain grid, ignoring + /// any runtime tower placement. Returns false for out-of-bounds tiles. + /// + /// + /// Flying enemies path against this grid so they soar over towers (which only + /// affect 's runtime grid) while still respecting + /// impassable terrain and map bounds. Because the baked grid never changes at + /// runtime, flying paths are computed once and never need invalidation. + /// + public bool IsBaseWalkable(Vector2Int tile) + { + if (level == null || level.WalkabilityGrid == null) return false; + if (!TryFlatIndex(tile, out int idx)) return false; + return level.WalkabilityGrid[idx]; + } + /// /// Placement state for . Returns /// for out-of-bounds tiles. diff --git a/Assets/_Project/Scripts/Gameplay/PathfindingService.cs b/Assets/_Project/Scripts/Gameplay/PathfindingService.cs index 413f1f4..d6c99df 100644 --- a/Assets/_Project/Scripts/Gameplay/PathfindingService.cs +++ b/Assets/_Project/Scripts/Gameplay/PathfindingService.cs @@ -125,7 +125,12 @@ namespace TD.Gameplay /// is unavailable (should not occur — TowerPlacementManager guarantees a path /// always exists after any placement). /// - public List ComputePath(Vector2Int startTile) + /// + /// When true, pathing uses the baked terrain grid + /// () instead of the runtime + /// tower-stamped grid, so the path soars over towers. Used by flying enemies. + /// + public List ComputePath(Vector2Int startTile, bool ignoreTowers = false) { var loader = LevelLoader.Instance; if (loader == null || !loader.IsLoaded || goalTiles == null || goalTiles.Count == 0) @@ -140,23 +145,31 @@ namespace TD.Gameplay // 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)) + // (For flyers this nudge is effectively never needed — the baked grid + // doesn't change — but it's cheap and keeps the two paths uniform.) + if (!IsTileWalkable(loader, startTile, ignoreTowers)) { - if (TryFindNearestWalkable(startTile, loader, maxRadius: 4, out var nudged)) + if (TryFindNearestWalkable(startTile, loader, maxRadius: 4, ignoreTowers, out var nudged)) startTile = nudged; else return new List(); } - return RunAStar(startTile, loader); + return RunAStar(startTile, loader, ignoreTowers); } + // Single seam for "is this tile traversable for this enemy kind": grounded + // enemies see the runtime grid (towers block them); flyers see only the baked + // terrain grid (towers are invisible to them). + private static bool IsTileWalkable(LevelLoader loader, Vector2Int tile, bool ignoreTowers) + => ignoreTowers ? loader.IsBaseWalkable(tile) : loader.IsWalkable(tile); + // 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) + int maxRadius, bool ignoreTowers, out Vector2Int found) { for (int r = 1; r <= maxRadius; r++) { @@ -167,7 +180,7 @@ namespace TD.Gameplay // 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)) + if (IsTileWalkable(loader, tile, ignoreTowers)) { found = tile; return true; @@ -181,7 +194,7 @@ namespace TD.Gameplay // ----- A* implementation ------------------------------------------ - private List RunAStar(Vector2Int start, LevelLoader loader) + private List RunAStar(Vector2Int start, LevelLoader loader, bool ignoreTowers) { cameFrom.Clear(); gScore.Clear(); @@ -197,14 +210,14 @@ namespace TD.Gameplay if (goalTiles.Contains(current)) { var tilePath = ReconstructPath(start, current); - return SmoothPath(start, tilePath, loader); + return SmoothPath(start, tilePath, loader, ignoreTowers); } float currentG = gScore.TryGetValue(current, out float g) ? g : float.MaxValue; foreach (var neighbor in GridCoordinates.GetNeighbors8(current)) { - if (!loader.IsWalkable(neighbor)) continue; + if (!IsTileWalkable(loader, neighbor, ignoreTowers)) continue; // Corner-cut prevention: for a diagonal step, both cardinal // shoulder tiles must also be walkable. Otherwise enemies could @@ -214,7 +227,8 @@ namespace TD.Gameplay { GridCoordinates.GetCornerShoulders(current, neighbor, out var shoulderA, out var shoulderB); - if (!loader.IsWalkable(shoulderA) || !loader.IsWalkable(shoulderB)) + if (!IsTileWalkable(loader, shoulderA, ignoreTowers) + || !IsTileWalkable(loader, shoulderB, ignoreTowers)) continue; } @@ -305,7 +319,7 @@ namespace TD.Gameplay /// squeezes through a diagonal corner. /// private List SmoothPath(Vector2Int start, List path, - LevelLoader loader) + LevelLoader loader, bool ignoreTowers) { if (path.Count <= 1) return path; @@ -319,7 +333,7 @@ namespace TD.Gameplay int farthest = i; for (int j = path.Count - 1; j > i; j--) { - if (HasLineOfSight(anchor, path[j], loader)) + if (HasLineOfSight(anchor, path[j], loader, ignoreTowers)) { farthest = j; break; @@ -340,7 +354,8 @@ namespace TD.Gameplay /// 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) + private static bool HasLineOfSight(Vector2Int a, Vector2Int b, LevelLoader loader, + bool ignoreTowers) { int x0 = a.x, y0 = a.y; int x1 = b.x, y1 = b.y; @@ -374,11 +389,11 @@ namespace TD.Gameplay // 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 (!IsTileWalkable(loader, new Vector2Int(x - sx, y), ignoreTowers)) return false; + if (!IsTileWalkable(loader, new Vector2Int(x, y - sy), ignoreTowers)) return false; } - if (!loader.IsWalkable(new Vector2Int(x, y))) return false; + if (!IsTileWalkable(loader, new Vector2Int(x, y), ignoreTowers)) return false; } return true; diff --git a/Assets/_Project/Scripts/Gameplay/WaveManager.cs b/Assets/_Project/Scripts/Gameplay/WaveManager.cs index 96e507b..00bf58a 100644 --- a/Assets/_Project/Scripts/Gameplay/WaveManager.cs +++ b/Assets/_Project/Scripts/Gameplay/WaveManager.cs @@ -421,7 +421,17 @@ namespace TD.Gameplay if (zHalfExtent > 0f) spawnPos.z += Random.Range(-zHalfExtent, zHalfExtent); - var go = Instantiate(def.EnemyPrefab, spawnPos, Quaternion.identity); + // Flying enemies spawn elevated so they visually soar over towers. The + // movement code preserves Y per-frame, so this height persists for the + // enemy's whole flight. NetworkTransform replicates the raised position. + Vector3 spawnPos = GridCoordinates.GridToWorld(spawnerTile); + if (def.IsFlying) + spawnPos.y += def.FlightHeight; + + var go = Instantiate( + def.EnemyPrefab, + spawnPos, + Quaternion.identity); var health = go.GetComponent(); var movement = go.GetComponent(); @@ -435,7 +445,7 @@ namespace TD.Gameplay } health.InitializeServer(def.MaxHp, def.LivesCost, def.IsFlying); - movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot); + movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot, def.IsFlying); health.OnDied += HandleEnemyKilled; movement.OnZoneLeaked += HandleZoneLeak; From 1d03689dadd20a202a8d64b48300ee32e64b7950 Mon Sep 17 00:00:00 2001 From: Matt F Date: Tue, 23 Jun 2026 22:52:24 -0700 Subject: [PATCH 2/3] Update WaveManager.cs --- Assets/_Project/Scripts/Gameplay/WaveManager.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Assets/_Project/Scripts/Gameplay/WaveManager.cs b/Assets/_Project/Scripts/Gameplay/WaveManager.cs index ad0ca3c..8834dc1 100644 --- a/Assets/_Project/Scripts/Gameplay/WaveManager.cs +++ b/Assets/_Project/Scripts/Gameplay/WaveManager.cs @@ -457,7 +457,6 @@ namespace TD.Gameplay // Flying enemies spawn elevated so they visually soar over towers. The // movement code preserves Y per-frame, so this height persists for the // enemy's whole flight. NetworkTransform replicates the raised position. - Vector3 spawnPos = GridCoordinates.GridToWorld(spawnerTile); if (def.IsFlying) spawnPos.y += def.FlightHeight; @@ -477,10 +476,8 @@ namespace TD.Gameplay return; } - health.InitializeServer(def.MaxHp, def.LivesCost, def.IsFlying); - movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot, def.IsFlying); health.InitializeServer(def.MaxHp, def.LivesCost, def.IsFlying, held); - movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot); + movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot, def.IsFlying); if (held) heldEnemies.Add(health); health.OnDied += HandleEnemyKilled; From a99570a29e436afe40469f69781b953f7fed0afa Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Tue, 23 Jun 2026 23:07:00 -0700 Subject: [PATCH 3/3] enemies face south --- Assets/_Project/Scripts/Gameplay/WaveManager.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Assets/_Project/Scripts/Gameplay/WaveManager.cs b/Assets/_Project/Scripts/Gameplay/WaveManager.cs index 8834dc1..0f49bae 100644 --- a/Assets/_Project/Scripts/Gameplay/WaveManager.cs +++ b/Assets/_Project/Scripts/Gameplay/WaveManager.cs @@ -414,7 +414,7 @@ namespace TD.Gameplay // PlayerZoneVolume (so OwnerGrid[spawnerTile] = None). var spawner = zone.Spawners[0]; (float xHalf, float zHalf) = ComputeSpawnerHalfExtents(spawner.TileArea); - SpawnEnemy(def, spawner.TilePosition, zone.Owner, xHalf, zHalf, held); + SpawnEnemy(def, spawner.TilePosition, zone.Owner, xHalf, zHalf, held, spawner.Facing); } } @@ -440,7 +440,8 @@ namespace TD.Gameplay } private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile, PlayerSlot ownerSlot, - float xHalfExtent = 0f, float zHalfExtent = 0f, bool held = false) + float xHalfExtent = 0f, float zHalfExtent = 0f, bool held = false, + Direction facing = Direction.South) { if (def.EnemyPrefab == null) { @@ -460,10 +461,19 @@ namespace TD.Gameplay if (def.IsFlying) spawnPos.y += def.FlightHeight; + float yaw = facing switch + { + Direction.North => 0f, + Direction.East => 90f, + Direction.South => 180f, + Direction.West => 270f, + _ => 0f, + }; + var go = Instantiate( def.EnemyPrefab, spawnPos, - Quaternion.identity); + Quaternion.Euler(0f, yaw, 0f)); var health = go.GetComponent(); var movement = go.GetComponent();