From e77ccf2f33926ad1168ce300c9392ab213481def Mon Sep 17 00:00:00 2001 From: MrMaho Date: Sat, 26 Sep 2026 19:37:28 +0200 Subject: [PATCH] Beta Release --- Branding.cs | 50 ++ Commands.cs | 89 +++ DiscordPresence.cs | 254 ++++++++ DoorSync.cs | 122 ++++ FsmSync.cs | 396 +++++++++++++ GameCharacter.cs | 405 +++++++++++++ InputLock.cs | 108 ++++ JoinLinks.cs | 84 +++ LobbyUI.cs | 610 +++++++++++++++++++ MSCO.cs | 12 +- MSCO.csproj | 34 ++ MenuInputBlocker.cs | 37 ++ MoneySync.cs | 66 +++ MpCommand.cs | 28 +- MultiplayerManager.cs | 1300 ++++++++++++++++++++++++++++++++++++----- NetSession.cs | 547 +++++++++++++---- ObjectSync.cs | 702 ++++++++++++++++++++++ Overlays.cs | 351 +++++++++++ Passenger.cs | 131 +++++ RemotePlayer.cs | 328 ++++++++++- SafetyPatches.cs | 74 +++ SceneCache.cs | 32 + ServerBrowser.cs | 264 +++++++++ SteamLobby.cs | 227 +++++++ UiTheme.cs | 119 ++++ VehicleFx.cs | 190 ++++++ VehicleStateSync.cs | 242 ++++++++ WinHttp.cs | 127 ++++ WorldSync.cs | 183 ++++++ 29 files changed, 6819 insertions(+), 293 deletions(-) create mode 100644 Branding.cs create mode 100644 Commands.cs create mode 100644 DiscordPresence.cs create mode 100644 DoorSync.cs create mode 100644 FsmSync.cs create mode 100644 GameCharacter.cs create mode 100644 InputLock.cs create mode 100644 JoinLinks.cs create mode 100644 LobbyUI.cs create mode 100644 MenuInputBlocker.cs create mode 100644 MoneySync.cs create mode 100644 ObjectSync.cs create mode 100644 Overlays.cs create mode 100644 Passenger.cs create mode 100644 SafetyPatches.cs create mode 100644 SceneCache.cs create mode 100644 ServerBrowser.cs create mode 100644 SteamLobby.cs create mode 100644 UiTheme.cs create mode 100644 VehicleFx.cs create mode 100644 VehicleStateSync.cs create mode 100644 WinHttp.cs create mode 100644 WorldSync.cs 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); + } + } +}