Files
MSCO/LobbyUI.cs
T

904 lines
45 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Multiplayer-Launcher im Hauptmenü (dunkles Design mit Seitenleiste, Statusleiste
/// und Minimieren). Seiten: P2P (Spieler hostet selbst) und Server Browser (Platzhalter,
/// dort kommt später auch Direct Connect zu dedizierten Servern hin).
/// </summary>
public class LobbyUI
{
enum Page { P2P, ServerBrowser, Favorites }
const float Width = 760f;
const float Height = 460f;
const float SidebarWidth = 190f;
const float StatusHeight = 36f;
static readonly Color Background = Hex(0x17, 0x17, 0x1b);
static readonly Color Sidebar = Hex(0x0f, 0x0f, 0x12);
static readonly Color Field = Hex(0x24, 0x24, 0x2a);
static readonly Color FieldFocus = Hex(0x2e, 0x2e, 0x36);
static readonly Color Accent = Hex(0xf2, 0x6b, 0x1d);
static readonly Color AccentHover = Hex(0xff, 0x82, 0x3a);
static readonly Color Secondary = Hex(0x2c, 0x2c, 0x33);
static readonly Color SecondaryHover = Hex(0x3a, 0x3a, 0x43);
static readonly Color Muted = Hex(0x8e, 0x8e, 0x99);
static readonly Color Online = Hex(0x3d, 0xd6, 0x8c);
static readonly Color Pending = Hex(0xf2, 0xc2, 0x1d);
Page page = Page.P2P;
Vector2 profileScroll;
bool quitConfirm;
bool minimized;
Vector2 playerScroll;
Vector2 serverScroll;
bool browserLoaded;
string selectedServer; // "adresse:port" des geöffneten Server-Profils
string searchInput = "";
bool showSteam = true, showNonSteam = true, hideFull;
readonly Dictionary<string, string> nicknameEdits = new Dictionary<string, string>(); // Favoriten-Tab: Adresse -> bearbeiteter Kurzname
string favoriteAddInput = "";
Vector2 favoritesScroll;
Texture2D texHeartOn, texHeartOff;
string nameInput;
string directInput;
const string PrefDirect = "MSCO_LastServer";
bool stylesReady;
Texture2D texBackground, texSidebar, texAccent, texOnline, texPending, texMuted, texRow;
List<ServerListEntry> shownAll, shownServers; // Stand des letzten Layout-Durchgangs
string layoutError;
GUIStyle dangerStyle;
GUIStyle windowBox, sidebarBox, rowBox, accentBox, pillStyle, pillMutedStyle, chipOnStyle, chipOffStyle;
GUIStyle logoStyle, taglineStyle, navStyle, navActiveStyle, titleStyle, textStyle, captionStyle,
fieldStyle, primaryStyle, secondaryStyle, statusStyle, tabStyle, headerCellStyle,
rowNameStyle, rowTagStyle, badgeStyle, toggleStyle, placeholderStyle;
/// <summary>Fläche, die das Fenster gerade belegt (GUI-Koordinaten, oben = 0).</summary>
public Rect ScreenRect { get; private set; }
/// <summary>Fenster offen (nicht minimiert) - dann ist das Spielmenü dahinter gesperrt.</summary>
public bool IsOpen { get { return !minimized; } }
/// <summary>Ist die Maus gerade über dem Fenster bzw. der eingeklappten Leiste?</summary>
public bool ContainsMouse()
{
Vector3 m = Input.mousePosition;
return ScreenRect.Contains(new Vector2(m.x, Screen.height - m.y));
}
public void Draw(MultiplayerManager mp)
{
EnsureStyles();
bool pendingJoin = mp.PendingJoinHost != null && !mp.IsInSession;
bool versionWarning = mp.PendingVersionWarning != null && !mp.IsInSession;
bool disconnected = mp.DisconnectNotice != null && !mp.IsInSession;
// Getrennt / Neu-Verbinden: eigenes Fenster in der Bildmitte, alles andere gesperrt
if (disconnected) { DrawPrompt(mp, false); return; }
if (mp.IsReconnecting) { DrawPrompt(mp, true); return; }
if (pendingJoin || versionWarning) minimized = false; // JOIN-Link / Warnung: Fenster zeigen
if (minimized) { DrawMinimized(mp); return; }
var window = new Rect((Screen.width - Width) / 2f, (Screen.height - Height) / 2f, Width, Height);
ScreenRect = window;
GUI.Box(window, GUIContent.none, windowBox);
DrawSidebar(new Rect(window.x, window.y, SidebarWidth, Height - StatusHeight));
var content = new Rect(window.x + SidebarWidth + 32, window.y + 28,
Width - SidebarWidth - 64, Height - StatusHeight - 52);
GUILayout.BeginArea(content);
if (versionWarning) DrawVersionWarning(mp);
else if (pendingJoin) DrawJoinConfirm(mp);
else if (mp.IsInSession) DrawSession(mp);
else if (page == Page.P2P) DrawP2P(mp);
else if (page == Page.Favorites) DrawFavorites(mp);
else DrawServerBrowser(mp);
GUILayout.EndArea();
DrawStatusBar(mp, new Rect(window.x, window.yMax - StatusHeight, Width, StatusHeight));
}
// ---------------------------------------------------------------- Bereiche
void DrawSidebar(Rect r)
{
GUI.Box(r, GUIContent.none, sidebarBox);
// Logo + Schriftzug
Texture2D logo = Branding.LogoTexture;
float textX = r.x + 22;
if (logo != null)
{
GUI.DrawTexture(new Rect(r.x + 20, r.y + 20, 44, 44), logo);
textX = r.x + 72;
}
GUI.Label(new Rect(textX, r.y + 24, r.xMax - textX, 40), "MSCO", logoStyle);
GUI.Label(new Rect(r.x + 22, r.y + 70, r.width - 30, 20), "MY SUMMER CAR ONLINE", taglineStyle);
float y = r.y + 110;
if (NavButton(new Rect(r.x, y, r.width, 42), "P2P", page == Page.P2P)) page = Page.P2P;
y += 44;
if (NavButton(new Rect(r.x, y, r.width, 42), "Server Browser", page == Page.ServerBrowser)) page = Page.ServerBrowser;
y += 44;
int favCount = Favorites.All.Count;
if (NavButton(new Rect(r.x, y, r.width, 42), "Favorites" + (favCount > 0 ? " (" + favCount + ")" : ""), page == Page.Favorites)) page = Page.Favorites;
// Eigenes Hauptmenü: Spiel beenden (die Knöpfe des Spiel-Menüs sind ausgeblendet)
if (quitConfirm)
{
if (GUI.Button(new Rect(r.x + 16, r.yMax - 118, r.width - 32, 36), "QUIT GAME?", dangerStyle)) Application.Quit();
if (GUI.Button(new Rect(r.x + 16, r.yMax - 76, r.width - 32, 30), "CANCEL", tabStyle)) quitConfirm = false;
}
else if (GUI.Button(new Rect(r.x + 16, r.yMax - 76, r.width - 32, 36), "QUIT", tabStyle)) quitConfirm = true;
GUI.Label(new Rect(r.x + 24, r.yMax - 30, r.width - 48, 20), "v" + MSCO.ModVersion, taglineStyle);
}
bool NavButton(Rect r, string text, bool active)
{
if (active) GUI.Box(new Rect(r.x + 4, r.y + 8, 4, r.height - 16), GUIContent.none, accentBox); // Akzentbalken links
return GUI.Button(r, text, active ? navActiveStyle : navStyle);
}
void DrawP2P(MultiplayerManager mp)
{
GUILayout.Label("P2P", titleStyle);
GUILayout.Label("Host a game from your own save and invite up to " + (MultiplayerManager.MaxPlayers - 1) +
" friends through Steam.", textStyle);
GUILayout.Space(18);
if (mp.SteamAvailable)
{
if (GUILayout.Button("HOST GAME", primaryStyle, GUILayout.Height(46), GUILayout.Width(250)))
mp.HostSteam();
GUILayout.Space(14);
GUILayout.Label("To join a friend: accept their invite in Steam, or right-click them in your " +
"Steam friends list and choose \"Join Game\".", textStyle);
}
else
{
GUILayout.Label("Steam is not running - start the game through Steam to host or join.", textStyle);
}
}
void DrawServerBrowser(MultiplayerManager mp)
{
ServerBrowser browser = mp.Browser;
// Beim ersten Öffnen laden, danach automatisch alle 15 s (Spielerzahl, Ping, neue Server)
if (!browserLoaded || (!browser.Loading && Time.realtimeSinceStartup - browser.LastRefresh > ServerBrowser.AutoRefreshSeconds))
{
browserLoaded = true;
browser.Refresh(mp);
}
// Server, die auf die direkte Abfrage nicht antworten (z.B. gerade geschlossen), gar nicht zeigen
// Unity-GUI läuft in mehreren Durchgängen (Layout, Klick, Zeichnen). Die Liste nur im Layout-Durchgang
// übernehmen - ändert sie sich dazwischen (Aktualisierung im Hintergrund), bricht das Layout sonst ab
if (shownAll == null || Event.current.type == EventType.Layout)
shownAll = browser.Servers.FindAll(s => !s.Offline);
List<ServerListEntry> all = shownAll;
// Server-Profil (Klick auf einen Server)
if (selectedServer != null)
{
ServerListEntry selected = all.Find(s => s.Address + ":" + s.Port == selectedServer);
DrawServerProfile(mp, selected);
return;
}
GUILayout.BeginHorizontal();
// Titel darf nicht vom REFRESH-Button zusammengedrückt werden (sonst Zeilenumbruch)
GUILayout.Label("Server Browser", titleStyle, GUILayout.ExpandWidth(true));
if (GUILayout.Button(browser.Loading ? "LOADING..." : "REFRESH", tabStyle, GUILayout.Height(28), GUILayout.Width(110)))
browser.Refresh(mp);
GUILayout.EndHorizontal();
// Suche + Filter
GUILayout.BeginHorizontal();
searchInput = GUILayout.TextField(searchInput, 40, fieldStyle, GUILayout.Height(26), GUILayout.Width(170));
if (string.IsNullOrEmpty(searchInput))
{
Rect field = GUILayoutUtility.GetLastRect();
GUI.Label(new Rect(field.x + 10, field.y, field.width, field.height), "Search...", placeholderStyle);
}
GUILayout.Space(12);
showSteam = Chip(showSteam, "STEAM");
GUILayout.Space(6);
showNonSteam = Chip(showNonSteam, "NON-STEAM");
GUILayout.Space(6);
hideFull = Chip(hideFull, "HIDE FULL");
GUILayout.EndHorizontal();
GUILayout.Space(6);
string search = (searchInput ?? "").Trim().ToLower();
if (shownServers == null || Event.current.type == EventType.Layout)
shownServers = all.FindAll(s =>
(s.RequireSteam ? showSteam : showNonSteam) &&
(!hideFull || s.Players < s.MaxPlayers) &&
(search.Length == 0 || (s.Name ?? "").ToLower().Contains(search) || (s.Owner ?? "").ToLower().Contains(search)));
List<ServerListEntry> servers = shownServers;
string shownError = Event.current.type == EventType.Layout ? (layoutError = browser.Error) : layoutError;
// Tabellenkopf
Rect header = GUILayoutUtility.GetRect(10, 26, GUILayout.ExpandWidth(true));
GUI.Box(header, GUIContent.none, rowBox);
GUI.Label(new Rect(header.x + 12, header.y, header.width * 0.48f, header.height), "SERVER NAME", headerCellStyle);
GUI.Label(new Rect(header.x + header.width * 0.5f, header.y, 70, header.height), "PLAYERS", headerCellStyle);
GUI.Label(new Rect(header.x + header.width * 0.62f, header.y, 70, header.height), "TYPE", headerCellStyle);
GUI.Label(new Rect(header.x + header.width * 0.76f, header.y, 50, header.height), "PING", headerCellStyle);
serverScroll = GUILayout.BeginScrollView(serverScroll, GUILayout.Height(140));
if (shownError != null) GUILayout.Label(shownError, textStyle);
if (servers.Count == 0)
GUILayout.Label(browser.Loading ? "Loading servers..." : all.Count > 0 ? "No servers match your filters." : "No servers online right now.", textStyle);
foreach (ServerListEntry s in servers)
{
Rect row = GUILayoutUtility.GetRect(10, 34, GUILayout.ExpandWidth(true));
GUI.Box(new Rect(row.x, row.y + 2, row.width, row.height - 4), GUIContent.none, rowBox);
// Herz: als Favorit merken / entfernen (vor dem Zeilen-Knopf, damit es den Klick bekommt)
Heart(new Rect(row.xMax - 58, row.y + 7, 20, 20), s);
// Klick auf die Zeile öffnet das Server-Profil (dort wird verbunden)
if (GUI.Button(new Rect(row.x, row.y, row.width, row.height), GUIContent.none, GUIStyle.none))
selectedServer = s.Address + ":" + s.Port;
GUI.DrawTexture(new Rect(row.x + 10, row.y + 13, 8, 8), s.Ping < 0 ? texPending : texOnline);
float nameX = row.x + 26;
Favorites.Favorite fav = Favorites.FindByAddress(s.Address, s.Port);
if (fav != null) nameX += Pill(nameX, row, fav.Nickname, pillMutedStyle);
if (s.IsLocal)
{
// Server auf diesem PC (localhost)
nameX += Pill(nameX, row, "LOCAL", pillStyle);
}
if (!string.IsNullOrEmpty(s.Version) && s.Version != MSCO.ModVersion)
{
nameX += Pill(nameX, row, "v" + s.Version, pillStyle); // abweichende Version
}
GUI.Label(new Rect(nameX, row.y, row.x + row.width * 0.49f - nameX, row.height), s.Name, rowNameStyle);
GUI.Label(new Rect(row.x + row.width * 0.5f, row.y, 70, row.height), s.Players + "/" + s.MaxPlayers, rowNameStyle);
Pill(row.x + row.width * 0.62f, row, s.RequireSteam ? "STEAM" : "NO STEAM", s.RequireSteam ? pillStyle : pillMutedStyle);
GUI.Label(new Rect(row.x + row.width * 0.76f, row.y, 50, row.height), s.Ping < 0 ? "..." : s.Ping + " ms", rowNameStyle);
GUI.Label(new Rect(row.xMax - 30, row.y, 20, row.height), "›", headerCellStyle); // Hinweis: Profil öffnen
}
GUILayout.EndScrollView();
GUILayout.FlexibleSpace();
GUILayout.BeginHorizontal();
if (!mp.SteamAvailable)
{
// Ohne Steam: eigener Name (nur für Non-Steam-Server)
GUILayout.BeginVertical(GUILayout.Width(150));
GUILayout.Label("YOUR NAME", captionStyle);
DrawNameField(mp, 150);
GUILayout.EndVertical();
GUILayout.Space(10);
}
GUILayout.BeginVertical();
GUILayout.Label("DIRECT CONNECT", captionStyle);
GUILayout.BeginHorizontal();
directInput = GUILayout.TextField(directInput, fieldStyle, GUILayout.Height(34));
GUILayout.Space(10);
if (GUILayout.Button("CONNECT", primaryStyle, GUILayout.Height(34), GUILayout.Width(110)))
{
string host;
int port;
if (ServerBrowser.TryParseAddress(directInput, out host, out port))
{
PlayerPrefs.SetString(PrefDirect, directInput.Trim());
mp.JoinServer(host, port);
}
}
GUILayout.EndHorizontal();
GUILayout.EndVertical();
GUILayout.EndHorizontal();
}
static bool CanJoin(MultiplayerManager mp, ServerListEntry s)
{
return s.Players < s.MaxPlayers && (!s.RequireSteam || mp.SteamAvailable);
}
void DrawNameField(MultiplayerManager mp, float width)
{
if (nameInput == null) nameInput = mp.PlayerName;
string edited = GUILayout.TextField(nameInput, 24, fieldStyle, GUILayout.Height(34), GUILayout.Width(width));
if (edited != nameInput)
{
nameInput = edited;
if (edited.Trim().Length > 0) mp.PlayerName = edited.Trim();
}
}
// ---------------------------------------------------------------- Favoriten
/// <summary>Herz: gefüllt = Favorit. Klick merkt den Server (Kurzname aus dem Namen) bzw. entfernt ihn.</summary>
void Heart(Rect r, ServerListEntry s)
{
EnsureHearts();
Favorites.Favorite fav = Favorites.FindByAddress(s.Address, s.Port);
bool hover = r.Contains(Event.current.mousePosition);
GUI.DrawTexture(r, fav != null || hover ? texHeartOn : texHeartOff);
if (GUI.Button(r, GUIContent.none, GUIStyle.none))
{
if (fav != null) Favorites.Remove(fav);
else AddFavorite(s.Name, s.Address, s.Port);
}
}
static void AddFavorite(string serverName, string host, int port)
{
// Kurzname aus dem Servernamen; ist er schon vergeben, eine Zahl anhängen
string baseNick = Favorites.SuggestNickname(serverName), nick = baseNick;
for (int i = 2; Favorites.Find(nick) != null && i < 100; i++) nick = baseNick + i;
string error = Favorites.Set(nick, host, port, serverName);
if (error != null) MscoLog.Warning(error);
}
/// <summary>Tab "Favorites": Kurznamen setzen, beitreten, entfernen, per Adresse hinzufügen.</summary>
void DrawFavorites(MultiplayerManager mp)
{
EnsureHearts();
GUILayout.Label("Favorites", titleStyle);
GUILayout.Label("Give each server a nickname - switch in the game with /switch <nickname> in the chat or F7 console.", textStyle);
GUILayout.Space(8);
List<Favorites.Favorite> favs = Favorites.All;
favoritesScroll = GUILayout.BeginScrollView(favoritesScroll, GUILayout.Height(220));
if (favs.Count == 0)
GUILayout.Label("No favorites yet. Click the heart next to a server in the Server Browser, or add one by address below.", textStyle);
Favorites.Favorite remove = null;
foreach (Favorites.Favorite f in favs)
{
ServerListEntry live = mp.Browser.Servers.Find(x => x.Port == f.Port && string.Equals(x.Address, f.Host, System.StringComparison.OrdinalIgnoreCase));
GUILayout.BeginHorizontal(rowBox, GUILayout.Height(44));
GUILayout.Space(6);
// Herz (entfernen)
Rect heart = GUILayoutUtility.GetRect(22, 22, GUILayout.Width(22), GUILayout.Height(40));
Rect heartIcon = new Rect(heart.x, heart.y + 10, 20, 20);
GUI.DrawTexture(heartIcon, texHeartOn);
if (GUI.Button(heartIcon, GUIContent.none, GUIStyle.none)) remove = f;
GUILayout.Space(8);
// Kurzname bearbeiten
string edit;
if (!nicknameEdits.TryGetValue(f.Address, out edit)) edit = f.Nickname;
GUILayout.BeginVertical(GUILayout.Width(150));
GUILayout.Space(7);
edit = GUILayout.TextField(edit, 20, fieldStyle, GUILayout.Height(28), GUILayout.Width(150));
GUILayout.EndVertical();
nicknameEdits[f.Address] = edit;
GUILayout.Space(6);
bool changed = Favorites.CleanNickname(edit) != f.Nickname;
GUILayout.BeginVertical(GUILayout.Width(64));
GUILayout.Space(7);
GUI.enabled = changed;
if (GUILayout.Button("SAVE", tabStyle, GUILayout.Height(28), GUILayout.Width(64)))
{
string error = Favorites.Set(edit, f.Host, f.Port, f.ServerName);
if (error != null) MscoLog.Warning(error);
nicknameEdits.Remove(f.Address);
}
GUI.enabled = true;
GUILayout.EndVertical();
GUILayout.Space(10);
// Server + Status
GUILayout.BeginVertical();
GUILayout.Space(4);
GUILayout.Label(string.IsNullOrEmpty(f.ServerName) ? f.Address : f.ServerName, rowNameStyle);
GUILayout.Label(live == null ? f.Address + " · not listed / offline"
: f.Address + " · " + live.Players + "/" + live.MaxPlayers + " · " + (live.Ping < 0 ? "..." : live.Ping + " ms"), textStyle);
GUILayout.EndVertical();
GUILayout.FlexibleSpace();
GUILayout.BeginVertical(GUILayout.Width(90));
GUILayout.Space(7);
if (GUILayout.Button("JOIN", primaryStyle, GUILayout.Height(30), GUILayout.Width(90))) mp.SwitchServer(f.Nickname);
GUILayout.EndVertical();
GUILayout.Space(6);
GUILayout.EndHorizontal();
GUILayout.Space(4);
}
if (remove != null) { Favorites.Remove(remove); nicknameEdits.Remove(remove.Address); }
GUILayout.EndScrollView();
GUILayout.FlexibleSpace();
GUILayout.Label("ADD BY ADDRESS", captionStyle);
GUILayout.BeginHorizontal();
favoriteAddInput = GUILayout.TextField(favoriteAddInput ?? "", 60, fieldStyle, GUILayout.Height(34));
if (string.IsNullOrEmpty(favoriteAddInput))
{
Rect field = GUILayoutUtility.GetLastRect();
GUI.Label(new Rect(field.x + 10, field.y, field.width, field.height), "host:port", placeholderStyle);
}
GUILayout.Space(10);
if (GUILayout.Button("ADD", secondaryStyle, GUILayout.Height(34), GUILayout.Width(110)))
{
string host;
int port;
if (ServerBrowser.TryParseAddress(favoriteAddInput, out host, out port))
{
ServerListEntry live = mp.Browser.Servers.Find(x => x.Port == port && x.Address == host);
AddFavorite(live != null ? live.Name : host, host, port);
favoriteAddInput = "";
}
else MscoLog.Warning("Enter an address like 1.2.3.4:7777");
}
GUILayout.EndHorizontal();
}
void EnsureHearts()
{
if (texHeartOn != null && texHeartOff != null) return;
texHeartOn = HeartTexture(new Color(0.93f, 0.28f, 0.33f), true);
texHeartOff = HeartTexture(new Color(1f, 1f, 1f, 0.35f), false);
}
/// <summary>Herz als Textur (gefüllt oder nur Umriss), weich geglättet.</summary>
static Texture2D HeartTexture(Color color, bool filled)
{
const int size = 48;
var tex = new Texture2D(size, size, TextureFormat.ARGB32, false);
for (int py = 0; py < size; py++)
for (int px = 0; px < size; px++)
{
// Herz-Kurve (x² + y² - 1)³ - x² y³ <= 0, mehrfach abgetastet für glatte Kanten
int inside = 0, samples = 0;
for (int sy = 0; sy < 3; sy++)
for (int sx = 0; sx < 3; sx++)
{
float x = ((px + (sx + 0.5f) / 3f) / size - 0.5f) * 2.6f;
float y = ((py + (sy + 0.5f) / 3f) / size - 0.55f) * 2.6f;
float a = x * x + y * y - 1f;
bool inHeart = a * a * a - x * x * y * y * y <= 0f;
if (!filled && inHeart)
{
// Umriss: innen ein kleineres Herz aussparen
float x2 = x * 1.28f, y2 = (y + 0.08f) * 1.28f;
float b = x2 * x2 + y2 * y2 - 1f;
if (b * b * b - x2 * x2 * y2 * y2 * y2 <= 0f) inHeart = false;
}
if (inHeart) inside++;
samples++;
}
tex.SetPixel(px, py, new Color(color.r, color.g, color.b, color.a * inside / samples));
}
tex.filterMode = FilterMode.Bilinear;
tex.wrapMode = TextureWrapMode.Clamp;
tex.Apply();
tex.hideFlags = HideFlags.HideAndDontSave;
return tex;
}
/// <summary>Server-Profil: wem der Server gehört, Version, Spieler, Laufzeit ...</summary>
void DrawServerProfile(MultiplayerManager mp, ServerListEntry s)
{
if (GUILayout.Button("◂ BACK", tabStyle, GUILayout.Height(26), GUILayout.Width(90)))
{
selectedServer = null;
return;
}
GUILayout.Space(8);
if (s == null)
{
GUILayout.Label("Server offline", titleStyle);
GUILayout.Label("This server does not respond anymore.", textStyle);
return;
}
// Kopf: Name + Abzeichen links, JOIN rechts - immer sichtbar
GUILayout.BeginHorizontal();
GUILayout.BeginVertical();
GUILayout.Label(s.Name, titleStyle);
GUILayout.BeginHorizontal();
Rect badgeRow = GUILayoutUtility.GetRect(10, 20, GUILayout.ExpandWidth(true));
float bx = badgeRow.x;
bx += Pill(bx, badgeRow, s.RequireSteam ? "STEAM" : "NON-STEAM", s.RequireSteam ? pillStyle : pillMutedStyle);
if (s.IsLocal) bx += Pill(bx, badgeRow, "LOCAL", pillStyle);
if (s.Version != MSCO.ModVersion) Pill(bx, badgeRow, "v" + s.Version, pillStyle);
GUILayout.EndHorizontal();
GUILayout.EndVertical();
GUILayout.Space(12);
Rect heartSlot = GUILayoutUtility.GetRect(44, 44, GUILayout.Width(44), GUILayout.Height(44));
GUI.Box(heartSlot, GUIContent.none, rowBox);
Heart(new Rect(heartSlot.x + 11, heartSlot.y + 11, 22, 22), s);
GUILayout.Space(8);
GUI.enabled = CanJoin(mp, s);
if (GUILayout.Button(s.Players >= s.MaxPlayers ? "SERVER FULL" : "JOIN SERVER", primaryStyle, GUILayout.Height(44), GUILayout.Width(180)))
mp.JoinServer(s.Address, s.Port, s.Name, s.MaxPlayers);
GUI.enabled = true;
GUILayout.EndHorizontal();
if (!string.IsNullOrEmpty(s.Description)) GUILayout.Label(s.Description, textStyle);
GUILayout.Space(10);
// Ohne Steam: Name wählen (sauber in einem eigenen Kasten)
if (s.RequireSteam && !mp.SteamAvailable)
{
GUILayout.BeginHorizontal(rowBox, GUILayout.Height(40));
GUILayout.Label("This server requires Steam - start the game through Steam to join.", textStyle);
GUILayout.EndHorizontal();
GUILayout.Space(10);
}
else if (!mp.SteamAvailable)
{
GUILayout.BeginHorizontal(rowBox, GUILayout.Height(44));
GUILayout.Space(6);
GUILayout.BeginVertical(GUILayout.Width(110));
GUILayout.Space(4);
GUILayout.Label("PLAYING AS", captionStyle);
GUILayout.Label("no Steam", textStyle);
GUILayout.EndVertical();
GUILayout.BeginVertical();
GUILayout.Space(5);
DrawNameField(mp, 220);
GUILayout.EndVertical();
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
GUILayout.Space(10);
}
// Infos links, Spielerliste rechts
bool versionOk = s.Version == MSCO.ModVersion;
GUILayout.BeginHorizontal();
GUILayout.BeginVertical(GUILayout.Width(250));
InfoLine("HOSTED BY", string.IsNullOrEmpty(s.Owner) ? "unknown" : s.Owner, rowNameStyle);
InfoLine("VERSION", "v" + s.Version + (versionOk ? "" : " (you: v" + MSCO.ModVersion + ")"), versionOk ? rowNameStyle : badgeStyle);
InfoLine("STEAM", s.RequireSteam ? "Required" : "Not required", rowNameStyle);
InfoLine("ONLINE FOR", s.UptimeSeconds >= 0 ? FormatUptime(s.UptimeSeconds) : "-", rowNameStyle);
InfoLine("PING", s.Ping < 0 ? "-" : s.Ping + " ms", rowNameStyle);
InfoLine("ADDRESS", s.Address + ":" + s.Port, rowNameStyle);
GUILayout.EndVertical();
GUILayout.Space(16);
GUILayout.BeginVertical(rowBox, GUILayout.ExpandWidth(true), GUILayout.MinHeight(150));
GUILayout.Space(6);
GUILayout.Label("PLAYERS " + s.Players + " / " + s.MaxPlayers, captionStyle);
profileScroll = GUILayout.BeginScrollView(profileScroll, GUILayout.Height(130));
if (s.PlayerNames == null || s.PlayerNames.Count == 0)
GUILayout.Label("Nobody online right now.", textStyle);
else
foreach (string name in s.PlayerNames)
{
Rect line = GUILayoutUtility.GetRect(10, 24, GUILayout.ExpandWidth(true));
GUI.DrawTexture(new Rect(line.x + 2, line.y + 8, 8, 8), texOnline);
GUI.Label(new Rect(line.x + 18, line.y, line.width - 18, line.height), name, rowNameStyle);
}
GUILayout.EndScrollView();
GUILayout.EndVertical();
GUILayout.EndHorizontal();
GUILayout.FlexibleSpace();
}
/// <summary>Eine Info-Zeile: Bezeichnung links (grau), Wert rechts.</summary>
void InfoLine(string caption, string value, GUIStyle valueStyle)
{
GUILayout.BeginHorizontal(GUILayout.Height(26));
GUILayout.Label(caption, captionStyle, GUILayout.Width(100));
GUILayout.Label(value, valueStyle);
GUILayout.EndHorizontal();
}
/// <summary>Kleines abgerundetes Abzeichen, so breit wie sein Text. Gibt die belegte Breite zurück.</summary>
static float Pill(float x, Rect row, string text, GUIStyle style)
{
Vector2 size = style.CalcSize(new GUIContent(text));
GUI.Label(new Rect(x, row.y + (row.height - 18) / 2f, size.x, 18), text, style);
return size.x + 8;
}
/// <summary>Filter-Chip (an = orange umrandet), ersetzt die Unity-Häkchen.</summary>
bool Chip(bool on, string text)
{
if (GUILayout.Button(text, on ? chipOnStyle : chipOffStyle, GUILayout.Height(26), GUILayout.ExpandWidth(false))) on = !on;
return on;
}
void InfoRow(string caption, string value, GUIStyle valueStyle)
{
GUILayout.Label(caption, captionStyle);
GUILayout.Label(value, valueStyle);
GUILayout.Space(2);
}
static string FormatUptime(int seconds)
{
int days = seconds / 86400, hours = seconds / 3600 % 24, minutes = seconds / 60 % 60;
if (days > 0) return days + "d " + hours + "h";
if (hours > 0) return hours + "h " + minutes + "m";
return Mathf.Max(1, minutes) + " min";
}
/// <summary>Server hat eine andere Version: warnen, Verbinden nur auf Wunsch.</summary>
void DrawVersionWarning(MultiplayerManager mp)
{
MultiplayerManager.VersionWarning w = mp.PendingVersionWarning;
GUILayout.Label("Version mismatch", titleStyle);
GUILayout.Label("This server runs a different MSCO version than you. You can still join, but things may not " +
"sync correctly (players, items, cars). For the best experience use the same version.", textStyle);
GUILayout.Space(16);
GUILayout.Label("SERVER", captionStyle);
GUILayout.Label(w.Name + " (" + w.Host + ":" + w.Port + ")", rowNameStyle);
GUILayout.Space(8);
GUILayout.Label("VERSIONS", captionStyle);
GUILayout.Label("Server v" + w.ServerVersion + " - You v" + MSCO.ModVersion, rowTagStyle);
GUILayout.Space(22);
GUILayout.BeginHorizontal();
if (GUILayout.Button("JOIN ANYWAY", primaryStyle, GUILayout.Height(42), GUILayout.Width(170)))
mp.ConfirmVersionWarning();
GUILayout.Space(10);
if (GUILayout.Button("CANCEL", secondaryStyle, GUILayout.Height(42), GUILayout.Width(170)))
mp.CancelVersionWarning();
GUILayout.EndHorizontal();
}
/// <summary>Nachfrage nach einem JOIN-Link der Website (keine Webseite soll einen ungefragt verbinden).</summary>
void DrawJoinConfirm(MultiplayerManager mp)
{
GUILayout.Label("Join server?", titleStyle);
GUILayout.Label("You opened a join link from the server list.", textStyle);
GUILayout.Space(18);
GUILayout.Label("SERVER ADDRESS", captionStyle);
GUILayout.Label(mp.PendingJoinHost + ":" + mp.PendingJoinPort, rowNameStyle);
GUILayout.Space(24);
if (!mp.SteamAvailable)
GUILayout.Label("Steam is required to join servers - start the game through Steam.", textStyle);
GUILayout.BeginHorizontal();
GUI.enabled = mp.SteamAvailable;
if (GUILayout.Button("JOIN", primaryStyle, GUILayout.Height(42), GUILayout.Width(170)))
mp.ConfirmPendingJoin();
GUI.enabled = true;
GUILayout.Space(10);
if (GUILayout.Button("CANCEL", secondaryStyle, GUILayout.Height(42), GUILayout.Width(170)))
mp.CancelPendingJoin();
GUILayout.EndHorizontal();
}
const float PromptWidth = 540f;
const float PromptHeight = 270f;
Texture2D texDim, texDanger;
GUIStyle promptTextStyle;
/// <summary>
/// Fenster in der Bildmitte über abgedunkeltem Hintergrund: nach dem Trennen (Kick, Ban, Shutdown, Timeout ...)
/// mit Grund und OK, bzw. beim automatischen Neu-Verbinden nach reset-world mit Versuch-Anzeige und CANCEL.
/// </summary>
void DrawPrompt(MultiplayerManager mp, bool reconnecting)
{
if (texDim == null) texDim = Solid(new Color(0f, 0f, 0f, 0.6f));
if (texDanger == null) texDanger = Solid(Hex(0xe5, 0x48, 0x4d));
if (promptTextStyle == null)
{
promptTextStyle = new GUIStyle(rowNameStyle);
promptTextStyle.wordWrap = true;
promptTextStyle.fontSize = 15;
}
GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), texDim);
var r = new Rect((Screen.width - PromptWidth) / 2f, (Screen.height - PromptHeight) / 2f, PromptWidth, PromptHeight);
ScreenRect = new Rect(0, 0, Screen.width, Screen.height); // Spielmenü dahinter komplett sperren
GUI.Box(r, GUIContent.none, windowBox);
string title = reconnecting
? ((mp.ReconnectMessage ?? "").StartsWith("Switching") ? "SWITCHING SERVER" : "RECONNECTING")
: mp.DisconnectTitle;
bool bad = !reconnecting && title != "DISCONNECTED";
GUI.DrawTexture(new Rect(r.x, r.y, r.width, 4), bad ? texDanger : texAccent);
float x = r.x + 32, w = r.width - 64, y = r.y + 28;
GUI.Label(new Rect(x, y, w, 34), title, titleStyle);
y += 50;
GUI.Label(new Rect(x, y, w, 20), "REASON", captionStyle);
if (reconnecting)
{
GUI.Label(new Rect(x, y + 22, w, 44), mp.ReconnectMessage ?? "", promptTextStyle);
string attempt = mp.ReconnectAttempt == 0
? "Connecting again in a moment..."
: "Attempt " + mp.ReconnectAttempt + " of " + mp.ReconnectMaxAttempts +
(string.IsNullOrEmpty(mp.Status) ? "" : " - " + mp.Status);
GUI.Label(new Rect(x, y + 74, w, 40), attempt, textStyle);
if (GUI.Button(new Rect(r.xMax - 32 - 170, r.yMax - 28 - 42, 170, 42), "CANCEL", secondaryStyle)) mp.CancelReconnect();
return;
}
GUI.Label(new Rect(x, y + 22, w, 60), mp.DisconnectNotice ?? "", promptTextStyle);
GUI.Label(new Rect(x, y + 88, w, 20), "Your own save game has been restored.", textStyle);
if (GUI.Button(new Rect(r.xMax - 32 - 170, r.yMax - 28 - 42, 170, 42), "OK", primaryStyle)) mp.ClearDisconnectNotice();
}
void DrawSession(MultiplayerManager mp)
{
GUILayout.Label(mp.IsHost ? "Hosting" : "Connected", titleStyle);
GUILayout.Label(mp.IsHost
? "Your game is open. Start when everyone has joined."
: mp.IsDedicated || mp.IsConnectingToServer
? "Connected to the server - the world is loading" + (string.IsNullOrEmpty(mp.Status) ? "." : ": " + mp.Status)
: "Waiting for the host to start the game.", textStyle);
GUILayout.Space(16);
GUILayout.Label("PLAYERS " + mp.Players.Count + "/" + mp.SessionMaxPlayers, captionStyle);
playerScroll = GUILayout.BeginScrollView(playerScroll, GUILayout.Height(170));
foreach (PlayerInfo p in mp.Players)
{
Rect row = GUILayoutUtility.GetRect(10, 32, GUILayout.ExpandWidth(true));
GUI.Box(new Rect(row.x, row.y, row.width, row.height - 4), GUIContent.none, rowBox);
// Eigener Punkt gelb, solange man noch lädt/synchronisiert; grün, wenn man wirklich im Spiel ist
GUI.DrawTexture(new Rect(row.x + 12, row.y + 11, 8, 8), p.IsLocal && !mp.IsFullyConnected ? texPending : texOnline);
GUI.Label(new Rect(row.x + 30, row.y, row.width - 150, row.height - 2), p.Name, rowNameStyle);
string tag = p.Id == NetSession.HostId ? "HOST" : "";
if (p.IsLocal) tag = tag.Length > 0 ? tag + " · YOU" : "YOU";
GUI.Label(new Rect(row.xMax - 120, row.y, 110, row.height - 2), tag, rowTagStyle);
}
GUILayout.EndScrollView();
GUILayout.Space(14);
GUILayout.BeginHorizontal();
if (mp.IsHost)
{
if (GUILayout.Button("START GAME", primaryStyle, GUILayout.Height(42), GUILayout.Width(150)))
mp.StartHostGame();
GUILayout.Space(10);
if (mp.IsSteamSession && mp.Players.Count < MultiplayerManager.MaxPlayers &&
GUILayout.Button("INVITE FRIENDS", secondaryStyle, GUILayout.Height(42), GUILayout.Width(150)))
mp.InviteFriends();
GUILayout.Space(10);
}
if (GUILayout.Button("DISCONNECT", secondaryStyle, GUILayout.Height(42), GUILayout.Width(150)))
mp.Disconnect();
GUILayout.EndHorizontal();
}
void DrawStatusBar(MultiplayerManager mp, Rect r)
{
GUI.Box(r, GUIContent.none, sidebarBox);
Texture2D dot = mp.IsFullyConnected ? texOnline : mp.IsInSession || mp.IsReconnecting ? texPending : texMuted;
GUI.DrawTexture(new Rect(r.x + 18, r.y + (r.height - 8) / 2f, 8, 8), dot);
GUI.Label(new Rect(r.x + 36, r.y, r.width - 180, r.height), mp.Status, statusStyle);
if (GUI.Button(new Rect(r.xMax - 118, r.y + 6, 106, r.height - 12), "MINIMIZE", tabStyle))
minimized = true;
}
void DrawMinimized(MultiplayerManager mp)
{
var r = new Rect((Screen.width - 220) / 2f, 0, 220, 30);
ScreenRect = r;
GUI.Box(r, GUIContent.none, sidebarBox);
GUI.Box(new Rect(r.x + 10, r.yMax - 3, r.width - 20, 3), GUIContent.none, accentBox);
Texture2D dot = mp.IsFullyConnected ? texOnline : mp.IsInSession || mp.IsReconnecting ? texPending : texMuted;
Texture2D logo = Branding.LogoTexture;
if (logo != null) GUI.DrawTexture(new Rect(r.x + 8, r.y + 4, 22, 22), logo);
GUI.DrawTexture(new Rect(r.x + 36, r.y + 11, 8, 8), dot);
if (GUI.Button(new Rect(r.x + 48, r.y, r.width - 48, r.height), "MSCO MULTIPLAYER ▾", tabStyle))
minimized = false;
}
// ---------------------------------------------------------------- Hilfen
void EnsureStyles()
{
if (stylesReady && texBackground != null) return;
// PlayerPrefs erst hier lesen (nicht im Konstruktor erlaubt)
directInput = PlayerPrefs.GetString(PrefDirect, "");
texBackground = Solid(Background);
texSidebar = Solid(Sidebar);
texAccent = Solid(Accent);
texOnline = UiTheme.Rounded(Online, 4); // runde Status-Punkte
texPending = UiTheme.Rounded(Pending, 4);
texMuted = UiTheme.Rounded(Muted, 4);
texRow = Solid(Field);
windowBox = UiTheme.Panel9(Background, 10);
sidebarBox = UiTheme.Panel9(Sidebar, 10);
rowBox = UiTheme.Panel9(Field, UiTheme.Radius);
accentBox = UiTheme.Panel9(Accent, 1);
pillStyle = UiTheme.Panel9(new Color(Accent.r, Accent.g, Accent.b, 0.18f), 4);
pillStyle.normal.textColor = Accent;
pillStyle.fontSize = 10;
pillStyle.fontStyle = FontStyle.Bold;
pillStyle.alignment = TextAnchor.MiddleCenter;
pillStyle.padding = new RectOffset(7, 7, 0, 0);
pillStyle.wordWrap = false;
pillMutedStyle = new GUIStyle(pillStyle);
pillMutedStyle.normal.background = UiTheme.Rounded(new Color(1f, 1f, 1f, 0.08f), 4);
pillMutedStyle.normal.textColor = Muted;
chipOnStyle = Button(new Color(Accent.r, Accent.g, Accent.b, 0.22f), new Color(Accent.r, Accent.g, Accent.b, 0.32f), AccentHover, 11, FontStyle.Bold);
chipOffStyle = Button(Field, FieldFocus, Muted, 11, FontStyle.Bold);
chipOnStyle.padding = chipOffStyle.padding = new RectOffset(12, 12, 0, 0);
logoStyle = Label(28, Color.white, FontStyle.Bold);
taglineStyle = Label(10, Muted, FontStyle.Bold);
titleStyle = Label(24, Color.white, FontStyle.Bold);
textStyle = Label(13, Muted, FontStyle.Normal);
textStyle.wordWrap = true;
captionStyle = Label(11, Muted, FontStyle.Bold);
captionStyle.margin.bottom = 6;
statusStyle = Label(12, Color.white, FontStyle.Normal);
statusStyle.alignment = TextAnchor.MiddleLeft;
headerCellStyle = Label(11, Muted, FontStyle.Bold);
headerCellStyle.alignment = TextAnchor.MiddleLeft;
rowNameStyle = Label(14, Color.white, FontStyle.Normal);
rowNameStyle.alignment = TextAnchor.MiddleLeft;
rowTagStyle = Label(11, Accent, FontStyle.Bold);
rowTagStyle.alignment = TextAnchor.MiddleRight;
badgeStyle = new GUIStyle(rowTagStyle);
badgeStyle.alignment = TextAnchor.MiddleLeft;
badgeStyle.padding = new RectOffset(6, 6, 2, 2);
placeholderStyle = Label(13, Muted, FontStyle.Italic);
placeholderStyle.alignment = TextAnchor.MiddleLeft;
toggleStyle = new GUIStyle(GUI.skin.toggle);
toggleStyle.normal.textColor = toggleStyle.onNormal.textColor = toggleStyle.hover.textColor = toggleStyle.onHover.textColor = Color.white;
toggleStyle.fontSize = 11;
toggleStyle.fontStyle = FontStyle.Bold;
navStyle = Button(Sidebar, Secondary, Muted, 14, FontStyle.Normal);
navStyle.alignment = TextAnchor.MiddleLeft;
navStyle.padding = new RectOffset(26, 10, 0, 0);
navActiveStyle = Button(Secondary, Secondary, Color.white, 14, FontStyle.Bold);
navActiveStyle.alignment = TextAnchor.MiddleLeft;
navActiveStyle.padding = new RectOffset(26, 10, 0, 0);
primaryStyle = Button(Accent, AccentHover, Color.white, 14, FontStyle.Bold);
dangerStyle = Button(UiTheme.Danger, UiTheme.DangerHover, Color.white, 13, FontStyle.Bold);
secondaryStyle = Button(Secondary, SecondaryHover, Color.white, 14, FontStyle.Bold);
tabStyle = Button(Sidebar, Secondary, Muted, 11, FontStyle.Bold);
fieldStyle = UiTheme.Field(14);
stylesReady = true;
}
static GUIStyle Label(int size, Color color, FontStyle font)
{
var s = new GUIStyle(GUI.skin.label);
s.fontSize = size;
s.fontStyle = font;
s.normal.textColor = color;
return s;
}
static GUIStyle Button(Color normal, Color hover, Color text, int size, FontStyle font)
{
var s = new GUIStyle(GUI.skin.button);
// abgerundete Ecken (9-Slice), siehe UiTheme
s.normal.background = UiTheme.Rounded(normal);
s.hover.background = UiTheme.Rounded(hover);
s.active.background = UiTheme.Rounded(hover);
s.focused.background = UiTheme.Rounded(normal);
s.normal.textColor = s.hover.textColor = s.active.textColor = s.focused.textColor = text;
s.border = new RectOffset(UiTheme.Radius + 2, UiTheme.Radius + 2, UiTheme.Radius + 2, UiTheme.Radius + 2);
s.fontSize = size;
s.fontStyle = font;
return s;
}
static Texture2D Solid(Color color)
{
var tex = new Texture2D(1, 1);
tex.SetPixel(0, 0, color);
tex.Apply();
tex.hideFlags = HideFlags.HideAndDontSave;
return tex;
}
static Color Hex(int r, int g, int b)
{
return new Color(r / 255f, g / 255f, b / 255f, 1f);
}
}
}