We've got enemies and movement!!
This commit is contained in:
parent
42ee0bf65d
commit
3287e8ea43
26 changed files with 1409 additions and 161 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
50
Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs
Normal file
50
Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c0d2521d49d21fe4380434f5951944d1
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
225
Assets/_Project/Scripts/Gameplay/EnemyMovement.cs
Normal file
225
Assets/_Project/Scripts/Gameplay/EnemyMovement.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Gameplay/EnemyMovement.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/EnemyMovement.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: fd6c02bbcc13fb14a9d596d9a2544dcc
|
||||
|
|
@ -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 (0–1). 1 = full speed, 0.5 = half speed, etc.</summary>
|
||||
/// <summary>Current speed fraction (0–1). 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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
279
Assets/_Project/Scripts/Gameplay/PathfindingService.cs
Normal file
279
Assets/_Project/Scripts/Gameplay/PathfindingService.cs
Normal 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 <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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: de7d013503af0f74c950f215f8dae1c0
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
45
Assets/_Project/Scripts/Gameplay/WaveDefinition.cs
Normal file
45
Assets/_Project/Scripts/Gameplay/WaveDefinition.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Gameplay/WaveDefinition.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/WaveDefinition.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 48e93688dc0fb5b4cbe7be9a241b4421
|
||||
331
Assets/_Project/Scripts/Gameplay/WaveManager.cs
Normal file
331
Assets/_Project/Scripts/Gameplay/WaveManager.cs
Normal 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 0–8). 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Gameplay/WaveManager.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/WaveManager.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 81d8e215d8419404ea4d959196cd9cc3
|
||||
Loading…
Add table
Add a link
Reference in a new issue