diff --git a/Branding.cs b/Branding.cs
new file mode 100644
index 0000000..544ec62
--- /dev/null
+++ b/Branding.cs
@@ -0,0 +1,50 @@
+using System.IO;
+using System.Reflection;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// MSCO-Logo (branding/logo-128.png, als Ressource in die DLL eingebettet):
+ /// Symbol in der MSCLoader-Modliste und im Launcher.
+ ///
+ public static class Branding
+ {
+ const string LogoResource = "MSCO.logo.png";
+
+ static byte[] logoPng;
+ static Texture2D logoTexture;
+
+ public static byte[] LogoPng
+ {
+ get
+ {
+ if (logoPng != null) return logoPng;
+ using (Stream s = Assembly.GetExecutingAssembly().GetManifestResourceStream(LogoResource))
+ {
+ if (s == null) return logoPng = new byte[0];
+ var data = new byte[s.Length];
+ int read = 0;
+ while (read < data.Length) read += s.Read(data, read, data.Length - read);
+ logoPng = data;
+ }
+ return logoPng;
+ }
+ }
+
+ /// Logo als Textur für OnGUI (nur im Hauptthread verwenden).
+ public static Texture2D LogoTexture
+ {
+ get
+ {
+ if (logoTexture != null) return logoTexture;
+ if (LogoPng.Length == 0) return null;
+ logoTexture = new Texture2D(2, 2, TextureFormat.ARGB32, false);
+ logoTexture.LoadImage(LogoPng);
+ logoTexture.filterMode = FilterMode.Bilinear;
+ logoTexture.hideFlags = HideFlags.HideAndDontSave;
+ return logoTexture;
+ }
+ }
+ }
+}
diff --git a/Commands.cs b/Commands.cs
new file mode 100644
index 0000000..8f41b68
--- /dev/null
+++ b/Commands.cs
@@ -0,0 +1,89 @@
+using System;
+
+namespace MSCO
+{
+ ///
+ /// Alle MSCO-Befehle an einer Stelle - genutzt von der eigenen F8-Konsole (ohne "mp" davor)
+ /// und von der MSCLoader-Konsole ("mp ...").
+ ///
+ public static class Commands
+ {
+ public const string Help =
+ "connect - join a dedicated server\n" +
+ "disconnect - leave the server / game\n" +
+ "host [port] / join [port] - LAN test session\n" +
+ "save - save the game (host / singleplayer)\n" +
+ "/admin - admin menu (kick / ban)\n" +
+ "vehicles | rig - debug info about vehicles / player models\n" +
+ "debug - toggle sync debug output\n" +
+ "master [url] - show / change the server list address\n" +
+ "clear - clear this console";
+
+ public static void Execute(string[] args, Action print)
+ {
+ var mp = MultiplayerManager.Instance;
+ if (mp == null) { print("Not ready yet"); return; }
+ if (args.Length == 0) { print(Help); return; }
+
+ int port = MultiplayerManager.DefaultPort;
+ switch (args[0].ToLower())
+ {
+ case "help":
+ case "?":
+ print(Help);
+ break;
+ case "host":
+ if (args.Length > 1) int.TryParse(args[1], out port);
+ mp.Host(port);
+ break;
+ case "join":
+ if (args.Length < 2) { print("join [port]"); return; }
+ if (args.Length > 2) int.TryParse(args[2], out port);
+ mp.Join(args[1], port);
+ break;
+ case "stop":
+ case "disconnect":
+ case "quit":
+ mp.Disconnect();
+ print("Disconnected.");
+ break;
+ case "connect":
+ {
+ string host;
+ int serverPort;
+ if (args.Length < 2 || !ServerBrowser.TryParseAddress(args[1], out host, out serverPort))
+ {
+ print("connect ");
+ return;
+ }
+ mp.JoinServer(host, serverPort);
+ break;
+ }
+ case "master":
+ if (args.Length > 1) ServerBrowser.MasterUrl = args[1];
+ print("Master server: " + ServerBrowser.MasterUrl);
+ break;
+ case "save":
+ mp.SaveGame();
+ break;
+ case "admin":
+ case "/admin":
+ mp.OpenAdminMenu();
+ break;
+ case "rig":
+ foreach (string line in mp.DescribeRigs()) print(line);
+ break;
+ case "vehicles":
+ foreach (string line in mp.DescribeVehicles()) print(line);
+ break;
+ case "debug":
+ FsmSync.Debug = !FsmSync.Debug;
+ print("Sync debug output " + (FsmSync.Debug ? "ON" : "OFF"));
+ break;
+ default:
+ print("Unknown command '" + args[0] + "' - type 'help'");
+ break;
+ }
+ }
+ }
+}
diff --git a/DiscordPresence.cs b/DiscordPresence.cs
new file mode 100644
index 0000000..36b7909
--- /dev/null
+++ b/DiscordPresence.cs
@@ -0,0 +1,254 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading;
+using MSCLoader;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Discord Rich Presence über Discords lokale IPC-Schnittstelle (Named Pipe "discord-ipc-N"),
+ /// ohne zusätzliche DLL. Liest/schreibt direkt mit ReadFile/WriteFile (Unitys altes Mono kommt
+ /// mit Pipes in FileStream nicht zurecht). Läuft in einem Hintergrund-Thread; ist Discord nicht
+ /// offen, wird es alle 15 s erneut versucht. Statusmeldungen landen über TakeLog() in der Konsole.
+ ///
+ public class DiscordPresence
+ {
+ ///
+ /// Application ID aus dem Discord Developer Portal (https://discord.com/developers/applications).
+ /// Unter "Rich Presence → Art Assets" ein Bild mit dem Namen "logo" hochladen (branding/logo-512.png).
+ ///
+ public const string ApplicationId = "1553384871303450664";
+
+ const string ServersUrl = "https://msco.mbm.wtf/servers";
+ const int RetrySeconds = 15;
+
+ readonly object gate = new object();
+ readonly Queue log = new Queue();
+ string pendingActivity; // JSON der Aktivität, die noch gesendet werden muss
+ string lastActivity;
+ string lastStatus;
+ Thread worker;
+ volatile bool running;
+ int nonce;
+ int pid;
+
+ public static bool Configured
+ {
+ get { return ApplicationId.Trim('0').Length > 0; }
+ }
+
+ public void Start()
+ {
+ if (running) return;
+ if (!Configured)
+ {
+ ModConsole.Print("[MSCO] Discord Rich Presence: no application id set (DiscordPresence.ApplicationId)");
+ return;
+ }
+ if (Application.platform != RuntimePlatform.WindowsPlayer && Application.platform != RuntimePlatform.WindowsEditor) return;
+
+ try { pid = System.Diagnostics.Process.GetCurrentProcess().Id; } catch { pid = 0; }
+ running = true;
+ worker = new Thread(Run) { IsBackground = true, Name = "MSCO Discord" };
+ worker.Start();
+ }
+
+ public void Stop()
+ {
+ running = false;
+ }
+
+ /// Statusmeldungen des Hintergrund-Threads (im Hauptthread in die Konsole schreiben).
+ public string TakeLog()
+ {
+ lock (gate) return log.Count > 0 ? log.Dequeue() : null;
+ }
+
+ void Status(string text)
+ {
+ lock (gate)
+ {
+ if (text == lastStatus) return; // gleiche Meldung nicht alle 15 s wiederholen
+ lastStatus = text;
+ log.Enqueue(text);
+ }
+ }
+
+ /// Neue Anzeige setzen (wird nur gesendet, wenn sie sich geändert hat).
+ public void SetActivity(string details, string state, int partySize, int partyMax, string partyId, long startUnix)
+ {
+ var sb = new StringBuilder();
+ sb.Append("{\"details\":").Append(Json(details));
+ if (!string.IsNullOrEmpty(state)) sb.Append(",\"state\":").Append(Json(state));
+ if (startUnix > 0) sb.Append(",\"timestamps\":{\"start\":").Append(startUnix).Append('}');
+ sb.Append(",\"assets\":{\"large_image\":\"logo\",\"large_text\":\"My Summer Car Online\"}");
+ if (partySize > 0 && partyMax >= partySize)
+ sb.Append(",\"party\":{\"id\":").Append(Json(partyId ?? "msco")).Append(",\"size\":[").Append(partySize).Append(',').Append(partyMax).Append("]}");
+ sb.Append(",\"buttons\":[{\"label\":\"MSCO Servers\",\"url\":").Append(Json(ServersUrl)).Append("}]}");
+
+ string activity = sb.ToString();
+ lock (gate)
+ {
+ if (activity == lastActivity) return;
+ lastActivity = activity;
+ pendingActivity = activity;
+ }
+ }
+
+ // ---------------------------------------------------------------- Hintergrund-Thread
+
+ void Run()
+ {
+ while (running)
+ {
+ IntPtr pipe = InvalidHandle;
+ try
+ {
+ pipe = Connect();
+ if (pipe == InvalidHandle)
+ {
+ Status("Discord not running (will retry)");
+ Sleep(RetrySeconds);
+ continue;
+ }
+
+ Write(pipe, 0, "{\"v\":1,\"client_id\":" + Json(ApplicationId) + "}");
+ string ready = Read(pipe);
+ if (ready.IndexOf("READY", StringComparison.Ordinal) < 0) throw new Exception("Unexpected handshake reply: " + Short(ready));
+ Status("Discord connected");
+ lock (gate) pendingActivity = lastActivity; // nach (Wieder-)Verbindung erneut senden
+
+ while (running)
+ {
+ string activity;
+ lock (gate)
+ {
+ activity = pendingActivity;
+ pendingActivity = null;
+ }
+ if (activity != null)
+ {
+ Write(pipe, 1, "{\"cmd\":\"SET_ACTIVITY\",\"args\":{\"pid\":" + pid + ",\"activity\":" + activity +
+ "},\"nonce\":\"" + (++nonce) + "\"}");
+ string reply = Read(pipe);
+ if (reply.IndexOf("\"evt\":\"ERROR\"", StringComparison.Ordinal) >= 0)
+ Status("Discord rejected the activity: " + Short(reply));
+ }
+ Thread.Sleep(1000);
+ }
+ }
+ catch (Exception e)
+ {
+ Status("Discord connection lost: " + e.Message);
+ }
+ finally
+ {
+ if (pipe != InvalidHandle) CloseHandle(pipe);
+ }
+ if (running) Sleep(RetrySeconds);
+ }
+ }
+
+ void Sleep(int seconds)
+ {
+ for (int i = 0; i < seconds * 10 && running; i++) Thread.Sleep(100);
+ }
+
+ static IntPtr Connect()
+ {
+ for (int i = 0; i < 10; i++)
+ {
+ IntPtr handle = CreateFile(@"\\.\pipe\discord-ipc-" + i, GenericRead | GenericWrite, 0, IntPtr.Zero, OpenExisting, 0, IntPtr.Zero);
+ if (handle != InvalidHandle) return handle;
+ }
+ return InvalidHandle;
+ }
+
+ /// Discord-IPC-Frame: int32 Opcode, int32 Länge, JSON (UTF-8).
+ static void Write(IntPtr pipe, int opcode, string json)
+ {
+ byte[] payload = Encoding.UTF8.GetBytes(json);
+ var frame = new byte[8 + payload.Length];
+ BitConverter.GetBytes(opcode).CopyTo(frame, 0);
+ BitConverter.GetBytes(payload.Length).CopyTo(frame, 4);
+ Buffer.BlockCopy(payload, 0, frame, 8, payload.Length);
+
+ int written;
+ if (!WriteFile(pipe, frame, frame.Length, out written, IntPtr.Zero) || written != frame.Length)
+ throw new Exception("WriteFile failed (" + Marshal.GetLastWin32Error() + ")");
+ }
+
+ static string Read(IntPtr pipe)
+ {
+ byte[] header = ReadExactly(pipe, 8);
+ int opcode = BitConverter.ToInt32(header, 0);
+ int length = BitConverter.ToInt32(header, 4);
+ if (length < 0 || length > 64 * 1024) throw new Exception("Bad Discord frame");
+ string json = Encoding.UTF8.GetString(ReadExactly(pipe, length));
+ if (opcode == 2) throw new Exception("Discord closed the connection: " + Short(json));
+ return json;
+ }
+
+ static byte[] ReadExactly(IntPtr pipe, int count)
+ {
+ var data = new byte[count];
+ int offset = 0;
+ var chunk = new byte[count];
+ while (offset < count)
+ {
+ int read;
+ if (!ReadFile(pipe, chunk, count - offset, out read, IntPtr.Zero) || read <= 0)
+ throw new Exception("ReadFile failed (" + Marshal.GetLastWin32Error() + ")");
+ Buffer.BlockCopy(chunk, 0, data, offset, read);
+ offset += read;
+ }
+ return data;
+ }
+
+ static string Short(string s)
+ {
+ return s == null ? "" : s.Length > 160 ? s.Substring(0, 160) + "..." : s;
+ }
+
+ static string Json(string value)
+ {
+ var sb = new StringBuilder("\"");
+ foreach (char c in value ?? "")
+ {
+ switch (c)
+ {
+ case '"': sb.Append("\\\""); break;
+ case '\\': sb.Append("\\\\"); break;
+ case '\n': sb.Append("\\n"); break;
+ case '\r': break;
+ case '\t': sb.Append(' '); break;
+ default:
+ if (c < 0x20) break;
+ sb.Append(c);
+ break;
+ }
+ }
+ return sb.Append('"').ToString();
+ }
+
+ static readonly IntPtr InvalidHandle = new IntPtr(-1);
+ const uint GenericRead = 0x80000000;
+ const uint GenericWrite = 0x40000000;
+ const uint OpenExisting = 3;
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ static extern IntPtr CreateFile(string name, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr template);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ static extern bool ReadFile(IntPtr handle, byte[] buffer, int toRead, out int read, IntPtr overlapped);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ static extern bool WriteFile(IntPtr handle, byte[] buffer, int toWrite, out int written, IntPtr overlapped);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ static extern bool CloseHandle(IntPtr handle);
+ }
+}
diff --git a/DoorSync.cs b/DoorSync.cs
new file mode 100644
index 0000000..00fdf1d
--- /dev/null
+++ b/DoorSync.cs
@@ -0,0 +1,122 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using HutongGames.PlayMaker;
+using MSCLoader;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Türen, Tore, Hauben, Klappen (Garagentor, Haustüren, Autotüren ...). Die Mod beobachtet die
+ /// PlayMaker-FSMs dieser Objekte: Wechselt eine bei uns in einen neuen Zustand (z.B. "Open door"),
+ /// wird dieser Zustand an die anderen geschickt und dort ebenfalls ausgelöst. Unabhängig davon,
+ /// wie der Spieler das Objekt bedient hat (Klick, Taste, Hand-FSM ...).
+ ///
+ public class DoorSync
+ {
+ static readonly string[] Keywords = { "door", "gate", "hood", "bonnet", "trunk", "lid", "hatch" };
+
+ const float Cooldown = 1.5f; // Folge-Zustände der Animation nicht nochmal senden
+ const float RescanInterval = 10f; // neue Objekte (z.B. angebaute Autotüren) aufnehmen
+
+ class Tracked
+ {
+ public string Key;
+ public PlayMakerFSM Fsm;
+ public string LastState;
+ public float QuietUntil;
+ }
+
+ readonly Dictionary byKey = new Dictionary();
+ float nextScan;
+
+ public int Count { get { return byKey.Count; } }
+
+ public void Reset()
+ {
+ byKey.Clear();
+ nextScan = 0f;
+ }
+
+ public void Update(Action send)
+ {
+ float now = Time.realtimeSinceStartup;
+ if (now >= nextScan)
+ {
+ nextScan = now + RescanInterval;
+ Scan();
+ }
+
+ foreach (Tracked t in byKey.Values)
+ {
+ if (t.Fsm == null) continue;
+ string state = t.Fsm.ActiveStateName;
+ if (state == t.LastState) continue;
+ t.LastState = state;
+
+ if (now < t.QuietUntil) continue; // Folge einer eigenen oder fremden Aktion
+ FsmState s = t.Fsm.Fsm.GetState(state);
+ if (s == null || FsmSync.ChecksInputPublic(s)) continue; // nur "Maus zeigt drauf" o.ä.
+
+ t.QuietUntil = now + Cooldown;
+ string key = t.Key;
+ send(MsgType.DoorState, NetSession.Build(w => { w.Write(key); w.Write(state); }));
+ if (FsmSync.Debug) ModConsole.Print("[MSCO] >> DOOR " + key + " -> " + state);
+ }
+ }
+
+ public void Apply(BinaryReader r)
+ {
+ ApplyState(r.ReadString(), r.ReadString());
+ }
+
+ /// Zustand übernehmen (von einem Mitspieler oder aus dem Server-Snapshot).
+ public void ApplyState(string key, string state)
+ {
+ Tracked t;
+ if (!byKey.TryGetValue(key, out t) || t.Fsm == null)
+ {
+ Scan(); // Objekt vielleicht erst seit kurzem aktiv
+ if (!byKey.TryGetValue(key, out t) || t.Fsm == null)
+ {
+ if (FsmSync.Debug) ModConsole.Warning("[MSCO] << DOOR not found: " + key);
+ return;
+ }
+ }
+
+ if (t.Fsm.ActiveStateName == state) return;
+ FsmState target = t.Fsm.Fsm.GetState(state);
+ if (target == null) return;
+ t.QuietUntil = Time.realtimeSinceStartup + Cooldown; // Folge-Zustände nicht zurückschicken
+ t.LastState = state;
+ FsmSync.SwitchStateSilently(t.Fsm, target);
+ if (FsmSync.Debug) ModConsole.Print("[MSCO] << DOOR " + key + " -> " + state);
+ }
+
+ void Scan()
+ {
+ foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f))
+ {
+ if (fsm == null) continue;
+ string root = fsm.transform.root.name;
+ if (root == "PLAYER" || root.StartsWith("MSCO_")) continue;
+ if (!Matches(fsm.gameObject.name) && !Matches(fsm.FsmName) && !Matches(fsm.transform.parent != null ? fsm.transform.parent.name : "")) continue;
+
+ string key = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName;
+ Tracked t;
+ if (byKey.TryGetValue(key, out t) && t.Fsm == fsm) continue;
+ byKey[key] = new Tracked { Key = key, Fsm = fsm, LastState = fsm.ActiveStateName };
+ }
+ }
+
+ static bool Matches(string name)
+ {
+ if (string.IsNullOrEmpty(name)) return false;
+ string lower = name.ToLower();
+ foreach (string k in Keywords)
+ if (lower.Contains(k)) return true;
+ return false;
+ }
+ }
+}
diff --git a/FsmSync.cs b/FsmSync.cs
new file mode 100644
index 0000000..352045a
--- /dev/null
+++ b/FsmSync.cs
@@ -0,0 +1,396 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using Harmony;
+using HutongGames.PlayMaker;
+using MSCLoader;
+using UnityEngine;
+
+namespace MSCO
+{
+ public struct FsmEventMsg
+ {
+ public string Path;
+ public string FsmName;
+ public string EventName;
+ public string State;
+ public Vector3 Position;
+
+ public byte[] Serialize()
+ {
+ FsmEventMsg m = this;
+ return NetSession.Build(w =>
+ {
+ w.Write(m.Path); w.Write(m.FsmName); w.Write(m.EventName); w.Write(m.State);
+ w.Write(m.Position.x); w.Write(m.Position.y); w.Write(m.Position.z);
+ });
+ }
+
+ public static FsmEventMsg Deserialize(BinaryReader r)
+ {
+ return new FsmEventMsg
+ {
+ Path = r.ReadString(), FsmName = r.ReadString(), EventName = r.ReadString(), State = r.ReadString(),
+ Position = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle())
+ };
+ }
+ }
+
+ ///
+ /// Überträgt Spieler-Aktionen, die in PlayMaker-FSMs ablaufen: Schrauben anziehen/lösen,
+ /// Teile anbauen/abbauen. Dazu wird Fsm.Event per Harmony abgefangen; beim anderen
+ /// Spieler wird dieselbe FSM in denselben Zielzustand geschaltet.
+ ///
+ public static class FsmSync
+ {
+ // Events, die direkt durch den Spieler ausgelöst werden
+ static readonly string[] SyncedEvents =
+ {
+ "TIGHTEN", "UNTIGHTEN", "ASSEMBLE", "DISASSEMBLE", "REMOVE", "INSTALL", "UNINSTALL"
+ };
+
+ // Folge-Events einer Aktion laufen auf beiden Seiten von selbst ab und dürfen nicht nochmal gesendet werden
+ const float ChainWindow = 0.3f;
+
+ public static bool Enabled;
+ public static bool Debug;
+ public static readonly List Outbox = new List();
+
+ static bool patched;
+ static int applying;
+ static float lastApplyTime = -100f;
+ static float lastSendTime = -100f;
+ static Fsm lastSendFsm;
+
+ static readonly MethodInfo switchState =
+ typeof(Fsm).GetMethod("SwitchState", BindingFlags.NonPublic | BindingFlags.Instance);
+
+ // "GameObject-Name|FSM-Name" -> FSMs, zum Wiederfinden beim Empfänger
+ static Dictionary> fsmCache;
+ static float lastCacheBuild = -100f;
+
+ public static void Init()
+ {
+ if (patched) return;
+ patched = true;
+ try
+ {
+ MethodInfo original = typeof(Fsm).GetMethod("Event", new[] { typeof(FsmEvent) });
+ MethodInfo prefix = typeof(FsmSync).GetMethod("EventPrefix", BindingFlags.NonPublic | BindingFlags.Static);
+ HarmonyInstance harmony = HarmonyInstance.Create("mbm.msco.fsmsync");
+ harmony.Patch(original, new HarmonyMethod(prefix), null);
+
+ // Klick-Interaktionen (Lichtschalter, Knöpfe, Radio ...) über Zustandswechsel erkennen
+ MethodInfo switchPrefix = typeof(FsmSync).GetMethod("SwitchStatePrefix", BindingFlags.NonPublic | BindingFlags.Static);
+ harmony.Patch(switchState, new HarmonyMethod(switchPrefix), null);
+ ModConsole.Print("[MSCO] FSM sync ready");
+ }
+ catch (Exception e)
+ {
+ ModConsole.Error("[MSCO] FSM sync patch failed: " + e);
+ }
+ }
+
+ public static void Reset()
+ {
+ Outbox.Clear();
+ fsmCache = null;
+ lastSendFsm = null;
+ clickCandidates.Clear();
+ clickActive = false;
+ }
+
+ // ---------------------------------------------------------------- Klick-Interaktionen
+
+ // Nach einem Klick wird gesammelt, welche FSMs in der Nähe den Zustand wechseln.
+ // Das Objekt, auf das der Spieler zielt, gewinnt; gesendet wird der erste Zustand,
+ // der nicht selbst auf Maus/Tasten/Raycast prüft (das würde beim Empfänger scheitern).
+ const float ClickWindow = 0.4f;
+ const float ClickRange = 4f;
+ static readonly string[] InputActionWords = { "Mouse", "Raycast", "GetKey", "GetButton", "Input", "Pick", "Axis" };
+
+ class ClickCandidate
+ {
+ public Fsm Fsm;
+ public float AimDistance;
+ public readonly List States = new List();
+ }
+
+ static readonly Dictionary clickCandidates = new Dictionary();
+ static bool clickActive;
+ static float clickTime;
+ static int lastClickCheckFrame = -1;
+ static bool useKeyAvailable = true;
+
+ static bool ClickThisFrame()
+ {
+ if (Input.GetMouseButtonDown(0) || Input.GetMouseButtonDown(1)) return true;
+ if (!useKeyAvailable) return false;
+ try { return cInput.GetKeyDown("Use"); }
+ catch { useKeyAvailable = false; return false; }
+ }
+
+ static void SwitchStatePrefix(Fsm __instance, FsmState toState)
+ {
+ if (!Enabled || applying > 0 || toState == null) return;
+ try
+ {
+ float now = Time.realtimeSinceStartup;
+ if (now - lastApplyTime < ChainWindow) return;
+
+ if (!clickActive && lastClickCheckFrame != Time.frameCount)
+ {
+ lastClickCheckFrame = Time.frameCount;
+ if (ClickThisFrame())
+ {
+ clickActive = true;
+ clickTime = now;
+ clickCandidates.Clear();
+ }
+ }
+ if (!clickActive) return;
+
+ ClickCandidate c;
+ if (!clickCandidates.TryGetValue(__instance, out c))
+ {
+ float aim;
+ if (!IsAimedAt(__instance, out aim)) return;
+ c = new ClickCandidate { Fsm = __instance, AimDistance = aim };
+ clickCandidates[__instance] = c;
+ }
+ c.States.Add(toState);
+ }
+ catch (Exception e)
+ {
+ ModConsole.Error("[MSCO] Click capture error: " + e.Message);
+ }
+ }
+
+ static bool IsAimedAt(Fsm fsm, out float aimDistance)
+ {
+ aimDistance = float.MaxValue;
+ GameObject go = fsm.GameObject;
+ Camera cam = Camera.main;
+ if (go == null || cam == null) return false;
+ string root = go.transform.root.name;
+ if (root == "PLAYER" || root.StartsWith("MSCO_")) return false;
+
+ Vector3 toObject = go.transform.position - cam.transform.position;
+ if (toObject.magnitude > ClickRange || Vector3.Dot(toObject, cam.transform.forward) < 0f) return false;
+ aimDistance = Vector3.Cross(cam.transform.forward, toObject).magnitude; // Abstand zur Blickachse
+ return true;
+ }
+
+ /// Jedes Frame vom Manager aufrufen: abgeschlossene Klicks auswerten.
+ public static void Tick()
+ {
+ if (!clickActive || Time.realtimeSinceStartup - clickTime < ClickWindow) return;
+ clickActive = false;
+
+ // Schrauben/Montage wurden schon über die Events gesendet
+ if (Time.realtimeSinceStartup - lastSendTime < ClickWindow + ChainWindow) { clickCandidates.Clear(); return; }
+
+ ClickCandidate best = null;
+ foreach (ClickCandidate c in clickCandidates.Values)
+ if (best == null || c.AimDistance < best.AimDistance) best = c;
+ clickCandidates.Clear();
+ if (best == null || best.Fsm.GameObject == null) return;
+
+ FsmState target = null;
+ foreach (FsmState s in best.States)
+ if (!ChecksInput(s)) { target = s; break; }
+ if (target == null) return;
+
+ GameObject go = best.Fsm.GameObject;
+ var msg = new FsmEventMsg
+ {
+ Path = BuildPath(go.transform),
+ FsmName = best.Fsm.Name,
+ EventName = "CLICK",
+ State = target.Name,
+ Position = go.transform.position
+ };
+ Outbox.Add(msg);
+ if (Debug) ModConsole.Print("[MSCO] >> CLICK " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]");
+ }
+
+ /// Prüft der Zustand nur Maus/Tasten/Raycast (z.B. "Maus zeigt auf den Griff")?
+ public static bool ChecksInputPublic(FsmState state) { return ChecksInput(state); }
+
+ static bool ChecksInput(FsmState state)
+ {
+ if (!state.ActionsLoaded) state.LoadActions();
+ if (state.Actions == null) return false;
+ foreach (FsmStateAction action in state.Actions)
+ {
+ if (action == null) continue;
+ string type = action.GetType().Name;
+ foreach (string word in InputActionWords)
+ if (type.Contains(word)) return true;
+ }
+ return false;
+ }
+
+ // ---------------------------------------------------------------- Senden
+
+ static void EventPrefix(Fsm __instance, FsmEvent fsmEvent)
+ {
+ if (!Enabled || applying > 0 || fsmEvent == null) return;
+ if (Array.IndexOf(SyncedEvents, fsmEvent.Name) < 0) return;
+
+ try
+ {
+ float now = Time.realtimeSinceStartup;
+ if (now - lastApplyTime < ChainWindow) return; // Folge einer Remote-Aktion
+ if (__instance != lastSendFsm && now - lastSendTime < ChainWindow) return; // Folge einer eigenen Aktion
+
+ GameObject go = __instance.GameObject;
+ if (go == null || go.transform.root.name == "PLAYER") return;
+
+ string target = FindTargetState(__instance, fsmEvent.Name);
+ if (target == null) return; // Event bewirkt im aktuellen Zustand nichts
+
+ lastSendTime = now;
+ lastSendFsm = __instance;
+ var msg = new FsmEventMsg
+ {
+ Path = BuildPath(go.transform),
+ FsmName = __instance.Name,
+ EventName = fsmEvent.Name,
+ State = target,
+ Position = go.transform.position
+ };
+ Outbox.Add(msg);
+ if (Debug) ModConsole.Print("[MSCO] >> " + msg.EventName + " " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]");
+ }
+ catch (Exception e)
+ {
+ ModConsole.Error("[MSCO] FSM capture error: " + e.Message);
+ }
+ }
+
+ static string FindTargetState(Fsm fsm, string eventName)
+ {
+ FsmState active = fsm.ActiveState;
+ if (active != null)
+ foreach (FsmTransition t in active.Transitions)
+ if (t.EventName == eventName) return t.ToState;
+ foreach (FsmTransition t in fsm.GlobalTransitions)
+ if (t.EventName == eventName) return t.ToState;
+ return null;
+ }
+
+ // ---------------------------------------------------------------- Empfangen
+
+ public static void Apply(FsmEventMsg msg)
+ {
+ PlayMakerFSM fsm = Resolve(msg);
+ if (fsm == null)
+ {
+ if (Debug) ModConsole.Warning("[MSCO] << not found: " + msg.Path + " [" + msg.FsmName + "]");
+ return;
+ }
+ FsmState state = fsm.Fsm.GetState(msg.State);
+ if (state == null || switchState == null) return;
+
+ if (Debug) ModConsole.Print("[MSCO] << " + msg.EventName + " " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]");
+ applying++;
+ lastApplyTime = Time.realtimeSinceStartup;
+ try { switchState.Invoke(fsm.Fsm, new object[] { state }); }
+ catch (Exception e) { ModConsole.Error("[MSCO] FSM apply error: " + e.Message); }
+ finally { applying--; }
+ }
+
+ /// FSM in einen Zustand schalten, ohne dass das als eigene Aktion erkannt und gesendet wird.
+ public static void SwitchStateSilently(PlayMakerFSM fsm, FsmState state)
+ {
+ if (switchState == null) return;
+ applying++;
+ lastApplyTime = Time.realtimeSinceStartup;
+ try { switchState.Invoke(fsm.Fsm, new object[] { state }); }
+ catch (Exception e) { ModConsole.Error("[MSCO] FSM switch error: " + e.Message); }
+ finally { applying--; }
+ }
+
+ /// Pfad eines Objekts, auf allen Rechnern gleich (siehe BuildPath).
+ public static string PathOf(Transform t) { return BuildPath(t); }
+
+ static PlayMakerFSM Resolve(FsmEventMsg msg)
+ {
+ string goName = LastSegmentName(msg.Path);
+ PlayMakerFSM found = FindInCache(goName, msg);
+ if (found == null && Time.realtimeSinceStartup - lastCacheBuild > 1f)
+ {
+ BuildCache(); // neu aktivierte Objekte (z.B. Schrauben eines gerade montierten Teils)
+ found = FindInCache(goName, msg);
+ }
+ return found;
+ }
+
+ static PlayMakerFSM FindInCache(string goName, FsmEventMsg msg)
+ {
+ if (fsmCache == null) return null;
+ List list;
+ if (!fsmCache.TryGetValue(goName + "|" + msg.FsmName, out list)) return null;
+
+ // Gleicher Pfad; bei mehreren Treffern das nächstgelegene Objekt
+ PlayMakerFSM best = null;
+ float bestDist = float.MaxValue;
+ foreach (PlayMakerFSM fsm in list)
+ {
+ if (fsm == null || BuildPath(fsm.transform) != msg.Path) continue;
+ float d = (fsm.transform.position - msg.Position).sqrMagnitude;
+ if (d < bestDist) { bestDist = d; best = fsm; }
+ }
+ return best;
+ }
+
+ static void BuildCache()
+ {
+ lastCacheBuild = Time.realtimeSinceStartup;
+ fsmCache = new Dictionary>();
+ foreach (PlayMakerFSM fsm in SceneCache.Fsms(1f))
+ {
+ if (fsm == null) continue;
+ string key = fsm.gameObject.name + "|" + fsm.FsmName;
+ List list;
+ if (!fsmCache.TryGetValue(key, out list)) fsmCache[key] = list = new List();
+ list.Add(fsm);
+ }
+ }
+
+ // ---------------------------------------------------------------- Pfade
+
+ ///
+ /// "SATSUMA(557kg, 248)|Body#0|alternator(Clone)#0|Bolts#0|bolt#2" - Name plus Nummer unter
+ /// gleichnamigen Geschwistern. Beim Wurzelobjekt nur der Name, Mehrdeutigkeit löst die Position.
+ ///
+ static string BuildPath(Transform t)
+ {
+ var segments = new List();
+ for (; t != null; t = t.parent)
+ {
+ if (t.parent == null) { segments.Add(t.name); break; }
+ int ordinal = 0;
+ Transform parent = t.parent;
+ for (int i = 0; i < parent.childCount; i++)
+ {
+ Transform sibling = parent.GetChild(i);
+ if (sibling == t) break;
+ if (sibling.name == t.name) ordinal++;
+ }
+ segments.Add(t.name + "#" + ordinal);
+ }
+ segments.Reverse();
+ return string.Join("|", segments.ToArray());
+ }
+
+ static string LastSegmentName(string path)
+ {
+ string last = path.Substring(path.LastIndexOf('|') + 1);
+ int hash = last.LastIndexOf('#');
+ return hash >= 0 && path.Contains("|") ? last.Substring(0, hash) : last;
+ }
+ }
+}
diff --git a/GameCharacter.cs b/GameCharacter.cs
new file mode 100644
index 0000000..282abd8
--- /dev/null
+++ b/GameCharacter.cs
@@ -0,0 +1,405 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Mitspieler-Figur aus einem echten My-Summer-Car-NPC (Körper-Mesh "bodymesh" + Skelett mit "pelvis").
+ /// Der NPC wird kopiert und von seiner Logik befreit; es bleiben Aussehen und die Spiel-Animationen
+ /// (Idle/Walk). Nach der Animation werden die Knochen für Ducken, Lehnen, Kopf-Nicken, Sitzen und
+ /// das Greifen zum Item verbogen (Zwei-Knochen-IK, unabhängig von den Achsen des Skeletts).
+ ///
+ public class GameCharacter
+ {
+ const float StandingEyeHeight = 1.65f;
+ const float EyeAboveHeadBone = 0.1f; // Augen liegen etwas über dem Kopf-Knochen
+ const float AnkleHeight = 0.07f; // Fuß-Knochen über dem Boden
+
+ int frames;
+ bool calibrated;
+
+ public Transform Root { get; private set; }
+ public string Description { get; private set; }
+
+ Animation anim;
+ string walkClip, idleClip;
+ Transform pelvis, spine, head;
+ readonly Transform[] thigh = new Transform[2], knee = new Transform[2], foot = new Transform[2];
+ readonly Transform[] upperArm = new Transform[2], forearm = new Transform[2], hand = new Transform[2];
+ readonly Vector3[] animatedFoot = new Vector3[2];
+
+ ///
+ /// Knochen, die wir verbiegen. Bewegt die Animation einen davon nicht selbst, würden sich unsere
+ /// Änderungen jedes Frame aufsummieren - dann wird er vorher auf die Grundhaltung zurückgesetzt.
+ ///
+ class BoneState
+ {
+ public Transform Bone;
+ public Vector3 RestPos, OutPos;
+ public Quaternion RestRot, OutRot;
+ public bool HasOut;
+ }
+ readonly List posed = new List();
+
+ public Vector3 HeadPosition { get { return head.position + Root.up * EyeAboveHeadBone; } }
+
+ // ---------------------------------------------------------------- Vorlage finden und kopieren
+
+ static List templates;
+
+ /// Kopiert einen NPC aus der Spielszene als Figur. null, wenn keine passende Vorlage da ist.
+ public static GameCharacter TryCreate(Transform parent, int variant)
+ {
+ List candidates = FindTemplates();
+ if (candidates.Count == 0) return null;
+ GameObject template = candidates[Mathf.Abs(variant) % candidates.Count];
+
+ GameObject clone = (GameObject)UnityEngine.Object.Instantiate(template);
+ clone.name = "Model";
+ Strip(clone);
+ clone.SetActive(true);
+ clone.transform.SetParent(parent, false);
+ clone.transform.localPosition = Vector3.zero;
+ clone.transform.localRotation = Quaternion.identity;
+ clone.transform.localScale = Vector3.one;
+
+ var c = new GameCharacter { Root = clone.transform };
+ if (!c.FindBones())
+ {
+ UnityEngine.Object.Destroy(clone);
+ return null;
+ }
+ c.Normalize();
+ c.SetupAnimation();
+ c.RememberRestPose();
+ c.Description = template.name + " (" + candidates.Count + " templates) - " + c.BoneReport();
+ return c;
+ }
+
+ /// NPC-Körper in der Szene (auch inaktive): SkinnedMeshRenderer "bodymesh" mit "pelvis"-Knochen.
+ static List FindTemplates()
+ {
+ if (templates != null)
+ {
+ templates.RemoveAll(t => t == null);
+ if (templates.Count > 0) return templates;
+ }
+ var scored = new List>();
+ var seen = new HashSet();
+ foreach (UnityEngine.Object o in Resources.FindObjectsOfTypeAll(typeof(SkinnedMeshRenderer)))
+ {
+ var smr = (SkinnedMeshRenderer)o;
+ if (smr == null || !smr.name.ToLower().Contains("bodymesh") || smr.transform.parent == null) continue;
+ bool hasPelvis = false;
+ if (smr.bones != null)
+ foreach (Transform b in smr.bones)
+ if (b != null && b.name == "pelvis") { hasPelvis = true; break; }
+ if (!hasPelvis) continue;
+
+ GameObject root = smr.transform.parent.gameObject; // Körper + Skelett liegen gemeinsam darunter
+ if (root.transform.root.name == "PLAYER" || root.name.StartsWith("MSCO")) continue;
+ // Keine Sammelgruppen mit mehreren Personen kopieren (nur eine Figur, evtl. mit Varianten dünn/normal/dick)
+ if (PelvisCount(root.transform) != 1 || root.GetComponentsInChildren(true).Length > 600) continue;
+
+ int score = AnimationScore(ref root);
+ if (!seen.Add(root.name + "/" + smr.sharedMesh)) continue; // gleiche Figur nicht mehrfach
+ scored.Add(new KeyValuePair(root, score));
+ }
+
+ // Nur die besten: am liebsten NPCs mit Lauf- UND Steh-Animation (ohne Gesten wie Anhalter/Betrunken)
+ int best = 0;
+ foreach (var kv in scored) best = Mathf.Max(best, kv.Value);
+ templates = new List();
+ foreach (var kv in scored) if (kv.Value == best) templates.Add(kv.Key);
+ templates.Sort((a, b) => string.CompareOrdinal(a.name, b.name));
+ return templates;
+ }
+
+ static int PelvisCount(Transform root)
+ {
+ int count = 0;
+ foreach (Transform t in root.GetComponentsInChildren(true)) if (t.name == "pelvis") count++;
+ return count;
+ }
+
+ // Animationen, die keine normale Steh-/Laufhaltung sind
+ static readonly string[] GestureWords = { "hitch", "drunk", "fat", "lift", "sit", "wave", "point", "dance", "angry", "back", "theatre", "car" };
+
+ static bool IsGesture(string clip)
+ {
+ string n = clip.ToLower();
+ foreach (string g in GestureWords) if (n.Contains(g)) return true;
+ return false;
+ }
+
+ ///
+ /// Bewertet die Animationen eines NPCs (liegt die Animation eine Ebene höher, wird dieser Knoten
+ /// die Vorlage). 3 = normales Laufen + Stehen, 2 = nur Laufen, 1 = nur Stehen, 0 = keine.
+ ///
+ static int AnimationScore(ref GameObject root)
+ {
+ Animation[] found = root.GetComponentsInChildren(true); // Unity 5.0: auch inaktive finden
+ Animation anim = found.Length > 0 ? found[0] : null;
+ if (anim == null)
+ {
+ Transform p = root.transform.parent;
+ for (int i = 0; i < 2 && p != null && anim == null; i++, p = p.parent)
+ {
+ var candidate = p.GetComponent();
+ if (candidate != null && PelvisCount(p) == 1)
+ {
+ anim = candidate;
+ root = p.gameObject;
+ }
+ }
+ }
+ if (anim == null) return 0;
+
+ bool walk = false, idle = false;
+ foreach (AnimationState s in anim)
+ {
+ if (IsGesture(s.name)) continue;
+ string n = s.name.ToLower();
+ if (n.Contains("walk")) walk = true;
+ if (n.Contains("idle")) idle = true;
+ }
+ return (walk ? 2 : 0) + (idle ? 1 : 0);
+ }
+
+ /// Alles außer Aussehen und Animation entfernen (KI-FSMs, Physik, Sounds, Trigger ...).
+ static void Strip(GameObject go)
+ {
+ foreach (Joint j in go.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(j);
+ foreach (Rigidbody r in go.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(r);
+ foreach (Collider c in go.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(c);
+ foreach (Behaviour b in go.GetComponentsInChildren(true))
+ {
+ if (b is Animation || b is Animator) continue;
+ UnityEngine.Object.DestroyImmediate(b);
+ }
+ foreach (AudioSource a in go.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(a);
+ }
+
+ bool FindBones()
+ {
+ pelvis = Find("pelvis");
+ spine = Find("spine_upper") ?? Find("spine_middle") ?? Find("spine_mid");
+ head = Find("head");
+ if (pelvis == null || spine == null || head == null) return false;
+
+ string[] sides = { "left", "right" };
+ for (int i = 0; i < 2; i++)
+ {
+ knee[i] = Find("knee_" + sides[i]);
+ if (knee[i] != null)
+ {
+ thigh[i] = knee[i].parent;
+ foot[i] = knee[i].childCount > 0 ? knee[i].GetChild(0) : null;
+ }
+ hand[i] = Find("hand_" + sides[i]);
+ if (hand[i] != null && hand[i].parent != null)
+ {
+ forearm[i] = hand[i].parent;
+ upperArm[i] = forearm[i].parent;
+ }
+ }
+ return true;
+ }
+
+ Transform Find(string name)
+ {
+ foreach (Transform t in Root.GetComponentsInChildren(true))
+ if (string.Equals(t.name, name, StringComparison.OrdinalIgnoreCase)) return t;
+ return null;
+ }
+
+ /// Größe auf Spielergröße bringen, Füße auf den Boden, Blickrichtung nach +Z.
+ void Normalize()
+ {
+ // Blickrichtung: Zehen (Kind des Fußes) liegen vor dem Fuß
+ Transform toe = foot[0] != null && foot[0].childCount > 0 ? foot[0].GetChild(0) : null;
+ if (toe != null && Root.parent.InverseTransformPoint(toe.position).z < Root.parent.InverseTransformPoint(foot[0].position).z - 0.01f)
+ Root.localRotation = Quaternion.Euler(0f, 180f, 0f);
+
+ float eye = Root.parent.InverseTransformPoint(head.position).y + EyeAboveHeadBone;
+ if (eye > 0.5f) Root.localScale = Vector3.one * Mathf.Clamp(StandingEyeHeight / eye, 0.6f, 1.6f);
+
+ float lowest = float.MaxValue;
+ for (int i = 0; i < 2; i++)
+ if (foot[i] != null) lowest = Mathf.Min(lowest, Root.parent.InverseTransformPoint(foot[i].position).y);
+ if (lowest < float.MaxValue) Root.localPosition = new Vector3(0f, -lowest + 0.06f, 0f);
+ }
+
+ void SetupAnimation()
+ {
+ anim = Root.GetComponentInChildren();
+ if (anim == null) return;
+ foreach (AnimationState s in anim)
+ {
+ if (IsGesture(s.name)) continue;
+ string n = s.name.ToLower();
+ if (walkClip == null && n.Contains("walk")) walkClip = s.name;
+ if (idleClip == null && n.Contains("idle")) idleClip = s.name;
+ }
+ // Keine Steh-Animation: im Stand die Laufanimation angehalten zeigen (statt einer Geste)
+ if (idleClip == null && walkClip == null && anim.clip != null && !IsGesture(anim.clip.name)) idleClip = anim.clip.name;
+ if (walkClip != null) anim[walkClip].wrapMode = WrapMode.Loop;
+ if (idleClip != null) anim[idleClip].wrapMode = WrapMode.Loop;
+ if (idleClip != null) anim.Play(idleClip);
+ }
+
+ void RememberRestPose()
+ {
+ var bones = new List { pelvis, spine, head };
+ bones.AddRange(thigh);
+ bones.AddRange(knee);
+ bones.AddRange(upperArm);
+ bones.AddRange(forearm);
+ foreach (Transform b in bones)
+ if (b != null)
+ posed.Add(new BoneState { Bone = b, RestPos = b.localPosition, RestRot = b.localRotation });
+ }
+
+ void ResetUnanimatedBones()
+ {
+ foreach (BoneState s in posed)
+ {
+ if (s.HasOut && s.Bone.localPosition == s.OutPos && s.Bone.localRotation == s.OutRot)
+ {
+ s.Bone.localPosition = s.RestPos;
+ s.Bone.localRotation = s.RestRot;
+ }
+ }
+ }
+
+ void RememberOutput()
+ {
+ foreach (BoneState s in posed)
+ {
+ s.OutPos = s.Bone.localPosition;
+ s.OutRot = s.Bone.localRotation;
+ s.HasOut = true;
+ }
+ }
+
+ string BoneReport()
+ {
+ var sb = new StringBuilder();
+ sb.Append("legs ").Append(knee[0] != null && foot[0] != null ? "ok" : "missing");
+ sb.Append(", arms ").Append(hand[1] != null && upperArm[1] != null ? "ok" : "missing");
+ sb.Append(", anim idle=").Append(idleClip ?? "-").Append(" walk=").Append(walkClip ?? "-");
+ return sb.ToString();
+ }
+
+ // ---------------------------------------------------------------- Pro Frame (LateUpdate, nach der Animation)
+
+ public void Animate(float speed)
+ {
+ if (anim == null) return;
+ bool walking = speed > 0.4f && walkClip != null;
+ string clip = walking ? walkClip : (idleClip ?? walkClip);
+ if (clip == null) return;
+ if (!anim.IsPlaying(clip)) anim.CrossFade(clip, 0.25f);
+ if (clip == walkClip) anim[walkClip].speed = walking ? Mathf.Clamp(speed / 1.4f, 0.6f, 2.2f) : 0f; // angehalten = stehen
+ }
+
+ public void Pose(Transform body, Vector3 headTarget, float pitch, bool seated, bool holding, Vector3 heldPos)
+ {
+ Vector3 up = body.up, forward = body.forward, right = body.right;
+ ResetUnanimatedBones();
+ frames++;
+
+ // 1) Auf den Boden stellen: die (animierten) Füße stehen auf der Bodenhöhe des Spielers.
+ // Die Grundhaltung eines NPCs passt oft nicht zur Animation - deshalb jedes Frame nachmessen.
+ float lowestFoot = float.MaxValue;
+ for (int i = 0; i < 2; i++)
+ if (foot[i] != null) lowestFoot = Mathf.Min(lowestFoot, Vector3.Dot(foot[i].position - body.position, up));
+ if (lowestFoot < float.MaxValue)
+ {
+ Vector3 lp = Root.localPosition;
+ lp.y -= lowestFoot - AnkleHeight;
+ Root.localPosition = lp;
+ }
+
+ // 2) Größe einmal an die laufende Animation anpassen (Augenhöhe wie der Spieler)
+ if (!calibrated && frames > 5 && !seated)
+ {
+ calibrated = true;
+ float eye = Vector3.Dot(head.position + up * EyeAboveHeadBone - body.position, up);
+ if (eye > 0.5f) Root.localScale *= Mathf.Clamp(StandingEyeHeight / eye, 0.7f, 1.4f);
+ RememberOutput();
+ return; // nächstes Frame mit neuer Größe neu auf den Boden stellen
+ }
+
+ // Fußpositionen der Animation merken (die sollen stehen bleiben)
+ for (int i = 0; i < 2; i++)
+ if (foot[i] != null) animatedFoot[i] = foot[i].position;
+
+ // 3) Becken: beim Ducken/Sitzen absenken, beim Lehnen nach hinten ausweichen
+ Vector3 eyeNow = head.position + up * EyeAboveHeadBone;
+ Vector3 offset = headTarget - eyeNow;
+ Vector3 horizontal = offset - up * Vector3.Dot(offset, up);
+ if (horizontal.magnitude > 0.6f) horizontal = horizontal.normalized * 0.6f;
+ float drop = Mathf.Clamp(Vector3.Dot(offset, up), -1.1f, 0f);
+ pelvis.position += up * drop - horizontal * 0.25f;
+
+ // Wirbelsäule so drehen, dass der Kopf an der Kamera sitzt (nur wenn nötig)
+ if (horizontal.sqrMagnitude > 0.0025f || drop < -0.05f)
+ Aim(spine, head.position + up * EyeAboveHeadBone, head.position + up * EyeAboveHeadBone + horizontal * 0.75f);
+ // Kopf nicken
+ head.rotation = Quaternion.AngleAxis(pitch, right) * head.rotation;
+
+ // Beine
+ for (int i = 0; i < 2; i++)
+ {
+ if (thigh[i] == null || knee[i] == null || foot[i] == null) continue;
+ Vector3 target = seated
+ ? thigh[i].position + forward * 0.45f - up * 0.4f
+ : animatedFoot[i];
+ SolveTwoBone(thigh[i], knee[i], foot[i], target, forward);
+ }
+
+ // Arme: rechts zum gehaltenen Item, im Auto beide nach vorne (Lenkrad)
+ for (int i = 0; i < 2; i++)
+ {
+ if (upperArm[i] == null || forearm[i] == null || hand[i] == null) continue;
+ if (i == 1 && holding)
+ SolveTwoBone(upperArm[i], forearm[i], hand[i], heldPos, -forward - up * 0.5f);
+ else if (seated)
+ SolveTwoBone(upperArm[i], forearm[i], hand[i], upperArm[i].position + forward * 0.38f - up * 0.28f, -up);
+ }
+ RememberOutput();
+ }
+
+ /// Knochen so drehen, dass "child" in Richtung "target" zeigt - egal wie das Skelett ausgerichtet ist.
+ static void Aim(Transform bone, Vector3 childPos, Vector3 target)
+ {
+ Vector3 from = childPos - bone.position;
+ Vector3 to = target - bone.position;
+ if (from.sqrMagnitude < 1e-6f || to.sqrMagnitude < 1e-6f) return;
+ bone.rotation = Quaternion.FromToRotation(from, to) * bone.rotation;
+ }
+
+ static void SolveTwoBone(Transform upper, Transform lower, Transform end, Vector3 target, Vector3 bendHint)
+ {
+ float a = (lower.position - upper.position).magnitude;
+ float b = (end.position - lower.position).magnitude;
+ if (a < 0.01f || b < 0.01f) return;
+
+ Vector3 root = upper.position;
+ Vector3 toTarget = target - root;
+ float d = Mathf.Clamp(toTarget.magnitude, 0.01f, a + b - 0.001f);
+ Vector3 dir = toTarget.normalized;
+ float along = (a * a - b * b + d * d) / (2f * d);
+ float sideways = Mathf.Sqrt(Mathf.Max(0f, a * a - along * along));
+ Vector3 bend = bendHint - dir * Vector3.Dot(bendHint, dir);
+ if (bend.sqrMagnitude < 1e-6f) bend = Vector3.up - dir * Vector3.Dot(Vector3.up, dir);
+ Vector3 joint = root + dir * along + bend.normalized * sideways;
+
+ Aim(upper, lower.position, joint);
+ Aim(lower, end.position, root + dir * d);
+ }
+ }
+}
diff --git a/InputLock.cs b/InputLock.cs
new file mode 100644
index 0000000..62b458c
--- /dev/null
+++ b/InputLock.cs
@@ -0,0 +1,108 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using Harmony;
+using MSCLoader;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Solange ein Eingabefenster offen ist (F8-Konsole, Chat, Admin-Menü): Spieler bewegt sich nicht,
+ /// Kamera dreht nicht mit, Mauszeiger ist frei. Danach wird genau das wieder eingeschaltet,
+ /// was hier ausgeschaltet wurde.
+ ///
+ public static class InputLock
+ {
+ static readonly string[] ControlScripts =
+ {
+ "CharacterMotor", "FPSInputController", "MouseLook", "SimpleSmoothMouseLook", "SmoothMouseLook", "MouseLook2", "MouseLook3"
+ };
+
+ static readonly List disabled = new List();
+ static bool locked;
+ static bool previousVisible;
+ static CursorLockMode previousLock;
+ static bool patched;
+
+ public static bool Locked { get { return locked; } }
+
+ public static void Set(bool on)
+ {
+ if (on) KeepCursorFree();
+ if (on == locked) return;
+ locked = on;
+
+ if (on)
+ {
+ PatchCursorActions();
+ previousVisible = Cursor.visible;
+ previousLock = Cursor.lockState;
+ GameObject player = GameObject.Find("PLAYER");
+ if (player != null)
+ {
+ foreach (MonoBehaviour mb in player.GetComponentsInChildren())
+ {
+ if (mb == null || !mb.enabled || Array.IndexOf(ControlScripts, mb.GetType().Name) < 0) continue;
+ mb.enabled = false;
+ disabled.Add(mb);
+ }
+ }
+ KeepCursorFree();
+ }
+ else
+ {
+ foreach (Behaviour b in disabled) if (b != null) b.enabled = true;
+ disabled.Clear();
+ PlayMakerGUI.LockCursor = previousLock != CursorLockMode.None;
+ PlayMakerGUI.HideCursor = !previousVisible;
+ Cursor.visible = previousVisible;
+ Cursor.lockState = previousLock;
+ }
+ }
+
+ ///
+ /// Das Spiel sperrt den Zeiger jeden Frame wieder (PlayMakerGUI.LockCursor + "SetMouseCursor"-Aktionen),
+ /// dadurch springt er in die Mitte. Solange offen: überall freigeben - auch aus OnGUI/LateUpdate aufrufen.
+ ///
+ public static void KeepCursorFree()
+ {
+ if (!locked) return;
+ PlayMakerGUI.LockCursor = false;
+ PlayMakerGUI.HideCursor = false;
+ Cursor.visible = true;
+ Cursor.lockState = CursorLockMode.None;
+ }
+
+ /// PlayMaker-Aktionen, die den Mauszeiger setzen, überspringen, solange ein Fenster offen ist.
+ static void PatchCursorActions()
+ {
+ if (patched) return;
+ patched = true;
+ try
+ {
+ Type action = null;
+ foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ action = asm.GetType("HutongGames.PlayMaker.Actions.SetMouseCursor");
+ if (action != null) break;
+ }
+ if (action == null) return;
+
+ HarmonyInstance harmony = HarmonyInstance.Create("mbm.msco.inputlock");
+ var prefix = new HarmonyMethod(typeof(InputLock).GetMethod("SkipWhileLocked", BindingFlags.Static | BindingFlags.NonPublic));
+ foreach (string name in new[] { "OnEnter", "OnUpdate", "DoSetMouseCursor" })
+ {
+ MethodInfo m = action.GetMethod(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+ if (m != null && m.DeclaringType == action) harmony.Patch(m, prefix, null);
+ }
+ }
+ catch (Exception e)
+ {
+ ModConsole.Warning("[MSCO] Could not patch cursor actions: " + e.Message);
+ }
+ }
+
+ static bool SkipWhileLocked() { return !locked; }
+ }
+}
diff --git a/JoinLinks.cs b/JoinLinks.cs
new file mode 100644
index 0000000..ee472f3
--- /dev/null
+++ b/JoinLinks.cs
@@ -0,0 +1,84 @@
+using System;
+using Microsoft.Win32;
+using MSCLoader;
+
+namespace MSCO
+{
+ ///
+ /// "msco://connect/<host>:<port>"-Links (JOIN-Button auf der Website).
+ /// Die Mod registriert den Link-Typ in Windows so, dass ein Klick Steam direkt mit
+ /// "-applaunch 516750 +msco_connect <link>" startet - ohne cmd/Skript dazwischen, damit
+ /// präparierte Links keine Befehle ausführen können. Die Mod liest die Adresse beim Start
+ /// aus der Kommandozeile und fragt im Hauptmenü, ob verbunden werden soll.
+ /// (Läuft das Spiel schon, gibt Steam den Link nicht weiter - dann den Server-Browser nutzen.)
+ ///
+ public static class JoinLinks
+ {
+ const string Scheme = "msco";
+ const string Argument = "+msco_connect";
+ const int MySummerCarAppId = 516750;
+
+ /// Link-Typ msco:// für den aktuellen Windows-Benutzer eintragen (kein Admin nötig).
+ public static void Register()
+ {
+ try
+ {
+ string steamExe = SteamExePath();
+ if (steamExe == null)
+ {
+ ModConsole.Warning("[MSCO] Steam not found - JOIN links from the website are not available.");
+ return;
+ }
+ string command = "\"" + steamExe + "\" -applaunch " + MySummerCarAppId + " " + Argument + " \"%1\"";
+
+ using (RegistryKey key = Registry.CurrentUser.CreateSubKey(@"Software\Classes\" + Scheme))
+ {
+ key.SetValue("", "URL:MSCO Join Link");
+ key.SetValue("URL Protocol", "");
+ using (RegistryKey cmd = key.CreateSubKey(@"shell\open\command"))
+ cmd.SetValue("", command);
+ }
+ }
+ catch (Exception e)
+ {
+ ModConsole.Warning("[MSCO] Could not register msco:// links: " + e.Message);
+ }
+ }
+
+ static string SteamExePath()
+ {
+ using (RegistryKey steam = Registry.CurrentUser.OpenSubKey(@"Software\Valve\Steam"))
+ {
+ string exe = steam != null ? steam.GetValue("SteamExe") as string : null;
+ return string.IsNullOrEmpty(exe) ? null : exe.Replace('/', '\\');
+ }
+ }
+
+ /// Wurde das Spiel über einen JOIN-Link gestartet?
+ public static bool TryGetFromCommandLine(out string host, out int port)
+ {
+ host = null;
+ port = 0;
+ string[] args = Environment.GetCommandLineArgs();
+ for (int i = 0; i < args.Length - 1; i++)
+ if (args[i] == Argument) return TryParse(args[i + 1], out host, out port);
+ return false;
+ }
+
+ /// "msco://connect/1.2.3.4:7777/" -> Host und Port (nur Buchstaben, Ziffern, . - :).
+ static bool TryParse(string text, out string host, out int port)
+ {
+ host = null;
+ port = 0;
+ text = text.Trim().Trim('"').Trim();
+ const string prefix = "msco://connect/";
+ if (!text.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
+ string address = text.Substring(prefix.Length).TrimEnd('/');
+
+ foreach (char c in address)
+ if (!char.IsLetterOrDigit(c) && c != '.' && c != '-' && c != ':') return false;
+
+ return ServerBrowser.TryParseAddress(address, out host, out port);
+ }
+ }
+}
diff --git a/LobbyUI.cs b/LobbyUI.cs
new file mode 100644
index 0000000..a67d45c
--- /dev/null
+++ b/LobbyUI.cs
@@ -0,0 +1,610 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Multiplayer-Launcher im Hauptmenü (dunkles Design mit Seitenleiste, Statusleiste
+ /// und Minimieren). Seiten: P2P (Spieler hostet selbst) und Server Browser (Platzhalter,
+ /// dort kommt später auch Direct Connect zu dedizierten Servern hin).
+ ///
+ public class LobbyUI
+ {
+ enum Page { P2P, ServerBrowser }
+
+ const float Width = 760f;
+ const float Height = 460f;
+ const float SidebarWidth = 190f;
+ const float StatusHeight = 36f;
+
+
+ static readonly Color Background = Hex(0x17, 0x17, 0x1b);
+ static readonly Color Sidebar = Hex(0x0f, 0x0f, 0x12);
+ static readonly Color Field = Hex(0x24, 0x24, 0x2a);
+ static readonly Color FieldFocus = Hex(0x2e, 0x2e, 0x36);
+ static readonly Color Accent = Hex(0xf2, 0x6b, 0x1d);
+ static readonly Color AccentHover = Hex(0xff, 0x82, 0x3a);
+ static readonly Color Secondary = Hex(0x2c, 0x2c, 0x33);
+ static readonly Color SecondaryHover = Hex(0x3a, 0x3a, 0x43);
+ static readonly Color Muted = Hex(0x8e, 0x8e, 0x99);
+ static readonly Color Online = Hex(0x3d, 0xd6, 0x8c);
+ static readonly Color Pending = Hex(0xf2, 0xc2, 0x1d);
+
+ Page page = Page.P2P;
+ bool minimized;
+ Vector2 playerScroll;
+ Vector2 serverScroll;
+ bool browserLoaded;
+ string selectedServer; // "adresse:port" des geöffneten Server-Profils
+ string searchInput = "";
+ bool showSteam = true, showNonSteam = true, hideFull;
+ string nameInput;
+ string directInput;
+ const string PrefDirect = "MSCO_LastServer";
+
+ bool stylesReady;
+ Texture2D texBackground, texSidebar, texAccent, texOnline, texPending, texMuted, texRow;
+ GUIStyle windowBox, sidebarBox, rowBox, accentBox, pillStyle, pillMutedStyle, chipOnStyle, chipOffStyle;
+ GUIStyle logoStyle, taglineStyle, navStyle, navActiveStyle, titleStyle, textStyle, captionStyle,
+ fieldStyle, primaryStyle, secondaryStyle, statusStyle, tabStyle, headerCellStyle,
+ rowNameStyle, rowTagStyle, badgeStyle, toggleStyle, placeholderStyle;
+
+ /// Fläche, die das Fenster gerade belegt (GUI-Koordinaten, oben = 0).
+ public Rect ScreenRect { get; private set; }
+
+ /// Fenster offen (nicht minimiert) - dann ist das Spielmenü dahinter gesperrt.
+ public bool IsOpen { get { return !minimized; } }
+
+ /// Ist die Maus gerade über dem Fenster bzw. der eingeklappten Leiste?
+ public bool ContainsMouse()
+ {
+ Vector3 m = Input.mousePosition;
+ return ScreenRect.Contains(new Vector2(m.x, Screen.height - m.y));
+ }
+
+ public void Draw(MultiplayerManager mp)
+ {
+ EnsureStyles();
+ bool pendingJoin = mp.PendingJoinHost != null && !mp.IsInSession;
+ bool versionWarning = mp.PendingVersionWarning != null && !mp.IsInSession;
+ if (pendingJoin || versionWarning) minimized = false; // JOIN-Link / Warnung: Fenster zeigen
+ if (minimized) { DrawMinimized(mp); return; }
+
+ var window = new Rect((Screen.width - Width) / 2f, (Screen.height - Height) / 2f, Width, Height);
+ ScreenRect = window;
+ GUI.Box(window, GUIContent.none, windowBox);
+
+ DrawSidebar(new Rect(window.x, window.y, SidebarWidth, Height - StatusHeight));
+
+ var content = new Rect(window.x + SidebarWidth + 32, window.y + 28,
+ Width - SidebarWidth - 64, Height - StatusHeight - 52);
+ GUILayout.BeginArea(content);
+ if (versionWarning) DrawVersionWarning(mp);
+ else if (pendingJoin) DrawJoinConfirm(mp);
+ else if (mp.IsInSession) DrawSession(mp);
+ else if (page == Page.P2P) DrawP2P(mp);
+ else DrawServerBrowser(mp);
+ GUILayout.EndArea();
+
+ DrawStatusBar(mp, new Rect(window.x, window.yMax - StatusHeight, Width, StatusHeight));
+ }
+
+ // ---------------------------------------------------------------- Bereiche
+
+ void DrawSidebar(Rect r)
+ {
+ GUI.Box(r, GUIContent.none, sidebarBox);
+ // Logo + Schriftzug
+ Texture2D logo = Branding.LogoTexture;
+ float textX = r.x + 22;
+ if (logo != null)
+ {
+ GUI.DrawTexture(new Rect(r.x + 20, r.y + 20, 44, 44), logo);
+ textX = r.x + 72;
+ }
+ GUI.Label(new Rect(textX, r.y + 24, r.xMax - textX, 40), "MSCO", logoStyle);
+ GUI.Label(new Rect(r.x + 22, r.y + 70, r.width - 30, 20), "MY SUMMER CAR ONLINE", taglineStyle);
+
+ float y = r.y + 110;
+ if (NavButton(new Rect(r.x, y, r.width, 42), "P2P", page == Page.P2P)) page = Page.P2P;
+ y += 44;
+ if (NavButton(new Rect(r.x, y, r.width, 42), "Server Browser", page == Page.ServerBrowser)) page = Page.ServerBrowser;
+
+ GUI.Label(new Rect(r.x + 24, r.yMax - 30, r.width - 48, 20), "v" + MSCO.ModVersion, taglineStyle);
+ }
+
+ bool NavButton(Rect r, string text, bool active)
+ {
+ if (active) GUI.Box(new Rect(r.x + 4, r.y + 8, 4, r.height - 16), GUIContent.none, accentBox); // Akzentbalken links
+ return GUI.Button(r, text, active ? navActiveStyle : navStyle);
+ }
+
+ void DrawP2P(MultiplayerManager mp)
+ {
+ GUILayout.Label("P2P", titleStyle);
+ GUILayout.Label("Host a game from your own save and invite up to " + (MultiplayerManager.MaxPlayers - 1) +
+ " friends through Steam.", textStyle);
+ GUILayout.Space(18);
+
+ if (mp.SteamAvailable)
+ {
+ if (GUILayout.Button("HOST GAME", primaryStyle, GUILayout.Height(46), GUILayout.Width(250)))
+ mp.HostSteam();
+ GUILayout.Space(14);
+ GUILayout.Label("To join a friend: accept their invite in Steam, or right-click them in your " +
+ "Steam friends list and choose \"Join Game\".", textStyle);
+ }
+ else
+ {
+ GUILayout.Label("Steam is not running - start the game through Steam to host or join.", textStyle);
+ }
+ }
+
+ void DrawServerBrowser(MultiplayerManager mp)
+ {
+ ServerBrowser browser = mp.Browser;
+ // Beim ersten Öffnen laden, danach automatisch alle 15 s (Spielerzahl, Ping, neue Server)
+ if (!browserLoaded || (!browser.Loading && Time.realtimeSinceStartup - browser.LastRefresh > ServerBrowser.AutoRefreshSeconds))
+ {
+ browserLoaded = true;
+ browser.Refresh(mp);
+ }
+
+ // Server, die auf die direkte Abfrage nicht antworten (z.B. gerade geschlossen), gar nicht zeigen
+ List all = browser.Servers.FindAll(s => !s.Offline);
+
+ // Server-Profil (Klick auf einen Server)
+ if (selectedServer != null)
+ {
+ ServerListEntry selected = all.Find(s => s.Address + ":" + s.Port == selectedServer);
+ DrawServerProfile(mp, selected);
+ return;
+ }
+
+ GUILayout.BeginHorizontal();
+ // Titel darf nicht vom REFRESH-Button zusammengedrückt werden (sonst Zeilenumbruch)
+ GUILayout.Label("Server Browser", titleStyle, GUILayout.ExpandWidth(true));
+ if (GUILayout.Button(browser.Loading ? "LOADING..." : "REFRESH", tabStyle, GUILayout.Height(28), GUILayout.Width(110)))
+ browser.Refresh(mp);
+ GUILayout.EndHorizontal();
+
+ // Suche + Filter
+ GUILayout.BeginHorizontal();
+ searchInput = GUILayout.TextField(searchInput, 40, fieldStyle, GUILayout.Height(26), GUILayout.Width(170));
+ if (string.IsNullOrEmpty(searchInput))
+ {
+ Rect field = GUILayoutUtility.GetLastRect();
+ GUI.Label(new Rect(field.x + 10, field.y, field.width, field.height), "Search...", placeholderStyle);
+ }
+ GUILayout.Space(12);
+ showSteam = Chip(showSteam, "STEAM");
+ GUILayout.Space(6);
+ showNonSteam = Chip(showNonSteam, "NON-STEAM");
+ GUILayout.Space(6);
+ hideFull = Chip(hideFull, "HIDE FULL");
+ GUILayout.EndHorizontal();
+ GUILayout.Space(6);
+
+ string search = (searchInput ?? "").Trim().ToLower();
+ List servers = all.FindAll(s =>
+ (s.RequireSteam ? showSteam : showNonSteam) &&
+ (!hideFull || s.Players < s.MaxPlayers) &&
+ (search.Length == 0 || (s.Name ?? "").ToLower().Contains(search) || (s.Owner ?? "").ToLower().Contains(search)));
+
+ // Tabellenkopf
+ Rect header = GUILayoutUtility.GetRect(10, 26, GUILayout.ExpandWidth(true));
+ GUI.Box(header, GUIContent.none, rowBox);
+ GUI.Label(new Rect(header.x + 12, header.y, header.width * 0.48f, header.height), "SERVER NAME", headerCellStyle);
+ GUI.Label(new Rect(header.x + header.width * 0.5f, header.y, 70, header.height), "PLAYERS", headerCellStyle);
+ GUI.Label(new Rect(header.x + header.width * 0.62f, header.y, 70, header.height), "TYPE", headerCellStyle);
+ GUI.Label(new Rect(header.x + header.width * 0.76f, header.y, 50, header.height), "PING", headerCellStyle);
+
+ serverScroll = GUILayout.BeginScrollView(serverScroll, GUILayout.Height(140));
+ if (browser.Error != null) GUILayout.Label(browser.Error, textStyle);
+ if (servers.Count == 0)
+ GUILayout.Label(browser.Loading ? "Loading servers..." : all.Count > 0 ? "No servers match your filters." : "No servers online right now.", textStyle);
+ foreach (ServerListEntry s in servers)
+ {
+ Rect row = GUILayoutUtility.GetRect(10, 34, GUILayout.ExpandWidth(true));
+ GUI.Box(new Rect(row.x, row.y + 2, row.width, row.height - 4), GUIContent.none, rowBox);
+
+ // Klick auf die Zeile (außer JOIN) öffnet das Server-Profil
+ if (GUI.Button(new Rect(row.x, row.y, row.width - 82, row.height), GUIContent.none, GUIStyle.none))
+ selectedServer = s.Address + ":" + s.Port;
+
+ GUI.DrawTexture(new Rect(row.x + 10, row.y + 13, 8, 8), s.Ping < 0 ? texPending : texOnline);
+ float nameX = row.x + 26;
+ if (s.IsLocal)
+ {
+ // Server auf diesem PC (wie bei FiveM "localhost")
+ nameX += Pill(nameX, row, "LOCAL", pillStyle);
+ }
+ if (!string.IsNullOrEmpty(s.Version) && s.Version != MSCO.ModVersion)
+ {
+ nameX += Pill(nameX, row, "v" + s.Version, pillStyle); // abweichende Version
+ }
+ GUI.Label(new Rect(nameX, row.y, row.x + row.width * 0.49f - nameX, row.height), s.Name, rowNameStyle);
+ GUI.Label(new Rect(row.x + row.width * 0.5f, row.y, 70, row.height), s.Players + "/" + s.MaxPlayers, rowNameStyle);
+ Pill(row.x + row.width * 0.62f, row, s.RequireSteam ? "STEAM" : "NO STEAM", s.RequireSteam ? pillStyle : pillMutedStyle);
+ GUI.Label(new Rect(row.x + row.width * 0.76f, row.y, 50, row.height), s.Ping < 0 ? "..." : s.Ping + " ms", rowNameStyle);
+
+ // Andere Version: trotzdem joinbar, beim Verbinden kommt eine Warnung
+ GUI.enabled = CanJoin(mp, s);
+ if (GUI.Button(new Rect(row.xMax - 76, row.y + 4, 70, row.height - 8), s.Players >= s.MaxPlayers ? "FULL" : "JOIN", primaryStyle))
+ mp.JoinServer(s.Address, s.Port, s.Name, s.MaxPlayers);
+ GUI.enabled = true;
+ }
+ GUILayout.EndScrollView();
+
+ GUILayout.FlexibleSpace();
+ GUILayout.BeginHorizontal();
+ if (!mp.SteamAvailable)
+ {
+ // Ohne Steam: eigener Name (nur für Non-Steam-Server)
+ GUILayout.BeginVertical(GUILayout.Width(150));
+ GUILayout.Label("YOUR NAME", captionStyle);
+ DrawNameField(mp, 150);
+ GUILayout.EndVertical();
+ GUILayout.Space(10);
+ }
+ GUILayout.BeginVertical();
+ GUILayout.Label("DIRECT CONNECT", captionStyle);
+ GUILayout.BeginHorizontal();
+ directInput = GUILayout.TextField(directInput, fieldStyle, GUILayout.Height(34));
+ GUILayout.Space(10);
+ if (GUILayout.Button("CONNECT", primaryStyle, GUILayout.Height(34), GUILayout.Width(110)))
+ {
+ string host;
+ int port;
+ if (ServerBrowser.TryParseAddress(directInput, out host, out port))
+ {
+ PlayerPrefs.SetString(PrefDirect, directInput.Trim());
+ mp.JoinServer(host, port);
+ }
+ }
+ GUILayout.EndHorizontal();
+ GUILayout.EndVertical();
+ GUILayout.EndHorizontal();
+ }
+
+ static bool CanJoin(MultiplayerManager mp, ServerListEntry s)
+ {
+ return s.Players < s.MaxPlayers && (!s.RequireSteam || mp.SteamAvailable);
+ }
+
+ void DrawNameField(MultiplayerManager mp, float width)
+ {
+ if (nameInput == null) nameInput = mp.PlayerName;
+ string edited = GUILayout.TextField(nameInput, 24, fieldStyle, GUILayout.Height(34), GUILayout.Width(width));
+ if (edited != nameInput)
+ {
+ nameInput = edited;
+ if (edited.Trim().Length > 0) mp.PlayerName = edited.Trim();
+ }
+ }
+
+ /// Server-Profil wie bei FiveM: wem der Server gehört, Version, Spieler, Laufzeit ...
+ void DrawServerProfile(MultiplayerManager mp, ServerListEntry s)
+ {
+ if (GUILayout.Button("◂ BACK", tabStyle, GUILayout.Height(26), GUILayout.Width(90)))
+ {
+ selectedServer = null;
+ return;
+ }
+ GUILayout.Space(6);
+
+ if (s == null)
+ {
+ GUILayout.Label("Server offline", titleStyle);
+ GUILayout.Label("This server does not respond anymore.", textStyle);
+ return;
+ }
+
+ GUILayout.Label(s.Name, titleStyle);
+ GUILayout.BeginHorizontal();
+ GUILayout.Label(s.RequireSteam ? "STEAM" : "NON-STEAM", badgeStyle, GUILayout.ExpandWidth(false));
+ if (s.IsLocal) { GUILayout.Space(10); GUILayout.Label("LOCAL", badgeStyle, GUILayout.ExpandWidth(false)); }
+ GUILayout.FlexibleSpace();
+ GUILayout.EndHorizontal();
+ if (!string.IsNullOrEmpty(s.Description)) GUILayout.Label(s.Description, textStyle);
+ GUILayout.Space(10);
+
+ bool versionOk = s.Version == MSCO.ModVersion;
+ GUILayout.BeginHorizontal();
+ GUILayout.BeginVertical(GUILayout.Width(250));
+ InfoRow("HOSTED BY", string.IsNullOrEmpty(s.Owner) ? "unknown" : s.Owner, rowNameStyle);
+ InfoRow("VERSION", "v" + s.Version + (versionOk ? "" : " (you: v" + MSCO.ModVersion + ")"), versionOk ? rowNameStyle : badgeStyle);
+ InfoRow("STEAM", s.RequireSteam ? "Required" : "Not required", rowNameStyle);
+ InfoRow("ONLINE FOR", s.UptimeSeconds >= 0 ? FormatUptime(s.UptimeSeconds) : "-", rowNameStyle);
+ GUILayout.EndVertical();
+ GUILayout.BeginVertical();
+ InfoRow("PLAYERS", s.Players + " / " + s.MaxPlayers, rowNameStyle);
+ InfoRow("PING", s.Ping < 0 ? "-" : s.Ping + " ms", rowNameStyle);
+ InfoRow("ADDRESS", s.Address + ":" + s.Port, rowNameStyle);
+ GUILayout.EndVertical();
+ GUILayout.EndHorizontal();
+
+ // Spielerliste
+ if (s.PlayerNames != null && s.PlayerNames.Count > 0)
+ {
+ GUILayout.Space(4);
+ GUILayout.Label("ONLINE", captionStyle);
+ GUILayout.Label(string.Join(", ", s.PlayerNames.ToArray()), textStyle);
+ }
+
+ GUILayout.FlexibleSpace();
+ GUILayout.BeginHorizontal();
+ GUI.enabled = CanJoin(mp, s);
+ if (GUILayout.Button(s.Players >= s.MaxPlayers ? "SERVER FULL" : "JOIN SERVER", primaryStyle, GUILayout.Height(42), GUILayout.Width(190)))
+ mp.JoinServer(s.Address, s.Port, s.Name, s.MaxPlayers);
+ GUI.enabled = true;
+ GUILayout.Space(12);
+ if (s.RequireSteam && !mp.SteamAvailable)
+ GUILayout.Label("This server requires Steam.", textStyle);
+ else if (!mp.SteamAvailable)
+ {
+ GUILayout.Label("NAME", captionStyle, GUILayout.Width(42));
+ DrawNameField(mp, 150);
+ }
+ GUILayout.EndHorizontal();
+ }
+
+ /// Kleines abgerundetes Abzeichen, so breit wie sein Text. Gibt die belegte Breite zurück.
+ static float Pill(float x, Rect row, string text, GUIStyle style)
+ {
+ Vector2 size = style.CalcSize(new GUIContent(text));
+ GUI.Label(new Rect(x, row.y + (row.height - 18) / 2f, size.x, 18), text, style);
+ return size.x + 8;
+ }
+
+ /// Filter-Chip (an = orange umrandet), ersetzt die Unity-Häkchen.
+ bool Chip(bool on, string text)
+ {
+ if (GUILayout.Button(text, on ? chipOnStyle : chipOffStyle, GUILayout.Height(26), GUILayout.ExpandWidth(false))) on = !on;
+ return on;
+ }
+
+ void InfoRow(string caption, string value, GUIStyle valueStyle)
+ {
+ GUILayout.Label(caption, captionStyle);
+ GUILayout.Label(value, valueStyle);
+ GUILayout.Space(2);
+ }
+
+ static string FormatUptime(int seconds)
+ {
+ int days = seconds / 86400, hours = seconds / 3600 % 24, minutes = seconds / 60 % 60;
+ if (days > 0) return days + "d " + hours + "h";
+ if (hours > 0) return hours + "h " + minutes + "m";
+ return Mathf.Max(1, minutes) + " min";
+ }
+
+ /// Server hat eine andere Version: warnen, Verbinden nur auf Wunsch.
+ void DrawVersionWarning(MultiplayerManager mp)
+ {
+ MultiplayerManager.VersionWarning w = mp.PendingVersionWarning;
+ GUILayout.Label("Version mismatch", titleStyle);
+ GUILayout.Label("This server runs a different MSCO version than you. You can still join, but things may not " +
+ "sync correctly (players, items, cars). For the best experience use the same version.", textStyle);
+ GUILayout.Space(16);
+
+ GUILayout.Label("SERVER", captionStyle);
+ GUILayout.Label(w.Name + " (" + w.Host + ":" + w.Port + ")", rowNameStyle);
+ GUILayout.Space(8);
+ GUILayout.Label("VERSIONS", captionStyle);
+ GUILayout.Label("Server v" + w.ServerVersion + " - You v" + MSCO.ModVersion, rowTagStyle);
+ GUILayout.Space(22);
+
+ GUILayout.BeginHorizontal();
+ if (GUILayout.Button("JOIN ANYWAY", primaryStyle, GUILayout.Height(42), GUILayout.Width(170)))
+ mp.ConfirmVersionWarning();
+ GUILayout.Space(10);
+ if (GUILayout.Button("CANCEL", secondaryStyle, GUILayout.Height(42), GUILayout.Width(170)))
+ mp.CancelVersionWarning();
+ GUILayout.EndHorizontal();
+ }
+
+ /// Nachfrage nach einem JOIN-Link der Website (keine Webseite soll einen ungefragt verbinden).
+ void DrawJoinConfirm(MultiplayerManager mp)
+ {
+ GUILayout.Label("Join server?", titleStyle);
+ GUILayout.Label("You opened a join link from the server list.", textStyle);
+ GUILayout.Space(18);
+
+ GUILayout.Label("SERVER ADDRESS", captionStyle);
+ GUILayout.Label(mp.PendingJoinHost + ":" + mp.PendingJoinPort, rowNameStyle);
+ GUILayout.Space(24);
+
+ if (!mp.SteamAvailable)
+ GUILayout.Label("Steam is required to join servers - start the game through Steam.", textStyle);
+
+ GUILayout.BeginHorizontal();
+ GUI.enabled = mp.SteamAvailable;
+ if (GUILayout.Button("JOIN", primaryStyle, GUILayout.Height(42), GUILayout.Width(170)))
+ mp.ConfirmPendingJoin();
+ GUI.enabled = true;
+ GUILayout.Space(10);
+ if (GUILayout.Button("CANCEL", secondaryStyle, GUILayout.Height(42), GUILayout.Width(170)))
+ mp.CancelPendingJoin();
+ GUILayout.EndHorizontal();
+ }
+
+ void DrawSession(MultiplayerManager mp)
+ {
+ GUILayout.Label(mp.IsHost ? "Hosting" : "Connected", titleStyle);
+ GUILayout.Label(mp.IsHost
+ ? "Your game is open. Start when everyone has joined."
+ : "Waiting for the host to start the game.", textStyle);
+ GUILayout.Space(16);
+
+ GUILayout.Label("PLAYERS " + mp.Players.Count + "/" + MultiplayerManager.MaxPlayers, captionStyle);
+ playerScroll = GUILayout.BeginScrollView(playerScroll, GUILayout.Height(170));
+ foreach (PlayerInfo p in mp.Players)
+ {
+ Rect row = GUILayoutUtility.GetRect(10, 32, GUILayout.ExpandWidth(true));
+ GUI.Box(new Rect(row.x, row.y, row.width, row.height - 4), GUIContent.none, rowBox);
+ GUI.DrawTexture(new Rect(row.x + 12, row.y + 11, 8, 8), texOnline);
+ GUI.Label(new Rect(row.x + 30, row.y, row.width - 150, row.height - 2), p.Name, rowNameStyle);
+ string tag = p.Id == NetSession.HostId ? "HOST" : "";
+ if (p.IsLocal) tag = tag.Length > 0 ? tag + " · YOU" : "YOU";
+ GUI.Label(new Rect(row.xMax - 120, row.y, 110, row.height - 2), tag, rowTagStyle);
+ }
+ GUILayout.EndScrollView();
+ GUILayout.Space(14);
+
+ GUILayout.BeginHorizontal();
+ if (mp.IsHost)
+ {
+ if (GUILayout.Button("START GAME", primaryStyle, GUILayout.Height(42), GUILayout.Width(150)))
+ mp.StartHostGame();
+ GUILayout.Space(10);
+ if (mp.IsSteamSession && mp.Players.Count < MultiplayerManager.MaxPlayers &&
+ GUILayout.Button("INVITE FRIENDS", secondaryStyle, GUILayout.Height(42), GUILayout.Width(150)))
+ mp.InviteFriends();
+ GUILayout.Space(10);
+ }
+ if (GUILayout.Button("DISCONNECT", secondaryStyle, GUILayout.Height(42), GUILayout.Width(150)))
+ mp.Disconnect();
+ GUILayout.EndHorizontal();
+ }
+
+ void DrawStatusBar(MultiplayerManager mp, Rect r)
+ {
+ GUI.Box(r, GUIContent.none, sidebarBox);
+ Texture2D dot = mp.IsPeerReady ? texOnline : mp.IsInSession ? texPending : texMuted;
+ GUI.DrawTexture(new Rect(r.x + 18, r.y + (r.height - 8) / 2f, 8, 8), dot);
+ GUI.Label(new Rect(r.x + 36, r.y, r.width - 180, r.height), mp.Status, statusStyle);
+ if (GUI.Button(new Rect(r.xMax - 118, r.y + 6, 106, r.height - 12), "MINIMIZE", tabStyle))
+ minimized = true;
+ }
+
+ void DrawMinimized(MultiplayerManager mp)
+ {
+ var r = new Rect((Screen.width - 220) / 2f, 0, 220, 30);
+ ScreenRect = r;
+ GUI.Box(r, GUIContent.none, sidebarBox);
+ GUI.Box(new Rect(r.x + 10, r.yMax - 3, r.width - 20, 3), GUIContent.none, accentBox);
+ Texture2D dot = mp.IsPeerReady ? texOnline : mp.IsInSession ? texPending : texMuted;
+ Texture2D logo = Branding.LogoTexture;
+ if (logo != null) GUI.DrawTexture(new Rect(r.x + 8, r.y + 4, 22, 22), logo);
+ GUI.DrawTexture(new Rect(r.x + 36, r.y + 11, 8, 8), dot);
+ if (GUI.Button(new Rect(r.x + 48, r.y, r.width - 48, r.height), "MSCO MULTIPLAYER ▾", tabStyle))
+ minimized = false;
+ }
+
+ // ---------------------------------------------------------------- Hilfen
+
+ void EnsureStyles()
+ {
+ if (stylesReady && texBackground != null) return;
+
+ // PlayerPrefs erst hier lesen (nicht im Konstruktor erlaubt)
+ directInput = PlayerPrefs.GetString(PrefDirect, "");
+
+ texBackground = Solid(Background);
+ texSidebar = Solid(Sidebar);
+ texAccent = Solid(Accent);
+ texOnline = UiTheme.Rounded(Online, 4); // runde Status-Punkte
+ texPending = UiTheme.Rounded(Pending, 4);
+ texMuted = UiTheme.Rounded(Muted, 4);
+ texRow = Solid(Field);
+ windowBox = UiTheme.Panel9(Background, 10);
+ sidebarBox = UiTheme.Panel9(Sidebar, 10);
+ rowBox = UiTheme.Panel9(Field, UiTheme.Radius);
+ accentBox = UiTheme.Panel9(Accent, 1);
+
+ pillStyle = UiTheme.Panel9(new Color(Accent.r, Accent.g, Accent.b, 0.18f), 4);
+ pillStyle.normal.textColor = Accent;
+ pillStyle.fontSize = 10;
+ pillStyle.fontStyle = FontStyle.Bold;
+ pillStyle.alignment = TextAnchor.MiddleCenter;
+ pillStyle.padding = new RectOffset(7, 7, 0, 0);
+ pillStyle.wordWrap = false;
+ pillMutedStyle = new GUIStyle(pillStyle);
+ pillMutedStyle.normal.background = UiTheme.Rounded(new Color(1f, 1f, 1f, 0.08f), 4);
+ pillMutedStyle.normal.textColor = Muted;
+
+ chipOnStyle = Button(new Color(Accent.r, Accent.g, Accent.b, 0.22f), new Color(Accent.r, Accent.g, Accent.b, 0.32f), AccentHover, 11, FontStyle.Bold);
+ chipOffStyle = Button(Field, FieldFocus, Muted, 11, FontStyle.Bold);
+ chipOnStyle.padding = chipOffStyle.padding = new RectOffset(12, 12, 0, 0);
+
+ logoStyle = Label(28, Color.white, FontStyle.Bold);
+ taglineStyle = Label(10, Muted, FontStyle.Bold);
+ titleStyle = Label(24, Color.white, FontStyle.Bold);
+ textStyle = Label(13, Muted, FontStyle.Normal);
+ textStyle.wordWrap = true;
+ captionStyle = Label(11, Muted, FontStyle.Bold);
+ captionStyle.margin.bottom = 6;
+ statusStyle = Label(12, Color.white, FontStyle.Normal);
+ statusStyle.alignment = TextAnchor.MiddleLeft;
+ headerCellStyle = Label(11, Muted, FontStyle.Bold);
+ headerCellStyle.alignment = TextAnchor.MiddleLeft;
+ rowNameStyle = Label(14, Color.white, FontStyle.Normal);
+ rowNameStyle.alignment = TextAnchor.MiddleLeft;
+ rowTagStyle = Label(11, Accent, FontStyle.Bold);
+ rowTagStyle.alignment = TextAnchor.MiddleRight;
+ badgeStyle = new GUIStyle(rowTagStyle);
+ badgeStyle.alignment = TextAnchor.MiddleLeft;
+ badgeStyle.padding = new RectOffset(6, 6, 2, 2);
+ placeholderStyle = Label(13, Muted, FontStyle.Italic);
+ placeholderStyle.alignment = TextAnchor.MiddleLeft;
+ toggleStyle = new GUIStyle(GUI.skin.toggle);
+ toggleStyle.normal.textColor = toggleStyle.onNormal.textColor = toggleStyle.hover.textColor = toggleStyle.onHover.textColor = Color.white;
+ toggleStyle.fontSize = 11;
+ toggleStyle.fontStyle = FontStyle.Bold;
+
+ navStyle = Button(Sidebar, Secondary, Muted, 14, FontStyle.Normal);
+ navStyle.alignment = TextAnchor.MiddleLeft;
+ navStyle.padding = new RectOffset(26, 10, 0, 0);
+ navActiveStyle = Button(Secondary, Secondary, Color.white, 14, FontStyle.Bold);
+ navActiveStyle.alignment = TextAnchor.MiddleLeft;
+ navActiveStyle.padding = new RectOffset(26, 10, 0, 0);
+
+ primaryStyle = Button(Accent, AccentHover, Color.white, 14, FontStyle.Bold);
+ secondaryStyle = Button(Secondary, SecondaryHover, Color.white, 14, FontStyle.Bold);
+ tabStyle = Button(Sidebar, Secondary, Muted, 11, FontStyle.Bold);
+
+ fieldStyle = UiTheme.Field(14);
+
+ stylesReady = true;
+ }
+
+ static GUIStyle Label(int size, Color color, FontStyle font)
+ {
+ var s = new GUIStyle(GUI.skin.label);
+ s.fontSize = size;
+ s.fontStyle = font;
+ s.normal.textColor = color;
+ return s;
+ }
+
+ static GUIStyle Button(Color normal, Color hover, Color text, int size, FontStyle font)
+ {
+ var s = new GUIStyle(GUI.skin.button);
+ // abgerundete Ecken (9-Slice), siehe UiTheme
+ s.normal.background = UiTheme.Rounded(normal);
+ s.hover.background = UiTheme.Rounded(hover);
+ s.active.background = UiTheme.Rounded(hover);
+ s.focused.background = UiTheme.Rounded(normal);
+ s.normal.textColor = s.hover.textColor = s.active.textColor = s.focused.textColor = text;
+ s.border = new RectOffset(UiTheme.Radius + 2, UiTheme.Radius + 2, UiTheme.Radius + 2, UiTheme.Radius + 2);
+ s.fontSize = size;
+ s.fontStyle = font;
+ return s;
+ }
+
+ static Texture2D Solid(Color color)
+ {
+ var tex = new Texture2D(1, 1);
+ tex.SetPixel(0, 0, color);
+ tex.Apply();
+ tex.hideFlags = HideFlags.HideAndDontSave;
+ return tex;
+ }
+
+ static Color Hex(int r, int g, int b)
+ {
+ return new Color(r / 255f, g / 255f, b / 255f, 1f);
+ }
+ }
+}
diff --git a/MSCO.cs b/MSCO.cs
index 461d440..92e7a25 100644
--- a/MSCO.cs
+++ b/MSCO.cs
@@ -5,7 +5,7 @@ namespace MSCO
{
public class MSCO : Mod
{
- public const string ModVersion = "1.0";
+ public const string ModVersion = "2.6";
public override string ID => "MSCO"; // Your (unique) mod ID
public override string Name => "MSCO"; // Your mod name
@@ -13,6 +13,8 @@ namespace MSCO
public override string Version => ModVersion; // Version
public override string Description => "Online mod by mbm.wtf"; // Short description of your mod
public override Game SupportedGames => Game.MySummerCar;
+ // Logo in der MSCLoader-Modliste (branding/logo-128.png, in die DLL eingebettet)
+ public override byte[] Icon { get { return Branding.LogoPng; } set { } }
bool commandAdded;
@@ -28,10 +30,18 @@ namespace MSCO
SetupFunction(Setup.ModSettings, Mod_Settings);
}
+ /// Taste zum Ein-/Aussteigen als Beifahrer (in den MSCLoader-Einstellungen änderbar).
+ internal static SettingsKeybind PassengerKey;
+ /// MSCO-Konsole (Standard F8) und Chat (Standard T), ebenfalls änderbar.
+ internal static SettingsKeybind ConsoleKey, ChatKey;
+
private void Mod_Settings()
{
// All settings should be created here.
// DO NOT put anything that isn't settings or keybinds in here!
+ PassengerKey = Keybind.Add("passenger", "Ride as passenger (toggle)", KeyCode.P);
+ ChatKey = Keybind.Add("chat", "Open chat", KeyCode.T);
+ ConsoleKey = Keybind.Add("console", "Open MSCO console", KeyCode.F8);
}
private void Mod_OnMenuLoad()
diff --git a/MSCO.csproj b/MSCO.csproj
index 6dda9ce..6c2e7b6 100644
--- a/MSCO.csproj
+++ b/MSCO.csproj
@@ -46,6 +46,14 @@
F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\MSCLoader.dll
False
+
+ F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\Newtonsoft.Json.dll
+ False
+
+
+ F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\Assembly-CSharp-firstpass.dll
+ False
+
F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\Assembly-CSharp.dll
False
@@ -64,12 +72,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ MSCO.logo.png
+
diff --git a/MenuInputBlocker.cs b/MenuInputBlocker.cs
new file mode 100644
index 0000000..aacb3ed
--- /dev/null
+++ b/MenuInputBlocker.cs
@@ -0,0 +1,37 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Das Hauptmenü reagiert über OnMouseEnter/OnMouseDown auf Collider in der 3D-Szene -
+ /// auch durch unser GUI hindurch (z.B. öffnet sich der Changelog). Solange die Maus über
+ /// unserem Fenster ist, werden die Maus-Events der Kameras abgeschaltet (eventMask = 0).
+ ///
+ public static class MenuInputBlocker
+ {
+ static readonly Dictionary savedMasks = new Dictionary();
+
+ public static bool IsBlocked { get { return savedMasks.Count > 0; } }
+
+ public static void SetBlocked(bool blocked)
+ {
+ if (blocked == IsBlocked) return;
+
+ if (blocked)
+ {
+ foreach (Camera cam in Camera.allCameras)
+ {
+ savedMasks[cam] = cam.eventMask;
+ cam.eventMask = 0;
+ }
+ }
+ else
+ {
+ foreach (KeyValuePair kv in savedMasks)
+ if (kv.Key != null) kv.Key.eventMask = kv.Value;
+ savedMasks.Clear();
+ }
+ }
+ }
+}
diff --git a/MoneySync.cs b/MoneySync.cs
new file mode 100644
index 0000000..30502da
--- /dev/null
+++ b/MoneySync.cs
@@ -0,0 +1,66 @@
+using System;
+using HutongGames.PlayMaker;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Gemeinsame Kasse: Ändert sich das Geld (globale Variable "PlayerMoney") bei einem Spieler,
+ /// wird nur die Änderung verschickt und bei allen anderen dazugerechnet. So gehen gleichzeitige
+ /// Einkäufe/Einnahmen nicht verloren, und niemand überschreibt den Kontostand eines anderen.
+ ///
+ public class MoneySync
+ {
+ const string MoneyVariable = "PlayerMoney";
+ const float MinChange = 0.01f;
+
+ FsmFloat money;
+ float last;
+ bool tracking;
+ float pending; // Änderungen, die ankamen, bevor das Geld aus dem Spielstand geladen war
+
+ /// Szenenwechsel / Verbindungsende.
+ public void Reset()
+ {
+ money = null;
+ tracking = false;
+ pending = 0f;
+ }
+
+ ///
+ /// Erst beobachten, wenn das Spiel fertig geladen ist - sonst würde das Laden des
+ /// Kontostands aus dem Spielstand als "Einnahme" verschickt.
+ ///
+ public void Begin()
+ {
+ if (tracking) return;
+ money = FsmVariables.GlobalVariables.FindFsmFloat(MoneyVariable);
+ if (money == null) return;
+ money.Value += pending;
+ pending = 0f;
+ last = money.Value;
+ tracking = true;
+ }
+
+ public void Update(Action send)
+ {
+ if (!tracking || money == null) return;
+ float delta = money.Value - last;
+ if (Mathf.Abs(delta) < MinChange) return;
+ last = money.Value;
+ send(MsgType.MoneyDelta, NetSession.Build(w => w.Write(delta)));
+ }
+
+ /// Änderung von einem anderen Spieler (oder vom Server) dazurechnen.
+ public void Apply(float delta)
+ {
+ if (!tracking || money == null)
+ {
+ pending += delta;
+ return;
+ }
+ money.Value += delta;
+ last += delta; // nicht als eigene Änderung zurückschicken
+ }
+ }
+}
diff --git a/MpCommand.cs b/MpCommand.cs
index 355e7de..91e0f34 100644
--- a/MpCommand.cs
+++ b/MpCommand.cs
@@ -2,37 +2,15 @@ using MSCLoader;
namespace MSCO
{
- /// Konsolenbefehl: mp host [port] | mp join [port] | mp stop
+ /// MSCLoader-Konsole: "mp <befehl>" - gleiche Befehle wie in der F8-Konsole.
public class MpCommand : ConsoleCommand
{
public override string Name => "mp";
- public override string Help => "mp host [port] | mp join [port] | mp stop";
+ public override string Help => "MSCO commands, e.g. mp help | mp connect | mp save (or press F8)";
public override void Run(string[] args)
{
- var mp = MultiplayerManager.Instance;
- if (mp == null) { ModConsole.Print("[MSCO] Not ready yet"); return; }
- if (args.Length == 0) { ModConsole.Print(Help); return; }
-
- int port = MultiplayerManager.DefaultPort;
- switch (args[0].ToLower())
- {
- case "host":
- if (args.Length > 1) int.TryParse(args[1], out port);
- mp.Host(port);
- break;
- case "join":
- if (args.Length < 2) { ModConsole.Print("mp join [port]"); return; }
- if (args.Length > 2) int.TryParse(args[2], out port);
- mp.Join(args[1], port);
- break;
- case "stop":
- mp.Disconnect();
- break;
- default:
- ModConsole.Print(Help);
- break;
- }
+ Commands.Execute(args, line => ModConsole.Print("[MSCO] " + line));
}
}
}
diff --git a/MultiplayerManager.cs b/MultiplayerManager.cs
index 6761006..a8108ed 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 MSCLoader;
@@ -6,33 +6,78 @@ using UnityEngine;
namespace MSCO
{
+ public class PlayerInfo
+ {
+ public int Id;
+ public string Name;
+ public bool IsLocal;
+ public RemotePlayer Avatar;
+ public bool SaveSent; // Host: Spielstand für diesen Start schon bekommen
+ }
+
///
- /// Lebt über Szenenwechsel hinweg (DontDestroyOnLoad): Lobby im Hauptmenü,
- /// Spielstand-Übertragung beim Start und Positions-Sync im Spiel.
+ /// Lebt über Szenenwechsel hinweg (DontDestroyOnLoad): Launcher im Hauptmenü,
+ /// Spielerliste, Spielstand-Übertragung beim Start und Sync im Spiel.
+ /// Der Host ist Verteiler: Clients schicken alles an ihn, er leitet an die anderen weiter.
///
public class MultiplayerManager : MonoBehaviour
{
public static MultiplayerManager Instance;
public const int DefaultPort = 7777;
+ public const int MaxPlayers = 3;
+ const float PingInterval = 2f;
+ const float LateJoinSaveDelay = 1.5f; // Host: Zeit zum Speichern, bevor der Spielstand verschickt wird
+ const float TeleportSideOffset = 1.5f; // später Beigetretene erscheinen so weit neben dem Host
const float SendInterval = 0.05f; // 20 Updates pro Sekunde
+ const string PrefName = "MSCO_Name";
+ const float NameTagHideDistance = 15f; // ab hier kein Namensschild mehr
+ const float NameTagFadeRange = 5f; // die letzten Meter davor wird es ausgeblendet
NetSession session;
- bool peerReady; // Handshake abgeschlossen
- bool saveSentThisLoad; // Host: Spielstand für diesen Start schon verschickt
+ int localId;
+ bool handshakeDone; // Client: Welcome erhalten
bool receivingSave; // Client: Spielstand wird gerade empfangen
+ bool teleportToHost; // Client: später beigetreten, nach dem Laden zum Host
+ bool dedicated; // verbunden mit einem dedizierten Server
+ bool connectingToServer; // Verbindungsaufbau zu einem dedizierten Server läuft
+ bool readySent; // Server: Weltzustand schon angefordert
+ byte[] pendingSnapshot; // Server: Weltzustand, wartet auf Objekt-Suchlauf
+ const int SnapshotEventsPerFrame = 20;
+ readonly ServerBrowser browser = new ServerBrowser();
string status = "";
+ string playerName;
- string ipInput = "127.0.0.1";
- string portInput = DefaultPort.ToString();
+ readonly Dictionary players = new Dictionary();
+ readonly LobbyUI lobby = new LobbyUI();
+ readonly ObjectSync objects = new ObjectSync();
+ readonly VehicleFx vehicleFx = new VehicleFx();
+ readonly WorldSync worldSync = new WorldSync();
+ readonly DiscordPresence discord = new DiscordPresence();
+ readonly MoneySync moneySync = new MoneySync();
+ readonly DoorSync doorSync = new DoorSync();
+ readonly Passenger passenger = new Passenger();
+ readonly SteamLobby steam = new SteamLobby();
+ readonly VehicleStateSync vehicleState = new VehicleStateSync();
+ readonly Overlays overlays = new Overlays();
+ bool isAdmin; // Server hat uns als Admin gemeldet
+ readonly HashSet bannedNames = new HashSet(); // P2P-Host: für diese Sitzung gebannt
+ Steamworks.CSteamID pendingLobby; // Einladung, die noch angenommen werden muss
+ Steamworks.CSteamID steamHost; // Client: Steam-ID des Hosts
+ float nextPing;
+ float savingMessageUntil;
+ GUIStyle hudStyle, savingStyle;
+ Texture2D hudBackground;
+ GUIStyle hudBox;
PlayMakerFSM continueFsm;
FsmState continueState;
float nextContinueSearch;
Transform localPlayer;
- RemotePlayer remotePlayer;
+ Camera playerCamera;
float sendTimer;
float nextPlayerSearch;
+ GUIStyle nameTagStyle;
public static void Ensure()
{
@@ -45,11 +90,58 @@ namespace MSCO
void Awake()
{
Application.runInBackground = true;
+ playerName = PlayerPrefs.GetString(PrefName, System.Environment.UserName);
+ objects.Send = SendGame;
+ FsmSync.Init();
+ SafetyPatches.Apply(); // verhindert den Absturz durch Kreis in der Objekt-Hierarchie
+ discord.Start();
+
+ steam.Created += OnSteamLobbyCreated;
+ steam.Entered += OnSteamLobbyEntered;
+ steam.JoinRequested += OnSteamJoinRequested;
+ steam.MemberLeft += OnSteamMemberLeft;
+ steam.Failed += SetStatus;
+ steam.Init();
+ // Spiel über eine Steam-Einladung gestartet?
+ pendingLobby = SteamLobby.LobbyFromCommandLine();
+
+ // JOIN-Links der Website (msco://connect/...) und: Spiel über so einen Link gestartet?
+ JoinLinks.Register();
+ string linkHost;
+ int linkPort;
+ if (JoinLinks.TryGetFromCommandLine(out linkHost, out linkPort))
+ {
+ PendingJoinHost = linkHost;
+ PendingJoinPort = linkPort;
+ }
// Falls das Spiel beim letzten Mal abgestürzt ist: eigenen Spielstand zurückholen
TryRestoreBackup();
}
- // ---------------------------------------------------------------- Verbindung
+ // ---------------------------------------------------------------- Öffentlich (Launcher/Konsole)
+
+ public bool IsInSession { get { return session != null; } }
+ public bool IsHost { get { return session != null && session.IsHost; } }
+ public string Status { get { return status; } }
+ public int LocalId { get { return localId; } }
+ public ICollection Players { get { return players.Values; } }
+
+ /// Mindestens ein Mitspieler da und Handshake fertig.
+ public bool IsPeerReady
+ {
+ get { return session != null && (session.IsHost ? players.Count > 1 : handshakeDone); }
+ }
+
+ public string PlayerName
+ {
+ get { return playerName; }
+ set
+ {
+ playerName = string.IsNullOrEmpty(value) ? "Player" : value;
+ if (playerName.Length > 24) playerName = playerName.Substring(0, 24);
+ PlayerPrefs.SetString(PrefName, playerName);
+ }
+ }
public void Host(int port)
{
@@ -57,14 +149,282 @@ namespace MSCO
try
{
session = NetSession.StartHost(port);
- SetStatus("Hosting on port " + port + " - waiting for player...");
+ localId = NetSession.HostId;
+ objects.LocalId = localId;
+ AddPlayer(localId, playerName, true);
+ SetStatus("Hosting on port " + port + " - waiting for players...");
}
catch (System.Exception e)
{
+ session = null;
SetStatus("Could not host: " + e.Message);
}
}
+ public bool IsDedicated { get { return dedicated; } }
+
+ /// Für "mp rig": welche Figur jeder Mitspieler hat.
+ public List DescribeRigs()
+ {
+ var lines = new List();
+ foreach (PlayerInfo p in players.Values)
+ if (p.Avatar != null) lines.Add(p.Name + ": " + p.Avatar.ModelDescription);
+ if (lines.Count == 0) lines.Add("No other players in the game right now.");
+ return lines;
+ }
+
+ public List DescribeVehicles()
+ {
+ var lines = objects.DescribeVehicles();
+ lines.Add("tracked doors/gates/hoods: " + doorSync.Count);
+ lines.Add("synced vehicle values (fuel, oil, bolts, wear ...): " + vehicleState.Count);
+ return lines;
+ }
+
+ // ---------------------------------------------------------------- Chat & Admin
+
+ /// Chatnachricht an alle (T).
+ public void SendChat(string text)
+ {
+ if (session == null) { overlays.AddChat("", "Not connected - chat only works in multiplayer", true); return; }
+ if (text.Length > 150) text = text.Substring(0, 150);
+ overlays.AddChat(OwnName(), text);
+ SendGame(MsgType.Chat, NetSession.Build(w => w.Write(text)));
+ }
+
+ /// /admin: Spieler kicken/bannen (Server: nur Admins aus server.json, P2P: nur der Host).
+ public void OpenAdminMenu()
+ {
+ if (session == null) { overlays.Log("Not connected."); return; }
+ if (dedicated)
+ {
+ if (!isAdmin) { overlays.Log("You are not an admin on this server."); return; }
+ overlays.OpenAdmin(null);
+ RequestAdminList();
+ }
+ else if (session.IsHost) overlays.OpenAdmin(LocalAdminList());
+ else overlays.Log("Only the host can kick or ban players.");
+ }
+
+ public void RequestAdminList()
+ {
+ if (session == null) return;
+ if (dedicated) session.SendTo(NetSession.HostId, MsgType.AdminListRequest);
+ else if (session.IsHost) overlays.SetAdminPlayers(LocalAdminList());
+ }
+
+ List LocalAdminList()
+ {
+ var list = new List();
+ foreach (PlayerInfo p in players.Values)
+ list.Add(new AdminPlayer { Id = p.Id, Name = p.Name, IsAdmin = p.IsLocal });
+ return list;
+ }
+
+ public void AdminAction(bool ban, int playerId, string reason)
+ {
+ if (session == null) return;
+ reason = (reason ?? "").Trim();
+ if (dedicated)
+ {
+ session.SendTo(NetSession.HostId, MsgType.AdminAction, NetSession.Build(w =>
+ {
+ w.Write((byte)(ban ? 2 : 1));
+ w.Write(playerId);
+ w.Write(reason);
+ }));
+ return;
+ }
+ PlayerInfo p;
+ if (!session.IsHost || !players.TryGetValue(playerId, out p) || p.IsLocal) return;
+ if (ban) bannedNames.Add(p.Name.ToLower());
+ string what = ban ? "banned" : "kicked";
+ string text = p.Name + " was " + what + (reason.Length > 0 ? " (" + reason + ")" : "");
+ Reject(playerId, "You were " + what + " by the host" + (reason.Length > 0 ? ": " + reason : ""));
+ RemovePlayer(playerId);
+ session.SendToAll(MsgType.PlayerLeft, NetSession.Build(w => w.Write(playerId)));
+ overlays.AddChat("", text, true);
+ SendGame(MsgType.Chat, NetSession.Build(w => w.Write("* " + text)));
+ overlays.SetAdminPlayers(LocalAdminList());
+ }
+
+ /// Server aus einem JOIN-Link der Website, wartet auf Bestätigung im Hauptmenü.
+ public string PendingJoinHost { get; private set; }
+ public int PendingJoinPort { get; private set; }
+
+ public void ConfirmPendingJoin()
+ {
+ string host = PendingJoinHost;
+ int port = PendingJoinPort;
+ PendingJoinHost = null;
+ if (host != null) JoinServer(host, port);
+ }
+
+ public void CancelPendingJoin()
+ {
+ PendingJoinHost = null;
+ }
+
+ ///
+ /// Wer Uhrzeit und Wetter vorgibt: bei P2P der Host, auf dem dedizierten Server der Spieler
+ /// mit der kleinsten ID (am längsten verbunden). Alle berechnen das gleich aus der Spielerliste.
+ ///
+ bool IsWorldAuthority
+ {
+ get
+ {
+ if (session == null) return false;
+ if (!dedicated) return session.IsHost;
+ foreach (int id in players.Keys)
+ if (id < localId) return false;
+ return true;
+ }
+ }
+ public ServerBrowser Browser { get { return browser; } }
+
+ /// Server mit anderer Version: Warnung im Launcher, Verbinden nur nach Bestätigung.
+ public class VersionWarning
+ {
+ public string Host;
+ public int Port;
+ public string Name;
+ public int MaxPlayers;
+ public string ServerVersion;
+ }
+
+ public VersionWarning PendingVersionWarning { get; private set; }
+ bool checkingServer;
+
+ ///
+ /// Mit einem dedizierten Server verbinden (Server-Browser, Direct Connect, JOIN-Link). Nur mit Steam.
+ /// Vorher wird der Server nach seiner Version gefragt - weicht sie ab, kommt eine Warnung.
+ ///
+ public void JoinServer(string host, int port, string displayName = null, int maxPlayers = 0)
+ {
+ if (checkingServer) return;
+ checkingServer = true;
+ PendingVersionWarning = null;
+ SetStatus("Checking " + host + ":" + port + "...");
+ StartCoroutine(CheckThenJoin(host, port, displayName, maxPlayers));
+ }
+
+ System.Collections.IEnumerator CheckThenJoin(string host, int port, string displayName, int maxPlayers)
+ {
+ ServerBrowser.ServerInfo info = new ServerBrowser.ServerInfo();
+ bool ok = false, done = false;
+ var thread = new System.Threading.Thread(() =>
+ {
+ ok = ServerBrowser.QueryInfo(host, port, 4000, out info);
+ done = true;
+ });
+ thread.IsBackground = true;
+ thread.Start();
+ while (!done) yield return null;
+ checkingServer = false;
+
+ if (!ok)
+ {
+ SetStatus("Server " + host + ":" + port + " is not reachable");
+ yield break;
+ }
+ if (info.RequireSteam && !SteamLobby.IsAvailable)
+ {
+ SetStatus("This server requires Steam - start the game through Steam");
+ yield break;
+ }
+ string name = string.IsNullOrEmpty(displayName) ? info.Name : displayName;
+ int max = info.MaxPlayers > 0 ? info.MaxPlayers : maxPlayers;
+
+ if (info.Version != MSCO.ModVersion)
+ {
+ PendingVersionWarning = new VersionWarning { Host = host, Port = port, Name = name, MaxPlayers = max, ServerVersion = info.Version };
+ SetStatus("Version mismatch: server v" + info.Version + ", you v" + MSCO.ModVersion);
+ yield break;
+ }
+ ConnectToServer(host, port, name, max);
+ }
+
+ public void ConfirmVersionWarning()
+ {
+ VersionWarning w = PendingVersionWarning;
+ PendingVersionWarning = null;
+ if (w != null) ConnectToServer(w.Host, w.Port, w.Name, w.MaxPlayers);
+ }
+
+ public void CancelVersionWarning()
+ {
+ PendingVersionWarning = null;
+ SetStatus("");
+ }
+
+ void ConnectToServer(string host, int port, string displayName, int maxPlayers)
+ {
+ Join(host, port);
+ if (session == null) return;
+ connectingToServer = true;
+ serverName = string.IsNullOrEmpty(displayName) ? host + ":" + port : displayName;
+ serverMaxPlayers = maxPlayers;
+ }
+
+ // ---------------------------------------------------------------- Discord
+
+ string serverName; // Name des dedizierten Servers (für Discord)
+ int serverMaxPlayers;
+ string presenceKey;
+ long presenceStart;
+ float nextPresenceUpdate;
+
+ /// Discord-Anzeige aus dem aktuellen Zustand (alle 2 s, gesendet wird nur bei Änderung).
+ void UpdatePresence()
+ {
+ // Meldungen des Discord-Threads (verbunden, Fehler ...) in die Konsole
+ string discordLog;
+ while ((discordLog = discord.TakeLog()) != null) ModConsole.Print("[MSCO] " + discordLog);
+
+ if (Time.realtimeSinceStartup < nextPresenceUpdate) return;
+ nextPresenceUpdate = Time.realtimeSinceStartup + 2f;
+
+ bool inGame = MenuHelper.InGame;
+ string details, state = null, partyId = null;
+ int size = 0, max = 0;
+
+ if (session == null)
+ {
+ details = inGame ? "Playing singleplayer" : "In the Menus";
+ }
+ else
+ {
+ size = Mathf.Max(1, players.Count);
+ if (dedicated || connectingToServer)
+ {
+ details = (inGame ? "Playing on " : "Joining ") + (serverName ?? "a server");
+ state = "Dedicated server";
+ max = serverMaxPlayers;
+ partyId = "server-" + serverName;
+ }
+ else
+ {
+ details = IsHost ? (inGame ? "Hosting a game" : "Hosting a lobby") : (inGame ? "Playing with friends" : "In a lobby");
+ state = session.IsSteam ? "Steam P2P" : "LAN / IP";
+ max = MaxPlayers;
+ partyId = "p2p-" + (IsHost ? SteamLobby.OwnSteamId : steamHost.m_SteamID);
+ }
+ }
+
+ // Spielzeit neu starten, wenn sich die Art der Aktivität ändert
+ if (details != presenceKey)
+ {
+ presenceKey = details;
+ presenceStart = (long)(System.DateTime.UtcNow - new System.DateTime(1970, 1, 1, 0, 0, 0, System.DateTimeKind.Utc)).TotalSeconds;
+ }
+ discord.SetActivity(details, state, size, max, partyId, presenceStart);
+ }
+
+ void OnApplicationQuit()
+ {
+ discord.Stop();
+ }
+
public void Join(string ip, int port)
{
if (!MenuHelper.InMenu)
@@ -73,39 +433,163 @@ namespace MSCO
return;
}
Disconnect();
- session = NetSession.StartClient(ip, port);
- SetStatus("Connecting to " + ip + ":" + port + "...");
+ try
+ {
+ session = NetSession.StartClient(ip, port);
+ SetStatus("Connecting to " + ip + ":" + port + "...");
+ }
+ catch (System.Exception e)
+ {
+ session = null;
+ SetStatus("Could not connect: " + e.Message);
+ }
}
public void Disconnect()
{
- if (session != null) session.Close();
+ steam.Leave();
+ if (session == null) return;
+ session.Close();
OnSessionEnded("Disconnected");
}
- void OnSessionEnded(string reason)
+ // ---------------------------------------------------------------- Steam
+
+ public bool SteamAvailable { get { return SteamLobby.IsAvailable; } }
+ public bool IsSteamSession { get { return session != null && session.IsSteam; } }
+
+ /// Freunde-Lobby erstellen (max. MaxPlayers), danach über Steam einladen.
+ public void HostSteam()
{
- session = null;
- peerReady = false;
- DestroyRemotePlayer();
- if (receivingSave)
+ if (!MenuHelper.InMenu) { SetStatus("Host from the main menu!"); return; }
+ Disconnect();
+ SetStatus("Creating Steam lobby...");
+ steam.Create(MaxPlayers);
+ }
+
+ public void InviteFriends()
+ {
+ steam.OpenInviteDialog();
+ }
+
+ /// Steam-Name, wenn Steam läuft; sonst der im Launcher eingetragene Name (Non-Steam).
+ string OwnName()
+ {
+ string persona = SteamLobby.IsAvailable ? SteamLobby.PersonaName : null;
+ return !string.IsNullOrEmpty(persona) ? persona : playerName;
+ }
+
+ void OnSteamLobbyCreated(Steamworks.CSteamID lobby)
+ {
+ session = NetSession.StartSteamHost(id => steam.IsMember(id));
+ localId = NetSession.HostId;
+ objects.LocalId = localId;
+ AddPlayer(localId, OwnName(), true);
+ SetStatus("Steam lobby ready - invite your friends!");
+ }
+
+ void OnSteamLobbyEntered(Steamworks.CSteamID lobby, Steamworks.CSteamID owner)
+ {
+ if (owner == Steamworks.SteamUser.GetSteamID()) return; // eigene Lobby
+ steamHost = owner;
+ session = NetSession.StartSteamClient(owner);
+ SetStatus("Joining " + Steamworks.SteamFriends.GetFriendPersonaName(owner) + "...");
+ }
+
+ void OnSteamJoinRequested(Steamworks.CSteamID lobby)
+ {
+ if (!MenuHelper.InMenu)
{
- // Abbruch mitten in der Übertragung: eigenen Spielstand zurück
- receivingSave = false;
- TryRestoreBackup();
+ pendingLobby = lobby; // wird im Hauptmenü angenommen
+ SetStatus("Go to the main menu to join your friend");
+ return;
}
- SetStatus(reason);
+ Disconnect();
+ SetStatus("Joining Steam lobby...");
+ steam.Join(lobby);
+ }
+
+ void OnSteamMemberLeft(Steamworks.CSteamID user)
+ {
+ if (session == null || !session.IsSteam) return;
+ if (session.IsHost) session.DropSteamPeer(user.m_SteamID, "Left the lobby");
+ else if (user == steamHost) session.Close("Host left the game");
+ }
+
+ /// START GAME im Launcher: Spielstand an alle schicken und wie CONTINUE laden.
+ public void StartHostGame()
+ {
+ if (!IsHost || !MenuHelper.InMenu) return;
+ SendSaveToWaitingPlayers();
+ SetStatus("Starting game...");
+ MenuHelper.StartGame();
+ }
+
+ ///
+ /// Speichert wie die Toilette: das Spiel schickt allen FSMs das globale Event "SAVEGAME".
+ /// Nur der Host speichert, Clients haben nur eine Kopie seines Spielstands.
+ ///
+ public void SaveGame()
+ {
+ if (!MenuHelper.InGame)
+ {
+ ModConsole.Print("[MSCO] You can only save while in the game.");
+ return;
+ }
+ if (session != null && dedicated)
+ {
+ ModConsole.Print("[MSCO] The server saves the world automatically.");
+ return;
+ }
+ if (session != null && !session.IsHost)
+ {
+ ModConsole.Print("[MSCO] Only the host can save the game.");
+ return;
+ }
+ savingMessageUntil = Time.realtimeSinceStartup + 2.5f; // "SAVING GAME..." in der Bildschirmmitte
+ PlayMakerFSM.BroadcastEvent("SAVEGAME");
+ ModConsole.Print("[MSCO] Game saved.");
}
// ---------------------------------------------------------------- Update
void Update()
{
+ // Menü-Buttons des Spiels nicht durch unser Fenster hindurch auslösen (z.B. Changelog)
+ // Solange das Multiplayer-Fenster offen ist, reagiert das Spielmenü (CONTINUE, QUIT ...) gar nicht;
+ // erst nach MINIMIZE wieder. Über der eingeklappten Leiste ebenfalls nicht (sonst öffnet sich z.B. der Changelog).
+ MenuInputBlocker.SetBlocked(MenuHelper.InMenu && (lobby.IsOpen || lobby.ContainsMouse() || overlays.AnyOpen));
+ UpdateOverlayKeys();
+ UpdatePresence();
+
+ steam.Tick();
+ if (pendingLobby.m_SteamID != 0 && MenuHelper.InMenu && session == null && steam.Init())
+ {
+ Steamworks.CSteamID lobbyToJoin = pendingLobby;
+ pendingLobby = new Steamworks.CSteamID(0);
+ SetStatus("Joining Steam lobby...");
+ steam.Join(lobbyToJoin);
+ }
+
if (session == null) return;
- List messages = session.Poll();
- if (session.ConsumeConnected()) OnConnected();
- foreach (Message m in messages) Handle(m);
+ // Lebenszeichen, damit Steam-Verbindungen nicht in den Timeout laufen
+ if (Time.realtimeSinceStartup >= nextPing)
+ {
+ nextPing = Time.realtimeSinceStartup + PingInterval;
+ session.SendToAll(MsgType.Ping);
+ }
+
+ foreach (NetEvent e in session.Poll())
+ {
+ switch (e.Kind)
+ {
+ case NetEventKind.Connected: OnConnected(e.PeerId); break;
+ case NetEventKind.Disconnected: OnPeerDisconnected(e.PeerId, e.Reason); break;
+ case NetEventKind.Message: OnMessage(e.PeerId, e.Message); break;
+ }
+ if (session == null) return; // z.B. vom Host abgelehnt
+ }
if (session.IsClosed)
{
@@ -113,13 +597,14 @@ namespace MSCO
return;
}
- if (session.IsHost && peerReady && MenuHelper.InMenu && !saveSentThisLoad)
+ if (session.IsHost && MenuHelper.InMenu && HasWaitingPlayers())
WatchHostContinue();
if (MenuHelper.InGame) UpdateInGame();
+ else FsmSync.Enabled = false;
}
- void OnConnected()
+ void OnConnected(int peerId)
{
if (session.IsHost)
{
@@ -127,51 +612,212 @@ namespace MSCO
}
else
{
- session.Send(MsgType.Hello, NetSession.Build(w => w.Write(MSCO.ModVersion)));
+ string name = OwnName();
+ byte[] ticket = SteamLobby.CreateAuthTicket();
+ session.SendTo(NetSession.HostId, MsgType.Hello, NetSession.Build(w =>
+ {
+ w.Write(MSCO.ModVersion);
+ w.Write(name);
+ // Steam-Identität: dedizierte Server lassen nur Spieler mit Steam rein
+ w.Write(SteamLobby.OwnSteamId);
+ w.Write(ticket.Length);
+ w.Write(ticket);
+ }));
SetStatus("Connected, waiting for host...");
}
}
- void Handle(Message m)
+ void OnPeerDisconnected(int peerId, string reason)
+ {
+ PlayerInfo p;
+ if (!players.TryGetValue(peerId, out p)) return;
+ RemovePlayer(peerId);
+ session.SendToAll(MsgType.PlayerLeft, NetSession.Build(w => w.Write(peerId)));
+ SetStatus(p.Name + " left (" + reason + ")");
+ }
+
+ void OnSessionEnded(string reason)
+ {
+ nextPresenceUpdate = 0f; // Discord sofort auf "In the Menus"/Singleplayer umstellen
+ bool wasClientInGame = session != null && !session.IsHost && MenuHelper.InGame;
+
+ if (session != null && session.IsSteam) steam.Leave();
+ session = null;
+ handshakeDone = false;
+ teleportToHost = false;
+ dedicated = false;
+ connectingToServer = false;
+ readySent = false;
+ pendingSnapshot = null;
+ SteamLobby.CancelAuthTicket();
+ passenger.Exit(localPlayer, playerCamera); // Beifahrer: neben das Auto stellen
+ foreach (PlayerInfo p in players.Values) DestroyAvatar(p);
+ players.Clear();
+ objects.Reset();
+ vehicleFx.Reset();
+ worldSync.Reset();
+ moneySync.Reset();
+ doorSync.Reset();
+ vehicleState.Reset();
+ isAdmin = false;
+ if (overlays.AdminOpen || overlays.ChatOpen) overlays.CloseAll();
+ FsmSync.Enabled = false;
+ FsmSync.Reset();
+ if (receivingSave)
+ {
+ // Abbruch mitten in der Übertragung: eigenen Spielstand zurück
+ receivingSave = false;
+ TryRestoreBackup();
+ }
+ SetStatus(reason);
+
+ // Client ohne Host: zurück ins Hauptmenü (dort kommt auch der eigene Spielstand zurück)
+ if (wasClientInGame)
+ {
+ SetStatus("Disconnected from host: " + reason);
+ Application.LoadLevel(MenuHelper.MenuScene);
+ }
+ }
+
+ // ---------------------------------------------------------------- Nachrichten
+
+ static bool IsGameMessage(MsgType t)
+ {
+ return t == MsgType.Player || t == MsgType.ObjStates || t == MsgType.ObjOwn
+ || t == MsgType.ObjRelease || t == MsgType.FsmEvent || t == MsgType.VehicleFx || t == MsgType.WorldTime || t == MsgType.MoneyDelta || t == MsgType.DoorState
+ || t == MsgType.Chat || t == MsgType.VarSync;
+ }
+
+ void OnMessage(int peerId, Message m)
{
BinaryReader r = m.Reader();
- switch (m.Type)
- {
- case MsgType.Hello:
- if (!session.IsHost) return;
- string version = r.ReadString();
- if (version != MSCO.ModVersion)
- {
- session.Send(MsgType.Reject, NetSession.Build(w =>
- w.Write("Version mismatch (host " + MSCO.ModVersion + ", you " + version + ")")));
- session.Close("Player rejected: wrong mod version " + version);
- return;
- }
- session.Send(MsgType.Welcome);
- peerReady = true;
- if (MenuHelper.InGame)
- {
- // Host spielt schon: letzten gespeicherten Stand schicken
- SendSaveAndStart();
- SetStatus("Player joined (sent last saved game)");
- }
- else
- {
- SetStatus("Player connected - press CONTINUE to start!");
- }
- break;
+ if (IsGameMessage(m.Type))
+ {
+ int from;
+ if (session.IsHost)
+ {
+ if (!players.ContainsKey(peerId)) return; // Handshake noch nicht fertig
+ from = peerId;
+ // an alle anderen weiterleiten, mit Absender
+ session.SendToAll(m.Type, NetSession.WithSender(peerId, m.Payload), peerId);
+ }
+ else
+ {
+ from = r.ReadInt32();
+ }
+ HandleGameMessage(from, m.Type, r);
+ return;
+ }
+
+ if (session.IsHost) HandleHostMessage(peerId, m.Type, r);
+ else HandleClientMessage(m.Type, r);
+ }
+
+ void HandleHostMessage(int peerId, MsgType type, BinaryReader r)
+ {
+ if (type != MsgType.Hello || players.ContainsKey(peerId)) return;
+
+ string version = r.ReadString();
+ string name = r.ReadString();
+ if (version != MSCO.ModVersion)
+ {
+ Reject(peerId, "Version mismatch (host " + MSCO.ModVersion + ", you " + version + ")");
+ return;
+ }
+ if (bannedNames.Contains(name.ToLower()))
+ {
+ Reject(peerId, "You are banned from this game");
+ return;
+ }
+ if (players.Count >= MaxPlayers)
+ {
+ Reject(peerId, "Server is full");
+ return;
+ }
+
+ PlayerInfo joined = AddPlayer(peerId, name, false);
+
+ // Neuer Spieler bekommt seine ID und die Spielerliste, alle anderen erfahren von ihm
+ var list = new List(players.Values);
+ session.SendTo(peerId, MsgType.Welcome, NetSession.Build(w =>
+ {
+ w.Write(peerId);
+ w.Write(false); // kein dedizierter Server
+ w.Write(list.Count);
+ foreach (PlayerInfo p in list) { w.Write(p.Id); w.Write(p.Name); }
+ }));
+ session.SendToAll(MsgType.PlayerJoined, NetSession.Build(w => { w.Write(peerId); w.Write(name); }), peerId);
+
+ if (MenuHelper.InGame)
+ {
+ // Host spielt schon: erst speichern, damit der Neue den aktuellen Stand bekommt
+ StartCoroutine(SaveThenSendLateJoin(joined));
+ SetStatus(name + " joined - saving and sending the game...");
+ }
+ else
+ {
+ SetStatus(name + " joined - press START GAME when everyone is here");
+ }
+ }
+
+ void Reject(int peerId, string reason)
+ {
+ session.SendTo(peerId, MsgType.Reject, NetSession.Build(w => w.Write(reason)));
+ session.Kick(peerId, reason);
+ SetStatus("Player rejected: " + reason);
+ }
+
+ void HandleClientMessage(MsgType type, BinaryReader r)
+ {
+ switch (type)
+ {
case MsgType.Welcome:
- peerReady = true;
- SetStatus("Connected - waiting for host to press CONTINUE...");
+ {
+ localId = r.ReadInt32();
+ dedicated = r.ReadBoolean();
+ objects.LocalId = localId;
+ players.Clear();
+ int count = r.ReadInt32();
+ for (int i = 0; i < count; i++)
+ {
+ int id = r.ReadInt32();
+ AddPlayer(id, r.ReadString(), id == localId);
+ }
+ handshakeDone = true;
+ SetStatus(dedicated ? "Connected to server - loading..." : "Connected - waiting for host to start...");
break;
+ }
case MsgType.Reject:
- session.Close("Rejected by host: " + r.ReadString());
+ session.Close((dedicated || connectingToServer ? "Rejected by server: " : "Rejected by host: ") + r.ReadString());
+ OnSessionEnded(session.CloseReason);
break;
+ case MsgType.WorldSnapshot:
+ // Wird angewendet, sobald das Spiel geladen und alle Objekte erfasst sind
+ pendingSnapshot = r.ReadBytes((int)(r.BaseStream.Length - r.BaseStream.Position));
+ break;
+
+ case MsgType.PlayerJoined:
+ {
+ int id = r.ReadInt32();
+ string name = r.ReadString();
+ AddPlayer(id, name, false);
+ SetStatus(name + " joined");
+ break;
+ }
+
+ case MsgType.PlayerLeft:
+ {
+ int id = r.ReadInt32();
+ PlayerInfo p;
+ if (players.TryGetValue(id, out p)) SetStatus(p.Name + " left");
+ RemovePlayer(id);
+ break;
+ }
+
case MsgType.SaveBegin:
- if (session.IsHost) return;
if (!MenuHelper.InMenu)
{
SetStatus("Host started - go back to the main menu to join");
@@ -179,31 +825,132 @@ namespace MSCO
}
SaveSync.BeginReceive();
receivingSave = true;
- SetStatus("Receiving host's save...");
+ SetStatus(dedicated ? "Downloading server save..." : "Receiving host's save...");
break;
case MsgType.SaveFile:
if (!receivingSave) return;
- string name = r.ReadString();
+ string file = r.ReadString();
byte[] data = r.ReadBytes(r.ReadInt32());
- SaveSync.WriteFile(name, data);
+ SaveSync.WriteFile(file, data);
break;
+ case MsgType.AdminStatus:
+ isAdmin = r.ReadBoolean();
+ if (isAdmin) overlays.Log("You are an admin on this server - type /admin in chat or 'admin' here.");
+ break;
+
+ case MsgType.AdminList:
+ {
+ var list = new List();
+ int count = r.ReadInt32();
+ for (int i = 0; i < count; i++)
+ list.Add(new AdminPlayer { Id = r.ReadInt32(), Name = r.ReadString(), SteamId = r.ReadString(), IsAdmin = r.ReadBoolean() });
+ overlays.SetAdminPlayers(list);
+ break;
+ }
+
+ case MsgType.AdminResult:
+ {
+ string result = r.ReadString();
+ overlays.Log(result);
+ overlays.AddChat("", result, true);
+ if (overlays.AdminOpen) RequestAdminList();
+ break;
+ }
+
case MsgType.StartGame:
if (!receivingSave) return;
receivingSave = false;
- SetStatus("Loading host's game...");
+ // Später beigetreten: nach dem Laden neben den Host setzen
+ teleportToHost = r.BaseStream.Length > 0 && r.ReadBoolean();
+ SetStatus(dedicated ? "Loading server world..." : "Loading host's game...");
MenuHelper.StartGame();
break;
-
- case MsgType.Player:
- if (MenuHelper.InGame) ApplyRemoteState(PlayerState.Deserialize(r));
- break;
}
}
+ void HandleGameMessage(int from, MsgType type, BinaryReader r)
+ {
+ if (from == localId) return;
+ if (type == MsgType.Chat)
+ {
+ string text = r.ReadString();
+ PlayerInfo sender;
+ if (dedicated && from == NetSession.HostId) overlays.AddChat("", text, true); // Server/Admin-Meldung
+ else if (text.StartsWith("* ")) overlays.AddChat("", text.Substring(2), true);
+ else overlays.AddChat(players.TryGetValue(from, out sender) ? sender.Name : "Player " + from, text);
+ return;
+ }
+ if (!MenuHelper.InGame) return;
+ switch (type)
+ {
+ case MsgType.Player:
+ ApplyRemoteState(from, PlayerState.Deserialize(r));
+ break;
+ case MsgType.ObjStates:
+ case MsgType.ObjOwn:
+ case MsgType.ObjRelease:
+ objects.Handle(from, type, r);
+ break;
+ case MsgType.FsmEvent:
+ FsmSync.Apply(FsmEventMsg.Deserialize(r));
+ break;
+ case MsgType.WorldTime:
+ worldSync.Apply(r);
+ break;
+ case MsgType.MoneyDelta:
+ moneySync.Apply(r.ReadSingle());
+ break;
+ case MsgType.DoorState:
+ doorSync.Apply(r);
+ break;
+ case MsgType.VehicleFx:
+ vehicleFx.Handle(r);
+ break;
+ case MsgType.VarSync:
+ vehicleState.Handle(from, r, objects, localId);
+ break;
+ }
+ }
+
+ /// Spiel-Nachricht an alle anderen (Client: über den Host).
+ void SendGame(MsgType type, byte[] payload)
+ {
+ if (session == null) return;
+ if (session.IsHost) session.SendToAll(type, NetSession.WithSender(localId, payload));
+ else session.SendTo(NetSession.HostId, type, payload);
+ }
+
+ // ---------------------------------------------------------------- Spielerliste
+
+ PlayerInfo AddPlayer(int id, string name, bool isLocal)
+ {
+ PlayerInfo p;
+ if (!players.TryGetValue(id, out p)) players[id] = p = new PlayerInfo { Id = id };
+ p.Name = name;
+ p.IsLocal = isLocal;
+ return p;
+ }
+
+ void RemovePlayer(int id)
+ {
+ PlayerInfo p;
+ if (!players.TryGetValue(id, out p)) return;
+ DestroyAvatar(p);
+ objects.ReleaseAllOf(id);
+ players.Remove(id);
+ }
+
// ---------------------------------------------------------------- Host: Spielstart
+ bool HasWaitingPlayers()
+ {
+ foreach (PlayerInfo p in players.Values)
+ if (!p.IsLocal && !p.SaveSent) return true;
+ return false;
+ }
+
void WatchHostContinue()
{
if (continueFsm == null)
@@ -214,45 +961,90 @@ namespace MSCO
}
// Host hat CONTINUE gedrückt und die FSM ist im Lade-Zustand
if (continueFsm.ActiveStateName == continueState.Name)
- SendSaveAndStart();
+ SendSaveToWaitingPlayers();
}
- void SendSaveAndStart()
+ void SendSaveToWaitingPlayers()
+ {
+ var targets = new List();
+ foreach (PlayerInfo p in players.Values)
+ if (!p.IsLocal && !p.SaveSent) targets.Add(p);
+ if (targets.Count > 0) SendSave(targets, false);
+ }
+
+ System.Collections.IEnumerator SaveThenSendLateJoin(PlayerInfo joined)
+ {
+ SaveGame();
+ yield return new WaitForSeconds(LateJoinSaveDelay); // Spiel schreibt die Dateien über mehrere Frames
+ if (session == null || !players.ContainsKey(joined.Id)) yield break; // inzwischen wieder weg
+ SendSave(new List { joined }, true);
+ SetStatus(joined.Name + " is loading your game");
+ }
+
+ void SendSave(List targets, bool lateJoin)
{
- saveSentThisLoad = true;
List> files = SaveSync.ReadLocalSave();
- session.Send(MsgType.SaveBegin);
+ var fileMessages = new List();
foreach (KeyValuePair file in files)
{
- session.Send(MsgType.SaveFile, NetSession.Build(w =>
+ fileMessages.Add(NetSession.Build(w =>
{
w.Write(file.Key);
w.Write(file.Value.Length);
w.Write(file.Value);
}));
}
- session.Send(MsgType.StartGame);
- ModConsole.Print("[MSCO] Sent save (" + files.Count + " files) to player");
+
+ foreach (PlayerInfo p in targets)
+ {
+ p.SaveSent = true;
+ session.SendTo(p.Id, MsgType.SaveBegin);
+ foreach (byte[] msg in fileMessages) session.SendTo(p.Id, MsgType.SaveFile, msg);
+ session.SendTo(p.Id, MsgType.StartGame, NetSession.Build(w => w.Write(lateJoin)));
+ }
+ ModConsole.Print("[MSCO] Sent save (" + files.Count + " files) to " + targets.Count + " player(s)");
}
void OnLevelWasLoaded(int level)
{
+ nextPresenceUpdate = 0f; // Discord gleich nach dem Szenenwechsel aktualisieren
if (MenuHelper.InGame)
{
// Falls der CONTINUE-Klick nicht erkannt wurde: jetzt nachholen
- if (session != null && session.IsHost && peerReady && !saveSentThisLoad)
- SendSaveAndStart();
+ if (session != null && session.IsHost) SendSaveToWaitingPlayers();
+ passenger.Reset();
localPlayer = null;
+ objects.OnGameLoaded();
+ vehicleState.Reset();
+ vehicleFx.Reset();
+ worldSync.Reset();
+ moneySync.Reset();
+ doorSync.Reset();
+ FsmSync.Reset();
+ readySent = false;
+ if (session != null) SetStatus(session.IsHost ? "Hosting" : "Connected");
}
else if (MenuHelper.InMenu)
{
- saveSentThisLoad = false;
+ foreach (PlayerInfo p in players.Values)
+ {
+ p.SaveSent = false;
+ DestroyAvatar(p);
+ }
continueFsm = null;
+ passenger.Reset();
localPlayer = null;
- DestroyRemotePlayer();
+ objects.Reset();
+ vehicleFx.Reset();
+ worldSync.Reset();
+ moneySync.Reset();
+ doorSync.Reset();
+ FsmSync.Reset();
+ // Zurück im Hauptmenü = Server verlassen
+ if (session != null && dedicated) Disconnect();
if (!receivingSave) TryRestoreBackup();
- if (session != null && peerReady && !session.IsHost)
- SetStatus("Connected - waiting for host to press CONTINUE...");
+ if (session != null && handshakeDone && !session.IsHost)
+ SetStatus("Connected - waiting for host to start...");
}
}
@@ -273,97 +1065,331 @@ namespace MSCO
GameObject player = GameObject.Find("PLAYER");
if (player == null) return;
localPlayer = player.transform;
+ playerCamera = FindPlayerCamera(player);
+ objects.Head = playerCamera != null ? playerCamera.transform : null;
+ ModConsole.Print("[MSCO] Player camera: " + (playerCamera != null ? playerCamera.name : "none"));
}
- if (peerReady)
+ if (teleportToHost) TryTeleportToHost();
+
+ // Dedizierter Server: nach dem ersten Objekt-Suchlauf den Weltzustand anfordern und anwenden
+ if (dedicated && handshakeDone && !readySent && objects.ScanComplete)
{
- sendTimer += Time.deltaTime;
+ readySent = true;
+ session.SendTo(NetSession.HostId, MsgType.Ready);
+ SetStatus("Syncing world...");
+ }
+ if (pendingSnapshot != null && objects.ScanComplete)
+ {
+ byte[] snapshot = pendingSnapshot;
+ pendingSnapshot = null;
+ StartCoroutine(ApplyWorldSnapshot(snapshot));
+ }
+
+ if (MSCO.PassengerKey != null && MSCO.PassengerKey.GetKeybindDown())
+ {
+ string result = passenger.Toggle(localPlayer, playerCamera, objects, players);
+ if (result != null) SetStatus(result);
+ }
+
+ bool ready = IsPeerReady;
+ if (ready)
+ {
+ sendTimer += Time.unscaledDeltaTime; // läuft auch im Pausenmenü (timeScale = 0) weiter
if (sendTimer >= SendInterval)
{
sendTimer = 0f;
- session.Send(MsgType.Player, new PlayerState
- {
- Position = localPlayer.position,
- RotationY = localPlayer.eulerAngles.y
- }.Serialize());
+ SendGame(MsgType.Player, BuildLocalState().Serialize());
}
+ objects.Update(localPlayer, Time.deltaTime);
+ vehicleFx.Update(objects, SendGame, Time.deltaTime);
+ worldSync.Update(IsWorldAuthority, SendGame, Time.unscaledDeltaTime);
+
+ // Geld erst beobachten, wenn das Spiel fertig geladen ist
+ if (objects.ScanComplete) moneySync.Begin();
+ moneySync.Update(SendGame);
+
+ // Türen, Tore, Hauben (z.B. Garagentor)
+ if (objects.ScanComplete) doorSync.Update(SendGame);
+
+ // Gesamtzustand der Fahrzeuge (Tank, Öl, Schrauben, Verschleiß ...)
+ if (objects.ScanComplete) vehicleState.Update(objects, localId, SendGame);
}
- if (remotePlayer != null) remotePlayer.Tick(Time.deltaTime);
- }
-
- void ApplyRemoteState(PlayerState state)
- {
- if (remotePlayer == null)
+ foreach (PlayerInfo p in players.Values)
{
- // Platzhalter-Modell: eine Kapsel
- GameObject go = GameObject.CreatePrimitive(PrimitiveType.Capsule);
- go.name = "MSCO_RemotePlayer";
- Destroy(go.GetComponent()); // stört sonst Physik/Trigger
- go.GetComponent().material.color = Color.cyan;
- go.transform.position = state.Position;
- remotePlayer = go.AddComponent();
- ModConsole.Print("[MSCO] Other player is in the game");
+ if (p.Avatar == null) continue;
+ p.Avatar.Tick(Time.unscaledDeltaTime);
+
+ // Gehaltenes Item an die Hand der Figur setzen
+ Vector3 pos;
+ Quaternion rot;
+ if (p.Avatar.TryGetHeldPose(out pos, out rot))
+ objects.ApplyAvatarHold(p.Id, p.Avatar.HeldId, pos, rot);
}
- remotePlayer.SetTarget(state);
+
+ // Abgefangene Schraub-/Montage-Aktionen verschicken
+ FsmSync.Enabled = ready;
+ FsmSync.Tick();
+ foreach (FsmEventMsg msg in FsmSync.Outbox) SendGame(MsgType.FsmEvent, msg.Serialize());
+ FsmSync.Outbox.Clear();
}
- void DestroyRemotePlayer()
+ void FixedUpdate()
{
- if (remotePlayer != null) Destroy(remotePlayer.gameObject);
- remotePlayer = null;
+ if (IsPeerReady && MenuHelper.InGame) objects.FixedUpdate();
+ }
+
+ void LateUpdate()
+ {
+ InputLock.KeepCursorFree(); // das Spiel sperrt den Zeiger im selben Frame evtl. wieder
+ // Nach den Spiel-FSMs, damit Lichter/Motorgeräusch der Fahrzeuge anderer gewinnen
+ if (IsPeerReady && MenuHelper.InGame) vehicleFx.Apply(objects);
+ // Beifahrer nach der Fahrzeugbewegung auf den Sitz setzen
+ if (localPlayer != null && MenuHelper.InGame) passenger.LateUpdate(localPlayer, playerCamera, objects);
+ }
+
+ ///
+ /// Die Ego-Kamera des Spielers (liegt unter PLAYER). Nicht Camera.main verwenden:
+ /// im Pausenmenü oder in Menüs ist das eine andere Kamera.
+ ///
+ static Camera FindPlayerCamera(GameObject player)
+ {
+ Camera best = null;
+ foreach (Camera c in player.GetComponentsInChildren(true))
+ {
+ if (c.name.Contains("FPSCamera")) return c;
+ if (best == null || (c.enabled && !best.enabled)) best = c;
+ }
+ return best != null ? best : Camera.main;
+ }
+
+ ///
+ /// Weltzustand vom Server anwenden: eigene letzte Position, Positionen aller bewegten Objekte
+ /// und danach alle Schraub-/Montage-Aktionen in der ursprünglichen Reihenfolge.
+ ///
+ System.Collections.IEnumerator ApplyWorldSnapshot(byte[] snapshot)
+ {
+ var r = new BinaryReader(new MemoryStream(snapshot));
+
+ if (r.ReadBoolean())
+ {
+ var spawn = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
+ if (localPlayer != null) localPlayer.position = spawn + Vector3.up * 0.2f;
+ }
+
+ int objectCount = r.ReadInt32();
+ int applied = 0;
+ for (int i = 0; i < objectCount; i++)
+ {
+ int id = r.ReadInt32();
+ var pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
+ var rot = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
+ if (objects.ApplySnapshotPose(id, pos, rot)) applied++;
+ }
+
+ int eventCount = r.ReadInt32();
+ for (int i = 0; i < eventCount; i++)
+ {
+ byte[] payload = r.ReadBytes(r.ReadInt32());
+ FsmSync.Apply(FsmEventMsg.Deserialize(new BinaryReader(new MemoryStream(payload))));
+ if ((i + 1) % SnapshotEventsPerFrame == 0) yield return null; // nicht alles in einem Frame
+ }
+
+ // Geld: Summe aller Änderungen seit dem Spielstand des Servers
+ if (r.BaseStream.Length - r.BaseStream.Position >= 4) moneySync.Apply(r.ReadSingle());
+
+ // Türen/Tore: letzter Zustand auf dem Server (z.B. Garagentor offen)
+ if (r.BaseStream.Length - r.BaseStream.Position >= 4)
+ {
+ int doors = r.ReadInt32();
+ for (int i = 0; i < doors; i++) doorSync.ApplyState(r.ReadString(), r.ReadString());
+ }
+
+ // Fahrzeugzustand (Tank, Öl, Schrauben ...)
+ if (r.BaseStream.Length - r.BaseStream.Position >= 4) vehicleState.ApplySnapshot(r);
+
+ ModConsole.Print("[MSCO] World synced: " + applied + "/" + objectCount + " objects, " + eventCount + " part/bolt actions");
+ SetStatus("Connected");
+ }
+
+ /// Sobald die erste Position des Hosts da ist: seitlich neben ihn stellen.
+ void TryTeleportToHost()
+ {
+ PlayerInfo host;
+ if (!players.TryGetValue(NetSession.HostId, out host) || host.Avatar == null) return;
+ teleportToHost = false;
+
+ PlayerState s = host.Avatar.LastState;
+ Vector3 side = Quaternion.Euler(0f, s.RotationY, 0f) * Vector3.right;
+ localPlayer.position = s.Position + side * TeleportSideOffset + Vector3.up * 0.2f;
+ SetStatus("Joined - you are next to " + host.Name);
+ }
+
+ ///
+ /// Boden unter dem Spieler (Strahl nach unten, eigene Collider ignoriert). Damit stehen die Füße der
+ /// Figur beim anderen auf dem Boden, und Ducken/Lehnen wird als Körperhaltung sichtbar.
+ ///
+ float GroundBelowPlayer(Vector3 headPos)
+ {
+ Vector3 from = new Vector3(localPlayer.position.x, headPos.y, localPlayer.position.z);
+ float best = float.MinValue;
+ foreach (RaycastHit hit in Physics.RaycastAll(from, Vector3.down, 3f))
+ {
+ if (hit.collider.isTrigger || hit.transform.root == localPlayer.root) continue;
+ if (hit.point.y > best) best = hit.point.y;
+ }
+ return best > float.MinValue ? best : headPos.y - 1.65f;
+ }
+
+ /// Eigene Position, Blickrichtung und gehaltenes Item (relativ zur Kamera).
+ PlayerState BuildLocalState()
+ {
+ // Gemerkte Spielerkamera: im Pausenmenü ist Camera.main eine andere (oder keine)
+ Transform head = playerCamera != null ? playerCamera.transform : localPlayer;
+ var state = new PlayerState
+ {
+ Position = localPlayer.position,
+ RotationY = localPlayer.eulerAngles.y,
+ CamPos = head.position,
+ CamRot = head.rotation,
+ Seated = passenger.IsRiding || objects.IsDriving,
+ FeetY = GroundBelowPlayer(head.position),
+ HeldLocalRot = Quaternion.identity
+ };
+
+ SyncedBody held = objects.Held;
+ if (held != null)
+ {
+ Transform item = held.Rb.transform;
+ state.HeldId = held.Id;
+ state.HeldLocalPos = head.InverseTransformPoint(item.position);
+ state.HeldLocalRot = Quaternion.Inverse(head.rotation) * item.rotation;
+ }
+ return state;
+ }
+
+ void ApplyRemoteState(int from, PlayerState state)
+ {
+ PlayerInfo p;
+ if (!players.TryGetValue(from, out p)) return;
+ if (p.Avatar == null)
+ {
+ // Platzhalter-Figur: Kapsel mit Kopf und Hand
+ p.Avatar = RemotePlayer.Create("MSCO_Player_" + from, PlayerColor(from), state, from);
+ ModConsole.Print("[MSCO] " + p.Name + " is in the game");
+ }
+ p.Avatar.SetTarget(state);
+ }
+
+ static Color PlayerColor(int id)
+ {
+ Color[] colors = { Color.cyan, new Color(1f, 0.55f, 0.1f), Color.green, Color.magenta,
+ Color.yellow, new Color(0.4f, 0.6f, 1f), Color.red, Color.white };
+ return colors[id % colors.Length];
+ }
+
+ void DestroyAvatar(PlayerInfo p)
+ {
+ if (p.Avatar != null) Destroy(p.Avatar.gameObject);
+ p.Avatar = null;
}
// ---------------------------------------------------------------- GUI
void OnGUI()
{
- if (MenuHelper.InMenu) DrawLobby();
- else if (session != null) GUI.Label(new Rect(10, 10, 500, 25), "[MSCO] " + status);
+ InputLock.KeepCursorFree();
+ if (MenuHelper.InMenu)
+ {
+ lobby.Draw(this);
+ overlays.Draw(this, false);
+ return;
+ }
+ EnsureHudStyles();
+
+ if (Time.realtimeSinceStartup < savingMessageUntil)
+ GUI.Label(new Rect(0, Screen.height / 2f - 40, Screen.width, 80), "SAVING GAME...", savingStyle);
+
+ if (session != null) DrawHud();
+ overlays.Draw(this, session != null);
}
- void DrawLobby()
+ /// F8 Konsole, T Chat (Tasten in den MSCLoader-Einstellungen änderbar).
+ void UpdateOverlayKeys()
{
- GUILayout.BeginArea(new Rect(10, 10, 280, 200), GUI.skin.box);
- GUILayout.Label("MSCO Multiplayer");
+ if (MSCO.ConsoleKey != null && MSCO.ConsoleKey.GetKeybindDown()) overlays.ToggleConsole();
+ else if (MSCO.ChatKey != null && MSCO.ChatKey.GetKeybindDown() && MenuHelper.InGame && session != null && !overlays.AnyOpen)
+ overlays.OpenChat();
- if (session == null)
- {
- GUILayout.BeginHorizontal();
- GUILayout.Label("IP", GUILayout.Width(40));
- ipInput = GUILayout.TextField(ipInput);
- GUILayout.EndHorizontal();
-
- GUILayout.BeginHorizontal();
- GUILayout.Label("Port", GUILayout.Width(40));
- portInput = GUILayout.TextField(portInput);
- GUILayout.EndHorizontal();
-
- GUILayout.BeginHorizontal();
- if (GUILayout.Button("Host")) Host(ParsePort());
- if (GUILayout.Button("Join")) Join(ipInput.Trim(), ParsePort());
- GUILayout.EndHorizontal();
- }
- else
- {
- GUILayout.Label(session.IsHost ? "You are the HOST" : "You are a CLIENT");
- if (GUILayout.Button("Disconnect")) Disconnect();
- }
-
- GUILayout.Label(status);
- GUILayout.EndArea();
+ // Beim Tippen soll der Spieler nicht laufen/sich umsehen
+ InputLock.Set(overlays.AnyOpen && MenuHelper.InGame);
}
- int ParsePort()
+ void DrawHud()
{
- int port;
- return int.TryParse(portInput, out port) ? port : DefaultPort;
+ // Statuszeile oben in der Mitte (links oben ist die Spielanzeige)
+ string state = IsPeerReady ? "CONNECTED" : "WAITING";
+ string text = "MSCO • " + state + " • " + (dedicated ? "Server" : IsHost ? "Host" : "Client") + " • " +
+ players.Count + " player(s) • " + objects.Count + " synced objects";
+ var rect = new Rect((Screen.width - 520) / 2f, 6, 520, 24);
+ if (hudBox == null) hudBox = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.85f), 8);
+ GUI.Box(rect, GUIContent.none, hudBox);
+ hudStyle.normal.textColor = IsPeerReady ? new Color(0.24f, 0.84f, 0.55f) : new Color(0.95f, 0.76f, 0.11f);
+ GUI.Label(rect, text, hudStyle);
+
+ DrawNameTags();
+ }
+
+ void EnsureHudStyles()
+ {
+ if (hudStyle != null && hudBackground != null) return;
+ hudBackground = new Texture2D(1, 1);
+ hudBackground.SetPixel(0, 0, new Color(0.06f, 0.06f, 0.07f, 0.85f));
+ hudBackground.Apply();
+ hudBackground.hideFlags = HideFlags.HideAndDontSave;
+
+ hudStyle = new GUIStyle(GUI.skin.label);
+ hudStyle.alignment = TextAnchor.MiddleCenter;
+ hudStyle.fontStyle = FontStyle.Bold;
+ hudStyle.fontSize = 12;
+
+ savingStyle = new GUIStyle(GUI.skin.label);
+ savingStyle.alignment = TextAnchor.MiddleCenter;
+ savingStyle.fontStyle = FontStyle.Bold;
+ savingStyle.fontSize = 42;
+ savingStyle.normal.textColor = Color.white;
+ }
+
+ void DrawNameTags()
+ {
+ Camera cam = playerCamera != null ? playerCamera : Camera.main;
+ if (cam == null) return;
+ if (nameTagStyle == null)
+ {
+ nameTagStyle = new GUIStyle(GUI.skin.label);
+ nameTagStyle.alignment = TextAnchor.MiddleCenter;
+ nameTagStyle.fontStyle = FontStyle.Bold;
+ nameTagStyle.normal.textColor = Color.white;
+ }
+ foreach (PlayerInfo p in players.Values)
+ {
+ if (p.Avatar == null) continue;
+ Vector3 screen = cam.WorldToScreenPoint(p.Avatar.HeadPosition + Vector3.up * 0.35f);
+ // Hinter der Kamera oder zu weit weg: kein Namensschild; kurz vorher ausblenden
+ if (screen.z <= 0f || screen.z > NameTagHideDistance) continue;
+ float alpha = Mathf.Clamp01((NameTagHideDistance - screen.z) / NameTagFadeRange);
+ GUI.color = new Color(1f, 1f, 1f, alpha);
+ GUI.Label(new Rect(screen.x - 100, Screen.height - screen.y - 12, 200, 24), p.Name, nameTagStyle);
+ }
+ GUI.color = Color.white;
}
void SetStatus(string text)
{
status = text;
ModConsole.Print("[MSCO] " + text);
+ if (!string.IsNullOrEmpty(text)) overlays.Log(text);
}
}
}
diff --git a/NetSession.cs b/NetSession.cs
index c964315..1988ba1 100644
--- a/NetSession.cs
+++ b/NetSession.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
@@ -9,13 +9,35 @@ namespace MSCO
{
public enum MsgType : byte
{
- Hello = 1, // Client -> Host: Mod-Version
- Welcome = 2, // Host -> Client: Handshake ok
- Reject = 3, // Host -> Client: Grund
- Player = 4, // Spielerposition
- SaveBegin = 5, // Host -> Client: Spielstand kommt
- SaveFile = 6, // Host -> Client: Dateiname + Inhalt
- StartGame = 7, // Host -> Client: jetzt laden
+ Hello = 1, // Client -> Host: Mod-Version + Name
+ Welcome = 2, // Host -> Client: eigene ID + Spielerliste
+ Reject = 3, // Host -> Client: Grund
+ Player = 4, // Spielerposition
+ SaveBegin = 5, // Host -> Client: Spielstand kommt
+ SaveFile = 6, // Host -> Client: Dateiname + Inhalt
+ StartGame = 7, // Host -> Client: jetzt laden
+ ObjStates = 8, // Positionen der Objekte, die man gerade besitzt
+ ObjOwn = 9, // "Ich übernehme dieses Objekt"
+ ObjRelease = 10, // "Objekt liegt still, wieder frei"
+ FsmEvent = 11, // Schraube angezogen/gelöst, Teil an-/abgebaut
+ PlayerJoined = 12,// Host -> alle: neuer Spieler (ID + Name)
+ PlayerLeft = 13, // Host -> alle: Spieler weg (ID)
+ VehicleFx = 14, // Lichter und Motor-/Dauergeräusche eines Fahrzeugs
+ Ping = 15, // Lebenszeichen (Steam hat keinen Verbindungsabbruch)
+ Ready = 16, // Client -> Server: Spiel geladen, Objekte erfasst
+ WorldSnapshot = 17,// Server -> Client: aktueller Weltzustand
+ Info = 18, // Server-Browser fragt Infos ab (ohne Hello)
+ InfoReply = 19,
+ WorldTime = 20, // Uhrzeit + Wetter vom "Weltuhr"-Spieler
+ MoneyDelta = 21, // Geld hat sich geändert (+/- Betrag, gemeinsame Kasse)
+ DoorState = 22, // Tür/Tor/Haube hat den Zustand gewechselt (FSM-Pfad + Zustand)
+ AdminStatus = 23, // Server -> Client: bool, ist Admin
+ AdminListRequest = 24, // Client -> Server: Spielerliste fürs Admin-Menü
+ AdminList = 25, // Server -> Client: int Anzahl, je [int id, string name, string steamId, bool admin]
+ AdminAction = 26, // Client -> Server: byte (1 = kick, 2 = ban), int Spieler-ID, string Grund
+ AdminResult = 27, // Server -> Client: string Meldung
+ Chat = 28, // Chatnachricht (string)
+ VarSync = 29 // Fahrzeugzustand: int n, je [int key, byte typ, wert]
}
public struct Message
@@ -33,49 +55,299 @@ namespace MSCO
{
public Vector3 Position;
public float RotationY;
+ public Vector3 CamPos; // Kopf/Kamera in Weltkoordinaten
+ public Quaternion CamRot; // Blickrichtung inkl. hoch/runter
+ public int HeldId; // ID des gehaltenen Objekts, 0 = nichts
+ public Vector3 HeldLocalPos; // relativ zur Kamera
+ public Quaternion HeldLocalRot; // relativ zur Kamera
+ public bool Seated; // fährt oder sitzt als Beifahrer
+ public float FeetY; // Bodenhöhe unter dem Spieler (für Ducken/Lehnen der Figur)
public byte[] Serialize()
{
- Vector3 pos = Position;
- float rotY = RotationY;
+ PlayerState s = this;
return NetSession.Build(w =>
{
- w.Write(pos.x); w.Write(pos.y); w.Write(pos.z);
- w.Write(rotY);
+ Write(w, s.Position);
+ w.Write(s.RotationY);
+ Write(w, s.CamPos);
+ Write(w, s.CamRot);
+ w.Write(s.Seated);
+ w.Write(s.FeetY);
+ w.Write(s.HeldId);
+ if (s.HeldId == 0) return;
+ Write(w, s.HeldLocalPos);
+ Write(w, s.HeldLocalRot);
});
}
public static PlayerState Deserialize(BinaryReader r)
{
- return new PlayerState
+ var s = new PlayerState
{
- Position = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()),
- RotationY = r.ReadSingle()
+ Position = ReadVector(r),
+ RotationY = r.ReadSingle(),
+ CamPos = ReadVector(r),
+ CamRot = ReadQuaternion(r),
+ Seated = r.ReadBoolean(),
+ FeetY = r.ReadSingle(),
+ HeldId = r.ReadInt32(),
+ HeldLocalRot = Quaternion.identity
};
+ if (s.HeldId != 0)
+ {
+ s.HeldLocalPos = ReadVector(r);
+ s.HeldLocalRot = ReadQuaternion(r);
+ }
+ return s;
+ }
+
+ static void Write(BinaryWriter w, Vector3 v) { w.Write(v.x); w.Write(v.y); w.Write(v.z); }
+ static void Write(BinaryWriter w, Quaternion q) { w.Write(q.x); w.Write(q.y); w.Write(q.z); w.Write(q.w); }
+ static Vector3 ReadVector(BinaryReader r) { return new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); }
+ static Quaternion ReadQuaternion(BinaryReader r) { return new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); }
+ }
+
+ public interface IConnection
+ {
+ bool IsClosed { get; }
+ string CloseReason { get; }
+ List Poll();
+ void Send(MsgType type, byte[] payload);
+ void Close(string reason);
+ }
+
+ /// Setzt aus einem Byte-Strom Nachrichten zusammen: [int Länge][byte Typ][Payload].
+ public class FrameParser
+ {
+ const int MaxFrame = 32 * 1024 * 1024;
+
+ byte[] buffer = new byte[64 * 1024];
+ int count;
+
+ public void Append(byte[] data, int length)
+ {
+ EnsureCapacity(count + length);
+ Buffer.BlockCopy(data, 0, buffer, count, length);
+ count += length;
+ }
+
+ public void Parse(List messages)
+ {
+ int offset = 0;
+ while (count - offset >= 5)
+ {
+ int length = BitConverter.ToInt32(buffer, offset);
+ if (length < 1 || length > MaxFrame) throw new IOException("Invalid packet");
+ if (count - offset - 4 < length) break;
+
+ var payload = new byte[length - 1];
+ Buffer.BlockCopy(buffer, offset + 5, payload, 0, payload.Length);
+ messages.Add(new Message { Type = (MsgType)buffer[offset + 4], Payload = payload });
+ offset += 4 + length;
+ }
+ if (offset > 0)
+ {
+ Buffer.BlockCopy(buffer, offset, buffer, 0, count - offset);
+ count -= offset;
+ }
+ }
+
+ public static byte[] Frame(MsgType type, byte[] payload)
+ {
+ if (payload == null) payload = new byte[0];
+ var frame = new byte[5 + payload.Length];
+ BitConverter.GetBytes(payload.Length + 1).CopyTo(frame, 0);
+ frame[4] = (byte)type;
+ Buffer.BlockCopy(payload, 0, frame, 5, payload.Length);
+ return frame;
+ }
+
+ void EnsureCapacity(int needed)
+ {
+ if (needed <= buffer.Length) return;
+ var bigger = new byte[Math.Max(needed, buffer.Length * 2)];
+ Buffer.BlockCopy(buffer, 0, bigger, 0, count);
+ buffer = bigger;
+ }
+ }
+
+ /// TCP-Verbindung (LAN / IP). Lesen ist nicht-blockierend und wird über Poll() abgefragt.
+ public class Connection : IConnection
+ {
+ public bool IsClosed { get; private set; }
+ public string CloseReason { get; private set; }
+
+ readonly TcpClient client;
+ readonly NetworkStream stream;
+ readonly FrameParser parser = new FrameParser();
+ readonly byte[] readBuffer = new byte[64 * 1024];
+
+ public Connection(TcpClient client)
+ {
+ this.client = client;
+ client.NoDelay = true;
+ client.SendBufferSize = 4 * 1024 * 1024;
+ stream = client.GetStream();
+ }
+
+ public List Poll()
+ {
+ var messages = new List();
+ if (IsClosed) return messages;
+ try
+ {
+ // Verbindung von der Gegenseite geschlossen?
+ if (client.Client.Poll(0, SelectMode.SelectRead) && client.Available == 0)
+ {
+ Close("Connection closed");
+ return messages;
+ }
+ while (stream.DataAvailable)
+ {
+ int read = stream.Read(readBuffer, 0, readBuffer.Length);
+ if (read <= 0) break;
+ parser.Append(readBuffer, read);
+ }
+ parser.Parse(messages);
+ }
+ catch (Exception e)
+ {
+ Close("Network error: " + e.Message);
+ }
+ return messages;
+ }
+
+ public void Send(MsgType type, byte[] payload)
+ {
+ if (IsClosed) return;
+ try
+ {
+ byte[] frame = FrameParser.Frame(type, payload);
+ stream.Write(frame, 0, frame.Length); // blockierend, damit große Dateien komplett rausgehen
+ }
+ catch (Exception e)
+ {
+ Close("Network error: " + e.Message);
+ }
+ }
+
+ public void Close(string reason)
+ {
+ if (IsClosed) return;
+ IsClosed = true;
+ CloseReason = reason;
+ try { client.Close(); } catch { }
}
}
///
- /// TCP-Verbindung zu genau einem Mitspieler. Nachrichten: [int Länge][byte Typ][Payload].
- /// Lesen ist nicht-blockierend und wird jedes Frame über Poll() abgefragt.
+ /// Verbindung über Steam P2P (Steam kümmert sich um NAT/Relay). Pakete sind auf 1 MB
+ /// begrenzt, deshalb werden Nachrichten in Stücke geteilt und beim Empfänger über den
+ /// FrameParser wieder zusammengesetzt (der Reliable-Kanal hält die Reihenfolge ein).
///
- public class NetSession
+ public class SteamConnection : IConnection
{
- const int MaxFrame = 32 * 1024 * 1024;
+ const int ChunkSize = 512 * 1024;
+ const float Timeout = 20f; // ohne Pakete (auch Pings) gilt die Verbindung als tot
- public bool IsHost { get; private set; }
- public bool IsConnected { get { return stream != null; } }
+ public readonly Steamworks.CSteamID Remote;
public bool IsClosed { get; private set; }
public string CloseReason { get; private set; }
- TcpListener listener;
- TcpClient client;
- NetworkStream stream;
- IAsyncResult connecting;
- bool justConnected;
+ readonly FrameParser parser = new FrameParser();
+ float lastReceive;
- byte[] buffer = new byte[64 * 1024];
- int bufferCount;
+ public SteamConnection(Steamworks.CSteamID remote)
+ {
+ Remote = remote;
+ lastReceive = Time.realtimeSinceStartup;
+ }
+
+ public void OnPacket(byte[] data, int length)
+ {
+ parser.Append(data, length);
+ lastReceive = Time.realtimeSinceStartup;
+ }
+
+ public List Poll()
+ {
+ var messages = new List();
+ if (IsClosed) return messages;
+ try { parser.Parse(messages); }
+ catch (Exception e) { Close("Network error: " + e.Message); }
+ if (Time.realtimeSinceStartup - lastReceive > Timeout) Close("Timed out");
+ return messages;
+ }
+
+ public void Send(MsgType type, byte[] payload)
+ {
+ if (IsClosed) return;
+ byte[] frame = FrameParser.Frame(type, payload);
+ for (int offset = 0; offset < frame.Length; offset += ChunkSize)
+ {
+ int length = Math.Min(ChunkSize, frame.Length - offset);
+ byte[] chunk = frame;
+ if (offset != 0 || length != frame.Length)
+ {
+ chunk = new byte[length];
+ Buffer.BlockCopy(frame, offset, chunk, 0, length);
+ }
+ if (!Steamworks.SteamNetworking.SendP2PPacket(Remote, chunk, (uint)length, Steamworks.EP2PSend.k_EP2PSendReliable, 0))
+ {
+ Close("Steam send failed");
+ return;
+ }
+ }
+ }
+
+ public void Close(string reason)
+ {
+ if (IsClosed) return;
+ IsClosed = true;
+ CloseReason = reason;
+ try { Steamworks.SteamNetworking.CloseP2PSessionWithUser(Remote); } catch { }
+ }
+ }
+
+ public enum NetEventKind { Connected, Disconnected, Message }
+
+ public struct NetEvent
+ {
+ public NetEventKind Kind;
+ public int PeerId; // beim Client immer NetSession.HostId
+ public Message Message;
+ public string Reason;
+ }
+
+ ///
+ /// Stern-Topologie: Der Host hält eine Verbindung zu jedem Client,
+ /// Clients sind nur mit dem Host verbunden. Der Host leitet Nachrichten weiter.
+ /// Transport entweder TCP (LAN/IP) oder Steam P2P.
+ ///
+ public class NetSession
+ {
+ public const int HostId = 0;
+
+ public bool IsHost { get; private set; }
+ public bool IsSteam { get; private set; }
+ public bool IsClosed { get; private set; }
+ public string CloseReason { get; private set; }
+
+ // Host
+ TcpListener listener;
+ readonly Dictionary peers = new Dictionary();
+ readonly Dictionary steamPeerIds = new Dictionary();
+ Func allowSteamPeer;
+ int nextPeerId = 1;
+
+ // Client
+ TcpClient connectingClient;
+ IAsyncResult connecting;
+ IConnection server;
+ bool pendingConnected;
+
+ byte[] steamBuffer = new byte[64 * 1024];
public static NetSession StartHost(int port)
{
@@ -88,127 +360,169 @@ namespace MSCO
public static NetSession StartClient(string ip, int port)
{
var s = new NetSession();
- s.client = new TcpClient();
- s.connecting = s.client.BeginConnect(ip, port, null, null);
+ s.connectingClient = new TcpClient();
+ s.connecting = s.connectingClient.BeginConnect(ip, port, null, null);
return s;
}
- /// True genau einmal, direkt nachdem die Verbindung steht.
- public bool ConsumeConnected()
+ /// Steam-Host: nimmt Pakete von Spielern an, die allowPeer erlaubt (Lobby-Mitglieder).
+ public static NetSession StartSteamHost(Func allowPeer)
{
- bool c = justConnected;
- justConnected = false;
- return c;
+ return new NetSession { IsHost = true, IsSteam = true, allowSteamPeer = allowPeer };
}
- public List Poll()
+ public static NetSession StartSteamClient(Steamworks.CSteamID host)
{
- var messages = new List();
- if (IsClosed) return messages;
+ return new NetSession { IsSteam = true, server = new SteamConnection(host), pendingConnected = true };
+ }
+ public List Poll()
+ {
+ var events = new List();
+ if (IsClosed) return events;
try
{
- AcceptOrConnect();
- if (stream == null) return messages;
-
- // Verbindung von der Gegenseite geschlossen?
- if (client.Client.Poll(0, SelectMode.SelectRead) && client.Available == 0)
- {
- Close("Connection closed by other player");
- return messages;
- }
-
- while (stream.DataAvailable)
- {
- EnsureCapacity(bufferCount + client.Available);
- int read = stream.Read(buffer, bufferCount, buffer.Length - bufferCount);
- if (read <= 0) break;
- bufferCount += read;
- }
-
- ParseFrames(messages);
+ if (IsSteam) PumpSteam(events);
+ if (IsHost) PollHost(events);
+ else PollClient(events);
}
catch (Exception e)
{
Close("Network error: " + e.Message);
}
- return messages;
+ return events;
}
- void AcceptOrConnect()
+ void PumpSteam(List events)
{
- if (IsHost)
+ uint size;
+ while (Steamworks.SteamNetworking.IsP2PPacketAvailable(out size, 0))
+ {
+ if (steamBuffer.Length < size) steamBuffer = new byte[size];
+ uint read;
+ Steamworks.CSteamID from;
+ if (!Steamworks.SteamNetworking.ReadP2PPacket(steamBuffer, size, out read, out from, 0)) break;
+
+ if (IsHost)
+ {
+ int id;
+ if (!steamPeerIds.TryGetValue(from.m_SteamID, out id))
+ {
+ if (allowSteamPeer == null || !allowSteamPeer(from)) continue; // nicht in der Lobby
+ id = nextPeerId++;
+ steamPeerIds[from.m_SteamID] = id;
+ peers[id] = new SteamConnection(from);
+ events.Add(new NetEvent { Kind = NetEventKind.Connected, PeerId = id });
+ }
+ IConnection c;
+ if (peers.TryGetValue(id, out c)) ((SteamConnection)c).OnPacket(steamBuffer, (int)read);
+ }
+ else
+ {
+ var host = server as SteamConnection;
+ if (host != null && from == host.Remote) host.OnPacket(steamBuffer, (int)read);
+ }
+ }
+ }
+
+ void PollHost(List events)
+ {
+ if (listener != null)
{
while (listener.Pending())
{
- TcpClient incoming = listener.AcceptTcpClient();
- if (client != null) { incoming.Close(); continue; } // nur ein Mitspieler
- client = incoming;
- OnSocketReady();
+ int id = nextPeerId++;
+ peers[id] = new Connection(listener.AcceptTcpClient());
+ events.Add(new NetEvent { Kind = NetEventKind.Connected, PeerId = id });
}
}
- else if (connecting != null && connecting.IsCompleted)
+
+ List closed = null;
+ foreach (KeyValuePair p in peers)
{
+ foreach (Message m in p.Value.Poll())
+ events.Add(new NetEvent { Kind = NetEventKind.Message, PeerId = p.Key, Message = m });
+ if (p.Value.IsClosed)
+ {
+ if (closed == null) closed = new List();
+ closed.Add(p.Key);
+ }
+ }
+ if (closed == null) return;
+ foreach (int id in closed)
+ {
+ IConnection c = peers[id];
+ events.Add(new NetEvent { Kind = NetEventKind.Disconnected, PeerId = id, Reason = c.CloseReason });
+ var steamConn = c as SteamConnection;
+ if (steamConn != null) steamPeerIds.Remove(steamConn.Remote.m_SteamID);
+ peers.Remove(id);
+ }
+ }
+
+ void PollClient(List events)
+ {
+ if (connecting != null)
+ {
+ if (!connecting.IsCompleted) return;
IAsyncResult result = connecting;
connecting = null;
- client.EndConnect(result); // wirft, wenn nicht erreichbar
- OnSocketReady();
+ connectingClient.EndConnect(result); // wirft, wenn nicht erreichbar
+ server = new Connection(connectingClient);
+ pendingConnected = true;
}
+ if (server == null) return;
+ if (pendingConnected)
+ {
+ pendingConnected = false;
+ events.Add(new NetEvent { Kind = NetEventKind.Connected, PeerId = HostId });
+ }
+
+ foreach (Message m in server.Poll())
+ events.Add(new NetEvent { Kind = NetEventKind.Message, PeerId = HostId, Message = m });
+ if (server.IsClosed) Close(server.CloseReason);
}
- void OnSocketReady()
+ /// An einen bestimmten Spieler (Client: immer an den Host).
+ public void SendTo(int peerId, MsgType type, byte[] payload = null)
{
- client.NoDelay = true;
- client.SendBufferSize = 4 * 1024 * 1024;
- stream = client.GetStream();
- justConnected = true;
+ if (!IsHost) { if (server != null) server.Send(type, payload); return; }
+ IConnection c;
+ if (peers.TryGetValue(peerId, out c)) c.Send(type, payload);
}
- void ParseFrames(List messages)
+ /// An alle (Client: an den Host). Optional einen Spieler auslassen.
+ public void SendToAll(MsgType type, byte[] payload = null, int except = -1)
{
- int offset = 0;
- while (bufferCount - offset >= 5)
- {
- int length = BitConverter.ToInt32(buffer, offset);
- if (length < 1 || length > MaxFrame) throw new IOException("Invalid packet");
- if (bufferCount - offset - 4 < length) break;
-
- var payload = new byte[length - 1];
- Buffer.BlockCopy(buffer, offset + 5, payload, 0, payload.Length);
- messages.Add(new Message { Type = (MsgType)buffer[offset + 4], Payload = payload });
- offset += 4 + length;
- }
- if (offset > 0)
- {
- Buffer.BlockCopy(buffer, offset, buffer, 0, bufferCount - offset);
- bufferCount -= offset;
- }
+ if (!IsHost) { if (server != null) server.Send(type, payload); return; }
+ foreach (KeyValuePair p in peers)
+ if (p.Key != except) p.Value.Send(type, payload);
}
- void EnsureCapacity(int needed)
+ /// Host: Verbindung zu einem Spieler trennen (wird beim nächsten Poll gemeldet).
+ public void Kick(int peerId, string reason)
{
- if (needed <= buffer.Length) return;
- var bigger = new byte[Math.Max(needed, buffer.Length * 2)];
- Buffer.BlockCopy(buffer, 0, bigger, 0, bufferCount);
- buffer = bigger;
+ IConnection c;
+ if (peers.TryGetValue(peerId, out c)) c.Close(reason);
}
- public void Send(MsgType type, byte[] payload = null)
+ /// Host: Steam-Spieler hat die Lobby verlassen.
+ public void DropSteamPeer(ulong steamId, string reason)
{
- if (stream == null) return;
- if (payload == null) payload = new byte[0];
- try
- {
- var frame = new byte[5 + payload.Length];
- BitConverter.GetBytes(payload.Length + 1).CopyTo(frame, 0);
- frame[4] = (byte)type;
- Buffer.BlockCopy(payload, 0, frame, 5, payload.Length);
- stream.Write(frame, 0, frame.Length); // blockierend, damit große Dateien komplett rausgehen
- }
- catch (Exception e)
- {
- Close("Network error: " + e.Message);
- }
+ int id;
+ if (steamPeerIds.TryGetValue(steamId, out id)) Kick(id, reason);
+ }
+
+ public void Close(string reason = "Disconnected")
+ {
+ if (IsClosed) return;
+ IsClosed = true;
+ CloseReason = reason;
+ foreach (IConnection c in peers.Values) c.Close(reason);
+ peers.Clear();
+ steamPeerIds.Clear();
+ if (server != null) server.Close(reason);
+ try { if (connectingClient != null && server == null) connectingClient.Close(); } catch { }
+ try { if (listener != null) listener.Stop(); } catch { }
}
public static byte[] Build(Action write)
@@ -222,14 +536,13 @@ namespace MSCO
}
}
- public void Close(string reason = "Disconnected")
+ /// Stellt der Nachricht die Absender-ID voran (für weitergeleitete Nachrichten).
+ public static byte[] WithSender(int senderId, byte[] payload)
{
- if (IsClosed) return;
- IsClosed = true;
- CloseReason = reason;
- stream = null;
- try { if (client != null) client.Close(); } catch { }
- try { if (listener != null) listener.Stop(); } catch { }
+ var result = new byte[4 + payload.Length];
+ BitConverter.GetBytes(senderId).CopyTo(result, 0);
+ Buffer.BlockCopy(payload, 0, result, 4, payload.Length);
+ return result;
}
}
}
diff --git a/ObjectSync.cs b/ObjectSync.cs
new file mode 100644
index 0000000..42a2a57
--- /dev/null
+++ b/ObjectSync.cs
@@ -0,0 +1,702 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using HutongGames.PlayMaker;
+using MSCLoader;
+using UnityEngine;
+
+namespace MSCO
+{
+ /// Ein synchronisierter Rigidbody (Item, loses Teil oder Fahrzeug).
+ public class SyncedBody
+ {
+ public const int NoOwner = -1;
+
+ public int Id;
+ public string Path;
+ public Rigidbody Rb;
+ public bool IsVehicle;
+ public string RootName;
+
+ public int OwnerId = NoOwner;
+ public bool OwnerActive; // Besitzer hält/fährt es gerade aktiv
+ public bool WasKinematic; // Zustand bevor ein anderer Spieler es übernommen hat
+ public float StillTime;
+ public float NoMotionClaimUntil; // kurz nach einer Übergabe nicht wegen Nachfedern/Nachrollen übernehmen
+
+ public Vector3 TargetPos;
+ public Quaternion TargetRot;
+ public float LastRecvTime;
+ public float AvatarHoldTime = -100f; // zuletzt von der Hand eines Mitspielers platziert
+
+ public Vector3 LastSentPos;
+ public Quaternion LastSentRot;
+ public float LastSentTime;
+ }
+
+ ///
+ /// Synchronisiert Items und Fahrzeuge mit Ownership für beliebig viele Spieler:
+ /// Wer ein Objekt bewegt (aufheben, fahren, mit G schieben, anstoßen), wird Besitzer
+ /// und sendet dessen Position. Bei allen anderen ist es solange kinematisch
+ /// und folgt nur. Kommt das Objekt zur Ruhe, wird es wieder freigegeben.
+ ///
+ public class ObjectSync
+ {
+ public static readonly string[] VehicleRoots =
+ {
+ "SATSUMA", "HAYOSIKO", "KEKMET", "FERNDALE", "GIFU", "JONNEZ",
+ "RCO_RUSCKO", "FLATBED", "COMBINE", "BOAT"
+ };
+
+ // Wurzelobjekte, die jeder Spieler selbst simuliert (Verkehr, NPCs, man selbst)
+ static readonly string[] IgnoredRoots = { "PLAYER", "TRAFFIC", "NPC_CARS" };
+
+ const float MaxSmallItemMass = 60f; // schwerere Dinge ohne ITEM/PART-Tag sind Teil der Welt
+ const float SendInterval = 1f / 15f;
+ const float KeepAliveInterval = 1f;
+ const float ScanInterval = 3f;
+ const float FirstScanDelay = 3f;
+ const float MotionClaimGrace = 10f; // nach dem Laden fällt noch alles an seinen Platz
+ const float ItemClaimRadius = 4f;
+ const float VehicleClaimRadius = 8f;
+ const float ReleaseAfterStill = 1f;
+ const float SnapDistance = 10f;
+ const float RemoteTimeout = 3f; // Besitzer meldet sich nicht mehr (z.B. Teil wurde dort montiert)
+ const float HandoverCooldown = 3f; // nach Freigabe/Übergabe keine Übernahme durch Bewegung
+ const float MaxHoldDistance = 1.5f; // gehaltene Items sind so nah an der Kamera
+ const float VehicleClaimSpeed = 0.7f; // Fahrzeuge erst ab echter Bewegung übernehmen (nicht Federn)
+
+ /// Kamera des lokalen Spielers (zum Prüfen, ob ein Item wirklich in der Hand ist).
+ public Transform Head;
+
+ /// Eigene Spieler-ID (Host = 0).
+ public int LocalId;
+ /// Senden an alle anderen Spieler (setzt der MultiplayerManager).
+ public Action Send;
+
+ readonly Dictionary byId = new Dictionary();
+ readonly Dictionary byRb = new Dictionary();
+ readonly List removeList = new List();
+ readonly List sendList = new List();
+ readonly HashSet rejected = new HashSet(); // schon geprüft, gehört nicht dazu
+ float nextRejectClear;
+ string drivenName;
+ SyncedBody drivenCache;
+
+ float loadTime;
+ float nextScan;
+ float sendTimer;
+
+ FsmGameObject pickedObject;
+ FsmString currentVehicle;
+ float nextFsmSearch;
+
+ public int Count { get { return byId.Count; } }
+
+ /// Erster Suchlauf nach dem Laden erledigt (vorher kann kein Snapshot angewendet werden).
+ public bool ScanComplete { get; private set; }
+
+ /// Position aus dem Server-Snapshot übernehmen (nur für Objekte, die gerade niemand bewegt).
+ public bool ApplySnapshotPose(int id, Vector3 pos, Quaternion rot)
+ {
+ SyncedBody b;
+ if (!byId.TryGetValue(id, out b) || b.Rb == null || b.OwnerId != SyncedBody.NoOwner) return false;
+ b.Rb.transform.position = pos;
+ b.Rb.transform.rotation = rot;
+ if (!b.Rb.isKinematic)
+ {
+ b.Rb.velocity = Vector3.zero;
+ b.Rb.angularVelocity = Vector3.zero;
+ b.Rb.Sleep();
+ }
+ return true;
+ }
+
+ public SyncedBody Get(int id)
+ {
+ SyncedBody b;
+ return byId.TryGetValue(id, out b) ? b : null;
+ }
+
+ public bool IsRemoteOwnedBody(SyncedBody b) { return IsRemoteOwned(b); }
+
+ /// Für "mp vehicles": Zustand aller erfassten Fahrzeuge (zur Fehlersuche).
+ public List DescribeVehicles()
+ {
+ var lines = new List();
+ string driving = currentVehicle != null ? currentVehicle.Value : "";
+ lines.Add("PlayerCurrentVehicle = '" + driving + "', my id = " + LocalId + ", synced objects = " + byId.Count);
+ foreach (SyncedBody b in byId.Values)
+ {
+ if (!b.IsVehicle) continue;
+ string owner = b.OwnerId == SyncedBody.NoOwner ? "free" : IsLocal(b) ? "ME" : "player " + b.OwnerId;
+ lines.Add(b.RootName + " id=" + b.Id + " owner=" + owner + (b.OwnerActive ? " (active)" : "") +
+ (b.Rb == null ? " [rigidbody gone]" : " kinematic=" + b.Rb.isKinematic + " speed=" + b.Rb.velocity.magnitude.ToString("0.0")));
+ }
+ if (lines.Count == 1) lines.Add("No vehicles registered! (SATSUMA, HAYOSIKO, ... not found as root objects with a Rigidbody)");
+ return lines;
+ }
+
+ /// Fährt der lokale Spieler gerade selbst?
+ public bool IsDriving { get; private set; }
+
+ /// Fahrzeuge, die gerade ein anderer Spieler aktiv fährt.
+ public List RemoteDrivenVehicles()
+ {
+ var list = new List();
+ foreach (SyncedBody b in byId.Values)
+ if (b.IsVehicle && IsRemoteOwned(b) && b.OwnerActive && b.Rb != null) list.Add(b);
+ return list;
+ }
+
+ /// Fahrzeuge, die der lokale Spieler gerade besitzt (fährt/schiebt).
+ public List LocalVehicles()
+ {
+ var list = new List();
+ foreach (SyncedBody b in byId.Values)
+ if (b.IsVehicle && IsLocal(b) && b.Rb != null) list.Add(b);
+ return list;
+ }
+
+ /// Was der lokale Spieler gerade in der Hand hat (nur wenn er es besitzt).
+ public SyncedBody Held { get; private set; }
+
+ public void OnGameLoaded()
+ {
+ Reset();
+ loadTime = Time.time;
+ nextScan = Time.time + FirstScanDelay;
+ }
+
+ /// Alles freigeben und vergessen (Szenenwechsel oder Verbindungsende).
+ public void Reset()
+ {
+ foreach (SyncedBody b in byId.Values)
+ if (IsRemoteOwned(b) && b.Rb != null) b.Rb.isKinematic = b.WasKinematic;
+ byId.Clear();
+ byRb.Clear();
+ rejected.Clear();
+ drivenName = null;
+ drivenCache = null;
+ ScanComplete = false;
+ pickedObject = null;
+ currentVehicle = null;
+ }
+
+ /// Spieler hat das Spiel verlassen: seine Objekte freigeben.
+ public void ReleaseAllOf(int playerId)
+ {
+ foreach (SyncedBody b in byId.Values)
+ {
+ if (b.OwnerId != playerId || b.Rb == null) continue;
+ b.Rb.isKinematic = b.WasKinematic;
+ b.OwnerId = SyncedBody.NoOwner;
+ b.NoMotionClaimUntil = Time.time + HandoverCooldown;
+ }
+ }
+
+ /// Besitzer eines Fahrzeugs (nach Wurzelname), NoOwner wenn frei oder unbekannt.
+ public int VehicleOwner(string rootName)
+ {
+ foreach (SyncedBody b in byId.Values)
+ if (b.IsVehicle && b.RootName == rootName) return b.OwnerId;
+ return SyncedBody.NoOwner;
+ }
+
+ bool IsLocal(SyncedBody b) { return b.OwnerId == LocalId; }
+ bool IsRemoteOwned(SyncedBody b) { return b.OwnerId != SyncedBody.NoOwner && b.OwnerId != LocalId; }
+
+ ///
+ /// Konfliktregel, die jeder Spieler gleich anwendet: Wer das Objekt aktiv hält/fährt
+ /// gewinnt, sonst die kleinere Spieler-ID (Host = 0 gewinnt immer bei Gleichstand).
+ ///
+ static bool Wins(int newId, bool newActive, int curId, bool curActive)
+ {
+ if (curId == SyncedBody.NoOwner || newId == curId) return true;
+ if (newActive != curActive) return newActive;
+ return newId < curId;
+ }
+
+ // ---------------------------------------------------------------- Update
+
+ public void Update(Transform player, float dt)
+ {
+ if (Time.time >= nextScan)
+ {
+ nextScan = Time.time + ScanInterval;
+ Scan();
+ ScanComplete = true;
+ }
+ FindPlayerFsms();
+
+ SyncedBody held = HeldBody();
+ SyncedBody driven = DrivenBody();
+ IsDriving = driven != null;
+ bool motionClaimAllowed = Time.time - loadTime > MotionClaimGrace;
+
+ removeList.Clear();
+ foreach (SyncedBody b in byId.Values)
+ {
+ if (b.Rb == null) { removeList.Add(b); continue; } // z.B. Teil wurde montiert
+ if (!b.Rb.gameObject.activeInHierarchy) continue;
+
+ bool active = b == held || b == driven;
+ if (b.OwnerId == SyncedBody.NoOwner)
+ {
+ if (active) Claim(b, true);
+ else if (motionClaimAllowed && Time.time >= b.NoMotionClaimUntil && IsMoving(b) && IsNear(b, player)) Claim(b, false);
+ }
+ else if (IsLocal(b))
+ {
+ if (b.OwnerActive != active)
+ {
+ // z.B. erst angeschoben, dann eingestiegen: allen Bescheid geben
+ b.OwnerActive = active;
+ SendOwn(b);
+ }
+ if (!active && (b.Rb.IsSleeping() || IsSlow(b.Rb)))
+ {
+ b.StillTime += dt;
+ if (b.StillTime >= ReleaseAfterStill) Release(b);
+ }
+ else b.StillTime = 0f;
+ }
+ else // gehört einem anderen Spieler
+ {
+ if (active && Wins(LocalId, true, b.OwnerId, b.OwnerActive)) Claim(b, true); // Spieler greift sich das Objekt
+ else if (Time.time - b.LastRecvTime > RemoteTimeout) SetFree(b);
+ }
+ }
+ foreach (SyncedBody b in removeList) Unregister(b);
+ Held = held != null && held.Rb != null && IsLocal(held) ? held : null;
+
+ sendTimer += dt;
+ if (sendTimer >= SendInterval)
+ {
+ sendTimer = 0f;
+ SendOwnedStates();
+ }
+ }
+
+ /// Objekte anderer Spieler weich zur Zielposition bewegen.
+ public void FixedUpdate()
+ {
+ foreach (SyncedBody b in byId.Values)
+ {
+ if (!IsRemoteOwned(b) || b.Rb == null) continue;
+ if (Time.time - b.AvatarHoldTime < 0.2f) continue; // sitzt gerade in der Hand eines Mitspielers
+ if ((b.Rb.position - b.TargetPos).sqrMagnitude > SnapDistance * SnapDistance)
+ {
+ b.Rb.transform.position = b.TargetPos;
+ b.Rb.transform.rotation = b.TargetRot;
+ }
+ else
+ {
+ b.Rb.MovePosition(Vector3.Lerp(b.Rb.position, b.TargetPos, 0.35f));
+ b.Rb.MoveRotation(Quaternion.Slerp(b.Rb.rotation, b.TargetRot, 0.35f));
+ }
+ }
+ }
+
+ ///
+ /// Ein Mitspieler hält dieses Objekt: direkt an seine Hand setzen (Position und Winkel
+ /// relativ zu seinem Kopf), statt der verzögerten Weltposition zu folgen.
+ ///
+ public void ApplyAvatarHold(int playerId, int bodyId, Vector3 pos, Quaternion rot)
+ {
+ SyncedBody b;
+ if (!byId.TryGetValue(bodyId, out b) || b.Rb == null) return;
+ if (b.OwnerId != playerId)
+ {
+ if (!Wins(playerId, true, b.OwnerId, b.OwnerActive)) return;
+ SetRemote(b, playerId, true);
+ }
+ else
+ {
+ b.LastRecvTime = Time.time;
+ b.OwnerActive = true;
+ }
+ b.Rb.transform.position = pos;
+ b.Rb.transform.rotation = rot;
+ b.TargetPos = pos;
+ b.TargetRot = rot;
+ b.AvatarHoldTime = Time.time;
+ }
+
+ // ---------------------------------------------------------------- Ownership
+
+ void Claim(SyncedBody b, bool active)
+ {
+ if (IsRemoteOwned(b)) b.Rb.isKinematic = b.WasKinematic;
+ b.OwnerId = LocalId;
+ b.OwnerActive = active;
+ b.StillTime = 0f;
+ b.LastSentTime = -100f; // Zustand sofort senden
+ SendOwn(b);
+ }
+
+ void SendOwn(SyncedBody b)
+ {
+ int id = b.Id;
+ bool active = b.OwnerActive;
+ Send(MsgType.ObjOwn, NetSession.Build(w => { w.Write(id); w.Write(active); }));
+ }
+
+ void Release(SyncedBody b)
+ {
+ b.OwnerId = SyncedBody.NoOwner;
+ b.NoMotionClaimUntil = Time.time + HandoverCooldown;
+ Send(MsgType.ObjRelease, NetSession.Build(w => WriteState(w, b)));
+ }
+
+ void SetRemote(SyncedBody b, int ownerId, bool active)
+ {
+ b.LastRecvTime = Time.time;
+ b.OwnerActive = active;
+ if (b.OwnerId == ownerId) return;
+ if (!IsRemoteOwned(b))
+ {
+ b.WasKinematic = b.Rb.isKinematic;
+ b.Rb.isKinematic = true;
+ b.TargetPos = b.Rb.position;
+ b.TargetRot = b.Rb.rotation;
+ }
+ b.OwnerId = ownerId;
+ }
+
+ void SetFree(SyncedBody b)
+ {
+ if (IsRemoteOwned(b)) b.Rb.isKinematic = b.WasKinematic;
+ b.OwnerId = SyncedBody.NoOwner;
+ b.NoMotionClaimUntil = Time.time + HandoverCooldown;
+ }
+
+ void SendOwnedStates()
+ {
+ sendList.Clear();
+ float now = Time.time;
+ foreach (SyncedBody b in byId.Values)
+ {
+ if (!IsLocal(b) || b.Rb == null) continue;
+ bool changed = (b.Rb.position - b.LastSentPos).sqrMagnitude > 0.0001f
+ || Quaternion.Angle(b.Rb.rotation, b.LastSentRot) > 0.5f;
+ if (!changed && now - b.LastSentTime < KeepAliveInterval) continue;
+ b.LastSentPos = b.Rb.position;
+ b.LastSentRot = b.Rb.rotation;
+ b.LastSentTime = now;
+ sendList.Add(b);
+ }
+ if (sendList.Count == 0) return;
+
+ Send(MsgType.ObjStates, NetSession.Build(w =>
+ {
+ w.Write(sendList.Count);
+ foreach (SyncedBody b in sendList) WriteState(w, b);
+ }));
+ }
+
+ // ---------------------------------------------------------------- Empfang
+
+ public void Handle(int from, MsgType type, BinaryReader r)
+ {
+ switch (type)
+ {
+ case MsgType.ObjOwn:
+ {
+ int id = r.ReadInt32();
+ bool active = r.ReadBoolean();
+ SyncedBody b;
+ if (!byId.TryGetValue(id, out b) || b.Rb == null) return;
+ if (!Wins(from, active, b.OwnerId, b.OwnerActive))
+ {
+ // Wir besitzen es und gewinnen: nochmal melden, damit alle es wissen
+ if (IsLocal(b)) SendOwn(b);
+ return;
+ }
+ SetRemote(b, from, active);
+ break;
+ }
+
+ case MsgType.ObjStates:
+ {
+ int count = r.ReadInt32();
+ for (int i = 0; i < count; i++)
+ {
+ int id; Vector3 pos; Quaternion rot; Vector3 vel;
+ ReadState(r, out id, out pos, out rot, out vel);
+ SyncedBody b;
+ if (!byId.TryGetValue(id, out b) || b.Rb == null) continue;
+ // Nur dem aktuellen Besitzer folgen (oder freie Objekte übernehmen)
+ if (b.OwnerId != from && b.OwnerId != SyncedBody.NoOwner) continue;
+ SetRemote(b, from, b.OwnerId == from && b.OwnerActive);
+ b.TargetPos = pos;
+ b.TargetRot = rot;
+ }
+ break;
+ }
+
+ case MsgType.ObjRelease:
+ {
+ int id; Vector3 pos; Quaternion rot; Vector3 vel;
+ ReadState(r, out id, out pos, out rot, out vel);
+ SyncedBody b;
+ if (!byId.TryGetValue(id, out b) || b.Rb == null || b.OwnerId != from) return;
+ b.Rb.transform.position = pos;
+ b.Rb.transform.rotation = rot;
+ b.Rb.isKinematic = b.WasKinematic;
+ if (!b.Rb.isKinematic)
+ {
+ b.Rb.velocity = vel;
+ b.Rb.angularVelocity = Vector3.zero;
+ }
+ b.OwnerId = SyncedBody.NoOwner;
+ b.NoMotionClaimUntil = Time.time + HandoverCooldown; // Nachrollen/Nachfedern nicht als "ich bewege es" werten
+ break;
+ }
+ }
+ }
+
+ static void WriteState(BinaryWriter w, SyncedBody b)
+ {
+ Vector3 p = b.Rb.position;
+ Quaternion q = b.Rb.rotation;
+ Vector3 v = b.Rb.isKinematic ? Vector3.zero : b.Rb.velocity;
+ w.Write(b.Id);
+ w.Write(p.x); w.Write(p.y); w.Write(p.z);
+ w.Write(q.x); w.Write(q.y); w.Write(q.z); w.Write(q.w);
+ w.Write(v.x); w.Write(v.y); w.Write(v.z);
+ }
+
+ static void ReadState(BinaryReader r, out int id, out Vector3 pos, out Quaternion rot, out Vector3 vel)
+ {
+ id = r.ReadInt32();
+ pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
+ rot = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
+ vel = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
+ }
+
+ // ---------------------------------------------------------------- Erkennung
+
+ static bool IsMoving(SyncedBody b)
+ {
+ Rigidbody rb = b.Rb;
+ if (rb.isKinematic || rb.IsSleeping()) return false;
+ // Fahrzeuge federn/wackeln ständig ein bisschen - nur echtes Rollen/Schieben zählt
+ if (b.IsVehicle) return rb.velocity.sqrMagnitude > VehicleClaimSpeed * VehicleClaimSpeed;
+ return rb.velocity.sqrMagnitude > 0.04f || rb.angularVelocity.sqrMagnitude > 0.25f;
+ }
+
+ static bool IsSlow(Rigidbody rb)
+ {
+ if (rb.isKinematic) return true;
+ return rb.velocity.sqrMagnitude < 0.01f && rb.angularVelocity.sqrMagnitude < 0.04f;
+ }
+
+ static bool IsNear(SyncedBody b, Transform player)
+ {
+ float radius = b.IsVehicle ? VehicleClaimRadius : ItemClaimRadius;
+ return (b.Rb.worldCenterOfMass - player.position).sqrMagnitude < radius * radius;
+ }
+
+ void FindPlayerFsms()
+ {
+ if ((pickedObject != null && currentVehicle != null) || Time.time < nextFsmSearch) return;
+ nextFsmSearch = Time.time + 2f;
+
+ if (currentVehicle == null)
+ currentVehicle = FsmVariables.GlobalVariables.FindFsmString("PlayerCurrentVehicle");
+
+ if (pickedObject == null)
+ {
+ GameObject player = GameObject.Find("PLAYER");
+ if (player == null) return;
+ // Es gibt mehrere Hände (z.B. "HandOld"); die aktive bevorzugen
+ PlayMakerFSM best = null;
+ foreach (PlayMakerFSM fsm in player.GetComponentsInChildren(true))
+ {
+ if (fsm.FsmName != "PickUp" || fsm.FsmVariables.FindFsmGameObject("PickedObject") == null) continue;
+ if (best == null || (!best.gameObject.activeInHierarchy && fsm.gameObject.activeInHierarchy)) best = fsm;
+ }
+ if (best != null)
+ {
+ pickedObject = best.FsmVariables.FindFsmGameObject("PickedObject");
+ ModConsole.Print("[MSCO] Found pickup FSM on " + best.gameObject.name +
+ (best.gameObject.activeInHierarchy ? "" : " (inactive)"));
+ }
+ }
+ }
+
+ SyncedBody HeldBody()
+ {
+ if (pickedObject == null || pickedObject.Value == null) return null;
+ for (Transform t = pickedObject.Value.transform; t != null; t = t.parent)
+ {
+ Rigidbody rb = t.GetComponent();
+ SyncedBody b;
+ if (rb != null && byRb.TryGetValue(rb, out b)) return IsReallyInHand(b) ? b : null;
+ }
+ return null;
+ }
+
+ ///
+ /// PickedObject bleibt nach dem Loslassen oft gesetzt. Dann würde die Mod das Item weiter als
+ /// "in der Hand" melden und sich mit dem Mitspieler darum streiten (Item zittert bei beiden).
+ /// Deshalb zusätzlich prüfen: liegt es wirklich direkt vor der Kamera?
+ ///
+ bool IsReallyInHand(SyncedBody b)
+ {
+ if (Head == null) return true;
+ Vector3 offset = b.Rb.worldCenterOfMass - Head.position;
+ if (offset.sqrMagnitude > MaxHoldDistance * MaxHoldDistance) return false;
+ return Vector3.Dot(offset.normalized, Head.forward) > 0.2f; // vor dem Spieler, nicht unter den Füßen
+ }
+
+ SyncedBody DrivenBody()
+ {
+ if (currentVehicle == null || string.IsNullOrEmpty(currentVehicle.Value)) return null;
+ // Nur neu suchen, wenn sich das Fahrzeug geändert hat (wird jeden Frame gefragt)
+ if (currentVehicle.Value == drivenName && drivenCache != null && drivenCache.Rb != null) return drivenCache;
+ drivenName = currentVehicle.Value;
+ drivenCache = null;
+ string vehicle = drivenName.ToUpper();
+ foreach (SyncedBody b in byId.Values)
+ if (b.IsVehicle && b.RootName.ToUpper().Contains(vehicle)) { drivenCache = b; break; }
+ return drivenCache;
+ }
+
+ // ---------------------------------------------------------------- Registrierung
+
+ /// Neue Rigidbodies suchen (gekaufte Items, abmontierte Teile ...).
+ void Scan()
+ {
+ // Ab und zu neu prüfen (z.B. abmontierte Teile, die jetzt frei beweglich sind)
+ if (Time.time >= nextRejectClear) { rejected.Clear(); nextRejectClear = Time.time + 30f; }
+ var candidates = new List>();
+ var rootsByName = new Dictionary>();
+
+ foreach (UnityEngine.Object o in UnityEngine.Object.FindObjectsOfType(typeof(Rigidbody)))
+ {
+ var rb = (Rigidbody)o;
+ Transform root = rb.transform.root;
+ List roots;
+ if (!rootsByName.TryGetValue(root.name, out roots)) rootsByName[root.name] = roots = new List();
+ if (!roots.Contains(root)) roots.Add(root);
+
+ bool isVehicle;
+ if (byRb.ContainsKey(rb) || rejected.Contains(rb)) continue;
+ if (ShouldSync(rb, out isVehicle)) candidates.Add(new KeyValuePair(rb, isVehicle));
+ else rejected.Add(rb); // nicht bei jedem Suchlauf wieder die ganze Hierarchie prüfen
+ }
+ if (candidates.Count == 0) return;
+
+ foreach (List roots in rootsByName.Values)
+ roots.Sort((a, b) => a.GetSiblingIndex().CompareTo(b.GetSiblingIndex()));
+
+ int added = 0;
+ foreach (KeyValuePair c in candidates)
+ {
+ string path = BuildPath(c.Key.transform, rootsByName);
+ int id = Hash(path);
+ if (byId.ContainsKey(id)) continue; // gleicher Pfad doppelt - nicht eindeutig zuordenbar
+
+ var b = new SyncedBody
+ {
+ Id = id,
+ Path = path,
+ Rb = c.Key,
+ IsVehicle = c.Value,
+ RootName = c.Key.transform.root.name
+ };
+ byId[id] = b;
+ byRb[c.Key] = b;
+ added++;
+ }
+ if (added > 0) ModConsole.Print("[MSCO] Syncing " + byId.Count + " objects (+" + added + ")");
+ }
+
+ static bool ShouldSync(Rigidbody rb, out bool isVehicle)
+ {
+ isVehicle = false;
+ Transform t = rb.transform;
+ string rootName = t.root.name;
+ if (rootName.StartsWith("MSCO_") || Array.IndexOf(IgnoredRoots, rootName) >= 0) return false;
+
+ // Rigidbodies unter einem anderen Rigidbody (Türen, Motorhaube ...) bewegen sich mit
+ for (Transform p = t.parent; p != null; p = p.parent)
+ if (p.GetComponent() != null) return false;
+
+ bool underVehicle = IsVehicleRoot(rootName);
+ if (underVehicle && t.parent == null)
+ {
+ isVehicle = true;
+ return true;
+ }
+
+ // Items und lose Teile sind im Spiel getaggt
+ if (rb.CompareTag("ITEM") || rb.CompareTag("PART")) return true;
+
+ // Alle anderen kleinen, frei beweglichen Dinge (Flaschen, Essen, Werkzeug ...),
+ // aber keine Gelenk-Objekte (Türen, Ragdolls) und nichts Animiertes (NPCs)
+ if (underVehicle || rb.mass > MaxSmallItemMass) return false;
+ if (rb.GetComponent() != null) return false;
+ for (Transform p = t; p != null; p = p.parent)
+ if (p.GetComponent() != null || p.GetComponent() != null) return false;
+ return true;
+ }
+
+ static bool IsVehicleRoot(string name)
+ {
+ foreach (string v in VehicleRoots)
+ if (name.StartsWith(v)) return true;
+ return false;
+ }
+
+ ///
+ /// Pfad wie "ITEMS#0/beer case(itemx)#3": Name plus Nummer unter gleichnamigen Geschwistern.
+ /// Alle Spieler laden denselben Spielstand, dadurch ist der Pfad überall gleich.
+ ///
+ static string BuildPath(Transform t, Dictionary> rootsByName)
+ {
+ var segments = new List();
+ for (; t != null; t = t.parent)
+ {
+ int ordinal = 0;
+ if (t.parent == null)
+ {
+ List roots;
+ if (rootsByName.TryGetValue(t.name, out roots)) ordinal = roots.IndexOf(t);
+ }
+ else
+ {
+ Transform parent = t.parent;
+ for (int i = 0; i < parent.childCount; i++)
+ {
+ Transform sibling = parent.GetChild(i);
+ if (sibling == t) break;
+ if (sibling.name == t.name) ordinal++;
+ }
+ }
+ segments.Add(t.name + "#" + ordinal);
+ }
+ segments.Reverse();
+ return string.Join("/", segments.ToArray());
+ }
+
+ static int Hash(string s)
+ {
+ unchecked
+ {
+ uint h = 2166136261;
+ foreach (byte c in Encoding.UTF8.GetBytes(s)) { h ^= c; h *= 16777619; }
+ return (int)h;
+ }
+ }
+
+ void Unregister(SyncedBody b)
+ {
+ byId.Remove(b.Id);
+ byRb.Remove(b.Rb); // funktioniert auch, wenn der Rigidbody schon zerstört wurde
+ }
+ }
+}
diff --git a/Overlays.cs b/Overlays.cs
new file mode 100644
index 0000000..f27364c
--- /dev/null
+++ b/Overlays.cs
@@ -0,0 +1,351 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MSCO
+{
+ /// Spieler im Admin-Menü.
+ public class AdminPlayer
+ {
+ public int Id;
+ public string Name;
+ public string SteamId;
+ public bool IsAdmin;
+ }
+
+ ///
+ /// Fenster über dem Spiel: F8-Konsole (alle MSCO-Befehle), Chat (T) und Admin-Menü (/admin).
+ /// Solange eins davon offen ist, sind Spielerbewegung und Kamera gesperrt (InputLock).
+ ///
+ public class Overlays
+ {
+ const int MaxConsoleLines = 300;
+ const int MaxChatLines = 60;
+ const float ChatFadeSeconds = 15f;
+ const string ConsoleControl = "msco_console";
+ const string ChatControl = "msco_chat";
+
+ class ChatLine
+ {
+ public string Name, Text;
+ public bool System;
+ public float Time;
+ }
+
+ // Konsole
+ public bool ConsoleOpen { get; private set; }
+ readonly List consoleLines = new List();
+ readonly List history = new List();
+ int historyIndex = -1;
+ string consoleInput = "";
+ Vector2 consoleScroll;
+ int consoleOpenedFrame = -1;
+ bool consoleScrollToEnd;
+
+ // Chat
+ public bool ChatOpen { get; private set; }
+ readonly List chat = new List();
+ string chatInput = "";
+ int chatOpenedFrame = -1;
+
+ // Admin
+ public bool AdminOpen { get; private set; }
+ List adminPlayers;
+ string adminReason = "";
+ int confirmBanId = -1;
+ Vector2 adminScroll;
+
+ public bool AnyOpen { get { return ConsoleOpen || ChatOpen || AdminOpen; } }
+
+ // Stile
+ bool stylesReady;
+ GUIStyle panel, rowStyle, field, logText, caption, title, primary, secondary, danger, chatName, chatText, chatSystem, small;
+
+ // ---------------------------------------------------------------- Öffnen/Schließen
+
+ public void ToggleConsole()
+ {
+ ConsoleOpen = !ConsoleOpen;
+ if (ConsoleOpen)
+ {
+ ChatOpen = false;
+ consoleOpenedFrame = Time.frameCount;
+ consoleScrollToEnd = true;
+ }
+ }
+
+ public void OpenChat()
+ {
+ if (AnyOpen) return;
+ ChatOpen = true;
+ chatOpenedFrame = Time.frameCount;
+ chatInput = "";
+ }
+
+ public void OpenAdmin(List players)
+ {
+ AdminOpen = true;
+ ConsoleOpen = false;
+ ChatOpen = false;
+ adminPlayers = players;
+ confirmBanId = -1;
+ }
+
+ public void SetAdminPlayers(List players) { adminPlayers = players; }
+
+ public void CloseAll()
+ {
+ ConsoleOpen = ChatOpen = AdminOpen = false;
+ }
+
+ // ---------------------------------------------------------------- Inhalte
+
+ public void Log(string line)
+ {
+ foreach (string part in (line ?? "").Split('\n'))
+ {
+ consoleLines.Add(part);
+ if (consoleLines.Count > MaxConsoleLines) consoleLines.RemoveAt(0);
+ }
+ consoleScrollToEnd = true;
+ }
+
+ public void AddChat(string name, string text, bool system = false)
+ {
+ chat.Add(new ChatLine { Name = name, Text = text, System = system, Time = Time.realtimeSinceStartup });
+ if (chat.Count > MaxChatLines) chat.RemoveAt(0);
+ }
+
+ // ---------------------------------------------------------------- Zeichnen (OnGUI)
+
+ public void Draw(MultiplayerManager mp, bool inGame)
+ {
+ EnsureStyles();
+ if (inGame) DrawChat(mp);
+ if (ConsoleOpen) DrawConsole();
+ if (AdminOpen) DrawAdmin(mp);
+ }
+
+ void DrawConsole()
+ {
+ float width = Mathf.Min(Screen.width - 40, 960);
+ var rect = new Rect((Screen.width - width) / 2f, 20, width, Mathf.Min(340, Screen.height * 0.5f));
+ GUI.Box(rect, GUIContent.none, panel);
+
+ GUI.Label(new Rect(rect.x + 16, rect.y + 10, 300, 20), "MSCO CONSOLE", caption);
+ GUI.Label(new Rect(rect.xMax - 216, rect.y + 10, 200, 20), "type 'help' - F8 to close", small);
+
+ // Tasten: Enter ausführen, Esc schließen, Hoch/Runter Verlauf
+ Event e = Event.current;
+ if (e.type == EventType.KeyDown && GUI.GetNameOfFocusedControl() == ConsoleControl)
+ {
+ if (e.keyCode == KeyCode.Return || e.keyCode == KeyCode.KeypadEnter)
+ {
+ RunConsoleLine(consoleInput);
+ consoleInput = "";
+ e.Use();
+ }
+ else if (e.keyCode == KeyCode.Escape) { ConsoleOpen = false; e.Use(); return; }
+ else if (e.keyCode == KeyCode.UpArrow && history.Count > 0)
+ {
+ historyIndex = historyIndex < 0 ? history.Count - 1 : Mathf.Max(0, historyIndex - 1);
+ consoleInput = history[historyIndex];
+ e.Use();
+ }
+ else if (e.keyCode == KeyCode.DownArrow && historyIndex >= 0)
+ {
+ historyIndex++;
+ consoleInput = historyIndex < history.Count ? history[historyIndex] : "";
+ if (historyIndex >= history.Count) historyIndex = -1;
+ e.Use();
+ }
+ }
+
+ GUILayout.BeginArea(new Rect(rect.x + 14, rect.y + 34, rect.width - 28, rect.height - 84));
+ if (consoleScrollToEnd && e.type == EventType.Repaint) { consoleScroll.y = float.MaxValue; consoleScrollToEnd = false; }
+ consoleScroll = GUILayout.BeginScrollView(consoleScroll);
+ foreach (string line in consoleLines) GUILayout.Label(line, logText);
+ GUILayout.EndScrollView();
+ GUILayout.EndArea();
+
+ // Taste, mit der die Konsole geöffnet wurde, nicht als Zeichen übernehmen
+ if (Time.frameCount == consoleOpenedFrame && e.type == EventType.KeyDown) e.Use();
+ GUI.SetNextControlName(ConsoleControl);
+ consoleInput = GUI.TextField(new Rect(rect.x + 14, rect.yMax - 46, rect.width - 28, 34), consoleInput, 200, field);
+ GUI.FocusControl(ConsoleControl);
+ }
+
+ void RunConsoleLine(string line)
+ {
+ line = (line ?? "").Trim();
+ if (line.Length == 0) return;
+ history.Add(line);
+ if (history.Count > 50) history.RemoveAt(0);
+ historyIndex = -1;
+ Log("> " + line);
+
+ if (line == "clear") { consoleLines.Clear(); return; }
+ string[] args = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
+ if (args.Length > 0 && args[0] == "mp") // "mp connect ..." geht auch
+ {
+ var rest = new string[args.Length - 1];
+ Array.Copy(args, 1, rest, 0, rest.Length);
+ args = rest;
+ }
+ Commands.Execute(args, Log);
+ }
+
+ void DrawChat(MultiplayerManager mp)
+ {
+ float now = Time.realtimeSinceStartup;
+ float width = Mathf.Min(460, Screen.width - 40);
+ float lineHeight = 20;
+ int maxLines = ChatOpen ? 12 : 6;
+
+ // Sichtbare Zeilen: offen = die letzten, geschlossen = nur neue (blenden aus)
+ var visible = new List();
+ for (int i = chat.Count - 1; i >= 0 && visible.Count < maxLines; i--)
+ if (ChatOpen || now - chat[i].Time < ChatFadeSeconds) visible.Insert(0, chat[i]);
+
+ float bottom = Screen.height * 0.62f;
+ float height = visible.Count * lineHeight + (ChatOpen ? 52 : 12);
+ var rect = new Rect(20, bottom - height, width, height);
+ if (ChatOpen || visible.Count > 0) GUI.Box(rect, GUIContent.none, ChatOpen ? panel : rowStyle);
+
+ Color old = GUI.color;
+ float y = rect.y + 6;
+ foreach (ChatLine line in visible)
+ {
+ float age = now - line.Time;
+ float alpha = ChatOpen ? 1f : Mathf.Clamp01((ChatFadeSeconds - age) / 3f);
+ GUI.color = new Color(1f, 1f, 1f, alpha);
+ if (line.System)
+ {
+ GUI.Label(new Rect(rect.x + 12, y, width - 24, lineHeight), line.Text, chatSystem);
+ }
+ else
+ {
+ Vector2 size = chatName.CalcSize(new GUIContent(line.Name + ":"));
+ GUI.Label(new Rect(rect.x + 12, y, size.x, lineHeight), line.Name + ":", chatName);
+ GUI.Label(new Rect(rect.x + 16 + size.x, y, width - 28 - size.x, lineHeight), line.Text, chatText);
+ }
+ y += lineHeight;
+ }
+ GUI.color = old;
+
+ if (!ChatOpen) return;
+
+ Event e = Event.current;
+ if (e.type == EventType.KeyDown && GUI.GetNameOfFocusedControl() == ChatControl)
+ {
+ if (e.keyCode == KeyCode.Return || e.keyCode == KeyCode.KeypadEnter)
+ {
+ string text = chatInput.Trim();
+ if (text.Length > 0)
+ {
+ if (text.StartsWith("/")) RunConsoleLine(text.Substring(1)); // z.B. /admin
+ else mp.SendChat(text);
+ }
+ chatInput = "";
+ ChatOpen = false;
+ e.Use();
+ return;
+ }
+ if (e.keyCode == KeyCode.Escape) { ChatOpen = false; e.Use(); return; }
+ }
+
+ // Taste, mit der der Chat geöffnet wurde (T), nicht als Zeichen übernehmen
+ if (Time.frameCount == chatOpenedFrame && e.type == EventType.KeyDown) e.Use();
+ GUI.SetNextControlName(ChatControl);
+ chatInput = GUI.TextField(new Rect(rect.x + 10, rect.yMax - 40, width - 20, 30), chatInput, 150, field);
+ GUI.FocusControl(ChatControl);
+ }
+
+ void DrawAdmin(MultiplayerManager mp)
+ {
+ float width = Mathf.Min(600, Screen.width - 40), height = Mathf.Min(460, Screen.height - 40);
+ var rect = new Rect((Screen.width - width) / 2f, (Screen.height - height) / 2f, width, height);
+ GUI.Box(rect, GUIContent.none, panel);
+
+ if (Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Escape)
+ {
+ AdminOpen = false;
+ Event.current.Use();
+ return;
+ }
+
+ GUILayout.BeginArea(new Rect(rect.x + 20, rect.y + 16, rect.width - 40, rect.height - 32));
+ GUILayout.BeginHorizontal();
+ GUILayout.Label("Admin", title, GUILayout.ExpandWidth(true)); // nicht von den Buttons zusammendrücken lassen
+ if (GUILayout.Button("REFRESH", secondary, GUILayout.Height(30), GUILayout.Width(100))) mp.RequestAdminList();
+ GUILayout.Space(8);
+ if (GUILayout.Button("CLOSE", secondary, GUILayout.Height(30), GUILayout.Width(90))) AdminOpen = false;
+ GUILayout.EndHorizontal();
+ GUILayout.Label(mp.IsDedicated ? "Players on this server. Banned players cannot join again." : "Players in your game.", small);
+ GUILayout.Space(10);
+
+ GUILayout.Label("REASON (optional)", caption);
+ adminReason = GUILayout.TextField(adminReason, 100, field, GUILayout.Height(32));
+ GUILayout.Space(12);
+
+ adminScroll = GUILayout.BeginScrollView(adminScroll);
+ if (adminPlayers == null) GUILayout.Label("Loading players...", small);
+ else if (adminPlayers.Count == 0) GUILayout.Label("No players.", small);
+ else
+ {
+ foreach (AdminPlayer p in adminPlayers)
+ {
+ GUILayout.BeginHorizontal(rowStyle, GUILayout.Height(44));
+ GUILayout.BeginVertical();
+ bool you = p.Id == mp.LocalId;
+ GUILayout.Label(p.Name + (you ? " (you)" : "") + (p.IsAdmin ? " (admin)" : ""), chatName);
+ GUILayout.Label(string.IsNullOrEmpty(p.SteamId) ? "id " + p.Id : p.SteamId.StartsWith("name:") ? "no Steam" : "SteamID " + p.SteamId, small);
+ GUILayout.EndVertical();
+ GUILayout.FlexibleSpace();
+
+ bool self = p.Id == mp.LocalId;
+ GUI.enabled = !self && !p.IsAdmin;
+ if (GUILayout.Button("KICK", secondary, GUILayout.Height(30), GUILayout.Width(80)))
+ mp.AdminAction(false, p.Id, adminReason);
+ GUILayout.Space(6);
+ bool confirming = confirmBanId == p.Id;
+ if (GUILayout.Button(confirming ? "SURE?" : "BAN", danger, GUILayout.Height(30), GUILayout.Width(80)))
+ {
+ if (confirming) { mp.AdminAction(true, p.Id, adminReason); confirmBanId = -1; }
+ else confirmBanId = p.Id;
+ }
+ GUI.enabled = true;
+ GUILayout.EndHorizontal();
+ GUILayout.Space(4);
+ }
+ }
+ GUILayout.EndScrollView();
+ GUILayout.EndArea();
+ }
+
+ // ---------------------------------------------------------------- Stile
+
+ void EnsureStyles()
+ {
+ if (stylesReady && panel.normal.background != null) return;
+ panel = UiTheme.Panel9(UiTheme.Background);
+ rowStyle = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.72f));
+ rowStyle.padding = new RectOffset(12, 12, 6, 6);
+ field = UiTheme.Field(14);
+ logText = UiTheme.Label(12, UiTheme.Text);
+ logText.wordWrap = true;
+ logText.margin = new RectOffset(0, 0, 0, 0);
+ caption = UiTheme.Label(11, UiTheme.Muted, FontStyle.Bold);
+ title = UiTheme.Label(22, UiTheme.Text, FontStyle.Bold);
+ title.wordWrap = false;
+ small = UiTheme.Label(11, UiTheme.Muted);
+ primary = UiTheme.Button(UiTheme.Accent, UiTheme.AccentHover, Color.white);
+ secondary = UiTheme.Button(UiTheme.Secondary, UiTheme.SecondaryHover, UiTheme.Text, 12);
+ danger = UiTheme.Button(UiTheme.Danger, UiTheme.DangerHover, Color.white, 12);
+ chatName = UiTheme.Label(13, UiTheme.AccentHover, FontStyle.Bold, TextAnchor.MiddleLeft);
+ chatText = UiTheme.Label(13, UiTheme.Text, FontStyle.Normal, TextAnchor.MiddleLeft);
+ chatSystem = UiTheme.Label(12, UiTheme.Muted, FontStyle.Italic, TextAnchor.MiddleLeft);
+ stylesReady = true;
+ }
+ }
+}
diff --git a/Passenger.cs b/Passenger.cs
new file mode 100644
index 0000000..e82d232
--- /dev/null
+++ b/Passenger.cs
@@ -0,0 +1,131 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Beifahrer: Neben einem Fahrzeug, das ein anderer Spieler fährt, per Taste einsteigen.
+ /// Der Sitz ist die Fahrerposition, auf die andere Seite gespiegelt (bei Fahrzeugen mit
+ /// Sitz in der Mitte, z.B. Traktor oder Moped: dahinter). Während der Fahrt wird der
+ /// Spieler jedes Frame auf den Sitz gesetzt und dreht mit dem Fahrzeug mit; umschauen geht weiter.
+ ///
+ public class Passenger
+ {
+ const float EnterRange = 4f; // so nah muss man am Fahrzeug stehen
+ const float CenterSeatThreshold = 0.2f;
+ const float BehindCenterSeat = 0.7f;
+ const float ExitSideOffset = 1.4f; // beim Aussteigen so weit neben den Sitz
+
+ // Bewegungs-Skripte des Spielers (Unity Standard Assets), während der Fahrt aus
+ static readonly string[] MovementScripts = { "CharacterMotor", "FPSInputController" };
+
+ SyncedBody vehicle;
+ Vector3 seatHeadLocal;
+ float lastYaw;
+ readonly List disabled = new List();
+ CharacterController controller;
+
+ public bool IsRiding { get { return vehicle != null; } }
+
+ /// Taste gedrückt: einsteigen oder aussteigen. Gibt einen Statustext zurück.
+ public string Toggle(Transform player, Camera cam, ObjectSync objects, IDictionary players)
+ {
+ if (IsRiding)
+ {
+ Exit(player, cam);
+ return "Got out";
+ }
+ if (cam == null) return null;
+
+ SyncedBody best = null;
+ RemotePlayer driver = null;
+ float bestDist = EnterRange * EnterRange;
+ foreach (SyncedBody b in objects.RemoteDrivenVehicles())
+ {
+ PlayerInfo owner;
+ if (!players.TryGetValue(b.OwnerId, out owner) || owner.Avatar == null) continue;
+ float d = (b.Rb.worldCenterOfMass - player.position).sqrMagnitude;
+ if (d < bestDist) { bestDist = d; best = b; driver = owner.Avatar; }
+ }
+ if (best == null) return "No vehicle driven by another player nearby";
+
+ Transform vt = best.Rb.transform;
+ Vector3 driverHead = vt.InverseTransformPoint(driver.HeadPosition);
+ seatHeadLocal = Mathf.Abs(driverHead.x) < CenterSeatThreshold
+ ? driverHead - new Vector3(0f, 0f, BehindCenterSeat) // Sitz in der Mitte: dahinter
+ : new Vector3(-driverHead.x, driverHead.y, driverHead.z); // gegenüber vom Fahrer
+
+ vehicle = best;
+ lastYaw = vt.eulerAngles.y;
+ SetMovementEnabled(player, false);
+ return "Riding as passenger (press again to get out)";
+ }
+
+ /// Nach der Physik (LateUpdate): Spieler auf den Sitz setzen und mitdrehen.
+ public void LateUpdate(Transform player, Camera cam, ObjectSync objects)
+ {
+ if (!IsRiding) return;
+ // Fahrer ausgestiegen oder Fahrzeug weg: automatisch aussteigen
+ if (vehicle.Rb == null || cam == null || !objects.IsRemoteOwnedBody(vehicle))
+ {
+ Exit(player, cam);
+ return;
+ }
+
+ Transform vt = vehicle.Rb.transform;
+ float yaw = vt.eulerAngles.y;
+ player.Rotate(0f, Mathf.DeltaAngle(lastYaw, yaw), 0f, Space.World);
+ lastYaw = yaw;
+
+ Vector3 cameraOffset = cam.transform.position - player.position;
+ player.position = vt.TransformPoint(seatHeadLocal) - cameraOffset;
+ }
+
+ public void Exit(Transform player, Camera cam)
+ {
+ if (!IsRiding) return;
+ if (player != null && vehicle.Rb != null)
+ {
+ // Auf der Seite des Sitzes neben das Fahrzeug stellen
+ float side = seatHeadLocal.x >= 0f ? 1f : -1f;
+ Vector3 outside = vehicle.Rb.transform.TransformPoint(seatHeadLocal + new Vector3(side * ExitSideOffset, 0f, 0f));
+ Vector3 cameraOffset = cam != null ? cam.transform.position - player.position : Vector3.up * 1.6f;
+ player.position = outside - cameraOffset + Vector3.up * 0.1f;
+ }
+ vehicle = null;
+ SetMovementEnabled(player, true);
+ }
+
+ /// Szenenwechsel/Verbindungsende: ohne Teleport alles zurücksetzen.
+ public void Reset()
+ {
+ vehicle = null;
+ SetMovementEnabled(null, true);
+ }
+
+ void SetMovementEnabled(Transform player, bool enabled)
+ {
+ if (!enabled)
+ {
+ disabled.Clear();
+ foreach (MonoBehaviour mb in player.GetComponents())
+ {
+ if (mb == null || !mb.enabled) continue;
+ if (System.Array.IndexOf(MovementScripts, mb.GetType().Name) < 0) continue;
+ mb.enabled = false;
+ disabled.Add(mb);
+ }
+ controller = player.GetComponent();
+ if (controller != null && controller.enabled) controller.enabled = false;
+ else controller = null;
+ }
+ else
+ {
+ foreach (Behaviour b in disabled) if (b != null) b.enabled = true;
+ disabled.Clear();
+ if (controller != null) controller.enabled = true;
+ controller = null;
+ }
+ }
+ }
+}
diff --git a/RemotePlayer.cs b/RemotePlayer.cs
index 3c34a30..b54fa78 100644
--- a/RemotePlayer.cs
+++ b/RemotePlayer.cs
@@ -1,29 +1,331 @@
-using UnityEngine;
+using UnityEngine;
namespace MSCO
{
- /// Bewegt den anderen Spieler weich zur zuletzt empfangenen Position.
+ ///
+ /// Figur eines Mitspielers mit echter Körperhaltung. Aus Kopfposition (Kamera) und Bodenhöhe wird
+ /// jedes Frame die Pose berechnet: Ducken beugt die Knie, nach vorne Lehnen neigt den Oberkörper,
+ /// Laufen bewegt Beine und Arme, beim Halten greift der rechte Arm (mit Ellbogen) zum Item, im Auto
+ /// sitzt die Figur. Die Körperteile sind abgerundete Kapseln zwischen den Gelenken.
+ ///
public class RemotePlayer : MonoBehaviour
{
- Vector3 targetPos;
- float targetRotY;
+ const float Smoothing = 15f;
+ const float StandingEyeHeight = 1.65f;
+ const float MaxHeadPitch = 55f;
+
+ // Körpermaße (Meter)
+ const float ThighLength = 0.45f, ShinLength = 0.45f;
+ const float UpperArmLength = 0.3f, ForearmLength = 0.29f;
+ const float TorsoLength = 0.55f; // Hüfte bis Hals
+ const float HipWidth = 0.1f, ShoulderWidth = 0.21f;
+ const float AnkleHeight = 0.07f;
+
+ static readonly Color Skin = new Color(0.93f, 0.76f, 0.62f);
+ static readonly Color Jeans = new Color(0.18f, 0.25f, 0.42f);
+ static readonly Color Boots = new Color(0.24f, 0.16f, 0.1f);
+ static readonly Color Dark = new Color(0.08f, 0.08f, 0.08f);
+
+ Color jacket;
+ Transform headPivot, view;
+ Transform pelvis, torso, belt, neck;
+ readonly Transform[] thigh = new Transform[2], shin = new Transform[2], boot = new Transform[2];
+ readonly Transform[] upperArm = new Transform[2], forearm = new Transform[2], hand = new Transform[2];
+
+ Vector3 targetFeet;
+ Vector3 targetHead;
+ Vector3 headWorld;
+ float targetYaw, targetPitch, pitch;
+ Quaternion targetViewRot = Quaternion.identity;
+ PlayerState last;
+
+ Vector3 lastPosition;
+ float walkPhase, walkAmount, speed;
+
+ // Echtes Spielmodell (NPC aus der Szene); null = eigene Figur als Rückfall
+ GameCharacter model;
+
+ public int HeldId { get { return last.HeldId; } }
+ public PlayerState LastState { get { return last; } }
+ public Vector3 HeadPosition { get { return model != null ? model.HeadPosition : headPivot.position; } }
+ public string ModelDescription { get { return model != null ? "game model: " + model.Description : "fallback figure (no NPC template found)"; } }
+
+ // ---------------------------------------------------------------- Aufbau
+
+ public static RemotePlayer Create(string name, Color jacketColor, PlayerState state, int variant)
+ {
+ var root = new GameObject(name);
+ var rp = root.AddComponent();
+ rp.jacket = jacketColor;
+ rp.Build();
+ rp.SetTarget(state);
+ root.transform.position = rp.targetFeet;
+ root.transform.rotation = Quaternion.Euler(0f, rp.targetYaw, 0f);
+
+ // Wenn möglich einen echten NPC aus dem Spiel als Figur nehmen, sonst die eigene Figur
+ try { rp.model = GameCharacter.TryCreate(root.transform, variant); }
+ catch (System.Exception e) { MSCLoader.ModConsole.Warning("[MSCO] Game model failed: " + e.Message); }
+ if (rp.model != null)
+ foreach (Renderer r in root.GetComponentsInChildren(true))
+ if (!r.transform.IsChildOf(rp.model.Root)) r.enabled = false;
+ rp.headWorld = rp.targetHead;
+ rp.pitch = rp.targetPitch;
+ rp.view.rotation = rp.targetViewRot;
+ rp.lastPosition = root.transform.position;
+ if (rp.model == null) rp.Pose(0f);
+ return rp;
+ }
+
+ void Build()
+ {
+ Transform r = transform;
+ Color jacketDark = jacket * 0.75f;
+ jacketDark.a = 1f;
+
+ pelvis = Capsule("Pelvis", r, Jeans);
+ torso = Capsule("Torso", r, jacket);
+ belt = Capsule("Belt", r, Dark);
+ neck = Capsule("Neck", r, Skin);
+
+ for (int i = 0; i < 2; i++)
+ {
+ thigh[i] = Capsule("Thigh", r, Jeans);
+ shin[i] = Capsule("Shin", r, Jeans);
+ boot[i] = Box("Boot", r, Boots);
+ upperArm[i] = Capsule("UpperArm", r, jacket);
+ forearm[i] = Capsule("Forearm", r, jacketDark);
+ hand[i] = Sphere("Hand", r, Skin, 0.1f);
+ }
+
+ // Kopf: dreht mit Blick (hoch/runter), Gesicht nach +Z
+ headPivot = new GameObject("Head").transform;
+ headPivot.SetParent(r, false);
+ Transform skull = Sphere("Skull", headPivot, Skin, 0.24f);
+ skull.localScale = new Vector3(0.22f, 0.26f, 0.24f);
+ Transform hair = Sphere("Hair", headPivot, new Color(0.25f, 0.17f, 0.1f), 0.24f);
+ hair.localPosition = new Vector3(0f, 0.02f, -0.025f);
+ hair.localScale = new Vector3(0.23f, 0.25f, 0.23f);
+ Transform cap = Sphere("Cap", headPivot, jacketDark, 0.25f);
+ cap.localPosition = new Vector3(0f, 0.07f, -0.005f);
+ cap.localScale = new Vector3(0.245f, 0.16f, 0.255f);
+ Transform brim = Box("CapBrim", headPivot, jacketDark);
+ brim.localPosition = new Vector3(0f, 0.075f, 0.13f);
+ brim.localScale = new Vector3(0.2f, 0.02f, 0.11f);
+ brim.localRotation = Quaternion.Euler(-8f, 0f, 0f);
+ for (int i = 0; i < 2; i++)
+ {
+ Transform eye = Sphere("Eye", headPivot, Dark, 0.035f);
+ eye.localPosition = new Vector3(i == 0 ? -0.045f : 0.045f, 0.015f, 0.105f);
+ }
+ Transform nose = Box("Nose", headPivot, Skin * 0.95f);
+ nose.localPosition = new Vector3(0f, -0.02f, 0.12f);
+ nose.localScale = new Vector3(0.035f, 0.05f, 0.04f);
+
+ // Unsichtbarer "Blick" mit der exakten Kameradrehung - daran hängt das gehaltene Item
+ view = new GameObject("View").transform;
+ view.SetParent(r, false);
+ }
+
+ static Transform Capsule(string name, Transform parent, Color color) { return Primitive(PrimitiveType.Capsule, name, parent, color); }
+
+ static Transform Box(string name, Transform parent, Color color) { return Primitive(PrimitiveType.Cube, name, parent, color); }
+
+ static Transform Sphere(string name, Transform parent, Color color, float size)
+ {
+ Transform t = Primitive(PrimitiveType.Sphere, name, parent, color);
+ t.localScale = Vector3.one * size;
+ return t;
+ }
+
+ static Transform Primitive(PrimitiveType type, string name, Transform parent, Color color)
+ {
+ GameObject go = GameObject.CreatePrimitive(type);
+ go.name = name;
+ Destroy(go.GetComponent()); // stört sonst Physik/Trigger
+ var renderer = go.GetComponent();
+ renderer.material.color = color;
+ renderer.receiveShadows = true;
+ go.transform.SetParent(parent, false);
+ return go.transform;
+ }
+
+ // ---------------------------------------------------------------- Zustand
public void SetTarget(PlayerState state)
{
- targetPos = state.Position;
- targetRotY = state.RotationY;
+ last = state;
+ targetHead = state.CamPos;
+
+ // Füße: Bodenhöhe vom Spieler, waagerecht an der Spielerposition (Lehnen = Kopf woanders als Füße).
+ // Liegt der Kopf unplausibel weit weg (andere Kamera), Füße unter den Kopf.
+ float feetY = state.FeetY != 0f ? state.FeetY : state.CamPos.y - StandingEyeHeight;
+ var feet = new Vector3(state.Position.x, feetY, state.Position.z);
+ var horizontal = new Vector3(state.CamPos.x - feet.x, 0f, state.CamPos.z - feet.z);
+ if (horizontal.sqrMagnitude > 0.8f * 0.8f || state.Seated) feet = new Vector3(state.CamPos.x, feetY, state.CamPos.z);
+ targetFeet = feet;
+
+ // Blickrichtung ohne seitliches Kippen
+ Vector3 forward = state.CamRot * Vector3.forward;
+ Vector3 flat = new Vector3(forward.x, 0f, forward.z);
+ targetYaw = flat.sqrMagnitude > 0.0001f ? Quaternion.LookRotation(flat).eulerAngles.y : state.RotationY;
+ targetPitch = Mathf.Clamp(-Mathf.Asin(Mathf.Clamp(forward.y, -1f, 1f)) * Mathf.Rad2Deg, -MaxHeadPitch, MaxHeadPitch);
+ targetViewRot = state.CamRot;
}
public void Tick(float dt)
{
// Bei großen Sprüngen (Teleport, Laden) direkt setzen statt gleiten
- if ((transform.position - targetPos).sqrMagnitude > 100f)
- transform.position = targetPos;
- else
- transform.position = Vector3.Lerp(transform.position, targetPos, dt * 15f);
+ bool snap = (transform.position - targetFeet).sqrMagnitude > 100f;
+ float t = snap ? 1f : Mathf.Clamp01(dt * Smoothing);
- transform.rotation = Quaternion.Slerp(transform.rotation,
- Quaternion.Euler(0f, targetRotY, 0f), dt * 15f);
+ transform.position = Vector3.Lerp(transform.position, targetFeet, t);
+ transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.Euler(0f, targetYaw, 0f), t);
+ headWorld = snap ? targetHead : Vector3.Lerp(headWorld, targetHead, t);
+ pitch = Mathf.Lerp(pitch, targetPitch, t);
+ view.position = headWorld;
+ view.rotation = Quaternion.Slerp(view.rotation, targetViewRot, t);
+
+ UpdateWalk(dt);
+ if (model != null) model.Animate(speed); // Spiel-Animation (Idle/Walk) läuft vor LateUpdate
+ else Pose(dt);
+ }
+
+ /// Spielmodell: nach der Animation Ducken/Lehnen/Kopf/Arme anpassen.
+ void LateUpdate()
+ {
+ if (model == null) return;
+ Vector3 heldPos;
+ Quaternion heldRot;
+ bool holding = TryGetHeldPose(out heldPos, out heldRot);
+ model.Pose(transform, headWorld, pitch, last.Seated, holding, heldPos);
+ }
+
+ void UpdateWalk(float dt)
+ {
+ Vector3 moved = transform.position - lastPosition;
+ lastPosition = transform.position;
+ moved.y = 0f;
+ speed = dt > 0f ? moved.magnitude / dt : 0f;
+ if (speed > 12f || last.Seated) speed = 0f; // Teleport oder im Auto
+
+ walkAmount = Mathf.Lerp(walkAmount, Mathf.Clamp01(speed / 2.5f), Mathf.Clamp01(dt * 8f));
+ walkPhase += speed * dt * 3.4f;
+ }
+
+ // ---------------------------------------------------------------- Pose (alles im lokalen Raum der Figur)
+
+ void Pose(float dt)
+ {
+ Vector3 head = transform.InverseTransformPoint(headWorld) + new Vector3(0f, -0.02f, -0.03f);
+ Vector3 neckTop = head + new Vector3(0f, -0.12f, -0.01f);
+ Vector3 neckBase = head + new Vector3(0f, -0.2f, -0.02f);
+
+ // Hüfte: beim Lehnen nach hinten ausweichen, beim Ducken absinken (Füße bleiben am Boden)
+ Vector3 hip;
+ if (last.Seated)
+ {
+ hip = neckBase + new Vector3(0f, -TorsoLength, -0.08f);
+ }
+ else
+ {
+ var lean = new Vector3(neckBase.x, 0f, neckBase.z);
+ hip = -lean * 0.25f;
+ float horizontal = (new Vector3(neckBase.x - hip.x, 0f, neckBase.z - hip.z)).magnitude;
+ float vertical = Mathf.Sqrt(Mathf.Max(0.04f, TorsoLength * TorsoLength - horizontal * horizontal));
+ hip.y = Mathf.Clamp(neckBase.y - vertical, 0.3f, ThighLength + ShinLength + AnkleHeight - 0.02f);
+ }
+
+ Vector3 up = (neckBase - hip).normalized;
+ Vector3 right = Vector3.right;
+ Vector3 forward = Vector3.Cross(right, up).normalized;
+
+ // Rumpf
+ Segment(torso, hip + up * 0.08f, neckBase, 0.38f, 0.24f);
+ Segment(belt, hip + up * 0.06f, hip + up * 0.1f, 0.37f, 0.23f);
+ Segment(pelvis, hip - right * HipWidth, hip + right * HipWidth, 0.2f, 0.22f);
+ Segment(neck, neckBase, neckTop, 0.09f, 0.09f);
+
+ // Kopf
+ headPivot.localPosition = head;
+ headPivot.localRotation = Quaternion.Euler(pitch, 0f, 0f);
+
+ float swing = Mathf.Sin(walkPhase) * walkAmount;
+ for (int i = 0; i < 2; i++)
+ {
+ float side = i == 0 ? -1f : 1f;
+ float phase = i == 0 ? swing : -swing;
+
+ // Beine
+ Vector3 hipJoint = hip + right * (HipWidth * side);
+ Vector3 ankle;
+ if (last.Seated)
+ ankle = hipJoint + new Vector3(0f, -0.4f, 0.48f);
+ else
+ ankle = new Vector3(HipWidth * side, AnkleHeight + Mathf.Max(0f, Mathf.Cos(walkPhase + i * Mathf.PI)) * 0.07f * walkAmount, phase * 0.3f);
+ Vector3 knee = Joint(hipJoint, ankle, ThighLength, ShinLength, Vector3.forward);
+ Segment(thigh[i], hipJoint, knee, 0.17f, 0.17f);
+ Segment(shin[i], knee, ankle, 0.14f, 0.14f);
+ boot[i].localPosition = ankle + new Vector3(0f, -0.02f, 0.05f);
+ boot[i].localRotation = Quaternion.identity;
+ boot[i].localScale = new Vector3(0.12f, 0.1f, 0.27f);
+
+ // Arme
+ Vector3 shoulder = neckBase - up * 0.06f + right * (ShoulderWidth * side);
+ Vector3 handTarget;
+ Vector3 heldPos;
+ Quaternion heldRot;
+ if (i == 1 && TryGetHeldPose(out heldPos, out heldRot))
+ handTarget = transform.InverseTransformPoint(heldPos);
+ else if (last.Seated)
+ handTarget = shoulder + new Vector3(-side * 0.05f, -0.3f, 0.38f); // Hände nach vorne (Lenkrad)
+ else
+ handTarget = shoulder + new Vector3(side * 0.03f, -(UpperArmLength + ForearmLength) + 0.04f, -phase * 0.22f);
+
+ // Nicht weiter als die Armlänge
+ Vector3 reach = handTarget - shoulder;
+ float maxReach = UpperArmLength + ForearmLength - 0.01f;
+ if (reach.magnitude > maxReach) handTarget = shoulder + reach.normalized * maxReach;
+
+ Vector3 elbow = Joint(shoulder, handTarget, UpperArmLength, ForearmLength, new Vector3(side * 0.4f, -0.2f, -1f));
+ Segment(upperArm[i], shoulder, elbow, 0.12f, 0.12f);
+ Segment(forearm[i], elbow, handTarget, 0.11f, 0.11f);
+ hand[i].localPosition = handTarget;
+ }
+ }
+
+ /// Zwei-Knochen-Gelenk (Knie/Ellbogen): Punkt zwischen a und b, zur Seite "bend" geknickt.
+ static Vector3 Joint(Vector3 a, Vector3 b, float lengthA, float lengthB, Vector3 bend)
+ {
+ Vector3 ab = b - a;
+ float d = Mathf.Clamp(ab.magnitude, 0.01f, lengthA + lengthB - 0.001f);
+ Vector3 dir = ab.normalized;
+ // Abstand vom Startpunkt entlang ab und seitlicher Abstand (Kosinussatz)
+ float along = (lengthA * lengthA - lengthB * lengthB + d * d) / (2f * d);
+ float sideways = Mathf.Sqrt(Mathf.Max(0f, lengthA * lengthA - along * along));
+ Vector3 bendDir = bend - dir * Vector3.Dot(bend, dir);
+ if (bendDir.sqrMagnitude < 0.0001f) bendDir = Vector3.forward - dir * Vector3.Dot(Vector3.forward, dir);
+ return a + dir * along + bendDir.normalized * sideways;
+ }
+
+ /// Kapsel von a nach b spannen (lokaler Raum der Figur).
+ static void Segment(Transform part, Vector3 a, Vector3 b, float width, float depth)
+ {
+ Vector3 dir = b - a;
+ float length = dir.magnitude;
+ part.localPosition = (a + b) * 0.5f;
+ part.localRotation = length > 0.0001f ? Quaternion.FromToRotation(Vector3.up, dir / length) : Quaternion.identity;
+ part.localScale = new Vector3(width, length * 0.5f + Mathf.Min(width, depth) * 0.5f, depth);
+ }
+
+ /// Wo das gehaltene Item beim Spieler gerade ist (relativ zum Blick).
+ public bool TryGetHeldPose(out Vector3 pos, out Quaternion rot)
+ {
+ pos = Vector3.zero;
+ rot = Quaternion.identity;
+ if (last.HeldId == 0) return false;
+ pos = view.TransformPoint(last.HeldLocalPos);
+ rot = view.rotation * last.HeldLocalRot;
+ return true;
}
}
-}
\ No newline at end of file
+}
diff --git a/SafetyPatches.cs b/SafetyPatches.cs
new file mode 100644
index 0000000..4b84b07
--- /dev/null
+++ b/SafetyPatches.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Reflection;
+using Harmony;
+using HutongGames.PlayMaker;
+using MSCLoader;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Schutz vor Abstürzen durch nachgespielte Aktionen: Wird beim Beitreten eine Montage-Aktion in einer
+ /// etwas anderen Welt nachgespielt, kann die "SetParent"-Aktion eines Teils ein Objekt unter sein eigenes
+ /// Kind hängen. Das ergibt einen Kreis in der Hierarchie und Unity stürzt nativ ab (Stack Overflow in
+ /// Transform::GetClassIDStatic). Solche Aufrufe werden hier abgefangen und übersprungen.
+ ///
+ public static class SafetyPatches
+ {
+ static bool applied;
+ static FieldInfo gameObjectField, parentField;
+ static int blocked;
+
+ public static void Apply()
+ {
+ if (applied) return;
+ applied = true;
+ try
+ {
+ Type action = null;
+ foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ action = asm.GetType("HutongGames.PlayMaker.Actions.SetParent");
+ if (action != null) break;
+ }
+ MethodInfo onEnter = action != null ? action.GetMethod("OnEnter", BindingFlags.Instance | BindingFlags.Public) : null;
+ if (onEnter == null) { ModConsole.Warning("[MSCO] SetParent action not found - hierarchy guard disabled"); return; }
+
+ gameObjectField = action.GetField("gameObject");
+ parentField = action.GetField("parent");
+ HarmonyInstance.Create("mbm.msco.safety").Patch(onEnter,
+ new HarmonyMethod(typeof(SafetyPatches).GetMethod("SetParentPrefix", BindingFlags.Static | BindingFlags.NonPublic)), null);
+ }
+ catch (Exception e)
+ {
+ ModConsole.Warning("[MSCO] Could not install hierarchy guard: " + e.Message);
+ }
+ }
+
+ static bool SetParentPrefix(FsmStateAction __instance)
+ {
+ try
+ {
+ var owner = gameObjectField != null ? gameObjectField.GetValue(__instance) as FsmOwnerDefault : null;
+ var parent = parentField != null ? parentField.GetValue(__instance) as FsmGameObject : null;
+ if (owner == null || parent == null || parent.Value == null) return true;
+ GameObject go = __instance.Fsm.GetOwnerDefaultTarget(owner);
+ if (go == null) return true;
+
+ Transform child = go.transform, newParent = parent.Value.transform;
+ if (newParent != child && !newParent.IsChildOf(child)) return true;
+
+ blocked++;
+ if (blocked <= 20)
+ ModConsole.Warning("[MSCO] Blocked SetParent that would crash the game: " + go.name + " -> " + parent.Value.name +
+ " (" + __instance.Fsm.Name + ")");
+ __instance.Finish();
+ return false;
+ }
+ catch
+ {
+ return true;
+ }
+ }
+ }
+}
diff --git a/SceneCache.cs b/SceneCache.cs
new file mode 100644
index 0000000..5e19e20
--- /dev/null
+++ b/SceneCache.cs
@@ -0,0 +1,32 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Gemeinsame Liste aller PlayMaker-FSMs der Szene. FindObjectsOfType ist teuer (tausende FSMs in MSC);
+ /// statt dass Türen-, Fahrzeug-, Wetter- und Montage-Sync jeweils selbst suchen, wird einmal gesucht
+ /// und das Ergebnis eine Weile geteilt.
+ ///
+ public static class SceneCache
+ {
+ static PlayMakerFSM[] fsms = new PlayMakerFSM[0];
+ static float builtAt = -1000f;
+ static int builtForLevel = -1;
+
+ /// Alle FSMs; höchstens maxAge Sekunden alt. Einträge können inzwischen zerstört sein (== null prüfen).
+ public static PlayMakerFSM[] Fsms(float maxAge)
+ {
+ if (Time.realtimeSinceStartup - builtAt > maxAge || builtForLevel != Application.loadedLevel)
+ {
+ Object[] found = Object.FindObjectsOfType(typeof(PlayMakerFSM));
+ var list = new List(found.Length);
+ foreach (Object o in found) list.Add((PlayMakerFSM)o);
+ fsms = list.ToArray();
+ builtAt = Time.realtimeSinceStartup;
+ builtForLevel = Application.loadedLevel;
+ }
+ return fsms;
+ }
+ }
+}
diff --git a/ServerBrowser.cs b/ServerBrowser.cs
new file mode 100644
index 0000000..4d1b462
--- /dev/null
+++ b/ServerBrowser.cs
@@ -0,0 +1,264 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Net.Sockets;
+using System.Threading;
+// (Unitys WWW wird nicht mehr benutzt - siehe WinHttp)
+using Newtonsoft.Json;
+using UnityEngine;
+
+namespace MSCO
+{
+ /// Ein Eintrag der Serverliste (vom Master-Server, Spielerzahl/Ping direkt beim Server abgefragt).
+ public class ServerListEntry
+ {
+ public string Name;
+ public string Description;
+ public string Address;
+ public int Port;
+ public int Players;
+ public int MaxPlayers;
+ public string Version;
+ public bool RequireSteam = true; // false = Non-Steam-Server
+ public string Owner; // Besitzer des Lizenzschlüssels
+
+ [JsonIgnore] public int UptimeSeconds = -1;
+ [JsonIgnore] public List PlayerNames = new List();
+ [JsonIgnore] public int Ping = -1; // -1 = noch nicht abgefragt
+ [JsonIgnore] public bool Offline;
+ [JsonIgnore] public bool IsLocal; // Server auf diesem PC (localhost)
+ }
+
+ ///
+ /// Holt die Serverliste vom Master-Server (Keymaster) und fragt jeden Server im Hintergrund
+ /// nach aktuellen Infos und Ping (MsgType.Info).
+ ///
+ public class ServerBrowser
+ {
+ public const string DefaultMasterUrl = "https://msco.mbm.wtf";
+ const string PrefMasterUrl = "MSCO_MasterUrl";
+ const int QueryTimeoutMs = 3000;
+
+ // Wie bei FiveM: ein Server auf diesem PC erscheint automatisch oben in der Liste
+ static readonly int[] LocalPorts = { 7777, 7778, 7779, 7780 };
+ public const float AutoRefreshSeconds = 15f;
+
+ readonly object gate = new object();
+ List servers = new List();
+ readonly List localServers = new List();
+
+ public bool Loading { get; private set; }
+ public string Error { get; private set; }
+ /// Zeitpunkt der letzten Aktualisierung (Time.realtimeSinceStartup), für das Auto-Refresh.
+ public float LastRefresh { get; private set; }
+
+ /// Adresse des Master-Servers (änderbar mit "mp master <url>", z.B. zum lokalen Testen).
+ public static string MasterUrl
+ {
+ get { return PlayerPrefs.GetString(PrefMasterUrl, DefaultMasterUrl).TrimEnd('/'); }
+ set { PlayerPrefs.SetString(PrefMasterUrl, string.IsNullOrEmpty(value) ? DefaultMasterUrl : value.TrimEnd('/')); }
+ }
+
+ /// Lokale Server zuerst, dann die öffentliche Liste.
+ public List Servers
+ {
+ get
+ {
+ lock (gate)
+ {
+ var all = new List(localServers);
+ all.AddRange(servers);
+ return all;
+ }
+ }
+ }
+
+ public void Refresh(MonoBehaviour host)
+ {
+ if (Loading) return;
+ host.StartCoroutine(Fetch());
+ }
+
+ IEnumerator Fetch()
+ {
+ Loading = true;
+ Error = null;
+ LastRefresh = Time.realtimeSinceStartup;
+ ProbeLocalServers(); // unabhängig vom Master: auch ohne Internet sichtbar
+
+ // Über WinHTTP (TLS 1.2/1.3) im Hintergrund - Unitys WWW kann nur TLS 1.0
+ string text = null, error = null;
+ bool done = false;
+ string url = MasterUrl + "/api/servers";
+ var thread = new Thread(() =>
+ {
+ text = WinHttp.Get(url, 8000, out error);
+ done = true;
+ });
+ thread.IsBackground = true;
+ thread.Start();
+ while (!done) yield return null;
+ Loading = false;
+
+ if (text == null)
+ {
+ Error = "Server list not reachable (" + error + ")";
+ yield break;
+ }
+
+ List list;
+ try { list = JsonConvert.DeserializeObject>(text) ?? new List(); }
+ catch (Exception e)
+ {
+ Error = "Invalid server list: " + e.Message;
+ yield break;
+ }
+
+ lock (gate) servers = list;
+ foreach (ServerListEntry entry in list) QueryAsync(entry);
+ }
+
+ /// Server auf diesem PC suchen (127.0.0.1, übliche Ports); nur antwortende werden gezeigt.
+ void ProbeLocalServers()
+ {
+ lock (gate) localServers.Clear();
+ foreach (int port in LocalPorts)
+ {
+ var entry = new ServerListEntry { Name = "Local server", Address = "127.0.0.1", Port = port, IsLocal = true, Version = "" };
+ QueryAsync(entry, found => localServers.Add(found));
+ }
+ }
+
+ /// Info + Ping im Hintergrund abfragen (blockiert das Spiel nicht).
+ void QueryAsync(ServerListEntry entry, Action onOnline = null)
+ {
+ var thread = new Thread(() =>
+ {
+ ServerInfo info;
+ bool ok = QueryInfo(entry.Address, entry.Port, QueryTimeoutMs, out info);
+ lock (gate)
+ {
+ if (!ok)
+ {
+ entry.Offline = true;
+ return;
+ }
+ entry.Name = info.Name;
+ entry.Description = info.Description;
+ entry.Players = info.Players;
+ entry.MaxPlayers = info.MaxPlayers;
+ entry.Version = info.Version;
+ entry.Ping = info.Ping;
+ if (info.HasProfile)
+ {
+ entry.RequireSteam = info.RequireSteam;
+ if (!string.IsNullOrEmpty(info.Owner)) entry.Owner = info.Owner;
+ entry.UptimeSeconds = info.UptimeSeconds;
+ entry.PlayerNames = info.PlayerNames;
+ }
+ entry.Offline = false;
+ if (onOnline != null) onOnline(entry);
+ }
+ });
+ thread.IsBackground = true;
+ thread.Start();
+ }
+
+ /// Antwort eines Servers auf die Info-Abfrage.
+ public struct ServerInfo
+ {
+ public string Name, Description, Version;
+ public int Players, MaxPlayers, Ping;
+ // Server-Profil (ab MSCO 2.4; ältere Server liefern das nicht)
+ public bool HasProfile;
+ public bool RequireSteam;
+ public string Owner;
+ public int UptimeSeconds;
+ public List PlayerNames;
+ }
+
+ /// Server direkt nach Name, Spielerzahl und Version fragen (blockierend - im Hintergrund aufrufen).
+ public static bool QueryInfo(string host, int port, int timeoutMs, out ServerInfo info)
+ {
+ info = new ServerInfo();
+ try
+ {
+ var watch = Stopwatch.StartNew();
+ using (var client = new TcpClient())
+ {
+ IAsyncResult connect = client.BeginConnect(host, port, null, null);
+ if (!connect.AsyncWaitHandle.WaitOne(timeoutMs)) return false;
+ client.EndConnect(connect);
+ client.ReceiveTimeout = timeoutMs;
+
+ NetworkStream stream = client.GetStream();
+ byte[] request = FrameParser.Frame(MsgType.Info, null);
+ stream.Write(request, 0, request.Length);
+
+ byte[] header = ReadExactly(stream, 5);
+ int length = BitConverter.ToInt32(header, 0);
+ if (header[4] != (byte)MsgType.InfoReply || length < 1 || length > 64 * 1024) return false;
+ byte[] payload = ReadExactly(stream, length - 1);
+ info.Ping = (int)watch.ElapsedMilliseconds;
+
+ using (var r = new BinaryReader(new MemoryStream(payload)))
+ {
+ info.Name = r.ReadString();
+ info.Description = r.ReadString();
+ info.Players = r.ReadInt32();
+ info.MaxPlayers = r.ReadInt32();
+ info.Version = r.ReadString();
+ info.RequireSteam = true;
+ if (r.BaseStream.Position < r.BaseStream.Length)
+ {
+ info.HasProfile = true;
+ info.RequireSteam = r.ReadBoolean();
+ info.Owner = r.ReadString();
+ info.UptimeSeconds = r.ReadInt32();
+ int count = r.ReadInt32();
+ info.PlayerNames = new List();
+ for (int i = 0; i < count && i < 256; i++) info.PlayerNames.Add(r.ReadString());
+ }
+ }
+ return true;
+ }
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ static byte[] ReadExactly(Stream stream, int count)
+ {
+ var data = new byte[count];
+ int read = 0;
+ while (read < count)
+ {
+ int n = stream.Read(data, read, count - read);
+ if (n <= 0) throw new IOException("Connection closed");
+ read += n;
+ }
+ return data;
+ }
+
+ /// "1.2.3.4:7777" oder "host" (Standard-Port) zerlegen.
+ public static bool TryParseAddress(string text, out string host, out int port)
+ {
+ host = null;
+ port = MultiplayerManager.DefaultPort;
+ if (string.IsNullOrEmpty(text)) return false;
+ text = text.Trim();
+ int colon = text.LastIndexOf(':');
+ if (colon > 0 && text.IndexOf(':') == colon)
+ {
+ if (!int.TryParse(text.Substring(colon + 1), out port) || port < 1 || port > 65535) return false;
+ text = text.Substring(0, colon);
+ }
+ host = text;
+ return host.Length > 0;
+ }
+ }
+}
diff --git a/SteamLobby.cs b/SteamLobby.cs
new file mode 100644
index 0000000..202ee01
--- /dev/null
+++ b/SteamLobby.cs
@@ -0,0 +1,227 @@
+using System;
+using MSCLoader;
+using Steamworks;
+
+namespace MSCO
+{
+ ///
+ /// Steam-Lobby für P2P: Host erstellt eine Freunde-Lobby, lädt über das Steam-Overlay ein,
+ /// Freunde treten über die Einladung bzw. "Spiel beitreten" bei. Die eigentlichen Daten
+ /// laufen danach über Steam P2P (siehe SteamConnection).
+ ///
+ public class SteamLobby
+ {
+ const string VersionKey = "msco_version";
+ const uint ChatMemberLeftMask = 2 | 4 | 8 | 16; // Left, Disconnected, Kicked, Banned
+
+ public CSteamID LobbyId { get; private set; }
+ public bool InLobby { get { return LobbyId.m_SteamID != 0; } }
+
+ /// Lobby erstellt (Host).
+ public event Action Created;
+ /// Lobby betreten (Client): Lobby-ID und Host.
+ public event Action Entered;
+ /// Einladung angenommen / "Spiel beitreten" im Freundesfenster.
+ public event Action JoinRequested;
+ /// Jemand hat die Lobby verlassen (Steam-ID).
+ public event Action MemberLeft;
+ /// Fehler (Text für die Statuszeile).
+ public event Action Failed;
+
+ CallResult createdResult;
+ CallResult enterResult;
+ Callback joinRequestedCallback;
+ Callback chatUpdateCallback;
+ Callback sessionRequestCallback;
+ bool initialized;
+
+ public static bool IsAvailable
+ {
+ get
+ {
+ try { return SteamAPI.IsSteamRunning() && SteamUser.GetSteamID().m_SteamID != 0; }
+ catch { return false; }
+ }
+ }
+
+ public static string PersonaName
+ {
+ get
+ {
+ try { return SteamFriends.GetPersonaName(); }
+ catch { return null; }
+ }
+ }
+
+ public static ulong OwnSteamId
+ {
+ get
+ {
+ try { return SteamUser.GetSteamID().m_SteamID; }
+ catch { return 0; }
+ }
+ }
+
+ static HAuthTicket authTicket;
+ static bool hasAuthTicket;
+
+ /// Steam-Anmeldeticket für dedizierte Server (der Server kann es bei Steam prüfen).
+ public static byte[] CreateAuthTicket()
+ {
+ try
+ {
+ CancelAuthTicket();
+ var buffer = new byte[1024];
+ uint length;
+ authTicket = SteamUser.GetAuthSessionTicket(buffer, buffer.Length, out length);
+ hasAuthTicket = true;
+ var ticket = new byte[length];
+ Array.Copy(buffer, ticket, length);
+ return ticket;
+ }
+ catch
+ {
+ return new byte[0];
+ }
+ }
+
+ public static void CancelAuthTicket()
+ {
+ if (!hasAuthTicket) return;
+ hasAuthTicket = false;
+ try { SteamUser.CancelAuthTicket(authTicket); } catch { }
+ }
+
+ public bool Init()
+ {
+ if (initialized) return true;
+ if (!IsAvailable) return false;
+ try
+ {
+ createdResult = CallResult.Create(OnLobbyCreated);
+ enterResult = CallResult.Create(OnLobbyEntered);
+ joinRequestedCallback = Callback.Create(OnJoinRequested);
+ chatUpdateCallback = Callback.Create(OnChatUpdate);
+ sessionRequestCallback = Callback.Create(OnSessionRequest);
+ initialized = true;
+ }
+ catch (Exception e)
+ {
+ ModConsole.Error("[MSCO] Steam init failed: " + e.Message);
+ }
+ return initialized;
+ }
+
+ /// Jedes Frame aufrufen, damit Steam-Callbacks ankommen.
+ public void Tick()
+ {
+ if (!initialized) return;
+ try { SteamAPI.RunCallbacks(); } catch { }
+ }
+
+ public void Create(int maxPlayers)
+ {
+ if (!Init()) { Fail("Steam is not running"); return; }
+ Leave();
+ createdResult.Set(SteamMatchmaking.CreateLobby(ELobbyType.k_ELobbyTypeFriendsOnly, maxPlayers));
+ }
+
+ public void Join(CSteamID lobby)
+ {
+ if (!Init()) { Fail("Steam is not running"); return; }
+ Leave();
+ enterResult.Set(SteamMatchmaking.JoinLobby(lobby));
+ }
+
+ public void OpenInviteDialog()
+ {
+ if (InLobby) SteamFriends.ActivateGameOverlayInviteDialog(LobbyId);
+ }
+
+ public void Leave()
+ {
+ if (!InLobby) return;
+ try { SteamMatchmaking.LeaveLobby(LobbyId); } catch { }
+ LobbyId = new CSteamID(0);
+ }
+
+ public bool IsMember(CSteamID user)
+ {
+ if (!InLobby) return false;
+ int count = SteamMatchmaking.GetNumLobbyMembers(LobbyId);
+ for (int i = 0; i < count; i++)
+ if (SteamMatchmaking.GetLobbyMemberByIndex(LobbyId, i) == user) return true;
+ return false;
+ }
+
+ // ---------------------------------------------------------------- Callbacks
+
+ void OnLobbyCreated(LobbyCreated_t result, bool ioFailure)
+ {
+ if (ioFailure || result.m_eResult != EResult.k_EResultOK)
+ {
+ Fail("Could not create Steam lobby (" + result.m_eResult + ")");
+ return;
+ }
+ LobbyId = new CSteamID(result.m_ulSteamIDLobby);
+ SteamMatchmaking.SetLobbyData(LobbyId, VersionKey, MSCO.ModVersion);
+ if (Created != null) Created(LobbyId);
+ }
+
+ void OnLobbyEntered(LobbyEnter_t result, bool ioFailure)
+ {
+ if (ioFailure || result.m_EChatRoomEnterResponse != 1) // 1 = Success
+ {
+ Fail("Could not join the lobby (full or closed)");
+ return;
+ }
+ LobbyId = new CSteamID(result.m_ulSteamIDLobby);
+
+ string hostVersion = SteamMatchmaking.GetLobbyData(LobbyId, VersionKey);
+ if (!string.IsNullOrEmpty(hostVersion) && hostVersion != MSCO.ModVersion)
+ {
+ Leave();
+ Fail("Version mismatch (host " + hostVersion + ", you " + MSCO.ModVersion + ")");
+ return;
+ }
+ CSteamID owner = SteamMatchmaking.GetLobbyOwner(LobbyId);
+ if (Entered != null) Entered(LobbyId, owner);
+ }
+
+ void OnJoinRequested(GameLobbyJoinRequested_t request)
+ {
+ if (JoinRequested != null) JoinRequested(request.m_steamIDLobby);
+ }
+
+ void OnChatUpdate(LobbyChatUpdate_t update)
+ {
+ if (update.m_ulSteamIDLobby != LobbyId.m_SteamID) return;
+ if ((update.m_rgfChatMemberStateChange & ChatMemberLeftMask) == 0) return;
+ if (MemberLeft != null) MemberLeft(new CSteamID(update.m_ulSteamIDUserChanged));
+ }
+
+ void OnSessionRequest(P2PSessionRequest_t request)
+ {
+ // Nur Verbindungen von Spielern aus unserer Lobby annehmen
+ if (IsMember(request.m_steamIDRemote))
+ SteamNetworking.AcceptP2PSessionWithUser(request.m_steamIDRemote);
+ }
+
+ void Fail(string message)
+ {
+ if (Failed != null) Failed(message);
+ }
+
+ /// Wurde das Spiel über eine Steam-Einladung gestartet? ("+connect_lobby <id>")
+ public static CSteamID LobbyFromCommandLine()
+ {
+ string[] args = Environment.GetCommandLineArgs();
+ for (int i = 0; i < args.Length - 1; i++)
+ {
+ ulong id;
+ if (args[i] == "+connect_lobby" && ulong.TryParse(args[i + 1], out id)) return new CSteamID(id);
+ }
+ return new CSteamID(0);
+ }
+ }
+}
diff --git a/UiTheme.cs b/UiTheme.cs
new file mode 100644
index 0000000..26c15cc
--- /dev/null
+++ b/UiTheme.cs
@@ -0,0 +1,119 @@
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Gemeinsamer Stil aller MSCO-Fenster (Launcher, F8-Konsole, Admin-Menü, Statusleiste):
+ /// dunkle Flächen mit abgerundeten Ecken, orange Akzente, einheitliche Schriften und Abstände.
+ /// Unitys OnGUI kennt keine runden Ecken - dafür werden kleine Texturen erzeugt und per
+ /// 9-Slice (GUIStyle.border) gestreckt, sodass die Ecken rund bleiben.
+ ///
+ public static class UiTheme
+ {
+ public static readonly Color Background = Hex(0x16, 0x16, 0x1a, 0.97f);
+ public static readonly Color Panel = Hex(0x0f, 0x0f, 0x12, 0.98f);
+ public static readonly Color Row = Hex(0x22, 0x22, 0x28);
+ public static readonly Color RowHover = Hex(0x2b, 0x2b, 0x33);
+ public static readonly Color FieldColor = Hex(0x22, 0x22, 0x28);
+ public static readonly Color FieldFocus = Hex(0x2c, 0x2c, 0x34);
+ public static readonly Color Accent = Hex(0xf2, 0x6b, 0x1d);
+ public static readonly Color AccentHover = Hex(0xff, 0x82, 0x3a);
+ public static readonly Color Secondary = Hex(0x2a, 0x2a, 0x31);
+ public static readonly Color SecondaryHover = Hex(0x36, 0x36, 0x3f);
+ public static readonly Color Danger = Hex(0xd9, 0x44, 0x48);
+ public static readonly Color DangerHover = Hex(0xee, 0x5a, 0x5e);
+ public static readonly Color Text = Hex(0xf2, 0xf2, 0xf5);
+ public static readonly Color Muted = Hex(0x8e, 0x8e, 0x99);
+ public static readonly Color Online = Hex(0x3d, 0xd6, 0x8c);
+ public static readonly Color Pending = Hex(0xf2, 0xc2, 0x1d);
+
+ public const int Radius = 6;
+
+ public static Color Hex(int r, int g, int b, float a = 1f)
+ {
+ return new Color(r / 255f, g / 255f, b / 255f, a);
+ }
+
+ /// Textur mit abgerundeten Ecken (weiche Kanten) für 9-Slice-Styles.
+ public static Texture2D Rounded(Color color, int radius = Radius)
+ {
+ int size = radius * 2 + 4;
+ var tex = new Texture2D(size, size, TextureFormat.ARGB32, false);
+ tex.wrapMode = TextureWrapMode.Clamp;
+ tex.filterMode = FilterMode.Bilinear;
+ for (int y = 0; y < size; y++)
+ {
+ for (int x = 0; x < size; x++)
+ {
+ // Abstand zur nächsten Ecken-Mitte (innerhalb des geraden Teils = 0)
+ float cx = Mathf.Clamp(x + 0.5f, radius, size - radius);
+ float cy = Mathf.Clamp(y + 0.5f, radius, size - radius);
+ float d = Vector2.Distance(new Vector2(x + 0.5f, y + 0.5f), new Vector2(cx, cy));
+ float alpha = Mathf.Clamp01(radius - d + 0.5f);
+ tex.SetPixel(x, y, new Color(color.r, color.g, color.b, color.a * alpha));
+ }
+ }
+ tex.Apply();
+ tex.hideFlags = HideFlags.HideAndDontSave;
+ return tex;
+ }
+
+ public static Texture2D Solid(Color color)
+ {
+ var tex = new Texture2D(1, 1);
+ tex.SetPixel(0, 0, color);
+ tex.Apply();
+ tex.hideFlags = HideFlags.HideAndDontSave;
+ return tex;
+ }
+
+ /// Fläche (Fenster, Karte, Zeile) mit runden Ecken; mit GUI.Box zeichnen.
+ public static GUIStyle Panel9(Color color, int radius = Radius)
+ {
+ var s = new GUIStyle();
+ s.normal.background = Rounded(color, radius);
+ s.border = new RectOffset(radius + 2, radius + 2, radius + 2, radius + 2);
+ return s;
+ }
+
+ public static GUIStyle Button(Color normal, Color hover, Color text, int fontSize = 13, bool bold = true)
+ {
+ var s = new GUIStyle(GUI.skin.button);
+ s.normal.background = Rounded(normal);
+ s.hover.background = Rounded(hover);
+ s.active.background = Rounded(hover);
+ s.focused.background = Rounded(normal);
+ s.onNormal.background = Rounded(hover);
+ s.normal.textColor = s.hover.textColor = s.active.textColor = s.focused.textColor = s.onNormal.textColor = text;
+ s.border = new RectOffset(Radius + 2, Radius + 2, Radius + 2, Radius + 2);
+ s.padding = new RectOffset(12, 12, 6, 6);
+ s.fontSize = fontSize;
+ s.fontStyle = bold ? FontStyle.Bold : FontStyle.Normal;
+ return s;
+ }
+
+ public static GUIStyle Field(int fontSize = 14)
+ {
+ var s = new GUIStyle(GUI.skin.textField);
+ s.normal.background = Rounded(FieldColor);
+ s.hover.background = Rounded(FieldFocus);
+ s.focused.background = Rounded(FieldFocus);
+ s.normal.textColor = s.hover.textColor = s.focused.textColor = Text;
+ s.border = new RectOffset(Radius + 2, Radius + 2, Radius + 2, Radius + 2);
+ s.padding = new RectOffset(12, 12, 0, 0);
+ s.alignment = TextAnchor.MiddleLeft;
+ s.fontSize = fontSize;
+ return s;
+ }
+
+ public static GUIStyle Label(int fontSize, Color color, FontStyle font = FontStyle.Normal, TextAnchor align = TextAnchor.UpperLeft)
+ {
+ var s = new GUIStyle(GUI.skin.label);
+ s.fontSize = fontSize;
+ s.fontStyle = font;
+ s.normal.textColor = color;
+ s.alignment = align;
+ return s;
+ }
+ }
+}
diff --git a/VehicleFx.cs b/VehicleFx.cs
new file mode 100644
index 0000000..4484427
--- /dev/null
+++ b/VehicleFx.cs
@@ -0,0 +1,190 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Lichter und Dauergeräusche (Motor, Blinker, Hupe, Radio ...) von Fahrzeugen.
+ /// Wer ein Fahrzeug besitzt (fährt/schiebt), schickt regelmäßig deren Zustand;
+ /// bei allen anderen wird dieser Zustand jedes Frame übernommen.
+ ///
+ public class VehicleFx
+ {
+ const float SendInterval = 0.2f;
+ const float CacheRefresh = 5f; // Teile können an-/abgebaut werden
+ const float ApplyTimeout = 2f; // ohne neue Daten nichts mehr erzwingen
+
+ struct AudioState
+ {
+ public bool Playing;
+ public float Pitch;
+ public float Volume;
+ }
+
+ class Components
+ {
+ public float BuiltAt;
+ public Transform Root;
+ public readonly Dictionary Lights = new Dictionary();
+ public readonly Dictionary Audio = new Dictionary();
+ }
+
+ class Snapshot
+ {
+ public float ReceivedAt;
+ public readonly Dictionary Lights = new Dictionary();
+ public readonly Dictionary Audio = new Dictionary();
+ }
+
+ readonly Dictionary components = new Dictionary();
+ readonly Dictionary incoming = new Dictionary();
+ float sendTimer;
+
+ public void Reset()
+ {
+ components.Clear();
+ incoming.Clear();
+ }
+
+ // ---------------------------------------------------------------- Senden
+
+ public void Update(ObjectSync objects, Action send, float dt)
+ {
+ sendTimer += dt;
+ if (sendTimer < SendInterval) return;
+ sendTimer = 0f;
+
+ foreach (SyncedBody b in objects.LocalVehicles())
+ {
+ Components c = GetComponents(b);
+ if (c == null) continue;
+ int id = b.Id;
+ send(MsgType.VehicleFx, NetSession.Build(w =>
+ {
+ w.Write(id);
+ w.Write(c.Lights.Count);
+ foreach (KeyValuePair l in c.Lights)
+ {
+ w.Write(l.Key);
+ w.Write(l.Value != null && l.Value.enabled && l.Value.gameObject.activeInHierarchy);
+ }
+ int audioCount = 0;
+ foreach (AudioSource a in c.Audio.Values) if (a != null) audioCount++;
+ w.Write(audioCount);
+ foreach (KeyValuePair a in c.Audio)
+ {
+ if (a.Value == null) continue;
+ w.Write(a.Key);
+ w.Write(a.Value.isPlaying);
+ w.Write(a.Value.pitch);
+ w.Write(a.Value.volume);
+ }
+ }));
+ }
+ }
+
+ // ---------------------------------------------------------------- Empfangen
+
+ public void Handle(BinaryReader r)
+ {
+ int id = r.ReadInt32();
+ var s = new Snapshot { ReceivedAt = Time.time };
+ int lights = r.ReadInt32();
+ for (int i = 0; i < lights; i++) s.Lights[r.ReadInt32()] = r.ReadBoolean();
+ int audio = r.ReadInt32();
+ for (int i = 0; i < audio; i++)
+ {
+ int key = r.ReadInt32();
+ s.Audio[key] = new AudioState { Playing = r.ReadBoolean(), Pitch = r.ReadSingle(), Volume = r.ReadSingle() };
+ }
+ incoming[id] = s;
+ }
+
+ /// Jedes Frame nach den Spiel-FSMs (LateUpdate), damit unsere Werte gewinnen.
+ public void Apply(ObjectSync objects)
+ {
+ foreach (KeyValuePair kv in incoming)
+ {
+ if (Time.time - kv.Value.ReceivedAt > ApplyTimeout) continue;
+ SyncedBody b = objects.Get(kv.Key);
+ if (b == null || !objects.IsRemoteOwnedBody(b)) continue;
+ Components c = GetComponents(b);
+ if (c == null) continue;
+
+ foreach (KeyValuePair l in kv.Value.Lights)
+ {
+ Light light;
+ if (c.Lights.TryGetValue(l.Key, out light) && light != null) SetLight(light, l.Value, c.Root);
+ }
+ foreach (KeyValuePair a in kv.Value.Audio)
+ {
+ AudioSource src;
+ if (c.Audio.TryGetValue(a.Key, out src) && src != null) SetAudio(src, a.Value);
+ }
+ }
+ }
+
+ static void SetLight(Light light, bool on, Transform root)
+ {
+ if (!on)
+ {
+ if (light.enabled) light.enabled = false;
+ return;
+ }
+ if (!light.enabled) light.enabled = true;
+ // Lichter werden im Spiel oft über ein übergeordnetes Objekt ein-/ausgeschaltet
+ for (Transform t = light.transform; t != null && t != root; t = t.parent)
+ if (!t.gameObject.activeSelf) t.gameObject.SetActive(true);
+ }
+
+ static void SetAudio(AudioSource src, AudioState state)
+ {
+ if (!src.gameObject.activeInHierarchy) return;
+ if (state.Playing && !src.isPlaying) src.Play();
+ else if (!state.Playing && src.isPlaying) src.Stop();
+ src.pitch = state.Pitch;
+ src.volume = state.Volume;
+ }
+
+ // ---------------------------------------------------------------- Komponenten
+
+ Components GetComponents(SyncedBody b)
+ {
+ if (b.Rb == null) return null;
+ Components c;
+ if (components.TryGetValue(b.Id, out c) && Time.time - c.BuiltAt < CacheRefresh) return c;
+
+ c = new Components { BuiltAt = Time.time, Root = b.Rb.transform };
+ var seen = new Dictionary();
+ foreach (Light l in b.Rb.GetComponentsInChildren(true))
+ c.Lights[Key(l.transform, c.Root, seen)] = l;
+ seen.Clear();
+ foreach (AudioSource a in b.Rb.GetComponentsInChildren(true))
+ if (a.loop) c.Audio[Key(a.transform, c.Root, seen)] = a; // nur Dauergeräusche
+ components[b.Id] = c;
+ return c;
+ }
+
+ /// Schlüssel aus dem Pfad unterhalb des Fahrzeugs (+ Nummer bei gleichen Pfaden).
+ static int Key(Transform t, Transform root, Dictionary seen)
+ {
+ var sb = new StringBuilder();
+ for (; t != null && t != root; t = t.parent) sb.Insert(0, "/" + t.name);
+ string path = sb.ToString();
+ int n;
+ seen.TryGetValue(path, out n);
+ seen[path] = n + 1;
+ path += "#" + n;
+
+ unchecked
+ {
+ uint h = 2166136261;
+ foreach (byte c in Encoding.UTF8.GetBytes(path)) { h ^= c; h *= 16777619; }
+ return (int)h;
+ }
+ }
+ }
+}
diff --git a/VehicleStateSync.cs b/VehicleStateSync.cs
new file mode 100644
index 0000000..5874d62
--- /dev/null
+++ b/VehicleStateSync.cs
@@ -0,0 +1,242 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using HutongGames.PlayMaker;
+using MSCLoader;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Gesamtzustand der Fahrzeuge (vor allem Satsuma): Werte in den PlayMaker-FSMs unter dem Fahrzeug,
+ /// z.B. Tankinhalt, Öl, Kühlwasser, Bremsflüssigkeit, Batterieladung, Schrauben-Festigkeit,
+ /// Schäden/Verschleiß, Kilometerstand, Lack, Einstellungen (Vergaser, Zündung ...).
+ /// Wer das Fahrzeug gerade besitzt (fährt/schiebt), gibt die Werte vor; ist es frei,
+ /// schickt jeder seine Änderungen (z.B. Öl nachfüllen, Schraube festziehen).
+ ///
+ public class VehicleStateSync
+ {
+ // Nur Werte, die den Zustand beschreiben - nicht Drehzahl, Lenkung, Raycasts usw.
+ static readonly string[] Keywords =
+ {
+ "fuel", "oil", "coolant", "water", "fluid", "brake", "clutchfluid", "battery", "charge",
+ "bolt", "tight", "damage", "wear", "condition", "odo", "km", "dirt", "paint", "color", "colour",
+ "tune", "tuning", "ratio", "broken", "fixed", "flat", "pressure", "level", "mixture", "timing",
+ "idle", "air", "jet", "spark", "gap", "rust", "health", "burn", "cool", "temp"
+ };
+
+ // Montiert-Flags nicht direkt setzen: das echte Anbauen läuft über FsmSync (Events)
+ static readonly string[] Excluded = { "install", "rpm", "speed", "steer", "throttle", "gear", "raycast", "distance" };
+
+ const float ScanInterval = 10f;
+ const float CheckInterval = 0.5f;
+ const int MaxPerMessage = 200;
+
+ class Var
+ {
+ public int Key;
+ public string Root;
+ public NamedVariable Variable;
+ public byte Type; // 1 float, 2 int, 3 bool
+ public float LastF;
+ public int LastI;
+ public bool LastB;
+ }
+
+ readonly Dictionary vars = new Dictionary();
+ readonly Dictionary pending = new Dictionary(); // noch nicht gefundene Werte
+ readonly List outgoing = new List();
+ readonly HashSet scanned = new HashSet();
+ float nextScan, nextCheck;
+
+ public int Count { get { return vars.Count; } }
+
+ public void Reset()
+ {
+ vars.Clear();
+ pending.Clear();
+ scanned.Clear();
+ nextScan = 0f;
+ }
+
+ public void Update(ObjectSync objects, int localId, Action send)
+ {
+ if (Time.time >= nextScan)
+ {
+ nextScan = Time.time + ScanInterval;
+ Scan();
+ }
+ if (Time.time < nextCheck) return;
+ nextCheck = Time.time + CheckInterval;
+
+ outgoing.Clear();
+ foreach (Var v in vars.Values)
+ {
+ if (!Changed(v)) continue;
+ Remember(v);
+ // Fährt/schiebt ein anderer das Fahrzeug, ist seine Version maßgeblich
+ int owner = objects.VehicleOwner(v.Root);
+ if (owner == SyncedBody.NoOwner || owner == localId) outgoing.Add(v);
+ }
+
+ for (int start = 0; start < outgoing.Count; start += MaxPerMessage)
+ {
+ int from = start, count = Math.Min(MaxPerMessage, outgoing.Count - start);
+ send(MsgType.VarSync, NetSession.Build(w =>
+ {
+ w.Write(count);
+ for (int i = from; i < from + count; i++) Write(w, outgoing[i]);
+ }));
+ }
+ }
+
+ public void Handle(int from, BinaryReader r, ObjectSync objects, int localId)
+ {
+ int count = r.ReadInt32();
+ for (int i = 0; i < count; i++)
+ {
+ int key = r.ReadInt32();
+ byte type = r.ReadByte();
+ byte[] value = r.ReadBytes(type == 3 ? 1 : 4);
+ Var v;
+ if (!vars.TryGetValue(key, out v)) { pending[key] = Combine(type, value); continue; }
+ if (from >= 0 && objects.VehicleOwner(v.Root) == localId) continue; // ich fahre gerade: meine Werte gelten
+ Apply(v, type, value);
+ }
+ }
+
+ /// Gespeicherte Werte vom Server (Snapshot) - auch für Teile, die erst später auftauchen.
+ public void ApplySnapshot(BinaryReader r)
+ {
+ int count = r.ReadInt32();
+ for (int i = 0; i < count; i++)
+ {
+ int key = r.ReadInt32();
+ byte type = r.ReadByte();
+ byte[] value = r.ReadBytes(type == 3 ? 1 : 4);
+ Var v;
+ if (vars.TryGetValue(key, out v)) Apply(v, type, value);
+ else pending[key] = Combine(type, value);
+ }
+ }
+
+ // ---------------------------------------------------------------- intern
+
+ void Scan()
+ {
+ int before = vars.Count;
+ foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f))
+ {
+ // Schon erfasste FSMs nicht jedes Mal neu durchgehen (Pfad + Hash kosten)
+ if (fsm == null || !scanned.Add(fsm)) continue;
+ string root = fsm.transform.root.name;
+ if (!IsVehicle(root)) continue;
+ string prefix = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName + "|";
+ FsmVariables fv = fsm.FsmVariables;
+ foreach (FsmFloat f in fv.FloatVariables) Register(prefix, root, f, 1);
+ foreach (FsmInt n in fv.IntVariables) Register(prefix, root, n, 2);
+ foreach (FsmBool b in fv.BoolVariables) Register(prefix, root, b, 3);
+ }
+ if (vars.Count != before) ModConsole.Print("[MSCO] Vehicle state: " + vars.Count + " values synced");
+ }
+
+ void Register(string prefix, string root, NamedVariable variable, byte type)
+ {
+ if (variable == null || string.IsNullOrEmpty(variable.Name) || !Wanted(variable.Name)) return;
+ int key = Hash(prefix + variable.Name);
+ Var existing;
+ if (vars.TryGetValue(key, out existing) && existing.Variable == variable) return;
+ var v = new Var { Key = key, Root = root, Variable = variable, Type = type };
+ Remember(v);
+ vars[key] = v;
+
+ byte[] stored;
+ if (pending.TryGetValue(key, out stored))
+ {
+ pending.Remove(key);
+ byte[] value = new byte[stored.Length - 1];
+ Array.Copy(stored, 1, value, 0, value.Length);
+ Apply(v, stored[0], value);
+ }
+ }
+
+ static bool Wanted(string name)
+ {
+ string n = name.ToLower();
+ foreach (string x in Excluded) if (n.Contains(x)) return false;
+ foreach (string k in Keywords) if (n.Contains(k)) return true;
+ return false;
+ }
+
+ static bool IsVehicle(string root)
+ {
+ foreach (string v in ObjectSync.VehicleRoots) if (root.StartsWith(v)) return true;
+ return false;
+ }
+
+ static bool Changed(Var v)
+ {
+ switch (v.Type)
+ {
+ case 1:
+ float f = ((FsmFloat)v.Variable).Value;
+ return Mathf.Abs(f - v.LastF) > Mathf.Max(0.01f, Mathf.Abs(v.LastF) * 0.002f);
+ case 2: return ((FsmInt)v.Variable).Value != v.LastI;
+ default: return ((FsmBool)v.Variable).Value != v.LastB;
+ }
+ }
+
+ static void Remember(Var v)
+ {
+ switch (v.Type)
+ {
+ case 1: v.LastF = ((FsmFloat)v.Variable).Value; break;
+ case 2: v.LastI = ((FsmInt)v.Variable).Value; break;
+ default: v.LastB = ((FsmBool)v.Variable).Value; break;
+ }
+ }
+
+ static void Apply(Var v, byte type, byte[] value)
+ {
+ if (type != v.Type) return;
+ switch (type)
+ {
+ case 1: ((FsmFloat)v.Variable).Value = BitConverter.ToSingle(value, 0); break;
+ case 2: ((FsmInt)v.Variable).Value = BitConverter.ToInt32(value, 0); break;
+ default: ((FsmBool)v.Variable).Value = value[0] != 0; break;
+ }
+ Remember(v); // nicht als eigene Änderung zurückschicken
+ }
+
+ static void Write(BinaryWriter w, Var v)
+ {
+ w.Write(v.Key);
+ w.Write(v.Type);
+ switch (v.Type)
+ {
+ case 1: w.Write(v.LastF); break;
+ case 2: w.Write(v.LastI); break;
+ default: w.Write(v.LastB); break;
+ }
+ }
+
+ static byte[] Combine(byte type, byte[] value)
+ {
+ var all = new byte[value.Length + 1];
+ all[0] = type;
+ Array.Copy(value, 0, all, 1, value.Length);
+ return all;
+ }
+
+ static int Hash(string s)
+ {
+ unchecked
+ {
+ uint h = 2166136261;
+ foreach (byte c in Encoding.UTF8.GetBytes(s)) { h ^= c; h *= 16777619; }
+ return (int)h;
+ }
+ }
+ }
+}
diff --git a/WinHttp.cs b/WinHttp.cs
new file mode 100644
index 0000000..5dd32c8
--- /dev/null
+++ b/WinHttp.cs
@@ -0,0 +1,127 @@
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace MSCO
+{
+ ///
+ /// HTTPS-Abruf über Windows (WinHTTP). Unitys WWW in My Summer Car (Unity 5.0) kann nur TLS 1.0,
+ /// heutige Server verlangen TLS 1.2+. WinHTTP nutzt die TLS-Version des Betriebssystems
+ /// (unter Proton/Wine über GnuTLS). Blockierend - nur in einem Hintergrund-Thread aufrufen.
+ ///
+ public static class WinHttp
+ {
+ public static string Get(string url, int timeoutMs, out string error)
+ {
+ error = null;
+ IntPtr session = IntPtr.Zero, connect = IntPtr.Zero, request = IntPtr.Zero;
+ try
+ {
+ var uri = new Uri(url);
+ bool secure = uri.Scheme == "https";
+
+ session = WinHttpOpen("MSCO/" + MSCO.ModVersion, AccessTypeDefaultProxy, null, null, 0);
+ if (session == IntPtr.Zero) return Fail(out error, "WinHttpOpen");
+ WinHttpSetTimeouts(session, timeoutMs, timeoutMs, timeoutMs, timeoutMs);
+
+ // Ältere Windows-Versionen haben TLS 1.2 nicht standardmäßig an - ausdrücklich erlauben
+ int protocols = ProtocolTls12 | ProtocolTls13;
+ if (!WinHttpSetOption(session, OptionSecureProtocols, ref protocols, 4))
+ {
+ protocols = ProtocolTls12;
+ WinHttpSetOption(session, OptionSecureProtocols, ref protocols, 4);
+ }
+
+ connect = WinHttpConnect(session, uri.Host, (ushort)uri.Port, 0);
+ if (connect == IntPtr.Zero) return Fail(out error, "WinHttpConnect");
+
+ request = WinHttpOpenRequest(connect, "GET", uri.PathAndQuery, null, null, IntPtr.Zero, secure ? FlagSecure : 0);
+ if (request == IntPtr.Zero) return Fail(out error, "WinHttpOpenRequest");
+
+ if (!WinHttpSendRequest(request, null, 0, IntPtr.Zero, 0, 0, IntPtr.Zero)) return Fail(out error, "WinHttpSendRequest");
+ if (!WinHttpReceiveResponse(request, IntPtr.Zero)) return Fail(out error, "WinHttpReceiveResponse");
+
+ int status = 0, size = 4;
+ WinHttpQueryHeaders(request, QueryStatusCode | QueryFlagNumber, null, ref status, ref size, IntPtr.Zero);
+ if (status != 0 && status != 200)
+ {
+ error = "HTTP " + status;
+ return null;
+ }
+
+ using (var body = new MemoryStream())
+ {
+ var buffer = new byte[16 * 1024];
+ while (true)
+ {
+ int read;
+ if (!WinHttpReadData(request, buffer, buffer.Length, out read)) return Fail(out error, "WinHttpReadData");
+ if (read == 0) break;
+ body.Write(buffer, 0, read);
+ if (body.Length > 4 * 1024 * 1024) { error = "Response too large"; return null; }
+ }
+ return Encoding.UTF8.GetString(body.ToArray());
+ }
+ }
+ catch (Exception e)
+ {
+ error = e.Message;
+ return null;
+ }
+ finally
+ {
+ if (request != IntPtr.Zero) WinHttpCloseHandle(request);
+ if (connect != IntPtr.Zero) WinHttpCloseHandle(connect);
+ if (session != IntPtr.Zero) WinHttpCloseHandle(session);
+ }
+ }
+
+ static string Fail(out string error, string step)
+ {
+ int code = Marshal.GetLastWin32Error();
+ error = step + " failed (" + code + (code == 12175 ? ", secure connection failed" : code == 12002 ? ", timeout" : code == 12029 ? ", cannot connect" : "") + ")";
+ return null;
+ }
+
+ const int AccessTypeDefaultProxy = 0;
+ const int FlagSecure = 0x00800000;
+ const int OptionSecureProtocols = 84;
+ const int ProtocolTls12 = 0x00000800;
+ const int ProtocolTls13 = 0x00002000;
+ const int QueryStatusCode = 19;
+ const int QueryFlagNumber = 0x20000000;
+
+ [DllImport("winhttp.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ static extern IntPtr WinHttpOpen(string agent, int accessType, string proxy, string bypass, int flags);
+
+ [DllImport("winhttp.dll", SetLastError = true)]
+ static extern bool WinHttpSetTimeouts(IntPtr handle, int resolve, int connect, int send, int receive);
+
+ [DllImport("winhttp.dll", SetLastError = true)]
+ static extern bool WinHttpSetOption(IntPtr handle, int option, ref int value, int length);
+
+ [DllImport("winhttp.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ static extern IntPtr WinHttpConnect(IntPtr session, string server, ushort port, int reserved);
+
+ [DllImport("winhttp.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ static extern IntPtr WinHttpOpenRequest(IntPtr connect, string verb, string path, string version, string referrer,
+ IntPtr acceptTypes, int flags);
+
+ [DllImport("winhttp.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ static extern bool WinHttpSendRequest(IntPtr request, string headers, int headersLength, IntPtr optional,
+ int optionalLength, int totalLength, IntPtr context);
+
+ [DllImport("winhttp.dll", SetLastError = true)]
+ static extern bool WinHttpReceiveResponse(IntPtr request, IntPtr reserved);
+
+ [DllImport("winhttp.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ static extern bool WinHttpQueryHeaders(IntPtr request, int infoLevel, string name, ref int buffer, ref int bufferLength, IntPtr index);
+
+ [DllImport("winhttp.dll", SetLastError = true)]
+ static extern bool WinHttpReadData(IntPtr request, byte[] buffer, int toRead, out int read);
+
+ [DllImport("winhttp.dll", SetLastError = true)]
+ static extern bool WinHttpCloseHandle(IntPtr handle);
+ }
+}
diff --git a/WorldSync.cs b/WorldSync.cs
new file mode 100644
index 0000000..37e8c43
--- /dev/null
+++ b/WorldSync.cs
@@ -0,0 +1,183 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using HutongGames.PlayMaker;
+using MSCLoader;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Uhrzeit und Wetter. Ein Spieler ist "Weltuhr" (P2P: der Host; dedizierter Server: der Spieler
+ /// mit der kleinsten ID) und schickt regelmäßig den Zustand der Uhr- und Wetter-FSMs
+ /// (aktiver Zustand + alle Zahlen-/Ja-Nein-Variablen + Drehung, z.B. der Sonne) sowie globale
+ /// Werte wie den Wochentag. Alle anderen übernehmen diesen Zustand.
+ ///
+ public class WorldSync
+ {
+ // FSMs, die Uhrzeit und Wetter steuern
+ static readonly string[] TrackedFsmNames = { "Clock", "Weather" };
+ // Globale PlayMaker-Variablen (Wochentag, Tageszeit)
+ static readonly string[] TrackedGlobals = { "GlobalDay", "TimeOfDay" };
+
+ const float SendInterval = 2f;
+ const float DiscoverRetry = 5f;
+
+ class Tracked
+ {
+ public string Key;
+ public PlayMakerFSM Fsm;
+ }
+
+ readonly Dictionary tracked = new Dictionary();
+ float sendTimer;
+ float nextDiscover;
+ bool discovered;
+
+ public int TrackedCount { get { return tracked.Count; } }
+
+ public void Reset()
+ {
+ tracked.Clear();
+ discovered = false;
+ nextDiscover = 0f;
+ }
+
+ // ---------------------------------------------------------------- Senden (Weltuhr)
+
+ public void Update(bool isAuthority, Action send, float dt)
+ {
+ if (!discovered && Time.time >= nextDiscover) Discover();
+ if (!isAuthority || tracked.Count == 0) return;
+
+ sendTimer += dt;
+ if (sendTimer < SendInterval) return;
+ sendTimer = 0f;
+ send(MsgType.WorldTime, BuildState());
+ }
+
+ byte[] BuildState()
+ {
+ var alive = new List();
+ foreach (Tracked t in tracked.Values) if (t.Fsm != null) alive.Add(t);
+
+ return NetSession.Build(w =>
+ {
+ w.Write(alive.Count);
+ foreach (Tracked t in alive)
+ {
+ w.Write(t.Key);
+ w.Write(t.Fsm.ActiveStateName ?? "");
+ Quaternion rot = t.Fsm.transform.localRotation;
+ w.Write(rot.x); w.Write(rot.y); w.Write(rot.z); w.Write(rot.w);
+ WriteVariables(w, t.Fsm.FsmVariables, null);
+ }
+ WriteVariables(w, FsmVariables.GlobalVariables, TrackedGlobals);
+ });
+ }
+
+ static void WriteVariables(BinaryWriter w, FsmVariables vars, string[] only)
+ {
+ var floats = Filter(vars.FloatVariables, only);
+ w.Write(floats.Count);
+ foreach (FsmFloat v in floats) { w.Write(v.Name); w.Write(v.Value); }
+
+ var ints = Filter(vars.IntVariables, only);
+ w.Write(ints.Count);
+ foreach (FsmInt v in ints) { w.Write(v.Name); w.Write(v.Value); }
+
+ var bools = Filter(vars.BoolVariables, only);
+ w.Write(bools.Count);
+ foreach (FsmBool v in bools) { w.Write(v.Name); w.Write(v.Value); }
+ }
+
+ static List Filter(T[] vars, string[] only) where T : NamedVariable
+ {
+ var list = new List();
+ if (vars == null) return list;
+ foreach (T v in vars)
+ if (v != null && !string.IsNullOrEmpty(v.Name) && (only == null || Array.IndexOf(only, v.Name) >= 0))
+ list.Add(v);
+ return list;
+ }
+
+ // ---------------------------------------------------------------- Empfangen
+
+ public void Apply(BinaryReader r)
+ {
+ if (!discovered) Discover();
+
+ int count = r.ReadInt32();
+ for (int i = 0; i < count; i++)
+ {
+ string key = r.ReadString();
+ string state = r.ReadString();
+ var rot = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
+
+ Tracked t;
+ PlayMakerFSM fsm = tracked.TryGetValue(key, out t) ? t.Fsm : null;
+ ReadVariables(r, fsm != null ? fsm.FsmVariables : null);
+ if (fsm == null) continue;
+
+ fsm.transform.localRotation = rot;
+ if (!string.IsNullOrEmpty(state) && fsm.ActiveStateName != state)
+ {
+ FsmState target = fsm.Fsm.GetState(state);
+ if (target != null) FsmSync.SwitchStateSilently(fsm, target); // z.B. Regen beginnt/hört auf
+ }
+ }
+ ReadVariables(r, FsmVariables.GlobalVariables);
+ }
+
+ static void ReadVariables(BinaryReader r, FsmVariables vars)
+ {
+ int floats = r.ReadInt32();
+ for (int i = 0; i < floats; i++)
+ {
+ string name = r.ReadString();
+ float value = r.ReadSingle();
+ FsmFloat v = vars != null ? vars.FindFsmFloat(name) : null;
+ if (v != null) v.Value = value;
+ }
+ int ints = r.ReadInt32();
+ for (int i = 0; i < ints; i++)
+ {
+ string name = r.ReadString();
+ int value = r.ReadInt32();
+ FsmInt v = vars != null ? vars.FindFsmInt(name) : null;
+ if (v != null) v.Value = value;
+ }
+ int bools = r.ReadInt32();
+ for (int i = 0; i < bools; i++)
+ {
+ string name = r.ReadString();
+ bool value = r.ReadBoolean();
+ FsmBool v = vars != null ? vars.FindFsmBool(name) : null;
+ if (v != null) v.Value = value;
+ }
+ }
+
+ // ---------------------------------------------------------------- Suchen
+
+ void Discover()
+ {
+ nextDiscover = Time.time + DiscoverRetry;
+ tracked.Clear();
+ foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f))
+ {
+ if (fsm == null) continue;
+ if (Array.IndexOf(TrackedFsmNames, fsm.FsmName) < 0) continue;
+ string root = fsm.transform.root.name;
+ if (root == "PLAYER" || root.StartsWith("MSCO_")) continue;
+
+ string key = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName;
+ if (!tracked.ContainsKey(key)) tracked[key] = new Tracked { Key = key, Fsm = fsm };
+ }
+ if (tracked.Count == 0) return; // Welt noch nicht fertig geladen - später nochmal
+ discovered = true;
+ ModConsole.Print("[MSCO] Time/weather sync: " + tracked.Count + " FSMs");
+ if (FsmSync.Debug)
+ foreach (string key in tracked.Keys) ModConsole.Print("[MSCO] " + key);
+ }
+ }
+}