UnityTowerDefense/Assets/_Project/Scripts/UI/HUDController.cs
Matt F 16706a1ecf Scale difficulty by run position; wipe towers at phase end
Follow-up pass on the 2.0 refactor, closing the gap between "waves are drawn
at random" and "difficulty still lives in the enemy assets".

- Enemy health is no longer authored per enemy type. New EnemyScalingConfig
  scales a per-zone HP *budget* by encounter number; per-enemy health is that
  budget divided by the wave's enemy count. Scaling the total rather than the
  per-enemy value keeps enemy count a texture knob (few tanks vs many swarmers)
  instead of a second, uncontrolled difficulty axis. EnemyDefinition.MaxHp
  becomes HpMultiplier, a deviation around 1.0. Speed stays archetype-only and
  unscaled -- escalating it compounds with health and invalidates tower balance
  mid-run.
- GoldConfig entries no longer reference a WaveDefinition. Payout is a property
  of run position, not of which wave got drawn: WaveGoldEntry ->
  EncounterGoldEntry, Waves -> Encounters, keyed by global encounter number.
  Its inspector labels elements "Encounter N" and projects cumulative earnings.
- The wave's voted buffs now show as icon badges in the top bar, read from the
  same RunState slot the spawn path uses so the display can't drift from what
  the enemies actually carry. Hover names the buff; unillustrated cards fall
  back to a lettered badge rather than vanishing.
- Clearing a phase's boss destroys every built tower, unrefunded. Queued build
  jobs still refund -- those towers were never delivered. Runs with the field
  empty, so the walkability churn doesn't hit the re-path scheduler.
- Fixed an RPC codegen break: [ClientRpc] requires a ClientRpc suffix, unlike
  the newer [Rpc(SendTo...)] style this file doesn't use.
- Setup checklist reordered into dependency order; it previously asked for a
  RunDefinition two sections before creating one.

Also carries the editor-side asset reorganisation into Definitions/RunDefinitions
and the sprite move into Enemy/Player draft icon folders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 00:32:33 -07:00

2815 lines
127 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.Gameplay.BuilderSpells;
using TD.Gameplay.Draft;
using TD.Gameplay.EnemyUpgrades;
using TD.Gameplay.Waves;
using TD.Towers;
using TD.UI.Minimap;
namespace TD.UI
{
/// <summary>
/// Drives the in-match HUD. Requires a UIDocument on the same GameObject.
/// Wires gold display, tower command grid, tooltip, rejection messages,
/// and minimap RenderTexture to their UI Toolkit counterparts.
/// </summary>
[RequireComponent(typeof(UIDocument))]
public class HUDController : MonoBehaviour
{
public static HUDController Instance { get; private set; }
// ----- Inspector --------------------------------------------------
[Header("Scene References")] [Tooltip("The local client's TowerPlacementController.")] [SerializeField]
private TowerPlacementController placementController;
[Tooltip("The local client's TowerPaintController (drives the Paint tab + paint cursor).")]
[SerializeField] private TowerPaintController paintController;
[Tooltip("The TowerPlacementManager NetworkObject in the scene.")] [SerializeField]
private TowerPlacementManager placementManager;
[Tooltip("The local client's CameraController. Used by the minimap for click-to-jump " +
"and drag-to-pan.")]
[SerializeField]
private CameraController cameraController;
[Header("Settings")] [SerializeField] private float rejectionMessageDuration = 2.5f;
[Tooltip("Optional icon for the top-right menu button. Leave empty to use the " +
"procedurally-drawn gear (no art asset required).")]
[SerializeField] private Sprite menuButtonIcon;
[Tooltip("Maximum visible height of the chat feed in pixels. Content past this " +
"height is clipped — older messages scroll off the top of the visible area " +
"but stay in history (scroll up while chat is open to view).")]
[SerializeField]
private float chatMaxHeight = 280f;
[Tooltip("Maximum messages kept in chat history. Defaults to effectively unlimited " +
"(int.MaxValue) — every message sent during a match stays scrollable. " +
"Lower the value if a long match ever shows DOM perf issues; this field " +
"is the safety valve, not a normal-play limit.")]
[SerializeField]
private int chatMaxMessages = int.MaxValue;
[Tooltip("Color used for SYSTEM chat messages (e.g. 'Life Lost', income changes).")] [SerializeField]
private Color chatSystemColor = new Color(1f, 0.7f, 0.2f);
[Tooltip("Color used for PLAYER chat message bodies. Sender prefix uses the player's slot color.")]
[SerializeField]
private Color chatPlayerColor = new Color(0.92f, 0.92f, 0.92f);
// ----- Cached UI element references -------------------------------
private Label goldLabel;
private Label waveLabel;
private Label livesLabel;
private Label nextWaveLabel; // prep countdown ("next: 0:12")
private Label leakedLabel; // local player's origin-leak count ("leaked: 3")
private Label incomeLabel; // top-bar per-wave gold-earned counter ("+150 g/wave")
// Top-bar row of buff badges for the wave currently spawning. Rebuilt only when the
// underlying set changes — see waveBuffSignature.
private VisualElement waveBuffRow;
// Cheap change-detector for the buff row. RefreshTopBar runs every frame, and rebuilding
// a handful of VisualElements at 60 Hz would churn the UI system for no reason. Encodes
// the slot plus the option ids on it, so it changes when the wave advances OR when a vote
// lands on the slot the players are about to face again.
private int waveBuffSignature = int.MinValue;
private readonly System.Collections.Generic.List<int> waveBuffScratch
= new System.Collections.Generic.List<int>();
private VisualElement playerListContainer; // right-panel scoreboard rows
private Label portraitName;
private Label levelLabel;
private VisualElement statLines;
private VisualElement commandGrid;
private VisualElement actionFrame; // hidden via display:none when no actions are available
private VisualElement commandTabs; // Build/Paint tab row — shown only for a Builder selection
private Button tabBuild;
private Button tabPaint;
private VisualElement buildProgressContainer; // info-panel sub-view, shown for BuildSiteVisual selections
private VisualElement buildProgressFill; // width driven each frame from progress
private Label buildProgressPercent;
private Label ttTitle;
private Label ttDesc;
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;
// Draft overlay (roguelike between-waves draft). Non-modal: the card row floats at
// top-center so the player can keep building during prep. The "Buy Roll" button is
// shown when no draft is pending so a paid roll can be bought any time.
private VisualElement draftPanel;
private VisualElement draftCardRow;
private bool draftSubscribed;
private PlayerDraft subscribedDraft;
// Enemy-buff vote overlay. Shares the draft overlay's position and card styling, but the
// cards are shared rather than per-player and carry live voter badges.
private VisualElement votePanel;
private VisualElement voteCardRow;
private Label voteTitle;
private bool voteSubscribed;
private WaveVote subscribedVote;
// Boss health bar. Shown only while a boss is alive; polled per frame like lives/gold
// rather than event-driven, since HP changes continuously anyway.
private VisualElement bossBarPanel;
private VisualElement bossHealthFill;
private Label bossHealthText;
private Label bossNameLabel;
// Reused when painting voter badges so a rebuild-per-ballot doesn't allocate a list per
// card. The HUD is single-threaded, so one shared buffer is safe.
private readonly System.Collections.Generic.List<PlayerSlot> voteVoterScratch
= new System.Collections.Generic.List<PlayerSlot>();
// Reused when building a selected tower's upgrade buttons.
private readonly System.Collections.Generic.List<(TowerDefinition Def, int TypeId)>
upgradeOptionScratch
= new System.Collections.Generic.List<(TowerDefinition, int)>();
// Spell hotbar (bottom-ui Section 6). Frame is visibility:hidden (but still occupies
// its reserved width, so the centered command bar never shifts) while the local player
// has no granted spells; rebuilt on grant (append-only, so this is rare). Cooldown
// state changes continuously and has no change event, so it's polled every Update.
private VisualElement spellHotbarFrame;
private VisualElement spellHotbar;
private bool spellLoadoutSubscribed;
private PlayerSpellLoadout subscribedSpellLoadout;
// The loadout the hotbar is currently showing — the SELECTED builder's, or null when
// no builder is selected (hotbar hidden). Cooldown polling reads this.
private PlayerSpellLoadout displayedSpellLoadout;
private readonly List<SpellSlotUi> spellSlotUis = new List<SpellSlotUi>();
private readonly struct SpellSlotUi
{
public readonly VisualElement Cell;
public readonly Label CooldownLabel;
public SpellSlotUi(VisualElement cell, Label cooldownLabel)
{
Cell = cell;
CooldownLabel = cooldownLabel;
}
}
// Chat panel (bottom-left, above portrait) — programmatic. The container
// holds both the scrollable feed and the input. Highlight + scroll
// interactivity are toggled on the container when typing.
private VisualElement chatContainer;
private ScrollView chatFeed;
private TextField chatInput;
private bool chatInputOpen;
// Frame on which the chat input was opened or closed. Enter on that frame
// and the next one is ignored to prevent the open/close-triggering keypress
// from also being consumed by the input or the open-toggle. Without this,
// pressing Enter to open chat would immediately submit an empty message.
private int chatToggleSuppressFrame = -1;
// Set true whenever the chat input or any other text field on the HUD has
// keyboard focus. Camera, builder input, and hotkey handlers all gate on
// this to keep typing from driving gameplay. Setter is internal (not
// private) so other standalone text-input surfaces — e.g. TD.Dev.DebugConsole,
// which is not part of HUDController — can contribute to the same flag
// instead of every gameplay script needing to know about every input widget.
public static bool IsTextInputActive { get; internal set; }
/// <summary>
/// True while a modal UI surface (currently the gear menu) owns the screen. Gameplay
/// systems should treat this exactly like <see cref="IsTextInputActive"/> — see
/// <see cref="IsUiCapturingInput"/>, which is the flag they actually gate on.
/// </summary>
public static bool IsModalUiOpen { get; private set; }
/// <summary>
/// True when any UI surface is consuming keyboard/mouse input this frame: a focused
/// text field OR an open modal menu. Gameplay input handlers (camera, selection,
/// hotkeys, Escape-to-cancel) gate on this so typing or a menu never doubles as a
/// gameplay command.
/// </summary>
public static bool IsUiCapturingInput => IsTextInputActive || IsModalUiOpen;
// Frame until which chat's "Enter opens chat" behavior is suppressed. Other
// standalone text-input surfaces (e.g. TD.Dev.DebugConsole) set this when their
// own Enter-driven submit/close already consumed the keypress, so the same
// Enter press doesn't also get interpreted as "open chat" this frame. Without
// this, submitting a debug console command with Enter would immediately pop
// chat open too, since both systems poll the raw key independently.
internal static int SuppressChatOpenUntilFrame = -1;
// ----- State ------------------------------------------------------
// Which command-grid tab is active for a Builder selection. Build = tower buttons
// (default), Paint = color swatches. Reset to Build whenever a non-builder is
// selected so reselecting a builder always starts on Build.
private enum CommandTab { Build, Paint }
private CommandTab activeTab = CommandTab.Build;
private Coroutine rejectionFadeCoroutine;
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 bool deckSubscribed; // true once we've hooked the local PlayerTowerDeck.OnDeckChanged
private PlayerTowerDeck subscribedDeck; // the deck we hooked, so we can unsubscribe the same instance
private bool upgradesSubscribed; // true once we've hooked the local PlayerTowerUpgrades
private PlayerTowerUpgrades subscribedUpgrades;
private MinimapView minimapView;
private GameMenuView gameMenu; // gear button + modal menu overlay
private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us
// ----- Hotkeys ----------------------------------------------------
//
// Per-slot hotkey layout matching the WC3 / Wintermaul Reforged convention.
// Slot index 0..14 corresponds to row-major position in the 5×3 action grid.
// To rebind, edit this array. (Per-tower hotkeys could live on TowerDefinition
// later; for now the position-based layout is enough and predictable.)
private static readonly Key[] HotkeyLayout =
{
Key.Q, Key.W, Key.E, Key.R, Key.T,
Key.A, Key.S, Key.D, Key.F, Key.G,
Key.Z, Key.X, Key.C, Key.V, Key.B,
};
// Active hotkey bindings — rebuilt on every selection change inside
// PopulateGridForSelection. HandleHotkeys reads this every Update.
private readonly List<HotkeyBinding> hotkeyBindings = new List<HotkeyBinding>();
private readonly struct HotkeyBinding
{
public readonly Key Key;
public readonly VisualElement Button; // for enabledSelf gating
public readonly System.Action Action;
public HotkeyBinding(Key k, VisualElement b, System.Action a)
{
Key = k;
Button = b;
Action = a;
}
}
// ----- Static hit-test probe --------------------------------------
// Set when InitializeUI succeeds; cleared on OnDestroy. Non-UI systems (camera,
// input handlers) can query IsPointerOverInteractiveHud without taking a direct
// reference to HUDController.
private static IPanel s_hudPanel;
/// <summary>
/// True if <paramref name="screenMousePos"/> falls over an interactive (non-ignore)
/// HUD element. Non-UI systems that consume mouse input (camera scroll-zoom, edge-pan)
/// should gate their handling on this so a cursor over the minimap, command grid, or
/// any other interactive HUD region doesn't drive both the HUD and the world at once.
/// </summary>
/// <remarks>
/// Convention: <paramref name="screenMousePos"/> uses Unity Input System screen coords
/// (origin bottom-left, y up). Returns false before the HUD has initialized; safe to
/// call from any system at any time.
/// </remarks>
public static bool IsPointerOverInteractiveHud(Vector2 screenMousePos)
{
if (s_hudPanel == null) return false;
// Coord convention rabbit hole:
// - Screen mouse position: origin bottom-left, y up (Unity Input System).
// - UI Toolkit panel coords: origin top-left, y down.
//
// RuntimePanelUtils.ScreenToPanel converts the SCALE (e.g., reference resolution
// vs. actual resolution) but does NOT flip Y. We flip manually using the visual
// tree's height so the result works regardless of PanelSettings scale mode.
//
// Subtle: visualTree.worldBound height may be 0 for one frame on the very first
// layout pass. The caller (CameraController) checks the result against "is over
// interactive HUD"; a one-frame false positive (camera zooms when it shouldn't)
// is harmless and self-corrects the next frame.
Vector2 scaled = RuntimePanelUtils.ScreenToPanel(s_hudPanel, screenMousePos);
float panelHeight = s_hudPanel.visualTree.worldBound.height;
Vector2 panelPos = new Vector2(scaled.x, panelHeight - scaled.y);
// panel.Pick returns null when the topmost element under the point has
// PickingMode.Ignore (or there's no element there at all). Non-null means an
// interactive HUD element is under the cursor.
return s_hudPanel.Pick(panelPos) != null;
}
// ----- Lifecycle --------------------------------------------------
private void Start()
{
// UIDocument creates its panel in OnEnable, which runs after all
// Awake() calls. Accessing rootVisualElement in Awake() is too early.
// Start() is safe because all OnEnable() calls have completed by then.
InitializeUI();
TryReadyPlacementManager();
// Subscription fallback: if OnEnable couldn't subscribe (SelectionState.Awake
// hadn't run yet), Start() is guaranteed to be after all Awake calls in the
// scene. Retry here. Without this fallback, the HUD silently misses every
// selection event for the rest of the session.
TrySubscribeSelection();
// 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;
}
// Hook the local player's deck so the command grid rebuilds live when a draft
// grant lands while the builder is already selected. The local deck may not
// exist for the first few frames (spawn race) — retried each Update until it does.
private void TrySubscribeDeck()
{
var deck = PlayerTowerDeck.Local;
if (deck == null) return;
deck.OnDeckChanged += HandleDeckChanged;
subscribedDeck = deck;
deckSubscribed = true;
// The deck may have populated before we subscribed — rebuild now so the
// grid reflects the current contents.
PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
}
private void HandleDeckChanged()
{
PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
}
// Same idea for unlocked upgrade nodes: a draft pick can grant an upgrade while the
// player already has the target tower selected, and the new branch should appear in the
// action grid without needing a reselect.
private void TrySubscribeUpgrades()
{
var upgrades = PlayerTowerUpgrades.Local;
if (upgrades == null) return;
upgrades.OnUpgradesChanged += HandleDeckChanged;
subscribedUpgrades = upgrades;
upgradesSubscribed = true;
PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
}
// Hook the local player's draft so the overlay rebuilds when options are offered,
// picked, or rerolled. Retried each Update until the local PlayerDraft exists.
private void TrySubscribeDraft()
{
var draft = PlayerDraft.Local;
if (draft == null) return;
draft.OnDraftChanged += HandleDraftChanged;
subscribedDraft = draft;
draftSubscribed = true;
RebuildDraftCards();
}
private void HandleDraftChanged()
{
RebuildDraftCards();
}
// Hook the local player's spell loadout so the hotbar rebuilds when a new spell is
// granted. Retried each Update until the local PlayerSpellLoadout exists.
private void TrySubscribeSpellLoadout()
{
var loadout = PlayerSpellLoadout.Local;
if (loadout == null) return;
loadout.OnLoadoutChanged += HandleSpellLoadoutChanged;
subscribedSpellLoadout = loadout;
spellLoadoutSubscribed = true;
RebuildSpellHotbar();
}
private void HandleSpellLoadoutChanged()
{
RebuildSpellHotbar();
}
// ----- Draft overlay ----------------------------------------------
// Non-modal draft UI: a card row floats at top-center while the local player has an
// unresolved offer. The host panel ignores picks so empty space clicks through to the
// world — the player can still pan and inspect their maze while deciding.
private void BuildDraftOverlay(VisualElement root)
{
draftPanel = new VisualElement();
draftPanel.style.position = Position.Absolute;
draftPanel.style.left = 0;
draftPanel.style.right = 0;
draftPanel.style.top = 70; // below the top bar
draftPanel.style.alignItems = Align.Center;
draftPanel.pickingMode = PickingMode.Ignore;
draftPanel.style.display = DisplayStyle.None;
var box = new VisualElement();
box.style.flexDirection = FlexDirection.Column;
box.style.alignItems = Align.Center;
box.pickingMode = PickingMode.Ignore;
draftCardRow = new VisualElement();
draftCardRow.style.flexDirection = FlexDirection.Row;
draftCardRow.pickingMode = PickingMode.Ignore;
box.Add(draftCardRow);
// The "Buy Roll" button lived here. Removed with the extra-draft shop for the 2.0 MVP
// — see PlayerDraft's commented-out RequestBuyRerollRpc for why the backend was kept.
draftPanel.Add(box);
root.Add(draftPanel);
UpdateDraftVisibility();
}
// Rebuilds the option cards from the local player's current offer. Called on
// OnDraftChanged (offered / picked / rerolled).
private void RebuildDraftCards()
{
if (draftCardRow == null) return;
draftCardRow.Clear();
var draft = PlayerDraft.Local;
var pool = DraftPool.Instance;
if (draft != null && pool != null)
{
for (int i = 0; i < draft.OptionCount; i++)
{
int id = draft.GetOptionId(i);
var option = pool.Get(id);
if (option == null) continue;
draftCardRow.Add(CreateDraftCard(option, id, draft));
}
}
UpdateDraftVisibility();
}
private VisualElement CreateDraftCard(DraftOption option, int optionId, PlayerDraft draft)
{
var card = new VisualElement();
card.style.width = 150;
card.style.marginLeft = card.style.marginRight = 4;
card.style.paddingTop = card.style.paddingBottom = 10;
card.style.paddingLeft = card.style.paddingRight = 10;
card.style.backgroundColor = new Color(0.10f, 0.10f, 0.13f, 0.96f);
card.style.borderTopWidth = card.style.borderBottomWidth =
card.style.borderLeftWidth = card.style.borderRightWidth = 2;
var border = new Color(0.4f, 0.4f, 0.45f);
card.style.borderTopColor = card.style.borderBottomColor =
card.style.borderLeftColor = card.style.borderRightColor = border;
card.style.alignItems = Align.Center;
var title = new Label(option.DisplayName);
title.style.fontSize = 14;
title.style.color = Color.white;
title.style.unityFontStyleAndWeight = FontStyle.Bold;
title.style.whiteSpace = WhiteSpace.Normal;
title.style.unityTextAlign = TextAnchor.MiddleCenter;
title.style.marginBottom = 6;
card.Add(title);
var iconSprite = option.ResolveIcon();
if (iconSprite != null)
{
var img = new Image { sprite = iconSprite };
img.style.width = 48;
img.style.height = 48;
img.style.marginBottom = 6;
card.Add(img);
}
var desc = new Label(option.Description ?? "");
desc.style.fontSize = 11;
desc.style.color = new Color(0.82f, 0.82f, 0.82f);
desc.style.whiteSpace = WhiteSpace.Normal;
desc.style.unityTextAlign = TextAnchor.MiddleCenter;
desc.style.marginBottom = 8;
card.Add(desc);
var pick = new Button(() => draft.RequestPickRpc(optionId)) { text = "Pick" };
card.Add(pick);
return card;
}
// Shows the draft row only while the local player has an unresolved offer. With the
// extra-draft shop gone there is nothing else in this panel, so it hides entirely once
// the player has picked — which also gives them a clear "I'm done, waiting on others"
// signal during the barrier.
private void UpdateDraftVisibility()
{
if (draftPanel == null) return;
var draft = PlayerDraft.Local;
bool hasDraft = draft != null && draft.HasActiveDraft;
if (draftCardRow != null)
draftCardRow.style.display = hasDraft ? DisplayStyle.Flex : DisplayStyle.None;
draftPanel.style.display = hasDraft ? DisplayStyle.Flex : DisplayStyle.None;
}
// ----- Enemy-buff vote overlay --------------------------------------
// Shared vote UI. Same anchor and card language as the draft row (they never show at the
// same time — the inter-wave stages are strictly sequential), but every peer sees the same
// three cards, and each card carries badges for the players currently voting for it.
private void BuildVoteOverlay(VisualElement root)
{
votePanel = new VisualElement();
votePanel.style.position = Position.Absolute;
votePanel.style.left = 0;
votePanel.style.right = 0;
votePanel.style.top = 70; // below the top bar, same as the draft row
votePanel.style.alignItems = Align.Center;
votePanel.pickingMode = PickingMode.Ignore;
votePanel.style.display = DisplayStyle.None;
var box = new VisualElement();
box.style.flexDirection = FlexDirection.Column;
box.style.alignItems = Align.Center;
box.pickingMode = PickingMode.Ignore;
voteTitle = new Label("Choose how this wave comes back");
voteTitle.style.fontSize = 15;
voteTitle.style.color = new Color(0.95f, 0.72f, 0.55f);
voteTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
voteTitle.style.marginBottom = 6;
box.Add(voteTitle);
voteCardRow = new VisualElement();
voteCardRow.style.flexDirection = FlexDirection.Row;
voteCardRow.pickingMode = PickingMode.Ignore;
box.Add(voteCardRow);
votePanel.Add(box);
root.Add(votePanel);
UpdateVoteVisibility();
}
// Hook the shared vote so the panel rebuilds whenever a ballot lands. Retried each Update
// until the scene's WaveVote has network-spawned.
private void TrySubscribeVote()
{
var vote = WaveVote.Instance;
if (vote == null) return;
vote.OnVoteChanged += HandleVoteChanged;
subscribedVote = vote;
voteSubscribed = true;
RebuildVoteCards();
}
private void HandleVoteChanged()
{
RebuildVoteCards();
}
// Rebuilt on every ballot change, not just when the offered set changes — the voter
// badges are the point of the panel, so they have to track votes as they land.
private void RebuildVoteCards()
{
if (voteCardRow == null) return;
voteCardRow.Clear();
var vote = WaveVote.Instance;
var pool = EnemyUpgradePool.Instance;
if (vote != null && pool != null && vote.IsOpen)
{
for (int i = 0; i < vote.OptionCount; i++)
{
int id = vote.GetOptionId(i);
var option = pool.Get(id);
if (option == null) continue;
voteCardRow.Add(CreateVoteCard(option, id, vote));
}
}
UpdateVoteVisibility();
}
private VisualElement CreateVoteCard(EnemyUpgradeOption option, int optionId, WaveVote vote)
{
var localSlot = PlayerMatchState.Local != null
? PlayerMatchState.Local.Slot
: PlayerSlot.None;
bool isLocalChoice = localSlot != PlayerSlot.None
&& vote.GetBallot(localSlot) == optionId;
var card = new VisualElement();
card.style.width = 160;
card.style.marginLeft = card.style.marginRight = 4;
card.style.paddingTop = card.style.paddingBottom = 10;
card.style.paddingLeft = card.style.paddingRight = 10;
card.style.backgroundColor = new Color(0.13f, 0.09f, 0.09f, 0.96f);
card.style.borderTopWidth = card.style.borderBottomWidth =
card.style.borderLeftWidth = card.style.borderRightWidth = 2;
// The local player's current choice gets a bright border so they can tell at a glance
// which one is theirs among everyone else's badges.
var border = isLocalChoice
? new Color(0.95f, 0.72f, 0.35f)
: new Color(0.45f, 0.30f, 0.30f);
card.style.borderTopColor = card.style.borderBottomColor =
card.style.borderLeftColor = card.style.borderRightColor = border;
card.style.alignItems = Align.Center;
var title = new Label(option.DisplayName);
title.style.fontSize = 14;
title.style.color = Color.white;
title.style.unityFontStyleAndWeight = FontStyle.Bold;
title.style.whiteSpace = WhiteSpace.Normal;
title.style.unityTextAlign = TextAnchor.MiddleCenter;
title.style.marginBottom = 6;
card.Add(title);
var iconSprite = option.ResolveIcon();
if (iconSprite != null)
{
var img = new Image { sprite = iconSprite };
img.style.width = 48;
img.style.height = 48;
img.style.marginBottom = 6;
card.Add(img);
}
var desc = new Label(option.Description ?? "");
desc.style.fontSize = 11;
desc.style.color = new Color(0.82f, 0.82f, 0.82f);
desc.style.whiteSpace = WhiteSpace.Normal;
desc.style.unityTextAlign = TextAnchor.MiddleCenter;
desc.style.marginBottom = 8;
card.Add(desc);
// Live voter badges — one per player currently voting for this card, in their own
// player color. This row is why the panel rebuilds on every ballot change.
var badgeRow = new VisualElement();
badgeRow.style.flexDirection = FlexDirection.Row;
badgeRow.style.justifyContent = Justify.Center;
badgeRow.style.minHeight = 24;
badgeRow.style.marginBottom = 6;
voteVoterScratch.Clear();
vote.GetVotersFor(optionId, voteVoterScratch);
foreach (var voter in voteVoterScratch)
badgeRow.Add(CreateVoterBadge(voter));
card.Add(badgeRow);
var pick = new Button(() => WaveVote.Instance?.RequestVoteRpc(optionId))
{
text = isLocalChoice ? "Voted" : "Vote"
};
card.Add(pick);
return card;
}
// Small filled circle carrying the player's slot number, in their canonical player color.
// Same visual vocabulary as the race-selection overlay's picker badges, so players read it
// as "who" without a legend.
private VisualElement CreateVoterBadge(PlayerSlot slot)
{
var badge = new Label(((int)slot).ToString());
badge.style.width = 20;
badge.style.height = 20;
badge.style.marginLeft = badge.style.marginRight = 2;
badge.style.backgroundColor = PlayerColors.Get(slot);
badge.style.color = Color.black;
badge.style.fontSize = 11;
badge.style.unityFontStyleAndWeight = FontStyle.Bold;
badge.style.unityTextAlign = TextAnchor.MiddleCenter;
badge.style.borderTopLeftRadius = badge.style.borderTopRightRadius =
badge.style.borderBottomLeftRadius = badge.style.borderBottomRightRadius = 10;
return badge;
}
private void UpdateVoteVisibility()
{
if (votePanel == null) return;
var vote = WaveVote.Instance;
bool open = vote != null && vote.IsOpen && vote.OptionCount > 0;
votePanel.style.display = open ? DisplayStyle.Flex : DisplayStyle.None;
}
// ----- Boss bar -----------------------------------------------------
// Prominent health bar for the phase boss. Hidden whenever no boss is alive, so it costs
// nothing outside a boss encounter.
private void BuildBossBar(VisualElement root)
{
bossBarPanel = new VisualElement();
bossBarPanel.style.position = Position.Absolute;
bossBarPanel.style.left = 0;
bossBarPanel.style.right = 0;
bossBarPanel.style.top = 70;
bossBarPanel.style.alignItems = Align.Center;
bossBarPanel.pickingMode = PickingMode.Ignore;
bossBarPanel.style.display = DisplayStyle.None;
var box = new VisualElement();
box.style.width = 440;
box.style.alignItems = Align.Center;
box.pickingMode = PickingMode.Ignore;
bossNameLabel = new Label("BOSS");
bossNameLabel.style.fontSize = 18;
bossNameLabel.style.color = new Color(0.95f, 0.45f, 0.4f);
bossNameLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
bossNameLabel.style.marginBottom = 3;
box.Add(bossNameLabel);
var track = new VisualElement();
track.style.width = 440;
track.style.height = 18;
track.style.backgroundColor = new Color(0.08f, 0.06f, 0.06f, 0.95f);
track.style.borderTopWidth = track.style.borderBottomWidth =
track.style.borderLeftWidth = track.style.borderRightWidth = 2;
var trackBorder = new Color(0.5f, 0.25f, 0.22f);
track.style.borderTopColor = track.style.borderBottomColor =
track.style.borderLeftColor = track.style.borderRightColor = trackBorder;
track.pickingMode = PickingMode.Ignore;
bossHealthFill = new VisualElement();
bossHealthFill.style.height = Length.Percent(100);
bossHealthFill.style.width = Length.Percent(100);
bossHealthFill.style.backgroundColor = new Color(0.75f, 0.18f, 0.16f);
bossHealthFill.pickingMode = PickingMode.Ignore;
track.Add(bossHealthFill);
bossHealthText = new Label("");
bossHealthText.style.position = Position.Absolute;
bossHealthText.style.left = 0;
bossHealthText.style.right = 0;
bossHealthText.style.top = 0;
bossHealthText.style.bottom = 0;
bossHealthText.style.fontSize = 12;
bossHealthText.style.color = Color.white;
bossHealthText.style.unityTextAlign = TextAnchor.MiddleCenter;
bossHealthText.pickingMode = PickingMode.Ignore;
track.Add(bossHealthText);
box.Add(track);
bossBarPanel.Add(box);
root.Add(bossBarPanel);
}
// Polled from the per-frame refresh, like lives and gold. Picks the boss in the local
// player's own zone when there is one — the boss encounter spawns one per zone, and the
// one actually walking through your maze is the one you need to watch.
private void RefreshBossBar()
{
if (bossBarPanel == null) return;
var boss = ResolveDisplayedBoss();
if (boss == null)
{
bossBarPanel.style.display = DisplayStyle.None;
return;
}
bossBarPanel.style.display = DisplayStyle.Flex;
float max = Mathf.Max(1f, boss.MaxHp);
float pct = Mathf.Clamp01(boss.CurrentHp / max);
bossHealthFill.style.width = Length.Percent(pct * 100f);
bossHealthText.text = $"{Mathf.CeilToInt(boss.CurrentHp)} / {Mathf.CeilToInt(max)}";
bossNameLabel.text = boss.DisplayName;
}
private EnemyHealth ResolveDisplayedBoss()
{
var bosses = EnemyHealth.ActiveBosses;
if (bosses.Count == 0) return null;
var localSlot = PlayerMatchState.Local != null
? PlayerMatchState.Local.Slot
: PlayerSlot.None;
EnemyHealth fallback = null;
for (int i = 0; i < bosses.Count; i++)
{
var boss = bosses[i];
if (boss == null || boss.IsDead) continue;
fallback ??= boss;
if (localSlot != PlayerSlot.None)
{
var movement = boss.GetComponent<EnemyMovement>();
if (movement != null && movement.OriginZone == localSlot) return boss;
}
}
return fallback;
}
// ----- Spell hotbar -------------------------------------------------
// Rebuilds the hotbar cells for the SELECTED builder's loadout. Called on selection
// change and on OnLoadoutChanged (a new spell granted). Spells are append-only, so
// this is rare, not a per-frame concern.
//
// The hotbar follows selection: it shows the selected builder's spells and is removed
// entirely when no builder is selected (spells belong to the builder). While a builder
// IS selected, the frame keeps its reserved width (visibility toggle, not display) so
// granting a spell mid-selection doesn't reflow/shift the centered command bar.
private void RebuildSpellHotbar()
{
if (spellHotbar == null) return;
spellHotbar.Clear();
spellSlotUis.Clear();
var loadout = GetSelectedBuilderLoadout();
displayedSpellLoadout = loadout;
if (spellHotbarFrame != null)
{
if (loadout == null)
{
// No builder selected → remove the slot (the command grid is hidden too).
spellHotbarFrame.style.display = DisplayStyle.None;
return;
}
// Builder selected → keep the reserved slot in layout; toggle only visibility so
// a mid-selection spell grant doesn't jump the command bar.
spellHotbarFrame.style.display = DisplayStyle.Flex;
spellHotbarFrame.style.visibility =
loadout.SlotCount > 0 ? Visibility.Visible : Visibility.Hidden;
}
if (loadout == null) return;
int slotCount = loadout.SlotCount;
var layout = SpellHotkeys.Layout;
for (int i = 0; i < slotCount; i++)
{
var kind = loadout.GetKind(i);
if (kind == null) continue;
var definition = BuilderSpellPool.Instance?.Get(kind.Value);
var cell = new VisualElement();
cell.AddToClassList("spell-slot");
var icon = new VisualElement();
icon.AddToClassList("spell-slot-icon");
icon.pickingMode = PickingMode.Ignore;
if (definition?.Icon != null)
icon.style.backgroundImage = new StyleBackground(definition.Icon);
cell.Add(icon);
if (i < layout.Length)
{
var hkLabel = new Label(SpellKeyToDisplay(layout[i]));
hkLabel.AddToClassList("spell-slot-hotkey");
hkLabel.pickingMode = PickingMode.Ignore;
cell.Add(hkLabel);
}
var cooldownLabel = new Label("");
cooldownLabel.AddToClassList("spell-slot-cooldown-label");
cooldownLabel.pickingMode = PickingMode.Ignore;
cell.Add(cooldownLabel);
cell.RegisterCallback<MouseEnterEvent>(_ => ShowSpellTooltip(definition));
cell.RegisterCallback<MouseLeaveEvent>(_ => ClearTooltip());
spellHotbar.Add(cell);
spellSlotUis.Add(new SpellSlotUi(cell, cooldownLabel));
}
}
// The loadout the spell hotbar should display: the currently-selected builder's
// (resolved via its owner, so it shows whichever builder is selected — normally the
// local player's), or null when the selection isn't a builder → hotbar hidden.
private PlayerSpellLoadout GetSelectedBuilderLoadout()
{
var selected = SelectionState.Instance?.SelectedObject;
if (selected is Builder builder)
return PlayerSpellLoadout.GetForClient(builder.OwnerClientId);
return null;
}
// Per-frame: dims each slot while on cooldown and shows the remaining whole seconds.
// Cooldown has no change event (it advances continuously with server time), so this
// has to be polled — cheap at hotbar scale (at most MaxSpellSlots cells).
private void UpdateSpellCooldowns()
{
if (spellSlotUis.Count == 0) return;
var loadout = displayedSpellLoadout;
if (loadout == null) return;
for (int i = 0; i < spellSlotUis.Count; i++)
{
var ui = spellSlotUis[i];
bool onCooldown = loadout.IsSlotOnCooldown(i);
ui.Cell.EnableInClassList("on-cooldown", onCooldown);
ui.CooldownLabel.text = onCooldown
? $"{Mathf.CeilToInt(loadout.GetCooldownRemaining(i))}"
: "";
}
}
// Renders a spell hotkey as its display glyph. SpellHotkeys.Layout uses Key.DigitN,
// whose ToString() is "DigitN" — strip the prefix so the badge shows "1", not "Digit1".
private static string SpellKeyToDisplay(Key key)
{
string s = key.ToString();
return s.StartsWith("Digit") ? s.Substring("Digit".Length) : s;
}
// Lightweight tooltip for spell slots — reuses the tooltip box (title + desc + cooldown).
private void ShowSpellTooltip(BuilderSpellDefinition def)
{
if (ttTitle == null || def == null) return;
ttTitle.text = def.DisplayName;
ttDesc.text = def.Description ?? "";
ttStats.text = $"Cooldown: {def.Cooldown:0.#}s";
ttCost.text = "";
}
private void InitializeUI()
{
var doc = GetComponent<UIDocument>();
if (doc == null)
{
Debug.LogError("[HUDController] No UIDocument component found.");
return;
}
var root = doc.rootVisualElement;
if (root == null)
{
Debug.LogError("[HUDController] rootVisualElement is null. " +
"Check that Panel Settings and Source Asset are assigned.");
return;
}
// Cache element references — log a warning for any that are missing
// so UXML/USS mismatches surface immediately.
goldLabel = Require<Label>(root, "gold-label");
waveLabel = Require<Label>(root, "wave-label");
livesLabel = Require<Label>(root, "lives-label");
nextWaveLabel = Require<Label>(root, "next-wave-label");
leakedLabel = Require<Label>(root, "leaked-label");
incomeLabel = Require<Label>(root, "income-label");
waveBuffRow = Require<VisualElement>(root, "wave-buff-row");
playerListContainer = Require<VisualElement>(root, "player-list");
portraitName = Require<Label>(root, "portrait-name");
levelLabel = Require<Label>(root, "level-label");
statLines = Require<VisualElement>(root, "stat-lines");
commandGrid = Require<VisualElement>(root, "command-grid");
actionFrame = Require<VisualElement>(root, "action-frame");
commandTabs = Require<VisualElement>(root, "command-tabs");
tabBuild = Require<Button>(root, "tab-build");
tabPaint = Require<Button>(root, "tab-paint");
if (tabBuild != null) tabBuild.clicked += () => SwitchTab(CommandTab.Build);
if (tabPaint != null) tabPaint.clicked += () => SwitchTab(CommandTab.Paint);
buildProgressContainer = Require<VisualElement>(root, "build-progress");
buildProgressFill = Require<VisualElement>(root, "build-progress-fill");
buildProgressPercent = Require<Label>(root, "build-progress-percent");
ttTitle = Require<Label>(root, "tt-title");
ttDesc = Require<Label>(root, "tt-desc");
ttStats = Require<Label>(root, "tt-stats");
ttCost = Require<Label>(root, "tt-cost");
rejectionLabel = Require<Label>(root, "rejection-label");
spellHotbarFrame = Require<VisualElement>(root, "spell-hotbar-frame");
spellHotbar = Require<VisualElement>(root, "spell-hotbar");
// Map area and its transparent ancestors must not consume pointer
// events so clicks reach the 3D scene underneath. The bottom-ui is now
// a transparent strip too — the *individual frames* are the opaque
// interactive surfaces, so the empty margins on either side click
// through to the world.
SetPickIgnore(root, "hud-root");
SetPickIgnore(root, "main-area");
SetPickIgnore(root, "map-area");
SetPickIgnore(root, "bottom-ui");
if (rejectionLabel != null)
rejectionLabel.pickingMode = PickingMode.Ignore;
// Minimap. The MinimapView owns the two sub-elements (terrain + entity overlay)
// and drives them; we just hand it the host container and the camera controller.
// Bake is deferred until LevelLoader is ready — view tries each frame in Tick().
var minimapContainer = root.Q<VisualElement>("minimap");
if (minimapContainer != null)
{
if (cameraController == null)
Debug.LogWarning("[HUDController] cameraController not assigned. " +
"Click-to-jump and drag-to-pan on the minimap will be disabled.");
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);
// Build the draft overlay (roguelike between-waves draft). Hidden until the
// local player has an active draft.
BuildDraftOverlay(root);
// Build the shared enemy-buff vote overlay. Hidden until a vote is open; never shows
// at the same time as the draft row, since the inter-wave stages run in sequence.
BuildVoteOverlay(root);
// Boss health bar. Shares the top-centre anchor with the draft/vote rows, which is
// safe because a boss is only ever alive once the inter-wave stages have finished.
BuildBossBar(root);
// Chat feed + input. Anchored bottom-left, just above the portrait/bottom-ui bar.
// Player typing toggled with Enter; system messages (e.g. life lost) post via
// ChatService.PostLocalSystem on every peer.
BuildChatPanel(root);
// Gear menu (top-right of the top bar). Built last so its overlay z-orders
// above every other HUD surface. "Return to Title Screen" reuses the same
// disconnect path as the match-end overlay's button.
gameMenu = new GameMenuView(root, Require<Button>(root, "menu-button"),
menuButtonIcon, OnReturnToMainMenuClicked);
// 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).
myPanel = root.panel;
s_hudPanel = myPanel;
uiInitialized = true;
}
private void Awake()
{
Instance = this;
}
private void OnEnable()
{
TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage;
WaveManager.OnLifeLost += HandleLifeLost;
WaveManager.OnTowersWiped += HandleTowersWiped;
ChatService.OnMessageReceived += HandleChatMessage;
// Try to subscribe now; if SelectionState.Awake hasn't run yet (Unity does
// not guarantee Awake/OnEnable ordering across objects), Start will retry.
TrySubscribeSelection();
}
private void OnDisable()
{
TowerPlacementController.OnRejectionMessageReady -= ShowRejectionMessage;
WaveManager.OnLifeLost -= HandleLifeLost;
WaveManager.OnTowersWiped -= HandleTowersWiped;
ChatService.OnMessageReceived -= HandleChatMessage;
if (selectionSubscribed && SelectionState.Instance != null)
{
SelectionState.Instance.OnSelectionChanged -= HandleSelectionChanged;
selectionSubscribed = false;
}
if (matchStateSubscribed && MatchState.Instance != null)
{
MatchState.Instance.OnPhaseChanged -= HandlePhaseChanged;
matchStateSubscribed = false;
}
if (deckSubscribed && subscribedDeck != null)
{
subscribedDeck.OnDeckChanged -= HandleDeckChanged;
}
deckSubscribed = false;
subscribedDeck = null;
if (upgradesSubscribed && subscribedUpgrades != null)
{
subscribedUpgrades.OnUpgradesChanged -= HandleDeckChanged;
}
upgradesSubscribed = false;
subscribedUpgrades = null;
if (draftSubscribed && subscribedDraft != null)
{
subscribedDraft.OnDraftChanged -= HandleDraftChanged;
}
draftSubscribed = false;
subscribedDraft = null;
if (voteSubscribed && subscribedVote != null)
{
subscribedVote.OnVoteChanged -= HandleVoteChanged;
}
voteSubscribed = false;
subscribedVote = null;
if (spellLoadoutSubscribed && subscribedSpellLoadout != null)
{
subscribedSpellLoadout.OnLoadoutChanged -= HandleSpellLoadoutChanged;
}
spellLoadoutSubscribed = false;
subscribedSpellLoadout = null;
}
private void TrySubscribeSelection()
{
if (selectionSubscribed) return;
if (SelectionState.Instance == null) return;
SelectionState.Instance.OnSelectionChanged += HandleSelectionChanged;
selectionSubscribed = true;
}
private void Update()
{
if (!uiInitialized) return;
if (!placementManagerReady)
TryReadyPlacementManager();
if (!matchStateSubscribed)
TrySubscribeMatchState();
if (!deckSubscribed)
TrySubscribeDeck();
if (!upgradesSubscribed)
TrySubscribeUpgrades();
if (!draftSubscribed)
TrySubscribeDraft();
if (!voteSubscribed)
TrySubscribeVote();
if (!spellLoadoutSubscribed)
TrySubscribeSpellLoadout();
RefreshGoldDisplay();
RefreshMatchStateDisplays();
UpdateBuildProgressIfShown();
UpdateEnemyInfoIfShown();
UpdateDraftVisibility();
UpdateSpellCooldowns();
HandleGameMenuInput();
// Chat and gameplay hotkeys are both off-limits while the menu is up —
// Enter shouldn't pop chat open behind the overlay, and Q/W/E/R shouldn't
// start a tower placement the player can't see.
if (!IsModalUiOpen)
{
HandleChatInput();
// Skip gameplay hotkeys while the chat input is focused — letters
// typed into chat should not also fire Q/W/E/R tower builds.
if (!IsTextInputActive)
HandleHotkeys();
}
minimapView?.Tick();
}
/// <summary>
/// While a BuildSiteVisual is selected, refresh the progress bar's fill
/// width and the percent label. Runs every frame so the bar tracks server
/// time as construction advances. Cheap enough not to throttle (one width
/// assignment, one string allocation per frame while selected).
/// </summary>
private void UpdateBuildProgressIfShown()
{
if (buildProgressContainer == null) return;
var sel = SelectionState.Instance?.SelectedObject;
// Use Unity's overloaded equality via cast — a destroyed BuildSiteVisual
// still passes `is` but blows up on member access. Match the same
// backstop pattern SelectionVisualizer uses.
if (sel is BuildSiteVisual bsv && (UnityEngine.Object)bsv != null)
{
float progress = bsv.ComputeProgressNormalized();
if (buildProgressFill != null)
{
// Width is a percent of the parent bar background; multiplying
// by 100 keeps the StyleLength in percent units.
buildProgressFill.style.width =
new StyleLength(new Length(progress * 100f, LengthUnit.Percent));
}
if (buildProgressPercent != null)
{
buildProgressPercent.text = $"{Mathf.RoundToInt(progress * 100f)}%";
}
}
}
// ----- Gear menu ---------------------------------------------------
/// <summary>
/// Escape handling for the gear menu, plus publishing <see cref="IsModalUiOpen"/>.
/// </summary>
/// <remarks>
/// <para><b>Escape is shared.</b> Five other systems already consume Escape
/// (cancel placement, cancel paint, cancel spell aim, close chat, clear selection),
/// and Unity gives no ordering guarantee between their Update calls. Rather than
/// introduce a cross-component arbiter, the menu claims Escape only when nothing
/// else has anything to cancel — a predicate over state that doesn't change during
/// the frame, so the outcome is the same regardless of which Update ran first.</para>
///
/// <para>The player-visible result is the familiar RTS stack: Escape backs out of
/// placement / paint / aiming / typing first, then clears the selection, then opens
/// the menu. While the menu is open Escape backs out one page at a time and
/// <see cref="IsModalUiOpen"/> keeps the gameplay handlers from seeing the key at
/// all — so the transition frame is the only one where ordering could matter, and
/// on that frame the other systems are by definition idle.</para>
/// </remarks>
private void HandleGameMenuInput()
{
if (gameMenu == null) return;
IsModalUiOpen = gameMenu.IsOpen;
var kb = Keyboard.current;
if (kb == null || !kb.escapeKey.wasPressedThisFrame) return;
if (gameMenu.IsOpen)
{
gameMenu.Back();
}
else if (!EscapeClaimedByGameplay())
{
gameMenu.Open();
}
IsModalUiOpen = gameMenu.IsOpen;
}
// True when some other system will act on this frame's Escape press. Spell aiming
// isn't checked directly (BuilderSpellCastController isn't referenced here) because
// it requires the local builder to be selected, which the selection check already
// covers.
private bool EscapeClaimedByGameplay()
{
if (IsTextInputActive || chatInputOpen) return true;
if (placementController != null && placementController.IsPlacing) return true;
if (paintController != null && paintController.IsPainting) return true;
return SelectionState.Instance?.SelectedObject != null;
}
/// <summary>
/// Reads raw keyboard state via the New Input System and fires the matching
/// action for any bound hotkey pressed this frame. Mirrors the disabled-button
/// behaviour: a SetEnabled(false) button is skipped (so Upgrade/Sell don't
/// trigger from the keyboard while their backing systems are still stubbed).
/// </summary>
private void HandleHotkeys()
{
var kb = Keyboard.current;
if (kb == null) return;
// Iterate by index — foreach over a struct list would copy each entry.
for (int i = 0; i < hotkeyBindings.Count; i++)
{
var binding = hotkeyBindings[i];
if (!kb[binding.Key].wasPressedThisFrame) continue;
if (binding.Button == null || !binding.Button.enabledSelf) continue;
binding.Action?.Invoke();
}
}
private void OnDestroy()
{
if (Instance == this) Instance = null;
// The menu dies with the HUD (scene change / disconnect). Clear the static so a
// stale "modal open" can't gate input in the next scene.
IsModalUiOpen = false;
gameMenu = null;
minimapView?.Dispose();
minimapView = null;
if (s_hudPanel != null && s_hudPanel == myPanel)
s_hudPanel = null;
myPanel = null;
}
// ----- Gold display -----------------------------------------------
private void RefreshGoldDisplay()
{
if (goldLabel == null) return;
var gm = PlayerGoldManager.Local;
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;
var wm = WaveManager.Instance;
if (livesLabel != null)
livesLabel.text = ms != null ? $"lives: {ms.Lives}" : "lives: --";
RefreshBossBar();
// Run progress reads "Phase 1 · Cycle 2 · Wave 3/5" (or "· BOSS"). RunState builds
// the string since it owns the phase/cycle/slot maths; the HUD just displays it.
if (waveLabel != null)
{
var run = RunState.Instance;
waveLabel.text = run != null && ms != null && ms.CurrentWave > 0
? run.ProgressLabel
: "Wave --";
}
RefreshWaveBuffRow();
// Inter-wave countdown. One timer serves all three stages (they never overlap), so
// the label is prefixed with which stage is running — "draft: 0:12", "vote: 0:08",
// "build: 0:15". Clears the moment the wave starts spawning. WaveManager.PrepCountdown
// is networked so this reads the same value on every peer.
if (nextWaveLabel != null)
{
float t = wm != null ? wm.PrepCountdown : 0f;
if (t > 0f)
{
string stage = wm.CurrentInterWaveStage switch
{
InterWaveStage.Draft => "draft",
InterWaveStage.Vote => "vote",
InterWaveStage.Build => "build",
_ => "next",
};
// Ceiling so the user sees a full "0:01" tick before "0:00".
int seconds = Mathf.CeilToInt(t);
int mm = seconds / 60;
int ss = seconds % 60;
nextWaveLabel.text = $"{stage}: {mm}:{ss:00}";
}
else
{
nextWaveLabel.text = "next: --:--";
}
}
// Top-bar "earned this wave" counter. Reads the LOCAL player's
// PlayerGoldManager.GoldEarnedThisWave — which the server resets to 0 at
// wave start and increments via AwardGold on every kill / completion /
// no-leak bonus. Spending doesn't decrement it.
if (incomeLabel != null)
{
var localGold = PlayerGoldManager.Local;
int earned = localGold != null ? localGold.GoldEarnedThisWave : 0;
incomeLabel.text = $"+{earned} g/wave";
}
// Local player's origin-leak count: how many enemies that spawned in MY
// zone escaped my maze. Resolves the local PlayerMatchState's slot then
// reads the per-slot counter from WaveManager (replicated NetworkList).
if (leakedLabel != null)
{
var local = PlayerMatchState.Local;
int leaks = 0;
if (wm != null && local != null && local.Slot != PlayerSlot.None)
leaks = wm.GetZoneLeakCount(local.Slot);
leakedLabel.text = $"leaked: {leaks}";
}
// Right-panel scoreboard rebuild — see RefreshScoreboard.
RefreshScoreboard();
}
// ----- Wave buff badges --------------------------------------------
/// <summary>
/// Rebuilds the top-bar badges showing which buffs the wave currently spawning carries.
/// </summary>
/// <remarks>
/// <b>Reads the same slot the spawn path does.</b> The source is
/// <c>RunState.CurrentUpgradeSlot</c> — exactly what <c>WaveManager.ResolveCurrentWaveAbilities</c>
/// uses to decide what the enemies actually get. Coupling them to one accessor means the
/// display can't drift from the truth: if they ever disagree that's a run-state bug, not a
/// HUD bug.
///
/// <para>Purely client-side — <c>RunState</c>'s upgrade list is a replicated NetworkList,
/// so every peer resolves the same icons without an RPC.</para>
/// </remarks>
private void RefreshWaveBuffRow()
{
if (waveBuffRow == null) return;
var run = RunState.Instance;
var pool = EnemyUpgradePool.Instance;
if (run == null || pool == null)
{
if (waveBuffSignature != int.MinValue)
{
waveBuffRow.Clear();
waveBuffSignature = int.MinValue;
}
return;
}
int slot = run.CurrentUpgradeSlot;
run.GetUpgradesForSlot(slot, waveBuffScratch);
// Order-sensitive hash of slot + ids. Buffs are appended in vote order and never
// removed, so this changes on exactly the two events that matter: the run advancing
// to a different wave, and a new buff landing on this one.
int signature = slot * 397;
for (int i = 0; i < waveBuffScratch.Count; i++)
signature = signature * 31 + waveBuffScratch[i];
if (signature == waveBuffSignature) return;
waveBuffSignature = signature;
waveBuffRow.Clear();
foreach (int id in waveBuffScratch)
{
var option = pool.Get(id);
if (option == null) continue;
waveBuffRow.Add(CreateWaveBuffBadge(option));
}
}
// One badge. Falls back to the card's initial when no icon is authored, so an
// unillustrated buff still shows up and can be counted rather than silently vanishing.
private VisualElement CreateWaveBuffBadge(EnemyUpgradeOption option)
{
var sprite = option.ResolveIcon();
VisualElement badge;
if (sprite != null)
{
badge = new Image { sprite = sprite };
}
else
{
string name = option.DisplayName;
badge = new Label(string.IsNullOrEmpty(name) ? "?" : name.Substring(0, 1).ToUpperInvariant());
badge.AddToClassList("wave-buff-fallback");
}
badge.AddToClassList("wave-buff-icon");
// Hovering names the buff. Without this the row is a puzzle — players see three
// unfamiliar glyphs and no way to learn what they mean mid-wave.
badge.tooltip = string.IsNullOrEmpty(option.Description)
? option.DisplayName
: $"{option.DisplayName}\n{option.Description}";
return badge;
}
// ----- Scoreboard --------------------------------------------------
// Snapshot of last-rebuilt scoreboard state so we only rebuild when something
// changes. Without this we'd destroy and recreate every row every frame —
// wasteful and (in line with LobbyController's player-list pattern) would
// also break any per-row pointer interaction we might add later.
private string lastScoreboardSignature = string.Empty;
private void RefreshScoreboard()
{
if (playerListContainer == null) return;
// Sort by slot for stable ordering. Counter-intuitively the underlying
// collection isn't slot-ordered (it's keyed by NGO clientId).
var players = new System.Collections.Generic.List<PlayerMatchState>();
foreach (var pms in PlayerMatchState.AllPlayers) players.Add(pms);
players.Sort((a, b) => ((int)a.Slot).CompareTo((int)b.Slot));
// Build a signature of everything the rendered rows depend on. Skip the
// rebuild when nothing has changed.
string sig = ComputeScoreboardSignature(players);
if (sig == lastScoreboardSignature) return;
lastScoreboardSignature = sig;
playerListContainer.Clear();
if (players.Count == 0)
{
var emptyLabel = new Label("(no players)");
emptyLabel.style.color = new Color(0.6f, 0.6f, 0.6f);
emptyLabel.style.unityFontStyleAndWeight = FontStyle.Italic;
playerListContainer.Add(emptyLabel);
return;
}
var wm = WaveManager.Instance;
foreach (var pms in players)
{
playerListContainer.Add(BuildScoreboardRow(pms, wm));
}
}
private VisualElement BuildScoreboardRow(PlayerMatchState pms, WaveManager wm)
{
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.alignItems = Align.Center;
row.style.marginBottom = 2;
row.style.paddingLeft = 4;
row.style.paddingRight = 4;
// Layout model: three columns with explicit widths/flex so all three are
// guaranteed visible inside the right panel. Name has flexGrow:1 so it
// takes leftover space; gold and leaks have fixed widths and right-align
// their text so the numbers line up vertically across rows. The previous
// layout used flexGrow on name with justify-content space-between, which
// pushed the leaks column off the panel's right edge on narrow widths.
string name = string.IsNullOrEmpty(pms.DisplayName)
? $"P{(int)pms.Slot}"
: pms.DisplayName;
var nameLabel = new Label(name);
// Tint with the canonical player-slot color — same palette used by
// builders, minimap icons, and zone outlines for consistent identity.
// Colors were tuned for gizmos on Unity's gray scene view; they're still
// legible on the dark panel for all slots except P9 (dark gray), which is
// intentionally subdued relative to the others.
nameLabel.style.color = PlayerColors.Get(pms.Slot);
nameLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
nameLabel.style.fontSize = 10;
nameLabel.style.whiteSpace = WhiteSpace.NoWrap; // keep name on one line
nameLabel.style.flexGrow = 1; // takes all leftover width
nameLabel.style.flexShrink = 1;
nameLabel.style.overflow = Overflow.Hidden;
nameLabel.style.textOverflow = TextOverflow.Ellipsis;
row.Add(nameLabel);
// Gold column. Read from PlayerGoldManager by clientId. May be null briefly
// during spawn races; render "--" in that case rather than crashing.
// Width 46px: right-aligned numbers; fits 4-digit gold totals at 10px font.
var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId);
string goldText = gm != null ? $"{gm.CurrentGold}" : "--";
var goldLabel = new Label(goldText);
goldLabel.style.color = new Color(1f, 0.85f, 0.35f); // gold-y
goldLabel.style.fontSize = 10;
goldLabel.style.width = 46;
goldLabel.style.flexShrink = 0;
goldLabel.style.unityTextAlign = TextAnchor.MiddleRight;
row.Add(goldLabel);
// Leaks column. Same NetworkList the local player's "leaked: N" top-bar
// label reads — keeps the two views in sync. Always rendered (including 0)
// so designers can see at a glance whether a player has clean runs so far.
// Width 30px: right-aligned; 2-digit leak counts fit comfortably at 10px font.
int leaks = (wm != null && pms.Slot != PlayerSlot.None)
? wm.GetZoneLeakCount(pms.Slot)
: 0;
var leaksLabel = new Label($"{leaks}");
leaksLabel.style.color = leaks == 0
? new Color(0.55f, 0.85f, 0.55f)
: new Color(0.95f, 0.6f, 0.4f);
leaksLabel.style.fontSize = 10;
leaksLabel.style.width = 30;
leaksLabel.style.flexShrink = 0;
leaksLabel.style.unityTextAlign = TextAnchor.MiddleRight;
row.Add(leaksLabel);
return row;
}
// Components: ordered slot sequence + their name / gold / leaks. Any change
// triggers a rebuild. Slot count itself rarely changes mid-match (joins are
// gated by the lobby), but the values do.
private static readonly System.Text.StringBuilder s_scoreboardSigBuf =
new System.Text.StringBuilder(64);
private string ComputeScoreboardSignature(System.Collections.Generic.List<PlayerMatchState> players)
{
s_scoreboardSigBuf.Clear();
var wm = WaveManager.Instance;
foreach (var pms in players)
{
var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId);
int gold = gm != null ? gm.CurrentGold : 0;
int leaks = (wm != null && pms.Slot != PlayerSlot.None)
? wm.GetZoneLeakCount(pms.Slot)
: 0;
s_scoreboardSigBuf.Append((int)pms.Slot).Append(':')
.Append(pms.DisplayName ?? string.Empty).Append(':')
.Append(gold).Append(':')
.Append(leaks).Append(';');
}
return s_scoreboardSigBuf.ToString();
}
// ----- Command grid -----------------------------------------------
private const int GRID_COLS = 5;
private const int GRID_ROWS = 3;
private const int GRID_MAX = GRID_COLS * GRID_ROWS;
// First-time check that TowerPlacementManager exists. Once ready, populates the
// grid for the current selection. Subsequent populates flow through
// HandleSelectionChanged → PopulateGridForSelection.
private void TryReadyPlacementManager()
{
if (placementManager == null)
placementManager = TowerPlacementManager.Instance;
if (placementManager == null) return; // not spawned yet — retry next frame
placementManagerReady = true;
PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
}
/// <summary>
/// Rebuilds the command grid based on the current selection AND toggles the
/// action frame visibility. Builder → tower-build buttons in the early slots.
/// Tower → Upgrade in slot 0, Sell in the last slot. Anything else (Enemy,
/// null) → action frame hidden entirely (display:none).
/// </summary>
private void PopulateGridForSelection(ISelectable selection)
{
if (commandGrid == null) return;
if (!placementManagerReady) return; // deferred to TryReadyPlacementManager
// Selection changed — invalidate the previous frame's hotkey bindings
// before creating new ones. Without this, stale buttons from a previous
// selection would keep responding to their hotkey.
hotkeyBindings.Clear();
// Decide whether any actions exist for this selection. The action frame
// is hidden entirely when there are none — matches the WC3-style UX.
bool hasActions = selection is Builder
|| selection is TowerInstance
|| selection is BuildSiteVisual;
if (actionFrame != null)
actionFrame.style.display = hasActions ? DisplayStyle.Flex : DisplayStyle.None;
// Build/Paint tabs only make sense for the builder's build menu. For any
// other selection (or none), reset to the Build tab and exit paint mode so
// the cursor never gets stuck as a brush after deselecting the builder.
bool isBuilder = selection is Builder;
if (!isBuilder)
{
activeTab = CommandTab.Build;
paintController?.CancelPaint();
}
if (commandTabs != null)
commandTabs.style.display = isBuilder ? DisplayStyle.Flex : DisplayStyle.None;
RefreshTabActiveState();
commandGrid.Clear();
if (!hasActions) return; // grid stays empty; frame is hidden anyway
// Build the 15-cell action layout for the active selection kind.
var cells = new VisualElement[GRID_MAX];
if (selection is Builder)
{
if (activeTab == CommandTab.Paint)
{
// Paint swatches: Red/Green/Blue then a Reset (None) brush, mapped to
// the Q/W/E/R hotkey slots via the shared CreateActionButton wiring.
cells[0] = CreatePaintButton(PaintColor.Red, HotkeyLayout[0]);
cells[1] = CreatePaintButton(PaintColor.Green, HotkeyLayout[1]);
cells[2] = CreatePaintButton(PaintColor.Blue, HotkeyLayout[2]);
cells[3] = CreatePaintButton(PaintColor.None, HotkeyLayout[3]);
}
else
{
// Build buttons come from the LOCAL player's deck — the per-player
// unlocked set — not the global catalog. Each deck entry is a
// TowerTypeId resolved back to its definition via the catalog.
var deck = PlayerTowerDeck.Local;
if (deck != null)
{
int count = Mathf.Min(deck.Count, GRID_MAX);
for (int i = 0; i < count; i++)
{
int typeId = deck.GetTypeIdAt(i);
var def = TowerPlacementManager.GetDefinition(typeId);
if (def != null)
cells[i] = CreateTowerButton(def, typeId, HotkeyLayout[i]);
}
}
}
}
else if (selection is TowerInstance tower)
{
// WC3 layout convention: primary actions top-left (Q onward), sell bottom-right.
// One button per unlocked upgrade branch rather than a single generic "Upgrade" —
// a tree node can have several children, and a lone button couldn't express the
// choice between them without an extra submenu layer.
upgradeOptionScratch.Clear();
var localClientId = NetworkManager.Singleton != null
? NetworkManager.Singleton.LocalClientId
: 0UL;
bool ownsTower = PlayerMatchState.Local != null
&& tower.Owner == PlayerMatchState.Local.Slot;
if (ownsTower)
tower.CollectAvailableUpgrades(localClientId, upgradeOptionScratch);
int upgradeSlots = Mathf.Min(upgradeOptionScratch.Count, GRID_MAX - 1);
for (int i = 0; i < upgradeSlots; i++)
{
var (def, typeId) = upgradeOptionScratch[i];
cells[i] = CreateUpgradeButton(tower, def, typeId, HotkeyLayout[i]);
}
cells[GRID_MAX - 1] = CreateSellButton(tower, HotkeyLayout[GRID_MAX - 1]);
}
else if (selection is BuildSiteVisual bsv)
{
// Cancel is the only action available on an in-progress build.
// Placed at top-left (Q) — primary slot for a single-action menu.
cells[0] = CreateCancelButton(bsv, HotkeyLayout[0]);
}
// Remaining cells become empty slots so the 5×3 grid layout is preserved.
for (int i = 0; i < GRID_MAX; i++)
{
if (cells[i] == null) cells[i] = CreateEmptySlot();
}
for (int row = 0; row < GRID_ROWS; row++)
{
var rowEl = new VisualElement();
rowEl.AddToClassList("cmd-row");
for (int col = 0; col < GRID_COLS; col++)
rowEl.Add(cells[row * GRID_COLS + col]);
commandGrid.Add(rowEl);
}
}
// Tower button: clicking begins placement; hovering drives the Tool Tip.
private VisualElement CreateTowerButton(TowerDefinition def, int typeId, Key hotkey)
{
var btn = CreateActionButton(
costText: $"{def.GoldCost}g",
hotkey: hotkey,
onClick: () =>
{
if (placementController != null)
placementController.BeginPlacement(def, typeId);
else
Debug.LogWarning("[HUDController] No TowerPlacementController assigned.");
});
// Show the tower's thumbnail on the icon placeholder. Falls back to the grey
// placeholder square when no Icon is assigned (see CreatePaintButton for the pattern).
if (def.Icon != null)
{
var icon = btn.Q<VisualElement>(null, "cmd-icon-placeholder");
if (icon != null)
icon.style.backgroundImage = new StyleBackground(def.Icon);
}
btn.RegisterCallback<MouseEnterEvent>(_ => ShowTooltip(def));
btn.RegisterCallback<MouseLeaveEvent>(_ => ClearTooltip());
return btn;
}
// ----- Build/Paint tabs -------------------------------------------
// Switch the active command-grid tab and rebuild for the current selection.
// Leaving the Paint tab also exits paint mode so the cursor reverts.
private void SwitchTab(CommandTab tab)
{
if (tab == CommandTab.Build)
paintController?.CancelPaint();
activeTab = tab;
PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
}
private void RefreshTabActiveState()
{
tabBuild?.EnableInClassList("active", activeTab == CommandTab.Build);
tabPaint?.EnableInClassList("active", activeTab == CommandTab.Paint);
}
// Paint swatch: clicking enters paint mode with that color (None = Reset brush).
// Built on CreateActionButton so the Q/W/E/R hotkey wiring comes for free; the
// icon placeholder is tinted to the swatch color.
private VisualElement CreatePaintButton(PaintColor color, Key hotkey)
{
var btn = CreateActionButton(
costText: "",
hotkey: hotkey,
onClick: () =>
{
if (paintController != null)
paintController.BeginPaint(color);
else
Debug.LogWarning("[HUDController] No TowerPaintController assigned.");
});
var icon = btn.Q<VisualElement>(null, "cmd-icon-placeholder");
if (icon != null)
icon.style.backgroundColor = PaintColors.Get(color);
string label = color == PaintColor.None ? "Reset" : color.ToString();
btn.RegisterCallback<MouseEnterEvent>(_ => ShowPaintTooltip(label, color));
btn.RegisterCallback<MouseLeaveEvent>(_ => ClearTooltip());
return btn;
}
// Lightweight tooltip for paint swatches — reuses the tooltip box (title + desc).
private void ShowPaintTooltip(string label, PaintColor color)
{
if (ttTitle == null) return;
ttTitle.text = label;
ttDesc.text = color == PaintColor.None
? "Clear paint — revert tower to its owner color."
: "Paint your towers this color.";
ttStats.text = "";
ttCost.text = "";
}
private static VisualElement CreateEmptySlot()
{
var slot = new VisualElement();
slot.AddToClassList("cmd-btn");
slot.AddToClassList("empty-slot");
slot.pickingMode = PickingMode.Ignore;
return slot;
}
/// <summary>
/// Builds the standard action button: a Button containing a centered icon
/// placeholder, plus optional hotkey badge (top-left) and cost badge
/// (bottom-left). Both badges are pure visual overlays — click events still
/// reach the Button. The hotkey, when non-None, is registered in
/// <see cref="hotkeyBindings"/> so Update can fire <paramref name="onClick"/>
/// on keypress (gated on the button's <c>enabledSelf</c>).
/// </summary>
private Button CreateActionButton(string costText, Key hotkey, System.Action onClick)
{
var btn = new Button(() => onClick?.Invoke());
btn.AddToClassList("cmd-btn");
// Icon — added FIRST so it sits underneath the absolute-positioned badges.
var iconPlaceholder = new VisualElement();
iconPlaceholder.AddToClassList("cmd-icon-placeholder");
iconPlaceholder.pickingMode = PickingMode.Ignore;
btn.Add(iconPlaceholder);
// Hotkey badge — top-left.
if (hotkey != Key.None)
{
var hkLabel = new Label(KeyToDisplay(hotkey));
hkLabel.AddToClassList("cmd-hotkey");
hkLabel.pickingMode = PickingMode.Ignore;
btn.Add(hkLabel);
hotkeyBindings.Add(new HotkeyBinding(hotkey, btn, onClick));
}
// Cost badge — bottom-left. Omitted when no cost is meaningful (e.g., Upgrade
// before the upgrade system has a tier-cost lookup).
if (!string.IsNullOrEmpty(costText))
{
var costLabel = new Label(costText);
costLabel.AddToClassList("cmd-cost");
costLabel.pickingMode = PickingMode.Ignore;
btn.Add(costLabel);
}
return btn;
}
// One button per unlocked upgrade branch on the selected tower. A tree node can have
// several children, so a single generic "Upgrade" button couldn't express the choice
// between them without an extra submenu layer — the grid already has the slots.
private VisualElement CreateUpgradeButton(TowerInstance tower, TowerDefinition target,
int targetTypeId, Key hotkey)
{
int cost = TowerInstance.GetUpgradeCost(target);
var gold = PlayerGoldManager.Local;
bool affordable = gold != null && gold.CurrentGold >= cost;
var btn = CreateActionButton(
costText: cost > 0 ? $"{cost}g" : "",
hotkey: hotkey,
onClick: () => tower.RequestUpgradeServerRpc(targetTypeId));
if (target.Icon != null)
{
var img = new Image { sprite = target.Icon };
img.AddToClassList("cmd-icon");
img.pickingMode = PickingMode.Ignore;
btn.Insert(0, img);
}
btn.tooltip = $"Upgrade to {target.DisplayName}";
// Shown but disabled when unaffordable, rather than hidden — a player saving toward
// an upgrade should be able to see the price. Disabling also makes the hotkey handler
// skip it, since that gates on enabledSelf.
btn.SetEnabled(affordable);
return btn;
}
// Sell action for a completed tower. Refund is computed on the TowerInstance (single
// source of truth shared with the server) so the badge shows exactly what the player
// gets back. The button stays click-only (Key.None).
private VisualElement CreateSellButton(TowerInstance tower, Key hotkey)
{
// Only the tower's owner can sell it (the server enforces this too). A non-owner
// may have this tower view-selected — show the slot disabled with no refund badge
// so the action reads as unavailable rather than misleading.
bool ownedByLocal = tower != null
&& PlayerMatchState.Local != null
&& tower.Owner == PlayerMatchState.Local.Slot;
int sellValue = ownedByLocal ? tower.ComputeSellRefund() : 0;
var btn = CreateActionButton(
costText: sellValue > 0 ? $"+{sellValue}g" : "",
hotkey: Key.None,
onClick: () =>
{
if (tower != null)
tower.RequestSellServerRpc();
});
btn.SetEnabled(ownedByLocal);
return btn;
}
// Cancel action for an in-progress build. Fires the owner-only RPC; the
// server cancels the matching job (or, for shelved sites, refunds + despawns
// directly), full gold is refunded, the BuildSiteVisual is despawned, and
// OnSelectionChanged fires with null — HUD/visualizer/ring all clear
// automatically.
private VisualElement CreateCancelButton(BuildSiteVisual bsv, Key hotkey)
{
int refund = bsv.GoldSpent;
return CreateActionButton(
costText: refund > 0 ? $"+{refund}g" : "",
hotkey: hotkey,
onClick: () => bsv.RequestCancelRpc());
}
// Renders a Key as a single-character badge ("Q", "1", etc.). Letter and number
// keys produce their own glyph via ToString; if we ever bind non-letter keys
// (e.g., F1 or Space), extend this with a mapping table.
private static string KeyToDisplay(Key key) => key.ToString();
// ----- Portrait / selection context ----------------------------------
/// <summary>
/// Called by the selection system when a unit is selected or deselected.
/// Pass null or empty to clear (nothing selected).
/// </summary>
public void SetSelectedUnitName(string unitName)
{
if (portraitName == null) return;
portraitName.text = string.IsNullOrEmpty(unitName) ? "" : unitName;
}
private void HandleSelectionChanged(ISelectable selection)
{
// Sections 2/3/4 (portrait, info, tooltip) stay visible regardless;
// their *contents* update based on selection. Section 5 (action menu)
// hides via PopulateGridForSelection when there are no actions.
PopulateInfoPanel(selection);
PopulateGridForSelection(selection);
// Section 6 (spell hotbar) follows selection: show the selected builder's spells,
// hide entirely when the selection isn't a builder.
RebuildSpellHotbar();
}
/// <summary>
/// Drives the portrait name (section 3 header), level (section 2 footer),
/// contextual stat lines (section 3 body), and the build-progress sub-view
/// (section 3 — shown only when a BuildSiteVisual is selected).
/// </summary>
private void PopulateInfoPanel(ISelectable selection)
{
// Name
SetSelectedUnitName(selection?.DisplayName);
// Level — placeholder until upgrade system lands.
// Builder → "Lv. 1" (per design; may carry experience later)
// Tower → "Lv. 1" (will reflect tower upgrade tier when implemented)
// Nothing → blank
if (levelLabel != null)
levelLabel.text = selection != null ? "Lv. 1" : "";
// Build-progress block visibility: shown only for BuildSiteVisual.
// Stat lines remain underneath and just stay empty for that case.
bool isBuildSite = selection is BuildSiteVisual;
if (buildProgressContainer != null)
buildProgressContainer.style.display =
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();
if (selection is Builder builder)
{
AddStatLine($"Build range: {builder.BuildRange:0.0}");
}
else if (selection is TowerInstance tower)
{
var def = tower.Definition;
if (def != null)
{
if (def.Damage > 0) AddStatLine($"Damage: {def.Damage}");
if (def.Range > 0) AddStatLine($"Range: {def.Range:0.0}");
if (def.AttacksPerSecond > 0) AddStatLine($"Fire rate: {def.AttacksPerSecond:0.0}/s");
if (def.SlowFactor < 1f)
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}");
// Bounty is per-encounter now (GoldConfig.Encounters[N].GoldPerEnemy) rather than
// per-enemy-type. Read the current encounter's value so the tooltip is accurate.
var wm = WaveManager.Instance;
var goldEntry = wm?.GoldConfig?.GetEncounterEntry(wm.CurrentEncounterNumber);
if (goldEntry != null)
AddStatLine($"Bounty: {goldEntry.GoldPerEnemy} 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);
label.AddToClassList("stat-line");
statLines.Add(label);
}
// ----- Tooltip ----------------------------------------------------
private void ShowTooltip(TowerDefinition def)
{
if (ttTitle == null) return;
ttTitle.text = def.DisplayName;
ttDesc.text = def.Description ?? "";
if (def.Damage > 0 || def.Range > 0)
{
ttStats.text = $"DMG: {def.Damage} · Range: {def.Range:0}";
if (def.AttacksPerSecond > 0)
ttStats.text += $"\nFire rate: {def.AttacksPerSecond:0.0}/s";
if (def.SlowFactor < 1f)
ttStats.text += $"\nSlow: {(1f - def.SlowFactor) * 100f:0}%";
}
else
{
ttStats.text = "(stats pending)";
}
// Sell preview for a freshly placed (un-upgraded) tower: mirrors
// TowerInstance.ComputeSellRefund for invested == GoldCost, upgradeCount == 0.
int sellValue = def.FullRefundIfUnupgraded
? def.GoldCost
: Mathf.RoundToInt(def.GoldCost * def.SellRefundPercent);
ttCost.text = $"Cost: {def.GoldCost}g · Sell: {sellValue}g";
}
private void ClearTooltip()
{
if (ttTitle == null) return;
ttTitle.text = "";
ttDesc.text = "";
ttStats.text = "";
ttCost.text = "";
}
// ----- Rejection messages -----------------------------------------
private void ShowRejectionMessage(string message)
{
if (rejectionLabel == null) return;
rejectionLabel.text = message;
rejectionLabel.style.display = DisplayStyle.Flex;
if (rejectionFadeCoroutine != null)
StopCoroutine(rejectionFadeCoroutine);
rejectionFadeCoroutine = StartCoroutine(HideRejectionAfterDelay());
}
private IEnumerator HideRejectionAfterDelay()
{
yield return new WaitForSeconds(rejectionMessageDuration);
if (rejectionLabel != null)
rejectionLabel.style.display = DisplayStyle.None;
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) => CenterCameraOnSelection();
/// <summary>
/// Recenters the camera on the current selection (same behavior as clicking the
/// portrait). No-op when nothing is selected or the camera isn't wired. Also used
/// by the Tab hotkey (select-builder-then-focus) in BuilderInputController.
/// </summary>
public void CenterCameraOnSelection()
{
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);
}
// ----- Chat feed + input -----------------------------------------
// Bottom-left chat panel. Anchored 12px from the left edge, with the
// bottom edge sitting above the 220px bottom-ui. Layout uses a flex
// column: scrollable feed on top, input below. The feed clips at
// chatMaxHeight; messages above that scroll off the top of the visible
// area but stay in history (visible by scrolling once chat is open).
private void BuildChatPanel(VisualElement root)
{
const float bottomUiHeight = 220f;
const float gap = 8f;
const float chatWidth = 380f;
const float inputMinHeight = 32f;
const float inputFeedGap = 6f; // breathing room between feed and input
// Container anchored to the bottom-left, growing upward as content
// grows (no top/height set → height auto-fits content).
chatContainer = new VisualElement();
chatContainer.pickingMode = PickingMode.Ignore; // closed = wheel falls through
chatContainer.style.position = Position.Absolute;
chatContainer.style.left = 12;
chatContainer.style.bottom = bottomUiHeight + gap;
chatContainer.style.width = chatWidth;
chatContainer.style.flexDirection = FlexDirection.Column;
chatContainer.style.paddingTop = 4;
chatContainer.style.paddingBottom = 4;
chatContainer.style.paddingLeft = 6;
chatContainer.style.paddingRight = 6;
// Feed: ScrollView so older messages can scroll off the top of the
// visible window while staying in history. Vertical-only.
chatFeed = new ScrollView(ScrollViewMode.Vertical);
chatFeed.style.maxHeight = chatMaxHeight;
chatFeed.style.flexShrink = 1;
chatFeed.pickingMode = PickingMode.Ignore;
chatFeed.contentViewport.pickingMode = PickingMode.Ignore;
chatFeed.contentContainer.pickingMode = PickingMode.Ignore;
chatFeed.verticalScrollerVisibility = ScrollerVisibility.Hidden;
// Belt-and-suspenders wheel interceptor. The recursive .hierarchy
// pickingMode walk should already prevent wheel events from reaching
// chatFeed when chat is closed; this handler is defense-in-depth
// against future Unity versions reorganizing ScrollView internals.
chatFeed.RegisterCallback<WheelEvent>(evt =>
{
if (!chatInputOpen)
evt.StopImmediatePropagation();
}, TrickleDown.TrickleDown);
// Track-style — make the scrollbar slim and dark so it doesn't compete with messages.
chatFeed.style.marginBottom = inputFeedGap;
chatContainer.Add(chatFeed);
// Input field. Hidden by default. We use minHeight (not a fixed height)
// because the inner unity-text-input element needs vertical padding
// for the font to render fully — pinning to 28px clipped descenders
// and ascenders. We also style the inner child directly because the
// TextField's visible white background lives on it, not on the root.
chatInput = new TextField();
chatInput.style.minHeight = inputMinHeight;
chatInput.style.width = Length.Percent(100);
chatInput.maxLength = 120;
chatInput.style.display = DisplayStyle.None;
chatInput.isDelayed = false;
StyleChatInputDark(chatInput);
// Focus tracking — gameplay input gates on IsTextInputActive.
chatInput.RegisterCallback<FocusInEvent>(_ => IsTextInputActive = true);
chatInput.RegisterCallback<FocusOutEvent>(_ => IsTextInputActive = false);
// Submit (Enter) and cancel (Escape) are handled in Update via
// direct Input System reads — not via UI Toolkit's NavigationSubmit
// event, which fires inconsistently relative to TextField.value
// commit timing. See HandleChatInput.
chatContainer.Add(chatInput);
root.Add(chatContainer);
// Initial state is closed → every chat descendant (including the
// ScrollView's internal scroll-container wrapper) starts Ignore so
// panel.Pick falls through to nothing and the camera owns the wheel.
SetPickingModeRecursive(chatContainer, PickingMode.Ignore);
}
// Recursively sets pickingMode on every visual descendant of root.
//
// IMPORTANT: this uses .hierarchy[i] / .hierarchy.childCount, NOT the
// root[i] indexer. VisualElement has two hierarchies:
//
// * Logical hierarchy (root[i] / root.childCount) — children added by
// user code via Add(). For elements with a redirected contentContainer
// (ScrollView, Foldout, etc.), this only enumerates the user content,
// not the internal scaffolding.
//
// * Visual hierarchy (root.hierarchy[i]) — the actual visual tree
// including internal scaffolding (ScrollView's
// unity-content-and-vertical-scroll-container, viewport, scrollers,
// etc.).
//
// We need the visual hierarchy because the wrapper element
// unity-content-and-vertical-scroll-container is what panel.Pick was
// landing on. Walking the logical hierarchy would visit ScrollView's
// user-added chat lines but skip the internal wrapper entirely.
private static void SetPickingModeRecursive(VisualElement root, PickingMode mode)
{
if (root == null) return;
root.pickingMode = mode;
int count = root.hierarchy.childCount;
for (int i = 0; i < count; i++)
SetPickingModeRecursive(root.hierarchy[i], mode);
}
// The TextField root color isn't where the visible background lives — it's
// on the inner "unity-text-input" element. Style both: the visible
// background goes dark, the text goes white so what the player types is
// legible against it, and the inner element gets a few px of vertical
// padding so characters with ascenders/descenders (capital letters,
// lowercase y/g/p/q/j, "?") don't get clipped.
private static void StyleChatInputDark(TextField field)
{
var darkBg = new Color(0.10f, 0.10f, 0.10f, 0.95f);
var borderClr = new Color(0.45f, 0.45f, 0.5f);
field.style.backgroundColor = darkBg;
field.style.color = Color.white;
field.style.borderTopWidth = field.style.borderBottomWidth =
field.style.borderLeftWidth = field.style.borderRightWidth = 1;
field.style.borderTopColor = field.style.borderBottomColor =
field.style.borderLeftColor = field.style.borderRightColor = borderClr;
// The actual editable area child. It exists immediately on construction.
var inner = field.Q("unity-text-input");
if (inner != null)
{
inner.style.backgroundColor = darkBg;
inner.style.color = Color.white;
inner.style.paddingTop = 4;
inner.style.paddingBottom = 4;
inner.style.paddingLeft = 6;
inner.style.paddingRight = 6;
}
}
// Called every frame from Update. Handles all three chat key bindings
// via direct Input System reads:
// - Enter (chat closed) → open input
// - Enter (chat open) → submit + close
// - Escape (chat open) → close without submit
//
// Reading the Input System directly avoids two UI Toolkit quirks:
// 1. The first Enter inside a focused TextField is sometimes consumed
// by the field's internal edit-mode handler before NavigationSubmit
// can fire, requiring a SECOND Enter to trigger the user-visible
// submit. Going through Keyboard.current sidesteps that pipeline.
// 2. KeyDownEvent / NavigationSubmitEvent fire timing isn't aligned
// with TextField.value commit on every Unity version. Reading the
// Input System happens at a deterministic point in Update where
// TextField.value is already up to date (since isDelayed = false).
private void HandleChatInput()
{
if (chatInput == null) return;
var kb = Keyboard.current;
if (kb == null) return;
// Suppression window covers the frame after open/close so the same
// keypress that toggled chat doesn't immediately fire the opposite path.
if (Time.frameCount <= chatToggleSuppressFrame) return;
bool enterDown = kb.enterKey.wasPressedThisFrame
|| kb.numpadEnterKey.wasPressedThisFrame;
bool escDown = kb.escapeKey.wasPressedThisFrame;
if (!chatInputOpen)
{
if (enterDown && Time.frameCount > SuppressChatOpenUntilFrame) OpenChatInput();
return;
}
// Chat is open.
if (enterDown) SubmitChatInput();
else if (escDown) CloseChatInput(submit: false);
}
private void OpenChatInput()
{
if (chatInput == null) return;
chatInput.style.display = DisplayStyle.Flex;
chatInput.SetValueWithoutNotify(string.Empty);
// Switch the chat panel from "passive display" to "interactive":
// - Container gets a dark translucent background as a visual cue
// AND becomes pointer-active so clicks on the highlight don't
// fall through and place towers / deselect units underneath.
// - Feed becomes pointer-active so wheel events scroll it (and
// so IsPointerOverInteractiveHud picks the chat for camera-gate).
// - Scrollbar becomes visible.
if (chatContainer != null)
chatContainer.style.backgroundColor = new Color(0f, 0f, 0f, 0.40f);
if (chatFeed != null)
chatFeed.verticalScrollerVisibility = ScrollerVisibility.Auto;
// Make EVERY descendant of the chat panel interactive. Setting Position
// recursively covers ScrollView's internal scroll-container wrapper
// (which our previous explicit list of three pickingMode targets
// missed), so panel.Pick will reliably land on a chat element and
// wheel events will reach the ScrollView's manipulator.
SetPickingModeRecursive(chatContainer, PickingMode.Position);
// Suppress Enter for this frame and the next so the keypress that
// opened chat doesn't also submit it empty. Focus is deferred a frame
// for the same reason — UI Toolkit would otherwise route the open-Enter
// to the freshly-focused TextField immediately.
chatToggleSuppressFrame = Time.frameCount + 1;
chatInputOpen = true;
StartCoroutine(FocusChatNextFrame());
}
private IEnumerator FocusChatNextFrame()
{
yield return null;
if (chatInputOpen && chatInput != null)
{
chatInput.Focus();
// Focus alone doesn't always activate the text caret immediately.
// SelectAll forces the field into edit mode reliably across versions.
chatInput.SelectAll();
}
}
private void CloseChatInput(bool submit)
{
if (chatInput == null) return;
if (submit) SubmitChatInput();
RevertChatPanelToPassive();
}
private void SubmitChatInput()
{
string text = chatInput?.value ?? string.Empty;
if (!string.IsNullOrWhiteSpace(text) && ChatService.Instance != null)
ChatService.Instance.SubmitMessage(text);
RevertChatPanelToPassive();
}
// Single source of truth for "chat is no longer in typing mode" — clears the
// input, hides it, restores pickingMode = Ignore on every layer that flipped
// to Position on Open, drops the highlight background, and flips the debug
// border back to red. Both Submit and Close route through here so the two
// close paths can't drift out of sync (which is exactly what caused the
// "border stays green / wheel keeps scrolling" bug).
private void RevertChatPanelToPassive()
{
if (chatInput != null)
{
chatInput.SetValueWithoutNotify(string.Empty);
chatInput.style.display = DisplayStyle.None;
chatInput.Blur();
}
if (chatContainer != null)
chatContainer.style.backgroundColor = StyleKeyword.Initial;
if (chatFeed != null)
chatFeed.verticalScrollerVisibility = ScrollerVisibility.Hidden;
// Same as Open's recursive Position, but Ignore — covers ScrollView's
// internal scroll-container wrapper so panel.Pick falls through to
// whatever's behind chat (which is nothing, so IsPointerOverInteractiveHud
// returns false and the camera processes wheel events normally).
SetPickingModeRecursive(chatContainer, PickingMode.Ignore);
chatInputOpen = false;
chatToggleSuppressFrame = Time.frameCount + 1;
}
// Subscribed to ChatService.OnMessageReceived. Appends a new line to
// the feed, prunes the oldest if we exceed the history cap, and
// auto-scrolls only if the player was already viewing the latest
// messages — preserving their position if they've scrolled up to
// read older history.
private void HandleChatMessage(ChatService.ChatEntry entry)
{
if (chatFeed == null) return;
// Capture "was the user at the bottom" BEFORE we append, while the
// layout still reflects the pre-message state. If they were, we
// re-pin to the new bottom after the message lays out. If they
// weren't (scrolled up reading history), we leave their position
// alone so new messages don't yank them back.
bool wasAtBottom = !chatInputOpen || IsChatScrolledToBottom();
var line = BuildChatLine(entry);
chatFeed.Add(line);
// Bound history. ScrollView.contentContainer is what actually holds
// child elements (the ScrollView root has its own layout chrome).
// Default chatMaxMessages is effectively unlimited so this loop is
// a no-op in normal play; the cap exists as a safety valve.
var content = chatFeed.contentContainer;
while (content.childCount > chatMaxMessages)
content.RemoveAt(0);
if (!wasAtBottom) return;
// Defer scroll-to-bottom until layout has resolved the new line's
// height — without the delay, contentContainer.layout.height is
// stale and we'd scroll to the previous max.
chatFeed.schedule.Execute(() =>
{
if (chatFeed == null) return;
var height = chatFeed.contentContainer.layout.height;
chatFeed.scrollOffset = new Vector2(0, Mathf.Max(0, height));
}).ExecuteLater(1);
}
// True if the feed isn't overflowing yet, or the player's scroll
// position is within a few pixels of the bottom edge of the content.
// Used to decide whether to auto-scroll on new messages.
private bool IsChatScrolledToBottom()
{
if (chatFeed == null) return true;
float viewportHeight = chatFeed.contentViewport.layout.height;
float contentHeight = chatFeed.contentContainer.layout.height;
// Not overflowing → effectively "at the bottom" (everything visible).
if (contentHeight <= viewportHeight + 0.5f) return true;
float maxScroll = contentHeight - viewportHeight;
const float tolerance = 4f;
return chatFeed.scrollOffset.y >= maxScroll - tolerance;
}
private Label BuildChatLine(ChatService.ChatEntry entry)
{
var line = new Label();
line.pickingMode = PickingMode.Ignore;
line.style.marginBottom = 2;
line.style.color = entry.Kind == ChatService.MessageKind.System
? chatSystemColor
: chatPlayerColor;
line.style.whiteSpace = WhiteSpace.Normal; // wrap long messages
// Soft drop shadow so messages stay readable over varied backgrounds.
line.style.textShadow = new TextShadow
{
offset = new Vector2(1, 1),
blurRadius = 2,
color = new Color(0f, 0f, 0f, 0.85f),
};
line.text = entry.Kind == ChatService.MessageKind.System
? entry.Text
: $"[{entry.SenderName}] {entry.Text}";
return line;
}
// Fires every time WaveManager.OnLifeLost is invoked (one event per leak).
// Routes the notification through ChatService as a SYSTEM message so it
// shares the feed with player chat. The lives counter in the top bar
// still updates independently via MatchState replication.
private void HandleLifeLost(int amount)
{
string text = amount == 1 ? "1 Life Lost" : $"{amount} Lives Lost";
ChatService.PostLocalSystem(text);
}
// Fires once when a phase ends and the board is cleared. Uses the same system-message
// channel as life loss so it lands where players are already looking for run events —
// and, more importantly, so an entire maze vanishing has a stated cause.
private void HandleTowersWiped(int destroyedCount)
{
ChatService.PostLocalSystem(destroyedCount == 1
? "Phase complete — 1 tower lost to the collapse. No refunds."
: $"Phase complete — {destroyedCount} towers lost to the collapse. No refunds.");
}
// ----- 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);
// Action row — Retry (back to lobby with everyone who retried)
// and Return to Main Menu (this player only disconnects).
var actionRow = new VisualElement();
actionRow.style.flexDirection = FlexDirection.Row;
actionRow.style.marginTop = 8;
panel.Add(actionRow);
var retryBtn = new Button(OnRetryClicked) { text = "Retry" };
retryBtn.style.minWidth = 140;
retryBtn.style.height = 36;
retryBtn.style.fontSize = 16;
retryBtn.style.marginRight = 12;
actionRow.Add(retryBtn);
var menuBtn = new Button(OnReturnToMainMenuClicked) { text = "Return to Main Menu" };
menuBtn.style.minWidth = 200;
menuBtn.style.height = 36;
menuBtn.style.fontSize = 16;
actionRow.Add(menuBtn);
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.
// Retry: take everyone back to the Lobby scene via LobbyService. The
// lobby preserves race picks, clears ready state. Anyone who clicked
// Return to Main Menu instead has already disconnected — they don't
// come along.
private void OnRetryClicked()
{
var svc = LobbyService.Instance;
if (svc != null)
{
svc.RequestReturnToLobbyRpc();
return;
}
// Fallback: LobbyService isn't spawned (e.g. testing the gameplay
// scene standalone without the lobby flow). Hard-reload the scene.
//
// Do NOT reload via NetworkManager.SceneManager here. Reloading the
// currently-active scene through NGO's networked scene manager in
// Single mode deadlocks: SceneEventProgress unloads the active scene
// then waits on a load-complete ack for the same scene handle it is
// mid-transition on, which never arrives — the main thread hangs
// (beachball). LoadSceneAsHost is safe only because it loads a
// *different* scene (Lobby). With no LobbyService there is no real
// multiplayer session worth preserving, so shut the host down and do
// a plain, non-networked Unity scene reload instead.
Debug.LogWarning("[HUDController] LobbyService not found — falling back to non-networked scene reload.");
string activeScene = SceneManager.GetActiveScene().name;
var nm = NetworkManager.Singleton;
if (nm != null && nm.IsListening)
nm.Shutdown();
SceneManager.LoadScene(activeScene, LoadSceneMode.Single);
}
// Return to Main Menu: disconnect only this player. SessionFlow's
// OnClientDisconnect handler routes us back to MainMenu locally. Other
// peers remain in the match (until the host quits, at which point
// SessionFlow on each remaining client routes them out too).
private void OnReturnToMainMenuClicked()
{
TD.Net.NetworkBootstrap.Disconnect();
}
// ----- Helpers ----------------------------------------------------
private static T Require<T>(VisualElement root, string name) where T : VisualElement
{
var el = root.Q<T>(name);
if (el == null)
Debug.LogWarning($"[HUDController] Element not found: '{name}' ({typeof(T).Name}). " +
$"Check that the name matches the UXML.");
return el;
}
private static void SetPickIgnore(VisualElement root, string name)
{
var el = root.Q<VisualElement>(name);
if (el != null) el.pickingMode = PickingMode.Ignore;
}
}
}