Adding Text Mesh Pro, new enemies, new enemy waves, new HUD functionality, New animators, a retry function, and floating text for gold earned and lives lost.
This commit is contained in:
parent
3287e8ea43
commit
f6cc6a7102
110 changed files with 62003 additions and 251 deletions
|
|
@ -1,4 +1,5 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyHealth.cs
|
||||
using System.Collections;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
|
@ -19,13 +20,47 @@ namespace TD.Gameplay
|
|||
/// 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.
|
||||
/// <b>Death flow (server-only):</b> <see cref="TakeDamage"/> clamps HP to 0
|
||||
/// and fires <see cref="OnDied"/> immediately so wave bookkeeping and gold
|
||||
/// award happen the moment HP hits zero. The component then disables
|
||||
/// <see cref="EnemyMovement"/> and all child <c>Collider</c>s, triggers the
|
||||
/// <c>Die</c> animator parameter (synced via <c>NetworkAnimator</c>), waits
|
||||
/// <see cref="deathAnimationDuration"/> seconds, sinks the transform
|
||||
/// <see cref="sinkDepth"/> units over <see cref="sinkDuration"/> seconds,
|
||||
/// and finally calls <c>NetworkObject.Despawn</c>.
|
||||
/// </remarks>
|
||||
[RequireComponent(typeof(NetworkObject))]
|
||||
public class EnemyHealth : NetworkBehaviour
|
||||
public class EnemyHealth : NetworkBehaviour, ISelectable
|
||||
{
|
||||
// ----- Inspector ------------------------------------------------------
|
||||
|
||||
[Header("Identity")]
|
||||
[Tooltip("The EnemyDefinition this prefab represents. Drives the HUD info " +
|
||||
"panel (name, speed, gold bounty) when the enemy is selected. " +
|
||||
"Must be assigned on the prefab so it's available on every peer " +
|
||||
"without needing a registry lookup.")]
|
||||
[SerializeField] private EnemyDefinition definition;
|
||||
|
||||
/// <summary>The static definition this enemy was spawned from.</summary>
|
||||
public EnemyDefinition Definition => definition;
|
||||
|
||||
[Header("Death sequence")]
|
||||
[Tooltip("Animator that plays the death animation. Trigger parameter 'Die' " +
|
||||
"is set on death. Leave null to skip the death animation.")]
|
||||
[SerializeField] private Animator deathAnimator;
|
||||
|
||||
[Tooltip("Seconds to hold after the Die trigger fires before the corpse starts sinking. " +
|
||||
"Should match the death animation clip's duration.")]
|
||||
[SerializeField] private float deathAnimationDuration = 2f;
|
||||
|
||||
[Tooltip("Seconds spent sinking into the ground.")]
|
||||
[SerializeField] private float sinkDuration = 1.5f;
|
||||
|
||||
[Tooltip("How far (world units) the corpse sinks before despawning.")]
|
||||
[SerializeField] private float sinkDepth = 1.5f;
|
||||
|
||||
private static readonly int DieHash = Animator.StringToHash("Die");
|
||||
|
||||
// ----- Pre-spawn init (server-local) ----------------------------------
|
||||
|
||||
private float pendingMaxHp = 100f;
|
||||
|
|
@ -121,6 +156,16 @@ namespace TD.Gameplay
|
|||
MaxHp = hp.Value;
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
// If this enemy was the locally-selected ISelectable, clear the
|
||||
// selection so the HUD doesn't keep displaying a stale corpse.
|
||||
// SelectionState is a local UI singleton, safe to query on any peer.
|
||||
var sel = SelectionState.Instance;
|
||||
if (sel != null && sel.IsSelected(this))
|
||||
sel.Clear();
|
||||
}
|
||||
|
||||
// ----- Server API -----------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -148,8 +193,64 @@ namespace TD.Gameplay
|
|||
|
||||
private void HandleDeath()
|
||||
{
|
||||
// Fire OnDied immediately so the wave count decrements and gold is
|
||||
// awarded the moment HP hits zero — the corpse animation and sink
|
||||
// play out asynchronously after that.
|
||||
OnDied?.Invoke(this);
|
||||
NetworkObject.Despawn();
|
||||
|
||||
// Stop pathing and remove from tower targeting / selection.
|
||||
var movement = GetComponent<EnemyMovement>();
|
||||
if (movement != null) movement.enabled = false;
|
||||
|
||||
foreach (var col in GetComponentsInChildren<Collider>())
|
||||
col.enabled = false;
|
||||
|
||||
// Trigger the death animation on all peers (NetworkAnimator syncs it).
|
||||
if (deathAnimator != null)
|
||||
deathAnimator.SetTrigger(DieHash);
|
||||
|
||||
StartCoroutine(DeathSequence());
|
||||
}
|
||||
|
||||
// ----- ISelectable ----------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Enemy display name. Pulls from <see cref="definition"/> when assigned;
|
||||
/// falls back to "Enemy" so the HUD never shows an empty portrait label
|
||||
/// even if a prefab is missing its definition reference.
|
||||
/// </summary>
|
||||
public string DisplayName =>
|
||||
definition != null && !string.IsNullOrEmpty(definition.DisplayName)
|
||||
? definition.DisplayName
|
||||
: "Enemy";
|
||||
|
||||
public SelectableKind Kind => SelectableKind.Enemy;
|
||||
public Transform SelectionTransform => transform;
|
||||
|
||||
// Used by SelectionVisualizer to size the selection ring. Tuned small
|
||||
// because enemy capsules are roughly 0.6-0.8 wide; if you change enemy
|
||||
// mesh sizes substantially, derive this from a collider bounds instead.
|
||||
public float SelectionRadius => 0.4f;
|
||||
|
||||
private IEnumerator DeathSequence()
|
||||
{
|
||||
// Hold while the death animation plays.
|
||||
yield return new WaitForSeconds(deathAnimationDuration);
|
||||
|
||||
// Sink phase. Server moves the transform; NetworkTransform replicates it.
|
||||
float t = 0f;
|
||||
Vector3 startPos = transform.position;
|
||||
Vector3 endPos = startPos + Vector3.down * sinkDepth;
|
||||
|
||||
while (t < sinkDuration)
|
||||
{
|
||||
t += Time.deltaTime;
|
||||
transform.position = Vector3.Lerp(startPos, endPos, t / sinkDuration);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
if (NetworkObject != null && NetworkObject.IsSpawned)
|
||||
NetworkObject.Despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ namespace TD.Gameplay
|
|||
private List<Vector2Int> remainingPath = new List<Vector2Int>();
|
||||
private PlayerSlot currentZone = PlayerSlot.None;
|
||||
private EnemyStatus status;
|
||||
private bool hasReachedGoal;
|
||||
|
||||
// ----- Events ---------------------------------------------------------
|
||||
|
||||
|
|
@ -139,12 +140,20 @@ namespace TD.Gameplay
|
|||
|
||||
float effectiveSpeed = moveSpeed * (status != null ? status.GetSpeedMultiplier() : 1f);
|
||||
Vector3 targetWorld = GridCoordinates.GridToWorld(remainingPath[0]);
|
||||
Vector3 toTarget = targetWorld - transform.position;
|
||||
|
||||
// XZ-only delta. Some enemy prefabs (e.g. the skeleton) have their root
|
||||
// sitting above Y=0; if we measured 3D distance, the Y offset would push
|
||||
// it permanently above the snap threshold and waypoints would never
|
||||
// complete. Tile arrival is a horizontal-plane concept anyway.
|
||||
Vector3 toTarget = targetWorld - transform.position;
|
||||
toTarget.y = 0f;
|
||||
|
||||
if (toTarget.sqrMagnitude <= WaypointSnapSq)
|
||||
{
|
||||
// Snap to the tile center then handle the waypoint transition.
|
||||
transform.position = targetWorld;
|
||||
// Snap XZ to the tile center. Preserve Y so we don't drag a visual
|
||||
// pivot down through the plane.
|
||||
transform.position = new Vector3(
|
||||
targetWorld.x, transform.position.y, targetWorld.z);
|
||||
AdvanceWaypoint();
|
||||
}
|
||||
else
|
||||
|
|
@ -187,11 +196,16 @@ namespace TD.Gameplay
|
|||
|
||||
private void HandleGoalReached()
|
||||
{
|
||||
if (hasReachedGoal) return; // belt-and-suspenders: never fire twice
|
||||
hasReachedGoal = true;
|
||||
|
||||
var health = GetComponent<EnemyHealth>();
|
||||
int livesCost = health != null ? health.LivesCost : 1;
|
||||
|
||||
OnReachedGoal?.Invoke(this, livesCost);
|
||||
NetworkObject.Despawn();
|
||||
|
||||
if (NetworkObject != null && NetworkObject.IsSpawned)
|
||||
NetworkObject.Despawn();
|
||||
}
|
||||
|
||||
// ----- Path invalidation ----------------------------------------------
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using Unity.Netcode;
|
|||
using UnityEngine;
|
||||
using TD.Core;
|
||||
using TD.Levels;
|
||||
using TD.UI;
|
||||
|
||||
namespace TD.Gameplay
|
||||
{
|
||||
|
|
@ -66,10 +67,11 @@ namespace TD.Gameplay
|
|||
|
||||
// ----- Server-local runtime state ---------------------------------
|
||||
|
||||
private int remainingLives;
|
||||
private int activeEnemyCount;
|
||||
private bool spawningComplete;
|
||||
private int currentWaveIndex = -1; // -1 = not yet started
|
||||
private int remainingLives;
|
||||
private int activeEnemyCount;
|
||||
private bool spawningComplete;
|
||||
private int currentWaveIndex = -1; // -1 = not yet started
|
||||
private Coroutine activeWaveCoroutine;
|
||||
|
||||
// ----- NGO lifecycle ----------------------------------------------
|
||||
|
||||
|
|
@ -127,6 +129,13 @@ namespace TD.Gameplay
|
|||
|
||||
// ----- Public accessors -------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Total number of waves in this match. Same on every peer because
|
||||
/// <see cref="waveDefinitions"/> is a serialized prefab field, identical
|
||||
/// on host and clients. Returns 0 if the array is unassigned.
|
||||
/// </summary>
|
||||
public int TotalWaves => waveDefinitions?.Length ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Number of times enemies have leaked out of the given player's zone.
|
||||
/// Replicated — safe to call on any peer.
|
||||
|
|
@ -148,7 +157,7 @@ namespace TD.Gameplay
|
|||
|
||||
// ----- Wave coroutine ---------------------------------------------
|
||||
|
||||
private void StartNextWave()
|
||||
private void StartNextWave(bool skipPrep = false)
|
||||
{
|
||||
currentWaveIndex++;
|
||||
|
||||
|
|
@ -166,13 +175,63 @@ namespace TD.Gameplay
|
|||
activeEnemyCount = 0;
|
||||
spawningComplete = false;
|
||||
|
||||
StartCoroutine(RunWave(waveDefinitions[currentWaveIndex]));
|
||||
activeWaveCoroutine = StartCoroutine(
|
||||
RunWave(waveDefinitions[currentWaveIndex], skipPrep));
|
||||
}
|
||||
|
||||
private IEnumerator RunWave(WaveDefinition def)
|
||||
// ----- Dev / cheats -----------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Dev cheat: skip the rest of the current wave (despawn any remaining
|
||||
/// enemies, kill the prep timer) and start the next wave immediately.
|
||||
/// Server-only — silently no-ops on clients. Safe to call during prep,
|
||||
/// mid-spawn, or while enemies are alive.
|
||||
/// </summary>
|
||||
public void ForceAdvanceToNextWave()
|
||||
{
|
||||
// Prep phase — players build while the countdown ticks.
|
||||
yield return new WaitForSeconds(def.PrepTime);
|
||||
if (!IsServer) return;
|
||||
|
||||
// Stop the current wave's coroutine (cancels prep timer + remaining spawns).
|
||||
if (activeWaveCoroutine != null)
|
||||
{
|
||||
StopCoroutine(activeWaveCoroutine);
|
||||
activeWaveCoroutine = null;
|
||||
}
|
||||
|
||||
// Despawn anything still running around. Iterate a snapshot since
|
||||
// EnemyHealth.HandleDeath will despawn the NetworkObject, mutating
|
||||
// the live collection.
|
||||
var spawnManager = NetworkManager.Singleton?.SpawnManager;
|
||||
if (spawnManager != null)
|
||||
{
|
||||
var snapshot = new System.Collections.Generic.List<NetworkObject>(
|
||||
spawnManager.SpawnedObjectsList);
|
||||
foreach (var no in snapshot)
|
||||
{
|
||||
if (no == null || !no.IsSpawned) continue;
|
||||
var movement = no.GetComponent<EnemyMovement>();
|
||||
if (movement == null) continue;
|
||||
|
||||
// Unsubscribe so the despawn doesn't deduct lives or fire side effects.
|
||||
var h = no.GetComponent<EnemyHealth>();
|
||||
UnsubscribeEnemy(h);
|
||||
no.Despawn();
|
||||
}
|
||||
}
|
||||
|
||||
// Reset bookkeeping and start the next wave's RunWave coroutine,
|
||||
// skipping the prep timer so spawning starts immediately.
|
||||
activeEnemyCount = 0;
|
||||
spawningComplete = false;
|
||||
StartNextWave(skipPrep: true);
|
||||
}
|
||||
|
||||
private IEnumerator RunWave(WaveDefinition def, bool skipPrep = false)
|
||||
{
|
||||
// Prep phase — players build while the countdown ticks. Skipped when
|
||||
// a dev cheat forces the wave to start immediately.
|
||||
if (!skipPrep)
|
||||
yield return new WaitForSeconds(def.PrepTime);
|
||||
|
||||
// Spawn phase.
|
||||
if (def.Entries != null)
|
||||
|
|
@ -209,6 +268,13 @@ namespace TD.Gameplay
|
|||
{
|
||||
if (zone.Spawners == null || zone.Spawners.Length == 0) continue;
|
||||
|
||||
// Skip zones whose owning slot is empty. Until a lobby exists,
|
||||
// this means a 1-player test session only spawns enemies in
|
||||
// Player 1's zone; Player 2/3/... zones stay quiet until those
|
||||
// slots are actually filled. PlayerMatchState.GetForSlot returns
|
||||
// null for unoccupied slots (and for PlayerSlot.None).
|
||||
if (PlayerMatchState.GetForSlot(zone.Owner) == null) continue;
|
||||
|
||||
// Use the first spawner in the zone. Future: round-robin through Spawners.
|
||||
SpawnEnemy(def, zone.Spawners[0].TilePosition);
|
||||
}
|
||||
|
|
@ -264,6 +330,13 @@ namespace TD.Gameplay
|
|||
?.AwardGold(health.GoldReward);
|
||||
}
|
||||
|
||||
// Show a "+N" gold popup above the corpse on every peer. Capture the
|
||||
// position here on the server — by the time the RPC fires on clients
|
||||
// the death sequence will be moving the corpse, but the spawn point
|
||||
// is good enough and we want the popup to anchor where the kill happened.
|
||||
if (health.GoldReward > 0)
|
||||
ShowGoldRewardClientRpc(health.transform.position, health.GoldReward);
|
||||
|
||||
UnsubscribeEnemy(health);
|
||||
DecrementAndCheckComplete();
|
||||
}
|
||||
|
|
@ -278,6 +351,13 @@ namespace TD.Gameplay
|
|||
|
||||
private void HandleEnemyReachedGoal(EnemyMovement movement, int livesCost)
|
||||
{
|
||||
// Capture the leak position BEFORE the enemy NetworkObject despawns
|
||||
// (HandleGoalReached on the enemy calls Despawn right after firing the
|
||||
// event we're handling here). Show the "-N" popup on every peer.
|
||||
Vector3 leakPos = movement.transform.position;
|
||||
if (livesCost > 0)
|
||||
ShowLifeLossClientRpc(leakPos, livesCost);
|
||||
|
||||
UnsubscribeEnemy(movement.GetComponent<EnemyHealth>());
|
||||
|
||||
remainingLives = Mathf.Max(0, remainingLives - livesCost);
|
||||
|
|
@ -293,6 +373,22 @@ namespace TD.Gameplay
|
|||
DecrementAndCheckComplete();
|
||||
}
|
||||
|
||||
// ----- Floating-text ClientRpcs -----------------------------------
|
||||
|
||||
// Fired on every peer (server + clients) so each one spawns its own local
|
||||
// FloatingText. The spawned GameObjects are not networked — purely visual.
|
||||
[ClientRpc]
|
||||
private void ShowGoldRewardClientRpc(Vector3 worldPos, int amount)
|
||||
{
|
||||
FloatingTextSpawner.Instance?.SpawnGoldReward(worldPos, amount);
|
||||
}
|
||||
|
||||
[ClientRpc]
|
||||
private void ShowLifeLossClientRpc(Vector3 worldPos, int amount)
|
||||
{
|
||||
FloatingTextSpawner.Instance?.SpawnLifeLoss(worldPos, amount);
|
||||
}
|
||||
|
||||
// ----- Helpers ----------------------------------------------------
|
||||
|
||||
private void UnsubscribeEnemy(EnemyHealth health)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue