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

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