// Assets/_Project/Scripts/UI/MainMenuController.cs using System.Collections; using Unity.Netcode; using UnityEngine; using UnityEngine.UIElements; using TD.Core; using TD.Gameplay; using TD.Net; namespace TD.UI { /// /// Drives the main menu UI. Requires a on the same /// GameObject. Builds the UI programmatically — no UXML needed for v1. /// /// /// Buttons. /// /// Host — calls on /// the default port, then NGO scene-loads the Lobby. Clients that /// join later get pulled into whatever scene the server is in. /// Join — reveals IP + port fields, then calls /// . The server will pull /// the client into the current networked scene (Lobby or Match) /// once the connection completes. /// Quit — Application.Quit (no-op in the editor). /// /// /// Future Steam integration. A lobby-browser panel will be /// added here. The Host button will create a Steam lobby instead of a /// direct-IP host; Join becomes "browse public lobbies" + "accept friend /// invite". See Project_Roadmap.md §1.7-Future Steam Lobby Migration. /// [RequireComponent(typeof(UIDocument))] public class MainMenuController : MonoBehaviour { // ----- Inspector -------------------------------------------------- [Tooltip("Default port shown in the Join field and used by the Host button.")] [SerializeField] private ushort defaultPort = NetworkBootstrap.DefaultPort; [Tooltip("Default address pre-filled in the Join field for solo testing.")] [SerializeField] private string defaultJoinAddress = NetworkBootstrap.DefaultConnectAddress; // ----- Cached UI elements ----------------------------------------- private Button hostButton; private Button joinButton; private Button quitButton; private Button quickStartButton; private VisualElement joinPanel; private TextField joinAddressField; private TextField joinPortField; private Button joinConfirmButton; private Button joinCancelButton; private Label statusLabel; // ----- Lifecycle -------------------------------------------------- private void Start() { // UIDocument creates its panel in OnEnable, which runs after Awake. // Start is the safe time to access rootVisualElement. var doc = GetComponent(); var root = doc?.rootVisualElement; if (root == null) { Debug.LogError("[MainMenuController] rootVisualElement is null. " + "Check the UIDocument's Panel Settings."); return; } BuildUI(root); } // ----- UI construction -------------------------------------------- private void BuildUI(VisualElement root) { // Centered vertical stack on a dark background. root.style.flexDirection = FlexDirection.Column; root.style.justifyContent = Justify.Center; root.style.alignItems = Align.Center; root.style.backgroundColor = new Color(0.05f, 0.05f, 0.08f, 1f); // The UIDocument root needs explicit width/height to fill the screen. root.style.width = Length.Percent(100); root.style.height = Length.Percent(100); // Title. var title = new Label("Unity Tower Defense"); title.style.fontSize = 48; title.style.color = Color.white; title.style.unityFontStyleAndWeight = FontStyle.Bold; title.style.marginBottom = 60; root.Add(title); // Primary button column. var buttonColumn = new VisualElement(); buttonColumn.style.flexDirection = FlexDirection.Column; buttonColumn.style.alignItems = Align.Center; root.Add(buttonColumn); hostButton = MakeMenuButton("Host Game", OnHostClicked); joinButton = MakeMenuButton("Join Game", OnJoinClicked); quitButton = MakeMenuButton("Quit", OnQuitClicked); quickStartButton = MakeMenuButton("Quick Start", OnQuickStartClicked); buttonColumn.Add(hostButton); buttonColumn.Add(joinButton); buttonColumn.Add(quitButton); buttonColumn.Add(quickStartButton); // Join sub-panel — hidden until Join is clicked. Holds the IP+port // fields and the Connect / Cancel buttons. joinPanel = new VisualElement(); joinPanel.style.flexDirection = FlexDirection.Column; joinPanel.style.alignItems = Align.Center; joinPanel.style.marginTop = 24; joinPanel.style.paddingTop = 16; joinPanel.style.paddingBottom = 16; joinPanel.style.paddingLeft = 24; joinPanel.style.paddingRight = 24; joinPanel.style.backgroundColor = new Color(0f, 0f, 0f, 0.4f); joinPanel.style.display = DisplayStyle.None; root.Add(joinPanel); joinAddressField = new TextField("Host address"); joinAddressField.value = defaultJoinAddress; joinAddressField.style.width = 280; StyleJoinFieldText(joinAddressField); joinPanel.Add(joinAddressField); joinPortField = new TextField("Port"); joinPortField.value = defaultPort.ToString(); joinPortField.style.width = 280; joinPortField.style.marginTop = 8; StyleJoinFieldText(joinPortField); joinPanel.Add(joinPortField); var joinButtons = new VisualElement(); joinButtons.style.flexDirection = FlexDirection.Row; joinButtons.style.marginTop = 12; joinPanel.Add(joinButtons); joinConfirmButton = new Button(OnJoinConfirmClicked) { text = "Connect" }; joinConfirmButton.style.minWidth = 120; joinConfirmButton.style.height = 32; joinConfirmButton.style.marginRight = 8; joinButtons.Add(joinConfirmButton); joinCancelButton = new Button(OnJoinCancelClicked) { text = "Cancel" }; joinCancelButton.style.minWidth = 120; joinCancelButton.style.height = 32; joinButtons.Add(joinCancelButton); // Status line at the bottom for connection feedback. statusLabel = new Label(string.Empty); statusLabel.style.color = new Color(1f, 0.6f, 0.3f); statusLabel.style.marginTop = 32; statusLabel.style.minHeight = 20; root.Add(statusLabel); } // TextField's visible text color lives on the inner "unity-text-input" // element, not on the TextField root. Setting it on the root alone // leaves the inner element's inherited white color in place — which is // invisible against the default white input background. Same gotcha as // the chat input's dark-styling path in HUDController. private static void StyleJoinFieldText(TextField field) { field.style.color = Color.black; var inner = field.Q("unity-text-input"); if (inner != null) inner.style.color = Color.black; } private static Button MakeMenuButton(string text, System.Action onClick) { var btn = new Button(() => onClick?.Invoke()) { text = text }; btn.style.minWidth = 240; btn.style.height = 48; btn.style.fontSize = 20; btn.style.marginBottom = 8; return btn; } // ----- Button handlers -------------------------------------------- private void OnHostClicked() { statusLabel.text = "Starting host…"; if (!NetworkBootstrap.StartHost(defaultPort)) { statusLabel.text = "Failed to start host. Check the console."; return; } // After StartHost the local peer is the server + client. Trigger // the networked scene load to take everyone to the Lobby. NGO // replicates this to any future-joining clients automatically. NetworkBootstrap.LoadSceneAsHost(SceneNames.Lobby); } private void OnJoinClicked() { // Toggle the join sub-panel. joinPanel.style.display = joinPanel.style.display == DisplayStyle.None ? DisplayStyle.Flex : DisplayStyle.None; } private void OnJoinConfirmClicked() { string address = string.IsNullOrWhiteSpace(joinAddressField.value) ? defaultJoinAddress : joinAddressField.value.Trim(); ushort port = defaultPort; if (!string.IsNullOrWhiteSpace(joinPortField.value) && ushort.TryParse(joinPortField.value.Trim(), out var parsed)) { port = parsed; } statusLabel.text = $"Connecting to {address}:{port}…"; if (!NetworkBootstrap.StartClient(address, port)) { statusLabel.text = "Failed to start client. Check the console."; return; } // The server's NGO SceneManager will pull this client into whatever // scene the server is in (Lobby or Match) once the connection // completes. SessionFlow handles disconnect recovery. joinPanel.style.display = DisplayStyle.None; } private void OnJoinCancelClicked() { joinPanel.style.display = DisplayStyle.None; statusLabel.text = string.Empty; } private void OnQuitClicked() { #if UNITY_EDITOR UnityEditor.EditorApplication.isPlaying = false; #else Application.Quit(); #endif } // Dev / testing shortcut: skips the lobby entirely. Hosts a single-player // session, auto-selects Race1 for the local player, and loads the Match // scene directly. Useful for iterating on gameplay without clicking // through Host → Lobby → Pick Race → Ready → Start every time. // // To remove for a shipping build: delete the button-creation lines in // BuildUI plus this method and its coroutine. No other consumers. private void OnQuickStartClicked() { statusLabel.text = "Quick starting…"; StartCoroutine(QuickStartCoroutine()); } private IEnumerator QuickStartCoroutine() { if (!NetworkBootstrap.StartHost(defaultPort)) { statusLabel.text = "Failed to start host. Check the console."; yield break; } // Wait for the local player's PlayerMatchState to spawn. Empirically // this happens synchronously inside StartHost, but waiting one frame // is cheap insurance against future NGO changes to spawn timing. // Cap the wait at 60 frames so a real failure doesn't silently hang. int safetyFrames = 0; while (PlayerMatchState.Local == null && safetyFrames++ < 60) yield return null; var pms = PlayerMatchState.Local; if (pms == null) { Debug.LogError("[MainMenuController] Quick Start: PlayerMatchState.Local " + "didn't appear within 60 frames after StartHost. Aborting."); statusLabel.text = "Quick Start failed: player did not spawn."; yield break; } // Server-only setters — host is server + client, so these are valid // directly. Skipping the RPC round-trip avoids any frame-of-latency // before LoadSceneAsHost reads RaceSelection on the way into Match. pms.SetRaceSelection(RaceId.Race1); pms.SetReady(true); // Skip Lobby — drop straight into the default map's scene. Pulls from // MapRegistry.Default (the first map authored in the registry — by convention // the 9-player map). Falls back to the hardcoded SceneNames.Match if the // registry isn't present (e.g. testing in editor without going through MainMenu, // though that's the very scene this controller lives in, so it should always // resolve in practice). string sceneToLoad; var registry = MapRegistry.Instance; var defaultMap = registry != null ? registry.Default : null; if (defaultMap != null && !string.IsNullOrEmpty(defaultMap.SceneName)) { sceneToLoad = defaultMap.SceneName; Debug.Log($"[MainMenu] Quick Start loading default map '{defaultMap.MapName}' " + $"(scene='{sceneToLoad}')."); } else { sceneToLoad = SceneNames.Match; Debug.LogWarning($"[MainMenu] Quick Start: MapRegistry default unavailable, " + $"falling back to hardcoded scene '{sceneToLoad}'."); } NetworkBootstrap.LoadSceneAsHost(sceneToLoad); } } }