321 lines
14 KiB
C#
321 lines
14 KiB
C#
// 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
|
|
{
|
|
/// <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 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<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);
|
|
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);
|
|
}
|
|
}
|
|
}
|