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

233 lines
9.5 KiB
C#

// Assets/_Project/Scripts/UI/MainMenuController.cs
using Unity.Netcode;
using UnityEngine;
using UnityEngine.UIElements;
using TD.Net;
namespace TD.UI
{
/// <summary>
/// Drives the main menu UI. Requires a <see cref="UIDocument"/> on the same
/// GameObject. Builds the UI programmatically — no UXML needed for v1.
/// </summary>
/// <remarks>
/// <para><b>Buttons.</b>
/// <list type="bullet">
/// <item><b>Host</b> — calls <see cref="NetworkBootstrap.StartHost"/> on
/// the default port, then NGO scene-loads the Lobby. Clients that
/// join later get pulled into whatever scene the server is in.</item>
/// <item><b>Join</b> — reveals IP + port fields, then calls
/// <see cref="NetworkBootstrap.StartClient"/>. The server will pull
/// the client into the current networked scene (Lobby or Match)
/// once the connection completes.</item>
/// <item><b>Quit</b> — Application.Quit (no-op in the editor).</item>
/// </list></para>
///
/// <para><b>Future Steam integration.</b> 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.</para>
/// </remarks>
[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<UIDocument>();
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
}
}
}