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 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());
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;
}
// ---------------------------------------------------------------- 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");
}
}
/// 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;
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;
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;
}
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);
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);
overlays.AddChat("", result, true); // im Spiel sichtbar, nicht nur in der Konsole
}
}
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);
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);
}
/// 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);
}
}
}