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:
Matt F 2026-05-13 17:39:16 -07:00
parent 3287e8ea43
commit f6cc6a7102
110 changed files with 62003 additions and 251 deletions

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 638c60741b05f49409f16991a160f0fa
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,76 @@
// Assets/_Project/Scripts/Dev/DevWaveControls.cs
using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;
using TD.Gameplay;
namespace TD.Dev
{
/// <summary>
/// Editor / testing convenience: shows an OnGUI button in the top-left
/// corner that force-advances the wave on the server. Add this component
/// to any GameObject in the scene (e.g. a "DevTools" empty) during testing,
/// disable or remove for shipping builds.
/// </summary>
/// <remarks>
/// The button calls <see cref="WaveManager.ForceAdvanceToNextWave"/>, which
/// despawns active enemies, stops the current wave's coroutine, and starts
/// the next wave with no prep delay.
///
/// Server-side only — the OnGUI button is gated to <c>NetworkManager.IsServer</c>
/// so clients connected to a remote host don't see it. The hotkey path is
/// also server-gated to avoid surprising results when pressed on a client.
/// </remarks>
public class DevWaveControls : MonoBehaviour
{
[Tooltip("Keyboard shortcut to force-advance to the next wave. " +
"Uses the new Input System (UnityEngine.InputSystem.Key). " +
"Set to Key.None to use the OnGUI button only.")]
[SerializeField] private Key hotkey = Key.F9;
private void Update()
{
if (hotkey == Key.None) return;
var kb = Keyboard.current;
if (kb == null) return; // no keyboard connected (e.g. headless server)
if (!kb[hotkey].wasPressedThisFrame) return;
TryForceNextWave();
}
private void OnGUI()
{
// Only show the button on the server (host or dedicated). Clients
// calling this from afar would no-op anyway.
if (NetworkManager.Singleton == null || !NetworkManager.Singleton.IsServer)
return;
// Anchored below the top HUD bar so it doesn't overlap gold/wave/lives.
const float topOffset = 90f;
GUI.Box(new Rect(10, topOffset, 180, 60), "Dev: Wave Controls");
if (GUI.Button(new Rect(20, topOffset + 25, 160, 28), "Force Next Wave"))
TryForceNextWave();
}
private void TryForceNextWave()
{
if (NetworkManager.Singleton == null || !NetworkManager.Singleton.IsServer)
{
Debug.LogWarning("[DevWaveControls] Force-advance requested off the server. Ignored.");
return;
}
var wm = WaveManager.Instance;
if (wm == null)
{
Debug.LogWarning("[DevWaveControls] WaveManager.Instance is null. " +
"Is the scene running with a WaveManager network-spawned?");
return;
}
Debug.Log("[DevWaveControls] Forcing next wave.");
wm.ForceAdvanceToNextWave();
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 468de2ce61e73ce4595ba4792874b076

View file

@ -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();
}
}
}

View file

@ -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 ----------------------------------------------

View file

@ -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)

View file

@ -0,0 +1,75 @@
// Assets/_Project/Scripts/UI/FloatingText.cs
using TMPro;
using UnityEngine;
namespace TD.UI
{
/// <summary>
/// One-shot world-space text that floats upward and fades out, then destroys itself.
/// Spawned by <see cref="FloatingTextSpawner"/> for kill-reward and life-loss feedback.
/// </summary>
/// <remarks>
/// <b>Prefab setup:</b> a GameObject with a <c>TextMeshPro</c> (3D, NOT TextMeshProUGUI)
/// component as a child or root, this script, and no Canvas required — TMP renders in
/// world space natively. The text auto-billboards toward the main camera each frame.
///
/// Lifetime, float speed, and fade are tuned on the prefab. The spawner only sets
/// content + color.
/// </remarks>
public class FloatingText : MonoBehaviour
{
[Tooltip("The TextMeshPro component that renders the text. Auto-located in children " +
"if left empty.")]
[SerializeField] private TMP_Text text;
[Tooltip("World units per second the text drifts upward.")]
[SerializeField] private float floatSpeed = 1.5f;
[Tooltip("Seconds the text remains visible before the GameObject is destroyed.")]
[SerializeField] private float lifetime = 1.2f;
private float elapsed;
private Color baseColor;
/// <summary>Sets content and color. Call once immediately after Instantiate.</summary>
public void Init(string content, Color color)
{
if (text == null) text = GetComponentInChildren<TMP_Text>();
if (text != null)
{
text.text = content;
text.color = color;
}
baseColor = color;
elapsed = 0f;
}
private void Update()
{
elapsed += Time.deltaTime;
if (elapsed >= lifetime)
{
Destroy(gameObject);
return;
}
// Drift upward.
transform.position += Vector3.up * floatSpeed * Time.deltaTime;
// Fade alpha linearly from 1 → 0 over the lifetime.
if (text != null)
{
float alpha = 1f - (elapsed / lifetime);
var c = baseColor;
c.a = alpha;
text.color = c;
}
// Billboard to the main camera. Using the camera's forward (rather than
// position-difference) keeps the text upright even when the camera tilts.
var cam = Camera.main;
if (cam != null)
transform.rotation = Quaternion.LookRotation(cam.transform.forward);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4e505af421a8fd24e9b8f3d8786beba8

View file

@ -0,0 +1,107 @@
// Assets/_Project/Scripts/UI/FloatingTextSpawner.cs
using UnityEngine;
namespace TD.UI
{
/// <summary>
/// Scene singleton that spawns <see cref="FloatingText"/> instances above
/// world positions for gold-reward and life-loss feedback.
/// </summary>
/// <remarks>
/// <b>Why a singleton:</b> the spawner is shared by every caller (kills, leaks,
/// future status pop-ups) and holds inspector-tunable colors and a prefab
/// reference. Plain <c>MonoBehaviour</c> — visual-only, no networking required.
///
/// <b>Who calls this:</b> <see cref="TD.Gameplay.WaveManager"/> invokes
/// <see cref="SpawnGoldReward"/> and <see cref="SpawnLifeLoss"/> via ClientRpc
/// so every peer shows the popup locally.
///
/// <b>Inspector setup:</b> drop this on any scene GameObject (e.g. a
/// <c>FloatingTextSpawner</c> empty), assign the floating-text prefab, tune
/// colors to match the HUD.
/// </remarks>
public class FloatingTextSpawner : MonoBehaviour
{
// ----- Singleton --------------------------------------------------
public static FloatingTextSpawner Instance { get; private set; }
// ----- Inspector --------------------------------------------------
[Tooltip("Prefab to instantiate for each pop-up. Must have a FloatingText " +
"component and a TextMeshPro renderer.")]
[SerializeField] private FloatingText prefab;
[Tooltip("Color used for kill-reward gold pop-ups. Tune to match the HUD's " +
"gold label color.")]
[SerializeField] private Color goldColor = new Color(1f, 0.84f, 0.2f);
[Tooltip("Color used for life-loss pop-ups (enemies reaching the goal).")]
[SerializeField] private Color livesColor = new Color(0.95f, 0.25f, 0.25f);
[Tooltip("Default vertical offset above the source position. Tunes how high " +
"above the enemy the text starts.")]
[SerializeField] private float verticalOffset = 2.0f;
[Tooltip("Maximum random horizontal jitter applied to the spawn position so " +
"consecutive pop-ups don't stack visually.")]
[SerializeField] private float positionJitter = 0.4f;
[Tooltip("Maximum random vertical jitter applied on top of verticalOffset.")]
[SerializeField] private float verticalJitter = 0.2f;
// ----- Lifecycle --------------------------------------------------
private void Awake()
{
if (Instance != null && Instance != this)
{
Debug.LogWarning("[FloatingTextSpawner] Duplicate instance detected. " +
"Only one should exist per scene.");
return;
}
Instance = this;
}
private void OnDestroy()
{
if (Instance == this) Instance = null;
}
// ----- Public API -------------------------------------------------
/// <summary>Spawns a gold-reward popup (e.g. "+10") at the given world position.</summary>
public void SpawnGoldReward(Vector3 worldPos, int amount)
=> SpawnInternal(worldPos, $"+{amount}", goldColor);
/// <summary>Spawns a life-loss popup (e.g. "-1") at the given world position.</summary>
public void SpawnLifeLoss(Vector3 worldPos, int amount)
=> SpawnInternal(worldPos, $"-{amount}", livesColor);
/// <summary>Lower-level overload: arbitrary content and color.</summary>
public void SpawnCustom(Vector3 worldPos, string text, Color color)
=> SpawnInternal(worldPos, text, color);
// ----- Internal ---------------------------------------------------
private void SpawnInternal(Vector3 worldPos, string text, Color color)
{
if (prefab == null)
{
Debug.LogWarning("[FloatingTextSpawner] No prefab assigned. " +
"Pop-up will not be shown.");
return;
}
Vector3 jitter = new Vector3(
Random.Range(-positionJitter, positionJitter),
Random.Range(0f, verticalJitter),
Random.Range(-positionJitter, positionJitter));
Vector3 spawnPos = worldPos + Vector3.up * verticalOffset + jitter;
var instance = Instantiate(prefab, spawnPos, Quaternion.identity);
instance.Init(text, color);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a31401869def6d64c87e645f1da1aa2a

View file

@ -1,9 +1,12 @@
// Assets/_Project/Scripts/UI/HUDController.cs
using System.Collections;
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;
using UnityEngine.UIElements;
using TD.Core;
using TD.Gameplay;
using TD.Towers;
using TD.UI.Minimap;
@ -38,6 +41,7 @@ namespace TD.UI
private Label goldLabel;
private Label waveLabel;
private Label livesLabel;
private Label portraitName;
private Label levelLabel;
private VisualElement statLines;
@ -51,6 +55,18 @@ namespace TD.UI
private Label ttStats;
private Label ttCost;
private Label rejectionLabel;
private VisualElement portraitFrame;
// Enemy-info sub-panel — built programmatically and inserted into stat-lines
// whenever an Enemy is selected. Cached so we can update HP each frame
// without rebuilding the elements.
private VisualElement enemyHealthBar;
private VisualElement enemyHealthFill;
private Label enemyHealthText;
// Match-end overlay — built once on Start and toggled on Phase changes.
private VisualElement matchEndOverlay;
private Label matchEndTitle;
// ----- State ------------------------------------------------------
@ -58,6 +74,7 @@ namespace TD.UI
private bool placementManagerReady; // true once TowerPlacementManager.Instance is non-null
private bool uiInitialized;
private bool selectionSubscribed; // true once we've successfully hooked SelectionState.OnSelectionChanged
private bool matchStateSubscribed; // true once OnPhaseChanged is hooked
private MinimapView minimapView;
private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us
@ -149,6 +166,16 @@ namespace TD.UI
// Seed portrait/grid in case the builder already auto-selected before Start.
HandleSelectionChanged(SelectionState.Instance?.SelectedObject);
TrySubscribeMatchState();
}
private void TrySubscribeMatchState()
{
if (matchStateSubscribed) return;
if (MatchState.Instance == null) return;
MatchState.Instance.OnPhaseChanged += HandlePhaseChanged;
matchStateSubscribed = true;
}
private void InitializeUI()
@ -172,6 +199,7 @@ namespace TD.UI
// so UXML/USS mismatches surface immediately.
goldLabel = Require<Label>(root, "gold-label");
waveLabel = Require<Label>(root, "wave-label");
livesLabel = Require<Label>(root, "lives-label");
portraitName = Require<Label>(root, "portrait-name");
levelLabel = Require<Label>(root, "level-label");
statLines = Require<VisualElement>(root, "stat-lines");
@ -210,6 +238,15 @@ namespace TD.UI
minimapView = new MinimapView(minimapContainer, cameraController);
}
// Portrait click → center camera on current selection (WC3 convention).
portraitFrame = root.Q<VisualElement>("portrait-frame");
if (portraitFrame != null)
portraitFrame.RegisterCallback<ClickEvent>(OnPortraitClicked);
// Build the match-end overlay (Victory / Defeat + Retry). Hidden until
// MatchState.OnPhaseChanged fires Victory or Defeat.
BuildMatchEndOverlay(root);
// Publish the panel so non-UI systems can query "is pointer over the HUD".
// Stored on `myPanel` too so OnDestroy only clears the static if it still
// points at this instance (defensive against re-creation overlap).
@ -235,6 +272,11 @@ namespace TD.UI
SelectionState.Instance.OnSelectionChanged -= HandleSelectionChanged;
selectionSubscribed = false;
}
if (matchStateSubscribed && MatchState.Instance != null)
{
MatchState.Instance.OnPhaseChanged -= HandlePhaseChanged;
matchStateSubscribed = false;
}
}
private void TrySubscribeSelection()
@ -252,8 +294,13 @@ namespace TD.UI
if (!placementManagerReady)
TryReadyPlacementManager();
if (!matchStateSubscribed)
TrySubscribeMatchState();
RefreshGoldDisplay();
RefreshMatchStateDisplays();
UpdateBuildProgressIfShown();
UpdateEnemyInfoIfShown();
HandleHotkeys();
minimapView?.Tick();
}
@ -330,6 +377,29 @@ namespace TD.UI
goldLabel.text = gm != null ? $"{gm.CurrentGold:N0} g" : "-- g";
}
// ----- Match-state display ----------------------------------------
// Polled each frame from Update. MatchState exposes Lives and CurrentWave
// as plain int properties backed by NetworkVariables; polling avoids
// having to add OnLivesChanged / OnCurrentWaveChanged events to MatchState
// for every consumer that wants to display them. The cost is one int read
// and a string allocation per frame — negligible at HUD scale.
private void RefreshMatchStateDisplays()
{
var ms = MatchState.Instance;
if (livesLabel != null)
livesLabel.text = ms != null ? $"lives: {ms.Lives}" : "lives: --";
if (waveLabel != null)
{
int total = WaveManager.Instance?.TotalWaves ?? 0;
waveLabel.text = ms != null && ms.CurrentWave > 0 && total > 0
? $"Wave {ms.CurrentWave} / {total}"
: "Wave --";
}
}
// ----- Command grid -----------------------------------------------
private const int GRID_COLS = 5;
@ -579,6 +649,12 @@ namespace TD.UI
isBuildSite ? DisplayStyle.Flex : DisplayStyle.None;
// Stat lines — clear and rebuild based on selection kind.
// The enemy-info cached references (health bar + label) are invalidated
// by Clear() and re-cached if a new Enemy gets selected below.
enemyHealthBar = null;
enemyHealthFill = null;
enemyHealthText = null;
if (statLines != null)
{
statLines.Clear();
@ -598,10 +674,113 @@ namespace TD.UI
AddStatLine($"Slow: {(1f - def.SlowFactor) * 100f:0}%");
}
}
else if (selection is EnemyHealth enemy)
{
BuildEnemyInfo(enemy);
}
// BuildSiteVisual: no stat lines — progress bar conveys the state.
}
}
// ----- Enemy info -------------------------------------------------
/// <summary>
/// Builds the enemy info stat block (HP bar + speed + bounty) inside the
/// stat-lines container. The bar and label references are cached so
/// <see cref="UpdateEnemyInfoIfShown"/> can drive them every frame as the
/// enemy takes damage.
/// </summary>
private void BuildEnemyInfo(EnemyHealth enemy)
{
var def = enemy.Definition;
// ----- Health bar (X / Y) -----
// A thin horizontal bar with a green fill and a centered "X / Y" label
// on top. Styled with inline-style so we don't need to add USS classes
// — keeps this self-contained.
var hpRow = new VisualElement();
hpRow.style.flexDirection = FlexDirection.Row;
hpRow.style.alignItems = Align.Center;
hpRow.style.marginTop = 4;
hpRow.style.marginBottom = 4;
var hpLabel = new Label("HP");
hpLabel.style.minWidth = 28;
hpLabel.style.color = new Color(0.85f, 0.85f, 0.85f);
hpRow.Add(hpLabel);
// Background + fill share a parent so the fill can be styled as a
// percentage width inside the background.
var hpBackground = new VisualElement();
hpBackground.style.flexGrow = 1;
hpBackground.style.height = 14;
hpBackground.style.backgroundColor = new Color(0.1f, 0.1f, 0.1f, 0.85f);
hpBackground.style.borderTopWidth = hpBackground.style.borderBottomWidth =
hpBackground.style.borderLeftWidth = hpBackground.style.borderRightWidth = 1;
var borderColor = new Color(0.3f, 0.3f, 0.3f);
hpBackground.style.borderTopColor = hpBackground.style.borderBottomColor =
hpBackground.style.borderLeftColor = hpBackground.style.borderRightColor = borderColor;
var hpFill = new VisualElement();
hpFill.style.height = Length.Percent(100);
hpFill.style.width = Length.Percent(100);
hpFill.style.backgroundColor = new Color(0.3f, 0.85f, 0.3f);
hpFill.pickingMode = PickingMode.Ignore;
hpBackground.Add(hpFill);
// Overlay "X / Y" label, centered above the bar.
var hpText = new Label();
hpText.pickingMode = PickingMode.Ignore;
hpText.style.position = Position.Absolute;
hpText.style.left = 0;
hpText.style.right = 0;
hpText.style.top = 0;
hpText.style.bottom = 0;
hpText.style.unityTextAlign = TextAnchor.MiddleCenter;
hpText.style.color = Color.white;
hpText.style.fontSize = 11;
hpBackground.Add(hpText);
hpRow.Add(hpBackground);
statLines.Add(hpRow);
// Cache references for per-frame updates.
enemyHealthBar = hpBackground;
enemyHealthFill = hpFill;
enemyHealthText = hpText;
// Initial value (will be refreshed every frame in UpdateEnemyInfoIfShown).
UpdateEnemyHpDisplay(enemy);
// ----- Speed + Bounty stat lines -----
if (def != null)
{
AddStatLine($"Speed: {def.MoveSpeed:0.0}");
AddStatLine($"Bounty: {def.GoldReward} g");
// (Weaknesses/resistances will go here once the resistance system lands.)
}
}
private void UpdateEnemyInfoIfShown()
{
if (enemyHealthFill == null) return; // no Enemy selected
var sel = SelectionState.Instance?.SelectedObject;
if (sel is EnemyHealth enemy && (UnityEngine.Object)enemy != null)
UpdateEnemyHpDisplay(enemy);
}
private void UpdateEnemyHpDisplay(EnemyHealth enemy)
{
float cur = Mathf.Max(0f, enemy.CurrentHp);
float max = Mathf.Max(1f, enemy.MaxHp);
float pct = Mathf.Clamp01(cur / max);
if (enemyHealthFill != null)
enemyHealthFill.style.width = Length.Percent(pct * 100f);
if (enemyHealthText != null)
enemyHealthText.text = $"{Mathf.CeilToInt(cur)} / {Mathf.CeilToInt(max)}";
}
private void AddStatLine(string text)
{
var label = new Label(text);
@ -667,6 +846,145 @@ namespace TD.UI
rejectionFadeCoroutine = null;
}
// ----- Portrait click: center camera on selection -----------------
// WC3 / SC2 convention: clicking the portrait recenters the camera on
// whoever is selected. Zoom is preserved (CameraController.JumpTo only
// moves the pivot). No-op when nothing is selected or the camera isn't wired.
private void OnPortraitClicked(ClickEvent evt)
{
var sel = SelectionState.Instance?.SelectedObject;
if (sel == null) return;
if (cameraController == null) return;
var t = sel.SelectionTransform;
if (t == null) return;
cameraController.JumpTo(t.position);
}
// ----- Match-end overlay (Victory / Defeat + Retry) ---------------
private void BuildMatchEndOverlay(VisualElement root)
{
matchEndOverlay = new VisualElement();
matchEndOverlay.style.position = Position.Absolute;
matchEndOverlay.style.left = 0;
matchEndOverlay.style.right = 0;
matchEndOverlay.style.top = 0;
matchEndOverlay.style.bottom = 0;
matchEndOverlay.style.alignItems = Align.Center;
matchEndOverlay.style.justifyContent = Justify.Center;
matchEndOverlay.style.backgroundColor = new Color(0f, 0f, 0f, 0.6f);
matchEndOverlay.style.display = DisplayStyle.None;
// The overlay swallows pointer events so the player can't click towers
// or builders through it while it's visible.
matchEndOverlay.pickingMode = PickingMode.Position;
var panel = new VisualElement();
panel.style.minWidth = 320;
panel.style.paddingTop = 24;
panel.style.paddingBottom = 24;
panel.style.paddingLeft = 32;
panel.style.paddingRight = 32;
panel.style.backgroundColor = new Color(0.08f, 0.08f, 0.10f, 0.95f);
panel.style.borderTopWidth = panel.style.borderBottomWidth =
panel.style.borderLeftWidth = panel.style.borderRightWidth = 2;
var border = new Color(0.4f, 0.4f, 0.45f);
panel.style.borderTopColor = panel.style.borderBottomColor =
panel.style.borderLeftColor = panel.style.borderRightColor = border;
panel.style.alignItems = Align.Center;
matchEndTitle = new Label("Victory");
matchEndTitle.style.fontSize = 32;
matchEndTitle.style.color = Color.white;
matchEndTitle.style.marginBottom = 16;
matchEndTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
panel.Add(matchEndTitle);
var retryBtn = new Button(OnRetryClicked) { text = "Retry" };
retryBtn.style.minWidth = 120;
retryBtn.style.height = 36;
retryBtn.style.fontSize = 16;
retryBtn.style.marginTop = 8;
panel.Add(retryBtn);
matchEndOverlay.Add(panel);
root.Add(matchEndOverlay);
}
private void HandlePhaseChanged(MatchPhase previous, MatchPhase next)
{
if (matchEndOverlay == null) return;
switch (next)
{
case MatchPhase.Victory:
if (matchEndTitle != null)
{
matchEndTitle.text = "Victory";
matchEndTitle.style.color = new Color(1f, 0.84f, 0.2f);
}
matchEndOverlay.style.display = DisplayStyle.Flex;
break;
case MatchPhase.Defeat:
if (matchEndTitle != null)
{
matchEndTitle.text = "Defeat";
matchEndTitle.style.color = new Color(0.95f, 0.25f, 0.25f);
}
matchEndOverlay.style.display = DisplayStyle.Flex;
break;
default:
matchEndOverlay.style.display = DisplayStyle.None;
break;
}
}
// Reloads the active scene and restarts the host. Host-only — testing
// convenience until a real lobby/restart flow exists. The static
// sceneLoaded callback survives the scene reload (HUDController dies),
// re-arms StartHost once the fresh scene has finished loading, and
// unsubscribes itself.
private void OnRetryClicked()
{
var nm = NetworkManager.Singleton;
if (nm == null)
{
Debug.LogWarning("[HUDController] Retry clicked but NetworkManager is null.");
return;
}
if (!nm.IsServer)
{
Debug.LogWarning("[HUDController] Retry only works on the host. " +
"Clients should ask the host to retry.");
return;
}
Scene active = SceneManager.GetActiveScene();
s_pendingHostRestartBuildIndex = active.buildIndex;
SceneManager.sceneLoaded += OnSceneLoadedForRetry;
nm.Shutdown();
SceneManager.LoadScene(active.buildIndex);
}
private static int s_pendingHostRestartBuildIndex = -1;
private static void OnSceneLoadedForRetry(Scene loaded, LoadSceneMode mode)
{
if (loaded.buildIndex != s_pendingHostRestartBuildIndex) return;
SceneManager.sceneLoaded -= OnSceneLoadedForRetry;
s_pendingHostRestartBuildIndex = -1;
var nm = NetworkManager.Singleton;
if (nm != null) nm.StartHost();
else Debug.LogWarning("[HUDController] Retry: no NetworkManager in reloaded scene.");
}
// ----- Helpers ----------------------------------------------------
private static T Require<T>(VisualElement root, string name) where T : VisualElement