// 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.");
}
}
}