diff --git a/Assets/_Project/Scripts/Audio/AudioManager.cs b/Assets/_Project/Scripts/Audio/AudioManager.cs
index 21099bb..5fbc61b 100644
--- a/Assets/_Project/Scripts/Audio/AudioManager.cs
+++ b/Assets/_Project/Scripts/Audio/AudioManager.cs
@@ -11,6 +11,12 @@ namespace TD.Audio
{
public AudioCategory category;
public int maxVoices;
+
+ // NOTE: currently inert. It seeds each pooled AudioSource at init, but every
+ // Play() call overwrites src.volume with (per-sound volume × SFX slider), so
+ // this value never reaches the mix. Left as-is rather than "fixed" because
+ // multiplying it in would halve every existing sound (authored scenes use 0.5).
+ // Wire it up only alongside a pass to re-level the per-sound volumes.
[Range(0f, 1f)]
public float volume;
}
@@ -58,6 +64,16 @@ namespace TD.Audio
}
}
+ ///
+ /// Plays a one-shot on the given category's voice pool. is
+ /// the per-sound authored level; it's scaled by the player's SFX slider
+ /// () on the way in.
+ ///
+ ///
+ /// The scalar is sampled at play time, so a sound already in flight keeps the volume
+ /// it started with when the slider moves. Every SFX here is short, so that's
+ /// imperceptible and avoids having to track live voices.
+ ///
public void Play(AudioClip clip, AudioCategory category, float pitch = 1f, float volume = 1f)
{
if (clip == null) return;
@@ -66,7 +82,7 @@ namespace TD.Audio
int idx = indices[category];
var src = pool[idx % pool.Length];
src.pitch = pitch;
- src.volume = volume;
+ src.volume = volume * AudioVolumeSettings.SfxScalar;
src.clip = clip;
src.Play();
indices[category] = idx + 1;
diff --git a/Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs b/Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs
new file mode 100644
index 0000000..5df40a6
--- /dev/null
+++ b/Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs
@@ -0,0 +1,97 @@
+// Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs
+using UnityEngine;
+
+namespace TD.Audio
+{
+ ///
+ /// Player-facing volume mix, persisted in and shared by
+ /// every audio consumer in the game. Two buses:
+ ///
+ /// Music — the looping scene track played by .
+ /// SFX — everything routed through
+ /// (spells, tower fire, build/land, enemy death, sell, UI clicks).
+ ///
+ ///
+ ///
+ /// Why not named AudioSettings.UnityEngine.AudioSettings already
+ /// exists, so a TD.Audio.AudioSettings would be an ambiguous reference in any
+ /// file that has both using UnityEngine; and using TD.Audio;.
+ ///
+ /// Units. Public values are ints 0..100 — the numbers the settings slider
+ /// shows. Consumers multiply by / ,
+ /// which apply a squared taper so the slider's lower half isn't perceptually dead
+ /// (linear amplitude drops off much faster than perceived loudness). At 100 the
+ /// scalar is exactly 1, so the default mix is byte-identical to the pre-settings
+ /// behaviour.
+ ///
+ /// Live updates. fires on every mutation.
+ /// Continuous sources (music) subscribe and re-apply; one-shots read the scalar at
+ /// play time, so a mid-flight SFX keeps the volume it started with. That's fine —
+ /// they're all short.
+ ///
+ public static class AudioVolumeSettings
+ {
+ public const int MinVolume = 0;
+ public const int MaxVolume = 100;
+
+ private const int DefaultVolume = 100;
+ private const string MusicKey = "td.audio.music";
+ private const string SfxKey = "td.audio.sfx";
+
+ /// Raised whenever music or SFX volume changes.
+ public static event System.Action OnChanged;
+
+ // -1 = not yet read from PlayerPrefs. Statics are cleared on domain reload
+ // (entering play mode in the editor), so the lazy load re-runs each session.
+ private static int music = -1;
+ private static int sfx = -1;
+
+ public static int MusicVolume
+ {
+ get
+ {
+ if (music < 0) music = PlayerPrefs.GetInt(MusicKey, DefaultVolume);
+ return music;
+ }
+ set => Set(ref music, MusicKey, value);
+ }
+
+ public static int SfxVolume
+ {
+ get
+ {
+ if (sfx < 0) sfx = PlayerPrefs.GetInt(SfxKey, DefaultVolume);
+ return sfx;
+ }
+ set => Set(ref sfx, SfxKey, value);
+ }
+
+ /// Amplitude multiplier (0..1) for the music bus.
+ public static float MusicScalar => ToScalar(MusicVolume);
+
+ /// Amplitude multiplier (0..1) for the sound-effects bus.
+ public static float SfxScalar => ToScalar(SfxVolume);
+
+ ///
+ /// Flushes PlayerPrefs to disk. Setters only write the in-memory pref (cheap
+ /// enough to call on every frame of a slider drag); call this once when the
+ /// settings UI closes so the choice survives a crash.
+ ///
+ public static void Flush() => PlayerPrefs.Save();
+
+ private static void Set(ref int field, string key, int value)
+ {
+ int clamped = Mathf.Clamp(value, MinVolume, MaxVolume);
+ if (field == clamped) return;
+ field = clamped;
+ PlayerPrefs.SetInt(key, clamped);
+ OnChanged?.Invoke();
+ }
+
+ private static float ToScalar(int volume)
+ {
+ float linear = Mathf.Clamp01(volume / (float)MaxVolume);
+ return linear * linear;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs.meta b/Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs.meta
new file mode 100644
index 0000000..2b1f334
--- /dev/null
+++ b/Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4d2f94f5f8df24e8aa1f47d9f0e3ecf4
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Audio/MusicPlayer.cs b/Assets/_Project/Scripts/Audio/MusicPlayer.cs
index 325b15c..781e36e 100644
--- a/Assets/_Project/Scripts/Audio/MusicPlayer.cs
+++ b/Assets/_Project/Scripts/Audio/MusicPlayer.cs
@@ -7,6 +7,12 @@ namespace TD.Audio
/// Plays a single looping music track for the scene it lives in.
/// Add to a GameObject in the scene, assign a clip, and it starts on load.
///
+ ///
+ /// Volume is the authored scaled by the player's music slider
+ /// (). Unlike one-shot SFX, the track is
+ /// continuous, so this subscribes to and
+ /// re-applies live while the slider is dragged.
+ ///
[RequireComponent(typeof(AudioSource))]
public class MusicPlayer : MonoBehaviour
{
@@ -14,14 +20,36 @@ namespace TD.Audio
[Range(0f, 1f)]
[SerializeField] private float volume = 1f;
+ private AudioSource source;
+
+ private void Awake()
+ {
+ source = GetComponent();
+ }
+
+ private void OnEnable()
+ {
+ AudioVolumeSettings.OnChanged += ApplyVolume;
+ }
+
+ private void OnDisable()
+ {
+ AudioVolumeSettings.OnChanged -= ApplyVolume;
+ }
+
private void Start()
{
- var src = GetComponent();
- src.clip = clip;
- src.volume = volume;
- src.loop = true;
- src.playOnAwake = false;
- src.Play();
+ source.clip = clip;
+ source.loop = true;
+ source.playOnAwake = false;
+ ApplyVolume();
+ source.Play();
+ }
+
+ private void ApplyVolume()
+ {
+ if (source == null) return;
+ source.volume = volume * AudioVolumeSettings.MusicScalar;
}
}
}
diff --git a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs
index 41eeee6..955f301 100644
--- a/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs
+++ b/Assets/_Project/Scripts/Gameplay/BuilderInputController.cs
@@ -139,10 +139,11 @@ namespace TD.Gameplay
// Escape: clear selection. Allowed during placement mode too — Escape never
// means anything else here, and clearing selection during placement is fine.
- // Suppressed while chat (or any HUD text field) has focus, since Escape there
- // means "cancel typing" and should not also clear the unit selection.
+ // Suppressed while chat (or any HUD text field) has focus or the gear menu is
+ // open, since Escape there means "cancel typing" / "back out of the menu" and
+ // should not also clear the unit selection.
if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame
- && !HUDController.IsTextInputActive)
+ && !HUDController.IsUiCapturingInput)
{
SelectionState.Instance?.Clear();
}
@@ -153,7 +154,7 @@ namespace TD.Gameplay
// Tab: select the builder, or (if already selected) recenter the camera on it.
if (keyboard != null && keyboard.tabKey.wasPressedThisFrame
- && !HUDController.IsTextInputActive)
+ && !HUDController.IsUiCapturingInput)
{
var selection = SelectionState.Instance;
if (selection != null && selection.IsSelected(builder))
diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs b/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs
index 745ebe8..557fe3d 100644
--- a/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs
+++ b/Assets/_Project/Scripts/Gameplay/BuilderSpellCastController.cs
@@ -95,7 +95,7 @@ namespace TD.Gameplay
private void Update()
{
- if (!HUDController.IsTextInputActive)
+ if (!HUDController.IsUiCapturingInput)
ScanHotkeys();
if (activeSlot < 0) return; // idle — nothing to aim
diff --git a/Assets/_Project/Scripts/Gameplay/CameraController.cs b/Assets/_Project/Scripts/Gameplay/CameraController.cs
index bd071d6..4a2009a 100644
--- a/Assets/_Project/Scripts/Gameplay/CameraController.cs
+++ b/Assets/_Project/Scripts/Gameplay/CameraController.cs
@@ -229,9 +229,10 @@ namespace TD.Gameplay
// Keyboard: arrow keys only. WASD is reserved for tower-build hotkeys (the command
// grid) — camera panning is arrow keys plus the mouse edge-pan below. Suppressed
- // entirely while the player is typing so arrow keys in chat navigate text instead of
- // panning. (Edge-pan below stays active since it's mouse-driven.)
- var kb = HUDController.IsTextInputActive ? null : Keyboard.current;
+ // entirely while the player is typing (so arrow keys in chat navigate text instead
+ // of panning) or while the gear menu is open. Edge-pan below is mouse-driven and
+ // gated separately by the pointer-over-HUD check, which the menu overlay satisfies.
+ var kb = HUDController.IsUiCapturingInput ? null : Keyboard.current;
if (kb != null)
{
if (kb.leftArrowKey.isPressed) dir.x -= 1f;
diff --git a/Assets/_Project/Scripts/Gameplay/TowerPaintController.cs b/Assets/_Project/Scripts/Gameplay/TowerPaintController.cs
index e6c52e0..05a1613 100644
--- a/Assets/_Project/Scripts/Gameplay/TowerPaintController.cs
+++ b/Assets/_Project/Scripts/Gameplay/TowerPaintController.cs
@@ -73,10 +73,11 @@ namespace TD.Gameplay
var keyboard = Keyboard.current;
// Right-click or Escape exits paint mode. (Escape is ignored while a HUD text
- // field has focus so it means "cancel typing" there, matching other systems.)
+ // field has focus or the gear menu is open, so it means "cancel typing" /
+ // "back out of the menu" there, matching other systems.)
bool escape = keyboard != null
&& keyboard.escapeKey.wasPressedThisFrame
- && !HUDController.IsTextInputActive;
+ && !HUDController.IsUiCapturingInput;
if (mouse.rightButton.wasPressedThisFrame || escape)
{
CancelPaint();
diff --git a/Assets/_Project/Scripts/UI/GameMenuView.cs b/Assets/_Project/Scripts/UI/GameMenuView.cs
new file mode 100644
index 0000000..7db20bd
--- /dev/null
+++ b/Assets/_Project/Scripts/UI/GameMenuView.cs
@@ -0,0 +1,407 @@
+// Assets/_Project/Scripts/UI/GameMenuView.cs
+using UnityEngine;
+using UnityEngine.UIElements;
+using TD.Audio;
+
+namespace TD.UI
+{
+ ///
+ /// The in-match menu: a gear button in the top-right of the HUD top bar plus a modal
+ /// overlay with two options — Return to Title Screen and Audio Settings.
+ ///
+ ///
+ /// Ownership. A plain class (not a MonoBehaviour), constructed by
+ /// and given the HUD's root element — same pattern as
+ /// MinimapView. Everything is built programmatically and appended last, so it
+ /// z-orders above the rest of the HUD with no extra UIDocument or PanelSettings.
+ ///
+ /// Modality. The overlay is full-screen with
+ /// , which both dims the match and makes
+ /// true everywhere — so clicks
+ /// can't reach towers or the ground underneath. HUDController also sets
+ /// while it's open, which gates keyboard-driven
+ /// gameplay (camera pan, hotkeys, Escape handlers). The match does not pause:
+ /// this is a multiplayer game and waves keep running.
+ ///
+ /// Pages. One panel, three swapped bodies: the option list, the audio mixer,
+ /// and a confirm step for leaving the match. Escape backs out one page at a time and
+ /// closes from the root page.
+ ///
+ public class GameMenuView
+ {
+ private enum Page { Root, Audio, ConfirmLeave }
+
+ // ----- Wiring -----------------------------------------------------
+
+ private readonly System.Action onReturnToTitle;
+
+ private readonly VisualElement overlay;
+ private readonly Label header;
+ private readonly VisualElement rootPage;
+ private readonly VisualElement audioPage;
+ private readonly VisualElement confirmPage;
+
+ private readonly SliderInt musicSlider;
+ private readonly Label musicValue;
+ private readonly SliderInt sfxSlider;
+ private readonly Label sfxValue;
+
+ private Page page = Page.Root;
+
+ // ----- Public API -------------------------------------------------
+
+ public bool IsOpen { get; private set; }
+
+ /// The HUD's rootVisualElement. The overlay is appended to it.
+ /// The top-bar "menu-button" from HUD.uxml. May be null
+ /// (Escape still works); the gear glyph is painted into it here.
+ /// Optional sprite for the gear. When null, the icon is drawn
+ /// procedurally so the button never depends on an art asset existing.
+ /// Invoked once the player confirms leaving the match.
+ public GameMenuView(VisualElement root, Button gearButton, Sprite gearIcon,
+ System.Action onReturnToTitle)
+ {
+ this.onReturnToTitle = onReturnToTitle;
+
+ if (gearButton != null)
+ {
+ gearButton.text = string.Empty;
+ gearButton.Add(CreateGearIcon(gearIcon));
+ gearButton.clicked += Toggle;
+ gearButton.tooltip = "Menu (Esc)";
+ }
+
+ // ----- Overlay + panel shell ----------------------------------
+
+ overlay = new VisualElement();
+ overlay.style.position = Position.Absolute;
+ overlay.style.left = 0;
+ overlay.style.right = 0;
+ overlay.style.top = 0;
+ overlay.style.bottom = 0;
+ overlay.style.alignItems = Align.Center;
+ overlay.style.justifyContent = Justify.Center;
+ overlay.style.backgroundColor = new Color(0f, 0f, 0f, 0.55f);
+ overlay.style.display = DisplayStyle.None;
+ overlay.pickingMode = PickingMode.Position;
+
+ var panel = new VisualElement();
+ panel.style.minWidth = 360;
+ panel.style.paddingTop = panel.style.paddingBottom = 24;
+ panel.style.paddingLeft = panel.style.paddingRight = 32;
+ panel.style.backgroundColor = new Color(0.08f, 0.08f, 0.10f, 0.97f);
+ 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;
+ overlay.Add(panel);
+
+ header = new Label("Menu");
+ header.style.fontSize = 24;
+ header.style.color = Color.white;
+ header.style.marginBottom = 18;
+ header.style.unityFontStyleAndWeight = FontStyle.Bold;
+ panel.Add(header);
+
+ // ----- Page 1: option list ------------------------------------
+
+ rootPage = new VisualElement();
+ rootPage.style.alignItems = Align.Center;
+ rootPage.Add(MakeMenuButton("Return to Title Screen", () => GoTo(Page.ConfirmLeave)));
+ rootPage.Add(MakeMenuButton("Audio Settings", () => GoTo(Page.Audio)));
+ rootPage.Add(MakeMenuButton("Resume", Close));
+ panel.Add(rootPage);
+
+ // ----- Page 2: audio mixer ------------------------------------
+
+ audioPage = new VisualElement();
+ audioPage.style.alignItems = Align.Stretch;
+ audioPage.style.display = DisplayStyle.None;
+
+ audioPage.Add(BuildVolumeRow("Music", AudioVolumeSettings.MusicVolume,
+ v => AudioVolumeSettings.MusicVolume = v,
+ out musicSlider, out musicValue));
+ audioPage.Add(BuildVolumeRow("Sound Effects", AudioVolumeSettings.SfxVolume,
+ v => AudioVolumeSettings.SfxVolume = v,
+ out sfxSlider, out sfxValue));
+
+ var audioNote = new Label("Sound effects covers spells, towers, building, and enemies.");
+ audioNote.style.fontSize = 11;
+ audioNote.style.color = new Color(0.6f, 0.6f, 0.65f);
+ audioNote.style.marginTop = 4;
+ audioNote.style.marginBottom = 12;
+ audioNote.style.whiteSpace = WhiteSpace.Normal;
+ audioPage.Add(audioNote);
+
+ var audioBack = MakeMenuButton("Back", () => GoTo(Page.Root));
+ audioBack.style.alignSelf = Align.Center;
+ audioPage.Add(audioBack);
+ panel.Add(audioPage);
+
+ // ----- Page 3: leave confirmation -----------------------------
+
+ confirmPage = new VisualElement();
+ confirmPage.style.alignItems = Align.Center;
+ confirmPage.style.display = DisplayStyle.None;
+
+ var confirmText = new Label("Leave the match and return to the title screen?");
+ confirmText.style.color = new Color(0.88f, 0.88f, 0.9f);
+ confirmText.style.whiteSpace = WhiteSpace.Normal;
+ confirmText.style.marginBottom = 16;
+ confirmText.style.unityTextAlign = TextAnchor.MiddleCenter;
+ confirmPage.Add(confirmText);
+
+ var confirmRow = new VisualElement();
+ confirmRow.style.flexDirection = FlexDirection.Row;
+ confirmPage.Add(confirmRow);
+
+ var leaveBtn = MakeMenuButton("Leave Match", ConfirmLeave);
+ leaveBtn.style.marginRight = 12;
+ leaveBtn.style.minWidth = 150;
+ confirmRow.Add(leaveBtn);
+
+ var cancelBtn = MakeMenuButton("Cancel", () => GoTo(Page.Root));
+ cancelBtn.style.minWidth = 150;
+ confirmRow.Add(cancelBtn);
+ panel.Add(confirmPage);
+
+ root.Add(overlay);
+ }
+
+ public void Open()
+ {
+ if (IsOpen) return;
+ IsOpen = true;
+ GoTo(Page.Root);
+ overlay.style.display = DisplayStyle.Flex;
+ }
+
+ public void Close()
+ {
+ if (!IsOpen) return;
+ IsOpen = false;
+ overlay.style.display = DisplayStyle.None;
+ // Volume setters only touch the in-memory pref so a slider drag stays cheap;
+ // write it out now that the player is done adjusting.
+ AudioVolumeSettings.Flush();
+ }
+
+ public void Toggle()
+ {
+ if (IsOpen) Close();
+ else Open();
+ }
+
+ ///
+ /// Escape while the menu is open: back out one page, or close from the root page.
+ ///
+ public void Back()
+ {
+ if (page == Page.Root) Close();
+ else GoTo(Page.Root);
+ }
+
+ // ----- Pages ------------------------------------------------------
+
+ private void GoTo(Page next)
+ {
+ page = next;
+
+ rootPage.style.display = next == Page.Root ? DisplayStyle.Flex : DisplayStyle.None;
+ audioPage.style.display = next == Page.Audio ? DisplayStyle.Flex : DisplayStyle.None;
+ confirmPage.style.display = next == Page.ConfirmLeave ? DisplayStyle.Flex : DisplayStyle.None;
+
+ header.text = next switch
+ {
+ Page.Audio => "Audio Settings",
+ Page.ConfirmLeave => "Leave Match",
+ _ => "Menu",
+ };
+
+ // Another surface could have changed the volumes since this page was last
+ // shown (a future title-screen mixer, a debug command). Re-sync on entry so
+ // the sliders never display a stale value.
+ if (next == Page.Audio) SyncSlidersFromSettings();
+ }
+
+ private void SyncSlidersFromSettings()
+ {
+ // SetValueWithoutNotify: writing the settings value back through the change
+ // callback would be a no-op today (the setter early-outs on an unchanged value)
+ // but it's the wrong direction of data flow, so don't.
+ musicSlider.SetValueWithoutNotify(AudioVolumeSettings.MusicVolume);
+ musicValue.text = AudioVolumeSettings.MusicVolume.ToString();
+ sfxSlider.SetValueWithoutNotify(AudioVolumeSettings.SfxVolume);
+ sfxValue.text = AudioVolumeSettings.SfxVolume.ToString();
+ }
+
+ private void ConfirmLeave()
+ {
+ AudioVolumeSettings.Flush();
+ IsOpen = false;
+ overlay.style.display = DisplayStyle.None;
+ onReturnToTitle?.Invoke();
+ }
+
+ // ----- Element builders -------------------------------------------
+
+ private static Button MakeMenuButton(string text, System.Action onClick)
+ {
+ var btn = new Button(() => onClick?.Invoke()) { text = text };
+ btn.style.minWidth = 260;
+ btn.style.height = 40;
+ btn.style.fontSize = 16;
+ btn.style.marginBottom = 8;
+ return btn;
+ }
+
+ // One mixer row: name, 0..100 slider, live numeric readout.
+ private static VisualElement BuildVolumeRow(string label, int initial,
+ System.Action onChange,
+ out SliderInt slider, out Label valueLabel)
+ {
+ var row = new VisualElement();
+ row.style.flexDirection = FlexDirection.Row;
+ // FlexStart, not Center: the labels are aligned to the slider's track by
+ // AlignLabelsToTrack below, which needs every child's box to start at the same
+ // top edge. See that method for why centering the boxes isn't enough.
+ row.style.alignItems = Align.FlexStart;
+ row.style.marginBottom = 10;
+
+ var name = new Label(label);
+ name.style.color = new Color(0.88f, 0.88f, 0.9f);
+ name.style.fontSize = 14;
+ name.style.width = 110;
+ name.style.flexShrink = 0;
+ name.style.unityTextAlign = TextAnchor.MiddleLeft;
+ row.Add(name);
+
+ slider = new SliderInt(AudioVolumeSettings.MinVolume, AudioVolumeSettings.MaxVolume);
+ slider.style.width = 200;
+ slider.style.flexShrink = 0;
+ slider.SetValueWithoutNotify(initial);
+ row.Add(slider);
+
+ // Fixed width + right-align so the slider doesn't shift as the number goes
+ // from 1 to 3 digits while dragging.
+ var value = new Label(initial.ToString());
+ value.style.color = Color.white;
+ value.style.fontSize = 14;
+ value.style.width = 40;
+ value.style.flexShrink = 0;
+ value.style.unityTextAlign = TextAnchor.MiddleRight;
+ row.Add(value);
+
+ AlignLabelsToTrack(row, slider, name, value);
+
+ var captured = value;
+ slider.RegisterValueChangedCallback(evt =>
+ {
+ captured.text = evt.newValue.ToString();
+ onChange(evt.newValue);
+ });
+
+ valueLabel = value;
+ return row;
+ }
+
+ // Vertically lines the row's text up with the groove the player actually sees.
+ //
+ // The default runtime theme gives a horizontal slider a box that's taller than its
+ // track and does NOT center the track inside it, so `align-items: center` on the row
+ // centers the *boxes* and leaves the text sitting well below the groove. The exact
+ // offset is a theme detail, so it's measured rather than hardcoded: with the row
+ // aligned FlexStart every child's box starts at the same top edge, so giving a label
+ // a height of twice the track's offset from that edge puts its vertically-centered
+ // text exactly on the track's center line.
+ //
+ // Runs on GeometryChangedEvent (not once at build time) because layout hasn't
+ // happened yet during construction, and because it needs to re-solve when the panel
+ // is rescaled or the menu is reopened at a different resolution.
+ private static void AlignLabelsToTrack(VisualElement row, SliderInt slider,
+ params Label[] labels)
+ {
+ // Align to the *tracker* — the thin groove. Not the drag-container: that's the
+ // full hit area, tall enough to hold the dragger handle (which overhangs the
+ // groove on both sides), so its center sits below the groove. Fall back through
+ // the container to the slider itself if the theme's internal names ever change;
+ // worst case is the old box-centered look, not a broken layout.
+ VisualElement track = slider.Q(className: "unity-base-slider__tracker")
+ ?? slider.Q(className: "unity-base-slider__drag-container")
+ ?? slider;
+
+ void Align(GeometryChangedEvent _)
+ {
+ float rowTop = row.worldBound.y;
+ float trackCenter = track.worldBound.center.y;
+ float half = trackCenter - rowTop;
+ if (half <= 0f || float.IsNaN(half)) return; // pre-layout; a later event fixes it
+
+ foreach (var label in labels)
+ label.style.height = half * 2f;
+ }
+
+ row.RegisterCallback(Align);
+ track.RegisterCallback(Align);
+ }
+
+ // Gear glyph. Drawn with Painter2D rather than a font glyph or a texture: Unity's
+ // default runtime font has no U+2699 gear, and a procedural icon keeps the button
+ // working in any scene without an imported sprite. Pass a sprite to override.
+ private static VisualElement CreateGearIcon(Sprite icon)
+ {
+ var el = new VisualElement();
+ el.pickingMode = PickingMode.Ignore;
+ el.style.flexGrow = 1;
+
+ if (icon != null)
+ {
+ el.style.backgroundImage = new StyleBackground(icon);
+ return el;
+ }
+
+ el.generateVisualContent += PaintGear;
+ // generateVisualContent runs against contentRect, which is 0×0 until the first
+ // layout pass — repaint once the element actually has a size.
+ el.RegisterCallback(_ => el.MarkDirtyRepaint());
+ return el;
+ }
+
+ private static void PaintGear(MeshGenerationContext ctx)
+ {
+ var rect = ctx.visualElement.contentRect;
+ if (rect.width < 4f || rect.height < 4f) return;
+
+ var painter = ctx.painter2D;
+ Vector2 center = rect.center;
+ float outer = Mathf.Min(rect.width, rect.height) * 0.5f - 1f;
+ float ring = outer * 0.62f;
+ float hub = outer * 0.24f;
+
+ painter.strokeColor = new Color(0.95f, 0.93f, 0.75f);
+ painter.lineWidth = Mathf.Max(1.5f, outer * 0.20f);
+
+ // Teeth: radial spokes from just inside the ring out to the edge.
+ const int teeth = 8;
+ painter.BeginPath();
+ for (int i = 0; i < teeth; i++)
+ {
+ float angle = i * Mathf.PI * 2f / teeth;
+ var dir = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
+ painter.MoveTo(center + dir * (ring * 0.85f));
+ painter.LineTo(center + dir * outer);
+ }
+ painter.Stroke();
+
+ painter.BeginPath();
+ painter.Arc(center, ring, new Angle(0f, AngleUnit.Degree), new Angle(360f, AngleUnit.Degree));
+ painter.Stroke();
+
+ painter.BeginPath();
+ painter.Arc(center, hub, new Angle(0f, AngleUnit.Degree), new Angle(360f, AngleUnit.Degree));
+ painter.Stroke();
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/UI/GameMenuView.cs.meta b/Assets/_Project/Scripts/UI/GameMenuView.cs.meta
new file mode 100644
index 0000000..0d488ea
--- /dev/null
+++ b/Assets/_Project/Scripts/UI/GameMenuView.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0f5b5b3bcd6ea4765a92a784b9c8de13
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs
index 9ff9ca4..488cea9 100644
--- a/Assets/_Project/Scripts/UI/HUDController.cs
+++ b/Assets/_Project/Scripts/UI/HUDController.cs
@@ -43,6 +43,10 @@ namespace TD.UI
[Header("Settings")] [SerializeField] private float rejectionMessageDuration = 2.5f;
+ [Tooltip("Optional icon for the top-right menu button. Leave empty to use the " +
+ "procedurally-drawn gear (no art asset required).")]
+ [SerializeField] private Sprite menuButtonIcon;
+
[Tooltip("Maximum visible height of the chat feed in pixels. Content past this " +
"height is clipped — older messages scroll off the top of the visible area " +
"but stay in history (scroll up while chat is open to view).")]
@@ -159,6 +163,21 @@ namespace TD.UI
// instead of every gameplay script needing to know about every input widget.
public static bool IsTextInputActive { get; internal set; }
+ ///
+ /// True while a modal UI surface (currently the gear menu) owns the screen. Gameplay
+ /// systems should treat this exactly like — see
+ /// , which is the flag they actually gate on.
+ ///
+ public static bool IsModalUiOpen { get; private set; }
+
+ ///
+ /// True when any UI surface is consuming keyboard/mouse input this frame: a focused
+ /// text field OR an open modal menu. Gameplay input handlers (camera, selection,
+ /// hotkeys, Escape-to-cancel) gate on this so typing or a menu never doubles as a
+ /// gameplay command.
+ ///
+ public static bool IsUiCapturingInput => IsTextInputActive || IsModalUiOpen;
+
// Frame until which chat's "Enter opens chat" behavior is suppressed. Other
// standalone text-input surfaces (e.g. TD.Dev.DebugConsole) set this when their
// own Enter-driven submit/close already consumed the keypress, so the same
@@ -183,6 +202,7 @@ namespace TD.UI
private bool deckSubscribed; // true once we've hooked the local PlayerTowerDeck.OnDeckChanged
private PlayerTowerDeck subscribedDeck; // the deck we hooked, so we can unsubscribe the same instance
private MinimapView minimapView;
+ private GameMenuView gameMenu; // gear button + modal menu overlay
private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us
// ----- Hotkeys ----------------------------------------------------
@@ -703,6 +723,12 @@ namespace TD.UI
// ChatService.PostLocalSystem on every peer.
BuildChatPanel(root);
+ // Gear menu (top-right of the top bar). Built last so its overlay z-orders
+ // above every other HUD surface. "Return to Title Screen" reuses the same
+ // disconnect path as the match-end overlay's button.
+ gameMenu = new GameMenuView(root, Require