// Assets/_Project/Scripts/UI/MainMenuController.cs using Unity.Netcode; using UnityEngine; using UnityEngine.UIElements; 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 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); buttonColumn.Add(hostButton); buttonColumn.Add(joinButton); buttonColumn.Add(quitButton); // 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; joinAddressField.style.color = Color.white; joinPanel.Add(joinAddressField); joinPortField = new TextField("Port"); joinPortField.value = defaultPort.ToString(); joinPortField.style.width = 280; joinPortField.style.marginTop = 8; joinPortField.style.color = Color.white; 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); } 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 } } }