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

379 lines
16 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 hostPanel;
private TextField hostPortField;
private Button hostConfirmButton;
private Button hostCancelButton;
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);
// Host sub-panel — hidden until Host is clicked. Holds the port field
// and the Start Host / Cancel buttons.
hostPanel = new VisualElement();
hostPanel.style.flexDirection = FlexDirection.Column;
hostPanel.style.alignItems = Align.Center;
hostPanel.style.marginTop = 24;
hostPanel.style.paddingTop = 16;
hostPanel.style.paddingBottom = 16;
hostPanel.style.paddingLeft = 24;
hostPanel.style.paddingRight = 24;
hostPanel.style.backgroundColor = new Color(0f, 0f, 0f, 0.4f);
hostPanel.style.display = DisplayStyle.None;
root.Add(hostPanel);
hostPortField = new TextField("Port");
hostPortField.value = defaultPort.ToString();
hostPortField.style.width = 280;
StyleJoinFieldText(hostPortField);
hostPanel.Add(hostPortField);
var hostButtons = new VisualElement();
hostButtons.style.flexDirection = FlexDirection.Row;
hostButtons.style.marginTop = 12;
hostPanel.Add(hostButtons);
hostConfirmButton = new Button(OnHostConfirmClicked) { text = "Start Host" };
hostConfirmButton.style.minWidth = 120;
hostConfirmButton.style.height = 32;
hostConfirmButton.style.marginRight = 8;
hostButtons.Add(hostConfirmButton);
hostCancelButton = new Button(OnHostCancelClicked) { text = "Cancel" };
hostCancelButton.style.minWidth = 120;
hostCancelButton.style.height = 32;
hostButtons.Add(hostCancelButton);
// 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()
{
hostPanel.style.display = hostPanel.style.display == DisplayStyle.None
? DisplayStyle.Flex
: DisplayStyle.None;
}
private void OnHostConfirmClicked()
{
ushort port = defaultPort;
if (!string.IsNullOrWhiteSpace(hostPortField.value)
&& ushort.TryParse(hostPortField.value.Trim(), out var parsed))
{
port = parsed;
}
statusLabel.text = $"Starting host on port {port}…";
if (!NetworkBootstrap.StartHost(port))
{
statusLabel.text = "Failed to start host. Check the console.";
return;
}
hostPanel.style.display = DisplayStyle.None;
NetworkBootstrap.LoadSceneAsHost(SceneNames.Lobby);
}
private void OnHostCancelClicked()
{
hostPanel.style.display = DisplayStyle.None;
statusLabel.text = string.Empty;
}
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);
}
}
}