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); + } + } +}