diff --git a/CHANGELOG.md b/CHANGELOG.md index 092688a..d787225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ Server und Mod müssen beide 2.8 sein (Server: `git pull && docker compose up -d - **Backups** mit Datum/Uhrzeit, **RESTORE** holt einen älteren Stand zurück (der aktuelle wird vorher gesichert) - Jede Aktion mit Sicherheitsabfrage; nur für Admins (vom Server geprüft) +**Favoriten** +- Herz neben jedem Server (Liste und Profil) merkt ihn als Favorit +- Neuer Tab **Favorites**: jedem Server einen eigenen Kurznamen geben, beitreten, entfernen, per Adresse hinzufügen +- Schnell wechseln: `/switch ` im Chat oder `switch ` in der F7-Konsole – trennt und verbindet + direkt mit dem anderen Server; `fav` listet alle, `fav add ` merkt den aktuellen Server + **Pro Spieler** - Hunger, Durst, Stress, Harndrang, Müdigkeit und Schmutz werden für jeden Spieler einzeln auf dem Server gespeichert und beim Beitreten zurückgegeben (nicht mehr die Werte aus dem gemeinsamen Spielstand) diff --git a/Commands.cs b/Commands.cs index 4f0d13a..942ab69 100644 --- a/Commands.cs +++ b/Commands.cs @@ -10,6 +10,10 @@ namespace MSCO { public const string Help = "connect - join a dedicated server\n" + + "switch - quickly switch to a favorite server (also /switch in chat)\n" + + "fav - list your favorite servers\n" + + "fav add [host:port] - add a favorite (without address: the server you are on)\n" + + "fav remove - remove a favorite\n" + "disconnect - leave the server / game\n" + "host [port] / join [port] - direct LAN game (no Steam)\n" + "save - save the game (host / singleplayer)\n" + @@ -19,6 +23,37 @@ namespace MSCO "debug - toggle sync debug output\n" + "clear - clear this console"; + static void Favorite(MultiplayerManager mp, string[] args, Action print) + { + string sub = args.Length > 1 ? args[1].ToLower() : "list"; + if (sub == "list") + { + if (Favorites.All.Count == 0) { print("No favorites yet - open a server in the browser and press FAVORITE, or: fav add "); return; } + foreach (Favorites.Favorite f in Favorites.All) + print(" " + f.Nickname + " -> " + f.Address + (string.IsNullOrEmpty(f.ServerName) ? "" : " (" + f.ServerName + ")")); + print("switch to join one"); + return; + } + if (sub == "add" || sub == "set") + { + if (args.Length < 3) { print("fav add [host:port]"); return; } + string host = mp.CurrentServerHost, name = mp.CurrentServerName; + int port = mp.CurrentServerPort; + if (args.Length > 3 && !ServerBrowser.TryParseAddress(args[3], out host, out port)) { print("Invalid address: " + args[3]); return; } + if (host == null) { print("You are not on a server - use: fav add "); return; } + string error = Favorites.Set(args[2], host, port, name); + print(error ?? "Favorite '" + Favorites.CleanNickname(args[2]) + "' saved (" + host + ":" + port + ")"); + return; + } + if (sub == "remove" || sub == "del" || sub == "delete") + { + Favorites.Favorite f = args.Length > 2 ? Favorites.Find(args[2]) : null; + print(f != null && Favorites.Remove(f) ? "Favorite '" + f.Nickname + "' removed" : "No such favorite"); + return; + } + print("fav | fav add [host:port] | fav remove "); + } + public static void Execute(string[] args, Action print) { var mp = MultiplayerManager.Instance; @@ -59,6 +94,15 @@ namespace MSCO mp.JoinServer(host, serverPort); break; } + case "switch": + case "sw": + print(mp.SwitchServer(args.Length > 1 ? args[1] : "")); + break; + case "fav": + case "favs": + case "favorites": + Favorite(mp, args, print); + break; case "save": mp.SaveGame(); break; diff --git a/Favorites.cs b/Favorites.cs new file mode 100644 index 0000000..8dd2d30 --- /dev/null +++ b/Favorites.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Text; +using UnityEngine; + +namespace MSCO +{ + /// + /// Lieblings-Server mit eigenem Kurznamen (z.B. "mbm"). Gespeichert in den PlayerPrefs, eine Zeile pro Server: + /// Kurzname, Adresse, Port, Servername (Tab-getrennt). Mit "switch " wechselt man schnell den Server. + /// + public static class Favorites + { + public class Favorite + { + public string Nickname; + public string Host; + public int Port; + public string ServerName; + public string Address { get { return Host + ":" + Port; } } + } + + const string Pref = "MSCO_Favorites"; + const int MaxFavorites = 30; + static List list; + + public static List All { get { Load(); return list; } } + + static void Load() + { + if (list != null) return; + list = new List(); + string raw = PlayerPrefs.GetString(Pref, ""); + foreach (string line in raw.Split('\n')) + { + string[] p = line.Split('\t'); + int port; + if (p.Length < 3 || p[0].Length == 0 || p[1].Length == 0 || !int.TryParse(p[2], out port)) continue; + list.Add(new Favorite { Nickname = p[0], Host = p[1], Port = port, ServerName = p.Length > 3 ? p[3] : "" }); + } + } + + static void Save() + { + var sb = new StringBuilder(); + foreach (Favorite f in list) + sb.Append(Clean(f.Nickname)).Append('\t').Append(Clean(f.Host)).Append('\t').Append(f.Port).Append('\t').Append(Clean(f.ServerName)).Append('\n'); + PlayerPrefs.SetString(Pref, sb.ToString()); + PlayerPrefs.Save(); + } + + static string Clean(string s) { return (s ?? "").Replace("\t", " ").Replace("\n", " ").Trim(); } + + /// Kurzname: Buchstaben, Zahlen, - und _ (ohne Leerzeichen, damit "switch name" eindeutig ist). + public static string CleanNickname(string nickname) + { + var sb = new StringBuilder(); + foreach (char c in (nickname ?? "").Trim()) + if (char.IsLetterOrDigit(c) || c == '-' || c == '_') sb.Append(c); + string s = sb.ToString(); + return s.Length > 20 ? s.Substring(0, 20) : s; + } + + public static Favorite Find(string nickname) + { + Load(); + return list.Find(f => string.Equals(f.Nickname, nickname, StringComparison.OrdinalIgnoreCase)); + } + + public static Favorite FindByAddress(string host, int port) + { + Load(); + return list.Find(f => f.Port == port && string.Equals(f.Host, host, StringComparison.OrdinalIgnoreCase)); + } + + /// Hinzufügen oder (gleiche Adresse) umbenennen. Gibt einen Fehler zurück oder null. + public static string Set(string nickname, string host, int port, string serverName) + { + Load(); + nickname = CleanNickname(nickname); + if (nickname.Length == 0) return "Nickname: letters, numbers, - and _"; + if (string.IsNullOrEmpty(host) || port <= 0 || port > 65535) return "Invalid server address"; + Favorite sameName = Find(nickname); + Favorite existing = FindByAddress(host, port); + if (sameName != null && sameName != existing) return "The nickname '" + nickname + "' is already used for " + sameName.Address; + if (existing == null) + { + if (list.Count >= MaxFavorites) return "Too many favorites (max " + MaxFavorites + ")"; + existing = new Favorite { Host = host, Port = port }; + list.Add(existing); + } + existing.Nickname = nickname; + if (!string.IsNullOrEmpty(serverName)) existing.ServerName = serverName; + Save(); + return null; + } + + public static bool Remove(Favorite f) + { + Load(); + if (f == null || !list.Remove(f)) return false; + Save(); + return true; + } + + /// Vorschlag für den Kurznamen aus dem Servernamen (erstes Wort, klein). + public static string SuggestNickname(string serverName) + { + string first = (serverName ?? "").Trim().Split(' ')[0]; + string nick = CleanNickname(first).ToLower(); + return nick.Length > 0 ? nick : "server"; + } + } +} diff --git a/LobbyUI.cs b/LobbyUI.cs index c9fd035..a1276dd 100644 --- a/LobbyUI.cs +++ b/LobbyUI.cs @@ -10,7 +10,7 @@ namespace MSCO /// public class LobbyUI { - enum Page { P2P, ServerBrowser } + enum Page { P2P, ServerBrowser, Favorites } const float Width = 760f; const float Height = 460f; @@ -40,6 +40,10 @@ namespace MSCO string selectedServer; // "adresse:port" des geöffneten Server-Profils string searchInput = ""; bool showSteam = true, showNonSteam = true, hideFull; + readonly Dictionary nicknameEdits = new Dictionary(); // Favoriten-Tab: Adresse -> bearbeiteter Kurzname + string favoriteAddInput = ""; + Vector2 favoritesScroll; + Texture2D texHeartOn, texHeartOff; string nameInput; string directInput; const string PrefDirect = "MSCO_LastServer"; @@ -92,6 +96,7 @@ namespace MSCO 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(); @@ -118,6 +123,9 @@ namespace MSCO 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) @@ -232,12 +240,17 @@ namespace MSCO 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) @@ -302,6 +315,168 @@ namespace MSCO } } + // ---------------------------------------------------------------- Favoriten + + /// Herz: gefüllt = Favorit. Klick merkt den Server (Kurzname aus dem Namen) bzw. entfernt ihn. + 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); + } + + /// Tab "Favorites": Kurznamen setzen, beitreten, entfernen, per Adresse hinzufügen. + void DrawFavorites(MultiplayerManager mp) + { + EnsureHearts(); + GUILayout.Label("Favorites", titleStyle); + GUILayout.Label("Give each server a nickname - switch in the game with /switch in the chat or F7 console.", textStyle); + GUILayout.Space(8); + + List 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); + } + + /// Herz als Textur (gefüllt oder nur Umriss), weich geglättet. + 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; + } + /// Server-Profil: wem der Server gehört, Version, Spieler, Laufzeit ... void DrawServerProfile(MultiplayerManager mp, ServerListEntry s) { @@ -332,6 +507,10 @@ namespace MSCO 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); @@ -511,7 +690,9 @@ namespace MSCO 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 ? "RECONNECTING" : mp.DisconnectTitle; + 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); diff --git a/MSCO.csproj b/MSCO.csproj index 9857b32..9d4481d 100644 --- a/MSCO.csproj +++ b/MSCO.csproj @@ -81,6 +81,7 @@ + diff --git a/MultiplayerManager.cs b/MultiplayerManager.cs index 157670e..21fcade 100644 --- a/MultiplayerManager.cs +++ b/MultiplayerManager.cs @@ -1021,6 +1021,53 @@ namespace MSCO public int ReconnectAttempt { get { return reconnectAttempts; } } public int ReconnectMaxAttempts { get { return MaxReconnectAttempts; } } + /// Server, mit dem man gerade verbunden ist (dedizierter Server), sonst null. + public string CurrentServerHost { get { return session != null && dedicated ? lastServerHost : null; } } + public int CurrentServerPort { get { return lastServerPort; } } + public string CurrentServerName { get { return lastServerName; } } + + /// + /// Schnell den Server wechseln: Favorit per Kurzname (oder host:port). Ist man gerade verbunden, wird wie beim + /// Neu-Verbinden nach einem Welt-Reset getrennt und danach automatisch mit dem neuen Server verbunden. + /// + public string SwitchServer(string target) + { + target = (target ?? "").Trim(); + if (target.Length == 0) return "switch (see 'fav')"; + Favorites.Favorite fav = Favorites.Find(target); + string host, name; + int port; + if (fav != null) { host = fav.Host; port = fav.Port; name = string.IsNullOrEmpty(fav.ServerName) ? fav.Nickname : fav.ServerName; } + else if (ServerBrowser.TryParseAddress(target, out host, out port)) name = null; + else return "No favorite called '" + target + "' - type 'fav' to see yours"; + + if (session != null && dedicated && string.Equals(lastServerHost, host, System.StringComparison.OrdinalIgnoreCase) && lastServerPort == port) + return "You are already on " + (fav != null ? fav.Nickname : host + ":" + port); + + string label = fav != null ? fav.Nickname : host + ":" + port; + if (session == null) + { + if (!MenuHelper.InMenu) return "Go to the main menu first (or join a server) to switch"; + JoinServer(host, port, name); + return "Connecting to " + label + " ..."; + } + + // Verbunden: trennen und danach automatisch mit dem neuen Server verbinden (übersteht das Neuladen der Mods) + lastServerHost = host; + lastServerPort = port; + lastServerName = name; + lastServerMax = 0; + serverReason = null; + reconnectAttempts = 0; + reconnectMessage = "Switching to " + label + " ..."; + centerMessage = reconnectMessage; + centerMessageUntil = Time.realtimeSinceStartup + 2f; + session.Close("Switching server"); + if (MenuHelper.InGame) reconnectHoldUntil = Time.realtimeSinceStartup + 0.8f; // kurz die Meldung zeigen + else OnSessionEnded("Switching server"); + return reconnectMessage; + } + /// Automatisches Neu-Verbinden abbrechen (Knopf im Reconnect-Fenster). public void CancelReconnect() {