From 4c1366b5d930fed7c4bce5bbbe1cd145f00752b7 Mon Sep 17 00:00:00 2001 From: MrMaho Date: Sat, 3 Oct 2026 01:00:16 +0200 Subject: [PATCH] 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 --- Bootstrap.cs | 26 +++- CHANGELOG.md | 13 ++ Commands.cs | 11 ++ LobbyUI.cs | 96 ++++++++++++++- MODDING.md | 85 +++++++++++++ MSCO.cs | 2 +- MSCO.csproj | 2 + ModApi.cs | 69 +++++++++++ ModLoader.cs | 167 ++++++++++++++++++++++++++ MultiplayerManager.cs | 190 ++++++++++++++++++++++++++++-- NetSession.cs | 7 +- README.md | 7 +- SafetyPatches.cs | 35 ++++++ ServerBrowser.cs | 19 +++ examples/HelloMod/HelloMod.cs | 62 ++++++++++ examples/HelloMod/HelloMod.csproj | 39 ++++++ 16 files changed, 815 insertions(+), 15 deletions(-) create mode 100644 MODDING.md create mode 100644 ModApi.cs create mode 100644 ModLoader.cs create mode 100644 examples/HelloMod/HelloMod.cs create mode 100644 examples/HelloMod/HelloMod.csproj 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 : null; + if (list == null) return names; + foreach (object item in list) + { + var m = item as Dictionary; + if (m == null) continue; + string name = MiniJson.Str(m, "name", MiniJson.Str(m, "id", "?")), version = MiniJson.Str(m, "version", ""); + names.Add(string.IsNullOrEmpty(version) ? name : name + " " + version); + } + return names; + } + /// Server auf diesem PC suchen (127.0.0.1, übliche Ports); nur antwortende werden gezeigt. void ProbeLocalServers() { diff --git a/examples/HelloMod/HelloMod.cs b/examples/HelloMod/HelloMod.cs new file mode 100644 index 0000000..1adf34c --- /dev/null +++ b/examples/HelloMod/HelloMod.cs @@ -0,0 +1,62 @@ +using System.Reflection; +using System.Text; +using MSCO; +using UnityEngine; + +[assembly: AssemblyTitle("Hello Mod")] +[assembly: AssemblyProduct("Hello Mod")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: AssemblyInformationalVersion("1.0.0")] + +namespace HelloMod +{ + /// + /// Beispiel-Mod: zeigt im Spiel oben links einen Hinweis. Mit H winkst du - alle anderen Spieler, die die Mod auch + /// haben, sehen "<Name> waves!". + /// + public class HelloMod : MscoMod + { + public override string ID { get { return "hellomod"; } } + public override string Name { get { return "Hello Mod"; } } + public override string Version { get { return "1.0.0"; } } + public override string Author { get { return "MBM"; } } + + bool inGame; + string lastWave = ""; + float waveUntil; + + public override void OnLoad() { Log("loaded"); } + public override void OnMenuLoad() { inGame = false; } + public override void OnGameLoad() { inGame = true; } + + public override void Update() + { + if (!inGame || !IsInSession || !Input.GetKeyDown(KeyCode.H)) return; + Send(Encoding.UTF8.GetBytes("wave")); + Show("You wave!"); + } + + public override void OnMessage(int fromPlayerId, byte[] data) + { + if (Encoding.UTF8.GetString(data) != "wave") return; + string name; + Show((Players.TryGetValue(fromPlayerId, out name) ? name : "Someone") + " waves!"); + } + + public override void OnPlayerJoined(int playerId, string name) { Show(name + " joined - press H to wave"); } + + void Show(string text) + { + lastWave = text; + waveUntil = Time.realtimeSinceStartup + 4f; + } + + public override void OnGUI() + { + if (!inGame) return; + GUI.Label(new Rect(20, 60, 400, 24), "Hello Mod: press H to wave"); + if (Time.realtimeSinceStartup < waveUntil) GUI.Label(new Rect(20, 84, 400, 24), lastWave); + } + } +} diff --git a/examples/HelloMod/HelloMod.csproj b/examples/HelloMod/HelloMod.csproj new file mode 100644 index 0000000..327d656 --- /dev/null +++ b/examples/HelloMod/HelloMod.csproj @@ -0,0 +1,39 @@ + + + + + + Release + AnyCPU + Library + HelloMod + HelloMod + v3.5 + Unity Full v3.5 + 13 + bin\$(Configuration)\ + none + true + F:\SteamLibrary\steamapps\common\My Summer Car + + + + + $(GamePath)\mysummercar_Data\Managed\UnityEngine.dll + False + + + + $(GamePath)\MSCO\MSCO.dll + False + + + + + + +