Compare commits

..

5 commits

6 changed files with 83 additions and 26 deletions

View file

@ -16,5 +16,6 @@ MonoBehaviour:
MaxHp: 800 MaxHp: 800
MoveSpeed: 4 MoveSpeed: 4
IsFlying: 1 IsFlying: 1
FlightHeight: 3
LivesCost: 1 LivesCost: 1
EnemyPrefab: {fileID: 1455822126534880203, guid: 738e5730a03623540aa1513639d0baf8, type: 3} EnemyPrefab: {fileID: 1455822126534880203, guid: 738e5730a03623540aa1513639d0baf8, type: 3}

View file

@ -27,12 +27,16 @@ namespace TD.Gameplay
[Tooltip("Movement speed in world units per second along the A* path.")] [Tooltip("Movement speed in world units per second along the A* path.")]
public float MoveSpeed = 3f; public float MoveSpeed = 3f;
[Tooltip("When true this enemy flies over tower footprints. " + [Tooltip("When true this enemy flies: it paths on the baked terrain grid, ignoring " +
"Towers with GroundedOnly=true will not target it. " + "towers, so it soars directly over the maze instead of following it. " +
"Flying enemies follow the same A* path but are not physically " + "Towers with GroundedOnly=true will not target it.")]
"blocked by tower colliders (handled in EnemyMovement).")]
public bool IsFlying; 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")] [Header("Costs")]
[Tooltip("Number of lives deducted from the shared pool when this enemy " + [Tooltip("Number of lives deducted from the shared pool when this enemy " +
"reaches the Goal. Boss enemies might cost 2 or more lives.")] "reaches the Goal. Boss enemies might cost 2 or more lives.")]

View file

@ -53,11 +53,16 @@ namespace TD.Gameplay
private float pendingMoveSpeed; private float pendingMoveSpeed;
private Vector2Int pendingSpawnerTile; private Vector2Int pendingSpawnerTile;
private PlayerSlot pendingOwnerSlot; private PlayerSlot pendingOwnerSlot;
private bool pendingIsFlying;
private bool hasPendingInit; private bool hasPendingInit;
// ----- Server-local runtime state ------------------------------------- // ----- Server-local runtime state -------------------------------------
private float moveSpeed; 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<Vector2Int> remainingPath = new List<Vector2Int>(); private List<Vector2Int> remainingPath = new List<Vector2Int>();
private PlayerSlot currentZone = PlayerSlot.None; private PlayerSlot currentZone = PlayerSlot.None;
// Zone the enemy was spawned in — i.e., which player "owns" this enemy as part // Zone the enemy was spawned in — i.e., which player "owns" this enemy as part
@ -107,11 +112,13 @@ namespace TD.Gameplay
/// tiles sit inside <c>SpawnerVolume</c>, not <c>PlayerZoneVolume</c>, so /// tiles sit inside <c>SpawnerVolume</c>, not <c>PlayerZoneVolume</c>, so
/// their owner-grid entry is <see cref="PlayerSlot.None"/>. /// their owner-grid entry is <see cref="PlayerSlot.None"/>.
/// </summary> /// </summary>
public void InitializeServer(float speed, Vector2Int spawnerTile, PlayerSlot ownerSlot) public void InitializeServer(float speed, Vector2Int spawnerTile, PlayerSlot ownerSlot,
bool flying)
{ {
pendingMoveSpeed = speed; pendingMoveSpeed = speed;
pendingSpawnerTile = spawnerTile; pendingSpawnerTile = spawnerTile;
pendingOwnerSlot = ownerSlot; pendingOwnerSlot = ownerSlot;
pendingIsFlying = flying;
hasPendingInit = true; hasPendingInit = true;
} }
@ -139,6 +146,7 @@ namespace TD.Gameplay
} }
moveSpeed = pendingMoveSpeed; moveSpeed = pendingMoveSpeed;
isFlying = pendingIsFlying;
// Resolve starting zone from the spawner tile. This is what the enemy // Resolve starting zone from the spawner tile. This is what the enemy
// observes as "the zone I am currently in." For SpawnerVolume tiles that // observes as "the zone I am currently in." For SpawnerVolume tiles that
@ -158,8 +166,10 @@ namespace TD.Gameplay
// Compute the initial path from the spawn tile to the nearest goal. // Compute the initial path from the spawn tile to the nearest goal.
ComputeAndStorePath(pendingSpawnerTile); ComputeAndStorePath(pendingSpawnerTile);
// Recompute when a tower is placed or sold. // Recompute when a tower is placed or sold — grounded enemies only.
if (PathfindingService.Instance != null) // 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; PathfindingService.Instance.OnPathsInvalidated += RecomputePath;
} }
@ -283,7 +293,7 @@ namespace TD.Gameplay
return; return;
} }
remainingPath = service.ComputePath(fromTile); remainingPath = service.ComputePath(fromTile, ignoreTowers: isFlying);
// Dedupe the "no path" log: only emit on the transition into stuck // Dedupe the "no path" log: only emit on the transition into stuck
// state, not every frame a walkability change re-fires the recompute. // state, not every frame a walkability change re-fires the recompute.

