Merge pull request 'enemies spawn held in place' (#5) from enemies-spawn-held-in-place into main
Reviewed-on: #5
This commit is contained in:
commit
49ce33b162
4 changed files with 75 additions and 26 deletions
|
|
@ -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+).
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -314,6 +317,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -349,26 +353,35 @@ 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++)
|
||||||
int chunkSize = Mathf.Max(1, Mathf.RoundToInt(entry.Count * 0.1f));
|
SpawnEnemyInAllZones(entry.EnemyType, held: true);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
spawningComplete = true;
|
||||||
|
|
||||||
// If every spawned enemy was already resolved before this coroutine finished
|
// If every spawned enemy was already resolved before this coroutine finished
|
||||||
|
|
@ -378,7 +391,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;
|
||||||
|
|
@ -401,7 +414,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -427,7 +440,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)
|
||||||
{
|
{
|
||||||
|
|
@ -454,8 +467,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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue