Bind online session browser to canvas, drop IMGUI

- OnlineSessionManager: remove OnGUI/IMGUI state; now a logic-only layer
  exposing Host/Refresh/Join/Disconnect plus Status/IsBusy/IsConnected/
  InSession/Sessions and Changed/SessionsChanged events
- OnlineSessionBrowserUI (new): drives the Servers canvas — 4 buttons,
  session list from Entry Template clones (name + players), row selection,
  state-driven interactability, status footer
- Main scene: attach binder, add status footer line, wire 8 refs; dev
  bootstrap panel disabled
- Online Session Manager.prefab (new)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-28 11:03:26 +02:00
1 parent 286473930a
commit d27ee16004
6 files changed
+3135 -85

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// uGUI binding for <see cref="OnlineSessionManager"/>. Drives the server-browser canvas - Host / Refresh /
/// pick a row / Join / Disconnect plus a status line - and mirrors the manager's state through its
/// <see cref="OnlineSessionManager.Changed"/> / <see cref="OnlineSessionManager.SessionsChanged"/> events.
/// Replaces the old IMGUI browser. Lives on the browser canvas; the session logic stays on the
/// NetworkManager's <see cref="OnlineSessionManager"/>.
///
/// Selection model: Refresh fills the list with clones of <see cref="entryTemplate"/> (each a clickable row
/// showing name + player count). Clicking a row selects it; Join connects to the selected session. While
/// connected, only Disconnect is interactable.
/// </summary>
public class OnlineSessionBrowserUI : MonoBehaviour
{
[SerializeField] private OnlineSessionManager manager;
[Header("Buttons")]
[SerializeField] private Button hostButton;
[SerializeField] private Button refreshButton;
[SerializeField] private Button joinButton;
[SerializeField] private Button disconnectButton;
[Header("List")]
[Tooltip("Content RectTransform of the session ScrollRect (rows are added here).")]
[SerializeField] private RectTransform listContent;
[Tooltip("A row prototype kept disabled; cloned once per session. Children named 'Name' and 'Players' carry the text.")]
[SerializeField] private GameObject entryTemplate;
[Header("Status")]
[SerializeField] private TMP_Text statusLabel;
[Tooltip("Background tint of the selected row.")]
[SerializeField] private Color selectedTint = new Color(0.26f, 0.52f, 0.96f, 1f);
[Tooltip("Background tint of an unselected row.")]
[SerializeField] private Color normalTint = new Color(1f, 1f, 1f, 0.05f);
private struct Row { public string id; public Image image; public GameObject go; }
private readonly List<Row> _rows = new List<Row>();
private string _selectedId;
private void Awake()
{
// The template is the prototype, never shown itself - layout ignores it while disabled.
if (entryTemplate) entryTemplate.SetActive(false);
}
private void OnEnable()
{
if (hostButton) hostButton.onClick.AddListener(OnHost);
if (refreshButton) refreshButton.onClick.AddListener(OnRefresh);
if (joinButton) joinButton.onClick.AddListener(OnJoin);
if (disconnectButton) disconnectButton.onClick.AddListener(OnDisconnect);
if (manager)
{
manager.Changed += OnManagerChanged;
manager.SessionsChanged += RebuildList;
}
RebuildList();
OnManagerChanged();
}
private void OnDisable()
{
if (hostButton) hostButton.onClick.RemoveListener(OnHost);
if (refreshButton) refreshButton.onClick.RemoveListener(OnRefresh);
if (joinButton) joinButton.onClick.RemoveListener(OnJoin);
if (disconnectButton) disconnectButton.onClick.RemoveListener(OnDisconnect);
if (manager)
{
manager.Changed -= OnManagerChanged;
manager.SessionsChanged -= RebuildList;
}
}
private void OnHost() { if (manager) manager.Host(); }
private void OnRefresh() { if (manager) manager.Refresh(); }
private void OnJoin() { if (manager && !string.IsNullOrEmpty(_selectedId)) manager.Join(_selectedId); }
private void OnDisconnect() { if (manager) manager.Disconnect(); }
private void OnManagerChanged()
{
if (!manager) return;
if (statusLabel) statusLabel.text = manager.Status;
UpdateButtons();
}
private void UpdateButtons()
{
if (!manager) return;
bool busy = manager.IsBusy;
bool connected = manager.IsConnected;
bool hasSelection = !string.IsNullOrEmpty(_selectedId);
if (hostButton) hostButton.interactable = !busy && !connected;
if (refreshButton) refreshButton.interactable = !busy && !connected;
if (joinButton) joinButton.interactable = !busy && !connected && hasSelection;
if (disconnectButton) disconnectButton.interactable = !busy && connected;
}
private void RebuildList()
{
for (int i = 0; i < _rows.Count; i++)
if (_rows[i].go) Destroy(_rows[i].go);
_rows.Clear();
if (manager && entryTemplate && listContent)
{
var sessions = manager.Sessions;
bool selectionStillPresent = false;
for (int i = 0; i < sessions.Count; i++)
{
var s = sessions[i];
var go = Instantiate(entryTemplate, listContent);
go.SetActive(true);
var nameText = FindText(go.transform, "Name");
if (nameText) nameText.text = s.Name;
var playersText = FindText(go.transform, "Players");
if (playersText)
{
int taken = Mathf.Max(0, s.MaxPlayers - s.AvailableSlots);
playersText.text = $"{taken}/{s.MaxPlayers}";
}
string id = s.Id; // capture per-row for the click closure
var button = go.GetComponent<Button>();
if (button) button.onClick.AddListener(() => Select(id));
_rows.Add(new Row { id = id, image = go.GetComponent<Image>(), go = go });
if (id == _selectedId) selectionStillPresent = true;
}
if (!selectionStillPresent) _selectedId = null;
}
ApplyTints();
UpdateButtons();
}
private void Select(string id)
{
_selectedId = id;
ApplyTints();
UpdateButtons();
}
private void ApplyTints()
{
for (int i = 0; i < _rows.Count; i++)
if (_rows[i].image)
_rows[i].image.color = _rows[i].id == _selectedId ? selectedTint : normalTint;
}
private static TMP_Text FindText(Transform root, string childName)
{
var t = root.Find(childName);
return t ? t.GetComponent<TMP_Text>() : null;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c001d18ade3f180488179878fbaa3eef
+90 -83
View File
@@ -20,8 +20,9 @@ using UnityEngine;
/// NetworkManager callbacks fire exactly as they do for loopback hosting, so MatchManager, spawning and
/// authority are unaffected.
///
/// This is a quick IMGUI server browser for clone testing; replace with proper uGUI later. The loopback
/// dev path (see <see cref="NetworkBootstrap"/>, H/C/J/K) stays for fast local testing - online is additive.
/// This is the logic layer only: state + async host/refresh/join/disconnect calls, surfaced through the
/// <see cref="Changed"/>/<see cref="SessionsChanged"/> events. The uGUI server browser that drives it lives
/// on its own canvas (<see cref="OnlineSessionBrowserUI"/>); this component carries no UI of its own.
/// Requires the project to be linked to a UGS cloud project (Project Settings > Services) with Relay,
/// Lobby and Authentication (Anonymous) enabled; otherwise sign-in fails with a clear status message.
/// Attach to the same GameObject as the NetworkManager.
@@ -33,17 +34,26 @@ public class OnlineSessionManager : MonoBehaviour
[Tooltip("Maximum players per hosted session (host included).")]
public int maxPlayers = 8;
[Tooltip("Draw the on-screen online server-browser panel (below the dev panel).")]
public bool showGui = true;
private NetworkManager _net;
private ISession _session; // active session (host or client); null when not in one
private IList<ISessionInfo> _browse = new List<ISessionInfo>();
private List<ISessionInfo> _sessions = new List<ISessionInfo>();
private string _hostName = "";
private string _status = "Idle";
private bool _busy;
private bool _leaving; // suppress the defensive leave while an explicit leave is in flight
private Vector2 _scroll;
/// <summary>Raised when status, busy state, or the connection state changes, so a bound UI can re-render.
/// Async continuations resume on the main thread, so handlers run there.</summary>
public event Action Changed;
/// <summary>Raised after the browsable session list is replaced (a refresh completed).</summary>
public event Action SessionsChanged;
public string Status => _status;
public bool IsBusy => _busy;
public bool IsConnected => _net && _net.IsListening;
public bool InSession => _session != null;
public IReadOnlyList<ISessionInfo> Sessions => _sessions;
private void Awake()
{
@@ -54,6 +64,8 @@ public class OnlineSessionManager : MonoBehaviour
private void OnEnable()
{
if (!_net) return;
_net.OnServerStarted += OnNetworkStarted;
_net.OnClientStarted += OnNetworkStarted;
_net.OnServerStopped += OnNetworkStopped;
_net.OnClientStopped += OnNetworkStopped;
}
@@ -61,10 +73,14 @@ public class OnlineSessionManager : MonoBehaviour
private void OnDisable()
{
if (!_net) return;
_net.OnServerStarted -= OnNetworkStarted;
_net.OnClientStarted -= OnNetworkStarted;
_net.OnServerStopped -= OnNetworkStopped;
_net.OnClientStopped -= OnNetworkStopped;
}
private void OnNetworkStarted() => Changed?.Invoke();
// The match-end / disconnect / Shutdown paths stop the NetworkManager without telling UGS. Leave the
// session here too so the Relay allocation and the public lobby entry are released.
private void OnNetworkStopped(bool _)
@@ -72,6 +88,7 @@ public class OnlineSessionManager : MonoBehaviour
var stale = _session;
_session = null;
if (stale != null && !_leaving) LeaveSilently(stale);
Changed?.Invoke();
}
private async void LeaveSilently(ISession session)
@@ -80,6 +97,27 @@ public class OnlineSessionManager : MonoBehaviour
catch (Exception e) { Debug.LogWarning($"[OnlineSession] leave-on-stop failed: {e.Message}"); }
}
// ----- Public API (driven by OnlineSessionBrowserUI) -------------------------------------------------
public void Host() => HostAsync();
public void Refresh() => RefreshAsync();
public void Join(string sessionId) => JoinAsync(sessionId);
public void Disconnect() => DisconnectAsync();
// ----- Implementation --------------------------------------------------------------------------------
private void SetStatus(string status)
{
_status = status;
Changed?.Invoke();
}
private void SetBusy(bool busy)
{
_busy = busy;
Changed?.Invoke();
}
private async System.Threading.Tasks.Task EnsureSignedInAsync()
{
if (UnityServices.State != ServicesInitializationState.Initialized)
@@ -113,131 +151,100 @@ public class OnlineSessionManager : MonoBehaviour
private async void HostAsync()
{
if (_busy) return;
_busy = true;
if (_busy || IsConnected) return;
SetBusy(true);
try
{
_status = "Signing in…";
SetStatus("Signing in…");
await EnsureSignedInAsync();
_status = "Creating session…";
SetStatus("Creating session…");
var options = new SessionOptions
{
Name = string.IsNullOrWhiteSpace(_hostName) ? "Chameleon game" : _hostName.Trim(),
MaxPlayers = Mathf.Max(2, maxPlayers)
}.WithRelayNetwork();
_session = await MultiplayerService.Instance.CreateSessionAsync(options);
_status = $"Hosting \"{_session.Name}\" (code {_session.Code})";
SetStatus($"Hosting \"{_session.Name}\" (code {_session.Code})");
}
catch (Exception e)
{
_status = $"Host failed: {e.Message}";
SetStatus($"Host failed: {e.Message}");
Debug.LogException(e);
}
finally { _busy = false; }
finally { SetBusy(false); }
}
private async void RefreshAsync()
{
if (_busy) return;
_busy = true;
SetBusy(true);
try
{
_status = "Signing in…";
SetStatus("Signing in…");
await EnsureSignedInAsync();
_status = "Querying sessions…";
SetStatus("Querying sessions…");
var results = await MultiplayerService.Instance.QuerySessionsAsync(new QuerySessionsOptions());
_browse = results.Sessions;
_status = $"{_browse.Count} session(s) found";
_sessions = new List<ISessionInfo>(results.Sessions);
SessionsChanged?.Invoke();
SetStatus($"{_sessions.Count} session(s) found");
}
catch (Exception e)
{
_status = $"Refresh failed: {e.Message}";
SetStatus($"Refresh failed: {e.Message}");
Debug.LogException(e);
}
finally { _busy = false; }
finally { SetBusy(false); }
}
private async void JoinAsync(string sessionId)
{
if (_busy) return;
_busy = true;
if (_busy || IsConnected || string.IsNullOrEmpty(sessionId)) return;
SetBusy(true);
try
{
_status = "Signing in…";
SetStatus("Signing in…");
await EnsureSignedInAsync();
_status = "Joining session…";
SetStatus("Joining session…");
_session = await MultiplayerService.Instance.JoinSessionByIdAsync(sessionId);
_status = $"Joined \"{_session.Name}\"";
SetStatus($"Joined \"{_session.Name}\"");
}
catch (Exception e)
{
_status = $"Join failed: {e.Message}";
SetStatus($"Join failed: {e.Message}");
Debug.LogException(e);
}
finally { _busy = false; }
finally { SetBusy(false); }
}
private async void LeaveAsync()
private async void DisconnectAsync()
{
if (_busy || _session == null) return;
_busy = true;
_leaving = true;
try
if (_busy) return;
// A UGS session leaves cleanly (LeaveAsync also tears down the NGO connection). A plain loopback
// host/client - if one is ever active without a session - just shuts the transport down.
if (_session != null)
{
_status = "Leaving…";
await _session.LeaveAsync(); // also tears down the NGO connection
_status = "Left session";
}
catch (Exception e)
{
_status = $"Leave failed: {e.Message}";
Debug.LogException(e);
}
finally { _session = null; _leaving = false; _busy = false; }
}
private void OnGUI()
{
if (!showGui || !_net) return;
GUILayout.BeginArea(new Rect(10, 180, 340, 360), GUI.skin.box);
GUILayout.Label("Online (UGS Relay + Lobby)");
GUILayout.Label(_status);
GUI.enabled = !_busy;
if (!_net.IsListening)
{
GUILayout.Label("Session name:");
_hostName = GUILayout.TextField(_hostName);
if (GUILayout.Button("Host Online")) HostAsync();
GUILayout.Space(6);
if (GUILayout.Button("Refresh List")) RefreshAsync();
_scroll = GUILayout.BeginScrollView(_scroll, GUILayout.Height(180));
if (_browse.Count == 0)
GUILayout.Label("No sessions. Click Refresh.");
foreach (var s in _browse)
SetBusy(true);
_leaving = true;
var session = _session;
_session = null;
try
{
GUILayout.BeginHorizontal();
int taken = Mathf.Max(0, s.MaxPlayers - s.AvailableSlots);
GUILayout.Label($"{s.Name} ({taken}/{s.MaxPlayers})");
if (GUILayout.Button("Join", GUILayout.Width(60))) JoinAsync(s.Id);
GUILayout.EndHorizontal();
SetStatus("Leaving…");
await session.LeaveAsync();
SetStatus("Left session");
}
GUILayout.EndScrollView();
catch (Exception e)
{
SetStatus($"Leave failed: {e.Message}");
Debug.LogException(e);
}
finally { _leaving = false; SetBusy(false); }
}
else if (_session != null)
else if (IsConnected)
{
GUILayout.Label($"In session: {_session.Name}");
if (GUILayout.Button("Leave Session")) LeaveAsync();
_net.Shutdown();
SetStatus("Disconnected");
}
else
{
GUILayout.Label("Local (loopback) session active.");
}
GUI.enabled = true;
GUILayout.EndArea();
}
}