using ProjectM.Simulation; using Unity.Entities; using UnityEngine; using UnityEngine.UIElements; namespace ProjectM.Client { /// /// Drives the front-end main menu (UI Toolkit). Lives on a GameObject (with a UIDocument) in /// MainMenu.unity — build index 0. On it ENSURES a default "menu" world exists (the /// bootstrap creates one on first boot, but on return-from-game World.DisposeAllWorlds left none and /// Initialize does not re-run), ensures the EventSystem, and assigns the shared PanelSettings; on /// enable it builds the menu. Single/Host/Join hand off to . /// [RequireComponent(typeof(UIDocument))] public class MainMenuController : MonoBehaviour { UIDocument _doc; VisualElement _mainPanel; VisualElement _settingsPanel; VisualElement _howToPanel; TextField _ipField; Label _classLabel; bool _autoStarted; void Awake() { EnsureMenuWorld(); MenuUi.EnsureEventSystem(); _doc = GetComponent(); if (_doc.panelSettings == null) _doc.panelSettings = MenuUi.LoadPanelSettings(); // The menu owns the cursor. UnityEngine.Cursor.lockState = CursorLockMode.None; UnityEngine.Cursor.visible = true; _autoStarted = TryAutoStartFromCommandLine(); } void OnEnable() { if (_autoStarted) return; // headless CLI co-op session; don't build the menu UI if (_doc == null) _doc = GetComponent(); var root = _doc.rootVisualElement; if (root == null) return; root.Clear(); BuildMain(root); } /// /// Dev/automation hook: a player launched with -mhost auto-hosts; -mjoin <ip> auto-joins /// (ip optional, defaults to loopback). Lets two standalone builds form a co-op session headlessly for /// testing — the same path the menu buttons use, no UI clicks. /// No effect when neither arg is present. Returns true if a session was started. /// static bool TryAutoStartFromCommandLine() { var args = System.Environment.GetCommandLineArgs(); for (int i = 0; i < args.Length; i++) { if (args[i] == "-mhost") { WorldLauncher.StartSession(SessionMode.Host, "", false); return true; } if (args[i] == "-mjoin") { string ip = (i + 1 < args.Length && !args[i + 1].StartsWith("-")) ? args[i + 1] : "127.0.0.1"; WorldLauncher.StartSession(SessionMode.Join, ip, false); return true; } } return false; } static void EnsureMenuWorld() { var w = World.DefaultGameObjectInjectionWorld; if (w == null || !w.IsCreated) DefaultWorldInitialization.Initialize("MenuWorld", false); } void BuildMain(VisualElement root) { _mainPanel = MenuUi.FullScreenRoot(true); var card = MenuUi.Card("LANTERN"); card.Add(MenuUi.Caption("Light is territory — co-op descent")); // Why the last session ended (connection watchdog / bad join address) — shown once, then cleared. if (!string.IsNullOrEmpty(SessionEnd.Reason)) { var reason = MenuUi.Caption(SessionEnd.Reason); reason.style.color = new Color(1f, 0.55f, 0.35f); reason.style.whiteSpace = WhiteSpace.Normal; reason.style.maxWidth = 340; SessionEnd.Reason = null; card.Add(reason); } // Slice 2: class picker -> sets WorldLauncher.SelectedClass for the next session (Bathynaut melee / Harpooner ranged). _classLabel = new Label(ClassName(WorldLauncher.SelectedClass)); _classLabel.style.unityTextAlign = TextAnchor.MiddleCenter; _classLabel.style.marginTop = 6; _classLabel.style.marginBottom = 2; card.Add(_classLabel); var classRow = new VisualElement(); classRow.style.flexDirection = FlexDirection.Row; classRow.style.justifyContent = Justify.Center; classRow.Add(MenuUi.Button("Bathynaut", () => SelectClass((byte)FrameKind.Bathynaut))); classRow.Add(MenuUi.Button("Harpooner", () => SelectClass((byte)FrameKind.Harpooner))); card.Add(classRow); card.Add(MenuUi.Button("Single Player", () => Launch(SessionMode.Single, false))); if (SaveService.HasSave()) card.Add(MenuUi.Button("Continue", () => Launch(SessionMode.Single, true))); card.Add(MenuUi.Button("Host Co-op (LAN)", () => Launch(SessionMode.Host, SaveService.HasSave()))); _ipField = new TextField("Join IP") { value = "127.0.0.1" }; _ipField.style.marginTop = 8; card.Add(_ipField); card.Add(MenuUi.Button("Join", () => Launch(SessionMode.Join, false))); card.Add(MenuUi.Button("Settings", ShowSettings)); card.Add(MenuUi.Button("How to Play", ShowHowToPlay)); // 2026-08-07 audit purge: the "Replay Tutorial" button re-armed first-run coach-marks whose system // DR-051 deleted — it wrote OnboardingMask/TutorialHints that nothing reads. Removed rather than left // as a button that visibly does nothing. The How-to-Play card above is the real reference. card.Add(MenuUi.Button("Quit", Quit)); _mainPanel.Add(card); root.Add(_mainPanel); } void Launch(SessionMode mode, bool loadSave) { string ip = _ipField != null ? _ipField.value : "127.0.0.1"; WorldLauncher.StartSession(mode, ip, loadSave); } void SelectClass(byte classId) { WorldLauncher.SelectedClass = classId; if (_classLabel != null) _classLabel.text = ClassName(classId); } static string ClassName(byte classId) => classId == (byte)FrameKind.Harpooner ? "CLASS: Harpooner (ranged)" : "CLASS: Bathynaut (melee)"; void ShowSettings() { _mainPanel.style.display = DisplayStyle.None; _settingsPanel = SettingsScreen.Build(HideSettings); _doc.rootVisualElement.Add(_settingsPanel); } void HideSettings() { if (_settingsPanel != null) { _settingsPanel.RemoveFromHierarchy(); _settingsPanel = null; } _mainPanel.style.display = DisplayStyle.Flex; } void ShowHowToPlay() { _mainPanel.style.display = DisplayStyle.None; _howToPanel = HowToPlayPanel.Build(HideHowToPlay); _doc.rootVisualElement.Add(_howToPanel); } void HideHowToPlay() { if (_howToPanel != null) { _howToPanel.RemoveFromHierarchy(); _howToPanel = null; } _mainPanel.style.display = DisplayStyle.Flex; } static void Quit() { #if UNITY_EDITOR UnityEditor.EditorApplication.isPlaying = false; #else Application.Quit(); #endif } } }