We've got enemies and movement!!

This commit is contained in:
Matt F 2026-05-12 22:18:23 -07:00
parent 42ee0bf65d
commit 3287e8ea43
26 changed files with 1409 additions and 161 deletions

View file

@ -12,35 +12,28 @@ namespace TD.Combat
/// </summary>
/// <remarks>
/// <b>Authority:</b> Movement and hit detection run server-only.
/// <c>NetworkTransform</c> (required on the prefab) replicates the position to
/// clients so the projectile is visible on all peers.
/// <c>NetworkTransform</c> (required on the prefab) replicates position to clients
/// so the projectile is visible on all peers.
///
/// <b>Initialization:</b> Mirrors the <c>TowerInstance.InitializeServer</c> pattern —
/// <see cref="InitializeServer"/> is called by <c>TowerCombat</c> immediately after
/// <c>Instantiate</c> and before <c>NetworkObject.Spawn()</c>, which avoids writing
/// to NetworkVariables before spawn.
/// <b>Initialization:</b> Call <see cref="InitializeServer"/> after
/// <c>Instantiate</c> and before <c>NetworkObject.Spawn()</c>.
///
/// <b>Target loss:</b> If the target dies or is destroyed before the projectile
/// arrives, the projectile despawns silently (no hit, no damage).
/// <b>Kill attribution:</b> <paramref name="sourceOwner"/> is the
/// <see cref="PlayerSlot"/> of the firing tower. It is passed to
/// <see cref="EnemyHealth.TakeDamage"/> and <see cref="EnemyStatus.ApplyEffect"/>
/// so kill gold credits the correct player even for projectile kills.
///
/// <b>Chain + Projectile:</b> By design, TargetType.Chain is hitscan. If a designer
/// sets TargetType = Chain on a tower that has a ProjectilePrefab, the projectile
/// will hit the primary target only and ignore the chain. Log a warning to surface
/// the misconfiguration.
/// <b>Chain + Projectile:</b> <see cref="TargetType.Chain"/> is hitscan-only.
/// A projectile with Chain type will hit the primary target only and log a warning.
///
/// <b>Prefab requirements:</b> Must have <c>NetworkObject</c>, <c>NetworkTransform</c>,
/// and this <c>Projectile</c> component at the root.
/// <b>Prefab requirements:</b> <c>NetworkObject</c>, <c>NetworkTransform</c>,
/// and this component at the root.
/// </remarks>
[RequireComponent(typeof(NetworkObject))]
public class Projectile : NetworkBehaviour
{
// Hit threshold: squared distance at which the projectile considers itself
// to have reached the target. 0.09 = 0.3 world units; small enough to
// feel accurate, large enough to survive a high-speed frame where the
// projectile could skip past the target's transform in one step.
private const float HitThresholdSq = 0.09f;
private const float HitThresholdSq = 0.09f; // 0.3 world units
// All fields are server-local. Set by InitializeServer before Spawn.
private EnemyHealth target;
private float damage;
private DamageType damageType;
@ -51,18 +44,17 @@ namespace TD.Combat
private float effectDuration;
private float speed;
private LayerMask enemyLayerMask;
private PlayerSlot sourceOwner;
private bool initialized;
// Shared with TowerCombat's overlap calls. Both components run on the
// server main thread so there is no concurrent access.
private static readonly Collider[] s_overlapBuffer = new Collider[32];
// ----- Initialization (server-only, called before Spawn) -----------
// ----- Pre-spawn init ---------------------------------------------
/// <summary>
/// Stores all data this projectile needs to travel and apply damage.
/// Call this immediately after <c>Instantiate</c> and before
/// <c>NetworkObject.Spawn()</c>.
/// Called by <see cref="TowerCombat"/> after <c>Instantiate</c> and before
/// <c>NetworkObject.Spawn()</c>. <paramref name="sourceOwner"/> is the firing
/// tower's <see cref="PlayerSlot"/> for kill-gold attribution.
/// </summary>
public void InitializeServer(
EnemyHealth target,
@ -74,7 +66,8 @@ namespace TD.Combat
float dotDamagePerSecond,
float effectDuration,
float speed,
LayerMask enemyLayerMask)
LayerMask enemyLayerMask,
PlayerSlot sourceOwner)
{
this.target = target;
this.damage = damage;
@ -86,24 +79,21 @@ namespace TD.Combat
this.effectDuration = effectDuration;
this.speed = speed;
this.enemyLayerMask = enemyLayerMask;
this.sourceOwner = sourceOwner;
initialized = true;
if (targetType == TargetType.Chain)
Debug.LogWarning("[Projectile] TargetType.Chain is hitscan-only. " +
"This projectile will hit the primary target only. " +
"Consider using hitscan (null ProjectilePrefab) for chain towers.");
"This projectile will hit the primary target only.");
}
// ----- Server movement + hit detection -----------------------------
// ----- Server movement + hit detection ----------------------------
private void Update()
{
if (!IsServer || !initialized) return;
// Target gone — silently despawn, no damage applied.
if (target == null
|| target.IsDead
|| (target as UnityEngine.Object) == null)
if (target == null || target.IsDead || (target as UnityEngine.Object) == null)
{
NetworkObject.Despawn();
return;
@ -118,20 +108,18 @@ namespace TD.Combat
return;
}
// Rotate to face the target so the projectile mesh looks correct
// on all clients (NetworkTransform replicates both position and rotation).
transform.rotation = Quaternion.LookRotation(toTarget);
transform.position += toTarget.normalized * (speed * Time.deltaTime);
}
// ----- Hit application ---------------------------------------------
// ----- Hit application --------------------------------------------
private void ApplyHit()
{
switch (targetType)
{
case TargetType.Single:
case TargetType.Chain: // chain falls back to single-target on projectiles
case TargetType.Chain:
HitEnemy(target);
break;
@ -156,7 +144,7 @@ namespace TD.Combat
private void HitEnemy(EnemyHealth eh)
{
eh.TakeDamage(damage, damageType);
eh.TakeDamage(damage, damageType, sourceOwner);
if (effectDuration > 0f)
{
@ -170,7 +158,7 @@ namespace TD.Combat
if (magnitude > 0f)
eh.GetComponent<EnemyStatus>()
?.ApplyEffect(damageType, magnitude, effectDuration);
?.ApplyEffect(damageType, magnitude, effectDuration, sourceOwner);
}
}
}

View file

@ -249,24 +249,27 @@ namespace TD.Combat
private void ApplyDamageToTarget(TowerDefinition def, EnemyHealth primary, Vector3 primaryPos)
{
PlayerSlot owner = towerInstance.Owner;
switch (def.TargetType)
{
case TargetType.Single:
HitEnemy(def, primary);
HitEnemy(def, primary, owner);
break;
case TargetType.Splash:
HitEnemy(def, primary);
ApplySplash(def, primary, primaryPos);
HitEnemy(def, primary, owner);
ApplySplash(def, primary, primaryPos, owner);
break;
case TargetType.Chain:
ApplyChain(def, primary);
ApplyChain(def, primary, owner);
break;
}
}
private void ApplySplash(TowerDefinition def, EnemyHealth primary, Vector3 origin)
private void ApplySplash(TowerDefinition def, EnemyHealth primary,
Vector3 origin, PlayerSlot owner)
{
if (def.SplashRadius <= 0f) return;
@ -277,25 +280,22 @@ namespace TD.Combat
{
var eh = s_overlapBuffer[i].GetComponent<EnemyHealth>();
if (eh == null || eh.IsDead || (object)eh == (object)primary) continue;
HitEnemy(def, eh);
HitEnemy(def, eh, owner);
}
}
private void ApplyChain(TowerDefinition def, EnemyHealth primary)
private void ApplyChain(TowerDefinition def, EnemyHealth primary, PlayerSlot owner)
{
// Chain hit positions are collected and sent to clients for the
// future lightning-arc visual. The list is per-fire, so allocation
// here is acceptable; optimise to a pool if chain towers become hot.
var hitPositions = new List<Vector3> { primary.transform.position };
var alreadyHit = new HashSet<EnemyHealth> { primary };
HitEnemy(def, primary);
HitEnemy(def, primary, owner);
EnemyHealth current = primary;
for (int jump = 0; jump < def.ChainCount; jump++)
{
EnemyHealth next = null;
float bestSqr = float.MaxValue;
EnemyHealth next = null;
float bestSqr = float.MaxValue;
int count = Physics.OverlapSphereNonAlloc(
current.transform.position, def.ChainRange, s_overlapBuffer, enemyLayerMask);
@ -313,20 +313,20 @@ namespace TD.Combat
alreadyHit.Add(next);
hitPositions.Add(next.transform.position);
HitEnemy(def, next);
HitEnemy(def, next, owner);
current = next;
}
ChainFiredClientRpc(hitPositions.ToArray());
}
private void HitEnemy(TowerDefinition def, EnemyHealth target)
private void HitEnemy(TowerDefinition def, EnemyHealth target, PlayerSlot owner)
{
target.TakeDamage(def.Damage, def.DamageType);
ApplyStatusEffect(def, target);
target.TakeDamage(def.Damage, def.DamageType, owner);
ApplyStatusEffect(def, target, owner);
}
private void ApplyStatusEffect(TowerDefinition def, EnemyHealth target)
private void ApplyStatusEffect(TowerDefinition def, EnemyHealth target, PlayerSlot owner)
{
if (def.EffectDuration <= 0f) return;
@ -341,7 +341,7 @@ namespace TD.Combat
if (magnitude <= 0f) return;
target.GetComponent<EnemyStatus>()
?.ApplyEffect(def.DamageType, magnitude, def.EffectDuration);
?.ApplyEffect(def.DamageType, magnitude, def.EffectDuration, owner);
}
// ----- Projectile spawning -----------------------------------------
@ -370,7 +370,8 @@ namespace TD.Combat
def.DotDamagePerSecond,
def.EffectDuration,
def.ProjectileSpeed,
enemyLayerMask);
enemyLayerMask,
towerInstance.Owner);
go.GetComponent<NetworkObject>().Spawn();
}

