enemies spawn held in place #5

Merged
ian merged 1 commit from enemies-spawn-held-in-place into main 2026-06-23 22:28:07 -07:00
4 changed files with 75 additions and 26 deletions

View file

@ -66,6 +66,7 @@ namespace TD.Gameplay
private float pendingMaxHp = 100f; private float pendingMaxHp = 100f;
private int pendingLivesCost = 1; private int pendingLivesCost = 1;
private bool pendingIsFlying; private bool pendingIsFlying;
private bool pendingIsHeld;
private bool hasPendingInit; private bool hasPendingInit;
// ----- Server-local runtime state ------------------------------------- // ----- Server-local runtime state -------------------------------------
@ -97,11 +98,17 @@ namespace TD.Gameplay
NetworkVariableReadPermission.Everyone, NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server); NetworkVariableWritePermission.Server);
private readonly NetworkVariable<bool> isHeld = new NetworkVariable<bool>(
false,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
// ----- Public state --------------------------------------------------- // ----- Public state ---------------------------------------------------
public float CurrentHp => hp.Value; public float CurrentHp => hp.Value;
public float MaxHp { get; private set; } = 100f; public float MaxHp { get; private set; } = 100f;
public bool IsDead => hp.Value <= 0f; public bool IsDead => hp.Value <= 0f;
public bool IsHeld => isHeld.Value;
/// <summary> /// <summary>
/// True if this enemy flies over tower footprints. /// True if this enemy flies over tower footprints.
@ -126,11 +133,12 @@ namespace TD.Gameplay
/// and before <c>NetworkObject.Spawn()</c>. Mirrors the /// and before <c>NetworkObject.Spawn()</c>. Mirrors the
/// <c>TowerInstance.InitializeServer</c> pattern. /// <c>TowerInstance.InitializeServer</c> pattern.
/// </summary> /// </summary>
public void InitializeServer(float maxHp, int livesCost, bool flying) public void InitializeServer(float maxHp, int livesCost, bool flying, bool held = false)
{ {
pendingMaxHp = maxHp; pendingMaxHp = maxHp;
pendingLivesCost = livesCost; pendingLivesCost = livesCost;
pendingIsFlying = flying; pendingIsFlying = flying;
pendingIsHeld = held;
hasPendingInit = true; hasPendingInit = true;
// Cache locally on the server immediately — clients resolve via NV. // Cache locally on the server immediately — clients resolve via NV.
@ -146,12 +154,19 @@ namespace TD.Gameplay
{ {
hp.Value = pendingMaxHp; hp.Value = pendingMaxHp;
isFlying.Value = pendingIsFlying; isFlying.Value = pendingIsFlying;
isHeld.Value = pendingIsHeld;
hasPendingInit = false; hasPendingInit = false;
} }
// Non-server clients resolve MaxHp from the replicated hp initial value. // Non-server clients resolve MaxHp from the replicated hp initial value.
if (!IsServer) if (!IsServer)
MaxHp = hp.Value; MaxHp = hp.Value;
// Apply initial held state on all peers and watch for future changes.
// isHeld.OnValueChanged doesn't fire for the initial replication, so we
// apply it explicitly here as well.
ApplyHeldState(isHeld.Value);
isHeld.OnValueChanged += (_, current) => ApplyHeldState(current);
} }
public override void OnNetworkDespawn() public override void OnNetworkDespawn()
@ -166,6 +181,23 @@ namespace TD.Gameplay
// ----- Server API ----------------------------------------------------- // ----- Server API -----------------------------------------------------
/// <summary>
/// Sets whether this enemy is held. Held enemies are untargetable and do not
/// move. Server-only; the NetworkVariable replicates the change to all peers.
/// </summary>
public void SetHeld(bool held)
{
if (!IsServer) return;
isHeld.Value = held;
}
private void ApplyHeldState(bool held)
{
if (IsDead) return;
foreach (var col in GetComponentsInChildren<Collider>())
col.enabled = !held;
}
/// <summary> /// <summary>
/// Applies damage to this enemy. Server-only; silently no-ops on clients. /// 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="type"/> is accepted for future resistance lookups (Phase 1.5+).

View file

@ -71,6 +71,7 @@ namespace TD.Gameplay
// possible if pathing is dynamic). // possible if pathing is dynamic).
private bool hasLeakedOriginZone; private bool hasLeakedOriginZone;
private EnemyStatus status; private EnemyStatus status;
private EnemyHealth health;
private bool hasReachedGoal; private bool hasReachedGoal;
private bool wasStuck; // dedupes the "no path" warning private bool wasStuck; // dedupes the "no path" warning
@ -119,6 +120,7 @@ namespace TD.Gameplay
public override void OnNetworkSpawn() public override void OnNetworkSpawn()
{ {
status = GetComponent<EnemyStatus>(); status = GetComponent<EnemyStatus>();
health = GetComponent<EnemyHealth>();
// Randomize the walk-cycle phase on every peer independently. Animations // Randomize the walk-cycle phase on every peer independently. Animations
// are purely visual and not replicated, so each client picks its own offset — // are purely visual and not replicated, so each client picks its own offset —
@ -172,6 +174,7 @@ namespace TD.Gameplay
private void Update() private void Update()
{ {
if (!IsServer) return; if (!IsServer) return;
if (health != null && health.IsHeld) return;
if (remainingPath.Count == 0) return; if (remainingPath.Count == 0) return;
float effectiveSpeed = moveSpeed * (status != null ? status.GetSpeedMultiplier() : 1f); float effectiveSpeed = moveSpeed * (status != null ? status.GetSpeedMultiplier() : 1f);

View file

@ -5,8 +5,7 @@ using UnityEngine;
namespace TD.Gameplay namespace TD.Gameplay
{ {
/// <summary> /// <summary>
/// A single spawn group within a wave: one enemy type, how many of them, /// A single spawn group within a wave: one enemy type and how many of them.
/// and how long to wait between each spawn.
/// </summary> /// </summary>
[Serializable] [Serializable]
public struct WaveEntry public struct WaveEntry
@ -16,10 +15,6 @@ namespace TD.Gameplay
[Tooltip("How many enemies of this type to spawn.")] [Tooltip("How many enemies of this type to spawn.")]
public int Count; public int Count;
[Tooltip("Seconds between each burst. Enemies spawn in bursts of 10% of Count at a time.")]
public float SpawnInterval;
} }
/// <summary> /// <summary>
@ -29,16 +24,21 @@ namespace TD.Gameplay
/// <remarks> /// <remarks>
/// Entries are processed in array order. Multiple entries let designers mix enemy /// 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). /// 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 /// All enemies spawn held at wave start; they are released in 10% chunks separated
/// leaked — not just until all entries are spawned. /// by <see cref="ReleaseInterval"/> seconds. The wave is not complete until all
/// enemies are dead or have leaked.
/// </remarks> /// </remarks>
[CreateAssetMenu(fileName = "WaveDefinition", menuName = "TD/Wave Definition", order = 4)] [CreateAssetMenu(fileName = "WaveDefinition", menuName = "TD/Wave Definition", order = 4)]
public class WaveDefinition : ScriptableObject public class WaveDefinition : ScriptableObject
{ {
[Tooltip("Seconds between the wave-number advancing (start of prep) and the " + [Tooltip("Seconds between the wave-number advancing (start of prep) and the " +
"first enemy spawning. Gives players time to build before the wave hits.")] "first enemies becoming visible. Gives players time to build before the horde appears.")]
public float PrepTime = 10f; public float PrepTime = 10f;
[Tooltip("Seconds between each chunk release. All enemies spawn held at wave " +
"start and are released 10% at a time on this interval.")]
public float ReleaseInterval = 2f;
[Tooltip("Enemy groups that make up this wave. Processed in order.")] [Tooltip("Enemy groups that make up this wave. Processed in order.")]
public WaveEntry[] Entries; public WaveEntry[] Entries;
} }

View file

@ -100,6 +100,9 @@ namespace TD.Gameplay
private int currentWaveIndex = -1; // -1 = not yet started private int currentWaveIndex = -1; // -1 = not yet started
private Coroutine activeWaveCoroutine; private Coroutine activeWaveCoroutine;
private readonly System.Collections.Generic.List<EnemyHealth> heldEnemies
= new System.Collections.Generic.List<EnemyHealth>();
// ----- NGO lifecycle ---------------------------------------------- // ----- NGO lifecycle ----------------------------------------------
public override void OnNetworkSpawn() public override void OnNetworkSpawn()
@ -294,6 +297,7 @@ namespace TD.Gameplay
// skipping the prep timer so spawning starts immediately. // skipping the prep timer so spawning starts immediately.
activeEnemyCount = 0; activeEnemyCount = 0;
spawningComplete = false; spawningComplete = false;
heldEnemies.Clear();
StartNextWave(skipPrep: true); StartNextWave(skipPrep: true);
} }
@ -329,24 +333,33 @@ namespace TD.Gameplay
// whether prep was skipped or just expired. // whether prep was skipped or just expired.
prepCountdown.Value = 0f; prepCountdown.Value = 0f;
// Spawn phase. // Spawn all enemies at once in a held (untargetable, immobile) state.
if (def.Entries != null) if (def.Entries != null)
{ {
foreach (var entry in def.Entries) foreach (var entry in def.Entries)
{ {
if (entry.EnemyType == null || entry.Count <= 0) continue; if (entry.EnemyType == null || entry.Count <= 0) continue;
for (int i = 0; i < entry.Count; i++)
SpawnEnemyInAllZones(entry.EnemyType, held: true);
}
}
int chunkSize = Mathf.Max(1, Mathf.RoundToInt(entry.Count * 0.1f)); // Release in chunks of 10% of the total held count.
for (int i = 0; i < entry.Count; i += chunkSize) int total = heldEnemies.Count;
int chunkSize = Mathf.Max(1, Mathf.RoundToInt(total * 0.1f));
while (heldEnemies.Count > 0)
{ {
int burst = Mathf.Min(chunkSize, entry.Count - i); int burst = Mathf.Min(chunkSize, heldEnemies.Count);
for (int j = 0; j < burst; j++) for (int i = 0; i < burst; i++)
SpawnEnemyInAllZones(entry.EnemyType); {
var h = heldEnemies[0];
heldEnemies.RemoveAt(0);
if (h != null && !h.IsDead)
h.SetHeld(false);
}
if (entry.SpawnInterval > 0f) if (heldEnemies.Count > 0)
yield return new WaitForSeconds(entry.SpawnInterval); yield return new WaitForSeconds(def.ReleaseInterval);
}
}
} }
spawningComplete = true; spawningComplete = true;
@ -358,7 +371,7 @@ namespace TD.Gameplay
// ----- Spawn helpers ---------------------------------------------- // ----- Spawn helpers ----------------------------------------------
private void SpawnEnemyInAllZones(EnemyDefinition def) private void SpawnEnemyInAllZones(EnemyDefinition def, bool held = false)
{ {
var loader = LevelLoader.Instance; var loader = LevelLoader.Instance;
if (loader?.LevelData?.PlayerZones == null) return; if (loader?.LevelData?.PlayerZones == null) return;
@ -381,7 +394,7 @@ namespace TD.Gameplay
// PlayerZoneVolume (so OwnerGrid[spawnerTile] = None). // PlayerZoneVolume (so OwnerGrid[spawnerTile] = None).
var spawner = zone.Spawners[0]; var spawner = zone.Spawners[0];
(float xHalf, float zHalf) = ComputeSpawnerHalfExtents(spawner.TileArea); (float xHalf, float zHalf) = ComputeSpawnerHalfExtents(spawner.TileArea);
SpawnEnemy(def, spawner.TilePosition, zone.Owner, xHalf, zHalf); SpawnEnemy(def, spawner.TilePosition, zone.Owner, xHalf, zHalf, held);
} }
} }
@ -407,7 +420,7 @@ namespace TD.Gameplay
} }
private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile, PlayerSlot ownerSlot, private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile, PlayerSlot ownerSlot,
float xHalfExtent = 0f, float zHalfExtent = 0f) float xHalfExtent = 0f, float zHalfExtent = 0f, bool held = false)
{ {
if (def.EnemyPrefab == null) if (def.EnemyPrefab == null)
{ {
@ -434,8 +447,9 @@ namespace TD.Gameplay
return; return;
} }
health.InitializeServer(def.MaxHp, def.LivesCost, def.IsFlying); health.InitializeServer(def.MaxHp, def.LivesCost, def.IsFlying, held);
movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot); movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot);
if (held) heldEnemies.Add(health);
health.OnDied += HandleEnemyKilled; health.OnDied += HandleEnemyKilled;
movement.OnZoneLeaked += HandleZoneLeak; movement.OnZoneLeaked += HandleZoneLeak;