1549 lines
62 KiB
C#
1549 lines
62 KiB
C#
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
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<int, PlayerInfo> players = new Dictionary<int, PlayerInfo>();
|
|
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<string> bannedNames = new HashSet<string>(); // 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<MultiplayerManager>();
|
|
}
|
|
|
|
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<PlayerInfo> Players { get { return players.Values; } }
|
|
|
|
/// <summary>Mindestens ein Mitspieler da und Handshake fertig.</summary>
|
|
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; } }
|
|
|
|
/// <summary>Für "mp rig": welche Figur jeder Mitspieler hat.</summary>
|
|
public List<string> DescribeRigs()
|
|
{
|
|
var lines = new List<string>();
|
|
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<string> 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
|
|
|
|
/// <summary>Chatnachricht an alle (T).</summary>
|
|
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)));
|
|
}
|
|
|
|
/// <summary>/admin: Spieler kicken/bannen (Server: nur Admins aus server.json, P2P: nur der Host).</summary>
|
|
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<AdminPlayer> LocalAdminList()
|
|
{
|
|
var list = new List<AdminPlayer>();
|
|
foreach (PlayerInfo p in players.Values)
|
|
list.Add(new AdminPlayer { Id = p.Id, Name = p.Name, IsAdmin = p.IsLocal });
|
|
return list;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Ping
|
|
|
|
/// <summary>Laufzeit zum Server/Host in ms (-1 = unbekannt, z.B. alter Server oder man ist selbst Host).</summary>
|
|
public float PingMs { get { return pingMs; } }
|
|
|
|
/// <summary>Ping-Anzeige oben rechts (Konsole: "ping"), bleibt gespeichert.</summary>
|
|
public bool ShowPing
|
|
{
|
|
get { return PlayerPrefs.GetInt(PrefShowPing, 0) == 1; }
|
|
set { PlayerPrefs.SetInt(PrefShowPing, value ? 1 : 0); }
|
|
}
|
|
|
|
/// <summary>Text für Anzeige und Konsole.</summary>
|
|
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ü)
|
|
|
|
/// <summary>Cheats nur im Spiel und nur für Server-Admins bzw. den P2P-Host.</summary>
|
|
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");
|
|
}
|
|
|
|
/// <summary>Zu einem Mitspieler teleportieren.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Mitspieler zu sich holen (Server prüft, ob man Admin ist).</summary>
|
|
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");
|
|
}
|
|
}
|
|
|
|
/// <summary>Admin: Satsuma neben sich holen (z.B. wenn er verschwunden oder im Boden ist).</summary>
|
|
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");
|
|
}
|
|
|
|
/// <summary>Cheats für alle sichtbar im Chat melden.</summary>
|
|
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());
|
|
}
|
|
|
|
/// <summary>Server aus einem JOIN-Link der Website, wartet auf Bestätigung im Hauptmenü.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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; } }
|
|
|
|
/// <summary>Server mit anderer Version: Warnung im Launcher, Verbinden nur nach Bestätigung.</summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>Discord-Anzeige aus dem aktuellen Zustand (alle 2 s, gesendet wird nur bei Änderung).</summary>
|
|
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; } }
|
|
|
|
/// <summary>Freunde-Lobby erstellen (max. MaxPlayers), danach über Steam einladen.</summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>Steam-Name, wenn Steam läuft; sonst der im Launcher eingetragene Name (Non-Steam).</summary>
|
|
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");
|
|
}
|
|
|
|
/// <summary>START GAME im Launcher: Spielstand an alle schicken und wie CONTINUE laden.</summary>
|
|
public void StartHostGame()
|
|
{
|
|
if (!IsHost || !MenuHelper.InMenu) return;
|
|
SendSaveToWaitingPlayers();
|
|
SetStatus("Starting game...");
|
|
MenuHelper.StartGame();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Speichert wie die Toilette: das Spiel schickt allen FSMs das globale Event "SAVEGAME".
|
|
/// Nur der Host speichert, Clients haben nur eine Kopie seines Spielstands.
|
|
/// </summary>
|
|
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<PlayerInfo>(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<AdminPlayer>();
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Spiel-Nachricht an alle anderen (Client: über den Host).</summary>
|
|
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<PlayerInfo>();
|
|
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<PlayerInfo> { joined }, true);
|
|
SetStatus(joined.Name + " is loading your game");
|
|
}
|
|
|
|
void SendSave(List<PlayerInfo> targets, bool lateJoin)
|
|
{
|
|
List<KeyValuePair<string, byte[]>> files = SaveSync.ReadLocalSave();
|
|
var fileMessages = new List<byte[]>();
|
|
foreach (KeyValuePair<string, byte[]> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Die Ego-Kamera des Spielers (liegt unter PLAYER). Nicht Camera.main verwenden:
|
|
/// im Pausenmenü oder in Menüs ist das eine andere Kamera.
|
|
/// </summary>
|
|
static Camera FindPlayerCamera(GameObject player)
|
|
{
|
|
Camera best = null;
|
|
foreach (Camera c in player.GetComponentsInChildren<Camera>(true))
|
|
{
|
|
if (c.name.Contains("FPSCamera")) return c;
|
|
if (best == null || (c.enabled && !best.enabled)) best = c;
|
|
}
|
|
return best != null ? best : Camera.main;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Weltzustand vom Server anwenden: eigene letzte Position, Positionen aller bewegten Objekte
|
|
/// und danach alle Schraub-/Montage-Aktionen in der ursprünglichen Reihenfolge.
|
|
/// </summary>
|
|
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");
|
|
}
|
|
|
|
/// <summary>Sobald die erste Position des Hosts da ist: seitlich neben ihn stellen.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Eigene Position, Blickrichtung und gehaltenes Item (relativ zur Kamera).</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>F8 Konsole, T Chat (Tasten in den MSCLoader-Einstellungen änderbar).</summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|