1915 lines
86 KiB
C#
1915 lines
86 KiB
C#
// 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
|
||
{
|
||
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("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")
|
||
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;
|
||
|
||
// Buff menu overlay — toggled by the B key via ToggleBuffMenu().
|
||
private VisualElement buffMenuOverlay;
|
||
private VisualElement buffMenuContent;
|
||
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 ------------------------------------------------------
|
||
|
||
// 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 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");
|
||
nextWaveLabel = Require<Label>(root, "next-wave-label");
|
||
leakedLabel = Require<Label>(root, "leaked-label");
|
||
incomeLabel = Require<Label>(root, "income-label");
|
||
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");
|
||
|
||
// 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 buff menu overlay. Hidden until the player presses B.
|
||
BuildBuffMenuOverlay(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 Awake()
|
||
{
|
||
Instance = this;
|
||
}
|
||
|
||
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()
|
||
{
|
||
if (Instance == this) Instance = 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: --";
|
||
|
||
if (waveLabel != null)
|
||
{
|
||
int total = wm?.TotalWaves ?? 0;
|
||
waveLabel.text = ms != null && ms.CurrentWave > 0 && total > 0
|
||
? $"Wave {ms.CurrentWave} / {total}"
|
||
: "Wave --";
|
||
}
|
||
|
||
// Next-wave countdown. Shows during prep ("next: 0:12") and clears the
|
||
// moment the wave actually 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)
|
||
{
|
||
// 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 = $"next: {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();
|
||
}
|
||
|
||
// ----- 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
|
||
{
|
||
int i = 0;
|
||
foreach (var (def, typeId) in placementManager.GetAvailableDefinitions())
|
||
{
|
||
if (i >= GRID_MAX) break;
|
||
cells[i] = CreateTowerButton(def, typeId, HotkeyLayout[i]);
|
||
i++;
|
||
}
|
||
|
||
}
|
||
cells[GRID_MAX - 1] = CreateBuffMenuButton(HotkeyLayout[GRID_MAX - 1]);
|
||
}
|
||
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;
|
||
}
|
||
|
||
// ----- 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;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
private VisualElement CreateBuffMenuButton(Key hotkey)
|
||
{
|
||
return CreateActionButton(
|
||
costText: "Buffs",
|
||
hotkey: hotkey,
|
||
onClick: () => ToggleBuffMenu());
|
||
}
|
||
|
||
// 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}");
|
||
// Bounty is per-wave now (GoldConfig.Waves[N].GoldPerEnemy) rather than
|
||
// per-enemy-type. Read the current wave's value so the tooltip is accurate.
|
||
var wm = WaveManager.Instance;
|
||
int currentWave = MatchState.Instance != null ? MatchState.Instance.CurrentWave : 0;
|
||
var goldEntry = wm?.GoldConfig?.GetWaveEntry(currentWave);
|
||
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.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 -----------------------------------------
|
||
|
||
// ----- Buff menu overlay ------------------------------------------
|
||
|
||
private void BuildBuffMenuOverlay(VisualElement root)
|
||
{
|
||
buffMenuOverlay = new VisualElement();
|
||
buffMenuOverlay.style.position = Position.Absolute;
|
||
buffMenuOverlay.style.left = 0;
|
||
buffMenuOverlay.style.right = 0;
|
||
buffMenuOverlay.style.top = 0;
|
||
buffMenuOverlay.style.bottom = 0;
|
||
buffMenuOverlay.style.alignItems = Align.Center;
|
||
buffMenuOverlay.style.justifyContent = Justify.Center;
|
||
buffMenuOverlay.style.backgroundColor = new Color(0f, 0f, 0f, 0.5f);
|
||
buffMenuOverlay.style.display = DisplayStyle.None;
|
||
buffMenuOverlay.pickingMode = PickingMode.Position;
|
||
|
||
var panel = new VisualElement();
|
||
panel.style.minWidth = 340;
|
||
panel.style.paddingTop = 20;
|
||
panel.style.paddingBottom = 20;
|
||
panel.style.paddingLeft = 28;
|
||
panel.style.paddingRight = 28;
|
||
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;
|
||
|
||
var title = new Label("Buffs");
|
||
title.style.fontSize = 24;
|
||
title.style.color = Color.white;
|
||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
title.style.marginBottom = 12;
|
||
panel.Add(title);
|
||
|
||
// Scrollable content area: current buffs + purchase buttons.
|
||
// Rebuilt each time the menu is shown via RefreshBuffMenuContent().
|
||
buffMenuContent = new VisualElement();
|
||
panel.Add(buffMenuContent);
|
||
|
||
var closeBtn = new Button(() => SetBuffMenuVisible(false)) { text = "Close [B]" };
|
||
closeBtn.style.marginTop = 16;
|
||
closeBtn.style.height = 32;
|
||
panel.Add(closeBtn);
|
||
|
||
buffMenuOverlay.Add(panel);
|
||
root.Add(buffMenuOverlay);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Toggles the buff menu overlay. Called by <see cref="TD.Gameplay.BuilderInputController"/>
|
||
/// when the player presses B.
|
||
/// </summary>
|
||
public void ToggleBuffMenu()
|
||
{
|
||
bool nowVisible = buffMenuOverlay?.style.display == DisplayStyle.None;
|
||
SetBuffMenuVisible(nowVisible);
|
||
}
|
||
|
||
private void SetBuffMenuVisible(bool visible)
|
||
{
|
||
if (buffMenuOverlay == null) return;
|
||
buffMenuOverlay.style.display = visible ? DisplayStyle.Flex : DisplayStyle.None;
|
||
if (visible) RefreshBuffMenuContent();
|
||
}
|
||
|
||
private void RefreshBuffMenuContent()
|
||
{
|
||
if (buffMenuContent == null) return;
|
||
buffMenuContent.Clear();
|
||
|
||
var buffManager = TD.Gameplay.PlayerBuffManager.Local;
|
||
|
||
// Current buffs section.
|
||
var buffsHeader = new Label("Active Buffs");
|
||
buffsHeader.style.color = new Color(0.7f, 0.9f, 0.7f);
|
||
buffsHeader.style.fontSize = 14;
|
||
buffsHeader.style.marginBottom = 4;
|
||
buffMenuContent.Add(buffsHeader);
|
||
|
||
if (buffManager == null || buffManager.Buffs.Count == 0)
|
||
{
|
||
var none = new Label("None");
|
||
none.style.color = new Color(0.55f, 0.55f, 0.55f);
|
||
none.style.marginBottom = 8;
|
||
buffMenuContent.Add(none);
|
||
}
|
||
else
|
||
{
|
||
for (int i = 0; i < buffManager.Buffs.Count; i++)
|
||
{
|
||
var buff = buffManager.Buffs[i];
|
||
string statName = buff.Stat == TD.Core.BuffStat.Damage ? "Damage" : "Attack Speed";
|
||
string activeTag = buff.IsActive ? "" : " [DISABLED]";
|
||
var row = new Label($"• {buff.DisplayName} ({statName} ×{buff.Multiplier:F2}){activeTag}");
|
||
row.style.color = buff.IsActive ? Color.white : new Color(0.5f, 0.5f, 0.5f);
|
||
row.style.fontSize = 13;
|
||
buffMenuContent.Add(row);
|
||
}
|
||
buffMenuContent.style.marginBottom = 12;
|
||
}
|
||
|
||
// Purchase buttons section.
|
||
var buyHeader = new Label("Purchase");
|
||
buyHeader.style.color = new Color(0.9f, 0.8f, 0.5f);
|
||
buyHeader.style.fontSize = 14;
|
||
buyHeader.style.marginTop = 8;
|
||
buyHeader.style.marginBottom = 4;
|
||
buffMenuContent.Add(buyHeader);
|
||
|
||
int categoryCount = buffManager?.CategoryCount ?? 0;
|
||
if (categoryCount == 0)
|
||
{
|
||
var none = new Label("No categories available.");
|
||
none.style.color = new Color(0.55f, 0.55f, 0.55f);
|
||
buffMenuContent.Add(none);
|
||
return;
|
||
}
|
||
|
||
int localGold = TD.Gameplay.PlayerGoldManager.Local?.CurrentGold ?? 0;
|
||
for (int i = 0; i < categoryCount; i++)
|
||
{
|
||
var category = buffManager.GetCategory(i);
|
||
if (category == null) continue;
|
||
|
||
int idx = i; // capture for lambda
|
||
var btn = new Button(() =>
|
||
{
|
||
TD.Gameplay.PlayerBuffManager.Local?.RequestPurchaseBuffRpc(idx);
|
||
RefreshBuffMenuContent();
|
||
})
|
||
{
|
||
text = $"{category.DisplayName} — {category.Cost}g"
|
||
};
|
||
btn.style.height = 34;
|
||
btn.style.fontSize = 13;
|
||
btn.style.marginBottom = 4;
|
||
btn.SetEnabled(localGold >= category.Cost);
|
||
buffMenuContent.Add(btn);
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
}
|