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 = 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; 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; 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; 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; 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(); } // ---------------------------------------------------------------- Ö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(); 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; } 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) { 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; 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; // 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) { 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 + ")"); } 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(); 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: { 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((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 (!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.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; // 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: 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) { 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() { 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))); } 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) 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) { foreach (PlayerInfo p in players.Values) { p.SaveSent = false; DestroyAvatar(p); } continueFsm = null; passenger.Reset(); localPlayer = null; 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 && handshakeDone && !session.IsHost) SetStatus("Connected - waiting for host to start..."); } } void TryRestoreBackup() { try { SaveSync.RestoreBackup(); } catch (System.Exception e) { ModConsole.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; ModConsole.Print("[MSCO] Player camera: " + (playerCamera != null ? playerCamera.name : "none")); } if (teleportToHost) TryTeleportToHost(); // 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 && 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; 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); } foreach (PlayerInfo p in players.Values) { 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); } // 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 FixedUpdate() { 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() { 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); } /// F8 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 = 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); } } }