Add settings menu to control audio and return to main menu
This commit is contained in:
parent
c88d3fc625
commit
0b7bc936ef
13 changed files with 697 additions and 22 deletions
|
|
@ -11,6 +11,12 @@ namespace TD.Audio
|
||||||
{
|
{
|
||||||
public AudioCategory category;
|
public AudioCategory category;
|
||||||
public int maxVoices;
|
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)]
|
[Range(0f, 1f)]
|
||||||
public float volume;
|
public float volume;
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +64,16 @@ namespace TD.Audio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plays a one-shot on the given category's voice pool. <paramref name="volume"/> is
|
||||||
|
/// the per-sound authored level; it's scaled by the player's SFX slider
|
||||||
|
/// (<see cref="AudioVolumeSettings.SfxScalar"/>) on the way in.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
public void Play(AudioClip clip, AudioCategory category, float pitch = 1f, float volume = 1f)
|
public void Play(AudioClip clip, AudioCategory category, float pitch = 1f, float volume = 1f)
|
||||||
{
|
{
|
||||||
if (clip == null) return;
|
if (clip == null) return;
|
||||||
|
|
@ -66,7 +82,7 @@ namespace TD.Audio
|
||||||
int idx = indices[category];
|
int idx = indices[category];
|
||||||
var src = pool[idx % pool.Length];
|
var src = pool[idx % pool.Length];
|
||||||
src.pitch = pitch;
|
src.pitch = pitch;
|
||||||
src.volume = volume;
|
src.volume = volume * AudioVolumeSettings.SfxScalar;
|
||||||
src.clip = clip;
|
src.clip = clip;
|
||||||
src.Play();
|
src.Play();
|
||||||
indices[category] = idx + 1;
|
indices[category] = idx + 1;
|
||||||
|
|
|
||||||
97
Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs
Normal file
97
Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// Assets/_Project/Scripts/Audio/AudioVolumeSettings.cs
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace TD.Audio
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Player-facing volume mix, persisted in <see cref="PlayerPrefs"/> and shared by
|
||||||
|
/// every audio consumer in the game. Two buses:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><b>Music</b> — the looping scene track played by <see cref="MusicPlayer"/>.</item>
|
||||||
|
/// <item><b>SFX</b> — everything routed through <see cref="AudioManager"/>
|
||||||
|
/// (spells, tower fire, build/land, enemy death, sell, UI clicks).</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Why not named AudioSettings.</b> <c>UnityEngine.AudioSettings</c> already
|
||||||
|
/// exists, so a <c>TD.Audio.AudioSettings</c> would be an ambiguous reference in any
|
||||||
|
/// file that has both <c>using UnityEngine;</c> and <c>using TD.Audio;</c>.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Units.</b> Public values are ints 0..100 — the numbers the settings slider
|
||||||
|
/// shows. Consumers multiply by <see cref="MusicScalar"/> / <see cref="SfxScalar"/>,
|
||||||
|
/// 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.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Live updates.</b> <see cref="OnChanged"/> 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.</para>
|
||||||
|
/// </remarks>
|
||||||
|
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";
|
||||||
|
|
||||||
|
/// <summary>Raised whenever music or SFX volume changes.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Amplitude multiplier (0..1) for the music bus.</summary>
|
||||||
|
public static float MusicScalar => ToScalar(MusicVolume);
|
||||||
|
|
||||||
|
/// <summary>Amplitude multiplier (0..1) for the sound-effects bus.</summary>
|
||||||
|
public static float SfxScalar => ToScalar(SfxVolume);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4d2f94f5f8df24e8aa1f47d9f0e3ecf4
|
||||||
|
|
@ -7,6 +7,12 @@ namespace TD.Audio
|
||||||
/// Plays a single looping music track for the scene it lives in.
|
/// 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.
|
/// Add to a GameObject in the scene, assign a clip, and it starts on load.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Volume is the authored <see cref="volume"/> scaled by the player's music slider
|
||||||
|
/// (<see cref="AudioVolumeSettings.MusicScalar"/>). Unlike one-shot SFX, the track is
|
||||||
|
/// continuous, so this subscribes to <see cref="AudioVolumeSettings.OnChanged"/> and
|
||||||
|
/// re-applies live while the slider is dragged.
|
||||||
|
/// </remarks>
|
||||||
[RequireComponent(typeof(AudioSource))]
|
[RequireComponent(typeof(AudioSource))]
|
||||||
public class MusicPlayer : MonoBehaviour
|
public class MusicPlayer : MonoBehaviour
|
||||||
{
|
{
|
||||||
|
|
@ -14,14 +20,36 @@ namespace TD.Audio
|
||||||
[Range(0f, 1f)]
|
[Range(0f, 1f)]
|
||||||
[SerializeField] private float volume = 1f;
|
[SerializeField] private float volume = 1f;
|
||||||
|
|
||||||
|
private AudioSource source;
|
||||||
|
|
||||||
|
private void Awake()
|
||||||
|
{
|
||||||
|
source = GetComponent<AudioSource>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEnable()
|
||||||
|
{
|
||||||
|
AudioVolumeSettings.OnChanged += ApplyVolume;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDisable()
|
||||||
|
{
|
||||||
|
AudioVolumeSettings.OnChanged -= ApplyVolume;
|
||||||
|
}
|
||||||
|
|
||||||
private void Start()
|
private void Start()
|
||||||
{
|
{
|
||||||
var src = GetComponent<AudioSource>();
|
source.clip = clip;
|
||||||
src.clip = clip;
|
source.loop = true;
|
||||||
src.volume = volume;
|
source.playOnAwake = false;
|
||||||
src.loop = true;
|
ApplyVolume();
|
||||||
src.playOnAwake = false;
|
source.Play();
|
||||||
src.Play();
|
}
|
||||||
|
|
||||||
|
private void ApplyVolume()
|
||||||
|
{
|
||||||
|
if (source == null) return;
|
||||||
|
source.volume = volume * AudioVolumeSettings.MusicScalar;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,10 +139,11 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
// Escape: clear selection. Allowed during placement mode too — Escape never
|
// Escape: clear selection. Allowed during placement mode too — Escape never
|
||||||
// means anything else here, and clearing selection during placement is fine.
|
// means anything else here, and clearing selection during placement is fine.
|
||||||
// Suppressed while chat (or any HUD text field) has focus, since Escape there
|
// Suppressed while chat (or any HUD text field) has focus or the gear menu is
|
||||||
// means "cancel typing" and should not also clear the unit selection.
|
// 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
|
if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame
|
||||||
&& !HUDController.IsTextInputActive)
|
&& !HUDController.IsUiCapturingInput)
|
||||||
{
|
{
|
||||||
SelectionState.Instance?.Clear();
|
SelectionState.Instance?.Clear();
|
||||||
}
|
}
|
||||||
|
|
@ -153,7 +154,7 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
// Tab: select the builder, or (if already selected) recenter the camera on it.
|
// Tab: select the builder, or (if already selected) recenter the camera on it.
|
||||||
if (keyboard != null && keyboard.tabKey.wasPressedThisFrame
|
if (keyboard != null && keyboard.tabKey.wasPressedThisFrame
|
||||||
&& !HUDController.IsTextInputActive)
|
&& !HUDController.IsUiCapturingInput)
|
||||||
{
|
{
|
||||||
var selection = SelectionState.Instance;
|
var selection = SelectionState.Instance;
|
||||||
if (selection != null && selection.IsSelected(builder))
|
if (selection != null && selection.IsSelected(builder))
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
{
|
{
|
||||||
if (!HUDController.IsTextInputActive)
|
if (!HUDController.IsUiCapturingInput)
|
||||||
ScanHotkeys();
|
ScanHotkeys();
|
||||||
|
|
||||||
if (activeSlot < 0) return; // idle — nothing to aim
|
if (activeSlot < 0) return; // idle — nothing to aim
|
||||||
|
|
|
||||||
|
|
@ -229,9 +229,10 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
// Keyboard: arrow keys only. WASD is reserved for tower-build hotkeys (the command
|
// 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
|
// 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
|
// entirely while the player is typing (so arrow keys in chat navigate text instead
|
||||||
// panning. (Edge-pan below stays active since it's mouse-driven.)
|
// of panning) or while the gear menu is open. Edge-pan below is mouse-driven and
|
||||||
var kb = HUDController.IsTextInputActive ? null : Keyboard.current;
|
// 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 != null)
|
||||||
{
|
{
|
||||||
if (kb.leftArrowKey.isPressed) dir.x -= 1f;
|
if (kb.leftArrowKey.isPressed) dir.x -= 1f;
|
||||||
|
|
|
||||||
|
|
@ -73,10 +73,11 @@ namespace TD.Gameplay
|
||||||
var keyboard = Keyboard.current;
|
var keyboard = Keyboard.current;
|
||||||
|
|
||||||
// Right-click or Escape exits paint mode. (Escape is ignored while a HUD text
|
// 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
|
bool escape = keyboard != null
|
||||||
&& keyboard.escapeKey.wasPressedThisFrame
|
&& keyboard.escapeKey.wasPressedThisFrame
|
||||||
&& !HUDController.IsTextInputActive;
|
&& !HUDController.IsUiCapturingInput;
|
||||||
if (mouse.rightButton.wasPressedThisFrame || escape)
|
if (mouse.rightButton.wasPressedThisFrame || escape)
|
||||||
{
|
{
|
||||||
CancelPaint();
|
CancelPaint();
|
||||||
|
|
|
||||||
407
Assets/_Project/Scripts/UI/GameMenuView.cs
Normal file
407
Assets/_Project/Scripts/UI/GameMenuView.cs
Normal file
|
|
@ -0,0 +1,407 @@
|
||||||
|
// Assets/_Project/Scripts/UI/GameMenuView.cs
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
using TD.Audio;
|
||||||
|
|
||||||
|
namespace TD.UI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Ownership.</b> A plain class (not a MonoBehaviour), constructed by
|
||||||
|
/// <see cref="HUDController"/> and given the HUD's root element — same pattern as
|
||||||
|
/// <c>MinimapView</c>. Everything is built programmatically and appended last, so it
|
||||||
|
/// z-orders above the rest of the HUD with no extra UIDocument or PanelSettings.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Modality.</b> The overlay is full-screen with
|
||||||
|
/// <see cref="PickingMode.Position"/>, which both dims the match and makes
|
||||||
|
/// <see cref="HUDController.IsPointerOverInteractiveHud"/> true everywhere — so clicks
|
||||||
|
/// can't reach towers or the ground underneath. HUDController also sets
|
||||||
|
/// <see cref="HUDController.IsModalUiOpen"/> while it's open, which gates keyboard-driven
|
||||||
|
/// gameplay (camera pan, hotkeys, Escape handlers). The match does <b>not</b> pause:
|
||||||
|
/// this is a multiplayer game and waves keep running.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Pages.</b> 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.</para>
|
||||||
|
/// </remarks>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <param name="root">The HUD's rootVisualElement. The overlay is appended to it.</param>
|
||||||
|
/// <param name="gearButton">The top-bar "menu-button" from HUD.uxml. May be null
|
||||||
|
/// (Escape still works); the gear glyph is painted into it here.</param>
|
||||||
|
/// <param name="gearIcon">Optional sprite for the gear. When null, the icon is drawn
|
||||||
|
/// procedurally so the button never depends on an art asset existing.</param>
|
||||||
|
/// <param name="onReturnToTitle">Invoked once the player confirms leaving the match.</param>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Escape while the menu is open: back out one page, or close from the root page.
|
||||||
|
/// </summary>
|
||||||
|
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<int> 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<GeometryChangedEvent>(Align);
|
||||||
|
track.RegisterCallback<GeometryChangedEvent>(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<GeometryChangedEvent>(_ => 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Scripts/UI/GameMenuView.cs.meta
Normal file
2
Assets/_Project/Scripts/UI/GameMenuView.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0f5b5b3bcd6ea4765a92a784b9c8de13
|
||||||
|
|
@ -43,6 +43,10 @@ namespace TD.UI
|
||||||
|
|
||||||
[Header("Settings")] [SerializeField] private float rejectionMessageDuration = 2.5f;
|
[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 " +
|
[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 " +
|
"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).")]
|
"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.
|
// instead of every gameplay script needing to know about every input widget.
|
||||||
public static bool IsTextInputActive { get; internal set; }
|
public static bool IsTextInputActive { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True while a modal UI surface (currently the gear menu) owns the screen. Gameplay
|
||||||
|
/// systems should treat this exactly like <see cref="IsTextInputActive"/> — see
|
||||||
|
/// <see cref="IsUiCapturingInput"/>, which is the flag they actually gate on.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsModalUiOpen { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when any UI surface is consuming keyboard/mouse input this frame: a focused
|
||||||
|
/// text field OR an open modal menu. Gameplay input handlers (camera, selection,
|
||||||
|
/// hotkeys, Escape-to-cancel) gate on this so typing or a menu never doubles as a
|
||||||
|
/// gameplay command.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsUiCapturingInput => IsTextInputActive || IsModalUiOpen;
|
||||||
|
|
||||||
// Frame until which chat's "Enter opens chat" behavior is suppressed. Other
|
// 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
|
// 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
|
// 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 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 PlayerTowerDeck subscribedDeck; // the deck we hooked, so we can unsubscribe the same instance
|
||||||
private MinimapView minimapView;
|
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
|
private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us
|
||||||
|
|
||||||
// ----- Hotkeys ----------------------------------------------------
|
// ----- Hotkeys ----------------------------------------------------
|
||||||
|
|
@ -703,6 +723,12 @@ namespace TD.UI
|
||||||
// ChatService.PostLocalSystem on every peer.
|
// ChatService.PostLocalSystem on every peer.
|
||||||
BuildChatPanel(root);
|
BuildChatPanel(root);
|
||||||
|
|
||||||
|
// Gear menu (top-right of the top bar). Built last so its overlay z-orders
|
||||||
|
// above every other HUD surface. "Return to Title Screen" reuses the same
|
||||||
|
// disconnect path as the match-end overlay's button.
|
||||||
|
gameMenu = new GameMenuView(root, Require<Button>(root, "menu-button"),
|
||||||
|
menuButtonIcon, OnReturnToMainMenuClicked);
|
||||||
|
|
||||||
// Publish the panel so non-UI systems can query "is pointer over the HUD".
|
// 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
|
// Stored on `myPanel` too so OnDestroy only clears the static if it still
|
||||||
// points at this instance (defensive against re-creation overlap).
|
// points at this instance (defensive against re-creation overlap).
|
||||||
|
|
@ -799,12 +825,21 @@ namespace TD.UI
|
||||||
UpdateEnemyInfoIfShown();
|
UpdateEnemyInfoIfShown();
|
||||||
UpdateDraftVisibility();
|
UpdateDraftVisibility();
|
||||||
UpdateSpellCooldowns();
|
UpdateSpellCooldowns();
|
||||||
|
|
||||||
|
HandleGameMenuInput();
|
||||||
|
|
||||||
|
// Chat and gameplay hotkeys are both off-limits while the menu is up —
|
||||||
|
// Enter shouldn't pop chat open behind the overlay, and Q/W/E/R shouldn't
|
||||||
|
// start a tower placement the player can't see.
|
||||||
|
if (!IsModalUiOpen)
|
||||||
|
{
|
||||||
HandleChatInput();
|
HandleChatInput();
|
||||||
|
|
||||||
// Skip gameplay hotkeys while the chat input is focused — letters
|
// Skip gameplay hotkeys while the chat input is focused — letters
|
||||||
// typed into chat should not also fire Q/W/E/R tower builds.
|
// typed into chat should not also fire Q/W/E/R tower builds.
|
||||||
if (!IsTextInputActive)
|
if (!IsTextInputActive)
|
||||||
HandleHotkeys();
|
HandleHotkeys();
|
||||||
|
}
|
||||||
|
|
||||||
minimapView?.Tick();
|
minimapView?.Tick();
|
||||||
}
|
}
|
||||||
|
|
@ -842,6 +877,59 @@ namespace TD.UI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- Gear menu ---------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Escape handling for the gear menu, plus publishing <see cref="IsModalUiOpen"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Escape is shared.</b> Five other systems already consume Escape
|
||||||
|
/// (cancel placement, cancel paint, cancel spell aim, close chat, clear selection),
|
||||||
|
/// and Unity gives no ordering guarantee between their Update calls. Rather than
|
||||||
|
/// introduce a cross-component arbiter, the menu claims Escape only when nothing
|
||||||
|
/// else has anything to cancel — a predicate over state that doesn't change during
|
||||||
|
/// the frame, so the outcome is the same regardless of which Update ran first.</para>
|
||||||
|
///
|
||||||
|
/// <para>The player-visible result is the familiar RTS stack: Escape backs out of
|
||||||
|
/// placement / paint / aiming / typing first, then clears the selection, then opens
|
||||||
|
/// the menu. While the menu is open Escape backs out one page at a time and
|
||||||
|
/// <see cref="IsModalUiOpen"/> keeps the gameplay handlers from seeing the key at
|
||||||
|
/// all — so the transition frame is the only one where ordering could matter, and
|
||||||
|
/// on that frame the other systems are by definition idle.</para>
|
||||||
|
/// </remarks>
|
||||||
|
private void HandleGameMenuInput()
|
||||||
|
{
|
||||||
|
if (gameMenu == null) return;
|
||||||
|
|
||||||
|
IsModalUiOpen = gameMenu.IsOpen;
|
||||||
|
|
||||||
|
var kb = Keyboard.current;
|
||||||
|
if (kb == null || !kb.escapeKey.wasPressedThisFrame) return;
|
||||||
|
|
||||||
|
if (gameMenu.IsOpen)
|
||||||
|
{
|
||||||
|
gameMenu.Back();
|
||||||
|
}
|
||||||
|
else if (!EscapeClaimedByGameplay())
|
||||||
|
{
|
||||||
|
gameMenu.Open();
|
||||||
|
}
|
||||||
|
|
||||||
|
IsModalUiOpen = gameMenu.IsOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when some other system will act on this frame's Escape press. Spell aiming
|
||||||
|
// isn't checked directly (BuilderSpellCastController isn't referenced here) because
|
||||||
|
// it requires the local builder to be selected, which the selection check already
|
||||||
|
// covers.
|
||||||
|
private bool EscapeClaimedByGameplay()
|
||||||
|
{
|
||||||
|
if (IsTextInputActive || chatInputOpen) return true;
|
||||||
|
if (placementController != null && placementController.IsPlacing) return true;
|
||||||
|
if (paintController != null && paintController.IsPainting) return true;
|
||||||
|
return SelectionState.Instance?.SelectedObject != null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads raw keyboard state via the New Input System and fires the matching
|
/// 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
|
/// action for any bound hotkey pressed this frame. Mirrors the disabled-button
|
||||||
|
|
@ -867,6 +955,11 @@ namespace TD.UI
|
||||||
{
|
{
|
||||||
if (Instance == this) Instance = null;
|
if (Instance == this) Instance = null;
|
||||||
|
|
||||||
|
// The menu dies with the HUD (scene change / disconnect). Clear the static so a
|
||||||
|
// stale "modal open" can't gate input in the next scene.
|
||||||
|
IsModalUiOpen = false;
|
||||||
|
gameMenu = null;
|
||||||
|
|
||||||
minimapView?.Dispose();
|
minimapView?.Dispose();
|
||||||
minimapView = null;
|
minimapView = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,30 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gear button at the far right of the top bar. Explicit min/max width and zero
|
||||||
|
padding override the default Button theme, which would otherwise pad it out
|
||||||
|
to a text-sized control. background-image: none is required too — the default
|
||||||
|
runtime theme skins Button with a light rounded sprite, and setting only
|
||||||
|
background-color leaves that sprite on top (renders as a blank pale square). */
|
||||||
|
.menu-button {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
min-width: 22px;
|
||||||
|
min-height: 22px;
|
||||||
|
max-width: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 0 0 0 12px;
|
||||||
|
padding: 0;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: rgba(90, 80, 32, 1);
|
||||||
|
background-image: none;
|
||||||
|
background-color: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button:hover {
|
||||||
|
background-color: rgba(90, 80, 32, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
.gold-label {
|
.gold-label {
|
||||||
color: rgb(255, 224, 102);
|
color: rgb(255, 224, 102);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@
|
||||||
<ui:Label name="leaked-label" text="leaked: ?" class="dimmed-label"/>
|
<ui:Label name="leaked-label" text="leaked: ?" class="dimmed-label"/>
|
||||||
<ui:Label name="lives-label" text="lives: ?" class="dimmed-label"/>
|
<ui:Label name="lives-label" text="lives: ?" class="dimmed-label"/>
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
|
<!-- Gear → opens the in-match menu (Escape does the same). The icon is
|
||||||
|
painted procedurally by GameMenuView, so no sprite asset is required. -->
|
||||||
|
<ui:Button name="menu-button" text="" class="menu-button"/>
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
<ui:VisualElement name="main-area" class="main-area">
|
<ui:VisualElement name="main-area" class="main-area">
|
||||||
<ui:VisualElement name="map-area" class="map-area">
|
<ui:VisualElement name="map-area" class="map-area">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue