diff --git a/Bootstrap.cs b/Bootstrap.cs
index b7d77ca..a6e49fb 100644
--- a/Bootstrap.cs
+++ b/Bootstrap.cs
@@ -64,6 +64,7 @@ namespace MSCO
try
{
string name = new AssemblyName(args.Name).Name;
+ if (name == typeof(Bootstrap).Assembly.GetName().Name) return typeof(Bootstrap).Assembly; // Mods verweisen auf MSCO
string file = Path.Combine(folder, name + ".dll");
if (File.Exists(file)) return Assembly.LoadFrom(file);
}
@@ -141,6 +142,23 @@ namespace MSCO
return;
}
MultiplayerManager.Ensure();
+ SceneChanged(scene);
+ }
+
+ /// Mods: beim ersten Hauptmenü laden, dann Menü/Spiel melden.
+ static void SceneChanged(string scene)
+ {
+ if (scene == "MainMenu")
+ {
+ ModLoader.LoadLocal();
+ ModLoader.MenuLoad();
+ }
+ else if (scene == "GAME") ModLoader.GameLoad();
+ }
+
+ void FixedUpdate()
+ {
+ ModLoader.FixedUpdate();
}
bool skipping, loadingScreen;
@@ -150,6 +168,7 @@ namespace MSCO
void Update()
{
+ ModLoader.Update();
if (!skipping || Time.frameCount < skipFrame + 2) return;
skipping = false;
loadingScreen = true;
@@ -158,7 +177,11 @@ namespace MSCO
void OnGUI()
{
- if (!skipping && !loadingScreen) return;
+ if (!skipping && !loadingScreen)
+ {
+ ModLoader.OnGUI();
+ return;
+ }
GUI.depth = -10000;
if (black == null)
{
@@ -183,6 +206,7 @@ namespace MSCO
MenuBranding.ApplyWindow();
if (Application.loadedLevelName == "MainMenu") StartCoroutine(BrandMenu());
MultiplayerManager.Ensure();
+ SceneChanged(Application.loadedLevelName);
}
/// Logo im Hauptmenü ersetzen - ein paar Mal, falls das Menü es erst nach dem Laden zeigt.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb257ba..834b130 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## 2.10.0
+
+**Mod-Support**
+- Eigene MSCO-Mods: DLLs in `\MSCO\Mods` mit einer Klasse, die von `MscoMod` erbt (OnLoad, OnMenuLoad,
+ OnGameLoad, Update, OnGUI, Spieler kommen/gehen, Sitzung, eigene Netzwerk-Nachrichten zwischen den Spielern).
+ Anleitung und Beispiel: `MODDING.md`, `examples/HelloMod`
+- **Server-Mods**: Server mit Mods schicken beim Beitreten die Liste; fehlende Mods lädt das Spiel nach Zustimmung
+ („INSTALL & JOIN“) direkt vom Server (SHA-256 geprüft). Sie sind nur auf diesem Server aktiv
+- Neue Seite **Mods** im MSCO-Menü, Konsole `mods`
+- Server-Browser: Pille **MODS n**, Filter **VANILLA** / **MODDED**, Mod-Liste im Server-Profil
+- **ESC im Hauptmenü** beendet das Spiel nicht mehr (zum Beenden: QUIT)
+- Netzwerk-Version bleibt 2.8 – Server und Mitspieler mit 2.8.x/2.9.x funktionieren weiter
+
## 2.9.1
**Fehler ohne MSCLoader behoben**
diff --git a/Commands.cs b/Commands.cs
index ea4d835..752ef4a 100644
--- a/Commands.cs
+++ b/Commands.cs
@@ -19,6 +19,7 @@ namespace MSCO
"host [port] / join [port] - direct LAN game (no Steam)\n" +
"save - save the game (host / singleplayer)\n" +
"bind [action key] - show / change keys (passenger, console, chat)\n" +
+ "mods - loaded MSCO mods (folder MSCO\\Mods)\n" +
"ping [on|off] - show your ping to the server (toggle)\n" +
"fps [on|off] - show the frames per second (toggle)\n" +
"/admin - admin menu (kick / ban / teleport / cheats)\n" +
@@ -117,6 +118,16 @@ namespace MSCO
else mp.ShowPing = !mp.ShowPing;
print("Ping: " + mp.PingText + " (display " + (mp.ShowPing ? "on" : "off") + ")");
break;
+ case "mods":
+ if (ModLoader.All.Count == 0) { print("No mods. Put MSCO mods (.dll) into " + ModLoader.Folder + " and restart the game."); break; }
+ foreach (ModLoader.LoadedMod m in ModLoader.All)
+ {
+ string name = "?", version = "";
+ try { name = m.Mod.Name; version = m.Mod.Version; } catch { }
+ print(" " + name + " " + version + (m.FromServer ? " [server]" : "") +
+ (m.Error != null ? " turned off: " + m.Error : m.Active ? " active" : " inactive"));
+ }
+ break;
case "bind":
{
// Tasten ändern: "bind" zeigt alle, "bind passenger O" setzt eine (früher MSCLoader-Einstellungen)
diff --git a/LobbyUI.cs b/LobbyUI.cs
index a8fd997..1fcd966 100644
--- a/LobbyUI.cs
+++ b/LobbyUI.cs
@@ -10,7 +10,7 @@ namespace MSCO
///
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 nicknameEdits = new Dictionary(); // 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 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;
+
+ /// Server verlangt Mods, die hier fehlen: zeigen, was geladen würde, und fragen.
+ 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 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();
+ }
+
+ /// Seite "Mods": geladene MSCO-Mods, Ordner öffnen.
+ 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;
/// Host: eigenen Spielstand weiterspielen oder ein neues Spiel anlegen (mit Sicherheitsabfrage).
diff --git a/MODDING.md b/MODDING.md
new file mode 100644
index 0000000..7fe0d86
--- /dev/null
+++ b/MODDING.md
@@ -0,0 +1,85 @@
+# MSCO-Mods schreiben
+
+MSCO läuft ohne MSCLoader und hat eine eigene, kleine Mod-Schnittstelle. Eine MSCO-Mod ist eine .NET-3.5-DLL mit einer
+Klasse, die von `MSCO.MscoMod` erbt. MSCLoader-Mods laufen damit **nicht** (sie brauchen MSCLoader).
+
+## Wo liegen Mods?
+
+| Ordner | |
+|---|---|
+| `\MSCO\Mods\*.dll` | eigene Mods – immer aktiv, werden beim Spielstart geladen |
+| `\MSCO\Mods\*.dll.off` | ausgeschaltet (Launcher → *Mods*) |
+| `\MSCO\Mods\Servers\*.dll` | Mods von Servern – nur aktiv, solange man auf einem Server ist, der sie verlangt |
+| Server: `data/mods/*.dll` | Mods, die der dedizierte Server an seine Spieler verteilt (Web-Panel → *Mods*) |
+
+Im Spiel: Seite **Mods** im MSCO-Menü, Konsole (F7) `mods`. Im Launcher: Seite **Mods** (hinzufügen, aus-/einschalten,
+entfernen).
+
+## Server-Mods
+
+Liegen Mods in `data/mods` des Servers, bekommt jeder Spieler beim Beitreten die Liste (Name, Version, Größe, SHA-256).
+Fehlt ihm eine Mod, fragt das Spiel: **INSTALL & JOIN** lädt sie direkt vom Server (Prüfsumme wird kontrolliert),
+**CANCEL** verlässt den Server. Danach geht es ins Spiel. Hat der Spieler dieselbe Datei schon (gleiche Prüfsumme), wird
+nichts geladen.
+
+Server mit Mods bekommen in der Serverliste automatisch den Tag **MODDED**; im Server-Browser (Spiel, Launcher, Website)
+steht, welche Mods sie nutzen, und es gibt Filter *VANILLA* / *MODDED*.
+
+> Mods sind Programme und laufen mit vollen Rechten auf dem PC der Spieler. Serverbetreiber: nur Mods anbieten, denen ihr
+> vertraut. Spieler: nur auf Servern zustimmen, denen ihr vertraut.
+
+## Eine Mod bauen
+
+Vorlage: [`examples/HelloMod`](examples/HelloMod) – Projekt (.NET Framework 3.5) mit Verweisen auf `UnityEngine.dll`
+(aus `mysummercar_Data\Managed`) und `MSCO.dll` (aus `\MSCO`, *Copy Local* aus).
+
+```
+msbuild examples/HelloMod/HelloMod.csproj -p:Configuration=Release -p:GamePath="C:\...\My Summer Car"
+```
+
+```csharp
+using MSCO;
+using UnityEngine;
+
+public class HelloMod : MscoMod
+{
+ public override string ID { get { return "hellomod"; } } // eindeutig: Buchstaben, Ziffern, . - _
+ public override string Name { get { return "Hello Mod"; } }
+ public override string Version { get { return "1.0.0"; } }
+ public override string Author { get { return "Ich"; } }
+
+ public override void OnGameLoad() { Log("Im Spiel!"); }
+
+ public override void Update()
+ {
+ if (Input.GetKeyDown(KeyCode.H)) Send(System.Text.Encoding.UTF8.GetBytes("wave"));
+ }
+
+ public override void OnMessage(int fromPlayerId, byte[] data)
+ {
+ string name;
+ Log((Players.TryGetValue(fromPlayerId, out name) ? name : "?") + " winkt");
+ }
+}
+```
+
+Name und Version, die Launcher und Server-Panel anzeigen, kommen aus den Assembly-Attributen
+(`AssemblyProduct`, `AssemblyInformationalVersion`).
+
+## Schnittstelle
+
+| Mitglied | |
+|---|---|
+| `ID`, `Name`, `Version`, `Author` | Angaben zur Mod (`ID` und `Name` müssen überschrieben werden) |
+| `OnLoad()` | einmal nach dem Laden |
+| `OnMenuLoad()`, `OnGameLoad()` | Hauptmenü bzw. Spiel (Szene `GAME`) geladen |
+| `Update()`, `FixedUpdate()`, `OnGUI()` | jeden Frame (wie bei Unity) |
+| `OnSessionStart()`, `OnSessionEnd()` | mit Server/Host verbunden bzw. getrennt |
+| `OnPlayerJoined(id, name)`, `OnPlayerLeft(id, name)` | Mitspieler kommt/geht |
+| `OnMessage(fromPlayerId, data)` | Daten, die dieselbe Mod (gleiche `ID`) bei einem anderen Spieler mit `Send` geschickt hat |
+| `Send(byte[] data)` | an dieselbe Mod bei allen anderen Spielern (bis 60 KB; P2P und dedizierter Server) |
+| `IsInSession`, `IsHost`, `LocalPlayerId`, `Players` | Sitzung, eigene ID, Spieler (ID → Name) |
+| `Log(text)`, `LogWarning(text)` | in die MSCO-Konsole (F7) und `output_log.txt` |
+
+Fehler in einer Mod fängt MSCO ab und schreibt sie ins Log. Wirft eine Mod ständig Fehler (50×), wird sie abgeschaltet.
+Harmony 1.2 (`0Harmony.dll`) liegt neben `MSCO.dll` und kann von Mods mitbenutzt werden.
diff --git a/MSCO.cs b/MSCO.cs
index 0a853d9..14384ea 100644
--- a/MSCO.cs
+++ b/MSCO.cs
@@ -11,7 +11,7 @@ namespace MSCO
/// Version der Mod. Kleine Updates: nur die dritte Stelle erhöhen (2.9.1, 2.9.2 ...) - sie bleiben mit
/// Servern und Mitspielern derselben ersten zwei Stellen kompatibel.
///
- public const string ModVersion = "2.9.1";
+ public const string ModVersion = "2.10.0";
///
/// Netzwerk-Version (die ersten beiden Stellen). Nur erhöhen, wenn sich das Protokoll inkompatibel ändert - dann
/// auch GameServer.ModVersion im Server.
diff --git a/MSCO.csproj b/MSCO.csproj
index a517911..407be74 100644
--- a/MSCO.csproj
+++ b/MSCO.csproj
@@ -94,6 +94,8 @@
+
+
diff --git a/ModApi.cs b/ModApi.cs
new file mode 100644
index 0000000..60ce816
--- /dev/null
+++ b/ModApi.cs
@@ -0,0 +1,69 @@
+using System.Collections.Generic;
+
+namespace MSCO
+{
+ ///
+ /// Basis für MSCO-Mods. Eine Mod ist eine .NET-3.5-DLL in <Spiel>\MSCO\Mods mit (mindestens) einer Klasse, die
+ /// von MscoMod erbt. MSCO lädt sie beim Spielstart; Mods eines Servers bekommen Spieler beim Beitreten (nach
+ /// Zustimmung) und sie sind nur auf diesem Server aktiv. Anleitung: MODDING.md im MSCO-Repository.
+ ///
+ public abstract class MscoMod
+ {
+ /// Eindeutige ID (Buchstaben, Ziffern, . - _). Mods mit derselben ID reden über Send/OnMessage miteinander.
+ public abstract string ID { get; }
+ public abstract string Name { get; }
+ public virtual string Version { get { return "1.0.0"; } }
+ public virtual string Author { get { return ""; } }
+
+ // ---------------------------------------------------------------- Lebenszyklus (überschreiben nach Bedarf)
+
+ /// Einmal nach dem Laden der Mod (Hauptmenü oder - bei Server-Mods - beim Beitreten).
+ public virtual void OnLoad() { }
+ /// Hauptmenü wurde geladen.
+ public virtual void OnMenuLoad() { }
+ /// Das Spiel (Szene GAME) wurde geladen.
+ public virtual void OnGameLoad() { }
+ public virtual void Update() { }
+ public virtual void FixedUpdate() { }
+ public virtual void OnGUI() { }
+
+ /// Mit einem Server/Host verbunden (bzw. selbst P2P-Host geworden).
+ public virtual void OnSessionStart() { }
+ /// Sitzung beendet (getrennt, gekickt, Host weg ...).
+ public virtual void OnSessionEnd() { }
+ public virtual void OnPlayerJoined(int playerId, string name) { }
+ public virtual void OnPlayerLeft(int playerId, string name) { }
+ /// Daten, die dieselbe Mod bei einem anderen Spieler mit Send() geschickt hat.
+ public virtual void OnMessage(int fromPlayerId, byte[] data) { }
+
+ // ---------------------------------------------------------------- Helfer
+
+ /// Daten an dieselbe Mod bei allen anderen Spielern schicken (max. 60 KB, nur in einer Sitzung).
+ protected bool Send(byte[] data)
+ {
+ MultiplayerManager mp = MultiplayerManager.Instance;
+ return mp != null && mp.SendModMessage(ID, data);
+ }
+
+ protected void Log(string text) { MscoLog.Info("[" + Name + "] " + text); }
+ protected void LogWarning(string text) { MscoLog.Warning("[" + Name + "] " + text); }
+
+ /// In einer Sitzung (Server oder P2P)?
+ protected bool IsInSession { get { return MultiplayerManager.Instance != null && MultiplayerManager.Instance.IsInSession; } }
+ /// Selbst P2P-Host?
+ protected bool IsHost { get { return MultiplayerManager.Instance != null && MultiplayerManager.Instance.IsHost; } }
+ /// Eigene Spieler-ID in der Sitzung.
+ protected int LocalPlayerId { get { return MultiplayerManager.Instance != null ? MultiplayerManager.Instance.LocalId : -1; } }
+ /// Spieler der Sitzung: ID -> Name.
+ protected Dictionary Players
+ {
+ get
+ {
+ var result = new Dictionary();
+ MultiplayerManager mp = MultiplayerManager.Instance;
+ if (mp != null) foreach (PlayerInfo p in mp.Players) result[p.Id] = p.Name;
+ return result;
+ }
+ }
+ }
+}
diff --git a/ModLoader.cs b/ModLoader.cs
new file mode 100644
index 0000000..51c0af5
--- /dev/null
+++ b/ModLoader.cs
@@ -0,0 +1,167 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using System.Security.Cryptography;
+
+namespace MSCO
+{
+ ///
+ /// Lädt MSCO-Mods und ruft sie auf. Eigene Mods: <Spiel>\MSCO\Mods\*.dll (immer aktiv). Mods eines Servers:
+ /// <Spiel>\MSCO\Mods\Servers\<id>.dll - nur aktiv, solange man auf einem Server ist, der sie verlangt.
+ /// Fehler einer Mod werden abgefangen und protokolliert; wirft sie ständig Fehler, wird sie abgeschaltet.
+ ///
+ public static class ModLoader
+ {
+ public sealed class LoadedMod
+ {
+ public MscoMod Mod;
+ public string File, Sha256;
+ public bool FromServer, Active;
+ public int Errors;
+ public string Error;
+ }
+
+ const int MaxErrors = 50;
+ static readonly List mods = new List();
+ static bool localLoaded;
+
+ public static IList All { get { return mods.AsReadOnly(); } }
+ public static string Folder { get { return Path.Combine(Bootstrap.Folder ?? ".", "Mods"); } }
+ public static string ServerFolder { get { return Path.Combine(Folder, "Servers"); } }
+ public static string ServerFile(string id) { return Path.Combine(ServerFolder, id + ".dll"); }
+
+ /// Eigene Mods einmal beim ersten Hauptmenü laden.
+ public static void LoadLocal()
+ {
+ if (localLoaded) return;
+ localLoaded = true;
+ try
+ {
+ Directory.CreateDirectory(Folder);
+ foreach (string file in Directory.GetFiles(Folder, "*.dll"))
+ Load(file, false);
+ if (mods.Count > 0) MscoLog.Info("[MSCO] " + mods.Count + " mod(s) loaded from " + Folder);
+ }
+ catch (Exception e) { MscoLog.Warning("[MSCO] Could not load mods: " + e.Message); }
+ }
+
+ public static string Sha256Of(byte[] data)
+ {
+ using (var sha = SHA256.Create())
+ return BitConverter.ToString(sha.ComputeHash(data)).Replace("-", "").ToLowerInvariant();
+ }
+
+ public static LoadedMod FindBySha(string sha)
+ {
+ foreach (LoadedMod m in mods) if (string.Equals(m.Sha256, sha, StringComparison.OrdinalIgnoreCase)) return m;
+ return null;
+ }
+
+ ///
+ /// DLL laden (aus den Bytes - die Datei bleibt frei, und eine neue Version einer Server-Mod kann neben der alten
+ /// geladen werden) und alle MscoMod-Klassen darin anlegen. Gibt die erste Mod zurück (oder null).
+ ///
+ public static LoadedMod Load(string file, bool fromServer)
+ {
+ LoadedMod first = null;
+ try
+ {
+ byte[] data = System.IO.File.ReadAllBytes(file);
+ string sha = Sha256Of(data);
+ LoadedMod existing = FindBySha(sha);
+ if (existing != null) return existing;
+
+ Assembly asm = Assembly.Load(data);
+ Type[] types;
+ try { types = asm.GetTypes(); }
+ catch (ReflectionTypeLoadException e) { types = Array.FindAll(e.Types, t => t != null); }
+ foreach (Type t in types)
+ {
+ if (t.IsAbstract || !typeof(MscoMod).IsAssignableFrom(t)) continue;
+ MscoMod mod;
+ try { mod = (MscoMod)Activator.CreateInstance(t); }
+ catch (Exception e)
+ {
+ MscoLog.Warning("[MSCO] Mod " + t.FullName + " could not be created: " + (e.InnerException ?? e).Message);
+ continue;
+ }
+ var loaded = new LoadedMod { Mod = mod, File = file, Sha256 = sha, FromServer = fromServer, Active = !fromServer };
+ mods.Add(loaded);
+ if (first == null) first = loaded;
+ MscoLog.Info("[MSCO] Mod loaded: " + Safe(() => mod.Name, t.Name) + " " + Safe(() => mod.Version, "") +
+ (fromServer ? " (from the server)" : ""));
+ Call(loaded, m => m.OnLoad());
+ }
+ if (first == null) MscoLog.Warning("[MSCO] " + Path.GetFileName(file) + " contains no MSCO mod (class deriving from MscoMod)");
+ }
+ catch (Exception e)
+ {
+ MscoLog.Warning("[MSCO] Could not load mod " + Path.GetFileName(file) + ": " + e.Message);
+ }
+ return first;
+ }
+
+ static string Safe(Func get, string fallback)
+ {
+ try { return get() ?? fallback; } catch { return fallback; }
+ }
+
+ /// Server-Mods für die aktuelle Sitzung einschalten (alle anderen Server-Mods aus).
+ public static void ActivateServerMods(ICollection shas)
+ {
+ foreach (LoadedMod m in mods)
+ if (m.FromServer && m.Error == null) m.Active = shas != null && shas.Contains(m.Sha256);
+ }
+
+ // ---------------------------------------------------------------- Aufrufe
+
+ static void Call(LoadedMod m, Action action)
+ {
+ try { action(m.Mod); }
+ catch (Exception e)
+ {
+ m.Errors++;
+ if (m.Errors <= 3) MscoLog.Warning("[MSCO] Mod " + Safe(() => m.Mod.Name, "?") + ": " + e);
+ if (m.Errors >= MaxErrors)
+ {
+ m.Active = false;
+ m.Error = e.Message;
+ MscoLog.Warning("[MSCO] Mod " + Safe(() => m.Mod.Name, "?") + " was turned off (too many errors)");
+ }
+ }
+ }
+
+ static void Each(Action action)
+ {
+ for (int i = 0; i < mods.Count; i++)
+ if (mods[i].Active) Call(mods[i], action);
+ }
+
+ public static void MenuLoad() { Each(m => m.OnMenuLoad()); }
+ public static void GameLoad() { Each(m => m.OnGameLoad()); }
+ public static void Update() { if (mods.Count > 0) Each(m => m.Update()); }
+ public static void FixedUpdate() { if (mods.Count > 0) Each(m => m.FixedUpdate()); }
+ public static void OnGUI() { if (mods.Count > 0) Each(m => m.OnGUI()); }
+ public static void SessionStart() { Each(m => m.OnSessionStart()); }
+ public static void PlayerJoined(int id, string name) { Each(m => m.OnPlayerJoined(id, name)); }
+ public static void PlayerLeft(int id, string name) { Each(m => m.OnPlayerLeft(id, name)); }
+
+ /// Sitzung vorbei: Mods informieren, Server-Mods ausschalten.
+ public static void SessionEnd()
+ {
+ Each(m => m.OnSessionEnd());
+ ActivateServerMods(null);
+ }
+
+ public static void Message(string modId, int from, byte[] data)
+ {
+ for (int i = 0; i < mods.Count; i++)
+ {
+ LoadedMod m = mods[i];
+ if (!m.Active || Safe(() => m.Mod.ID, null) != modId) continue;
+ Call(m, mod => mod.OnMessage(from, data));
+ }
+ }
+ }
+}
diff --git a/MultiplayerManager.cs b/MultiplayerManager.cs
index babdc3a..d85a4d4 100644
--- a/MultiplayerManager.cs
+++ b/MultiplayerManager.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.IO;
using HutongGames.PlayMaker;
using UnityEngine;
@@ -192,6 +192,7 @@ namespace MSCO
objects.LocalId = localId;
AddPlayer(localId, playerName, true);
SetStatus("Hosting on port " + port + " - waiting for players...");
+ ModLoader.SessionStart();
}
catch (System.Exception e)
{
@@ -883,6 +884,7 @@ namespace MSCO
objects.LocalId = localId;
AddPlayer(localId, OwnName(), true);
SetStatus("Steam lobby ready - invite your friends!");
+ ModLoader.SessionStart();
}
void OnSteamLobbyEntered(Steamworks.CSteamID lobby, Steamworks.CSteamID owner)
@@ -1339,6 +1341,10 @@ namespace MSCO
connectingToServer = false;
readySent = false;
pendingSnapshot = null;
+ PendingMods = null;
+ downloadingMods.Clear();
+ deferredStart = null;
+ ModLoader.SessionEnd();
SteamLobby.CancelAuthTicket();
passenger.Exit(localPlayer, playerCamera); // Beifahrer: neben das Auto stellen
foreach (PlayerInfo p in players.Values) DestroyAvatar(p);
@@ -1379,7 +1385,7 @@ namespace MSCO
|| t == MsgType.Chat || t == MsgType.VarSync || t == MsgType.SpawnItem
|| t == MsgType.ItemAppeared || t == MsgType.ObjDestroy || t == MsgType.VehicleDamage
|| t == MsgType.TrafficStates || t == MsgType.WorldVars || t == MsgType.HingeStates
- || t == MsgType.VehicleControls;
+ || t == MsgType.VehicleControls || t == MsgType.ModMessage;
}
void OnMessage(int peerId, Message m)
@@ -1499,9 +1505,39 @@ namespace MSCO
reconnectMessage = null; // (wieder) verbunden
reconnectAttempts = 0;
SetStatus(dedicated ? "Connected to server - loading..." : "Connected - waiting for host to start...");
+ if (PendingMods != null) SetStatus("This server uses mods - confirm to download them");
+ ModLoader.SessionStart();
break;
}
+ case MsgType.ModList:
+ {
+ var list = new List();
+ int n = r.ReadInt32();
+ for (int i = 0; i < n && i < 64; i++)
+ list.Add(new ServerModInfo { Id = r.ReadString(), Name = r.ReadString(), Version = r.ReadString(), Sha256 = r.ReadString(), Size = r.ReadInt32() });
+ ResolveServerMods(list);
+ break;
+ }
+
+ case MsgType.ModFilePart:
+ {
+ string id = r.ReadString();
+ int total = r.ReadInt32(), offset = r.ReadInt32(), len = r.ReadInt32();
+ byte[] part = r.ReadBytes(len);
+ ServerModInfo info = downloadingMods.Find(x => x.Id == id);
+ if (info == null || total != info.Size || total > MaxModBytes || offset < 0 || offset + part.Length > total) break;
+ if (info.Buffer == null) info.Buffer = new byte[total];
+ System.Buffer.BlockCopy(part, 0, info.Buffer, offset, part.Length);
+ info.Received += part.Length;
+ SetStatus("Downloading mods... " + ModDownloadPercent() + "%");
+ break;
+ }
+
+ case MsgType.ModFileDone:
+ FinishModDownload(r.ReadString());
+ break;
+
case MsgType.Reject:
{
string why = r.ReadString();
@@ -1630,28 +1666,50 @@ namespace MSCO
}
case MsgType.StartGame:
+ {
if (!receivingSave) return;
receivingSave = false;
// Später beigetreten: nach dem Laden neben den Host setzen
teleportToHost = r.BaseStream.Length > 0 && r.ReadBoolean();
// Server mit "neues Spiel" (Panel): kein Spielstand - hier ein neues Spiel starten (Server ab 2.9.3)
bool serverNewGame = r.BaseStream.Length - r.BaseStream.Position >= 1 && r.ReadBoolean();
- if (serverNewGame)
+ // Fehlen noch Mods des Servers: erst nach Zustimmung + Download ins Spiel
+ if (PendingMods != null || ModsDownloading)
{
- SetStatus("The server starts a new game - creating a fresh world...");
- MscoLog.Info("[MSCO] Server has a new game - starting a fresh world");
- MenuHelper.StartNewGame();
+ deferredStart = serverNewGame;
break;
}
- SetStatus(dedicated ? "Loading server world..." : "Loading host's game...");
- MenuHelper.StartGame();
+ BeginServerGame(serverNewGame);
break;
+ }
}
}
+ /// Spielstand ist da: ins Spiel (bzw. bei einem neuen Server-Spiel ein neues Spiel anlegen).
+ void BeginServerGame(bool serverNewGame)
+ {
+ if (serverNewGame)
+ {
+ SetStatus("The server starts a new game - creating a fresh world...");
+ MscoLog.Info("[MSCO] Server has a new game - starting a fresh world");
+ MenuHelper.StartNewGame();
+ return;
+ }
+ SetStatus(dedicated ? "Loading server world..." : "Loading host's game...");
+ MenuHelper.StartGame();
+ }
+
void HandleGameMessage(int from, MsgType type, BinaryReader r)
{
if (from == localId) return;
+ if (type == MsgType.ModMessage)
+ {
+ string modId = r.ReadString();
+ int len = r.ReadInt32();
+ if (len < 0 || len > MaxModMessageBytes) return;
+ ModLoader.Message(modId, from, r.ReadBytes(len));
+ return;
+ }
if (type == MsgType.Chat)
{
string text = r.ReadString();
@@ -1724,6 +1782,117 @@ namespace MSCO
}
/// Spiel-Nachricht an alle anderen (Client: über den Host).
+ // ---------------------------------------------------------------- MSCO-Mods
+
+ public sealed class ServerModInfo
+ {
+ public string Id, Name, Version, Sha256;
+ public int Size, Received;
+ public byte[] Buffer;
+ }
+
+ const int MaxModBytes = 20 * 1024 * 1024, MaxModMessageBytes = 60000;
+ /// Mods, die der Server verlangt und die hier fehlen - warten auf Zustimmung des Spielers (null = keine).
+ public List PendingMods { get; private set; }
+ readonly List downloadingMods = new List();
+ readonly List serverModShas = new List();
+ bool? deferredStart;
+ public bool ModsDownloading { get { return downloadingMods.Count > 0; } }
+
+ int ModDownloadPercent()
+ {
+ long total = 0, done = 0;
+ foreach (ServerModInfo m in downloadingMods) { total += m.Size; done += m.Received; }
+ return total > 0 ? (int)(done * 100 / total) : 100;
+ }
+
+ /// Liste vom Server: vorhandene Mods (gleiche Prüfsumme) einschalten, fehlende zum Bestätigen merken.
+ void ResolveServerMods(List list)
+ {
+ serverModShas.Clear();
+ var missing = new List();
+ foreach (ServerModInfo m in list)
+ {
+ if (string.IsNullOrEmpty(m.Id) || m.Id.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || m.Id.Contains("..")) continue;
+ serverModShas.Add(m.Sha256);
+ if (ModLoader.FindBySha(m.Sha256) != null) continue;
+ // Schon einmal geladen (gleiche Datei auf der Platte)?
+ string file = ModLoader.ServerFile(m.Id);
+ if (File.Exists(file) && ModLoader.Sha256Of(File.ReadAllBytes(file)) == m.Sha256 && ModLoader.Load(file, true) != null) continue;
+ missing.Add(m);
+ }
+ ModLoader.ActivateServerMods(serverModShas);
+ PendingMods = missing.Count > 0 ? missing : null;
+ if (PendingMods != null) MscoLog.Info("[MSCO] The server uses " + list.Count + " mod(s), " + missing.Count + " missing");
+ }
+
+ /// Spieler stimmt zu: fehlende Mods vom Server laden.
+ public void AcceptServerMods()
+ {
+ if (PendingMods == null || session == null) return;
+ downloadingMods.Clear();
+ downloadingMods.AddRange(PendingMods);
+ PendingMods = null;
+ foreach (ServerModInfo m in downloadingMods)
+ {
+ string id = m.Id;
+ session.SendTo(NetSession.HostId, MsgType.ModFileRequest, NetSession.Build(w => w.Write(id)));
+ }
+ SetStatus("Downloading mods... 0%");
+ }
+
+ /// Spieler lehnt ab: ohne die Mods geht es auf diesem Server nicht.
+ public void DeclineServerMods()
+ {
+ PendingMods = null;
+ serverReason = "This server needs its mods - you declined to install them.";
+ reconnectMessage = null;
+ if (session != null) session.Close("Declined the server's mods");
+ OnSessionEnded("Declined the server's mods");
+ }
+
+ void FinishModDownload(string id)
+ {
+ ServerModInfo m = downloadingMods.Find(x => x.Id == id);
+ if (m == null) return;
+ downloadingMods.Remove(m);
+ if (m.Buffer == null || m.Received < m.Size || ModLoader.Sha256Of(m.Buffer) != m.Sha256)
+ {
+ MscoLog.Warning("[MSCO] Mod " + m.Name + " arrived damaged (checksum mismatch) - not loaded");
+ serverReason = "The mod " + m.Name + " could not be downloaded correctly - try again.";
+ if (session != null) session.Close("Mod download failed");
+ OnSessionEnded("Mod download failed");
+ return;
+ }
+ try
+ {
+ Directory.CreateDirectory(ModLoader.ServerFolder);
+ File.WriteAllBytes(ModLoader.ServerFile(m.Id), m.Buffer);
+ }
+ catch (System.Exception e) { MscoLog.Warning("[MSCO] Could not save mod " + m.Name + ": " + e.Message); }
+ if (ModLoader.Load(ModLoader.ServerFile(m.Id), true) == null)
+ MscoLog.Warning("[MSCO] Mod " + m.Name + " could not be started - see the log");
+ ModLoader.ActivateServerMods(serverModShas);
+ if (MenuHelper.InMenu) ModLoader.MenuLoad();
+ if (downloadingMods.Count > 0) return;
+
+ SetStatus("Mods installed - loading the world...");
+ if (deferredStart != null)
+ {
+ bool newGame = deferredStart.Value;
+ deferredStart = null;
+ BeginServerGame(newGame);
+ }
+ }
+
+ /// Für MscoMod.Send: Daten einer Mod an dieselbe Mod bei allen anderen Spielern.
+ public bool SendModMessage(string modId, byte[] data)
+ {
+ if (session == null || !handshakeDone || string.IsNullOrEmpty(modId) || data == null || data.Length > MaxModMessageBytes) return false;
+ SendGame(MsgType.ModMessage, NetSession.Build(w => { w.Write(modId); w.Write(data.Length); w.Write(data); }));
+ return true;
+ }
+
void SendGame(MsgType type, byte[] payload)
{
if (session == null) return;
@@ -1736,9 +1905,11 @@ namespace MSCO
PlayerInfo AddPlayer(int id, string name, bool isLocal)
{
PlayerInfo p;
- if (!players.TryGetValue(id, out p)) players[id] = p = new PlayerInfo { Id = id };
+ bool isNew = !players.TryGetValue(id, out p);
+ if (isNew) players[id] = p = new PlayerInfo { Id = id };
p.Name = name;
p.IsLocal = isLocal;
+ if (isNew && !isLocal) ModLoader.PlayerJoined(id, name);
return p;
}
@@ -1746,6 +1917,7 @@ namespace MSCO
{
PlayerInfo p;
if (!players.TryGetValue(id, out p)) return;
+ if (!p.IsLocal) ModLoader.PlayerLeft(id, p.Name);
DestroyAvatar(p);
objects.ReleaseAllOf(id);
players.Remove(id);
diff --git a/NetSession.cs b/NetSession.cs
index a81598b..891b6b1 100644
--- a/NetSession.cs
+++ b/NetSession.cs
@@ -52,7 +52,12 @@ namespace MSCO
WorldVars = 41, // Jobs/Laden: int n, je [int Schlüssel, byte Typ (2 int, 3 bool), Wert]
HingeStates = 42, // Autotüren/Haube/Kofferraum: int n, je [int Schlüssel, float Scharnier-Winkel]
VehicleControls = 43, // Zündung/Motor/Licht ...: int n, je [int FSM-Schlüssel, string Zustand]
- PlayerNeeds = 44 // Client -> Server: eigene Bedürfnisse (Hunger, Durst ...): int n, je [string Name, float Wert]
+ PlayerNeeds = 44, // Client -> Server: eigene Bedürfnisse (Hunger, Durst ...): int n, je [string Name, float Wert]
+ ModMessage = 45, // Nachricht einer MSCO-Mod an die anderen Spieler: string Mod-ID, int Länge, bytes
+ ModList = 46, // Server -> Client (vor Welcome): int n, je [string ID, string Name, string Version, string SHA-256, int Größe]
+ ModFileRequest = 47, // Client -> Server: string Mod-ID
+ ModFilePart = 48, // Server -> Client: string Mod-ID, int Gesamtgröße, int Offset, int Länge, bytes
+ ModFileDone = 49 // Server -> Client: string Mod-ID
}
public struct Message
diff --git a/README.md b/README.md
index e3d96f0..c0881ce 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,10 @@ einem dedizierten Server aus dem Server-Browser. MSCO läuft **eigenständig, oh
- **P** / **Leertaste** – aussteigen; **P** neben einem Auto – auf den Beifahrersitz
- Tasten ändern mit `bind` in der F7-Konsole (z.B. `bind passenger O`)
- Rich Presence: zeigt Freunden deinen Status („In the Menus“, Server, Spielerzahl)
-- MSCO-Look: Fenstertitel, Symbol und Logo im Hauptmenü; der „Press any key“-Startbildschirm wird übersprungen
+- MSCO-Look: Fenstertitel, Symbol und Logo im Hauptmenü; der „Press any key“-Startbildschirm wird übersprungen;
+ ESC im Hauptmenü beendet das Spiel nicht
+- **Mods**: eigene MSCO-Mods in `\MSCO\Mods`, Server können eigene Mods mitbringen (Download nach
+ Zustimmung) – siehe [MODDING.md](MODDING.md)
## Installation
@@ -111,6 +114,7 @@ In der F7-Konsole:
| `switch `, `fav …` | Zu einem Favoriten wechseln, Favoriten verwalten |
| `model [game\|classic]` | Mitspieler als MSC-Menschen oder als einfache Figur |
| `vars ` | Diagnose: synchronisierte Fahrzeugwerte |
+| `mods` | geladene MSCO-Mods |
| `admin` bzw. `/admin` im Chat | Admin-Menü |
| `vehicles`, `rig` | Diagnose: Fahrzeuge / Spielermodelle |
| `debug` | Sync-Debugausgaben an/aus |
@@ -150,6 +154,7 @@ msbuild MSCO.csproj -p:Configuration=Release
|---|---|
| `Bootstrap.cs`, `MSCO.cs` | Start ohne MSCLoader (Doorstop), Ladebildschirm, Versionen, Tastenbelegung |
| `MenuBranding.cs`, `MiniJson.cs` | Fenstertitel/-symbol und Menü-Logo, eigener JSON-Leser |
+| `ModApi.cs`, `ModLoader.cs` | Mod-Schnittstelle `MscoMod`, Laden und Aufrufen der Mods (siehe MODDING.md) |
| `MultiplayerManager.cs` | Sitzung, Nachrichten, Spielerliste, Spielstart, HUD |
| `NetSession.cs` | Netzwerk (TCP und Steam P2P), Protokoll |
| `LobbyUI.cs`, `Overlays.cs`, `UiTheme.cs` | Launcher, Konsole/Chat/Admin-Menü, gemeinsamer Stil |
diff --git a/SafetyPatches.cs b/SafetyPatches.cs
index 05ce33d..f5e7257 100644
--- a/SafetyPatches.cs
+++ b/SafetyPatches.cs
@@ -42,6 +42,41 @@ namespace MSCO
{
MscoLog.Warning("[MSCO] Could not install hierarchy guard: " + e.Message);
}
+ PatchMenuQuit();
+ }
+
+ ///
+ /// ESC im Hauptmenü beendet beim Spiel sofort das Programm (PlayMaker-Aktion "ApplicationQuit" im Menü). Im
+ /// MSCO-Hauptmenü gibt es dafür den QUIT-Knopf - dort also nicht über die Spiel-Logik beenden. Im Spiel selbst
+ /// (Pausemenü) bleibt alles wie gehabt.
+ ///
+ static void PatchMenuQuit()
+ {
+ try
+ {
+ Type action = null;
+ foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ action = asm.GetType("HutongGames.PlayMaker.Actions.ApplicationQuit");
+ if (action != null) break;
+ }
+ MethodInfo onEnter = action != null ? action.GetMethod("OnEnter", BindingFlags.Instance | BindingFlags.Public) : null;
+ if (onEnter == null) return;
+ HarmonyInstance.Create("mbm.msco.menuquit").Patch(onEnter,
+ new HarmonyMethod(typeof(SafetyPatches).GetMethod("QuitPrefix", BindingFlags.Static | BindingFlags.NonPublic)), null);
+ }
+ catch (Exception e)
+ {
+ MscoLog.Warning("[MSCO] Could not block ESC-quit in the main menu: " + e.Message);
+ }
+ }
+
+ static bool QuitPrefix(FsmStateAction __instance)
+ {
+ if (!MenuHelper.InMenu) return true;
+ MscoLog.Info("[MSCO] The game tried to quit from the main menu (ESC) - use QUIT in the MSCO menu to exit");
+ try { __instance.Finish(); } catch { }
+ return false;
}
static bool SetParentPrefix(FsmStateAction __instance)
diff --git a/ServerBrowser.cs b/ServerBrowser.cs
index d6fbccd..4fd6429 100644
--- a/ServerBrowser.cs
+++ b/ServerBrowser.cs
@@ -24,6 +24,8 @@ namespace MSCO
public string Owner; // Besitzer des Lizenzschlüssels
public bool Featured; // vom Keymaster-Admin hervorgehoben (steht in der Liste oben)
public List Tags = new List(); // Admin-Tags (z.B. "official") und eigene Tags des Servers (Web-Panel)
+ public List Mods = new List(); // Mods des Servers ("Name Version") - bekommt man beim Beitreten
+ public bool Modded { get { return Mods.Count > 0 || Tags.Contains("modded"); } }
public int UptimeSeconds = -1;
public List PlayerNames = new List();
@@ -137,11 +139,28 @@ namespace MSCO
Owner = MiniJson.Str(o, "owner"),
Featured = MiniJson.Bool(o, "featured"),
Tags = MiniJson.Strings(o, "tags"),
+ Mods = ModNames(o),
});
}
return list;
}
+ static List ModNames(Dictionary o)
+ {
+ var names = new List();
+ object v;
+ var list = o.TryGetValue("mods", out v) ? v as List