using System.Collections.Generic; using System.IO; using HutongGames.PlayMaker; using MSCLoader; 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): 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 = 1f / 30f; // 30 Spieler-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; 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 bool rescanSoon; // nach einer Abbau-Aktion neue lose Teile sofort erfassen byte[] pendingSnapshot; // Server: Weltzustand, wartet auf Objekt-Suchlauf const int SnapshotEventsPerFrame = 20; readonly ServerBrowser browser = new ServerBrowser(); string status = ""; string playerName; readonly Dictionary players = new Dictionary(); readonly LobbyUI lobby = new LobbyUI(); readonly ObjectSync objects = new ObjectSync(); readonly VehicleFx vehicleFx = new VehicleFx(); readonly WheelSpin wheelSpin = new WheelSpin(); 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 DamageSync damageSync = new DamageSync(); readonly WorldVarSync worldVars = new WorldVarSync(); readonly TrafficSync traffic = new TrafficSync(); readonly HingeSync hinges = new HingeSync(); readonly ControlSync controls = new ControlSync(); readonly Toasts toasts = new Toasts(); float nextNeedsSend; const float NeedsInterval = 10f; void ToastJoined(string name) { toasts.Add(name, "joined the game", UiTheme.Online); } void ToastLeft(string name) { toasts.Add(name, "left the game", UiTheme.Muted); } 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 pingMs = -1f; // geglättete Laufzeit zum Server/Host (-1 = noch unbekannt) const string PrefShowPing = "MSCO_ShowPing"; GUIStyle pingStyle; float savingMessageUntil; GUIStyle hudStyle, savingStyle; Texture2D hudBackground; GUIStyle hudBox; PlayMakerFSM continueFsm; FsmState continueState; float nextContinueSearch; Transform localPlayer; Camera playerCamera; float sendTimer; float nextPlayerSearch; GUIStyle nameTagStyle; public static void Ensure() { if (Instance != null) return; var go = new GameObject("MSCO_Multiplayer"); DontDestroyOnLoad(go); Instance = go.AddComponent(); } void Awake() { Application.runInBackground = true; playerName = PlayerPrefs.GetString(PrefName, System.Environment.UserName); objects.Send = SendGame; // Einkäufe / verbrauchte Items bei uns: allen melden objects.ItemAppeared = b => { Vector3 pos = b.Rb.position; Quaternion rot = b.Rb.rotation; string name = b.Go.name; int id = b.Id; SendGame(MsgType.ItemAppeared, NetSession.Build(w => { w.Write(id); w.Write(name); w.Write(pos.x); w.Write(pos.y); w.Write(pos.z); w.Write(rot.x); w.Write(rot.y); w.Write(rot.z); w.Write(rot.w); })); }; objects.ItemDestroyed = b => { int id = b.Id; SendGame(MsgType.ObjDestroy, NetSession.Build(w => w.Write(id))); }; FsmSync.Init(); SafetyPatches.Apply(); // verhindert den Absturz durch Kreis in der Objekt-Hierarchie RestorePendingState(); 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(); } // ---------------------------------------------------------------- Ö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) { Disconnect(); PartStart(); try { session = NetSession.StartHost(port); 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; } /// Konsole "vars ": synchronisierte Fahrzeugwerte (Diagnose). public List DescribeVehicleValues(string filter) { return vehicleState.Describe(filter, 60); } 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); lines.Add("synced body meshes (crash damage): " + damageSync.Count); lines.Add("synced car doors/hoods/trunks: " + hinges.Count); lines.Add("synced vehicle controls (ignition, engine, lights ...): " + controls.Count + " (" + controls.MutedCount + " muted)"); lines.Add("synced job/shop values: " + worldVars.Count); lines.Add("synced traffic cars: " + traffic.Count + (IsWorldAuthority ? " (simulated here)" : " (following)")); 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()); overlays.SetBanned(new List(bannedNames)); } 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()); overlays.SetBanned(new List(bannedNames)); } } 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; } // ---------------------------------------------------------------- Ping /// Laufzeit zum Server/Host in ms (-1 = unbekannt, z.B. alter Server oder man ist selbst Host). public float PingMs { get { return pingMs; } } /// Ping-Anzeige oben rechts (Konsole: "ping"), bleibt gespeichert. public bool ShowPing { get { if (showPing < 0) showPing = PlayerPrefs.GetInt(PrefShowPing, 0); // einmal lesen, nicht bei jedem Zeichnen return showPing == 1; } set { showPing = value ? 1 : 0; PlayerPrefs.SetInt(PrefShowPing, showPing); } } int showPing = -1, showFps = -1; // ---------------------------------------------------------------- FPS-Anzeige (Konsole: "fps") const string PrefShowFps = "MSCO_ShowFps"; float fpsTimer, fpsValue = -1f, frameMsWorst; int fpsFrames; float frameMsWorstWindow; /// FPS-Anzeige oben rechts, bleibt gespeichert. public bool ShowFps { get { if (showFps < 0) showFps = PlayerPrefs.GetInt(PrefShowFps, 0); return showFps == 1; } set { showFps = value ? 1 : 0; PlayerPrefs.SetInt(PrefShowFps, showFps); } } /// Bilder pro Sekunde (Mittel über eine halbe Sekunde) und längstes Bild darin (Ruckler). public string FpsText { get { if (fpsValue < 0f) return "measuring..."; return Mathf.RoundToInt(fpsValue) + " FPS (" + (1000f / Mathf.Max(1f, fpsValue)).ToString("0.0") + " ms, worst " + frameMsWorst.ToString("0") + " ms)"; } } void MeasureFps() { float dt = Time.unscaledDeltaTime; fpsFrames++; fpsTimer += dt; frameMsWorstWindow = Mathf.Max(frameMsWorstWindow, dt * 1000f); if (fpsTimer < 0.5f) return; fpsValue = fpsFrames / fpsTimer; frameMsWorst = frameMsWorstWindow; fpsFrames = 0; fpsTimer = 0f; frameMsWorstWindow = 0f; } GUIStyle fpsStyle; /// Oben rechts, links neben dem Ping. void DrawFps(float rightEdge) { if (!ShowFps) return; if (fpsStyle == null) { fpsStyle = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.85f), 6); fpsStyle.fontSize = 12; fpsStyle.fontStyle = FontStyle.Bold; fpsStyle.alignment = TextAnchor.MiddleCenter; fpsStyle.padding = new RectOffset(10, 10, 0, 0); } // grün ab 50, gelb ab 30, sonst rot fpsStyle.normal.textColor = fpsValue < 0f ? UiTheme.Muted : fpsValue >= 50f ? UiTheme.Online : fpsValue >= 30f ? UiTheme.Pending : UiTheme.Danger; var content = new GUIContent(fpsValue < 0f ? "FPS ..." : "FPS " + Mathf.RoundToInt(fpsValue)); float width = fpsStyle.CalcSize(content).x; GUI.Box(new Rect(rightEdge - width, 8, width, 24), content, fpsStyle); } /// Text für Anzeige und Konsole. public string PingText { get { if (session == null) return "not connected"; if (session.IsHost) return "you are the host"; return pingMs < 0f ? "measuring..." : Mathf.RoundToInt(pingMs) + " ms"; } } void DrawPing() { if (!ShowPing || session == null) return; if (pingStyle == null) { pingStyle = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.85f), 6); pingStyle.fontSize = 12; pingStyle.fontStyle = FontStyle.Bold; pingStyle.alignment = TextAnchor.MiddleCenter; pingStyle.padding = new RectOffset(10, 10, 0, 0); } // Farbe nach Qualität: grün < 80 ms, gelb < 150 ms, sonst rot Color color = pingMs < 0f ? UiTheme.Muted : pingMs < 80f ? UiTheme.Online : pingMs < 150f ? UiTheme.Pending : UiTheme.Danger; pingStyle.normal.textColor = color; var content = new GUIContent("PING " + PingText); float width = pingStyle.CalcSize(content).x; GUI.Box(new Rect(Screen.width - width - 12, 8, width, 24), content, pingStyle); pingWidth = width + 8; } float pingWidth; // Platz, den die Ping-Anzeige belegt (FPS steht links daneben) // ---------------------------------------------------------------- Cheats (Admin-Menü) /// Cheats nur im Spiel und nur für Server-Admins bzw. den P2P-Host. public bool CanCheat { get { return session != null && MenuHelper.InGame && (dedicated ? isAdmin : session.IsHost); } } public void CheatMoney(float amount) { if (!CanCheat) return; overlays.Log(Cheats.AddMoney(amount)); AnnounceCheat("gave themselves " + amount.ToString("0") + " MK"); } public void CheatNeeds() { if (!CanCheat) return; overlays.Log(Cheats.FillNeeds()); AnnounceCheat("refilled their needs"); } /// Zu einem Mitspieler teleportieren. public void GotoPlayer(int playerId) { PlayerInfo p; if (!CanCheat || !players.TryGetValue(playerId, out p) || p.Avatar == null) { overlays.Log("Player position unknown"); return; } passenger.Exit(localPlayer, playerCamera); PlayerState s = p.Avatar.LastState; Vector3 side = Quaternion.Euler(0f, s.RotationY, 0f) * Vector3.right; if (Cheats.Teleport(localPlayer, s.Position + side * TeleportSideOffset)) overlays.Log("Teleported to " + p.Name); } /// Mitspieler zu sich holen (Server prüft, ob man Admin ist). public void BringPlayer(int playerId) { if (!CanCheat || localPlayer == null || playerId == localId) return; Vector3 pos = localPlayer.position + localPlayer.forward * TeleportSideOffset; if (dedicated) { session.SendTo(NetSession.HostId, MsgType.AdminAction, NetSession.Build(w => { w.Write((byte)3); w.Write(playerId); w.Write(""); w.Write(pos.x); w.Write(pos.y); w.Write(pos.z); })); } else if (players.ContainsKey(playerId)) { session.SendTo(playerId, MsgType.Teleport, NetSession.Build(w => { w.Write(pos.x); w.Write(pos.y); w.Write(pos.z); })); overlays.Log("Brought " + players[playerId].Name + " to you"); } } /// Admin: Satsuma neben sich holen (z.B. wenn er verschwunden oder im Boden ist). public void BringCar() { if (!CanCheat || localPlayer == null) return; Vector3 right = localPlayer.right; Vector3 pos = localPlayer.position + right * 4f; Quaternion rot = Quaternion.LookRotation(localPlayer.forward, Vector3.up); string result = objects.BringVehicle("SATSUMA", pos, rot); overlays.Log(result); overlays.AddChat("", result, true); if (result.EndsWith("next to you")) AnnounceCheat("brought the Satsuma to themselves"); } /// Admin: Item vor sich spawnen - bei allen, und auf dem Server gespeichert. public void SpawnItem(string item) { if (!CanCheat || localPlayer == null) return; if (item == ItemSpawner.RatchetSet) { // Werkzeug, kein Gegenstand: nur für einen selbst freischalten overlays.Log(ItemSpawner.GiveRatchetSet() ? "You now have the ratchet set" : "Ratchet set not found (only works in the game)"); AnnounceCheat("gave themselves the ratchet set"); return; } Transform head = playerCamera != null ? playerCamera.transform : localPlayer; Vector3 forward = new Vector3(head.forward.x, 0f, head.forward.z).normalized; if (forward.sqrMagnitude < 0.01f) forward = localPlayer.forward; Vector3 pos = head.position + forward * 1.2f; Quaternion rot = Quaternion.LookRotation(forward, Vector3.up); if (!ItemSpawner.Spawn(item, pos, rot)) { overlays.Log("No '" + item + "' in this game"); return; } objects.MarkNetworkCreated(ItemSpawner.LastSpawned); // wird per SpawnItem gemeldet, nicht als "gekauft" rescanSoon = true; SendGame(MsgType.SpawnItem, NetSession.Build(w => { w.Write(item); w.Write(pos.x); w.Write(pos.y); w.Write(pos.z); w.Write(rot.x); w.Write(rot.y); w.Write(rot.z); w.Write(rot.w); })); AnnounceCheat("spawned " + item); } /// Gespawntes Item eines Admins (live oder aus dem Weltzustand) nachbauen. static bool ApplySpawn(BinaryReader r) { string item = r.ReadString(); var pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); var rot = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); if (!ItemSpawner.IsKnown(item) || item == ItemSpawner.RatchetSet) return false; // nur bekannte Gegenstände bool ok = ItemSpawner.Spawn(item, pos, rot); if (ok && Instance != null) Instance.objects.MarkNetworkCreated(ItemSpawner.LastSpawned); return ok; } /// Bei einem anderen Spieler ist ein Item aufgetaucht (Einkauf ...): hier auch erzeugen. bool ApplyAppeared(BinaryReader r) { int id = r.ReadInt32(); string name = r.ReadString(); var pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); var rot = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); if (objects.HasNear(id, pos, 5f)) return false; // haben wir schon (z.B. war nur kurz ausgeblendet) if (!ItemSpawner.SpawnObjectNamed(name, pos, rot)) return false; objects.MarkNetworkCreated(ItemSpawner.LastSpawned); return true; } /// Admin (nur dedizierter Server): Welt zurücksetzen (5), Backup anlegen (6), Backup wiederherstellen (7). public void WorldAction(byte action, string backup) { if (session == null || !dedicated) return; session.SendTo(NetSession.HostId, MsgType.AdminAction, NetSession.Build(w => { w.Write(action); w.Write(-1); w.Write(backup ?? ""); })); } /// Admin: Bann aufheben (Server: SteamID oder name:..., P2P: Name). public void Unban(string id) { if (session == null) return; if (dedicated) { session.SendTo(NetSession.HostId, MsgType.AdminAction, NetSession.Build(w => { w.Write((byte)4); w.Write(-1); w.Write(id); })); return; } if (!session.IsHost) return; bannedNames.Remove(id); overlays.SetBanned(new List(bannedNames)); overlays.AddChat("", id + " is no longer banned", true); } /// Cheats für alle sichtbar im Chat melden. void AnnounceCheat(string what) { string text = OwnName() + " " + what + " (admin)"; overlays.AddChat("", text, true); SendGame(MsgType.Chat, NetSession.Build(w => w.Write("* " + text))); } 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(""); } // Letzter Server (für das automatische Neu-Verbinden nach reset-world) string lastServerHost, lastServerName; int lastServerPort, lastServerMax; string reconnectMessage; // gesetzt, wenn der Server uns neu verbinden lässt float reconnectHoldUntil; // so lange die Meldung noch im Spiel zeigen string centerMessage; // große Meldung in der Bildmitte float centerMessageUntil; float reconnectAt; int reconnectAttempts; const int MaxReconnectAttempts = 10; // ~1 Minute: reicht für einen Server-Neustart const float ReconnectRetrySeconds = 6f; void ConnectToServer(string host, int port, string displayName, int maxPlayers) { lastServerHost = host; lastServerPort = port; lastServerName = displayName; lastServerMax = 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; /// Wie viele Spieler in diese Sitzung passen: auf einem Server dessen Limit, bei P2P 3. public int SessionMaxPlayers { get { return (dedicated || connectingToServer) && serverMaxPlayers > 0 ? serverMaxPlayers : MaxPlayers; } } public bool IsConnectingToServer { get { return connectingToServer; } } 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) MscoLog.Info("[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) { SetStatus("Join from the main menu!"); return; } Disconnect(); 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() { steam.Leave(); if (session == null) return; session.Close(); OnSessionEnded("Disconnected"); } // ---------------------------------------------------------------- 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() { 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) { pendingLobby = lobby; // wird im Hauptmenü angenommen SetStatus("Go to the main menu to join your friend"); return; } 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; newGamePending = false; SendSaveToWaitingPlayers(); SetStatus("Starting game..."); MenuHelper.StartGame(); } // Host hat ein neues Spiel gestartet: es gibt noch keinen Spielstand zum Verschicken - erst im Spiel speichern bool newGamePending; /// /// Host: neues Spiel statt des eigenen Spielstands. Der bisherige Spielstand wird gesichert (MSCO_Saves), die /// Welt frisch angelegt; die Mitspieler bekommen sie, sobald sie einmal gespeichert ist. /// public void StartHostNewGame() { if (!IsHost || !MenuHelper.InMenu) return; string archived; try { archived = SaveSync.ArchiveForNewGame(); } catch (System.Exception e) { // Lieber gar nicht starten, als den Spielstand ungesichert zu löschen MscoLog.Error("[MSCO] Could not back up your save game - new game cancelled: " + e.Message); SetStatus("Could not back up your save game - new game cancelled"); return; } newGamePending = true; if (archived != null) overlays.Log("Your previous save game was backed up to " + archived); SetStatus("Starting a new game..."); MenuHelper.StartNewGame(); } /// Neues Spiel geladen: warten, bis die Welt steht, speichern und an die wartenden Mitspieler schicken. System.Collections.IEnumerator NewGameSaveAndSend() { float give = Time.realtimeSinceStartup + 60f; while (localPlayer == null && Time.realtimeSinceStartup < give) yield return new WaitForSeconds(0.5f); yield return new WaitForSeconds(3f); // Spielwelt erst fertig aufbauen lassen if (!newGamePending || session == null || !session.IsHost || !MenuHelper.InGame) yield break; SaveGame(); yield return new WaitForSeconds(LateJoinSaveDelay); newGamePending = false; var targets = new List(); foreach (PlayerInfo p in players.Values) if (!p.IsLocal && !p.SaveSent) targets.Add(p); if (targets.Count > 0) { SendSave(targets, true); SetStatus("New game sent to " + targets.Count + " player(s)"); } } /// /// 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) { MscoLog.Info("[MSCO] You can only save while in the game."); return; } if (session != null && dedicated) { MscoLog.Info("[MSCO] The server saves the world automatically."); return; } if (session != null && !session.IsHost) { MscoLog.Info("[MSCO] Only the host can save the game."); return; } savingMessageUntil = Time.realtimeSinceStartup + 2.5f; // "SAVING GAME..." in der Bildschirmmitte lastSaveTriggered = Time.realtimeSinceStartup; PlayMakerFSM.BroadcastEvent("SAVEGAME"); MscoLog.Info("[MSCO] Game saved."); } // ---------------------------------------------------------------- Update void Update() { CheckHitch(); MeasureFps(); // 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)); // Eigenes Hauptmenü: Singleplayer-Knöpfe des Spiels ausblenden (einmal pro Menü-Besuch, sobald sie da sind) if (MenuHelper.InMenu && !menuButtonsHidden && Time.realtimeSinceStartup >= nextMenuButtonCheck) { nextMenuButtonCheck = Time.realtimeSinceStartup + 0.5f; menuButtonsHidden = MenuHelper.HideGameMenuButtons() > 0; MenuHelper.MuteMenuAudio(); // Launcher im Vollbild: Menü-Musik/-Geräusche aus } UpdateOverlayKeys(); PersistPendingState(); // Kick-Grund / Neu-Verbinden über das Neuladen der Mods hinweg merken MscoLog.Drain(overlays.Log); // alle Mod-Meldungen in die MSCO-Konsole (F7) UpdatePresence(); steam.Tick(); // Nach reset-world o.ä.: im Hauptmenü automatisch wieder mit demselben Server verbinden. Mehrere Versuche - // der Server kann nach einem Neustart noch ein paar Sekunden brauchen. if (reconnectMessage != null && session == null && MenuHelper.InMenu && !checkingServer && Time.realtimeSinceStartup >= reconnectAt) { if (lastServerHost == null || reconnectAttempts >= MaxReconnectAttempts) { DisconnectNotice = "Could not reconnect to the server after " + reconnectAttempts + " attempts. " + "Try again from the server browser."; reconnectMessage = null; reconnectAttempts = 0; } else { reconnectAttempts++; reconnectAt = Time.realtimeSinceStartup + ReconnectRetrySeconds; MscoLog.Info("[MSCO] Reconnecting to " + lastServerHost + ":" + lastServerPort + " (attempt " + reconnectAttempts + "/" + MaxReconnectAttempts + ")"); JoinServer(lastServerHost, lastServerPort, lastServerName, lastServerMax); } } 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; // Lebenszeichen, damit Steam-Verbindungen nicht in den Timeout laufen if (Time.realtimeSinceStartup >= nextPing) { nextPing = Time.realtimeSinceStartup + PingInterval; float sentAt = Time.realtimeSinceStartup; session.SendToAll(MsgType.Ping, NetSession.Build(w => w.Write(sentAt))); // Zeitstempel für die Ping-Messung } 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) { if (Time.realtimeSinceStartup < reconnectHoldUntil) return; // Meldung erst lesen lassen OnSessionEnded(session.CloseReason); return; } if (session.IsHost && MenuHelper.InMenu && HasWaitingPlayers()) WatchHostContinue(); if (MenuHelper.InGame) UpdateInGame(); else FsmSync.Enabled = false; } void OnConnected(int peerId) { if (session.IsHost) { SetStatus("Player connecting..."); } else { 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 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 + ")"); ToastLeft(p.Name); } string serverReason; // Grund, den der Server/Host beim Trennen mitgeschickt hat /// Warum man zuletzt getrennt wurde - wird im Hauptmenü angezeigt, bis man OK drückt. public string DisconnectNotice { get; private set; } // ---------------------------------------------------------------- Über Neuladen hinweg merken // Beim Wechsel ins Hauptmenü lädt MSCLoader alle Mods neu - dabei ginge der Kick-Grund bzw. "bitte neu // verbinden" verloren, bevor man das Menü überhaupt sieht. Deshalb in den PlayerPrefs sichern. const string PrefNotice = "MSCO_PendingNotice"; const string PrefReconnect = "MSCO_PendingReconnect"; const double NoticeMaxAgeMinutes = 10; const double ReconnectMaxAgeMinutes = 3; string savedNotice, savedReconnect; int savedAttempts = -1; void PersistPendingState() { if (ReferenceEquals(savedNotice, DisconnectNotice) && ReferenceEquals(savedReconnect, reconnectMessage) && savedAttempts == reconnectAttempts) return; savedNotice = DisconnectNotice; savedReconnect = reconnectMessage; savedAttempts = reconnectAttempts; string now = System.DateTime.UtcNow.Ticks.ToString(); try { if (DisconnectNotice != null) PlayerPrefs.SetString(PrefNotice, now + "\n" + DisconnectNotice); else PlayerPrefs.DeleteKey(PrefNotice); if (reconnectMessage != null && lastServerHost != null) PlayerPrefs.SetString(PrefReconnect, string.Join("\n", new[] { now, lastServerHost, lastServerPort.ToString(), lastServerName ?? "", lastServerMax.ToString(), reconnectAttempts.ToString(), reconnectMessage })); else PlayerPrefs.DeleteKey(PrefReconnect); PlayerPrefs.Save(); } catch (System.Exception e) { MscoLog.Warning("Could not remember the disconnect state: " + e.Message); } } static bool Fresh(string ticks, double maxMinutes) { long t; return long.TryParse(ticks, out t) && (System.DateTime.UtcNow - new System.DateTime(t)).TotalMinutes < maxMinutes; } void RestorePendingState() { try { string[] notice = PlayerPrefs.GetString(PrefNotice, "").Split(new[] { '\n' }, 2); if (notice.Length == 2 && Fresh(notice[0], NoticeMaxAgeMinutes)) DisconnectNotice = notice[1]; string[] rc = PlayerPrefs.GetString(PrefReconnect, "").Split(new[] { '\n' }, 7); int port, max, attempts; if (rc.Length == 7 && Fresh(rc[0], ReconnectMaxAgeMinutes) && int.TryParse(rc[2], out port) && int.TryParse(rc[4], out max) && int.TryParse(rc[5], out attempts)) { lastServerHost = rc[1]; lastServerPort = port; lastServerName = rc[3].Length > 0 ? rc[3] : null; lastServerMax = max; reconnectAttempts = attempts; reconnectMessage = rc[6]; reconnectAt = Time.realtimeSinceStartup + 2f; // erst das Menü fertig laden lassen DisconnectNotice = null; MscoLog.Info("[MSCO] Continuing reconnect to " + lastServerHost + ":" + lastServerPort); } } catch (System.Exception e) { MscoLog.Warning("Could not restore the disconnect state: " + e.Message); } savedNotice = DisconnectNotice; savedReconnect = reconnectMessage; savedAttempts = reconnectAttempts; // Einmal gelesen = erledigt; neu gespeichert wird bei der nächsten Änderung PlayerPrefs.DeleteKey(PrefNotice); PlayerPrefs.DeleteKey(PrefReconnect); if (DisconnectNotice != null || reconnectMessage != null) { savedNotice = null; savedReconnect = null; savedAttempts = -1; } } public void ClearDisconnectNotice() { DisconnectNotice = null; } public bool IsReconnecting { get { return reconnectMessage != null && session == null; } } public int ReconnectAttempt { get { return reconnectAttempts; } } public int ReconnectMaxAttempts { get { return MaxReconnectAttempts; } } /// Server, mit dem man gerade verbunden ist (dedizierter Server), sonst null. public string CurrentServerHost { get { return session != null && dedicated ? lastServerHost : null; } } public int CurrentServerPort { get { return lastServerPort; } } public string CurrentServerName { get { return lastServerName; } } /// /// Schnell den Server wechseln: Favorit per Kurzname (oder host:port). Ist man gerade verbunden, wird wie beim /// Neu-Verbinden nach einem Welt-Reset getrennt und danach automatisch mit dem neuen Server verbunden. /// public string SwitchServer(string target) { target = (target ?? "").Trim(); if (target.Length == 0) return "switch (see 'fav')"; Favorites.Favorite fav = Favorites.Find(target); string host, name; int port; if (fav != null) { host = fav.Host; port = fav.Port; name = string.IsNullOrEmpty(fav.ServerName) ? fav.Nickname : fav.ServerName; } else if (ServerBrowser.TryParseAddress(target, out host, out port)) name = null; else return "No favorite called '" + target + "' - type 'fav' to see yours"; if (session != null && dedicated && string.Equals(lastServerHost, host, System.StringComparison.OrdinalIgnoreCase) && lastServerPort == port) return "You are already on " + (fav != null ? fav.Nickname : host + ":" + port); string label = fav != null ? fav.Nickname : host + ":" + port; if (session == null) { if (!MenuHelper.InMenu) return "Go to the main menu first (or join a server) to switch"; JoinServer(host, port, name); return "Connecting to " + label + " ..."; } // Verbunden: trennen und danach automatisch mit dem neuen Server verbinden (übersteht das Neuladen der Mods) lastServerHost = host; lastServerPort = port; lastServerName = name; lastServerMax = 0; serverReason = null; reconnectAttempts = 0; reconnectMessage = "Switching to " + label + " ..."; centerMessage = reconnectMessage; centerMessageUntil = Time.realtimeSinceStartup + 2f; session.Close("Switching server"); if (MenuHelper.InGame) reconnectHoldUntil = Time.realtimeSinceStartup + 0.8f; // kurz die Meldung zeigen else OnSessionEnded("Switching server"); return reconnectMessage; } /// Automatisches Neu-Verbinden abbrechen (Knopf im Reconnect-Fenster). public void CancelReconnect() { reconnectMessage = null; reconnectAttempts = 0; SetStatus("Reconnect cancelled"); } /// Überschrift für das Trennungs-Fenster. public string DisconnectTitle { get { string r = (DisconnectNotice ?? "").ToLower(); if (r.Contains("banned")) return "BANNED"; if (r.Contains("kick")) return "KICKED"; if (r.Contains("shutting down") || r.Contains("server stopped") || r.Contains("shut down")) return "SERVER CLOSED"; if (r.Contains("could not reconnect")) return "RECONNECT FAILED"; return "DISCONNECTED"; } } bool worldSynced; // Server: Weltzustand angewendet /// /// Wirklich verbunden (grüner Punkt): Host sobald die Sitzung läuft; auf einem Server erst, wenn man im Spiel ist /// und der Weltzustand angewendet wurde; bei P2P als Client, wenn man im Spiel ist. Vorher gelb. /// public bool IsFullyConnected { get { if (session == null) return false; if (session.IsHost) return true; if (!handshakeDone || !MenuHelper.InGame) return false; return !dedicated || worldSynced; } } public string ReconnectMessage { get { return reconnectMessage; } } /// Technische Gründe in verständliche Sätze übersetzen. static string FriendlyReason(string reason) { if (string.IsNullOrEmpty(reason)) return "The connection was closed."; string r = reason.ToLower(); if (r.Contains("timed out") || r.Contains("timeout")) return "Connection lost - the server did not respond anymore (timeout)."; if (r.Contains("network error") || r.Contains("forcibly closed") || r.Contains("connection reset")) return "Connection lost (" + reason + ")."; if (r == "connection closed" || r.Contains("closed")) return "The server closed the connection."; if (r.Contains("host left")) return "The host left the game."; return reason; } void OnSessionEnded(string reason) { // Verbindung zum Server unerwartet weg (Absturz, Timeout, Netz) - nicht gekickt/gebannt, nicht selbst // getrennt: automatisch neu verbinden, als hätte der Server einen Neustart angekündigt if (reconnectMessage == null && serverReason == null && reason != "Disconnected" && dedicated && handshakeDone && lastServerHost != null) { reconnectMessage = "Lost the connection to the server - reconnecting..."; reconnectAttempts = 0; MscoLog.Warning("[MSCO] Connection lost (" + reason + ") - will reconnect"); } // Hinweis fürs Hauptmenü - außer man hat selbst auf DISCONNECT gedrückt oder verbindet gleich neu if (reconnectMessage != null) { DisconnectNotice = null; reconnectAt = Time.realtimeSinceStartup + 3f; // erst ins Menü, dann neu verbinden } else if (reason != "Disconnected") DisconnectNotice = serverReason ?? FriendlyReason(reason); serverReason = null; 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; worldSynced = false; pingMs = -1f; 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(); wheelSpin.Reset(); worldSync.Reset(); moneySync.Reset(); doorSync.Reset(); vehicleState.Reset(); damageSync.Reset(); worldVars.Reset(); traffic.Reset(); hinges.Reset(); controls.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); PersistPendingState(); // MSCLoader lädt die Mods im Menü neu - vorher sichern 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 || t == MsgType.SpawnItem || t == MsgType.ItemAppeared || t == MsgType.ObjDestroy || t == MsgType.VehicleDamage || t == MsgType.TrafficStates || t == MsgType.WorldVars || t == MsgType.HingeStates || t == MsgType.VehicleControls; } void OnMessage(int peerId, Message m) { BinaryReader r = m.Reader(); // Ping-Messung: der Host schickt den Zeitstempel sofort zurück, der Client rechnet die Laufzeit aus if (m.Type == MsgType.Ping) { if (session.IsHost && m.Payload.Length >= 4) session.SendTo(peerId, MsgType.Pong, m.Payload); return; } if (m.Type == MsgType.Pong) { if (!session.IsHost && m.Payload.Length >= 4) { float rtt = (Time.realtimeSinceStartup - r.ReadSingle()) * 1000f; if (rtt >= 0f && rtt < 60000f) pingMs = pingMs < 0f ? rtt : Mathf.Lerp(pingMs, rtt, 0.4f); } return; } 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); ToastJoined(name); 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: { 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; reconnectMessage = null; // (wieder) verbunden reconnectAttempts = 0; SetStatus(dedicated ? "Connected to server - loading..." : "Connected - waiting for host to start..."); break; } case MsgType.Reject: { string why = r.ReadString(); serverReason = why; // für das Fenster im Hauptmenü (Kick, Ban, Server voll, Shutdown ...) if (why.ToLower().Contains("banned")) reconnectMessage = null; // gebannt: nicht weiter versuchen session.Close((dedicated || connectingToServer ? "Rejected by server: " : "Rejected by host: ") + why); if (MenuHelper.InGame) { // Im Spiel: Grund erst groß anzeigen, dann ins Hauptmenü (dort kommt das Fenster mit OK) centerMessage = why; centerMessageUntil = Time.realtimeSinceStartup + 3f; reconnectHoldUntil = Time.realtimeSinceStartup + 2.5f; return; } 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"); ToastJoined(name); break; } case MsgType.PlayerLeft: { int id = r.ReadInt32(); PlayerInfo p; if (players.TryGetValue(id, out p)) { SetStatus(p.Name + " left"); ToastLeft(p.Name); } RemovePlayer(id); break; } case MsgType.SaveBegin: if (!MenuHelper.InMenu) { SetStatus("Host started - go back to the main menu to join"); return; } SaveSync.BeginReceive(); receivingSave = true; SetStatus(dedicated ? "Downloading server save..." : "Receiving host's save..."); break; case MsgType.SaveFile: if (!receivingSave) return; string file = r.ReadString(); byte[] data = r.ReadBytes(r.ReadInt32()); SaveSync.WriteFile(file, data); break; case MsgType.Teleport: { // Ein Admin holt uns zu sich var pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); if (!MenuHelper.InGame || localPlayer == null) break; passenger.Exit(localPlayer, playerCamera); Cheats.Teleport(localPlayer, pos); overlays.AddChat("", "An admin teleported you", true); break; } case MsgType.SaveRequest: // Server möchte den aktuellen Spielstand (für neue Spieler / zum Sichern) StartCoroutine(UploadSave(r.ReadInt32())); break; case MsgType.Reconnect: { // Server hat die Welt zurückgesetzt o.ä.: Meldung zeigen, ins Menü, dann automatisch neu verbinden reconnectMessage = r.ReadString(); overlays.AddChat("", reconnectMessage, true); // Groß in der Bildmitte zeigen und erst nach kurzer Zeit ins Menü wechseln - sonst sieht man nichts centerMessage = reconnectMessage; centerMessageUntil = Time.realtimeSinceStartup + 3f; reconnectHoldUntil = Time.realtimeSinceStartup + 2.5f; session.Close("Reconnecting"); return; } 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); var banned = new List(); if (r.BaseStream.Length - r.BaseStream.Position >= 4) { int bans = r.ReadInt32(); for (int i = 0; i < bans; i++) banned.Add(r.ReadString()); } overlays.SetBanned(banned); var backups = new List(); if (r.BaseStream.Length - r.BaseStream.Position >= 4) { int n = r.ReadInt32(); for (int i = 0; i < n; i++) backups.Add(r.ReadString()); } overlays.SetBackups(backups); 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; // 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; } } 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: { FsmEventMsg msg = FsmEventMsg.Deserialize(r); FsmSync.Apply(msg); // Ein Teil wurde abgebaut: gleich als loses Objekt erfassen, damit es der Hand des Mitspielers // folgt, statt Sekunden lang im Auto zu stecken (die Physik würde es samt Auto wegschleudern) if (msg.EventName == "DISASSEMBLE" || msg.EventName == "REMOVE" || msg.EventName == "UNINSTALL") rescanSoon = true; 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; case MsgType.VehicleDamage: damageSync.Handle(r); break; case MsgType.WorldVars: worldVars.Handle(r); break; case MsgType.HingeStates: hinges.Handle(r); break; case MsgType.VehicleControls: controls.Handle(r, objects, localId); break; case MsgType.TrafficStates: traffic.Handle(r, IsWorldAuthority); break; case MsgType.SpawnItem: if (ApplySpawn(r)) rescanSoon = true; // neues Item gleich in den Objekt-Sync aufnehmen break; case MsgType.ItemAppeared: if (ApplyAppeared(r)) rescanSoon = true; break; case MsgType.ObjDestroy: objects.DestroyRemote(r.ReadInt32()); 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) { if (Time.realtimeSinceStartup < nextContinueSearch) return; nextContinueSearch = Time.realtimeSinceStartup + 1f; if (!MenuHelper.FindContinueLoadState(out continueFsm, out continueState)) return; } // Host hat CONTINUE gedrückt und die FSM ist im Lade-Zustand if (continueFsm.ActiveStateName == continueState.Name) SendSaveToWaitingPlayers(); } void SendSaveToWaitingPlayers() { if (newGamePending) return; // neues Spiel: es gibt noch keinen Spielstand - kommt nach dem ersten Speichern 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) { List> files = SaveSync.ReadLocalSave(); var fileMessages = new List(); foreach (KeyValuePair file in files) { fileMessages.Add(NetSession.Build(w => { w.Write(file.Key); w.Write(file.Value.Length); w.Write(file.Value); })); } 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))); } MscoLog.Info("[MSCO] Sent save (" + files.Count + " files) to " + targets.Count + " player(s)"); } bool menuButtonsHidden; float nextMenuButtonCheck; void OnLevelWasLoaded(int level) { menuButtonsHidden = false; // neues Hauptmenü: Knöpfe wieder ausblenden nextPresenceUpdate = 0f; // Discord gleich nach dem Szenenwechsel aktualisieren if (MenuHelper.InGame) { // Falls der CONTINUE-Klick nicht erkannt wurde: jetzt nachholen. Neues Spiel: erst speichern, dann senden if (session != null && session.IsHost) { if (newGamePending) StartCoroutine(NewGameSaveAndSend()); else SendSaveToWaitingPlayers(); } passenger.Reset(); localPlayer = null; objects.OnGameLoaded(); vehicleState.Reset(); damageSync.Reset(); worldVars.Reset(); traffic.Reset(); hinges.Reset(); controls.Reset(); vehicleFx.Reset(); wheelSpin.Reset(); worldSync.Reset(); moneySync.Reset(); doorSync.Reset(); FsmSync.Reset(); readySent = false; worldSynced = false; if (session != null) SetStatus(session.IsHost ? "Hosting" : "Connected"); } else if (MenuHelper.InMenu) { foreach (PlayerInfo p in players.Values) { p.SaveSent = false; DestroyAvatar(p); } continueFsm = null; newGamePending = false; passenger.Reset(); localPlayer = null; objects.Reset(); vehicleFx.Reset(); wheelSpin.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 && handshakeDone && !session.IsHost) SetStatus("Connected - waiting for host to start..."); } } void TryRestoreBackup() { try { SaveSync.RestoreBackup(); } catch (System.Exception e) { MscoLog.Error("[MSCO] Could not restore own save: " + e.Message); } } // ---------------------------------------------------------------- Im Spiel void UpdateInGame() { if (localPlayer == null) { if (Time.realtimeSinceStartup < nextPlayerSearch) return; nextPlayerSearch = Time.realtimeSinceStartup + 1f; GameObject player = GameObject.Find("PLAYER"); if (player == null) return; localPlayer = player.transform; playerCamera = FindPlayerCamera(player); objects.Head = playerCamera != null ? playerCamera.transform : null; MscoLog.Info("[MSCO] Player camera: " + (playerCamera != null ? playerCamera.name : "none")); } if (teleportToHost) TryTeleportToHost(); if (rescanSoon && objects.ScanComplete) { rescanSoon = false; objects.ScanNow(); } // Dedizierter Server: nach dem ersten Objekt-Suchlauf den Weltzustand anfordern und anwenden if (dedicated && handshakeDone && !readySent && objects.ScanComplete) { 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 && !overlays.AnyOpen && MSCO.PassengerKey.GetKeybindDown()) { string result = passenger.Toggle(localPlayer, playerCamera, objects, players); if (result != null) { SetStatus(result); overlays.AddChat("", result, true); // im Spiel sichtbar, nicht nur in der Konsole } } bool ready = IsPeerReady; if (ready) { // Jedes Teilsystem einzeln abgesichert: ein Fehler in einem (z.B. an einem gerade zerstörten Objekt) // soll nicht den ganzen Sync stoppen sendTimer += Time.unscaledDeltaTime; // läuft auch im Pausenmenü (timeScale = 0) weiter if (sendTimer >= SendInterval) { sendTimer = 0f; PartStart(); try { SendGame(MsgType.Player, BuildLocalState().Serialize()); } catch (System.Exception e) { ReportError("player state", e); } PartEnd("player state"); } // Eigene Bedürfnisse (Hunger, Durst ...) pro Spieler auf dem Server sichern if (dedicated && worldSynced && Time.realtimeSinceStartup >= nextNeedsSend) { nextNeedsSend = Time.realtimeSinceStartup + NeedsInterval; PartStart(); try { byte[] needs = PlayerNeeds.Build(); if (needs != null) session.SendTo(NetSession.HostId, MsgType.PlayerNeeds, needs); } catch (System.Exception e) { ReportError("player needs", e); } PartEnd("player needs"); } PartStart(); try { objects.Update(localPlayer, Time.deltaTime); } catch (System.Exception e) { ReportError("object sync", e); } PartStart(); PartEnd("object sync"); try { vehicleFx.Update(objects, SendGame, Time.deltaTime, IsWorldAuthority); } catch (System.Exception e) { ReportError("vehicle lights/sound", e); } PartStart(); PartEnd("vehicle lights/sound"); try { worldSync.Update(IsWorldAuthority, SendGame, Time.unscaledDeltaTime); } catch (System.Exception e) { ReportError("time/weather sync", e); } PartEnd("time/weather sync"); // Geld erst beobachten, wenn das Spiel fertig geladen ist PartStart(); try { if (objects.ScanComplete) moneySync.Begin(); moneySync.Update(SendGame); } catch (System.Exception e) { ReportError("money sync", e); } PartEnd("money sync"); // Türen, Tore, Hauben (z.B. Garagentor) PartStart(); try { if (objects.ScanComplete) doorSync.Update(SendGame); } catch (System.Exception e) { ReportError("door sync", e); } PartEnd("door sync"); // Gesamtzustand der Fahrzeuge (Tank, Öl, Verschleiß ...) PartStart(); try { if (objects.ScanComplete) vehicleState.Update(objects, localId, SendGame); } catch (System.Exception e) { ReportError("vehicle state sync", e); } PartEnd("vehicle state sync"); // Unfallschäden (verbeulte Karosserie) PartStart(); try { if (objects.ScanComplete) damageSync.Update(objects, localId, SendGame); } catch (System.Exception e) { ReportError("crash damage sync", e); } PartEnd("crash damage sync"); // Zündung, Motor, Licht, Choke ... der Fahrzeuge PartStart(); try { if (objects.ScanComplete && localPlayer != null) controls.Update(objects, localId, localPlayer.position, SendGame); } catch (System.Exception e) { ReportError("vehicle controls sync", e); } PartEnd("vehicle controls sync"); // Türen, Motorhaube, Kofferraum der Fahrzeuge PartStart(); try { if (objects.ScanComplete && localPlayer != null) hinges.Update(objects, localId, localPlayer.position, Time.deltaTime, SendGame); } catch (System.Exception e) { ReportError("car door sync", e); } PartEnd("car door sync"); // Jobs, Aufträge, Laden PartStart(); try { if (objects.ScanComplete) worldVars.Update(SendGame); } catch (System.Exception e) { ReportError("jobs/shop sync", e); } PartEnd("jobs/shop sync"); // Verkehr: die Welt-Autorität simuliert, alle anderen fahren nach PartStart(); try { if (objects.ScanComplete) traffic.Update(IsWorldAuthority, Time.deltaTime, SendGame); } catch (System.Exception e) { ReportError("traffic sync", e); } PartEnd("traffic sync"); } foreach (PlayerInfo p in players.Values) { if (p.Avatar == null) continue; PartStart(); try { // Sitzt der Mitspieler in einem Fahrzeug, die Figur an unsere Kopie dieses Fahrzeugs hängen int seatId = p.Avatar.LastState.SeatId; SyncedBody seat = seatId != 0 ? objects.Get(seatId) : null; p.Avatar.Seat = seat != null && seat.Rb != null && seat.IsVehicle ? seat.Rb.transform : null; 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); } catch (System.Exception e) { ReportError("player figure", e); } PartEnd("player figure"); } // Abgefangene Schraub-/Montage-Aktionen verschicken try { FsmSync.Enabled = ready; FsmSync.Tick(); foreach (FsmEventMsg msg in FsmSync.Outbox) SendGame(MsgType.FsmEvent, msg.Serialize()); } catch (System.Exception e) { ReportError("part/bolt sync", e); } PartEnd("part/bolt sync"); FsmSync.Outbox.Clear(); } readonly Dictionary lastErrorReport = new Dictionary(); /// Fehler eines Teilsystems melden (höchstens alle 30 s pro Teilsystem, damit das Log nicht vollläuft). // ---------------------------------------------------------------- Hänger erkennen // Jeder Sync-Teil wird gemessen. Dauert ein Bild sehr lange, steht in der F7-Konsole, ob MSCO schuld war // (und welcher Teil) oder das Spiel selbst (z.B. beim Speichern). readonly System.Diagnostics.Stopwatch partWatch = System.Diagnostics.Stopwatch.StartNew(); long partStart; double frameMsco, slowestMs; string slowestPart; float nextHitchReport, lastSaveTriggered = -100f; const float HitchSeconds = 0.2f; void PartStart() { partStart = partWatch.ElapsedTicks; } void PartEnd(string name) { double ms = (partWatch.ElapsedTicks - partStart) * 1000.0 / System.Diagnostics.Stopwatch.Frequency; frameMsco += ms; if (ms > slowestMs) { slowestMs = ms; slowestPart = name; } } /// Zu Beginn jedes Bilds: war das letzte Bild ein Hänger? Dann Ursache melden (höchstens alle 20 s). void CheckHitch() { float dt = Time.unscaledDeltaTime; if (dt > HitchSeconds && MenuHelper.InGame && Time.realtimeSinceStartup >= nextHitchReport) { nextHitchReport = Time.realtimeSinceStartup + 20f; int total = Mathf.RoundToInt(dt * 1000f); if (frameMsco > dt * 1000f * 0.4f) MscoLog.Warning("[MSCO] Hitch " + total + " ms - caused by MSCO: " + slowestPart + " " + slowestMs.ToString("0") + " ms (MSCO total " + frameMsco.ToString("0") + " ms)"); else MscoLog.Info("[MSCO] Hitch " + total + " ms - not MSCO (MSCO used " + frameMsco.ToString("0") + " ms)" + (Time.realtimeSinceStartup - lastSaveTriggered < 5f ? " - the game was saving" : " - the game itself (loading, saving, physics)")); } frameMsco = 0; slowestMs = 0; slowestPart = null; } void ReportError(string what, System.Exception e) { float now = Time.realtimeSinceStartup, last; if (lastErrorReport.TryGetValue(what, out last) && now - last < 30f) return; lastErrorReport[what] = now; MscoLog.Error(what + " failed: " + e.Message); Debug.Log("[MSCO] " + what + " failed: " + e); // voller Stacktrace nur in output_log.txt } void FixedUpdate() { if (!IsPeerReady || !MenuHelper.InGame) return; PartStart(); try { objects.FixedUpdate(); } catch (System.Exception e) { ReportError("object movement", e); } PartEnd("object movement"); PartStart(); try { traffic.FixedUpdate(); } catch (System.Exception e) { ReportError("traffic movement", e); } PartEnd("traffic movement"); PartStart(); try { hinges.FixedUpdate(); } catch (System.Exception e) { ReportError("car door movement", e); } PartEnd("car door movement"); } 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 PartStart(); try { if (IsPeerReady && MenuHelper.InGame) vehicleFx.Apply(objects); } catch (System.Exception e) { ReportError("vehicle lights/sound", e); } PartEnd("vehicle lights/sound"); PartStart(); try { if (IsPeerReady && MenuHelper.InGame) wheelSpin.Apply(objects, Time.deltaTime); } catch (System.Exception e) { ReportError("wheels", e); } PartEnd("wheels"); // Beifahrer nach der Fahrzeugbewegung auf den Sitz setzen PartStart(); try { if (localPlayer != null && MenuHelper.InGame) passenger.LateUpdate(localPlayer, playerCamera, objects); } catch (System.Exception e) { ReportError("passenger", e); } PartEnd("passenger"); } /// /// 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()) { // Letzte eigene Position auf dem Server - nur wenn sie plausibel ist, und über den Boden gehoben var spawn = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); bool valid = !float.IsNaN(spawn.x) && !float.IsInfinity(spawn.x) && !float.IsNaN(spawn.y) && !float.IsNaN(spawn.z) && Mathf.Abs(spawn.x) < 6000f && Mathf.Abs(spawn.z) < 6000f && spawn.y > -200f && spawn.y < 3000f; if (localPlayer != null && valid) { RaycastHit hit; if (Physics.Raycast(spawn + Vector3.up * 2f, Vector3.down, out hit, 6f)) spawn.y = Mathf.Max(spawn.y, hit.point.y); localPlayer.position = spawn + Vector3.up * 0.2f; MscoLog.Info("[MSCO] Back at your last position " + spawn.ToString("F0")); } } // Positionen erst merken: sie werden NACH den Montage-Aktionen gesetzt. Sonst steckt ein Teil, das eine // nachgespielte Aktion vom Auto löst, noch im Auto - die Physik schleudert es dann samt Auto weg. int objectCount = r.ReadInt32(); var poses = new List>(objectCount); 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()); poses.Add(new KeyValuePair(id, new Pose { Position = pos, Rotation = rot })); } 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); // Von Admins gespawnte Items (in der Reihenfolge des Servers, damit alle sie gleich benennen) int spawned = 0; if (r.BaseStream.Length - r.BaseStream.Position >= 4) { int spawns = r.ReadInt32(); for (int i = 0; i < spawns; i++) { byte[] payload = r.ReadBytes(r.ReadInt32()); try { if (ApplySpawn(new BinaryReader(new MemoryStream(payload)))) spawned++; } catch (System.Exception e) { MscoLog.Warning("Could not spawn a saved item: " + e.Message); } } } // Seit dem letzten Spielstand gekaufte bzw. verbrauchte Items var destroyedIds = new List(); if (r.BaseStream.Length - r.BaseStream.Position >= 4) { int appeared = r.ReadInt32(); for (int i = 0; i < appeared; i++) { byte[] payload = r.ReadBytes(r.ReadInt32()); try { ApplyAppeared(new BinaryReader(new MemoryStream(payload))); } catch (System.Exception e) { MscoLog.Warning("Could not restore a bought item: " + e.Message); } } } if (r.BaseStream.Length - r.BaseStream.Position >= 4) { int destroyed = r.ReadInt32(); for (int i = 0; i < destroyed; i++) destroyedIds.Add(r.ReadInt32()); } // Eigene Bedürfnisse (Hunger, Durst ...) - statt der Werte aus dem gemeinsamen Spielstand if (r.BaseStream.Length - r.BaseStream.Position >= 1 && r.ReadBoolean()) { int needs = PlayerNeeds.Apply(r); if (needs > 0) MscoLog.Info("[MSCO] Your own hunger/thirst/... restored (" + needs + " values)"); } // Abgebaute Teile und gespawnte Items sind jetzt eigene Objekte: erfassen, dann alle Positionen setzen yield return null; objects.ScanNow(); foreach (int id in destroyedIds) objects.DestroyRemote(id); int applied = 0; foreach (KeyValuePair p in poses) if (objects.ApplySnapshotPose(p.Key, p.Value.Position, p.Value.Rotation)) applied++; if (spawned > 0) MscoLog.Info(spawned + " spawned item(s) restored"); worldSynced = true; MscoLog.Info("[MSCO] World synced: " + applied + "/" + objectCount + " objects, " + eventCount + " part/bolt actions"); SetStatus("Connected"); } struct Pose { public Vector3 Position; public Quaternion Rotation; } /// /// Im Spiel speichern (wie auf der Toilette) und den Spielstand an den Server schicken. So bekommen neue Spieler /// genau die aktuelle Welt - Schrauben, Teile, alles - statt eines alten Stands mit nachgespielten Aktionen. /// Der eigene Spielstand ist davon nicht betroffen: er liegt gesichert im Backup und kommt im Hauptmenü zurück. /// System.Collections.IEnumerator UploadSave(int requestId) { if (!MenuHelper.InGame || !dedicated || session == null) yield break; lastSaveTriggered = Time.realtimeSinceStartup; PlayMakerFSM.BroadcastEvent("SAVEGAME"); float until = Time.realtimeSinceStartup + 3f; // das Spiel schreibt die Dateien über mehrere Frames while (Time.realtimeSinceStartup < until) yield return null; if (session == null) yield break; List> files; try { files = SaveSync.ReadLocalSave(); } catch (System.Exception e) { MscoLog.Warning("Could not read the save game for the server: " + e.Message); yield break; } const int Chunk = 256 * 1024; // unter der Paketgrenze des Servers foreach (KeyValuePair f in files) { for (int offset = 0; offset < f.Value.Length || offset == 0; offset += Chunk) { if (session == null) yield break; int start = offset, length = Mathf.Min(Chunk, f.Value.Length - offset); string name = f.Key; byte[] data = f.Value; session.SendTo(NetSession.HostId, MsgType.SaveUploadPart, NetSession.Build(w => { w.Write(requestId); w.Write(name); w.Write(data.Length); w.Write(start); w.Write(length); w.Write(data, start, length); })); yield return null; if (data.Length == 0) break; } } int count = files.Count; session.SendTo(NetSession.HostId, MsgType.SaveUploadDone, NetSession.Build(w => { w.Write(requestId); w.Write(count); })); MscoLog.Info("Sent the current save game to the server (" + count + " files)"); } /// 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, SeatView = Quaternion.identity }; // Im Fahrzeug: Kopf und Blick relativ zum Fahrzeug mitschicken SyncedBody seat = passenger.IsRiding ? passenger.Vehicle : objects.Driven; if (seat != null && seat.Rb != null) { Transform vt = seat.Rb.transform; state.SeatId = seat.Id; // Beifahrer: der Sitz ist bekannt - die Weltposition stammt noch vom letzten Frame, das Auto ist seitdem // weitergefahren (bei Tempo ein halber Meter) state.SeatHead = passenger.IsRiding ? passenger.SeatHeadLocal : vt.InverseTransformPoint(head.position); state.SeatView = Quaternion.Inverse(vt.rotation) * head.rotation; } 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); MscoLog.Info("[MSCO] " + p.Name + " is in the game"); } p.Avatar.SetTarget(state); } static Color PlayerColor(int id) { // Gedeckte Farben wie Flanellhemden/Arbeitsjacken in MSC Color[] colors = { new Color(0.6f, 0.13f, 0.12f), new Color(0.16f, 0.25f, 0.5f), new Color(0.2f, 0.38f, 0.2f), new Color(0.7f, 0.52f, 0.14f), new Color(0.42f, 0.28f, 0.17f), new Color(0.33f, 0.36f, 0.4f), new Color(0.16f, 0.36f, 0.38f), new Color(0.38f, 0.4f, 0.16f) }; return colors[id % colors.Length]; } void DestroyAvatar(PlayerInfo p) { if (p.Avatar != null) Destroy(p.Avatar.gameObject); p.Avatar = null; } // ---------------------------------------------------------------- GUI void OnGUI() { InputLock.KeepCursorFree(); if (MenuHelper.InMenu) { lobby.Draw(this); pingWidth = 0f; DrawPing(); DrawFps(Screen.width - 12 - pingWidth); toasts.Draw(); 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 (centerMessage != null && Time.realtimeSinceStartup < centerMessageUntil) { int oldSize = savingStyle.fontSize; savingStyle.fontSize = 30; GUI.Label(new Rect(0, Screen.height / 2f - 40, Screen.width, 80), centerMessage.ToUpper(), savingStyle); savingStyle.fontSize = oldSize; } if (session != null) DrawHud(); pingWidth = 0f; DrawPing(); DrawFps(Screen.width - 12 - pingWidth); toasts.Draw(); overlays.Draw(this, session != null); } /// F7 Konsole, T Chat (Tasten in den MSCLoader-Einstellungen änderbar). void UpdateOverlayKeys() { 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(); // Beim Tippen soll der Spieler nicht laufen/sich umsehen InputLock.Set(overlays.AnyOpen && MenuHelper.InGame); } void DrawHud() { // Statuszeile oben in der Mitte (links oben ist die Spielanzeige) string state = IsFullyConnected ? "CONNECTED" : IsPeerReady ? "SYNCING..." : "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 = IsFullyConnected ? 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; if (!string.IsNullOrEmpty(text)) MscoLog.Info(text); } } }