View file

@ -0,0 +1,50 @@
// Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs
using UnityEngine;
namespace TD.Gameplay
{
/// <summary>
/// Data definition for a single enemy type. One asset per type; shared across all
/// instances spawned in a match. Consumed by <see cref="WaveManager"/> at spawn time.
/// </summary>
/// <remarks>
/// Follows the same ScriptableObject pattern as <c>TowerDefinition</c>: data lives
/// in project assets, only the asset reference (or its fields) crosses runtime code.
/// Replace <see cref="EnemyPrefab"/> with a real mesh/animator when art is ready —
/// no code changes required.
/// </remarks>
[CreateAssetMenu(fileName = "EnemyDefinition", menuName = "TD/Enemy Definition", order = 3)]
public class EnemyDefinition : ScriptableObject
{
[Header("Identity")]
[Tooltip("Human-readable name shown in debug logs and future enemy-info UI.")]
public string DisplayName;
[Header("Stats")]
[Tooltip("Maximum hit points for this enemy type.")]
public float MaxHp = 100f;
[Tooltip("Movement speed in world units per second along the A* path.")]
public float MoveSpeed = 3f;
[Tooltip("When true this enemy flies over tower footprints. " +
"Towers with GroundedOnly=true will not target it. " +
"Flying enemies follow the same A* path but are not physically " +
"blocked by tower colliders (handled in EnemyMovement).")]
public bool IsFlying;
[Header("Rewards / Costs")]
[Tooltip("Gold awarded to the player whose tower lands the killing blow.")]
public int GoldReward = 10;
[Tooltip("Number of lives deducted from the shared pool when this enemy " +
"reaches the Goal. Boss enemies might cost 2 or more lives.")]
public int LivesCost = 1;
[Header("Visuals")]
[Tooltip("Prefab spawned in the world for this enemy. Must have NetworkObject, " +
"NetworkTransform, EnemyHealth, EnemyStatus, and EnemyMovement at its root. " +
"Place it on the Enemy physics layer.")]
public GameObject EnemyPrefab;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c0d2521d49d21fe4380434f5951944d1

View file

@ -6,65 +6,129 @@ using TD.Core;
namespace TD.Gameplay
{
/// <summary>
/// Per-enemy HP component. Holds replicated HP and is the single point
/// through which all damage flows, so resistance lookups (Phase 1.5+) can
/// be added in one place without touching every damage source.
/// Per-enemy HP component. Single point through which all damage flows so
/// resistance lookups (Phase 1.5+) and kill attribution remain in one place.
/// </summary>
/// <remarks>
/// Lives on the enemy prefab root alongside <see cref="EnemyStatus"/> and the
/// future <c>EnemyMovement</c> component (Phase 1.5/1.6). HP is server-written
/// and replicated to all clients so health bars can render on any peer.
/// <b>Initialization:</b> Call <see cref="InitializeServer"/> on the server
/// immediately after <c>Instantiate</c> and before <c>NetworkObject.Spawn()</c>,
/// following the same pattern as <c>TowerInstance.InitializeServer</c>.
///
/// <b>Death flow (server-only):</b>
/// <c>TakeDamage</c> clamps HP to 0, fires <see cref="OnDied"/>, then calls
/// <c>NetworkObject.Despawn</c>. Subscribers must not touch the NetworkObject
/// after <c>OnDied</c> returns.
/// <b>Kill attribution:</b> <see cref="LastHitOwner"/> tracks the
/// <see cref="PlayerSlot"/> of the tower that most recently dealt direct damage.
/// DoT ticks from <c>EnemyStatus</c> also carry an owner so the credit
/// follows the source tower, not the DoT applicator.
///
/// <b>Death flow (server-only):</b> <see cref="TakeDamage"/> clamps HP to 0,
/// fires <see cref="OnDied"/>, then calls <c>NetworkObject.Despawn</c>.
/// Subscribers must not touch the NetworkObject after <see cref="OnDied"/> returns.
/// </remarks>
[RequireComponent(typeof(NetworkObject))]
public class EnemyHealth : NetworkBehaviour
{
[SerializeField] private float maxHp = 100f;
// ----- Pre-spawn init (server-local) ----------------------------------
private float pendingMaxHp = 100f;
private int pendingGoldReward;
private int pendingLivesCost = 1;
private bool pendingIsFlying;
private bool hasPendingInit;
// ----- Server-local runtime state -------------------------------------
/// <summary>Gold awarded to the killing player when this enemy dies.</summary>
public int GoldReward { get; private set; }
/// <summary>Lives deducted from the shared pool when this enemy reaches the goal.</summary>
public int LivesCost { get; private set; } = 1;
/// <summary>
/// The <see cref="PlayerSlot"/> of the tower that last dealt direct damage.
/// Used by <c>WaveManager</c> to award kill gold to the correct player.
/// Updated on every <see cref="TakeDamage"/> call, including DoT ticks whose
/// source owner is tracked on <see cref="EnemyStatus.StatusEffect"/>.
/// </summary>
public PlayerSlot LastHitOwner { get; private set; } = PlayerSlot.None;
// ----- Networked state ------------------------------------------------
private readonly NetworkVariable<float> hp = new NetworkVariable<float>(
0f,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
// ----- Public state -----------------------------------------------
private readonly NetworkVariable<bool> isFlying = new NetworkVariable<bool>(
false,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
// ----- Public state ---------------------------------------------------
public float CurrentHp => hp.Value;
public float MaxHp => maxHp;
public float MaxHp { get; private set; } = 100f;
public bool IsDead => hp.Value <= 0f;
// Stub: set by EnemyMovement or spawner in Phase 1.5/1.6.
// TowerCombat reads this to honour the GroundedOnly tower flag.
public bool IsFlying => false;
/// <summary>
/// True if this enemy flies over tower footprints.
/// Replicated so client visuals can adjust altitude.
/// Grounded towers with GroundedOnly=true will not target flying enemies.
/// </summary>
public bool IsFlying => isFlying.Value;
// ----- Events -----------------------------------------------------
// ----- Events ---------------------------------------------------------
/// <summary>
/// Fired on the server immediately before the enemy NetworkObject is despawned.
/// <see cref="TD.Combat.TowerCombat"/> subscribes to clear its target reference.
/// <c>WaveManager</c> subscribes to credit kill gold and decrement wave count.
/// Do not access the NetworkObject after this event returns.
/// </summary>
public event System.Action<EnemyHealth> OnDied;
// ----- NGO lifecycle ----------------------------------------------
// ----- Server-only pre-spawn init -------------------------------------
/// <summary>
/// Called by <c>WaveManager</c> on the server after <c>Instantiate</c>
/// and before <c>NetworkObject.Spawn()</c>. Mirrors the
/// <c>TowerInstance.InitializeServer</c> pattern.
/// </summary>
public void InitializeServer(float maxHp, int goldReward, int livesCost, bool flying)
{
pendingMaxHp = maxHp;
pendingGoldReward = goldReward;
pendingLivesCost = livesCost;
pendingIsFlying = flying;
hasPendingInit = true;
// Cache locally on the server immediately — clients resolve via NV.
MaxHp = maxHp;
GoldReward = goldReward;
LivesCost = livesCost;
}
// ----- NGO lifecycle --------------------------------------------------
public override void OnNetworkSpawn()
{
if (IsServer)
hp.Value = maxHp;
if (IsServer && hasPendingInit)
{
hp.Value = pendingMaxHp;
isFlying.Value = pendingIsFlying;
hasPendingInit = false;
}
// Non-server clients resolve MaxHp from the replicated hp initial value.
if (!IsServer)
MaxHp = hp.Value;
}
// ----- Server API -------------------------------------------------
// ----- Server API -----------------------------------------------------
/// <summary>
/// Applies <paramref name="damage"/> to this enemy. Server-only; no-op on clients.
/// <paramref name="type"/> is recorded for future resistance/weakness lookups —
/// all damage is full-value until the resistance table is implemented (Phase 1.5+).
/// Applies damage to this enemy. Server-only; silently no-ops on clients.
/// <paramref name="type"/> is accepted for future resistance lookups (Phase 1.5+).
/// <paramref name="attackerSlot"/> identifies the tower owner for kill attribution.
/// </summary>
public void TakeDamage(float damage, DamageType type)
public void TakeDamage(float damage, DamageType type, PlayerSlot attackerSlot)
{
if (!IsServer) return;
if (IsDead) return;
@ -73,13 +137,14 @@ namespace TD.Gameplay
// float modified = ResistanceTable.Apply(damage, type, this);
float modified = damage;
hp.Value = Mathf.Max(0f, hp.Value - modified);
LastHitOwner = attackerSlot;
hp.Value = Mathf.Max(0f, hp.Value - modified);
if (hp.Value <= 0f)
HandleDeath();
}
// ----- Private ----------------------------------------------------
// ----- Private --------------------------------------------------------
private void HandleDeath()
{

View file

@ -0,0 +1,225 @@
// 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 bool hasPendingInit;
// ----- Server-local runtime state -------------------------------------
private float moveSpeed;
private List<Vector2Int> remainingPath = new List<Vector2Int>();
private PlayerSlot currentZone = PlayerSlot.None;
private EnemyStatus status;
// ----- Events ---------------------------------------------------------
/// <summary>
/// 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.
/// <c>WaveManager</c> subscribes to deduct from the correct player's life pool.
/// </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).
/// </summary>
public void InitializeServer(float speed, Vector2Int spawnerTile)
{
pendingMoveSpeed = speed;
pendingSpawnerTile = spawnerTile;
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.
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<EnemyHealth>();
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.");
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fd6c02bbcc13fb14a9d596d9a2544dcc

View file

@ -17,45 +17,42 @@ namespace TD.Gameplay
/// <item>Poison — damage per second applied as a DoT tick</item>
/// <item>Others — unused (magnitude = 0)</item>
/// </list>
/// <b>SourceOwner</b> carries the <see cref="PlayerSlot"/> of the tower that
/// applied this effect so DoT kill credit goes to the right player.
/// </remarks>
public struct StatusEffect
{
public DamageType Source;
public float Magnitude;
public float RemainingDuration;
public PlayerSlot SourceOwner;
}
/// <summary>
/// Tracks and ticks lingering status effects (slow, burn, poison) on an enemy.
/// </summary>
/// <remarks>
/// <b>Authority:</b> The active-effect list is server-local (not replicated).
/// Only the derived <see cref="speedMultiplier"/> NetworkVariable is replicated,
/// so <c>EnemyMovement</c> (Phase 1.5/1.6) can scale speed on all peers without
/// re-broadcasting the full effect list.
/// <b>Authority:</b> The active-effect list is server-local. Only the derived
/// <see cref="speedMultiplier"/> NetworkVariable is replicated so
/// <c>EnemyMovement</c> can scale speed on all peers.
///
/// <b>Stacking rule:</b> A second hit of the same <see cref="DamageType"/> refreshes
/// the duration and magnitude rather than stacking. Cross-type interactions (e.g.
/// Cold + Fire) are not yet implemented; <see cref="HasEffect"/> is the hook for
/// when that design is worked out.
/// <b>Stacking rule:</b> Re-hitting with the same <see cref="DamageType"/>
/// refreshes duration and magnitude; it does not stack. Cross-type interactions
/// are not implemented; <see cref="HasEffect"/> is the hook for future work.
///
/// <b>DoT damage</b> is applied by calling <see cref="EnemyHealth.TakeDamage"/> each
/// tick so resistance lookups remain in one place.
/// <b>DoT damage</b> calls <see cref="EnemyHealth.TakeDamage"/> with the original
/// <see cref="StatusEffect.SourceOwner"/> so kill attribution stays correct.
/// </remarks>
[RequireComponent(typeof(NetworkObject))]
public class EnemyStatus : NetworkBehaviour
{
// Replicated so EnemyMovement can read it on all clients without
// knowing anything about which effects are active.
private readonly NetworkVariable<float> speedMultiplier = new NetworkVariable<float>(
1f,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
// Server-local — only the derived speedMultiplier NV crosses the wire.
private readonly List<StatusEffect> activeEffects = new List<StatusEffect>();
// Resolved once; used by Tick for DoT TakeDamage calls.
private EnemyHealth health;
// ----- NGO lifecycle -----------------------------------------------
@ -67,10 +64,10 @@ namespace TD.Gameplay
// ----- Public API --------------------------------------------------
/// <summary>Current speed fraction (01). 1 = full speed, 0.5 = half speed, etc.</summary>
/// <summary>Current speed fraction (01). 1 = full speed, 0.5 = half speed.</summary>
public float GetSpeedMultiplier() => speedMultiplier.Value;
/// <summary>True if an effect of the given type is currently active on this enemy.</summary>
/// <summary>True if an effect of the given type is currently active.</summary>
public bool HasEffect(DamageType type)
{
for (int i = 0; i < activeEffects.Count; i++)
@ -80,9 +77,12 @@ namespace TD.Gameplay
/// <summary>
/// Applies or refreshes a lingering effect. Server-only; no-op on clients.
/// Re-hitting with the same damage type refreshes duration and magnitude.
/// Re-hitting with the same source type refreshes duration and magnitude.
/// <paramref name="owner"/> is the tower's <see cref="PlayerSlot"/> — carried
/// on the effect so DoT ticks credit the right player on a kill.
/// </summary>
public void ApplyEffect(DamageType source, float magnitude, float duration)
public void ApplyEffect(DamageType source, float magnitude, float duration,
PlayerSlot owner)
{
if (!IsServer) return;
@ -91,9 +91,10 @@ namespace TD.Gameplay
if (activeEffects[i].Source != source) continue;
var e = activeEffects[i];
e.Magnitude = magnitude;
e.RemainingDuration = duration;
activeEffects[i] = e;
e.Magnitude = magnitude;
e.RemainingDuration = duration;
e.SourceOwner = owner;
activeEffects[i] = e;
RecalculateSpeedMultiplier();
return;
}
@ -103,6 +104,7 @@ namespace TD.Gameplay
Source = source,
Magnitude = magnitude,
RemainingDuration = duration,
SourceOwner = owner,
});
RecalculateSpeedMultiplier();
}
@ -123,11 +125,11 @@ namespace TD.Gameplay
{
var e = activeEffects[i];
// Apply DoT for Fire and Poison.
if (e.Source == DamageType.Fire || e.Source == DamageType.Poison)
// DoT tick — pass the original source owner so kill credit is correct.
if ((e.Source == DamageType.Fire || e.Source == DamageType.Poison)
&& health != null && !health.IsDead)
{
if (health != null && !health.IsDead)
health.TakeDamage(e.Magnitude * dt, e.Source);
health.TakeDamage(e.Magnitude * dt, e.Source, e.SourceOwner);
}
e.RemainingDuration -= dt;

View file

@ -365,6 +365,14 @@ namespace TD.Gameplay
// NetworkObject spawns and its Start/OnNetworkSpawn stamps its own
// footprint locally.
/// <summary>
/// Fired on every peer whenever <see cref="SetWalkable"/> changes a tile's
/// walkability. <see cref="TD.Gameplay.PathfindingService"/> subscribes to
/// invalidate cached paths so in-flight enemies reroute after a tower is
/// placed or removed.
/// </summary>
public event System.Action OnWalkabilityChanged;
/// <summary>
/// Sets the runtime walkability of <paramref name="tile"/>. Called by
/// <c>TowerPlacementManager</c> on the server when a tower is accepted (pass
@ -374,7 +382,9 @@ namespace TD.Gameplay
public void SetWalkable(Vector2Int tile, bool walkable)
{
if (!TryFlatIndex(tile, out int idx)) return;
if (runtimeWalkability[idx] == walkable) return; // no change — don't fire event
runtimeWalkability[idx] = walkable;
OnWalkabilityChanged?.Invoke();
}
/// <summary>

View file

@ -0,0 +1,279 @@
// Assets/_Project/Scripts/Gameplay/PathfindingService.cs
using System.Collections.Generic;
using UnityEngine;
using TD.Core;
using TD.Levels;
namespace TD.Gameplay
{
/// <summary>
/// Scene singleton that computes shortest-path routes from any tile to the
/// nearest goal tile using A* on the runtime walkability grid.
/// </summary>
/// <remarks>
/// <b>Algorithm:</b> A* with Manhattan-distance heuristic. Grid cost is uniform
/// (1 per step, 4-connected, no diagonals), so A* is optimal and significantly
/// faster than plain BFS on large grids thanks to the heuristic.
///
/// <b>Who calls this:</b>
/// <list type="bullet">
/// <item><see cref="EnemyMovement"/> calls <see cref="ComputePath"/> once on
/// spawn and again whenever <see cref="OnPathsInvalidated"/> fires.</item>
/// </list>
///
/// <b>Invalidation:</b> Subscribes to <see cref="LevelLoader.OnWalkabilityChanged"/>.
/// When a tower is placed or sold, <c>LevelLoader.SetWalkable</c> fires that event
/// and <see cref="OnPathsInvalidated"/> is relayed to all active enemies, which
/// each recompute their own path from their current tile.
///
/// <b>Goal tile set:</b> Built once on <c>Start</c> from
/// <c>LevelLoader.LevelData.Goals[].TileArea</c>. Goal tiles never change at
/// runtime (they are baked into the level), so there is no need to rebuild the set.
///
/// <b>No caching:</b> Paths are computed on demand per enemy. On typical TD grid
/// sizes (50×50 or smaller) a single A* run takes &lt;1 ms. If profiling shows
/// otherwise, add a per-startTile cache here.
/// </remarks>
public class PathfindingService : MonoBehaviour
{
// ----- Singleton --------------------------------------------------
public static PathfindingService Instance { get; private set; }
// ----- Events -----------------------------------------------------
/// <summary>
/// Fired on every peer when the walkability grid changes (tower placed/sold).
/// <see cref="EnemyMovement"/> subscribes per-instance to recompute its path.
/// </summary>
public event System.Action OnPathsInvalidated;
// ----- State ------------------------------------------------------
// Built once on Start from LevelData.Goals[].TileArea.
private HashSet<Vector2Int> goalTiles;
// A* scratch collections — allocated once and cleared per run to avoid GC.
// PathfindingService is a singleton, so single-instance scratch is safe.
private readonly Dictionary<Vector2Int, Vector2Int> cameFrom = new Dictionary<Vector2Int, Vector2Int>();
private readonly Dictionary<Vector2Int, int> gScore = new Dictionary<Vector2Int, int>();
private readonly SimplePriorityQueue openSet = new SimplePriorityQueue();
// ----- Lifecycle --------------------------------------------------
private void Awake()
{
if (Instance != null && Instance != this)
{
Debug.LogError("[PathfindingService] Duplicate instance detected. " +
"Only one PathfindingService should exist per scene.");
Destroy(gameObject);
return;
}
Instance = this;
}
private void Start()
{
BuildGoalTileSet();
var loader = LevelLoader.Instance;
if (loader != null)
loader.OnWalkabilityChanged += HandleWalkabilityChanged;
else
Debug.LogWarning("[PathfindingService] LevelLoader not found in Start. " +
"Path invalidation will not work.");
}
private void OnDestroy()
{
if (Instance == this) Instance = null;
var loader = LevelLoader.Instance;
if (loader != null)
loader.OnWalkabilityChanged -= HandleWalkabilityChanged;
}
// ----- Public API -------------------------------------------------
/// <summary>
/// Computes the shortest walkable path from <paramref name="startTile"/> to
/// the nearest goal tile. Returns an ordered list of tiles to visit, starting
/// with the first step AFTER <paramref name="startTile"/> and ending with the
/// reached goal tile. Returns an empty list if no path exists or LevelLoader
/// is unavailable (should not occur — TowerPlacementManager guarantees a path
/// always exists after any placement).
/// </summary>
public List<Vector2Int> ComputePath(Vector2Int startTile)
{
var loader = LevelLoader.Instance;
if (loader == null || !loader.IsLoaded || goalTiles == null || goalTiles.Count == 0)
{
Debug.LogWarning("[PathfindingService] Cannot compute path: " +
"LevelLoader unavailable or no goal tiles baked.");
return new List<Vector2Int>();
}
return RunAStar(startTile, loader);
}
// ----- A* implementation ------------------------------------------
private List<Vector2Int> RunAStar(Vector2Int start, LevelLoader loader)
{
cameFrom.Clear();
gScore.Clear();
openSet.Clear();
gScore[start] = 0;
openSet.Enqueue(start, Heuristic(start));
while (openSet.Count > 0)
{
Vector2Int current = openSet.Dequeue();
if (goalTiles.Contains(current))
return ReconstructPath(start, current);
int currentG = gScore.TryGetValue(current, out int g) ? g : int.MaxValue;
foreach (var neighbor in GridCoordinates.GetNeighbors(current))
{
if (!loader.IsWalkable(neighbor)) continue;
int tentativeG = currentG + 1;
if (gScore.TryGetValue(neighbor, out int existingG)
&& tentativeG >= existingG) continue;
cameFrom[neighbor] = current;
gScore[neighbor] = tentativeG;
int f = tentativeG + Heuristic(neighbor);
// Re-enqueue with updated priority. SimplePriorityQueue handles
// duplicate entries by ignoring higher-cost duplicates on dequeue.
openSet.Enqueue(neighbor, f);
}
}
// No path found — TowerPlacementManager should have prevented this.
Debug.LogWarning($"[PathfindingService] A* found no path from {start}. " +
"Check that TowerPlacementManager BFS is validating correctly.");
return new List<Vector2Int>();
}
// Manhattan distance to the nearest goal tile. Admissible heuristic for
// a 4-connected uniform-cost grid.
private int Heuristic(Vector2Int tile)
{
int best = int.MaxValue;
foreach (var goal in goalTiles)
{
int d = GridCoordinates.ManhattanDistance(tile, goal);
if (d < best) best = d;
}
return best;
}
private List<Vector2Int> ReconstructPath(Vector2Int start, Vector2Int goal)
{
var path = new List<Vector2Int>();
Vector2Int current = goal;
while (current != start)
{
path.Add(current);
if (!cameFrom.TryGetValue(current, out current))
break; // shouldn't happen
}
path.Reverse();
return path;
}
// ----- Helpers ----------------------------------------------------
private void BuildGoalTileSet()
{
goalTiles = new HashSet<Vector2Int>();
var loader = LevelLoader.Instance;
if (loader == null || loader.LevelData == null || loader.LevelData.Goals == null)
{
Debug.LogWarning("[PathfindingService] No LevelData or Goals found. " +
"Enemies will have no destination.");
return;
}
foreach (var goal in loader.LevelData.Goals)
{
if (goal.TileArea == null) continue;
foreach (var tile in goal.TileArea)
goalTiles.Add(tile);
}
Debug.Log($"[PathfindingService] Goal tile set built: {goalTiles.Count} tiles.");
}
private void HandleWalkabilityChanged()
{
OnPathsInvalidated?.Invoke();
}
}
// -------------------------------------------------------------------------
// Minimal priority queue for A*. Uses a sorted list of (priority, tile) pairs.
// Suitable for the grid sizes in this project (typically < 10,000 tiles).
// Replace with a binary heap if profiling shows this as a bottleneck.
// -------------------------------------------------------------------------
internal sealed class SimplePriorityQueue
{
private readonly List<(int priority, Vector2Int tile)> heap
= new List<(int, Vector2Int)>();
public int Count => heap.Count;
public void Clear() => heap.Clear();
public void Enqueue(Vector2Int tile, int priority)
{
heap.Add((priority, tile));
SiftUp(heap.Count - 1);
}
public Vector2Int Dequeue()
{
Vector2Int result = heap[0].tile;
int last = heap.Count - 1;
heap[0] = heap[last];
heap.RemoveAt(last);
if (heap.Count > 0) SiftDown(0);
return result;
}
private void SiftUp(int i)
{
while (i > 0)
{
int parent = (i - 1) / 2;
if (heap[parent].priority <= heap[i].priority) break;
(heap[i], heap[parent]) = (heap[parent], heap[i]);
i = parent;
}
}
private void SiftDown(int i)
{
int count = heap.Count;
while (true)
{
int smallest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < count && heap[left].priority < heap[smallest].priority) smallest = left;
if (right < count && heap[right].priority < heap[smallest].priority) smallest = right;
if (smallest == i) break;
(heap[i], heap[smallest]) = (heap[smallest], heap[i]);
i = smallest;
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: de7d013503af0f74c950f215f8dae1c0

View file

@ -48,6 +48,19 @@ namespace TD.Gameplay
public static PlayerSlot SlotForClient(ulong clientId)
=> GetForClient(clientId)?.Slot ?? PlayerSlot.None;
/// <summary>
/// Returns the <see cref="PlayerMatchState"/> whose assigned slot matches
/// <paramref name="slot"/>, or null if no connected client holds that slot.
/// O(n) over connected players (max 9) — acceptable for server-side use.
/// Used by <c>WaveManager</c> to resolve kill-gold recipients.
/// </summary>
public static PlayerMatchState GetForSlot(PlayerSlot slot)
{
foreach (var pms in s_byClientId.Values)
if (pms.Slot == slot) return pms;
return null;
}
/// <summary>
/// The local client's own state. Null on a dedicated server or before the
/// local player has spawned.

View file

@ -15,14 +15,13 @@ namespace TD.Gameplay
/// the full ScriptableObject locally on every client. TowerRegistry is the lookup
/// table that makes that resolution possible without hard-coding asset paths.</para>
///
/// <para><b>Auto-discovery.</b> On Awake, all <see cref="TowerDefinition"/> assets
/// under <c>Resources/TowerDefinitions/</c> are loaded automatically. No inspector
/// drag-and-drop required — add a new asset to that folder and it is registered at
/// runtime with no other changes needed. This scales cleanly to 100+ tower types.</para>
/// <para><b>Registration.</b> Drag every <see cref="TowerDefinition"/> asset into the
/// <c>Definitions</c> list on this component in the inspector. Assets can live anywhere
/// in the project — no special folder required.</para>
///
/// <para><b>Path E upgrade path.</b> In Path E the registry will filter to only the
/// definitions belonging to the active match's <c>RaceDefinition</c> rosters. For now
/// all assets in the Resources folder are registered.</para>
/// all assigned assets are registered.</para>
///
/// <para><b>Plain MonoBehaviour.</b> Not a NetworkBehaviour — the registry is
/// identical on every peer (same assets, same names), so there is nothing to sync.</para>
@ -37,14 +36,12 @@ namespace TD.Gameplay
/// </summary>
public static TowerRegistry Instance { get; private set; }
// ----- Constants --------------------------------------------------
// ----- Inspector --------------------------------------------------
/// <summary>
/// Resources-relative folder path that TowerDefinition assets must live under
/// to be auto-discovered. Create this folder if it doesn't exist.
/// Full path: Assets/Resources/TowerDefinitions/
/// </summary>
private const string ResourcesFolder = "TowerDefinitions";
[Tooltip("All TowerDefinition assets available in this match. " +
"Drag assets here from Assets/_Project/Data/TowerDefinitions/ " +
"(or wherever they live). Asset name is used as the registry key.")]
[SerializeField] private TowerDefinition[] definitions;
// ----- Internal lookup table --------------------------------------
@ -96,21 +93,14 @@ namespace TD.Gameplay
{
byName.Clear();
// Resources.LoadAll finds every TowerDefinition asset anywhere under
// Assets/Resources/TowerDefinitions/ (including sub-folders).
// No manual registration needed — drop an asset in the folder and it
// is available on the next play session.
var loaded = Resources.LoadAll<TowerDefinition>(ResourcesFolder);
if (loaded.Length == 0)
if (definitions == null || definitions.Length == 0)
{
Debug.LogWarning($"[TowerRegistry] No TowerDefinition assets found under " +
$"Resources/{ResourcesFolder}/. " +
$"Create the folder and add TowerDefinition assets to it.");
Debug.LogWarning("[TowerRegistry] No TowerDefinition assets assigned. " +
"Drag assets into the Definitions list on the TowerRegistry component.");
return;
}
foreach (var def in loaded)
foreach (var def in definitions)
{
if (def == null) continue;
@ -124,8 +114,7 @@ namespace TD.Gameplay
byName[def.name] = def;
}
Debug.Log($"[TowerRegistry] Auto-discovered and registered " +
$"{byName.Count} tower definition(s) from Resources/{ResourcesFolder}/.");
Debug.Log($"[TowerRegistry] Registered {byName.Count} tower definition(s).");
}
}
}

View file

@ -0,0 +1,45 @@
// Assets/_Project/Scripts/Gameplay/WaveDefinition.cs
using System;
using UnityEngine;
namespace TD.Gameplay
{
/// <summary>
/// A single spawn group within a wave: one enemy type, how many of them,
/// and how long to wait between each spawn.
/// </summary>
[Serializable]
public struct WaveEntry
{
[Tooltip("The enemy type to spawn for this group.")]
public EnemyDefinition EnemyType;
[Tooltip("How many enemies of this type to spawn.")]
public int Count;
[Tooltip("Seconds between each individual spawn within this group. " +
"0 = all spawn simultaneously.")]
public float SpawnInterval;
}
/// <summary>
/// Defines the composition of a single wave. One asset per wave; referenced in
/// order by <see cref="WaveManager.waveDefinitions"/>.
/// </summary>
/// <remarks>
/// Entries are processed in array order. Multiple entries let designers mix enemy
/// types within one wave (e.g. 10 fast scouts followed by 3 armoured brutes).
/// The wave is not considered complete until all spawned enemies are dead or have
/// leaked — not just until all entries are spawned.
/// </remarks>
[CreateAssetMenu(fileName = "WaveDefinition", menuName = "TD/Wave Definition", order = 4)]
public class WaveDefinition : ScriptableObject
{
[Tooltip("Seconds between the wave-number advancing (start of prep) and the " +
"first enemy spawning. Gives players time to build before the wave hits.")]
public float PrepTime = 10f;
[Tooltip("Enemy groups that make up this wave. Processed in order.")]
public WaveEntry[] Entries;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 48e93688dc0fb5b4cbe7be9a241b4421

View file

@ -0,0 +1,331 @@
// Assets/_Project/Scripts/Gameplay/WaveManager.cs
using System.Collections;
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Levels;
namespace TD.Gameplay
{
/// <summary>
/// Server-authoritative wave controller. Spawns enemies across all player zones,
/// tracks wave completion, awards kill gold, and manages the shared lives pool.
/// </summary>
/// <remarks>
/// <b>Wave lifecycle:</b>
/// <list type="bullet">
/// <item>When <see cref="MatchPhase.Playing"/> is entered,
/// <see cref="StartNextWave"/> advances <see cref="MatchState.CurrentWave"/>
/// immediately (so the HUD shows the wave number during prep), then waits
/// <see cref="WaveDefinition.PrepTime"/> before spawning.</item>
/// <item>Each <see cref="WaveEntry"/> spawns <c>Count</c> enemies per player zone,
/// one zone per frame-group, with <c>SpawnInterval</c> seconds between
/// individual enemies in the group.</item>
/// <item>After all entries are spawned, the wave is considered complete only when
/// every active enemy is either killed or has reached the goal.</item>
/// <item>All waves exhausted → <see cref="MatchPhase.Victory"/>.</item>
/// <item>Lives drop to 0 → <see cref="MatchPhase.Defeat"/>.</item>
/// </list>
///
/// <b>Kill gold:</b> When an enemy dies, <see cref="EnemyHealth.LastHitOwner"/> names
/// the tower's player. <see cref="PlayerMatchState.GetForSlot"/> resolves the
/// <c>OwnerClientId</c>, and <see cref="PlayerGoldManager.GetForClient"/> awards
/// the gold.
///
/// <b>Zone leak counts:</b> <see cref="zoneLeakCounts"/> is a <c>NetworkList</c>
/// indexed by <c>(int)PlayerSlot</c> (indices 08). It is incremented when an enemy
/// crosses from one player zone into another, giving the HUD a per-player leak score.
/// Index 0 corresponds to <see cref="PlayerSlot.None"/> and is unused.
///
/// <b>Inspector setup:</b>
/// <list type="bullet">
/// <item>Assign <see cref="waveDefinitions"/> in order (Wave 1 at index 0).</item>
/// <item>Set <see cref="startingLives"/> to match your level design intent.</item>
/// </list>
/// </remarks>
public class WaveManager : NetworkBehaviour
{
// ----- Singleton --------------------------------------------------
public static WaveManager Instance { get; private set; }
// ----- Inspector --------------------------------------------------
[Tooltip("Wave definitions in order. Index 0 = Wave 1.")]
[SerializeField] private WaveDefinition[] waveDefinitions;
[Tooltip("Shared lives pool at the start of a match.")]
[SerializeField] private int startingLives = 20;
// ----- Networked state --------------------------------------------
// Per-slot zone-leak counters. Index = (int)PlayerSlot; size = 10 (0-9).
// Index 0 (PlayerSlot.None) is allocated but never written.
// Replicated so the HUD can show per-player leak scores on all peers.
private readonly NetworkList<int> zoneLeakCounts = new NetworkList<int>();
// ----- Server-local runtime state ---------------------------------
private int remainingLives;
private int activeEnemyCount;
private bool spawningComplete;
private int currentWaveIndex = -1; // -1 = not yet started
// ----- NGO lifecycle ----------------------------------------------
public override void OnNetworkSpawn()
{
if (Instance != null && Instance != this)
{
Debug.LogError("[WaveManager] Duplicate WaveManager detected. " +
"Only one may exist per scene.");
return;
}
Instance = this;
if (!IsServer) return;
// Populate the NetworkList with 10 zeros (indices 0-9 for PlayerSlot.None..Player9).
for (int i = 0; i < 10; i++)
zoneLeakCounts.Add(0);
remainingLives = startingLives;
// NGO's scene-object spawn sweep calls all OnNetworkSpawn methods
// synchronously in a single call stack. Yielding one frame guarantees
// every sibling NetworkBehaviour (including MatchState) has finished
// its own OnNetworkSpawn before we try to read MatchState.Instance.
StartCoroutine(InitAfterSpawn());
}
private System.Collections.IEnumerator InitAfterSpawn()
{
yield return null; // wait one frame
var ms = MatchState.Instance;
if (ms == null)
{
Debug.LogWarning("[WaveManager] MatchState not found after spawn. " +
"Waves will not start automatically.");
yield break;
}
ms.SetLives(remainingLives);
ms.OnPhaseChanged += HandlePhaseChanged;
if (ms.Phase == MatchPhase.Playing)
StartNextWave();
}
public override void OnNetworkDespawn()
{
if (Instance == this) Instance = null;
if (MatchState.Instance != null)
MatchState.Instance.OnPhaseChanged -= HandlePhaseChanged;
}
// ----- Public accessors -------------------------------------------
/// <summary>
/// Number of times enemies have leaked out of the given player's zone.
/// Replicated — safe to call on any peer.
/// </summary>
public int GetZoneLeakCount(PlayerSlot slot)
{
int idx = (int)slot;
return (idx >= 0 && idx < zoneLeakCounts.Count) ? zoneLeakCounts[idx] : 0;
}
// ----- Phase handling ---------------------------------------------
private void HandlePhaseChanged(MatchPhase previous, MatchPhase next)
{
if (!IsServer) return;
if (next == MatchPhase.Playing && currentWaveIndex < 0)
StartNextWave();
}
// ----- Wave coroutine ---------------------------------------------
private void StartNextWave()
{
currentWaveIndex++;
if (waveDefinitions == null || currentWaveIndex >= waveDefinitions.Length)
{
Debug.Log("[WaveManager] All waves complete. Victory.");
MatchState.Instance?.SetPhase(MatchPhase.Victory);
return;
}
// Advance the replicated wave counter at the START of prep so the HUD
// shows the upcoming wave number during the countdown.
MatchState.Instance?.SetCurrentWave(currentWaveIndex + 1); // 1-based
activeEnemyCount = 0;
spawningComplete = false;
StartCoroutine(RunWave(waveDefinitions[currentWaveIndex]));
}
private IEnumerator RunWave(WaveDefinition def)
{
// Prep phase — players build while the countdown ticks.
yield return new WaitForSeconds(def.PrepTime);
// Spawn phase.
if (def.Entries != null)
{
foreach (var entry in def.Entries)
{
if (entry.EnemyType == null || entry.Count <= 0) continue;
for (int i = 0; i < entry.Count; i++)
{
SpawnEnemyInAllZones(entry.EnemyType);
if (entry.SpawnInterval > 0f)
yield return new WaitForSeconds(entry.SpawnInterval);
}
}
}
spawningComplete = true;
// If every spawned enemy was already resolved before this coroutine finished
// (edge case: SpawnInterval = 0 and enemies die instantly), complete the wave now.
CheckWaveComplete();
}
// ----- Spawn helpers ----------------------------------------------
private void SpawnEnemyInAllZones(EnemyDefinition def)
{
var loader = LevelLoader.Instance;
if (loader?.LevelData?.PlayerZones == null) return;
foreach (var zone in loader.LevelData.PlayerZones)
{
if (zone.Spawners == null || zone.Spawners.Length == 0) continue;
// Use the first spawner in the zone. Future: round-robin through Spawners.
SpawnEnemy(def, zone.Spawners[0].TilePosition);
}
}
private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile)
{
if (def.EnemyPrefab == null)
{
Debug.LogWarning($"[WaveManager] EnemyDefinition '{def.name}' has no EnemyPrefab assigned.");
return;
}
var go = Instantiate(
def.EnemyPrefab,
GridCoordinates.GridToWorld(spawnerTile),
Quaternion.identity);
var health = go.GetComponent<EnemyHealth>();
var movement = go.GetComponent<EnemyMovement>();
if (health == null || movement == null)
{
Debug.LogError($"[WaveManager] Enemy prefab '{def.EnemyPrefab.name}' is missing " +
$"EnemyHealth or EnemyMovement. Enemy will not be spawned.");
Destroy(go);
return;
}
health.InitializeServer(def.MaxHp, def.GoldReward, def.LivesCost, def.IsFlying);
movement.InitializeServer(def.MoveSpeed, spawnerTile);
health.OnDied += HandleEnemyKilled;
movement.OnZoneLeaked += HandleZoneLeak;
movement.OnReachedGoal += HandleEnemyReachedGoal;
activeEnemyCount++;
go.GetComponent<NetworkObject>().Spawn();
}
// ----- Enemy event handlers (server-only) -------------------------
private void HandleEnemyKilled(EnemyHealth health)
{
// Award kill gold to the tower owner that landed the killing blow.
PlayerSlot killerSlot = health.LastHitOwner;
if (killerSlot != PlayerSlot.None)
{
var pms = PlayerMatchState.GetForSlot(killerSlot);
if (pms != null)
PlayerGoldManager.GetForClient(pms.OwnerClientId)
?.AwardGold(health.GoldReward);
}
UnsubscribeEnemy(health);
DecrementAndCheckComplete();
}
private void HandleZoneLeak(PlayerSlot leavingZone)
{
// Increment the per-slot leak counter for the zone the enemy is leaving.
int idx = (int)leavingZone;
if (idx >= 0 && idx < zoneLeakCounts.Count)
zoneLeakCounts[idx]++;
}
private void HandleEnemyReachedGoal(EnemyMovement movement, int livesCost)
{
UnsubscribeEnemy(movement.GetComponent<EnemyHealth>());
remainingLives = Mathf.Max(0, remainingLives - livesCost);
MatchState.Instance?.SetLives(remainingLives);
if (remainingLives <= 0)
{
Debug.Log("[WaveManager] Lives depleted. Defeat.");
MatchState.Instance?.SetPhase(MatchPhase.Defeat);
return;
}
DecrementAndCheckComplete();
}
// ----- Helpers ----------------------------------------------------
private void UnsubscribeEnemy(EnemyHealth health)
{
if (health == null) return;
health.OnDied -= HandleEnemyKilled;
var movement = health.GetComponent<EnemyMovement>();
if (movement != null)
{
movement.OnZoneLeaked -= HandleZoneLeak;
movement.OnReachedGoal -= HandleEnemyReachedGoal;
}
}
private void DecrementAndCheckComplete()
{
activeEnemyCount--;
CheckWaveComplete();
}
private void CheckWaveComplete()
{
if (!spawningComplete) return;
if (activeEnemyCount > 0) return;
// Guard: don't start the next wave if the match is already decided.
var ms = MatchState.Instance;
if (ms == null || ms.Phase == MatchPhase.Defeat || ms.Phase == MatchPhase.Victory)
return;
Debug.Log($"[WaveManager] Wave {currentWaveIndex + 1} complete. Starting next wave.");
StartNextWave();
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 81d8e215d8419404ea4d959196cd9cc3