Files
MSCO/LobbyUI.cs
T
MrMahoandClaude Opus 5.5 d195a484d8 2.10.8: fewer hitches
- Sending on a background thread (the game thread blocked whenever the TCP
  buffer was full, e.g. while uploading the save); small frames are batched
- Server save requests: routine saves wait for a calm moment (not while driving
  fast) and are announced; urgent flag from server 2.10.5
- Dynamic built-in font for all MSCO GUI (Unity logged hundreds of
  "Font size and style overrides" warnings with stutters)
- Admin VEHICLE tab rescans every 30 s / after actions instead of every 5 s;
  time/weather FSM search less often
- Reused buffers for Build/Frame/Poll (less garbage, fewer GC pauses)
- Hitch log: GC and save detection, count of hitches between reports
- Show Bolt Size 1.0.1: finds the FPSCamera (Camera.main may be missing), uses
  MSCO's shared FSM list instead of FindObjectsOfType every 2 s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:08:36 +02:00

1244 lines
68 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, Mods }
// Vollbild, automatisch skaliert: gebaut für RefHeight x RefWidth, vergrößert/verkleinert passend zur Auflösung
const float RefHeight = 760f;
const float RefWidth = 1180f;
const float MinScale = 0.8f, MaxScale = 1.75f;
const float SidebarWidth = 240f;
const float StatusHeight = 40f;
const float MaxContentWidth = 1100f;
float vw, vh; // Bildschirmgröße in Pixeln
static float uiScale = 1f; // Skalierung passend zur Auflösung
float stylesScale = -1f; // für diese Skalierung wurden die Stile gebaut
float contentHeight = 400f; // Höhe des Inhaltsbereichs - Listen wachsen damit
// Maße (S) und Schriftgrößen/Abstände (P) werden direkt in Bildschirmpixeln berechnet - Schrift und Kanten
// werden so in voller Auflösung gezeichnet (scharf), statt ein kleines Bild hochzuziehen
static float S(float v) { return Mathf.Round(v * uiScale); }
static int P(int v) { return Mathf.Max(1, Mathf.RoundToInt(v * uiScale)); }
/// <summary>Höhe für eine Liste: was nach "reserved" für Überschriften/Knöpfe übrig bleibt.</summary>
float ListHeight(float reserved, float min) { return Mathf.Max(min, contentHeight - reserved); }
static readonly Color Background = Hex(0x11, 0x11, 0x14);
static readonly Color Sidebar = Hex(0x0c, 0x0c, 0x0f);
static readonly Color Field = Hex(0x1b, 0x1b, 0x20);
static readonly Color FieldFocus = Hex(0x24, 0x24, 0x2a);
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);
static readonly Color Gold = Hex(0xff, 0xb4, 0x5a); // hervorgehobene (featured) Server
Page page = Page.P2P;
Vector2 profileScroll;
bool quitConfirm;
Vector2 playerScroll;
Vector2 serverScroll;
bool browserLoaded;
string selectedServer; // "adresse:port" des geöffneten Server-Profils
string searchInput = "";
bool showSteam = true, showNonSteam = true, hideFull, showVanilla = true, showModded = true;
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;
Texture2D texDivider, texGold, texBarBack, texBarFill, texBarFull, texGlow;
GUIStyle scrollbarStyle, scrollThumbStyle;
GUIStyle rowHoverBox, pingGoodStyle, pingMidStyle, pingBadStyle;
List<ServerListEntry> shownAll, shownServers; // Stand des letzten Layout-Durchgangs
string layoutError;
GUIStyle dangerStyle;
GUIStyle windowBox, sidebarBox, rowBox, accentBox, pillStyle, pillMutedStyle, chipOnStyle, chipOffStyle;
GUIStyle featuredRowBox, pillFeaturedStyle;
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>
/// Eingeklappt: nur eine kleine MSCO-Leiste oben, das Startmenü des Spiels (Optionen, Farbe, Musik ...) ist wieder
/// sichtbar und bedienbar. Bei Meldungen (getrennt, neu verbinden, beitreten ...) klappt der Launcher von selbst auf.
/// </summary>
public bool Minimized { get; set; }
/// <summary>Aufgeklappt füllt der Launcher den ganzen Bildschirm - das Spielmenü dahinter ist dann 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)
{
uiScale = Mathf.Clamp(Mathf.Min(Screen.height / RefHeight, Screen.width / RefWidth), MinScale, MaxScale);
EnsureStyles(); // baut die Stile neu, wenn sich die Skalierung ändert (Auflösung gewechselt)
vw = Screen.width;
vh = Screen.height;
// Wichtige Meldungen nie hinter der eingeklappten Leiste verstecken
if (Minimized && !mp.IsInSession && (mp.DisconnectNotice != null || mp.PendingJoinHost != null || mp.PendingVersionWarning != null))
Minimized = false;
if (Minimized && mp.IsReconnecting) Minimized = false;
if (Minimized && mp.PendingMods != null) Minimized = false;
if (Minimized) { DrawMinimized(mp); return; }
ScreenRect = new Rect(0, 0, Screen.width, Screen.height);
DrawScaled(mp);
}
void DrawScaled(MultiplayerManager mp)
{
// Undurchsichtiger Hintergrund über das ganze Spielmenü (Logo, Farbauswahl, Knöpfe ... sind dahinter verborgen)
if (texScreen == null) texScreen = Solid(Hex(0x0d, 0x0d, 0x10));
GUI.DrawTexture(new Rect(0, 0, vw, vh), texScreen);
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 (mp.PendingMods != null && mp.IsInSession) { DrawModsPrompt(mp); return; }
var window = new Rect(0, 0, vw, vh);
GUI.Box(window, GUIContent.none, windowBox);
float glowWidth = Mathf.Min(vw * 0.9f, S(1300));
GUI.DrawTexture(new Rect(S(SidebarWidth) + (vw - S(SidebarWidth) - glowWidth) / 2f, -S(60), glowWidth, S(420)), texGlow);
DrawSidebar(new Rect(0, 0, S(SidebarWidth), vh - S(StatusHeight)));
// Inhalt mittig im freien Bereich, nicht beliebig breit (lesbar bleiben)
float free = vw - S(SidebarWidth);
float width = Mathf.Min(S(MaxContentWidth), free - S(96f));
var content = new Rect(S(SidebarWidth) + Mathf.Round((free - width) / 2f), S(40f), width, vh - S(StatusHeight) - S(72f));
contentHeight = content.height;
GUIStyle oldBar = GUI.skin.verticalScrollbar, oldThumb = GUI.skin.verticalScrollbarThumb;
GUI.skin.verticalScrollbar = scrollbarStyle;
GUI.skin.verticalScrollbarThumb = scrollThumbStyle;
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 if (page == Page.Mods) DrawMods();
else DrawServerBrowser(mp);
GUILayout.EndArea();
GUI.skin.verticalScrollbar = oldBar;
GUI.skin.verticalScrollbarThumb = oldThumb;
DrawStatusBar(mp, new Rect(0, vh - S(StatusHeight), vw, S(StatusHeight)));
// Einklappen (oben rechts): das Startmenü des Spiels wird wieder sichtbar
var minimize = new Rect(vw - S(54), S(12), S(40), S(32));
if (GUI.Button(minimize, new GUIContent("–", "Minimize - show the game's start menu"), secondaryStyle)) Minimized = true;
}
/// <summary>Eingeklappt: kleine Leiste oben in der Mitte mit Verbindungsstand - Klick öffnet den Launcher wieder.</summary>
void DrawMinimized(MultiplayerManager mp)
{
float w = S(300), h = S(40);
var bar = new Rect(Mathf.Round((vw - w) / 2f), S(10), w, h);
ScreenRect = bar;
GUI.Box(bar, GUIContent.none, sidebarBox);
Texture2D logo = Branding.LogoTexture;
float x = bar.x + S(10);
if (logo != null)
{
GUI.DrawTexture(new Rect(x, bar.y + S(6), S(28), S(28)), logo);
x += S(36);
}
Texture2D dot = mp.IsFullyConnected ? texOnline : mp.IsInSession || mp.IsReconnecting ? texPending : texMuted;
GUI.DrawTexture(new Rect(x, bar.y + (h - S(8)) / 2f, S(8), S(8)), dot);
string text = mp.IsInSession ? "MSCO · " + mp.Players.Count + " player(s) · open" : "MSCO · open multiplayer";
GUI.Label(new Rect(x + S(16), bar.y, bar.xMax - x - S(24), h), text, statusStyle);
if (GUI.Button(bar, new GUIContent("", "Open the MSCO launcher"), GUIStyle.none)) Minimized = false;
}
// ---------------------------------------------------------------- Bereiche
void DrawSidebar(Rect r)
{
GUI.Box(r, GUIContent.none, sidebarBox);
// Logo + Schriftzug
Texture2D logo = Branding.LogoTexture;
float textX = r.x + S(22);
if (logo != null)
{
GUI.DrawTexture(new Rect(r.x + S(20), r.y + S(20), S(44), S(44)), logo);
textX = r.x + S(72);
}
GUI.Label(new Rect(textX, r.y + S(24), r.xMax - textX, S(40)), "MSCO", logoStyle);
GUI.Label(new Rect(r.x + S(22), r.y + S(70), r.width - S(30), S(20)), "MY SUMMER CAR ONLINE", taglineStyle);
GUI.DrawTexture(new Rect(r.x + S(20), r.y + S(98), r.width - S(40), S(1)), texDivider);
float y = r.y + S(110);
if (NavButton(new Rect(r.x, y, r.width, S(42)), "P2P", page == Page.P2P)) page = Page.P2P;
y += S(44);
if (NavButton(new Rect(r.x, y, r.width, S(42)), "Server Browser", page == Page.ServerBrowser)) page = Page.ServerBrowser;
y += S(44);
int favCount = Favorites.All.Count;
if (NavButton(new Rect(r.x, y, r.width, S(42)), "Favorites" + (favCount > 0 ? " (" + favCount + ")" : ""), page == Page.Favorites)) page = Page.Favorites;
y += S(44);
int modCount = ModLoader.All.Count;
if (NavButton(new Rect(r.x, y, r.width, S(42)), "Mods" + (modCount > 0 ? " (" + modCount + ")" : ""), page == Page.Mods)) page = Page.Mods;
// Eigenes Hauptmenü: Spiel beenden (die Knöpfe des Spiel-Menüs sind ausgeblendet)
if (quitConfirm)
{
if (GUI.Button(new Rect(r.x + S(16), r.yMax - S(118), r.width - S(32), S(36)), "QUIT GAME?", dangerStyle)) Application.Quit();
if (GUI.Button(new Rect(r.x + S(16), r.yMax - S(76), r.width - S(32), S(30)), "CANCEL", tabStyle)) quitConfirm = false;
}
else if (GUI.Button(new Rect(r.x + S(16), r.yMax - S(76), r.width - S(32), S(36)), "QUIT", tabStyle)) quitConfirm = true;
GUI.Label(new Rect(r.x + S(24), r.yMax - S(30), r.width - S(48), S(20)), "v" + MSCO.ModVersion, taglineStyle);
}
bool NavButton(Rect r, string text, bool active)
{
var item = new Rect(r.x + S(12), r.y, r.width - S(24), r.height);
bool clicked = GUI.Button(item, text, active ? navActiveStyle : navStyle);
if (active) GUI.Box(new Rect(item.x + S(8), item.y + S(11), S(3), item.height - S(22)), GUIContent.none, accentBox); // Akzentbalken
return clicked;
}
void DrawP2P(MultiplayerManager mp)
{
GUILayout.Label("P2P", titleStyle);
GUILayout.Label("Host a game and invite up to " + (MultiplayerManager.MaxPlayers - 1) +
" friends through Steam - continue your own save or start a new game.", textStyle);
GUILayout.Space(S(18));
if (mp.SteamAvailable)
{
if (GUILayout.Button("HOST GAME", primaryStyle, GUILayout.Height(S(46)), GUILayout.Width(S(250))))
mp.HostSteam();
GUILayout.Space(S(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(S(28)), GUILayout.Width(S(110))))
browser.Refresh(mp);
GUILayout.EndHorizontal();
// Suche + Filter
GUILayout.BeginHorizontal();
searchInput = GUILayout.TextField(searchInput, 40, fieldStyle, GUILayout.Height(S(26)), GUILayout.Width(S(170)));
if (string.IsNullOrEmpty(searchInput))
{
Rect field = GUILayoutUtility.GetLastRect();
GUI.Label(new Rect(field.x + S(10), field.y, field.width, field.height), "Search...", placeholderStyle);
}
GUILayout.Space(S(12));
showSteam = Chip(showSteam, "STEAM");
GUILayout.Space(S(6));
showNonSteam = Chip(showNonSteam, "NON-STEAM");
GUILayout.Space(S(6));
hideFull = Chip(hideFull, "HIDE FULL");
GUILayout.Space(S(12));
showVanilla = Chip(showVanilla, "VANILLA");
GUILayout.Space(S(6));
showModded = Chip(showModded, "MODDED");
GUILayout.EndHorizontal();
GUILayout.Space(S(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) &&
(s.Modded ? showModded : showVanilla) &&
(search.Length == 0 || (s.Name ?? "").ToLower().Contains(search) || (s.Owner ?? "").ToLower().Contains(search) ||
(s.Tags != null && s.Tags.Exists(t => (t ?? "").Contains(search)))));
List<ServerListEntry> servers = shownServers;
string shownError = Event.current.type == EventType.Layout ? (layoutError = browser.Error) : layoutError;
// Tabellenkopf
GUILayout.Space(S(6));
Rect header = GUILayoutUtility.GetRect(S(10), S(26), GUILayout.ExpandWidth(true));
GUI.DrawTexture(new Rect(header.x, header.yMax - S(1), header.width, S(1)), texDivider);
GUI.Label(new Rect(header.x + S(12), header.y, header.width * 0.48f, header.height), "SERVER NAME", headerCellStyle);
GUI.Label(new Rect(header.x + header.width * 0.5f, header.y, S(70), header.height), "PLAYERS", headerCellStyle);
GUI.Label(new Rect(header.x + header.width * 0.62f, header.y, S(70), header.height), "TYPE", headerCellStyle);
GUI.Label(new Rect(header.x + header.width * 0.76f, header.y, S(50), header.height), "PING", headerCellStyle);
serverScroll = GUILayout.BeginScrollView(serverScroll, GUILayout.Height(ListHeight(S(230f), S(140f))));
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(S(10), S(34), GUILayout.ExpandWidth(true));
// Hervorgehobene Server (Keymaster-Admin) stehen oben - der Master liefert sie zuerst
var rowRect = new Rect(row.x, row.y + S(2), row.width, row.height - S(4));
bool hover = rowRect.Contains(Event.current.mousePosition);
GUI.Box(rowRect, GUIContent.none, s.Featured ? featuredRowBox : hover ? rowHoverBox : rowBox);
if (s.Featured) GUI.DrawTexture(new Rect(row.x, row.y + S(8), S(3), row.height - S(16)), texGold);
// Herz: als Favorit merken / entfernen (vor dem Zeilen-Knopf, damit es den Klick bekommt)
Heart(new Rect(row.xMax - S(58), row.y + S(7), S(20), S(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 + S(10), row.y + S(13), S(8), S(8)), s.Ping < 0 ? texPending : texOnline);
float nameX = row.x + S(26);
Favorites.Favorite fav = Favorites.FindByAddress(s.Address, s.Port);
if (s.Featured) nameX += Pill(nameX, row, "FEATURED", pillFeaturedStyle);
if (fav != null) nameX += Pill(nameX, row, fav.Nickname, pillMutedStyle);
if (s.Modded) nameX += Pill(nameX, row, s.Mods.Count > 0 ? "MODS " + s.Mods.Count : "MODDED", pillStyle);
if (s.IsLocal)
{
// Server auf diesem PC (localhost)
nameX += Pill(nameX, row, "LOCAL", pillStyle);
}
if (!string.IsNullOrEmpty(s.Version) && !MSCO.Compatible(s.Version))
{
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);
// Spielerzahl mit Füllbalken (wie auf der Website)
float px = row.x + row.width * 0.5f;
GUI.Label(new Rect(px, row.y - S(3), S(70), row.height), s.Players + "/" + s.MaxPlayers, rowNameStyle);
float fill = s.MaxPlayers > 0 ? Mathf.Clamp01((float)s.Players / s.MaxPlayers) : 0f;
GUI.DrawTexture(new Rect(px, row.y + S(24), S(48), S(3)), texBarBack);
GUI.DrawTexture(new Rect(px, row.y + S(24), Mathf.Max(S(2f), S(48) * fill), S(3)), fill >= 1f ? texBarFull : texBarFill);
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, S(60), row.height), s.Ping < 0 ? "..." : s.Ping + " ms", PingStyle(s.Ping));
GUI.Label(new Rect(row.xMax - S(30), row.y, S(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(S(150)));
GUILayout.Label("YOUR NAME", captionStyle);
DrawNameField(mp, 150);
GUILayout.EndVertical();
GUILayout.Space(S(10));
}
GUILayout.BeginVertical();
GUILayout.Label("DIRECT CONNECT", captionStyle);
GUILayout.BeginHorizontal();
directInput = GUILayout.TextField(directInput, fieldStyle, GUILayout.Height(S(34)));
GUILayout.Space(S(10));
if (GUILayout.Button("CONNECT", primaryStyle, GUILayout.Height(S(34)), GUILayout.Width(S(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(S(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(S(8));
List<Favorites.Favorite> favs = Favorites.All;
favoritesScroll = GUILayout.BeginScrollView(favoritesScroll, GUILayout.Height(ListHeight(S(190f), S(160f))));
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(S(44)));
GUILayout.Space(S(6));
// Herz (entfernen)
Rect heart = GUILayoutUtility.GetRect(S(22), S(22), GUILayout.Width(S(22)), GUILayout.Height(S(40)));
Rect heartIcon = new Rect(heart.x, heart.y + S(10), S(20), S(20));
GUI.DrawTexture(heartIcon, texHeartOn);
if (GUI.Button(heartIcon, GUIContent.none, GUIStyle.none)) remove = f;
GUILayout.Space(S(8));
// Kurzname bearbeiten
string edit;
if (!nicknameEdits.TryGetValue(f.Address, out edit)) edit = f.Nickname;
GUILayout.BeginVertical(GUILayout.Width(S(150)));
GUILayout.Space(S(7));
edit = GUILayout.TextField(edit, 20, fieldStyle, GUILayout.Height(S(28)), GUILayout.Width(S(150)));
GUILayout.EndVertical();
nicknameEdits[f.Address] = edit;
GUILayout.Space(S(6));
bool changed = Favorites.CleanNickname(edit) != f.Nickname;
GUILayout.BeginVertical(GUILayout.Width(S(64)));
GUILayout.Space(S(7));
GUI.enabled = changed;
if (GUILayout.Button("SAVE", tabStyle, GUILayout.Height(S(28)), GUILayout.Width(S(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(S(10));
// Server + Status
GUILayout.BeginVertical();
GUILayout.Space(S(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(S(90)));
GUILayout.Space(S(7));
if (GUILayout.Button("JOIN", primaryStyle, GUILayout.Height(S(30)), GUILayout.Width(S(90)))) mp.SwitchServer(f.Nickname);
GUILayout.EndVertical();
GUILayout.Space(S(6));
GUILayout.EndHorizontal();
GUILayout.Space(S(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(S(34)));
if (string.IsNullOrEmpty(favoriteAddInput))
{
Rect field = GUILayoutUtility.GetLastRect();
GUI.Label(new Rect(field.x + S(10), field.y, field.width, field.height), "host:port", placeholderStyle);
}
GUILayout.Space(S(10));
if (GUILayout.Button("ADD", secondaryStyle, GUILayout.Height(S(34)), GUILayout.Width(S(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(S(26)), GUILayout.Width(S(90))))
{
selectedServer = null;
return;
}
GUILayout.Space(S(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(S(10), S(22), GUILayout.ExpandWidth(true));
float bx = badgeRow.x + titleStyle.padding.left;
if (s.Featured) bx += Pill(bx, badgeRow, "FEATURED", pillFeaturedStyle);
bool official = s.Tags != null && s.Tags.Contains("official"); // vergibt nur ein MSCO-Admin
if (official) bx += Pill(bx, badgeRow, "OFFICIAL", pillStyle);
bx += Pill(bx, badgeRow, s.RequireSteam ? "STEAM" : "NON-STEAM", s.RequireSteam ? pillStyle : pillMutedStyle);
if (s.IsLocal) bx += Pill(bx, badgeRow, "LOCAL", pillStyle);
if (!MSCO.Compatible(s.Version)) bx += Pill(bx, badgeRow, "v" + s.Version, pillStyle);
if (s.Tags != null)
foreach (string tag in s.Tags)
if (!string.IsNullOrEmpty(tag) && tag != "featured" && tag != "official") bx += Pill(bx, badgeRow, tag.ToUpper(), pillMutedStyle);
GUILayout.EndHorizontal();
GUILayout.EndVertical();
GUILayout.Space(S(12));
Rect heartSlot = GUILayoutUtility.GetRect(S(44), S(44), GUILayout.Width(S(44)), GUILayout.Height(S(44)));
GUI.Box(heartSlot, GUIContent.none, rowBox);
Heart(new Rect(heartSlot.x + S(11), heartSlot.y + S(11), S(22), S(22)), s);
GUILayout.Space(S(8));
GUI.enabled = CanJoin(mp, s);
if (GUILayout.Button(s.Players >= s.MaxPlayers ? "SERVER FULL" : "JOIN SERVER", primaryStyle, GUILayout.Height(S(44)), GUILayout.Width(S(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(S(10));
// Ohne Steam: Name wählen (sauber in einem eigenen Kasten)
if (s.RequireSteam && !mp.SteamAvailable)
{
GUILayout.BeginHorizontal(rowBox, GUILayout.Height(S(40)));
GUILayout.Label("This server requires Steam - start the game through Steam to join.", textStyle);
GUILayout.EndHorizontal();
GUILayout.Space(S(10));
}
else if (!mp.SteamAvailable)
{
GUILayout.BeginHorizontal(rowBox, GUILayout.Height(S(44)));
GUILayout.Space(S(6));
GUILayout.BeginVertical(GUILayout.Width(S(110)));
GUILayout.Space(S(4));
GUILayout.Label("PLAYING AS", captionStyle);
GUILayout.Label("no Steam", textStyle);
GUILayout.EndVertical();
GUILayout.BeginVertical();
GUILayout.Space(S(5));
DrawNameField(mp, 220);
GUILayout.EndVertical();
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
GUILayout.Space(S(10));
}
// Zwei Karten gleicher Höhe: Infos links, Spielerliste rechts
bool versionOk = MSCO.Compatible(s.Version);
float cardHeight = Mathf.Clamp(contentHeight - S(250), S(240), S(380));
Rect area = GUILayoutUtility.GetRect(S(10), cardHeight, GUILayout.ExpandWidth(true));
float gap = S(16), leftWidth = Mathf.Round(area.width * 0.44f);
var info = new Rect(area.x, area.y, leftWidth, area.height);
var list = new Rect(area.x + leftWidth + gap, area.y, area.width - leftWidth - gap, area.height);
GUI.Box(info, GUIContent.none, rowBox);
GUI.Box(list, GUIContent.none, rowBox);
EnsureProfileStyles();
float x = info.x + S(20), w = info.width - S(40), y = info.y + S(16);
GUI.Label(new Rect(x, y, w, S(18)), "SERVER INFO", captionStyle);
y += S(28);
y = ProfileRow(x, y, w, "HOSTED BY", string.IsNullOrEmpty(s.Owner) ? "unknown" : s.Owner, profileValueStyle);
y = ProfileRow(x, y, w, "VERSION", "v" + s.Version + (versionOk ? "" : " (you: v" + MSCO.ModVersion + ")"), versionOk ? profileValueStyle : badgeStyle);
y = ProfileRow(x, y, w, "STEAM", s.RequireSteam ? "Required" : "Not required", profileValueStyle);
y = ProfileRow(x, y, w, "ONLINE FOR", s.UptimeSeconds >= 0 ? FormatUptime(s.UptimeSeconds) : "-", profileValueStyle);
y = ProfileRow(x, y, w, "PING", s.Ping < 0 ? "-" : s.Ping + " ms", PingStyle(s.Ping));
y = ProfileRow(x, y, w, "ADDRESS", s.Address + ":" + s.Port, profileValueStyle);
if (s.Mods.Count > 0) ProfileRow(x, y, w, "MODS", string.Join(", ", s.Mods.ToArray()), profileWrapStyle);
// Spieler
float lx = list.x + S(20), lw = list.width - S(40), ly = list.y + S(16);
GUI.Label(new Rect(lx, ly, lw, S(18)), "PLAYERS " + s.Players + " / " + s.MaxPlayers, captionStyle);
ly += S(30);
var listArea = new Rect(lx, ly, lw, list.yMax - ly - S(14));
if (s.PlayerNames == null || s.PlayerNames.Count == 0)
{
GUI.Label(listArea, "Nobody online right now.", profileEmptyStyle);
}
else
{
float rowH = S(32);
var inner = new Rect(0, 0, lw - S(14), rowH * s.PlayerNames.Count);
profileScroll = GUI.BeginScrollView(listArea, profileScroll, inner);
for (int i = 0; i < s.PlayerNames.Count; i++)
{
var line = new Rect(0, i * rowH, inner.width, rowH);
if (i > 0) GUI.DrawTexture(new Rect(line.x, line.y, line.width, S(1)), texDivider);
GUI.DrawTexture(new Rect(line.x + S(2), line.y + (rowH - S(8)) / 2f, S(8), S(8)), texOnline);
GUI.Label(new Rect(line.x + S(20), line.y, line.width - S(20), rowH), s.PlayerNames[i], profileValueStyle);
}
GUI.EndScrollView();
}
GUILayout.FlexibleSpace();
}
GUIStyle profileValueStyle, profileWrapStyle, profileCaptionStyle, profileEmptyStyle;
void EnsureProfileStyles()
{
if (profileValueStyle != null && profileValueStyle.fontSize == P(15)) return;
profileValueStyle = new GUIStyle(rowNameStyle) { fontSize = P(15), wordWrap = false, clipping = TextClipping.Clip, alignment = TextAnchor.MiddleLeft };
profileWrapStyle = new GUIStyle(profileValueStyle) { wordWrap = true, fontSize = P(13), alignment = TextAnchor.UpperLeft };
profileWrapStyle.normal.textColor = Muted;
profileCaptionStyle = new GUIStyle(captionStyle) { alignment = TextAnchor.MiddleLeft };
profileCaptionStyle.margin = new RectOffset();
profileEmptyStyle = new GUIStyle(textStyle) { alignment = TextAnchor.MiddleCenter };
}
/// <summary>Info-Zeile der Server-Karte: Bezeichnung links, Wert daneben, feine Linie darunter. Gibt das nächste y zurück.</summary>
float ProfileRow(float x, float y, float w, string caption, string value, GUIStyle valueStyle)
{
const float captionWidth = 120f;
float valueWidth = w - S(captionWidth);
float h = S(36);
if (valueStyle.wordWrap) h = Mathf.Max(h, valueStyle.CalcHeight(new GUIContent(value), valueWidth) + S(16));
GUI.Label(new Rect(x, y, S(captionWidth), S(36)), caption, profileCaptionStyle);
var valueRect = valueStyle.wordWrap ? new Rect(x + S(captionWidth), y + S(9), valueWidth, h - S(9)) : new Rect(x + S(captionWidth), y, valueWidth, S(36));
GUIStyle style = valueStyle;
if (!valueStyle.wordWrap && valueStyle.alignment != TextAnchor.MiddleLeft)
style = new GUIStyle(valueStyle) { alignment = TextAnchor.MiddleLeft };
GUI.Label(valueRect, value, style);
GUI.DrawTexture(new Rect(x, y + h, w, S(1)), texDivider);
return y + h + S(1);
}
/// <summary>Eine Info-Zeile: Bezeichnung links (grau), Wert rechts.</summary>
void InfoLine(string caption, string value, GUIStyle valueStyle)
{
GUILayout.BeginHorizontal(GUILayout.Height(S(26)));
GUILayout.Label(caption, captionStyle, GUILayout.Width(S(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 - S(18)) / 2f, size.x, S(18)), text, style);
return size.x + S(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(S(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(S(2));
}
/// <summary>Ping farbig: grün gut, gelb geht so, rot schlecht.</summary>
GUIStyle PingStyle(int ping)
{
if (ping < 0) return rowNameStyle;
return ping < 80 ? pingGoodStyle : ping < 160 ? pingMidStyle : pingBadStyle;
}
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(S(16));
GUILayout.Label("SERVER", captionStyle);
GUILayout.Label(w.Name + " (" + w.Host + ":" + w.Port + ")", rowNameStyle);
GUILayout.Space(S(8));
GUILayout.Label("VERSIONS", captionStyle);
GUILayout.Label("Server v" + w.ServerVersion + " - You v" + MSCO.ModVersion, rowTagStyle);
GUILayout.Space(S(22));
GUILayout.BeginHorizontal();
if (GUILayout.Button("JOIN ANYWAY", primaryStyle, GUILayout.Height(S(42)), GUILayout.Width(S(170))))
mp.ConfirmVersionWarning();
GUILayout.Space(S(10));
if (GUILayout.Button("CANCEL", secondaryStyle, GUILayout.Height(S(42)), GUILayout.Width(S(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(S(18));
GUILayout.Label("SERVER ADDRESS", captionStyle);
GUILayout.Label(mp.PendingJoinHost + ":" + mp.PendingJoinPort, rowNameStyle);
GUILayout.Space(S(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(S(42)), GUILayout.Width(S(170))))
mp.ConfirmPendingJoin();
GUI.enabled = true;
GUILayout.Space(S(10));
if (GUILayout.Button("CANCEL", secondaryStyle, GUILayout.Height(S(42)), GUILayout.Width(S(170))))
mp.CancelPendingJoin();
GUILayout.EndHorizontal();
}
const float PromptWidth = 540f;
const float PromptHeight = 270f;
Texture2D texDim, texDanger, texScreen;
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.fontSize != P(15))
{
promptTextStyle = new GUIStyle(rowNameStyle);
promptTextStyle.wordWrap = true;
promptTextStyle.fontSize = P(15);
}
GUI.DrawTexture(new Rect(0, 0, vw, vh), texDim);
var r = new Rect(Mathf.Round((vw - S(PromptWidth)) / 2f), Mathf.Round((vh - S(PromptHeight)) / 2f), S(PromptWidth), S(PromptHeight));
UiTheme.DrawShadow(r);
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, S(4)), bad ? texDanger : texAccent);
float x = r.x + S(32), w = r.width - S(64), y = r.y + S(28);
GUI.Label(new Rect(x, y, w, S(34)), title, titleStyle);
y += S(50);
GUI.Label(new Rect(x, y, w, S(20)), "REASON", captionStyle);
if (reconnecting)
{
GUI.Label(new Rect(x, y + S(22), w, S(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 + S(74), w, S(40)), attempt, textStyle);
if (GUI.Button(new Rect(r.xMax - S(32) - S(170), r.yMax - S(28) - S(42), S(170), S(42)), "CANCEL", secondaryStyle)) mp.CancelReconnect();
return;
}
GUI.Label(new Rect(x, y + S(22), w, S(60)), mp.DisconnectNotice ?? "", promptTextStyle);
GUI.Label(new Rect(x, y + S(88), w, S(20)), "Your own save game has been restored.", textStyle);
if (GUI.Button(new Rect(r.xMax - S(32) - S(170), r.yMax - S(28) - S(42), S(170), S(42)), "OK", primaryStyle)) mp.ClearDisconnectNotice();
}
// ---------------------------------------------------------------- Mods
Vector2 modsScroll;
/// <summary>Server verlangt Mods, die hier fehlen: zeigen, was geladen würde, und fragen.</summary>
void DrawModsPrompt(MultiplayerManager mp)
{
if (texDim == null) texDim = Solid(new Color(0f, 0f, 0f, 0.6f));
GUI.DrawTexture(new Rect(0, 0, vw, vh), texDim);
List<MultiplayerManager.ServerModInfo> list = mp.PendingMods;
float height = S(250) + S(46) * Mathf.Min(list.Count, 6);
var r = new Rect(Mathf.Round((vw - S(620)) / 2f), Mathf.Round((vh - height) / 2f), S(620), height);
UiTheme.DrawShadow(r);
GUI.Box(r, GUIContent.none, windowBox);
GUI.DrawTexture(new Rect(r.x, r.y, r.width, S(4)), texAccent);
float x = r.x + S(32), w = r.width - S(64), y = r.y + S(26);
GUI.Label(new Rect(x, y, w, S(34)), "THIS SERVER USES MODS", titleStyle);
y += S(44);
GUI.Label(new Rect(x, y, w, S(44)), "To play here you need these mods. Mods are programs - they run on your PC. " +
"Only install them from servers you trust.", textStyle);
y += S(52);
for (int i = 0; i < list.Count && i < 6; i++)
{
MultiplayerManager.ServerModInfo m = list[i];
var row = new Rect(x, y, w, S(40));
GUI.Box(row, GUIContent.none, rowBox);
GUI.Label(new Rect(row.x + S(14), row.y, w * 0.55f, row.height), m.Name + (string.IsNullOrEmpty(m.Version) ? "" : " " + m.Version), rowNameStyle);
GUI.Label(new Rect(row.x + w * 0.55f, row.y, w * 0.45f - S(14), row.height),
(m.Size / 1024) + " KB · SHA-256 " + (m.Sha256.Length > 12 ? m.Sha256.Substring(0, 12) + "…" : m.Sha256), headerCellStyle);
y += S(46);
}
if (list.Count > 6) GUI.Label(new Rect(x, y - S(6), w, S(20)), "+ " + (list.Count - 6) + " more", textStyle);
float by = r.yMax - S(28) - S(42);
if (GUI.Button(new Rect(r.xMax - S(32) - S(220), by, S(220), S(42)), "INSTALL & JOIN", primaryStyle)) mp.AcceptServerMods();
if (GUI.Button(new Rect(r.xMax - S(32) - S(220) - S(12) - S(140), by, S(140), S(42)), "CANCEL", secondaryStyle)) mp.DeclineServerMods();
}
/// <summary>Seite "Mods": geladene MSCO-Mods, Ordner öffnen.</summary>
void DrawMods()
{
GUILayout.BeginHorizontal();
GUILayout.Label("Mods", titleStyle, GUILayout.ExpandWidth(true));
if (GUILayout.Button("OPEN FOLDER", tabStyle, GUILayout.Height(S(28)), GUILayout.Width(S(130))))
{
try
{
System.IO.Directory.CreateDirectory(ModLoader.Folder);
System.Diagnostics.Process.Start("explorer.exe", "\"" + ModLoader.Folder + "\"");
}
catch { }
}
GUILayout.EndHorizontal();
GUILayout.Label("Put MSCO mods (.dll) into MSCO\\Mods in the game folder and restart the game - or manage them in the MSCO Launcher. " +
"Mods from a server are downloaded when you join it (you are asked first) and only run on that server.", textStyle);
GUILayout.Space(S(12));
modsScroll = GUILayout.BeginScrollView(modsScroll, GUILayout.Height(ListHeight(S(150f), S(140f))));
if (ModLoader.All.Count == 0) GUILayout.Label("No mods installed.", textStyle);
foreach (ModLoader.LoadedMod m in ModLoader.All)
{
Rect row = GUILayoutUtility.GetRect(S(10), S(52), GUILayout.ExpandWidth(true));
var box = new Rect(row.x, row.y + S(3), row.width, row.height - S(6));
GUI.Box(box, GUIContent.none, rowBox);
GUI.DrawTexture(new Rect(box.x + S(14), box.y + (box.height - S(8)) / 2f, S(8), S(8)),
m.Error != null ? texPending : m.Active ? texOnline : texMuted);
string name = "?", version = "", author = "";
try { name = m.Mod.Name; version = m.Mod.Version; author = m.Mod.Author; } catch { }
float nx = box.x + S(32);
if (m.FromServer) nx += Pill(nx, new Rect(box.x, box.y, box.width, box.height), "SERVER", pillMutedStyle);
GUI.Label(new Rect(nx, box.y, box.width * 0.5f, box.height), name + " " + version, rowNameStyle);
string state = m.Error != null ? "turned off: " + m.Error : m.Active ? "active" : m.FromServer ? "only on its server" : "off";
GUI.Label(new Rect(box.x + box.width * 0.55f, box.y, box.width * 0.45f - S(14), box.height),
(string.IsNullOrEmpty(author) ? "" : "by " + author + " · ") + state, headerCellStyle);
}
GUILayout.EndScrollView();
}
bool newGameConfirm;
/// <summary>Host: eigenen Spielstand weiterspielen oder ein neues Spiel anlegen (mit Sicherheitsabfrage).</summary>
void DrawHostStart(MultiplayerManager mp)
{
System.DateTime? saved = SaveSync.LocalSaveTime;
if (newGameConfirm)
{
GUILayout.BeginVertical(rowBox);
GUILayout.Space(S(6));
GUILayout.Label("START A NEW GAME?", captionStyle);
GUILayout.Label(saved != null
? "Your current save game is kept as a backup in MSCO_Saves - everyone starts in a fresh world."
: "Everyone starts in a fresh world.", textStyle);
GUILayout.Space(S(6));
GUILayout.BeginHorizontal();
if (GUILayout.Button("YES, NEW GAME", dangerStyle, GUILayout.Height(S(38)), GUILayout.Width(S(170))))
{
newGameConfirm = false;
mp.StartHostNewGame();
}
GUILayout.Space(S(8));
if (GUILayout.Button("CANCEL", secondaryStyle, GUILayout.Height(S(38)), GUILayout.Width(S(110)))) newGameConfirm = false;
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
GUILayout.Space(S(6));
GUILayout.EndVertical();
return;
}
GUILayout.BeginHorizontal();
GUI.enabled = saved != null;
GUILayout.BeginVertical(GUILayout.Width(S(190)));
if (GUILayout.Button("CONTINUE", primaryStyle, GUILayout.Height(S(42)), GUILayout.Width(S(190))))
mp.StartHostGame();
GUILayout.Label(saved != null ? "saved " + saved.Value.ToString("dd.MM.yyyy HH:mm") : "no save game yet", captionStyle);
GUILayout.EndVertical();
GUI.enabled = true;
GUILayout.Space(S(10));
GUILayout.BeginVertical(GUILayout.Width(S(190)));
if (GUILayout.Button("NEW GAME", secondaryStyle, GUILayout.Height(S(42)), GUILayout.Width(S(190))))
{
if (saved != null) newGameConfirm = true;
else mp.StartHostNewGame();
}
GUILayout.Label("fresh world for everyone", captionStyle);
GUILayout.EndVertical();
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
}
void DrawSession(MultiplayerManager mp)
{
GUILayout.Label(mp.IsHost ? "Hosting" : "Connected", titleStyle);
GUILayout.Label(mp.IsHost
? "Your game is open. Continue your save or start a new game - friends can also join later."
: 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(S(16));
GUILayout.Label("PLAYERS " + mp.Players.Count + "/" + mp.SessionMaxPlayers, captionStyle);
playerScroll = GUILayout.BeginScrollView(playerScroll, GUILayout.Height(ListHeight(mp.IsHost ? S(330f) : S(170f), S(120f))));
foreach (PlayerInfo p in mp.Players)
{
Rect row = GUILayoutUtility.GetRect(S(10), S(32), GUILayout.ExpandWidth(true));
GUI.Box(new Rect(row.x, row.y, row.width, row.height - S(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 + S(12), row.y + S(11), S(8), S(8)), p.IsLocal && !mp.IsFullyConnected ? texPending : texOnline);
GUI.Label(new Rect(row.x + S(30), row.y, row.width - S(150), row.height - S(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 - S(120), row.y, S(110), row.height - S(2)), tag, rowTagStyle);
}
GUILayout.EndScrollView();
GUILayout.Space(S(14));
if (mp.IsHost)
{
DrawHostStart(mp);
GUILayout.Space(S(10));
}
GUILayout.BeginHorizontal();
if (mp.IsHost)
{
if (mp.IsSteamSession && mp.Players.Count < MultiplayerManager.MaxPlayers &&
GUILayout.Button("INVITE FRIENDS", secondaryStyle, GUILayout.Height(S(42)), GUILayout.Width(S(150))))
mp.InviteFriends();
GUILayout.Space(S(10));
}
if (GUILayout.Button("DISCONNECT", secondaryStyle, GUILayout.Height(S(42)), GUILayout.Width(S(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 + S(18), r.y + (r.height - S(8)) / 2f, S(8), S(8)), dot);
GUI.Label(new Rect(r.x + S(36), r.y, r.width - S(72), r.height), mp.Status, statusStyle);
}
// ---------------------------------------------------------------- Hilfen
void EnsureStyles()
{
if (stylesReady && texBackground != null && Mathf.Abs(stylesScale - uiScale) < 0.01f) return;
stylesScale = uiScale;
// PlayerPrefs erst hier lesen (nicht im Konstruktor erlaubt)
if (directInput == null) directInput = PlayerPrefs.GetString(PrefDirect, "");
texBackground = Solid(Background);
texSidebar = Solid(Sidebar);
texAccent = Solid(Accent);
texOnline = UiTheme.Rounded(Online, P(4)); // runde Status-Punkte
texPending = UiTheme.Rounded(Pending, P(4));
texMuted = UiTheme.Rounded(Muted, P(4));
texRow = Solid(Field);
// Fenster und Seitenleiste mit feinem Rand (Seitenleiste: ihr Rand trennt sie vom Inhalt)
windowBox = UiTheme.Card(Background, P(10));
sidebarBox = UiTheme.Card(Sidebar, P(10));
rowBox = UiTheme.Card(Field, P(10));
rowHoverBox = new GUIStyle(rowBox);
rowHoverBox.normal.background = UiTheme.RoundedEx(FieldFocus, FieldFocus, new Color(Accent.r, Accent.g, Accent.b, 0.45f), true, P(10), P(10) * 2 + 4);
texGlow = Glow(new Color(Accent.r, Accent.g, Accent.b, 0.16f));
scrollbarStyle = new GUIStyle();
scrollbarStyle.fixedWidth = S(8);
scrollbarStyle.margin = new RectOffset(P(6), 0, 0, 0);
scrollThumbStyle = UiTheme.Panel9(new Color(1f, 1f, 1f, 0.14f), P(3));
scrollThumbStyle.fixedWidth = S(6);
scrollThumbStyle.hover.background = UiTheme.Rounded(new Color(1f, 1f, 1f, 0.24f), P(3));
texDivider = Solid(new Color(1f, 1f, 1f, 0.07f));
texGold = Solid(Gold);
texBarBack = Solid(new Color(1f, 1f, 1f, 0.1f));
texBarFill = Solid(Online);
texBarFull = Solid(UiTheme.Danger);
accentBox = UiTheme.Panel9(Accent, P(1));
pillStyle = UiTheme.Panel9(new Color(Accent.r, Accent.g, Accent.b, 0.18f), P(4));
pillStyle.normal.textColor = Accent;
pillStyle.fontSize = P(10);
pillStyle.fontStyle = FontStyle.Bold;
pillStyle.alignment = TextAnchor.MiddleCenter;
pillStyle.padding = new RectOffset(P(7), P(7), P(0), P(0));
pillStyle.wordWrap = false;
pillMutedStyle = new GUIStyle(pillStyle);
pillMutedStyle.normal.background = UiTheme.Rounded(new Color(1f, 1f, 1f, 0.08f), P(4));
pillMutedStyle.normal.textColor = Muted;
pillFeaturedStyle = new GUIStyle(pillStyle);
pillFeaturedStyle.normal.background = UiTheme.Rounded(Gold, P(4));
pillFeaturedStyle.normal.textColor = Hex(0x1a, 0x12, 0x06);
featuredRowBox = UiTheme.Card(Color.Lerp(Field, Gold, 0.14f), P(10));
chipOnStyle = Button(new Color(Accent.r, Accent.g, Accent.b, 0.2f), new Color(Accent.r, Accent.g, Accent.b, 0.3f), AccentHover, P(11), FontStyle.Bold, P(11), new Color(Accent.r, Accent.g, Accent.b, 0.55f));
chipOffStyle = Button(Field, FieldFocus, Muted, P(11), FontStyle.Bold, P(11), UiTheme.Border);
chipOffStyle.hover.textColor = Color.white;
chipOnStyle.padding = chipOffStyle.padding = new RectOffset(P(12), P(12), P(0), P(0));
logoStyle = Label(P(28), Color.white, FontStyle.Bold);
taglineStyle = Label(P(10), Muted, FontStyle.Bold);
titleStyle = Label(P(24), Color.white, FontStyle.Bold);
textStyle = Label(P(13), Muted, FontStyle.Normal);
textStyle.wordWrap = true;
captionStyle = Label(P(11), Muted, FontStyle.Bold);
captionStyle.margin.bottom = P(6);
statusStyle = Label(P(12), Color.white, FontStyle.Normal);
statusStyle.alignment = TextAnchor.MiddleLeft;
headerCellStyle = Label(P(11), Muted, FontStyle.Bold);
headerCellStyle.alignment = TextAnchor.MiddleLeft;
rowNameStyle = Label(P(14), Color.white, FontStyle.Normal);
rowNameStyle.alignment = TextAnchor.MiddleLeft;
pingGoodStyle = new GUIStyle(rowNameStyle);
pingGoodStyle.normal.textColor = Online;
pingMidStyle = new GUIStyle(rowNameStyle);
pingMidStyle.normal.textColor = Pending;
pingBadStyle = new GUIStyle(rowNameStyle);
pingBadStyle.normal.textColor = UiTheme.DangerHover;
rowTagStyle = Label(P(11), Accent, FontStyle.Bold);
rowTagStyle.alignment = TextAnchor.MiddleRight;
badgeStyle = new GUIStyle(rowTagStyle);
badgeStyle.alignment = TextAnchor.MiddleLeft;
badgeStyle.padding = new RectOffset(P(6), P(6), P(2), P(2));
placeholderStyle = Label(P(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 = P(11);
toggleStyle.fontStyle = FontStyle.Bold;
navStyle = Button(Sidebar, new Color(1f, 1f, 1f, 0.05f), Muted, P(14), FontStyle.Normal, P(10));
navStyle.hover.textColor = Color.white; // Hover: Text hell (vorher blieb er grau)
navStyle.alignment = TextAnchor.MiddleLeft;
navStyle.padding = new RectOffset(P(22), P(10), P(0), P(0));
navActiveStyle = Button(Color.Lerp(Sidebar, Accent, 0.12f), Color.Lerp(Sidebar, Accent, 0.16f), Color.white, P(14), FontStyle.Bold, P(10), new Color(Accent.r, Accent.g, Accent.b, 0.25f));
navActiveStyle.alignment = TextAnchor.MiddleLeft;
navActiveStyle.padding = new RectOffset(P(22), P(10), P(0), P(0));
primaryStyle = Button(Accent, AccentHover, Color.white, P(14), FontStyle.Bold, P(10));
primaryStyle.normal.background = primaryStyle.focused.background = UiTheme.RoundedEx(AccentHover, Accent, Color.clear, false, P(10));
primaryStyle.hover.background = primaryStyle.active.background = primaryStyle.onNormal.background =
UiTheme.RoundedEx(Color.Lerp(AccentHover, Color.white, 0.12f), AccentHover, Color.clear, false, P(10));
dangerStyle = Button(UiTheme.Danger, UiTheme.DangerHover, Color.white, P(13), FontStyle.Bold, P(10));
secondaryStyle = Button(Hex(0x22, 0x22, 0x28), Hex(0x2a, 0x2a, 0x31), Color.white, P(14), FontStyle.Bold, P(10), UiTheme.Border);
tabStyle = Button(Hex(0x1b, 0x1b, 0x20), Hex(0x24, 0x24, 0x2a), Muted, P(11), FontStyle.Bold, P(10), UiTheme.Border);
tabStyle.hover.textColor = Color.white;
fieldStyle = UiTheme.Field(P(14));
fieldStyle.normal.background = UiTheme.RoundedEx(Hex(0x16, 0x16, 0x1a), Hex(0x16, 0x16, 0x1a), UiTheme.Border, true, P(10), P(10) * 2 + 4);
fieldStyle.hover.background = UiTheme.RoundedEx(Hex(0x19, 0x19, 0x1e), Hex(0x19, 0x19, 0x1e), new Color(1f, 1f, 1f, 0.14f), true, P(10), P(10) * 2 + 4);
fieldStyle.focused.background = UiTheme.RoundedEx(Hex(0x19, 0x19, 0x1e), Hex(0x19, 0x19, 0x1e), Accent, true, P(10), P(10) * 2 + 4);
fieldStyle.border = new RectOffset(P(10) + 2, P(10) + 2, P(10) + 2, P(10) + 2);
stylesReady = true;
}
static GUIStyle Label(int size, Color color, FontStyle font)
{
var s = new GUIStyle(GUI.skin.label);
if (UiTheme.DynamicFont != null) s.font = UiTheme.DynamicFont;
s.fontSize = size;
s.fontStyle = font;
s.normal.textColor = color;
return s;
}
/// <summary>Knopf mit runden Ecken (9-Slice, siehe UiTheme), optional mit feinem Rand wie .btn auf der Website.</summary>
static GUIStyle Button(Color normal, Color hover, Color text, int size, FontStyle font, int radius = UiTheme.Radius, Color border = default(Color))
{
var s = new GUIStyle(GUI.skin.button);
if (UiTheme.DynamicFont != null) s.font = UiTheme.DynamicFont;
bool withBorder = border.a > 0f;
int h = radius * 2 + 4;
Texture2D n = UiTheme.RoundedEx(normal, normal, border, withBorder, radius, h);
Texture2D o = UiTheme.RoundedEx(hover, hover, withBorder ? new Color(border.r, border.g, border.b, Mathf.Min(1f, border.a * 1.8f)) : border, withBorder, radius, h);
s.normal.background = n;
s.focused.background = n;
s.hover.background = o;
s.active.background = o;
s.onNormal.background = o;
s.normal.textColor = s.hover.textColor = s.active.textColor = s.focused.textColor = s.onNormal.textColor = text;
s.border = new RectOffset(radius + 2, radius + 2, radius + 2, radius + 2);
s.fontSize = size;
s.fontStyle = font;
return s;
}
/// <summary>Weicher runder Schein (Mitte deckend, nach außen durchsichtig) - für den orangen Schimmer oben.</summary>
static Texture2D Glow(Color color)
{
const int w = 128, h = 64;
var tex = new Texture2D(w, h, TextureFormat.ARGB32, false);
tex.wrapMode = TextureWrapMode.Clamp;
tex.filterMode = FilterMode.Bilinear;
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
float dx = (x + 0.5f) / w * 2f - 1f, dy = (y + 0.5f) / h * 2f - 1f;
float d = Mathf.Clamp01(Mathf.Sqrt(dx * dx + dy * dy));
float a = 1f - d;
tex.SetPixel(x, y, new Color(color.r, color.g, color.b, color.a * a * a));
}
tex.Apply();
tex.hideFlags = HideFlags.HideAndDontSave;
return tex;
}
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);
}
}
}