// 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 bool hasPendingInit; // ----- Server-local runtime state ------------------------------------- private float moveSpeed; private List remainingPath = new List(); private PlayerSlot currentZone = PlayerSlot.None; private EnemyStatus status; // ----- Events --------------------------------------------------------- /// /// Fired on the server when this enemy crosses from one player zone into another /// (or from a neutral zone into a player zone). The argument is the zone being /// LEFT — the zone that should be debited a life. /// WaveManager subscribes to deduct from the correct player's life pool. /// 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). /// public void InitializeServer(float speed, Vector2Int spawnerTile) { pendingMoveSpeed = speed; pendingSpawnerTile = spawnerTile; 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. var loader = LevelLoader.Instance; if (loader != null) currentZone = loader.GetOwner(pendingSpawnerTile); // 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]); Vector3 toTarget = targetWorld - transform.position; if (toTarget.sqrMagnitude <= WaypointSnapSq) { // Snap to the tile center then handle the waypoint transition. transform.position = targetWorld; AdvanceWaypoint(); } else { transform.position += toTarget.normalized * (effectiveSpeed * Time.deltaTime); // Face the direction of travel. if (toTarget.sqrMagnitude > 0.0001f) transform.rotation = Quaternion.LookRotation(toTarget); } } // ----- 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 leaving currentZone — debit that player's life pool. if (currentZone != PlayerSlot.None) OnZoneLeaked?.Invoke(currentZone); currentZone = newZone; } private void HandleGoalReached() { var health = GetComponent(); int livesCost = health != null ? health.LivesCost : 1; OnReachedGoal?.Invoke(this, livesCost); 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); if (remainingPath.Count == 0) Debug.LogWarning($"[EnemyMovement] No path found from {fromTile}. " + "TowerPlacementManager should have prevented a full block."); } } }