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 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 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;
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());
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 { return PlayerPrefs.GetInt(PrefShowPing, 0) == 1; }
set { PlayerPrefs.SetInt(PrefShowPing, value ? 1 : 0); }
}
/// 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);
}
// ---------------------------------------------------------------- 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; }
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
return ItemSpawner.Spawn(item, pos, rot);
}
/// 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("");
}
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;
/// 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;
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)
{
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
PlayMakerFSM.BroadcastEvent("SAVEGAME");
MscoLog.Info("[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));
// 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;
}
UpdateOverlayKeys();
MscoLog.Drain(overlays.Log); // alle Mod-Meldungen in die MSCO-Konsole (F7)
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;
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)
{
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;
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();
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 || t == MsgType.SpawnItem;
}
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);
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.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.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);
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.SpawnItem:
if (ApplySpawn(r)) rescanSoon = true; // neues Item gleich in den Objekt-Sync aufnehmen
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)));
}
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
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) { 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;
try { SendGame(MsgType.Player, BuildLocalState().Serialize()); }
catch (System.Exception e) { ReportError("player state", e); }
}
try { objects.Update(localPlayer, Time.deltaTime); }
catch (System.Exception e) { ReportError("object sync", e); }
try { vehicleFx.Update(objects, SendGame, Time.deltaTime); }
catch (System.Exception e) { ReportError("vehicle lights/sound", e); }
try { worldSync.Update(IsWorldAuthority, SendGame, Time.unscaledDeltaTime); }
catch (System.Exception e) { ReportError("time/weather sync", e); }
// Geld erst beobachten, wenn das Spiel fertig geladen ist
try
{
if (objects.ScanComplete) moneySync.Begin();
moneySync.Update(SendGame);
}
catch (System.Exception e) { ReportError("money sync", e); }
// Türen, Tore, Hauben (z.B. Garagentor)
try { if (objects.ScanComplete) doorSync.Update(SendGame); }
catch (System.Exception e) { ReportError("door sync", e); }
// Gesamtzustand der Fahrzeuge (Tank, Öl, Verschleiß ...)
try { if (objects.ScanComplete) vehicleState.Update(objects, localId, SendGame); }
catch (System.Exception e) { ReportError("vehicle state sync", e); }
}
foreach (PlayerInfo p in players.Values)
{
if (p.Avatar == null) continue;
try
{
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); }
}
// 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); }
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).
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;
try { objects.FixedUpdate(); }
catch (System.Exception e) { ReportError("object movement", e); }
}
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
try { if (IsPeerReady && MenuHelper.InGame) vehicleFx.Apply(objects); }
catch (System.Exception e) { ReportError("vehicle lights/sound", e); }
// Beifahrer nach der Fahrzeugbewegung auf den Sitz setzen
try { if (localPlayer != null && MenuHelper.InGame) passenger.LateUpdate(localPlayer, playerCamera, objects); }
catch (System.Exception e) { ReportError("passenger", e); }
}
///
/// 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); }
}
}
// Abgebaute Teile und gespawnte Items sind jetzt eigene Objekte: erfassen, dann alle Positionen setzen
yield return null;
objects.ScanNow();
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");
MscoLog.Info("[MSCO] World synced: " + applied + "/" + objectCount + " objects, " + eventCount + " part/bolt actions");
SetStatus("Connected");
}
struct Pose
{
public Vector3 Position;
public Quaternion Rotation;
}
/// 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);
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);
DrawPing();
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();
DrawPing();
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 = 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;
if (!string.IsNullOrEmpty(text)) MscoLog.Info(text);
}
}
}