View file

@ -316,6 +316,23 @@ namespace TD.Gameplay
return runtimeWalkability[idx]; return runtimeWalkability[idx];
} }
/// <summary>
/// True if <paramref name="tile"/> is walkable in the BAKED terrain grid, ignoring
/// any runtime tower placement. Returns false for out-of-bounds tiles.
/// </summary>
/// <remarks>
/// Flying enemies path against this grid so they soar over towers (which only
/// affect <see cref="IsWalkable"/>'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.
/// </remarks>
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];
}
/// <summary> /// <summary>
/// Placement state for <paramref name="tile"/>. Returns /// Placement state for <paramref name="tile"/>. Returns
/// <see cref="PlacementState.Outside"/> for out-of-bounds tiles. /// <see cref="PlacementState.Outside"/> for out-of-bounds tiles.

View file

@ -125,7 +125,12 @@ namespace TD.Gameplay
/// is unavailable (should not occur — TowerPlacementManager guarantees a path /// is unavailable (should not occur — TowerPlacementManager guarantees a path
/// always exists after any placement). /// always exists after any placement).
/// </summary> /// </summary>
public List<Vector2Int> ComputePath(Vector2Int startTile) /// <param name="ignoreTowers">
/// When true, pathing uses the baked terrain grid
/// (<see cref="LevelLoader.IsBaseWalkable"/>) instead of the runtime
/// tower-stamped grid, so the path soars over towers. Used by flying enemies.
/// </param>
public List<Vector2Int> ComputePath(Vector2Int startTile, bool ignoreTowers = false)
{ {
var loader = LevelLoader.Instance; var loader = LevelLoader.Instance;
if (loader == null || !loader.IsLoaded || goalTiles == null || goalTiles.Count == 0) 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 // that just became blocked). Nudge to the nearest walkable tile so
// the enemy can resume routing instead of repeatedly failing. // the enemy can resume routing instead of repeatedly failing.
// Search outward in Chebyshev rings — closest 8-neighbor wins. // 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; startTile = nudged;
else else
return new List<Vector2Int>(); return new List<Vector2Int>();
} }
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 // Expanding ring search (Chebyshev / 8-connected) for the nearest
// walkable tile around <paramref name="origin"/>. Caps at maxRadius to // walkable tile around <paramref name="origin"/>. Caps at maxRadius to
// keep this O(maxRadius²) in the worst case. Used by ComputePath when // keep this O(maxRadius²) in the worst case. Used by ComputePath when
// an enemy's start tile becomes non-walkable mid-flight. // an enemy's start tile becomes non-walkable mid-flight.
private static bool TryFindNearestWalkable(Vector2Int origin, LevelLoader loader, 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++) 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). // 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; if (Mathf.Abs(dx) != r && Mathf.Abs(dy) != r) continue;
var tile = new Vector2Int(origin.x + dx, origin.y + dy); var tile = new Vector2Int(origin.x + dx, origin.y + dy);
if (loader.IsWalkable(tile)) if (IsTileWalkable(loader, tile, ignoreTowers))
{ {
found = tile; found = tile;
return true; return true;
@ -181,7 +194,7 @@ namespace TD.Gameplay
// ----- A* implementation ------------------------------------------ // ----- A* implementation ------------------------------------------
private List<Vector2Int> RunAStar(Vector2Int start, LevelLoader loader) private List<Vector2Int> RunAStar(Vector2Int start, LevelLoader loader, bool ignoreTowers)
{ {
cameFrom.Clear(); cameFrom.Clear();
gScore.Clear(); gScore.Clear();
@ -197,14 +210,14 @@ namespace TD.Gameplay
if (goalTiles.Contains(current)) if (goalTiles.Contains(current))
{ {
var tilePath = ReconstructPath(start, 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; float currentG = gScore.TryGetValue(current, out float g) ? g : float.MaxValue;
foreach (var neighbor in GridCoordinates.GetNeighbors8(current)) 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 // Corner-cut prevention: for a diagonal step, both cardinal
// shoulder tiles must also be walkable. Otherwise enemies could // shoulder tiles must also be walkable. Otherwise enemies could
@ -214,7 +227,8 @@ namespace TD.Gameplay
{ {
GridCoordinates.GetCornerShoulders(current, neighbor, GridCoordinates.GetCornerShoulders(current, neighbor,
out var shoulderA, out var shoulderB); out var shoulderA, out var shoulderB);
if (!loader.IsWalkable(shoulderA) || !loader.IsWalkable(shoulderB)) if (!IsTileWalkable(loader, shoulderA, ignoreTowers)
|| !IsTileWalkable(loader, shoulderB, ignoreTowers))
continue; continue;
} }
@ -305,7 +319,7 @@ namespace TD.Gameplay
/// squeezes through a diagonal corner. /// squeezes through a diagonal corner.
/// </remarks> /// </remarks>
private List<Vector2Int> SmoothPath(Vector2Int start, List<Vector2Int> path, private List<Vector2Int> SmoothPath(Vector2Int start, List<Vector2Int> path,
LevelLoader loader) LevelLoader loader, bool ignoreTowers)
{ {
if (path.Count <= 1) return path; if (path.Count <= 1) return path;
@ -319,7 +333,7 @@ namespace TD.Gameplay
int farthest = i; int farthest = i;
for (int j = path.Count - 1; j > i; j--) for (int j = path.Count - 1; j > i; j--)
{ {
if (HasLineOfSight(anchor, path[j], loader)) if (HasLineOfSight(anchor, path[j], loader, ignoreTowers))
{ {
farthest = j; farthest = j;
break; break;
@ -340,7 +354,8 @@ namespace TD.Gameplay
/// crosses walkable tiles, with no diagonal corner-cuts. Used by /// crosses walkable tiles, with no diagonal corner-cuts. Used by
/// <see cref="SmoothPath"/> to decide whether two waypoints can be collapsed. /// <see cref="SmoothPath"/> to decide whether two waypoints can be collapsed.
/// </summary> /// </summary>
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 x0 = a.x, y0 = a.y;
int x1 = b.x, y1 = b.y; int x1 = b.x, y1 = b.y;
@ -374,11 +389,11 @@ namespace TD.Gameplay
// shoulder tiles (same rule A* uses). // shoulder tiles (same rule A* uses).
if (steppedX && steppedY) if (steppedX && steppedY)
{ {
if (!loader.IsWalkable(new Vector2Int(x - sx, y))) return false; if (!IsTileWalkable(loader, new Vector2Int(x - sx, y), ignoreTowers)) return false;
if (!loader.IsWalkable(new Vector2Int(x, y - sy))) 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; return true;

View file

@ -455,6 +455,12 @@ namespace TD.Gameplay
if (zHalfExtent > 0f) if (zHalfExtent > 0f)
spawnPos.z += Random.Range(-zHalfExtent, zHalfExtent); spawnPos.z += Random.Range(-zHalfExtent, zHalfExtent);
// 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.
if (def.IsFlying)
spawnPos.y += def.FlightHeight;
float yaw = facing switch float yaw = facing switch
{ {
Direction.North => 0f, Direction.North => 0f,
@ -463,7 +469,11 @@ namespace TD.Gameplay
Direction.West => 270f, Direction.West => 270f,
_ => 0f, _ => 0f,
}; };
var go = Instantiate(def.EnemyPrefab, spawnPos, Quaternion.Euler(0f, yaw, 0f));
var go = Instantiate(
def.EnemyPrefab,
spawnPos,
Quaternion.Euler(0f, yaw, 0f));
var health = go.GetComponent<EnemyHealth>(); var health = go.GetComponent<EnemyHealth>();
var movement = go.GetComponent<EnemyMovement>(); var movement = go.GetComponent<EnemyMovement>();
@ -477,7 +487,7 @@ namespace TD.Gameplay
} }
health.InitializeServer(def.MaxHp, def.LivesCost, def.IsFlying, held); 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); if (held) heldEnemies.Add(health);
health.OnDied += HandleEnemyKilled; health.OnDied += HandleEnemyKilled;