299 lines
13 KiB
C#
299 lines
13 KiB
C#
// Assets/_Project/Scripts/Gameplay/EnemyMovement.cs
|
|
using System.Collections.Generic;
|
|
using Unity.Netcode;
|
|
using UnityEngine;
|
|
using TD.Core;
|
|
using TD.Levels;
|
|
|
|
namespace TD.Gameplay
|
|
{
|
|
/// <summary>
|
|
/// Server-authoritative enemy movement along a dynamically computed A* path.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Initialization:</b> Call <see cref="InitializeServer"/> after <c>Instantiate</c>
|
|
/// and before <c>NetworkObject.Spawn()</c>. Provides the base move speed (from
|
|
/// <see cref="EnemyDefinition.MoveSpeed"/>) and the tile the enemy spawned on.
|
|
///
|
|
/// <b>Path lifecycle:</b>
|
|
/// <list type="bullet">
|
|
/// <item><see cref="OnNetworkSpawn"/> (server): queries <see cref="PathfindingService"/>
|
|
/// and stores the tile waypoint list.</item>
|
|
/// <item>Each frame: moves toward the world center of <c>remainingPath[0]</c>.
|
|
/// When within snap distance, pops the waypoint and checks for zone transitions.</item>
|
|
/// <item>When <see cref="PathfindingService.OnPathsInvalidated"/> fires (tower placed /
|
|
/// sold), <see cref="RecomputePath"/> reruns A* from the current tile.</item>
|
|
/// <item>When <c>remainingPath</c> is empty after a pop, the enemy has reached the
|
|
/// goal — <see cref="OnReachedGoal"/> fires and the enemy is despawned.</item>
|
|
/// </list>
|
|
///
|
|
/// <b>Zone leak tracking:</b> Each time a waypoint is popped, <see cref="LevelLoader.GetOwner"/>
|
|
/// is compared against <c>currentZone</c>. A change means the enemy has crossed a zone
|
|
/// boundary. <see cref="OnZoneLeaked"/> fires with the zone being LEFT so <c>WaveManager</c>
|
|
/// can debit the correct player's life pool.
|
|
///
|
|
/// <b>Speed:</b> Effective speed = <c>moveSpeed * EnemyStatus.GetSpeedMultiplier()</c>.
|
|
/// <c>EnemyStatus</c> replicates the multiplier as a NetworkVariable so movement looks
|
|
/// correct on all peers.
|
|
///
|
|
/// <b>Movement replication:</b> Requires a <c>NetworkTransform</c> on the prefab.
|
|
/// Position is written by the server; <c>NetworkTransform</c> interpolates on clients.
|
|
/// </remarks>
|
|
[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<Vector2Int> remainingPath = new List<Vector2Int>();
|
|
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 ---------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// <c>WaveManager</c> subscribes to increment the per-player leak counter.
|
|
/// </summary>
|
|
public event System.Action<PlayerSlot> OnZoneLeaked;
|
|
|
|
/// <summary>
|
|
/// Fired on the server when the enemy reaches the goal tile.
|
|
/// Carries this component and the enemy's <see cref="EnemyHealth.LivesCost"/>
|
|
/// so <c>WaveManager</c> can deduct the right number of lives in one call.
|
|
/// The NetworkObject is despawned immediately after subscribers return.
|
|
/// </summary>
|
|
public event System.Action<EnemyMovement, int> OnReachedGoal;
|
|
|
|
// ----- Server-only pre-spawn init -------------------------------------
|
|
|
|
/// <summary>
|
|
/// Called by <c>WaveManager</c> on the server after <c>Instantiate</c> and
|
|
/// before <c>NetworkObject.Spawn()</c>. <paramref name="speed"/> comes from
|
|
/// <see cref="EnemyDefinition.MoveSpeed"/>; <paramref name="spawnerTile"/> is
|
|
/// the tile the enemy spawns on (used as the A* start node);
|
|
/// <paramref name="ownerSlot"/> identifies which player "owns" this enemy
|
|
/// for leak-attribution (the slot whose <c>PlayerZoneData</c> contained the
|
|
/// spawner). It is NOT derived from the spawner tile's owner because spawner
|
|
/// tiles sit inside <c>SpawnerVolume</c>, not <c>PlayerZoneVolume</c>, so
|
|
/// their owner-grid entry is <see cref="PlayerSlot.None"/>.
|
|
/// </summary>
|
|
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<EnemyStatus>();
|
|
|
|
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<EnemyHealth>();
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|