enemies spawn held in place
This commit is contained in:
parent
54f11d91ff
commit
ff921080db
4 changed files with 75 additions and 26 deletions
|
|
@ -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+).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -294,6 +297,7 @@ namespace TD.Gameplay
|
|||
// skipping the prep timer so spawning starts immediately.
|
||||
activeEnemyCount = 0;
|
||||
spawningComplete = false;
|
||||
heldEnemies.Clear();
|
||||
StartNextWave(skipPrep: true);
|
||||
}
|
||||
|
||||
|
|
@ -329,26 +333,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
|
||||
|
|
@ -358,7 +371,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;
|
||||
|
|
@ -381,7 +394,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -407,7 +420,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)
|
||||
{
|
||||
|
|
@ -434,8 +447,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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue