// Assets/_Project/Scripts/Gameplay/EnemyMovement.cs using System.Collections.Generic; using Unity.Netcode; using UnityEngine; using TD.Core; using TD.Levels; namespace TD.Gameplay { /// /// Server-authoritative enemy movement along a dynamically computed A* path. /// /// /// Initialization: Call after Instantiate /// and before NetworkObject.Spawn(). Provides the base move speed (from /// ) and the tile the enemy spawned on. /// /// Path lifecycle: /// /// (server): queries /// and stores the tile waypoint list. /// Each frame: moves toward the world center of remainingPath[0]. /// When within snap distance, pops the waypoint and checks for zone transitions. /// When fires (tower placed / /// sold), reruns A* from the current tile. /// When remainingPath is empty after a pop, the enemy has reached the /// goal — fires and the enemy is despawned. /// /// /// Zone leak tracking: Each time a waypoint is popped, /// is compared against currentZone. A change means the enemy has crossed a zone /// boundary. fires with the zone being LEFT so WaveManager /// can debit the correct player's life pool. /// /// Speed: Effective speed = moveSpeed * EnemyStatus.GetSpeedMultiplier(). /// EnemyStatus replicates the multiplier as a NetworkVariable so movement looks /// correct on all peers. /// /// Movement replication: Requires a NetworkTransform on the prefab. /// Position is written by the server; NetworkTransform interpolates on clients. /// [RequireComponent(typeof(NetworkObject))] [RequireComponent(typeof(EnemyHealth))] [RequireComponent(typeof(EnemyStatus))] public class EnemyMovement : NetworkBehaviour { // Snap threshold in world units. When sqrMagnitude to the waypoint center // drops below this, we count the tile as reached and pop it. private const float WaypointSnapSq = 0.04f; // 0.2 world units // ----- Pre-spawn init (server-local) ---------------------------------- private float pendingMoveSpeed; private Vector2Int pendingSpawnerTile; private PlayerSlot pendingOwnerSlot; private bool hasPendingInit; // ----- Server-local runtime state ------------------------------------- private float moveSpeed; 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 // of their wave. Used so OnZoneLeaked fires only when the enemy escapes that // origin zone (not when it transits through any subsequent zone on its way to // the goal). Without this, every zone crossing was counted as a leak; only // the originating player should be credited a leak per the design. private PlayerSlot originZone = PlayerSlot.None; // Latches once the enemy has crossed its origin zone's leak volume, so we // never double-count a leak if the enemy re-enters its origin (rare but // possible if pathing is dynamic). private bool hasLeakedOriginZone; private EnemyStatus status; private bool hasReachedGoal; private bool wasStuck; // dedupes the "no path" warning // ----- Events --------------------------------------------------------- /// /// Fired on the server EXACTLY ONCE per enemy, when it crosses out of its /// origin zone (the zone it spawned in) into another zone. The argument is the /// origin zone — the player who "owns" this enemy and should be credited a /// leak. Transit through subsequent zones does NOT fire this event. /// WaveManager subscribes to increment the per-player leak counter. /// public event System.Action OnZoneLeaked; /// /// Fired on the server when the enemy reaches the goal tile. /// Carries this component and the enemy's /// so WaveManager can deduct the right number of lives in one call. /// The NetworkObject is despawned immediately after subscribers return. /// public event System.Action OnReachedGoal; // ----- Server-only pre-spawn init ------------------------------------- /// /// Called by WaveManager on the server after Instantiate and /// before NetworkObject.Spawn(). comes from /// ; is /// the tile the enemy spawns on (used as the A* start node); /// identifies which player "owns" this enemy /// for leak-attribution (the slot whose PlayerZoneData contained the /// spawner). It is NOT derived from the spawner tile's owner because spawner /// tiles sit inside SpawnerVolume, not PlayerZoneVolume, so /// their owner-grid entry is . /// public void InitializeServer(float speed, Vector2Int spawnerTile, PlayerSlot ownerSlot) { pendingMoveSpeed = speed; pendingSpawnerTile = spawnerTile; pendingOwnerSlot = ownerSlot; hasPendingInit = true; } // ----- NGO lifecycle -------------------------------------------------- public override void OnNetworkSpawn() { status = GetComponent(); if (!IsServer) return; if (!hasPendingInit) { Debug.LogError("[EnemyMovement] OnNetworkSpawn reached without InitializeServer " + "having been called. Enemy will not move."); return; } moveSpeed = pendingMoveSpeed; // Resolve starting zone from the spawner tile. This is what the enemy // observes as "the zone I am currently in." For SpawnerVolume tiles that // sit outside any PlayerZoneVolume (the common case) this is None — the // enemy will transition to its owner's zone on the first waypoint inside // that PlayerZoneVolume. var loader = LevelLoader.Instance; if (loader != null) currentZone = loader.GetOwner(pendingSpawnerTile); // Origin zone is the player who "owns" this enemy for leak attribution. // WaveManager passes it in (zone.Owner) because the spawner tile's owner- // grid entry is unreliable (typically None — see InitializeServer remarks). originZone = pendingOwnerSlot; hasLeakedOriginZone = false; // 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) PathfindingService.Instance.OnPathsInvalidated += RecomputePath; } public override void OnNetworkDespawn() { if (PathfindingService.Instance != null) PathfindingService.Instance.OnPathsInvalidated -= RecomputePath; } // ----- Server update -------------------------------------------------- private void Update() { if (!IsServer) return; if (remainingPath.Count == 0) return; float effectiveSpeed = moveSpeed * (status != null ? status.GetSpeedMultiplier() : 1f); Vector3 targetWorld = GridCoordinates.GridToWorld(remainingPath[0]); // XZ-only delta. Some enemy prefabs (e.g. the skeleton) have their root // sitting above Y=0; if we measured 3D distance, the Y offset would push // it permanently above the snap threshold and waypoints would never // complete. Tile arrival is a horizontal-plane concept anyway. Vector3 toTarget = targetWorld - transform.position; toTarget.y = 0f; if (toTarget.sqrMagnitude <= WaypointSnapSq) { // Snap XZ to the tile center. Preserve Y so we don't drag a visual // pivot down through the plane. transform.position = new Vector3( targetWorld.x, transform.position.y, targetWorld.z); AdvanceWaypoint(); } else { transform.position += toTarget.normalized * (effectiveSpeed * Time.deltaTime); // Face the direction of travel. 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 ------------------------------------------------ private void AdvanceWaypoint() { Vector2Int arrivedTile = remainingPath[0]; remainingPath.RemoveAt(0); CheckZoneTransition(arrivedTile); if (remainingPath.Count == 0) HandleGoalReached(); } private void CheckZoneTransition(Vector2Int tile) { var loader = LevelLoader.Instance; if (loader == null) return; PlayerSlot newZone = loader.GetOwner(tile); if (newZone == currentZone) return; // The enemy is crossing a zone boundary. Only fire OnZoneLeaked if it's // the FIRST time this enemy escapes its ORIGIN zone — that's the // "I failed to stop it in my own maze" event the leak counter tracks. // Subsequent transit through other zones is just routing toward the goal // and doesn't credit any player a leak. if (!hasLeakedOriginZone && currentZone == originZone && currentZone != PlayerSlot.None) { hasLeakedOriginZone = true; OnZoneLeaked?.Invoke(originZone); } currentZone = newZone; } private void HandleGoalReached() { if (hasReachedGoal) return; // belt-and-suspenders: never fire twice hasReachedGoal = true; var health = GetComponent(); int livesCost = health != null ? health.LivesCost : 1; OnReachedGoal?.Invoke(this, livesCost); if (NetworkObject != null && NetworkObject.IsSpawned) NetworkObject.Despawn(); } // ----- Path invalidation ---------------------------------------------- // Called on server when LevelLoader.OnWalkabilityChanged fires (tower placed/sold). private void RecomputePath() { if (!IsServer) return; // Recompute from the enemy's current tile position. Vector2Int currentTile = GridCoordinates.WorldToGrid(transform.position); ComputeAndStorePath(currentTile); } private void ComputeAndStorePath(Vector2Int fromTile) { var service = PathfindingService.Instance; if (service == null) { Debug.LogWarning("[EnemyMovement] PathfindingService not found. Enemy will not move."); return; } remainingPath = service.ComputePath(fromTile); // Dedupe the "no path" log: only emit on the transition into stuck // state, not every frame a walkability change re-fires the recompute. // Stuck enemies are a legitimate edge case (tower placement disconnected // a pocket the enemy was in — placement BFS only checks spawner→exit // reachability, not every enemy's current tile). The enemy will just // stand still until a placement change re-opens a route. if (remainingPath.Count == 0) { if (!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; } } } }