UnityTowerDefense/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs
Matt F 4892d7253d First pass at full refactor to 2.0 design
Restructures the game around the cyclical run loop from Game Design Doc V2:
5 waves = a cycle, 3 cycles = a phase, each phase ends in a boss.

- New TD.Gameplay.Waves: WaveGroup / PhaseDefinition / RunDefinition author the
  run as draggable weighted pools; RunState owns phase/cycle position, the drawn
  wave slots, and the per-slot enemy buff sets. WaveManager's flat wave array is
  gone -- it now only runs the encounter RunState points at.
- New TD.Gameplay.EnemyUpgrades: the post-wave enemy-buff vote, with public
  live-replicated ballots so the HUD can show who voted for what.
- Inter-wave flow is now strictly sequential: draft -> vote -> build, each stage
  ending early once every player has acted.
- Enemy abilities inverted from per-instance random rolls to deterministic,
  stacking per-wave-slot sets. Six cards ship: Split (reworked), Flight, Blink,
  No Bounty, Gold Theft, Double Up.
- Tower upgrades are a two-step tree: a draft pick unlocks a node, gold converts
  an already-placed tower in place.
- Boss encounters flag their enemies and drive a boss HP bar.
- Player cap reduced to 3 via MatchRules.MaxPlayers.
- GoldConfig is now keyed by global encounter number rather than wave index.

Compiles clean; NOT yet verified in-engine. Editor wiring still required --
see Docs/2.0_Setup_Checklist.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:45:11 -07:00

369 lines
17 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 the maze changes (tower placed / sold), <see cref="PathfindingService"/>'s
/// budgeted scheduler calls <see cref="RecomputePath"/> (registered via
/// <see cref="PathfindingService.RegisterMover"/>), rerunning A* from the current
/// tile — spread across frames so a full wave never spikes.</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 pendingIsFlying;
private bool hasPendingInit;
// ----- Server-local runtime state -------------------------------------
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 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;
/// <summary>The zone (player) this enemy was originally spawned for — see
/// <see cref="InitializeServer"/>. Used by on-death abilities (e.g. Split on Death) so
/// spawned children preserve the same leak attribution as their parent.</summary>
public PlayerSlot OriginZone => originZone;
// 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 EnemyHealth health;
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,
bool flying)
{
pendingMoveSpeed = speed;
pendingSpawnerTile = spawnerTile;
pendingOwnerSlot = ownerSlot;
pendingIsFlying = flying;
hasPendingInit = true;
}
// ----- NGO lifecycle --------------------------------------------------
public override void OnNetworkSpawn()
{
status = GetComponent<EnemyStatus>();
health = GetComponent<EnemyHealth>();
// Randomize the walk-cycle phase on every peer independently. Animations
// are purely visual and not replicated, so each client picks its own offset —
// the result looks correct everywhere without any network traffic.
var animator = GetComponentInChildren<Animator>();
if (animator != null)
animator.Update(Random.Range(0f, 2f));
if (!IsServer) return;
if (!hasPendingInit)
{
Debug.LogError("[EnemyMovement] OnNetworkSpawn reached without InitializeServer " +
"having been called. Enemy will not move.");
return;
}
moveSpeed = pendingMoveSpeed;
isFlying = pendingIsFlying;
// 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 — grounded enemies only.
// Flyers path on the static baked grid, so tower changes can never affect
// their route; registering would just trigger needless recomputes.
// Registration (not a direct event subscription) lets PathfindingService
// spread the recomputes across frames under a per-frame budget, so a maze
// change with a full wave present never spikes a single frame.
if (!isFlying && PathfindingService.Instance != null)
PathfindingService.Instance.RegisterMover(this);
}
public override void OnNetworkDespawn()
{
if (PathfindingService.Instance != null)
PathfindingService.Instance.UnregisterMover(this);
}
// ----- Server update --------------------------------------------------
private void Update()
{
if (!IsServer) return;
if (health != null && health.IsHeld) 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();
}
// ----- Ability-driven movement ----------------------------------------
/// <summary>
/// Server-only: teleport this enemy forward along its existing path by up to
/// <paramref name="tiles"/> waypoints. Used by the Blink wave buff.
/// </summary>
/// <remarks>
/// <b>Follows the path rather than the straight line to the goal.</b> Jumping toward the
/// goal as the crow flies would let a grounded enemy blink through walls, which is the
/// flying buff's job, not this one. Skipping waypoints keeps the enemy inside the maze —
/// it cuts corners, it doesn't ignore them.
///
/// <para>Zone tracking still runs on the destination tile, so an enemy that blinks out of
/// its origin zone still credits its owner a leak. Skipping that would make the buff a way
/// to launder leaks past the no-leak bonus.</para>
///
/// <para>Blinking onto the final waypoint resolves as a normal goal arrival, despawn and
/// life cost included.</para>
/// </remarks>
public void ServerBlinkForward(int tiles)
{
if (!IsServer || tiles <= 0) return;
if (hasReachedGoal || remainingPath.Count == 0) return;
if (health != null && health.IsHeld) return;
int skip = Mathf.Min(tiles, remainingPath.Count);
Vector2Int destination = remainingPath[skip - 1];
remainingPath.RemoveRange(0, skip);
// Preserve Y so flyers stay at altitude and grounded pivots don't sink.
Vector3 world = GridCoordinates.GridToWorld(destination);
transform.position = new Vector3(world.x, transform.position.y, world.z);
CheckZoneTransition(destination);
if (remainingPath.Count == 0)
HandleGoalReached();
}
// ----- Path invalidation ----------------------------------------------
// Called by PathfindingService's budgeted scheduler after a walkability change
// (tower placed/sold). Public so the scheduler can drive it across frames.
public 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, ignoreTowers: isFlying);
// 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;
}
}
}
}