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

407 lines
18 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Assets/_Project/Scripts/UI/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();
}
}
}