UnityTowerDefense/Assets/_Project/Scripts/UI/HUDController.cs

1431 lines
65 KiB
C#
Raw 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.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
{
// ----- Inspector --------------------------------------------------
[Header("Scene References")]
[Tooltip("The local client's TowerPlacementController.")]
[SerializeField] private TowerPlacementController placementController;
[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("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 portraitName;
private Label levelLabel;
private VisualElement statLines;
private VisualElement commandGrid;
private VisualElement actionFrame; // hidden via display:none when no actions are available
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;
// 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.
public static bool IsTextInputActive { get; private set; }
// ----- State ------------------------------------------------------
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 MinimapView minimapView;
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;
}
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");
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");
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");
// 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);
// 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);
// 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 OnEnable()
{
TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage;
WaveManager.OnLifeLost += HandleLifeLost;
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;
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;
}
}
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();
RefreshGoldDisplay();
RefreshMatchStateDisplays();
UpdateBuildProgressIfShown();
UpdateEnemyInfoIfShown();
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)}%";
}
}
}
/// <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()
{
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;
if (livesLabel != null)
livesLabel.text = ms != null ? $"lives: {ms.Lives}" : "lives: --";
if (waveLabel != null)
{
int total = WaveManager.Instance?.TotalWaves ?? 0;
waveLabel.text = ms != null && ms.CurrentWave > 0 && total > 0
? $"Wave {ms.CurrentWave} / {total}"
: "Wave --";
}
}
// ----- Command grid -----------------------------------------------
private const int GRID_COLS = 5;
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;
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)
{
int i = 0;
foreach (var (def, typeId) in placementManager.GetAvailableDefinitions())
{
if (i >= GRID_MAX) break;
cells[i] = CreateTowerButton(def, typeId, HotkeyLayout[i]);
i++;
}
}
else if (selection is TowerInstance tower)
{
// WC3 layout convention: primary action top-left (Q), sell bottom-right (B).
cells[0] = CreateUpgradeButton(tower, HotkeyLayout[0]);
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.");
});
btn.RegisterCallback<MouseEnterEvent>(_ => ShowTooltip(def));
btn.RegisterCallback<MouseLeaveEvent>(_ => ClearTooltip());
return btn;
}
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;
}
// Upgrade and Sell — visuals + hotkeys wired; click is a no-op because the
// upgrade/sell systems aren't built yet. Buttons are SetEnabled(false) so the
// hotkey handler also skips them (it gates on enabledSelf).
private VisualElement CreateUpgradeButton(TowerInstance tower, Key hotkey)
{
var btn = CreateActionButton(
costText: "", // tier cost unknown until upgrade system lands
hotkey: hotkey,
onClick: () => { /* TODO: upgrade flow */ });
btn.SetEnabled(false);
return btn;
}
private VisualElement CreateSellButton(TowerInstance tower, Key hotkey)
{
int sellValue = tower.Definition != null
? Mathf.RoundToInt(tower.Definition.GoldCost * 0.7f)
: 0;
var btn = CreateActionButton(
costText: sellValue > 0 ? $"+{sellValue}g" : "",
hotkey: hotkey,
onClick: () => { /* TODO: sell flow */ });
btn.SetEnabled(false);
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);
}
/// <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.FireRate > 0) AddStatLine($"Fire rate: {def.FireRate: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}");
AddStatLine($"Bounty: {def.GoldReward} g");
// (Weaknesses/resistances will go here once the resistance system lands.)
}
}
private void UpdateEnemyInfoIfShown()
{
if (enemyHealthFill == null) return; // no Enemy selected
var sel = SelectionState.Instance?.SelectedObject;
if (sel is EnemyHealth enemy && (UnityEngine.Object)enemy != null)
UpdateEnemyHpDisplay(enemy);
}
private void UpdateEnemyHpDisplay(EnemyHealth enemy)
{
float cur = Mathf.Max(0f, enemy.CurrentHp);
float max = Mathf.Max(1f, enemy.MaxHp);
float pct = Mathf.Clamp01(cur / max);
if (enemyHealthFill != null)
enemyHealthFill.style.width = Length.Percent(pct * 100f);
if (enemyHealthText != null)
enemyHealthText.text = $"{Mathf.CeilToInt(cur)} / {Mathf.CeilToInt(max)}";
}
private void AddStatLine(string text)
{
var label = new Label(text);
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.FireRate > 0)
ttStats.text += $"\nFire rate: {def.FireRate:0.0}/s";
if (def.SlowFactor < 1f)
ttStats.text += $"\nSlow: {(1f - def.SlowFactor) * 100f:0}%";
}
else
{
ttStats.text = "(stats pending)";
}
int sellValue = Mathf.RoundToInt(def.GoldCost * 0.7f);
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)
{
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) 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);
}
// ----- 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.
Debug.LogWarning("[HUDController] LobbyService not found — falling back to scene reload.");
var nm = NetworkManager.Singleton;
if (nm != null && nm.IsServer && nm.SceneManager != null)
nm.SceneManager.LoadScene(SceneManager.GetActiveScene().name, 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;
}
}
}