Compare commits

...

2 commits

4 changed files with 75 additions and 26 deletions

View file

@ -66,6 +66,7 @@ namespace TD.Gameplay
private float pendingMaxHp = 100f;
private int pendingLivesCost = 1;
private bool pendingIsFlying;
private bool pendingIsHeld;
private bool hasPendingInit;
// ----- Server-local runtime state -------------------------------------
@ -97,11 +98,17 @@ namespace TD.Gameplay
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private readonly NetworkVariable<bool> isHeld = new NetworkVariable<bool>(
false,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
// ----- Public state ---------------------------------------------------
public float CurrentHp => hp.Value;
public float MaxHp { get; private set; } = 100f;
public bool IsDead => hp.Value <= 0f;
public bool IsHeld => isHeld.Value;
/// <summary>
/// True if this enemy flies over tower footprints.
@ -126,11 +133,12 @@ namespace TD.Gameplay
/// and before <c>NetworkObject.Spawn()</c>. Mirrors the
/// <c>TowerInstance.InitializeServer</c> pattern.
/// </summary>
public void InitializeServer(float maxHp, int livesCost, bool flying)
public void InitializeServer(float maxHp, int livesCost, bool flying, bool held = false)
{
pendingMaxHp = maxHp;
pendingLivesCost = livesCost;
pendingIsFlying = flying;
pendingIsHeld = held;
hasPendingInit = true;
// Cache locally on the server immediately — clients resolve via NV.
@ -146,12 +154,19 @@ namespace TD.Gameplay
{
hp.Value = pendingMaxHp;
isFlying.Value = pendingIsFlying;
isHeld.Value = pendingIsHeld;
hasPendingInit = false;
}
// Non-server clients resolve MaxHp from the replicated hp initial value.
if (!IsServer)
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()
@ -166,6 +181,23 @@ namespace TD.Gameplay
// ----- 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>
/// Applies damage to this enemy. Server-only; silently no-ops on clients.
/// <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).
private bool hasLeakedOriginZone;
private EnemyStatus status;
private EnemyHealth health;
private bool hasReachedGoal;
private bool wasStuck; // dedupes the "no path" warning
@ -119,6 +120,7 @@ namespace TD.Gameplay
public override void OnNetworkSpawn()
{
status = GetComponent<EnemyStatus>();
health = GetComponent<EnemyHealth>();
// Randomize the walk-cycle phase on every peer independently. Animations
// are purely visual and not replicated, so each client picks its own offset —
@ -172,6 +174,7 @@ namespace TD.Gameplay
private void Update()
{
if (!IsServer) return;
if (health != null && health.IsHeld) return;
if (remainingPath.Count == 0) return;
float effectiveSpeed = moveSpeed * (status != null ? status.GetSpeedMultiplier() : 1f);

View file

@ -5,8 +5,7 @@ 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.
/// A single spawn group within a wave: one enemy type and how many of them.
/// </summary>
[Serializable]
public struct WaveEntry
@ -16,10 +15,6 @@ namespace TD.Gameplay
[Tooltip("How many enemies of this type to spawn.")]
public int Count;
[Tooltip("Seconds between each burst. Enemies spawn in bursts of 10% of Count at a time.")]
public float SpawnInterval;
}
/// <summary>
@ -29,16 +24,21 @@ namespace TD.Gameplay
/// <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.
/// All enemies spawn held at wave start; they are released in 10% chunks separated
/// by <see cref="ReleaseInterval"/> seconds. The wave is not complete until all
/// enemies are dead or have leaked.
/// </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.")]
"first enemies becoming visible. Gives players time to build before the horde appears.")]
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.")]
public WaveEntry[] Entries;
}

View file

@ -100,6 +100,9 @@ namespace TD.Gameplay
private int currentWaveIndex = -1; // -1 = not yet started
private Coroutine activeWaveCoroutine;
private readonly System.Collections.Generic.List<EnemyHealth> heldEnemies
= new System.Collections.Generic.List<EnemyHealth>();
// ----- NGO lifecycle ----------------------------------------------
public override void OnNetworkSpawn()
@ -314,6 +317,7 @@ namespace TD.Gameplay
// skipping the prep timer so spawning starts immediately.
activeEnemyCount = 0;
spawningComplete = false;
heldEnemies.Clear();
StartNextWave(skipPrep: true);
}
@ -349,26 +353,35 @@ namespace TD.Gameplay
// whether prep was skipped or just expired.
prepCountdown.Value = 0f;
// Spawn phase.
// Spawn all enemies at once in a held (untargetable, immobile) state.
if (def.Entries != null)
{
foreach (var entry in def.Entries)
{
if (entry.EnemyType == null || entry.Count <= 0) continue;
int chunkSize = Mathf.Max(1, Mathf.RoundToInt(entry.Count * 0.1f));
for (int i = 0; i < entry.Count; i += chunkSize)
{
int burst = Mathf.Min(chunkSize, entry.Count - i);
for (int j = 0; j < burst; j++)
SpawnEnemyInAllZones(entry.EnemyType);
if (entry.SpawnInterval > 0f)
yield return new WaitForSeconds(entry.SpawnInterval);
}
for (int i = 0; i < entry.Count; i++)
SpawnEnemyInAllZones(entry.EnemyType, held: true);
}
}
// Release in chunks of 10% of the total held count.
int total = heldEnemies.Count;
int chunkSize = Mathf.Max(1, Mathf.RoundToInt(total * 0.1f));
while (heldEnemies.Count > 0)
{
int burst = Mathf.Min(chunkSize, heldEnemies.Count);
for (int i = 0; i < burst; i++)
{
var h = heldEnemies[0];
heldEnemies.RemoveAt(0);
if (h != null && !h.IsDead)
h.SetHeld(false);
}
if (heldEnemies.Count > 0)
yield return new WaitForSeconds(def.ReleaseInterval);
}
spawningComplete = true;
// If every spawned enemy was already resolved before this coroutine finished
@ -378,7 +391,7 @@ namespace TD.Gameplay
// ----- Spawn helpers ----------------------------------------------
private void SpawnEnemyInAllZones(EnemyDefinition def)
private void SpawnEnemyInAllZones(EnemyDefinition def, bool held = false)
{
var loader = LevelLoader.Instance;
if (loader?.LevelData?.PlayerZones == null) return;
@ -401,7 +414,7 @@ namespace TD.Gameplay
// PlayerZoneVolume (so OwnerGrid[spawnerTile] = None).
var spawner = zone.Spawners[0];
(float xHalf, float zHalf) = ComputeSpawnerHalfExtents(spawner.TileArea);
SpawnEnemy(def, spawner.TilePosition, zone.Owner, xHalf, zHalf);
SpawnEnemy(def, spawner.TilePosition, zone.Owner, xHalf, zHalf, held);
}
}
@ -427,7 +440,7 @@ namespace TD.Gameplay
}
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)
{
@ -454,8 +467,9 @@ namespace TD.Gameplay
return;
}
health.InitializeServer(def.MaxHp, def.LivesCost, def.IsFlying);
health.InitializeServer(def.MaxHp, def.LivesCost, def.IsFlying, held);
movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot);
if (held) heldEnemies.Add(health);
health.OnDied += HandleEnemyKilled;
movement.OnZoneLeaked += HandleZoneLeak;