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
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue