// 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(); } } }