MSCO 2.10.0: mod support, ESC no longer quits from the main menu

- MscoMod API (ModApi.cs) and ModLoader: own mods from MSCO\Mods, hooks for menu/game/update/GUI,
  session and player events, Send/OnMessage between players (MsgType.ModMessage)
- Server mods: ModList before Welcome, consent prompt (INSTALL & JOIN), download with SHA-256 check
  into MSCO\Mods\Servers, active only on that server; game start waits for the download
- Mods page in the MSCO menu, console command "mods"
- Server browser: MODS pill, VANILLA/MODDED filters, mod list in the server profile
- PlayMaker ApplicationQuit is ignored in the main menu (ESC used to close the game)
- MODDING.md and examples/HelloMod

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 01:00:16 +02:00
co-authored by Claude Opus 5.5
parent 027296d27c
commit 4c1366b5d9
16 changed files with 815 additions and 15 deletions
+94 -2
View File
@@ -10,7 +10,7 @@ namespace MSCO
/// </summary>
public class LobbyUI
{
enum Page { P2P, ServerBrowser, Favorites }
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;
@@ -54,7 +54,7 @@ namespace MSCO
bool browserLoaded;
string selectedServer; // "adresse:port" des geöffneten Server-Profils
string searchInput = "";
bool showSteam = true, showNonSteam = true, hideFull;
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;
@@ -106,6 +106,7 @@ namespace MSCO
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);
@@ -122,6 +123,7 @@ namespace MSCO
// 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));
@@ -143,6 +145,7 @@ namespace MSCO
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;
@@ -200,6 +203,9 @@ namespace MSCO
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)
@@ -287,6 +293,10 @@ namespace MSCO
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));
@@ -295,6 +305,7 @@ namespace MSCO
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;
@@ -334,6 +345,7 @@ namespace MSCO
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)
@@ -649,6 +661,7 @@ namespace MSCO
InfoLine("ONLINE FOR", s.UptimeSeconds >= 0 ? FormatUptime(s.UptimeSeconds) : "-", rowNameStyle);
InfoLine("PING", s.Ping < 0 ? "-" : s.Ping + " ms", PingStyle(s.Ping));
InfoLine("ADDRESS", s.Address + ":" + s.Port, rowNameStyle);
if (s.Mods.Count > 0) InfoLine("MODS", string.Join(", ", s.Mods.ToArray()), textStyle);
GUILayout.EndVertical();
GUILayout.Space(S(16));
@@ -819,6 +832,85 @@ namespace MSCO
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>