A '-' button collapses the fullscreen launcher into a small MSCO bar at the top; the game's start menu is visible and clickable again and its music is unmuted. Clicking the bar opens the launcher; prompts (disconnected, reconnecting, join confirmation, version warning) open it automatically. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2478 lines
110 KiB
C#
2478 lines
110 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 = 1f / 30f; // 30 Spieler-Updates pro Sekunde
|
|
const string PrefName = "MSCO_Name";
|
|
const float NameTagHideDistance = 15f; // ab hier kein Namensschild mehr
|
|
const float NameTagFadeRange = 5f; // die letzten Meter davor wird es ausgeblendet
|
|
|
|
NetSession session;
|
|
int localId;
|
|
bool handshakeDone; // Client: Welcome erhalten
|
|
bool receivingSave; // Client: Spielstand wird gerade empfangen
|
|
bool teleportToHost; // Client: später beigetreten, nach dem Laden zum Host
|
|
bool dedicated; // verbunden mit einem dedizierten Server
|
|
bool connectingToServer; // Verbindungsaufbau zu einem dedizierten Server läuft
|
|
bool readySent; // Server: Weltzustand schon angefordert
|
|
bool rescanSoon; // nach einer Abbau-Aktion neue lose Teile sofort erfassen
|
|
byte[] pendingSnapshot; // Server: Weltzustand, wartet auf Objekt-Suchlauf
|
|
const int SnapshotEventsPerFrame = 20;
|
|
readonly ServerBrowser browser = new ServerBrowser();
|
|
string status = "";
|
|
string playerName;
|
|
|
|
readonly Dictionary<int, PlayerInfo> players = new Dictionary<int, PlayerInfo>();
|
|
readonly LobbyUI lobby = new LobbyUI();
|
|
readonly ObjectSync objects = new ObjectSync();
|
|
readonly VehicleFx vehicleFx = new VehicleFx();
|
|
readonly WheelSpin wheelSpin = new WheelSpin();
|
|
readonly WorldSync worldSync = new WorldSync();
|
|
readonly DiscordPresence discord = new DiscordPresence();
|
|
readonly MoneySync moneySync = new MoneySync();
|
|
readonly DoorSync doorSync = new DoorSync();
|
|
readonly Passenger passenger = new Passenger();
|
|
readonly SteamLobby steam = new SteamLobby();
|
|
readonly VehicleStateSync vehicleState = new VehicleStateSync();
|
|
readonly DamageSync damageSync = new DamageSync();
|
|
readonly WorldVarSync worldVars = new WorldVarSync();
|
|
readonly TrafficSync traffic = new TrafficSync();
|
|
readonly HingeSync hinges = new HingeSync();
|
|
readonly ControlSync controls = new ControlSync();
|
|
bool lobbyWasMinimized;
|
|
float nextControlResync;
|
|
readonly Toasts toasts = new Toasts();
|
|
float nextNeedsSend;
|
|
const float NeedsInterval = 10f;
|
|
|
|
void ToastJoined(string name) { toasts.Add(name, "joined the game", UiTheme.Online); }
|
|
void ToastLeft(string name) { toasts.Add(name, "left the game", UiTheme.Muted); }
|
|
readonly Overlays overlays = new Overlays();
|
|
bool isAdmin; // Server hat uns als Admin gemeldet
|
|
readonly HashSet<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;
|
|
// Einkäufe / verbrauchte Items bei uns: allen melden
|
|
objects.ItemAppeared = b =>
|
|
{
|
|
Vector3 pos = b.Rb.position;
|
|
Quaternion rot = b.Rb.rotation;
|
|
string name = b.Go.name;
|
|
int id = b.Id;
|
|
SendGame(MsgType.ItemAppeared, NetSession.Build(w =>
|
|
{
|
|
w.Write(id);
|
|
w.Write(name);
|
|
w.Write(pos.x); w.Write(pos.y); w.Write(pos.z);
|
|
w.Write(rot.x); w.Write(rot.y); w.Write(rot.z); w.Write(rot.w);
|
|
}));
|
|
};
|
|
objects.ItemDestroyed = b =>
|
|
{
|
|
int id = b.Id;
|
|
SendGame(MsgType.ObjDestroy, NetSession.Build(w => w.Write(id)));
|
|
};
|
|
FsmSync.Init();
|
|
SafetyPatches.Apply(); // verhindert den Absturz durch Kreis in der Objekt-Hierarchie
|
|
RestorePendingState();
|
|
discord.Start();
|
|
|
|
steam.Created += OnSteamLobbyCreated;
|
|
steam.Entered += OnSteamLobbyEntered;
|
|
steam.JoinRequested += OnSteamJoinRequested;
|
|
steam.MemberLeft += OnSteamMemberLeft;
|
|
steam.Failed += SetStatus;
|
|
steam.Init();
|
|
// Spiel über eine Steam-Einladung gestartet?
|
|
pendingLobby = SteamLobby.LobbyFromCommandLine();
|
|
|
|
// JOIN-Links der Website (msco://connect/...) und: Spiel über so einen Link gestartet?
|
|
JoinLinks.Register();
|
|
string linkHost;
|
|
int linkPort;
|
|
if (JoinLinks.TryGetFromCommandLine(out linkHost, out linkPort))
|
|
{
|
|
PendingJoinHost = linkHost;
|
|
PendingJoinPort = linkPort;
|
|
}
|
|
// Falls das Spiel beim letzten Mal abgestürzt ist: eigenen Spielstand zurückholen
|
|
TryRestoreBackup();
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Öffentlich (Launcher/Konsole)
|
|
|
|
public bool IsInSession { get { return session != null; } }
|
|
public bool IsHost { get { return session != null && session.IsHost; } }
|
|
public string Status { get { return status; } }
|
|
public int LocalId { get { return localId; } }
|
|
public ICollection<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();
|
|
PartStart();
|
|
try
|
|
{
|
|
session = NetSession.StartHost(port);
|
|
localId = NetSession.HostId;
|
|
objects.LocalId = localId;
|
|
AddPlayer(localId, playerName, true);
|
|
SetStatus("Hosting on port " + port + " - waiting for players...");
|
|
}
|
|
catch (System.Exception e)
|
|
{
|
|
session = null;
|
|
SetStatus("Could not host: " + e.Message);
|
|
}
|
|
}
|
|
|
|
public bool IsDedicated { get { return dedicated; } }
|
|
|
|
/// <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;
|
|
}
|
|
|
|
/// <summary>Konsole "model": Figuren neu bauen (kommen mit dem nächsten Spielerzustand wieder).</summary>
|
|
public void RebuildAvatars()
|
|
{
|
|
foreach (PlayerInfo p in players.Values) DestroyAvatar(p);
|
|
}
|
|
|
|
/// <summary>Konsole "vars <filter>": synchronisierte Fahrzeugwerte (Diagnose).</summary>
|
|
public List<string> DescribeVehicleValues(string filter) { return vehicleState.Describe(filter, 60); }
|
|
|
|
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);
|
|
lines.Add("synced body meshes (crash damage): " + damageSync.Count);
|
|
lines.Add("synced car doors/hoods/trunks: " + hinges.Count);
|
|
lines.Add("synced vehicle controls (ignition, engine, lights ...): " + controls.Count + " (" + controls.MutedCount + " muted)");
|
|
lines.Add("synced job/shop values: " + worldVars.Count);
|
|
lines.Add("synced traffic cars: " + traffic.Count + (IsWorldAuthority ? " (simulated here)" : " (following)"));
|
|
return lines;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Chat & Admin
|
|
|
|
/// <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());
|
|
overlays.SetBanned(new List<string>(bannedNames));
|
|
}
|
|
else overlays.Log("Only the host can kick or ban players.");
|
|
}
|
|
|
|
public void RequestAdminList()
|
|
{
|
|
if (session == null) return;
|
|
if (dedicated) session.SendTo(NetSession.HostId, MsgType.AdminListRequest);
|
|
else if (session.IsHost)
|
|
{
|
|
overlays.SetAdminPlayers(LocalAdminList());
|
|
overlays.SetBanned(new List<string>(bannedNames));
|
|
}
|
|
}
|
|
|
|
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
|
|
{
|
|
if (showPing < 0) showPing = PlayerPrefs.GetInt(PrefShowPing, 0); // einmal lesen, nicht bei jedem Zeichnen
|
|
return showPing == 1;
|
|
}
|
|
set
|
|
{
|
|
showPing = value ? 1 : 0;
|
|
PlayerPrefs.SetInt(PrefShowPing, showPing);
|
|
}
|
|
}
|
|
int showPing = -1, showFps = -1;
|
|
|
|
// ---------------------------------------------------------------- FPS-Anzeige (Konsole: "fps")
|
|
const string PrefShowFps = "MSCO_ShowFps";
|
|
float fpsTimer, fpsValue = -1f, frameMsWorst;
|
|
int fpsFrames;
|
|
float frameMsWorstWindow;
|
|
|
|
/// <summary>FPS-Anzeige oben rechts, bleibt gespeichert.</summary>
|
|
public bool ShowFps
|
|
{
|
|
get
|
|
{
|
|
if (showFps < 0) showFps = PlayerPrefs.GetInt(PrefShowFps, 0);
|
|
return showFps == 1;
|
|
}
|
|
set
|
|
{
|
|
showFps = value ? 1 : 0;
|
|
PlayerPrefs.SetInt(PrefShowFps, showFps);
|
|
}
|
|
}
|
|
|
|
/// <summary>Bilder pro Sekunde (Mittel über eine halbe Sekunde) und längstes Bild darin (Ruckler).</summary>
|
|
public string FpsText
|
|
{
|
|
get
|
|
{
|
|
if (fpsValue < 0f) return "measuring...";
|
|
return Mathf.RoundToInt(fpsValue) + " FPS (" + (1000f / Mathf.Max(1f, fpsValue)).ToString("0.0") + " ms, worst " +
|
|
frameMsWorst.ToString("0") + " ms)";
|
|
}
|
|
}
|
|
|
|
void MeasureFps()
|
|
{
|
|
float dt = Time.unscaledDeltaTime;
|
|
fpsFrames++;
|
|
fpsTimer += dt;
|
|
frameMsWorstWindow = Mathf.Max(frameMsWorstWindow, dt * 1000f);
|
|
if (fpsTimer < 0.5f) return;
|
|
fpsValue = fpsFrames / fpsTimer;
|
|
frameMsWorst = frameMsWorstWindow;
|
|
fpsFrames = 0;
|
|
fpsTimer = 0f;
|
|
frameMsWorstWindow = 0f;
|
|
}
|
|
|
|
GUIStyle fpsStyle;
|
|
|
|
/// <summary>Oben rechts, links neben dem Ping.</summary>
|
|
void DrawFps(float rightEdge)
|
|
{
|
|
if (!ShowFps) return;
|
|
if (fpsStyle == null)
|
|
{
|
|
fpsStyle = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.85f), 6);
|
|
fpsStyle.fontSize = 12;
|
|
fpsStyle.fontStyle = FontStyle.Bold;
|
|
fpsStyle.alignment = TextAnchor.MiddleCenter;
|
|
fpsStyle.padding = new RectOffset(10, 10, 0, 0);
|
|
}
|
|
// grün ab 50, gelb ab 30, sonst rot
|
|
fpsStyle.normal.textColor = fpsValue < 0f ? UiTheme.Muted : fpsValue >= 50f ? UiTheme.Online : fpsValue >= 30f ? UiTheme.Pending : UiTheme.Danger;
|
|
var content = new GUIContent(fpsValue < 0f ? "FPS ..." : "FPS " + Mathf.RoundToInt(fpsValue));
|
|
float width = fpsStyle.CalcSize(content).x;
|
|
GUI.Box(new Rect(rightEdge - width, 8, width, 24), content, fpsStyle);
|
|
}
|
|
|
|
/// <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);
|
|
pingWidth = width + 8;
|
|
}
|
|
float pingWidth; // Platz, den die Ping-Anzeige belegt (FPS steht links daneben)
|
|
|
|
// ---------------------------------------------------------------- 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>Admin: Item vor sich spawnen - bei allen, und auf dem Server gespeichert.</summary>
|
|
public void SpawnItem(string item)
|
|
{
|
|
if (!CanCheat || localPlayer == null) return;
|
|
if (item == ItemSpawner.RatchetSet)
|
|
{
|
|
// Werkzeug, kein Gegenstand: nur für einen selbst freischalten
|
|
overlays.Log(ItemSpawner.GiveRatchetSet() ? "You now have the ratchet set" : "Ratchet set not found (only works in the game)");
|
|
AnnounceCheat("gave themselves the ratchet set");
|
|
return;
|
|
}
|
|
Transform head = playerCamera != null ? playerCamera.transform : localPlayer;
|
|
Vector3 forward = new Vector3(head.forward.x, 0f, head.forward.z).normalized;
|
|
if (forward.sqrMagnitude < 0.01f) forward = localPlayer.forward;
|
|
Vector3 pos = head.position + forward * 1.2f;
|
|
Quaternion rot = Quaternion.LookRotation(forward, Vector3.up);
|
|
if (!ItemSpawner.Spawn(item, pos, rot)) { overlays.Log("No '" + item + "' in this game"); return; }
|
|
objects.MarkNetworkCreated(ItemSpawner.LastSpawned); // wird per SpawnItem gemeldet, nicht als "gekauft"
|
|
rescanSoon = true;
|
|
SendGame(MsgType.SpawnItem, NetSession.Build(w =>
|
|
{
|
|
w.Write(item);
|
|
w.Write(pos.x); w.Write(pos.y); w.Write(pos.z);
|
|
w.Write(rot.x); w.Write(rot.y); w.Write(rot.z); w.Write(rot.w);
|
|
}));
|
|
AnnounceCheat("spawned " + item);
|
|
}
|
|
|
|
/// <summary>Gespawntes Item eines Admins (live oder aus dem Weltzustand) nachbauen.</summary>
|
|
static bool ApplySpawn(BinaryReader r)
|
|
{
|
|
string item = r.ReadString();
|
|
var pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
|
|
var rot = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
|
|
if (!ItemSpawner.IsKnown(item) || item == ItemSpawner.RatchetSet) return false; // nur bekannte Gegenstände
|
|
bool ok = ItemSpawner.Spawn(item, pos, rot);
|
|
if (ok && Instance != null) Instance.objects.MarkNetworkCreated(ItemSpawner.LastSpawned);
|
|
return ok;
|
|
}
|
|
|
|
/// <summary>Bei einem anderen Spieler ist ein Item aufgetaucht (Einkauf ...): hier auch erzeugen.</summary>
|
|
bool ApplyAppeared(BinaryReader r)
|
|
{
|
|
int id = r.ReadInt32();
|
|
string name = r.ReadString();
|
|
var pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
|
|
var rot = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
|
|
if (objects.HasNear(id, pos, 5f)) return false; // haben wir schon (z.B. war nur kurz ausgeblendet)
|
|
if (!ItemSpawner.SpawnObjectNamed(name, pos, rot)) return false;
|
|
objects.MarkNetworkCreated(ItemSpawner.LastSpawned);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Admin (nur dedizierter Server): Welt zurücksetzen (5), Backup anlegen (6), Backup wiederherstellen (7).</summary>
|
|
public void WorldAction(byte action, string backup)
|
|
{
|
|
if (session == null || !dedicated) return;
|
|
session.SendTo(NetSession.HostId, MsgType.AdminAction, NetSession.Build(w =>
|
|
{
|
|
w.Write(action);
|
|
w.Write(-1);
|
|
w.Write(backup ?? "");
|
|
}));
|
|
}
|
|
|
|
/// <summary>Admin: Bann aufheben (Server: SteamID oder name:..., P2P: Name).</summary>
|
|
public void Unban(string id)
|
|
{
|
|
if (session == null) return;
|
|
if (dedicated)
|
|
{
|
|
session.SendTo(NetSession.HostId, MsgType.AdminAction, NetSession.Build(w =>
|
|
{
|
|
w.Write((byte)4);
|
|
w.Write(-1);
|
|
w.Write(id);
|
|
}));
|
|
return;
|
|
}
|
|
if (!session.IsHost) return;
|
|
bannedNames.Remove(id);
|
|
overlays.SetBanned(new List<string>(bannedNames));
|
|
overlays.AddChat("", id + " is no longer banned", true);
|
|
}
|
|
|
|
/// <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("");
|
|
}
|
|
|
|
// Letzter Server (für das automatische Neu-Verbinden nach reset-world)
|
|
string lastServerHost, lastServerName;
|
|
int lastServerPort, lastServerMax;
|
|
string reconnectMessage; // gesetzt, wenn der Server uns neu verbinden lässt
|
|
float reconnectHoldUntil; // so lange die Meldung noch im Spiel zeigen
|
|
string centerMessage; // große Meldung in der Bildmitte
|
|
float centerMessageUntil;
|
|
float reconnectAt;
|
|
int reconnectAttempts;
|
|
const int MaxReconnectAttempts = 10; // ~1 Minute: reicht für einen Server-Neustart
|
|
const float ReconnectRetrySeconds = 6f;
|
|
|
|
void ConnectToServer(string host, int port, string displayName, int maxPlayers)
|
|
{
|
|
lastServerHost = host;
|
|
lastServerPort = port;
|
|
lastServerName = displayName;
|
|
lastServerMax = maxPlayers;
|
|
Join(host, port);
|
|
if (session == null) return;
|
|
connectingToServer = true;
|
|
serverName = string.IsNullOrEmpty(displayName) ? host + ":" + port : displayName;
|
|
serverMaxPlayers = maxPlayers;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Discord
|
|
|
|
string serverName; // Name des dedizierten Servers (für Discord)
|
|
int serverMaxPlayers;
|
|
|
|
/// <summary>Wie viele Spieler in diese Sitzung passen: auf einem Server dessen Limit, bei P2P 3.</summary>
|
|
public int SessionMaxPlayers { get { return (dedicated || connectingToServer) && serverMaxPlayers > 0 ? serverMaxPlayers : MaxPlayers; } }
|
|
public bool IsConnectingToServer { get { return connectingToServer; } }
|
|
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) MscoLog.Info("[MSCO] " + discordLog);
|
|
|
|
if (Time.realtimeSinceStartup < nextPresenceUpdate) return;
|
|
nextPresenceUpdate = Time.realtimeSinceStartup + 2f;
|
|
|
|
bool inGame = MenuHelper.InGame;
|
|
string details, state = null, partyId = null;
|
|
int size = 0, max = 0;
|
|
|
|
if (session == null)
|
|
{
|
|
details = inGame ? "Playing singleplayer" : "In the Menus";
|
|
}
|
|
else
|
|
{
|
|
size = Mathf.Max(1, players.Count);
|
|
if (dedicated || connectingToServer)
|
|
{
|
|
details = (inGame ? "Playing on " : "Joining ") + (serverName ?? "a server");
|
|
state = "Dedicated server";
|
|
max = serverMaxPlayers;
|
|
partyId = "server-" + serverName;
|
|
}
|
|
else
|
|
{
|
|
details = IsHost ? (inGame ? "Hosting a game" : "Hosting a lobby") : (inGame ? "Playing with friends" : "In a lobby");
|
|
state = session.IsSteam ? "Steam P2P" : "LAN / IP";
|
|
max = MaxPlayers;
|
|
partyId = "p2p-" + (IsHost ? SteamLobby.OwnSteamId : steamHost.m_SteamID);
|
|
}
|
|
}
|
|
|
|
// Spielzeit neu starten, wenn sich die Art der Aktivität ändert
|
|
if (details != presenceKey)
|
|
{
|
|
presenceKey = details;
|
|
presenceStart = (long)(System.DateTime.UtcNow - new System.DateTime(1970, 1, 1, 0, 0, 0, System.DateTimeKind.Utc)).TotalSeconds;
|
|
}
|
|
discord.SetActivity(details, state, size, max, partyId, presenceStart);
|
|
}
|
|
|
|
void OnApplicationQuit()
|
|
{
|
|
discord.Stop();
|
|
}
|
|
|
|
public void Join(string ip, int port)
|
|
{
|
|
if (!MenuHelper.InMenu)
|
|
{
|
|
SetStatus("Join from the main menu!");
|
|
return;
|
|
}
|
|
Disconnect();
|
|
try
|
|
{
|
|
session = NetSession.StartClient(ip, port);
|
|
SetStatus("Connecting to " + ip + ":" + port + "...");
|
|
}
|
|
catch (System.Exception e)
|
|
{
|
|
session = null;
|
|
SetStatus("Could not connect: " + e.Message);
|
|
}
|
|
}
|
|
|
|
public void Disconnect()
|
|
{
|
|
steam.Leave();
|
|
if (session == null) return;
|
|
session.Close();
|
|
OnSessionEnded("Disconnected");
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Steam
|
|
|
|
public bool SteamAvailable { get { return SteamLobby.IsAvailable; } }
|
|
public bool IsSteamSession { get { return session != null && session.IsSteam; } }
|
|
|
|
/// <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;
|
|
newGamePending = false;
|
|
SendSaveToWaitingPlayers();
|
|
SetStatus("Starting game...");
|
|
MenuHelper.StartGame();
|
|
}
|
|
|
|
// Host hat ein neues Spiel gestartet: es gibt noch keinen Spielstand zum Verschicken - erst im Spiel speichern
|
|
bool newGamePending;
|
|
|
|
/// <summary>
|
|
/// Host: neues Spiel statt des eigenen Spielstands. Der bisherige Spielstand wird gesichert (MSCO_Saves), die
|
|
/// Welt frisch angelegt; die Mitspieler bekommen sie, sobald sie einmal gespeichert ist.
|
|
/// </summary>
|
|
public void StartHostNewGame()
|
|
{
|
|
if (!IsHost || !MenuHelper.InMenu) return;
|
|
string archived;
|
|
try { archived = SaveSync.ArchiveForNewGame(); }
|
|
catch (System.Exception e)
|
|
{
|
|
// Lieber gar nicht starten, als den Spielstand ungesichert zu löschen
|
|
MscoLog.Error("[MSCO] Could not back up your save game - new game cancelled: " + e.Message);
|
|
SetStatus("Could not back up your save game - new game cancelled");
|
|
return;
|
|
}
|
|
newGamePending = true;
|
|
if (archived != null) overlays.Log("Your previous save game was backed up to " + archived);
|
|
SetStatus("Starting a new game...");
|
|
MenuHelper.StartNewGame();
|
|
}
|
|
|
|
/// <summary>Neues Spiel geladen: warten, bis die Welt steht, speichern und an die wartenden Mitspieler schicken.</summary>
|
|
System.Collections.IEnumerator NewGameSaveAndSend()
|
|
{
|
|
float give = Time.realtimeSinceStartup + 60f;
|
|
while (localPlayer == null && Time.realtimeSinceStartup < give) yield return new WaitForSeconds(0.5f);
|
|
yield return new WaitForSeconds(3f); // Spielwelt erst fertig aufbauen lassen
|
|
if (!newGamePending || session == null || !session.IsHost || !MenuHelper.InGame) yield break;
|
|
SaveGame();
|
|
yield return new WaitForSeconds(LateJoinSaveDelay);
|
|
newGamePending = false;
|
|
var targets = new List<PlayerInfo>();
|
|
foreach (PlayerInfo p in players.Values)
|
|
if (!p.IsLocal && !p.SaveSent) targets.Add(p);
|
|
if (targets.Count > 0)
|
|
{
|
|
SendSave(targets, true);
|
|
SetStatus("New game sent to " + targets.Count + " player(s)");
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
MscoLog.Info("[MSCO] You can only save while in the game.");
|
|
return;
|
|
}
|
|
if (session != null && dedicated)
|
|
{
|
|
MscoLog.Info("[MSCO] The server saves the world automatically.");
|
|
return;
|
|
}
|
|
if (session != null && !session.IsHost)
|
|
{
|
|
MscoLog.Info("[MSCO] Only the host can save the game.");
|
|
return;
|
|
}
|
|
savingMessageUntil = Time.realtimeSinceStartup + 2.5f; // "SAVING GAME..." in der Bildschirmmitte
|
|
lastSaveTriggered = Time.realtimeSinceStartup;
|
|
PlayMakerFSM.BroadcastEvent("SAVEGAME");
|
|
MscoLog.Info("[MSCO] Game saved.");
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Update
|
|
|
|
void Update()
|
|
{
|
|
CheckHitch();
|
|
MeasureFps();
|
|
// Menü-Buttons des Spiels nicht durch unser Fenster hindurch auslösen (z.B. Changelog)
|
|
// Solange das Multiplayer-Fenster offen ist, reagiert das Spielmenü (CONTINUE, QUIT ...) gar nicht;
|
|
// erst nach MINIMIZE wieder. Über der eingeklappten Leiste ebenfalls nicht (sonst öffnet sich z.B. der Changelog).
|
|
MenuInputBlocker.SetBlocked(MenuHelper.InMenu && (lobby.IsOpen || lobby.ContainsMouse() || overlays.AnyOpen));
|
|
// Eigenes Hauptmenü: Singleplayer-Knöpfe des Spiels ausblenden (einmal pro Menü-Besuch, sobald sie da sind)
|
|
if (MenuHelper.InMenu && !menuButtonsHidden && Time.realtimeSinceStartup >= nextMenuButtonCheck)
|
|
{
|
|
nextMenuButtonCheck = Time.realtimeSinceStartup + 0.5f;
|
|
menuButtonsHidden = MenuHelper.HideGameMenuButtons() > 0;
|
|
if (!lobby.Minimized) MenuHelper.MuteMenuAudio(); // Launcher im Vollbild: Menü-Musik/-Geräusche aus
|
|
}
|
|
// Launcher eingeklappt: Menü-Musik wieder an; aufgeklappt: wieder aus
|
|
if (MenuHelper.InMenu && lobby.Minimized != lobbyWasMinimized)
|
|
{
|
|
lobbyWasMinimized = lobby.Minimized;
|
|
if (lobby.Minimized) MenuHelper.UnmuteMenuAudio(); else MenuHelper.MuteMenuAudio();
|
|
}
|
|
UpdateOverlayKeys();
|
|
PersistPendingState(); // Kick-Grund / Neu-Verbinden über das Neuladen der Mods hinweg merken
|
|
MscoLog.Drain(overlays.Log); // alle Mod-Meldungen in die MSCO-Konsole (F7)
|
|
UpdatePresence();
|
|
|
|
steam.Tick();
|
|
|
|
// Nach reset-world o.ä.: im Hauptmenü automatisch wieder mit demselben Server verbinden. Mehrere Versuche -
|
|
// der Server kann nach einem Neustart noch ein paar Sekunden brauchen.
|
|
if (reconnectMessage != null && session == null && MenuHelper.InMenu && !checkingServer
|
|
&& Time.realtimeSinceStartup >= reconnectAt)
|
|
{
|
|
if (lastServerHost == null || reconnectAttempts >= MaxReconnectAttempts)
|
|
{
|
|
DisconnectNotice = "Could not reconnect to the server after " + reconnectAttempts + " attempts. " +
|
|
"Try again from the server browser.";
|
|
reconnectMessage = null;
|
|
reconnectAttempts = 0;
|
|
}
|
|
else
|
|
{
|
|
reconnectAttempts++;
|
|
reconnectAt = Time.realtimeSinceStartup + ReconnectRetrySeconds;
|
|
MscoLog.Info("[MSCO] Reconnecting to " + lastServerHost + ":" + lastServerPort +
|
|
" (attempt " + reconnectAttempts + "/" + MaxReconnectAttempts + ")");
|
|
JoinServer(lastServerHost, lastServerPort, lastServerName, lastServerMax);
|
|
}
|
|
}
|
|
if (pendingLobby.m_SteamID != 0 && MenuHelper.InMenu && session == null && steam.Init())
|
|
{
|
|
Steamworks.CSteamID lobbyToJoin = pendingLobby;
|
|
pendingLobby = new Steamworks.CSteamID(0);
|
|
SetStatus("Joining Steam lobby...");
|
|
steam.Join(lobbyToJoin);
|
|
}
|
|
|
|
if (session == null) return;
|
|
|
|
// Lebenszeichen, damit Steam-Verbindungen nicht in den Timeout laufen
|
|
if (Time.realtimeSinceStartup >= nextPing)
|
|
{
|
|
nextPing = Time.realtimeSinceStartup + PingInterval;
|
|
float sentAt = Time.realtimeSinceStartup;
|
|
session.SendToAll(MsgType.Ping, NetSession.Build(w => w.Write(sentAt))); // Zeitstempel für die Ping-Messung
|
|
}
|
|
|
|
foreach (NetEvent e in session.Poll())
|
|
{
|
|
switch (e.Kind)
|
|
{
|
|
case NetEventKind.Connected: OnConnected(e.PeerId); break;
|
|
case NetEventKind.Disconnected: OnPeerDisconnected(e.PeerId, e.Reason); break;
|
|
case NetEventKind.Message: OnMessage(e.PeerId, e.Message); break;
|
|
}
|
|
if (session == null) return; // z.B. vom Host abgelehnt
|
|
}
|
|
|
|
if (session.IsClosed)
|
|
{
|
|
if (Time.realtimeSinceStartup < reconnectHoldUntil) return; // Meldung erst lesen lassen
|
|
OnSessionEnded(session.CloseReason);
|
|
return;
|
|
}
|
|
|
|
if (session.IsHost && MenuHelper.InMenu && HasWaitingPlayers())
|
|
WatchHostContinue();
|
|
|
|
if (MenuHelper.InGame) UpdateInGame();
|
|
else FsmSync.Enabled = false;
|
|
}
|
|
|
|
void OnConnected(int peerId)
|
|
{
|
|
if (session.IsHost)
|
|
{
|
|
SetStatus("Player connecting...");
|
|
}
|
|
else
|
|
{
|
|
string name = OwnName();
|
|
byte[] ticket = SteamLobby.CreateAuthTicket();
|
|
session.SendTo(NetSession.HostId, MsgType.Hello, NetSession.Build(w =>
|
|
{
|
|
w.Write(MSCO.ModVersion);
|
|
w.Write(name);
|
|
// Steam-Identität: dedizierte Server lassen nur Spieler mit Steam rein
|
|
w.Write(SteamLobby.OwnSteamId);
|
|
w.Write(ticket.Length);
|
|
w.Write(ticket);
|
|
}));
|
|
SetStatus("Connected, waiting for host...");
|
|
}
|
|
}
|
|
|
|
void OnPeerDisconnected(int peerId, string reason)
|
|
{
|
|
PlayerInfo p;
|
|
if (!players.TryGetValue(peerId, out p)) return;
|
|
RemovePlayer(peerId);
|
|
session.SendToAll(MsgType.PlayerLeft, NetSession.Build(w => w.Write(peerId)));
|
|
SetStatus(p.Name + " left (" + reason + ")");
|
|
ToastLeft(p.Name);
|
|
}
|
|
|
|
string serverReason; // Grund, den der Server/Host beim Trennen mitgeschickt hat
|
|
|
|
/// <summary>Warum man zuletzt getrennt wurde - wird im Hauptmenü angezeigt, bis man OK drückt.</summary>
|
|
public string DisconnectNotice { get; private set; }
|
|
|
|
// ---------------------------------------------------------------- Über Neuladen hinweg merken
|
|
// Beim Wechsel ins Hauptmenü lädt MSCLoader alle Mods neu - dabei ginge der Kick-Grund bzw. "bitte neu
|
|
// verbinden" verloren, bevor man das Menü überhaupt sieht. Deshalb in den PlayerPrefs sichern.
|
|
const string PrefNotice = "MSCO_PendingNotice";
|
|
const string PrefReconnect = "MSCO_PendingReconnect";
|
|
const double NoticeMaxAgeMinutes = 10;
|
|
const double ReconnectMaxAgeMinutes = 3;
|
|
string savedNotice, savedReconnect;
|
|
int savedAttempts = -1;
|
|
|
|
void PersistPendingState()
|
|
{
|
|
if (ReferenceEquals(savedNotice, DisconnectNotice) && ReferenceEquals(savedReconnect, reconnectMessage)
|
|
&& savedAttempts == reconnectAttempts) return;
|
|
savedNotice = DisconnectNotice;
|
|
savedReconnect = reconnectMessage;
|
|
savedAttempts = reconnectAttempts;
|
|
string now = System.DateTime.UtcNow.Ticks.ToString();
|
|
try
|
|
{
|
|
if (DisconnectNotice != null) PlayerPrefs.SetString(PrefNotice, now + "\n" + DisconnectNotice);
|
|
else PlayerPrefs.DeleteKey(PrefNotice);
|
|
if (reconnectMessage != null && lastServerHost != null)
|
|
PlayerPrefs.SetString(PrefReconnect, string.Join("\n", new[]
|
|
{
|
|
now, lastServerHost, lastServerPort.ToString(), lastServerName ?? "", lastServerMax.ToString(),
|
|
reconnectAttempts.ToString(), reconnectMessage
|
|
}));
|
|
else PlayerPrefs.DeleteKey(PrefReconnect);
|
|
PlayerPrefs.Save();
|
|
}
|
|
catch (System.Exception e) { MscoLog.Warning("Could not remember the disconnect state: " + e.Message); }
|
|
}
|
|
|
|
static bool Fresh(string ticks, double maxMinutes)
|
|
{
|
|
long t;
|
|
return long.TryParse(ticks, out t) && (System.DateTime.UtcNow - new System.DateTime(t)).TotalMinutes < maxMinutes;
|
|
}
|
|
|
|
void RestorePendingState()
|
|
{
|
|
try
|
|
{
|
|
string[] notice = PlayerPrefs.GetString(PrefNotice, "").Split(new[] { '\n' }, 2);
|
|
if (notice.Length == 2 && Fresh(notice[0], NoticeMaxAgeMinutes)) DisconnectNotice = notice[1];
|
|
|
|
string[] rc = PlayerPrefs.GetString(PrefReconnect, "").Split(new[] { '\n' }, 7);
|
|
int port, max, attempts;
|
|
if (rc.Length == 7 && Fresh(rc[0], ReconnectMaxAgeMinutes) && int.TryParse(rc[2], out port)
|
|
&& int.TryParse(rc[4], out max) && int.TryParse(rc[5], out attempts))
|
|
{
|
|
lastServerHost = rc[1];
|
|
lastServerPort = port;
|
|
lastServerName = rc[3].Length > 0 ? rc[3] : null;
|
|
lastServerMax = max;
|
|
reconnectAttempts = attempts;
|
|
reconnectMessage = rc[6];
|
|
reconnectAt = Time.realtimeSinceStartup + 2f; // erst das Menü fertig laden lassen
|
|
DisconnectNotice = null;
|
|
MscoLog.Info("[MSCO] Continuing reconnect to " + lastServerHost + ":" + lastServerPort);
|
|
}
|
|
}
|
|
catch (System.Exception e) { MscoLog.Warning("Could not restore the disconnect state: " + e.Message); }
|
|
savedNotice = DisconnectNotice;
|
|
savedReconnect = reconnectMessage;
|
|
savedAttempts = reconnectAttempts;
|
|
// Einmal gelesen = erledigt; neu gespeichert wird bei der nächsten Änderung
|
|
PlayerPrefs.DeleteKey(PrefNotice);
|
|
PlayerPrefs.DeleteKey(PrefReconnect);
|
|
if (DisconnectNotice != null || reconnectMessage != null) { savedNotice = null; savedReconnect = null; savedAttempts = -1; }
|
|
}
|
|
public void ClearDisconnectNotice() { DisconnectNotice = null; }
|
|
public bool IsReconnecting { get { return reconnectMessage != null && session == null; } }
|
|
public int ReconnectAttempt { get { return reconnectAttempts; } }
|
|
public int ReconnectMaxAttempts { get { return MaxReconnectAttempts; } }
|
|
|
|
/// <summary>Server, mit dem man gerade verbunden ist (dedizierter Server), sonst null.</summary>
|
|
public string CurrentServerHost { get { return session != null && dedicated ? lastServerHost : null; } }
|
|
public int CurrentServerPort { get { return lastServerPort; } }
|
|
public string CurrentServerName { get { return lastServerName; } }
|
|
|
|
/// <summary>
|
|
/// Schnell den Server wechseln: Favorit per Kurzname (oder host:port). Ist man gerade verbunden, wird wie beim
|
|
/// Neu-Verbinden nach einem Welt-Reset getrennt und danach automatisch mit dem neuen Server verbunden.
|
|
/// </summary>
|
|
public string SwitchServer(string target)
|
|
{
|
|
target = (target ?? "").Trim();
|
|
if (target.Length == 0) return "switch <nickname> (see 'fav')";
|
|
Favorites.Favorite fav = Favorites.Find(target);
|
|
string host, name;
|
|
int port;
|
|
if (fav != null) { host = fav.Host; port = fav.Port; name = string.IsNullOrEmpty(fav.ServerName) ? fav.Nickname : fav.ServerName; }
|
|
else if (ServerBrowser.TryParseAddress(target, out host, out port)) name = null;
|
|
else return "No favorite called '" + target + "' - type 'fav' to see yours";
|
|
|
|
if (session != null && dedicated && string.Equals(lastServerHost, host, System.StringComparison.OrdinalIgnoreCase) && lastServerPort == port)
|
|
return "You are already on " + (fav != null ? fav.Nickname : host + ":" + port);
|
|
|
|
string label = fav != null ? fav.Nickname : host + ":" + port;
|
|
if (session == null)
|
|
{
|
|
if (!MenuHelper.InMenu) return "Go to the main menu first (or join a server) to switch";
|
|
JoinServer(host, port, name);
|
|
return "Connecting to " + label + " ...";
|
|
}
|
|
|
|
// Verbunden: trennen und danach automatisch mit dem neuen Server verbinden (übersteht das Neuladen der Mods)
|
|
lastServerHost = host;
|
|
lastServerPort = port;
|
|
lastServerName = name;
|
|
lastServerMax = 0;
|
|
serverReason = null;
|
|
reconnectAttempts = 0;
|
|
reconnectMessage = "Switching to " + label + " ...";
|
|
centerMessage = reconnectMessage;
|
|
centerMessageUntil = Time.realtimeSinceStartup + 2f;
|
|
session.Close("Switching server");
|
|
if (MenuHelper.InGame) reconnectHoldUntil = Time.realtimeSinceStartup + 0.8f; // kurz die Meldung zeigen
|
|
else OnSessionEnded("Switching server");
|
|
return reconnectMessage;
|
|
}
|
|
|
|
/// <summary>Automatisches Neu-Verbinden abbrechen (Knopf im Reconnect-Fenster).</summary>
|
|
public void CancelReconnect()
|
|
{
|
|
reconnectMessage = null;
|
|
reconnectAttempts = 0;
|
|
SetStatus("Reconnect cancelled");
|
|
}
|
|
|
|
/// <summary>Überschrift für das Trennungs-Fenster.</summary>
|
|
public string DisconnectTitle
|
|
{
|
|
get
|
|
{
|
|
string r = (DisconnectNotice ?? "").ToLower();
|
|
if (r.Contains("banned")) return "BANNED";
|
|
if (r.Contains("kick")) return "KICKED";
|
|
if (r.Contains("shutting down") || r.Contains("server stopped") || r.Contains("shut down")) return "SERVER CLOSED";
|
|
if (r.Contains("could not reconnect")) return "RECONNECT FAILED";
|
|
return "DISCONNECTED";
|
|
}
|
|
}
|
|
|
|
bool worldSynced; // Server: Weltzustand angewendet
|
|
|
|
/// <summary>
|
|
/// Wirklich verbunden (grüner Punkt): Host sobald die Sitzung läuft; auf einem Server erst, wenn man im Spiel ist
|
|
/// und der Weltzustand angewendet wurde; bei P2P als Client, wenn man im Spiel ist. Vorher gelb.
|
|
/// </summary>
|
|
public bool IsFullyConnected
|
|
{
|
|
get
|
|
{
|
|
if (session == null) return false;
|
|
if (session.IsHost) return true;
|
|
if (!handshakeDone || !MenuHelper.InGame) return false;
|
|
return !dedicated || worldSynced;
|
|
}
|
|
}
|
|
public string ReconnectMessage { get { return reconnectMessage; } }
|
|
|
|
/// <summary>Technische Gründe in verständliche Sätze übersetzen.</summary>
|
|
static string FriendlyReason(string reason)
|
|
{
|
|
if (string.IsNullOrEmpty(reason)) return "The connection was closed.";
|
|
string r = reason.ToLower();
|
|
if (r.Contains("timed out") || r.Contains("timeout")) return "Connection lost - the server did not respond anymore (timeout).";
|
|
if (r.Contains("network error") || r.Contains("forcibly closed") || r.Contains("connection reset"))
|
|
return "Connection lost (" + reason + ").";
|
|
if (r == "connection closed" || r.Contains("closed")) return "The server closed the connection.";
|
|
if (r.Contains("host left")) return "The host left the game.";
|
|
return reason;
|
|
}
|
|
|
|
void OnSessionEnded(string reason)
|
|
{
|
|
// Verbindung zum Server unerwartet weg (Absturz, Timeout, Netz) - nicht gekickt/gebannt, nicht selbst
|
|
// getrennt: automatisch neu verbinden, als hätte der Server einen Neustart angekündigt
|
|
if (reconnectMessage == null && serverReason == null && reason != "Disconnected" && dedicated && handshakeDone
|
|
&& lastServerHost != null)
|
|
{
|
|
reconnectMessage = "Lost the connection to the server - reconnecting...";
|
|
reconnectAttempts = 0;
|
|
MscoLog.Warning("[MSCO] Connection lost (" + reason + ") - will reconnect");
|
|
}
|
|
// Hinweis fürs Hauptmenü - außer man hat selbst auf DISCONNECT gedrückt oder verbindet gleich neu
|
|
if (reconnectMessage != null)
|
|
{
|
|
DisconnectNotice = null;
|
|
reconnectAt = Time.realtimeSinceStartup + 3f; // erst ins Menü, dann neu verbinden
|
|
}
|
|
else if (reason != "Disconnected")
|
|
DisconnectNotice = serverReason ?? FriendlyReason(reason);
|
|
serverReason = null;
|
|
|
|
nextPresenceUpdate = 0f; // Discord sofort auf "In the Menus"/Singleplayer umstellen
|
|
bool wasClientInGame = session != null && !session.IsHost && MenuHelper.InGame;
|
|
|
|
if (session != null && session.IsSteam) steam.Leave();
|
|
session = null;
|
|
handshakeDone = false;
|
|
worldSynced = false;
|
|
pingMs = -1f;
|
|
teleportToHost = false;
|
|
dedicated = false;
|
|
connectingToServer = false;
|
|
readySent = false;
|
|
pendingSnapshot = null;
|
|
SteamLobby.CancelAuthTicket();
|
|
passenger.Exit(localPlayer, playerCamera); // Beifahrer: neben das Auto stellen
|
|
foreach (PlayerInfo p in players.Values) DestroyAvatar(p);
|
|
players.Clear();
|
|
objects.Reset();
|
|
vehicleFx.Reset(); wheelSpin.Reset();
|
|
worldSync.Reset();
|
|
moneySync.Reset();
|
|
doorSync.Reset();
|
|
vehicleState.Reset(); damageSync.Reset(); worldVars.Reset(); traffic.Reset(); hinges.Reset(); controls.Reset();
|
|
isAdmin = false;
|
|
if (overlays.AdminOpen || overlays.ChatOpen) overlays.CloseAll();
|
|
FsmSync.Enabled = false;
|
|
FsmSync.Reset();
|
|
if (receivingSave)
|
|
{
|
|
// Abbruch mitten in der Übertragung: eigenen Spielstand zurück
|
|
receivingSave = false;
|
|
TryRestoreBackup();
|
|
}
|
|
SetStatus(reason);
|
|
|
|
// Client ohne Host: zurück ins Hauptmenü (dort kommt auch der eigene Spielstand zurück)
|
|
if (wasClientInGame)
|
|
{
|
|
SetStatus("Disconnected from host: " + reason);
|
|
PersistPendingState(); // MSCLoader lädt die Mods im Menü neu - vorher sichern
|
|
Application.LoadLevel(MenuHelper.MenuScene);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Nachrichten
|
|
|
|
static bool IsGameMessage(MsgType t)
|
|
{
|
|
return t == MsgType.Player || t == MsgType.ObjStates || t == MsgType.ObjOwn
|
|
|| t == MsgType.ObjRelease || t == MsgType.FsmEvent || t == MsgType.VehicleFx || t == MsgType.WorldTime || t == MsgType.MoneyDelta || t == MsgType.DoorState
|
|
|| t == MsgType.Chat || t == MsgType.VarSync || t == MsgType.SpawnItem
|
|
|| t == MsgType.ItemAppeared || t == MsgType.ObjDestroy || t == MsgType.VehicleDamage
|
|
|| t == MsgType.TrafficStates || t == MsgType.WorldVars || t == MsgType.HingeStates
|
|
|| t == MsgType.VehicleControls;
|
|
}
|
|
|
|
void OnMessage(int peerId, Message m)
|
|
{
|
|
BinaryReader r = m.Reader();
|
|
|
|
// Ping-Messung: der Host schickt den Zeitstempel sofort zurück, der Client rechnet die Laufzeit aus
|
|
if (m.Type == MsgType.Ping)
|
|
{
|
|
if (session.IsHost && m.Payload.Length >= 4) session.SendTo(peerId, MsgType.Pong, m.Payload);
|
|
return;
|
|
}
|
|
if (m.Type == MsgType.Pong)
|
|
{
|
|
if (!session.IsHost && m.Payload.Length >= 4)
|
|
{
|
|
float rtt = (Time.realtimeSinceStartup - r.ReadSingle()) * 1000f;
|
|
if (rtt >= 0f && rtt < 60000f) pingMs = pingMs < 0f ? rtt : Mathf.Lerp(pingMs, rtt, 0.4f);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (IsGameMessage(m.Type))
|
|
{
|
|
int from;
|
|
if (session.IsHost)
|
|
{
|
|
if (!players.ContainsKey(peerId)) return; // Handshake noch nicht fertig
|
|
from = peerId;
|
|
// an alle anderen weiterleiten, mit Absender
|
|
session.SendToAll(m.Type, NetSession.WithSender(peerId, m.Payload), peerId);
|
|
}
|
|
else
|
|
{
|
|
from = r.ReadInt32();
|
|
}
|
|
HandleGameMessage(from, m.Type, r);
|
|
return;
|
|
}
|
|
|
|
if (session.IsHost) HandleHostMessage(peerId, m.Type, r);
|
|
else HandleClientMessage(m.Type, r);
|
|
}
|
|
|
|
void HandleHostMessage(int peerId, MsgType type, BinaryReader r)
|
|
{
|
|
if (type != MsgType.Hello || players.ContainsKey(peerId)) return;
|
|
|
|
string version = r.ReadString();
|
|
string name = r.ReadString();
|
|
if (version != MSCO.ModVersion)
|
|
{
|
|
Reject(peerId, "Version mismatch (host " + MSCO.ModVersion + ", you " + version + ")");
|
|
return;
|
|
}
|
|
if (bannedNames.Contains(name.ToLower()))
|
|
{
|
|
Reject(peerId, "You are banned from this game");
|
|
return;
|
|
}
|
|
if (players.Count >= MaxPlayers)
|
|
{
|
|
Reject(peerId, "Server is full");
|
|
return;
|
|
}
|
|
|
|
PlayerInfo joined = AddPlayer(peerId, name, false);
|
|
|
|
// Neuer Spieler bekommt seine ID und die Spielerliste, alle anderen erfahren von ihm
|
|
var list = new List<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);
|
|
ToastJoined(name);
|
|
|
|
if (MenuHelper.InGame)
|
|
{
|
|
// Host spielt schon: erst speichern, damit der Neue den aktuellen Stand bekommt
|
|
StartCoroutine(SaveThenSendLateJoin(joined));
|
|
SetStatus(name + " joined - saving and sending the game...");
|
|
}
|
|
else
|
|
{
|
|
SetStatus(name + " joined - press START GAME when everyone is here");
|
|
}
|
|
}
|
|
|
|
void Reject(int peerId, string reason)
|
|
{
|
|
session.SendTo(peerId, MsgType.Reject, NetSession.Build(w => w.Write(reason)));
|
|
session.Kick(peerId, reason);
|
|
SetStatus("Player rejected: " + reason);
|
|
}
|
|
|
|
void HandleClientMessage(MsgType type, BinaryReader r)
|
|
{
|
|
switch (type)
|
|
{
|
|
case MsgType.Welcome:
|
|
{
|
|
localId = r.ReadInt32();
|
|
dedicated = r.ReadBoolean();
|
|
objects.LocalId = localId;
|
|
players.Clear();
|
|
int count = r.ReadInt32();
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
int id = r.ReadInt32();
|
|
AddPlayer(id, r.ReadString(), id == localId);
|
|
}
|
|
handshakeDone = true;
|
|
reconnectMessage = null; // (wieder) verbunden
|
|
reconnectAttempts = 0;
|
|
SetStatus(dedicated ? "Connected to server - loading..." : "Connected - waiting for host to start...");
|
|
break;
|
|
}
|
|
|
|
case MsgType.Reject:
|
|
{
|
|
string why = r.ReadString();
|
|
serverReason = why; // für das Fenster im Hauptmenü (Kick, Ban, Server voll, Shutdown ...)
|
|
if (why.ToLower().Contains("banned")) reconnectMessage = null; // gebannt: nicht weiter versuchen
|
|
session.Close((dedicated || connectingToServer ? "Rejected by server: " : "Rejected by host: ") + why);
|
|
if (MenuHelper.InGame)
|
|
{
|
|
// Im Spiel: Grund erst groß anzeigen, dann ins Hauptmenü (dort kommt das Fenster mit OK)
|
|
centerMessage = why;
|
|
centerMessageUntil = Time.realtimeSinceStartup + 3f;
|
|
reconnectHoldUntil = Time.realtimeSinceStartup + 2.5f;
|
|
return;
|
|
}
|
|
OnSessionEnded(session.CloseReason);
|
|
break;
|
|
}
|
|
|
|
case MsgType.WorldSnapshot:
|
|
// Wird angewendet, sobald das Spiel geladen und alle Objekte erfasst sind
|
|
pendingSnapshot = r.ReadBytes((int)(r.BaseStream.Length - r.BaseStream.Position));
|
|
break;
|
|
|
|
case MsgType.PlayerJoined:
|
|
{
|
|
int id = r.ReadInt32();
|
|
string name = r.ReadString();
|
|
AddPlayer(id, name, false);
|
|
SetStatus(name + " joined");
|
|
ToastJoined(name);
|
|
break;
|
|
}
|
|
|
|
case MsgType.PlayerLeft:
|
|
{
|
|
int id = r.ReadInt32();
|
|
PlayerInfo p;
|
|
if (players.TryGetValue(id, out p)) { SetStatus(p.Name + " left"); ToastLeft(p.Name); }
|
|
RemovePlayer(id);
|
|
break;
|
|
}
|
|
|
|
case MsgType.SaveBegin:
|
|
if (!MenuHelper.InMenu)
|
|
{
|
|
SetStatus("Host started - go back to the main menu to join");
|
|
return;
|
|
}
|
|
SaveSync.BeginReceive();
|
|
receivingSave = true;
|
|
SetStatus(dedicated ? "Downloading server save..." : "Receiving host's save...");
|
|
break;
|
|
|
|
case MsgType.SaveFile:
|
|
if (!receivingSave) return;
|
|
string file = r.ReadString();
|
|
byte[] data = r.ReadBytes(r.ReadInt32());
|
|
SaveSync.WriteFile(file, data);
|
|
break;
|
|
|
|
case MsgType.Teleport:
|
|
{
|
|
// Ein Admin holt uns zu sich
|
|
var pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
|
|
if (!MenuHelper.InGame || localPlayer == null) break;
|
|
passenger.Exit(localPlayer, playerCamera);
|
|
Cheats.Teleport(localPlayer, pos);
|
|
overlays.AddChat("", "An admin teleported you", true);
|
|
break;
|
|
}
|
|
|
|
case MsgType.SaveRequest:
|
|
// Server möchte den aktuellen Spielstand (für neue Spieler / zum Sichern)
|
|
StartCoroutine(UploadSave(r.ReadInt32()));
|
|
break;
|
|
|
|
case MsgType.Reconnect:
|
|
{
|
|
// Server hat die Welt zurückgesetzt o.ä.: Meldung zeigen, ins Menü, dann automatisch neu verbinden
|
|
reconnectMessage = r.ReadString();
|
|
overlays.AddChat("", reconnectMessage, true);
|
|
// Groß in der Bildmitte zeigen und erst nach kurzer Zeit ins Menü wechseln - sonst sieht man nichts
|
|
centerMessage = reconnectMessage;
|
|
centerMessageUntil = Time.realtimeSinceStartup + 3f;
|
|
reconnectHoldUntil = Time.realtimeSinceStartup + 2.5f;
|
|
session.Close("Reconnecting");
|
|
return;
|
|
}
|
|
|
|
case MsgType.AdminStatus:
|
|
isAdmin = r.ReadBoolean();
|
|
if (isAdmin) overlays.Log("You are an admin on this server - type /admin in chat or 'admin' here.");
|
|
break;
|
|
|
|
case MsgType.AdminList:
|
|
{
|
|
var list = new List<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);
|
|
var banned = new List<string>();
|
|
if (r.BaseStream.Length - r.BaseStream.Position >= 4)
|
|
{
|
|
int bans = r.ReadInt32();
|
|
for (int i = 0; i < bans; i++) banned.Add(r.ReadString());
|
|
}
|
|
overlays.SetBanned(banned);
|
|
var backups = new List<string>();
|
|
if (r.BaseStream.Length - r.BaseStream.Position >= 4)
|
|
{
|
|
int n = r.ReadInt32();
|
|
for (int i = 0; i < n; i++) backups.Add(r.ReadString());
|
|
}
|
|
overlays.SetBackups(backups);
|
|
break;
|
|
}
|
|
|
|
case MsgType.AdminResult:
|
|
{
|
|
string result = r.ReadString();
|
|
overlays.Log(result);
|
|
overlays.AddChat("", result, true);
|
|
if (overlays.AdminOpen) RequestAdminList();
|
|
break;
|
|
}
|
|
|
|
case MsgType.StartGame:
|
|
if (!receivingSave) return;
|
|
receivingSave = false;
|
|
// Später beigetreten: nach dem Laden neben den Host setzen
|
|
teleportToHost = r.BaseStream.Length > 0 && r.ReadBoolean();
|
|
SetStatus(dedicated ? "Loading server world..." : "Loading host's game...");
|
|
MenuHelper.StartGame();
|
|
break;
|
|
}
|
|
}
|
|
|
|
void HandleGameMessage(int from, MsgType type, BinaryReader r)
|
|
{
|
|
if (from == localId) return;
|
|
if (type == MsgType.Chat)
|
|
{
|
|
string text = r.ReadString();
|
|
PlayerInfo sender;
|
|
if (dedicated && from == NetSession.HostId) overlays.AddChat("", text, true); // Server/Admin-Meldung
|
|
else if (text.StartsWith("* ")) overlays.AddChat("", text.Substring(2), true);
|
|
else overlays.AddChat(players.TryGetValue(from, out sender) ? sender.Name : "Player " + from, text);
|
|
return;
|
|
}
|
|
if (!MenuHelper.InGame) return;
|
|
switch (type)
|
|
{
|
|
case MsgType.Player:
|
|
ApplyRemoteState(from, PlayerState.Deserialize(r));
|
|
break;
|
|
case MsgType.ObjStates:
|
|
case MsgType.ObjOwn:
|
|
case MsgType.ObjRelease:
|
|
objects.Handle(from, type, r);
|
|
break;
|
|
case MsgType.FsmEvent:
|
|
{
|
|
FsmEventMsg msg = FsmEventMsg.Deserialize(r);
|
|
FsmSync.Apply(msg);
|
|
// Ein Teil wurde abgebaut: gleich als loses Objekt erfassen, damit es der Hand des Mitspielers
|
|
// folgt, statt Sekunden lang im Auto zu stecken (die Physik würde es samt Auto wegschleudern)
|
|
if (msg.EventName == "DISASSEMBLE" || msg.EventName == "REMOVE" || msg.EventName == "UNINSTALL") rescanSoon = true;
|
|
break;
|
|
}
|
|
case MsgType.WorldTime:
|
|
worldSync.Apply(r);
|
|
break;
|
|
case MsgType.MoneyDelta:
|
|
moneySync.Apply(r.ReadSingle());
|
|
break;
|
|
case MsgType.DoorState:
|
|
doorSync.Apply(r);
|
|
break;
|
|
case MsgType.VehicleFx:
|
|
vehicleFx.Handle(r);
|
|
break;
|
|
case MsgType.VarSync:
|
|
vehicleState.Handle(from, r, objects, localId);
|
|
break;
|
|
case MsgType.VehicleDamage:
|
|
damageSync.Handle(r);
|
|
break;
|
|
case MsgType.WorldVars:
|
|
worldVars.Handle(r);
|
|
break;
|
|
case MsgType.HingeStates:
|
|
hinges.Handle(r);
|
|
break;
|
|
case MsgType.VehicleControls:
|
|
controls.Handle(r, objects, localId);
|
|
break;
|
|
case MsgType.TrafficStates:
|
|
traffic.Handle(r, IsWorldAuthority);
|
|
break;
|
|
case MsgType.SpawnItem:
|
|
if (ApplySpawn(r)) rescanSoon = true; // neues Item gleich in den Objekt-Sync aufnehmen
|
|
break;
|
|
case MsgType.ItemAppeared:
|
|
if (ApplyAppeared(r)) rescanSoon = true;
|
|
break;
|
|
case MsgType.ObjDestroy:
|
|
objects.DestroyRemote(r.ReadInt32());
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <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()
|
|
{
|
|
if (newGamePending) return; // neues Spiel: es gibt noch keinen Spielstand - kommt nach dem ersten Speichern
|
|
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)));
|
|
}
|
|
MscoLog.Info("[MSCO] Sent save (" + files.Count + " files) to " + targets.Count + " player(s)");
|
|
}
|
|
|
|
bool menuButtonsHidden;
|
|
float nextMenuButtonCheck;
|
|
|
|
void OnLevelWasLoaded(int level)
|
|
{
|
|
menuButtonsHidden = false; // neues Hauptmenü: Knöpfe wieder ausblenden
|
|
nextPresenceUpdate = 0f; // Discord gleich nach dem Szenenwechsel aktualisieren
|
|
if (MenuHelper.InGame)
|
|
{
|
|
// Falls der CONTINUE-Klick nicht erkannt wurde: jetzt nachholen. Neues Spiel: erst speichern, dann senden
|
|
if (session != null && session.IsHost)
|
|
{
|
|
if (newGamePending) StartCoroutine(NewGameSaveAndSend());
|
|
else SendSaveToWaitingPlayers();
|
|
}
|
|
passenger.Reset();
|
|
localPlayer = null;
|
|
objects.OnGameLoaded();
|
|
vehicleState.Reset(); damageSync.Reset(); worldVars.Reset(); traffic.Reset(); hinges.Reset(); controls.Reset();
|
|
vehicleFx.Reset(); wheelSpin.Reset();
|
|
worldSync.Reset();
|
|
moneySync.Reset();
|
|
doorSync.Reset();
|
|
FsmSync.Reset();
|
|
readySent = false;
|
|
worldSynced = false;
|
|
if (session != null) SetStatus(session.IsHost ? "Hosting" : "Connected");
|
|
}
|
|
else if (MenuHelper.InMenu)
|
|
{
|
|
foreach (PlayerInfo p in players.Values)
|
|
{
|
|
p.SaveSent = false;
|
|
DestroyAvatar(p);
|
|
}
|
|
continueFsm = null;
|
|
newGamePending = false;
|
|
passenger.Reset();
|
|
localPlayer = null;
|
|
objects.Reset();
|
|
vehicleFx.Reset(); wheelSpin.Reset();
|
|
worldSync.Reset();
|
|
moneySync.Reset();
|
|
doorSync.Reset();
|
|
FsmSync.Reset();
|
|
// Zurück im Hauptmenü = Server verlassen
|
|
if (session != null && dedicated) Disconnect();
|
|
if (!receivingSave) TryRestoreBackup();
|
|
if (session != null && handshakeDone && !session.IsHost)
|
|
SetStatus("Connected - waiting for host to start...");
|
|
}
|
|
}
|
|
|
|
void TryRestoreBackup()
|
|
{
|
|
try { SaveSync.RestoreBackup(); }
|
|
catch (System.Exception e) { MscoLog.Error("[MSCO] Could not restore own save: " + e.Message); }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Im Spiel
|
|
|
|
void UpdateInGame()
|
|
{
|
|
if (localPlayer == null)
|
|
{
|
|
if (Time.realtimeSinceStartup < nextPlayerSearch) return;
|
|
nextPlayerSearch = Time.realtimeSinceStartup + 1f;
|
|
GameObject player = GameObject.Find("PLAYER");
|
|
if (player == null) return;
|
|
localPlayer = player.transform;
|
|
playerCamera = FindPlayerCamera(player);
|
|
objects.Head = playerCamera != null ? playerCamera.transform : null;
|
|
MscoLog.Info("[MSCO] Player camera: " + (playerCamera != null ? playerCamera.name : "none"));
|
|
}
|
|
|
|
if (teleportToHost) TryTeleportToHost();
|
|
if (rescanSoon && objects.ScanComplete)
|
|
{
|
|
rescanSoon = false;
|
|
objects.ScanNow();
|
|
}
|
|
|
|
// Dedizierter Server: nach dem ersten Objekt-Suchlauf den Weltzustand anfordern und anwenden
|
|
if (dedicated && handshakeDone && !readySent && objects.ScanComplete)
|
|
{
|
|
readySent = true;
|
|
session.SendTo(NetSession.HostId, MsgType.Ready);
|
|
SetStatus("Syncing world...");
|
|
}
|
|
if (pendingSnapshot != null && objects.ScanComplete)
|
|
{
|
|
byte[] snapshot = pendingSnapshot;
|
|
pendingSnapshot = null;
|
|
StartCoroutine(ApplyWorldSnapshot(snapshot));
|
|
}
|
|
|
|
string passengerResult = null;
|
|
if (MSCO.PassengerKey != null && !overlays.AnyOpen && MSCO.PassengerKey.GetKeybindDown())
|
|
passengerResult = passenger.Toggle(localPlayer, playerCamera, objects, players);
|
|
else if (passenger.IsRiding && !overlays.AnyOpen && Input.GetKeyDown(KeyCode.Space))
|
|
{
|
|
passenger.Exit(localPlayer, playerCamera); // aussteigen wie zu Fuß losspringen
|
|
passengerResult = "Got out";
|
|
}
|
|
else if (IsPeerReady && MenuHelper.InGame && localPlayer != null && !overlays.AnyOpen)
|
|
passengerResult = passenger.AutoUpdate(localPlayer, playerCamera, objects, players); // in die Beifahrertür treten
|
|
if (passengerResult != null)
|
|
{
|
|
SetStatus(passengerResult);
|
|
overlays.AddChat("", passengerResult, true); // im Spiel sichtbar, nicht nur in der Konsole
|
|
}
|
|
|
|
bool ready = IsPeerReady;
|
|
if (ready)
|
|
{
|
|
// Jedes Teilsystem einzeln abgesichert: ein Fehler in einem (z.B. an einem gerade zerstörten Objekt)
|
|
// soll nicht den ganzen Sync stoppen
|
|
sendTimer += Time.unscaledDeltaTime; // läuft auch im Pausenmenü (timeScale = 0) weiter
|
|
if (sendTimer >= SendInterval)
|
|
{
|
|
sendTimer = 0f;
|
|
PartStart();
|
|
try { SendGame(MsgType.Player, BuildLocalState().Serialize()); }
|
|
catch (System.Exception e) { ReportError("player state", e); }
|
|
PartEnd("player state");
|
|
}
|
|
// Eigene Bedürfnisse (Hunger, Durst ...) pro Spieler auf dem Server sichern
|
|
if (dedicated && worldSynced && Time.realtimeSinceStartup >= nextNeedsSend)
|
|
{
|
|
nextNeedsSend = Time.realtimeSinceStartup + NeedsInterval;
|
|
PartStart();
|
|
try
|
|
{
|
|
byte[] needs = PlayerNeeds.Build();
|
|
if (needs != null) session.SendTo(NetSession.HostId, MsgType.PlayerNeeds, needs);
|
|
}
|
|
catch (System.Exception e) { ReportError("player needs", e); }
|
|
PartEnd("player needs");
|
|
}
|
|
PartStart();
|
|
try { objects.Update(localPlayer, Time.deltaTime); }
|
|
catch (System.Exception e) { ReportError("object sync", e); }
|
|
PartStart();
|
|
PartEnd("object sync");
|
|
try { vehicleFx.Update(objects, SendGame, Time.deltaTime, IsWorldAuthority); }
|
|
catch (System.Exception e) { ReportError("vehicle lights/sound", e); }
|
|
PartStart();
|
|
PartEnd("vehicle lights/sound");
|
|
try { worldSync.Update(IsWorldAuthority, SendGame, Time.unscaledDeltaTime); }
|
|
catch (System.Exception e) { ReportError("time/weather sync", e); }
|
|
PartEnd("time/weather sync");
|
|
|
|
// Geld erst beobachten, wenn das Spiel fertig geladen ist
|
|
PartStart();
|
|
try
|
|
{
|
|
if (objects.ScanComplete) moneySync.Begin();
|
|
moneySync.Update(SendGame);
|
|
}
|
|
catch (System.Exception e) { ReportError("money sync", e); }
|
|
PartEnd("money sync");
|
|
|
|
// Türen, Tore, Hauben (z.B. Garagentor)
|
|
PartStart();
|
|
try { if (objects.ScanComplete) doorSync.Update(SendGame); }
|
|
catch (System.Exception e) { ReportError("door sync", e); }
|
|
PartEnd("door sync");
|
|
|
|
// Gesamtzustand der Fahrzeuge (Tank, Öl, Verschleiß ...)
|
|
PartStart();
|
|
try { if (objects.ScanComplete) vehicleState.Update(objects, localId, SendGame); }
|
|
catch (System.Exception e) { ReportError("vehicle state sync", e); }
|
|
PartEnd("vehicle state sync");
|
|
// Unfallschäden (verbeulte Karosserie)
|
|
PartStart();
|
|
try { if (objects.ScanComplete) damageSync.Update(objects, localId, SendGame); }
|
|
catch (System.Exception e) { ReportError("crash damage sync", e); }
|
|
PartEnd("crash damage sync");
|
|
// Zündung, Motor, Licht, Choke ... der Fahrzeuge
|
|
PartStart();
|
|
try
|
|
{
|
|
if (objects.ScanComplete && localPlayer != null) controls.Update(objects, localId, localPlayer.position, SendGame);
|
|
// P2P: der Host gleicht regelmäßig alle Schalter ab (später Beigetretene, verlorene Nachrichten)
|
|
if (objects.ScanComplete && IsHost && !dedicated && Time.time >= nextControlResync)
|
|
{
|
|
nextControlResync = Time.time + 15f;
|
|
controls.ResendAll(objects, localId, SendGame);
|
|
}
|
|
}
|
|
catch (System.Exception e) { ReportError("vehicle controls sync", e); }
|
|
PartEnd("vehicle controls sync");
|
|
// Türen, Motorhaube, Kofferraum der Fahrzeuge
|
|
PartStart();
|
|
try { if (objects.ScanComplete && localPlayer != null) hinges.Update(objects, localId, localPlayer.position, Time.deltaTime, SendGame); }
|
|
catch (System.Exception e) { ReportError("car door sync", e); }
|
|
PartEnd("car door sync");
|
|
// Jobs, Aufträge, Laden
|
|
PartStart();
|
|
try { if (objects.ScanComplete) worldVars.Update(SendGame); }
|
|
catch (System.Exception e) { ReportError("jobs/shop sync", e); }
|
|
PartEnd("jobs/shop sync");
|
|
// Verkehr: die Welt-Autorität simuliert, alle anderen fahren nach
|
|
PartStart();
|
|
try { if (objects.ScanComplete) traffic.Update(IsWorldAuthority, Time.deltaTime, SendGame); }
|
|
catch (System.Exception e) { ReportError("traffic sync", e); }
|
|
PartEnd("traffic sync");
|
|
}
|
|
|
|
foreach (PlayerInfo p in players.Values)
|
|
{
|
|
if (p.Avatar == null) continue;
|
|
PartStart();
|
|
try
|
|
{
|
|
// Sitzt der Mitspieler in einem Fahrzeug, die Figur an unsere Kopie dieses Fahrzeugs hängen
|
|
int seatId = p.Avatar.LastState.SeatId;
|
|
SyncedBody seat = seatId != 0 ? objects.Get(seatId) : null;
|
|
p.Avatar.Seat = seat != null && seat.Rb != null && seat.IsVehicle ? seat.Rb.transform : null;
|
|
p.Avatar.Tick(Time.unscaledDeltaTime);
|
|
|
|
// Gehaltenes Item an die Hand der Figur setzen
|
|
Vector3 pos;
|
|
Quaternion rot;
|
|
if (p.Avatar.TryGetHeldPose(out pos, out rot))
|
|
objects.ApplyAvatarHold(p.Id, p.Avatar.HeldId, pos, rot);
|
|
}
|
|
catch (System.Exception e) { ReportError("player figure", e); }
|
|
PartEnd("player figure");
|
|
}
|
|
|
|
// Abgefangene Schraub-/Montage-Aktionen verschicken
|
|
try
|
|
{
|
|
FsmSync.Enabled = ready;
|
|
FsmSync.Tick();
|
|
foreach (FsmEventMsg msg in FsmSync.Outbox) SendGame(MsgType.FsmEvent, msg.Serialize());
|
|
}
|
|
catch (System.Exception e) { ReportError("part/bolt sync", e); }
|
|
PartEnd("part/bolt sync");
|
|
FsmSync.Outbox.Clear();
|
|
}
|
|
|
|
readonly Dictionary<string, float> lastErrorReport = new Dictionary<string, float>();
|
|
|
|
/// <summary>Fehler eines Teilsystems melden (höchstens alle 30 s pro Teilsystem, damit das Log nicht vollläuft).</summary>
|
|
// ---------------------------------------------------------------- Hänger erkennen
|
|
// Jeder Sync-Teil wird gemessen. Dauert ein Bild sehr lange, steht in der F7-Konsole, ob MSCO schuld war
|
|
// (und welcher Teil) oder das Spiel selbst (z.B. beim Speichern).
|
|
readonly System.Diagnostics.Stopwatch partWatch = System.Diagnostics.Stopwatch.StartNew();
|
|
long partStart;
|
|
double frameMsco, slowestMs;
|
|
string slowestPart;
|
|
float nextHitchReport, lastSaveTriggered = -100f;
|
|
const float HitchSeconds = 0.2f;
|
|
|
|
void PartStart() { partStart = partWatch.ElapsedTicks; }
|
|
|
|
void PartEnd(string name)
|
|
{
|
|
double ms = (partWatch.ElapsedTicks - partStart) * 1000.0 / System.Diagnostics.Stopwatch.Frequency;
|
|
frameMsco += ms;
|
|
if (ms > slowestMs) { slowestMs = ms; slowestPart = name; }
|
|
}
|
|
|
|
/// <summary>Zu Beginn jedes Bilds: war das letzte Bild ein Hänger? Dann Ursache melden (höchstens alle 20 s).</summary>
|
|
void CheckHitch()
|
|
{
|
|
float dt = Time.unscaledDeltaTime;
|
|
if (dt > HitchSeconds && MenuHelper.InGame && Time.realtimeSinceStartup >= nextHitchReport)
|
|
{
|
|
nextHitchReport = Time.realtimeSinceStartup + 20f;
|
|
int total = Mathf.RoundToInt(dt * 1000f);
|
|
if (frameMsco > dt * 1000f * 0.4f)
|
|
MscoLog.Warning("[MSCO] Hitch " + total + " ms - caused by MSCO: " + slowestPart + " " + slowestMs.ToString("0") +
|
|
" ms (MSCO total " + frameMsco.ToString("0") + " ms)");
|
|
else
|
|
MscoLog.Info("[MSCO] Hitch " + total + " ms - not MSCO (MSCO used " + frameMsco.ToString("0") + " ms)" +
|
|
(Time.realtimeSinceStartup - lastSaveTriggered < 5f ? " - the game was saving" : " - the game itself (loading, saving, physics)"));
|
|
}
|
|
frameMsco = 0;
|
|
slowestMs = 0;
|
|
slowestPart = null;
|
|
}
|
|
|
|
void ReportError(string what, System.Exception e)
|
|
{
|
|
float now = Time.realtimeSinceStartup, last;
|
|
if (lastErrorReport.TryGetValue(what, out last) && now - last < 30f) return;
|
|
lastErrorReport[what] = now;
|
|
MscoLog.Error(what + " failed: " + e.Message);
|
|
Debug.Log("[MSCO] " + what + " failed: " + e); // voller Stacktrace nur in output_log.txt
|
|
}
|
|
|
|
void FixedUpdate()
|
|
{
|
|
if (!IsPeerReady || !MenuHelper.InGame) return;
|
|
PartStart();
|
|
try { objects.FixedUpdate(); }
|
|
catch (System.Exception e) { ReportError("object movement", e); }
|
|
PartEnd("object movement");
|
|
PartStart();
|
|
try { traffic.FixedUpdate(); }
|
|
catch (System.Exception e) { ReportError("traffic movement", e); }
|
|
PartEnd("traffic movement");
|
|
PartStart();
|
|
try { hinges.FixedUpdate(); }
|
|
catch (System.Exception e) { ReportError("car door movement", e); }
|
|
PartEnd("car door movement");
|
|
}
|
|
|
|
void LateUpdate()
|
|
{
|
|
InputLock.KeepCursorFree(); // das Spiel sperrt den Zeiger im selben Frame evtl. wieder
|
|
// Nach den Spiel-FSMs, damit Lichter/Motorgeräusch der Fahrzeuge anderer gewinnen
|
|
PartStart();
|
|
try { if (IsPeerReady && MenuHelper.InGame) vehicleFx.Apply(objects); }
|
|
catch (System.Exception e) { ReportError("vehicle lights/sound", e); }
|
|
PartEnd("vehicle lights/sound");
|
|
PartStart();
|
|
try { if (IsPeerReady && MenuHelper.InGame) wheelSpin.Apply(objects, Time.deltaTime); }
|
|
catch (System.Exception e) { ReportError("wheels", e); }
|
|
PartEnd("wheels");
|
|
// Beifahrer nach der Fahrzeugbewegung auf den Sitz setzen
|
|
PartStart();
|
|
try { if (localPlayer != null && MenuHelper.InGame) passenger.LateUpdate(localPlayer, playerCamera, objects); }
|
|
catch (System.Exception e) { ReportError("passenger", e); }
|
|
PartEnd("passenger");
|
|
}
|
|
|
|
/// <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())
|
|
{
|
|
// Letzte eigene Position auf dem Server - nur wenn sie plausibel ist, und über den Boden gehoben
|
|
var spawn = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
|
|
bool valid = !float.IsNaN(spawn.x) && !float.IsInfinity(spawn.x) && !float.IsNaN(spawn.y) && !float.IsNaN(spawn.z)
|
|
&& Mathf.Abs(spawn.x) < 6000f && Mathf.Abs(spawn.z) < 6000f && spawn.y > -200f && spawn.y < 3000f;
|
|
if (localPlayer != null && valid)
|
|
{
|
|
RaycastHit hit;
|
|
if (Physics.Raycast(spawn + Vector3.up * 2f, Vector3.down, out hit, 6f)) spawn.y = Mathf.Max(spawn.y, hit.point.y);
|
|
localPlayer.position = spawn + Vector3.up * 0.2f;
|
|
MscoLog.Info("[MSCO] Back at your last position " + spawn.ToString("F0"));
|
|
}
|
|
}
|
|
|
|
// Positionen erst merken: sie werden NACH den Montage-Aktionen gesetzt. Sonst steckt ein Teil, das eine
|
|
// nachgespielte Aktion vom Auto löst, noch im Auto - die Physik schleudert es dann samt Auto weg.
|
|
int objectCount = r.ReadInt32();
|
|
var poses = new List<KeyValuePair<int, Pose>>(objectCount);
|
|
for (int i = 0; i < objectCount; i++)
|
|
{
|
|
int id = r.ReadInt32();
|
|
var pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
|
|
var rot = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
|
|
poses.Add(new KeyValuePair<int, Pose>(id, new Pose { Position = pos, Rotation = rot }));
|
|
}
|
|
|
|
int eventCount = r.ReadInt32();
|
|
for (int i = 0; i < eventCount; i++)
|
|
{
|
|
byte[] payload = r.ReadBytes(r.ReadInt32());
|
|
FsmSync.Apply(FsmEventMsg.Deserialize(new BinaryReader(new MemoryStream(payload))));
|
|
if ((i + 1) % SnapshotEventsPerFrame == 0) yield return null; // nicht alles in einem Frame
|
|
}
|
|
|
|
// Geld: Summe aller Änderungen seit dem Spielstand des Servers
|
|
if (r.BaseStream.Length - r.BaseStream.Position >= 4) moneySync.Apply(r.ReadSingle());
|
|
|
|
// Türen/Tore: letzter Zustand auf dem Server (z.B. Garagentor offen)
|
|
if (r.BaseStream.Length - r.BaseStream.Position >= 4)
|
|
{
|
|
int doors = r.ReadInt32();
|
|
for (int i = 0; i < doors; i++) doorSync.ApplyState(r.ReadString(), r.ReadString());
|
|
}
|
|
|
|
// Fahrzeugzustand (Tank, Öl, Schrauben ...)
|
|
if (r.BaseStream.Length - r.BaseStream.Position >= 4) vehicleState.ApplySnapshot(r);
|
|
|
|
// Von Admins gespawnte Items (in der Reihenfolge des Servers, damit alle sie gleich benennen)
|
|
int spawned = 0;
|
|
if (r.BaseStream.Length - r.BaseStream.Position >= 4)
|
|
{
|
|
int spawns = r.ReadInt32();
|
|
for (int i = 0; i < spawns; i++)
|
|
{
|
|
byte[] payload = r.ReadBytes(r.ReadInt32());
|
|
try { if (ApplySpawn(new BinaryReader(new MemoryStream(payload)))) spawned++; }
|
|
catch (System.Exception e) { MscoLog.Warning("Could not spawn a saved item: " + e.Message); }
|
|
}
|
|
}
|
|
|
|
// Seit dem letzten Spielstand gekaufte bzw. verbrauchte Items
|
|
var destroyedIds = new List<int>();
|
|
if (r.BaseStream.Length - r.BaseStream.Position >= 4)
|
|
{
|
|
int appeared = r.ReadInt32();
|
|
for (int i = 0; i < appeared; i++)
|
|
{
|
|
byte[] payload = r.ReadBytes(r.ReadInt32());
|
|
try { ApplyAppeared(new BinaryReader(new MemoryStream(payload))); }
|
|
catch (System.Exception e) { MscoLog.Warning("Could not restore a bought item: " + e.Message); }
|
|
}
|
|
}
|
|
if (r.BaseStream.Length - r.BaseStream.Position >= 4)
|
|
{
|
|
int destroyed = r.ReadInt32();
|
|
for (int i = 0; i < destroyed; i++) destroyedIds.Add(r.ReadInt32());
|
|
}
|
|
// Eigene Bedürfnisse (Hunger, Durst ...) - statt der Werte aus dem gemeinsamen Spielstand
|
|
if (r.BaseStream.Length - r.BaseStream.Position >= 1 && r.ReadBoolean())
|
|
{
|
|
int needs = PlayerNeeds.Apply(r);
|
|
if (needs > 0) MscoLog.Info("[MSCO] Your own hunger/thirst/... restored (" + needs + " values)");
|
|
}
|
|
// Jobs/Laden und Schalter (Licht, Zündung, Motor ...) - Server ab 2.9.2
|
|
if (r.BaseStream.Length - r.BaseStream.Position >= 4) worldVars.Handle(r);
|
|
if (r.BaseStream.Length - r.BaseStream.Position >= 4) controls.Handle(r, objects, localId);
|
|
|
|
// Abgebaute Teile und gespawnte Items sind jetzt eigene Objekte: erfassen, dann alle Positionen setzen
|
|
yield return null;
|
|
objects.ScanNow();
|
|
foreach (int id in destroyedIds) objects.DestroyRemote(id);
|
|
int applied = 0;
|
|
foreach (KeyValuePair<int, Pose> p in poses)
|
|
if (objects.ApplySnapshotPose(p.Key, p.Value.Position, p.Value.Rotation)) applied++;
|
|
if (spawned > 0) MscoLog.Info(spawned + " spawned item(s) restored");
|
|
|
|
worldSynced = true;
|
|
MscoLog.Info("[MSCO] World synced: " + applied + "/" + objectCount + " objects, " + eventCount + " part/bolt actions");
|
|
SetStatus("Connected");
|
|
}
|
|
|
|
struct Pose
|
|
{
|
|
public Vector3 Position;
|
|
public Quaternion Rotation;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Im Spiel speichern (wie auf der Toilette) und den Spielstand an den Server schicken. So bekommen neue Spieler
|
|
/// genau die aktuelle Welt - Schrauben, Teile, alles - statt eines alten Stands mit nachgespielten Aktionen.
|
|
/// Der eigene Spielstand ist davon nicht betroffen: er liegt gesichert im Backup und kommt im Hauptmenü zurück.
|
|
/// </summary>
|
|
System.Collections.IEnumerator UploadSave(int requestId)
|
|
{
|
|
if (!MenuHelper.InGame || !dedicated || session == null) yield break;
|
|
lastSaveTriggered = Time.realtimeSinceStartup;
|
|
PlayMakerFSM.BroadcastEvent("SAVEGAME");
|
|
float until = Time.realtimeSinceStartup + 3f; // das Spiel schreibt die Dateien über mehrere Frames
|
|
while (Time.realtimeSinceStartup < until) yield return null;
|
|
if (session == null) yield break;
|
|
|
|
List<KeyValuePair<string, byte[]>> files;
|
|
try { files = SaveSync.ReadLocalSave(); }
|
|
catch (System.Exception e) { MscoLog.Warning("Could not read the save game for the server: " + e.Message); yield break; }
|
|
|
|
const int Chunk = 256 * 1024; // unter der Paketgrenze des Servers
|
|
foreach (KeyValuePair<string, byte[]> f in files)
|
|
{
|
|
for (int offset = 0; offset < f.Value.Length || offset == 0; offset += Chunk)
|
|
{
|
|
if (session == null) yield break;
|
|
int start = offset, length = Mathf.Min(Chunk, f.Value.Length - offset);
|
|
string name = f.Key;
|
|
byte[] data = f.Value;
|
|
session.SendTo(NetSession.HostId, MsgType.SaveUploadPart, NetSession.Build(w =>
|
|
{
|
|
w.Write(requestId); w.Write(name); w.Write(data.Length); w.Write(start); w.Write(length);
|
|
w.Write(data, start, length);
|
|
}));
|
|
yield return null;
|
|
if (data.Length == 0) break;
|
|
}
|
|
}
|
|
int count = files.Count;
|
|
session.SendTo(NetSession.HostId, MsgType.SaveUploadDone, NetSession.Build(w => { w.Write(requestId); w.Write(count); }));
|
|
MscoLog.Info("Sent the current save game to the server (" + count + " files)");
|
|
}
|
|
|
|
/// <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,
|
|
SeatView = Quaternion.identity
|
|
};
|
|
|
|
// Im Fahrzeug: Kopf und Blick relativ zum Fahrzeug mitschicken
|
|
SyncedBody seat = passenger.IsRiding ? passenger.Vehicle : objects.Driven;
|
|
if (seat != null && seat.Rb != null)
|
|
{
|
|
Transform vt = seat.Rb.transform;
|
|
state.SeatId = seat.Id;
|
|
// Beifahrer: der Sitz ist bekannt - die Weltposition stammt noch vom letzten Frame, das Auto ist seitdem
|
|
// weitergefahren (bei Tempo ein halber Meter)
|
|
state.SeatHead = passenger.IsRiding ? passenger.SeatHeadLocal : vt.InverseTransformPoint(head.position);
|
|
state.SeatView = Quaternion.Inverse(vt.rotation) * head.rotation;
|
|
}
|
|
|
|
SyncedBody held = objects.Held;
|
|
if (held != null)
|
|
{
|
|
Transform item = held.Rb.transform;
|
|
state.HeldId = held.Id;
|
|
state.HeldLocalPos = head.InverseTransformPoint(item.position);
|
|
state.HeldLocalRot = Quaternion.Inverse(head.rotation) * item.rotation;
|
|
}
|
|
return state;
|
|
}
|
|
|
|
void ApplyRemoteState(int from, PlayerState state)
|
|
{
|
|
PlayerInfo p;
|
|
if (!players.TryGetValue(from, out p)) return;
|
|
if (p.Avatar == null)
|
|
{
|
|
// Echter MSC-Mensch (oder die eigene MSCO-Figur, wenn keiner passt / "model classic")
|
|
p.Avatar = RemotePlayer.Create("MSCO_Player_" + from, PlayerColor(from), state, from);
|
|
MscoLog.Info("[MSCO] " + p.Name + " is in the game (" + p.Avatar.ModelDescription + ")");
|
|
}
|
|
p.Avatar.SetTarget(state);
|
|
}
|
|
|
|
static Color PlayerColor(int id)
|
|
{
|
|
// Gedeckte Farben wie Flanellhemden/Arbeitsjacken in MSC
|
|
Color[] colors =
|
|
{
|
|
new Color(0.6f, 0.13f, 0.12f), new Color(0.16f, 0.25f, 0.5f), new Color(0.2f, 0.38f, 0.2f), new Color(0.7f, 0.52f, 0.14f),
|
|
new Color(0.42f, 0.28f, 0.17f), new Color(0.33f, 0.36f, 0.4f), new Color(0.16f, 0.36f, 0.38f), new Color(0.38f, 0.4f, 0.16f)
|
|
};
|
|
return colors[id % colors.Length];
|
|
}
|
|
|
|
void DestroyAvatar(PlayerInfo p)
|
|
{
|
|
if (p.Avatar != null) Destroy(p.Avatar.gameObject);
|
|
p.Avatar = null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- GUI
|
|
|
|
void OnGUI()
|
|
{
|
|
InputLock.KeepCursorFree();
|
|
if (MenuHelper.InMenu)
|
|
{
|
|
lobby.Draw(this);
|
|
pingWidth = 0f; DrawPing(); DrawFps(Screen.width - 12 - pingWidth); toasts.Draw();
|
|
overlays.Draw(this, false);
|
|
return;
|
|
}
|
|
EnsureHudStyles();
|
|
|
|
if (Time.realtimeSinceStartup < savingMessageUntil)
|
|
GUI.Label(new Rect(0, Screen.height / 2f - 40, Screen.width, 80), "SAVING GAME...", savingStyle);
|
|
if (centerMessage != null && Time.realtimeSinceStartup < centerMessageUntil)
|
|
{
|
|
int oldSize = savingStyle.fontSize;
|
|
savingStyle.fontSize = 30;
|
|
GUI.Label(new Rect(0, Screen.height / 2f - 40, Screen.width, 80), centerMessage.ToUpper(), savingStyle);
|
|
savingStyle.fontSize = oldSize;
|
|
}
|
|
|
|
if (session != null) DrawHud();
|
|
pingWidth = 0f; DrawPing(); DrawFps(Screen.width - 12 - pingWidth); toasts.Draw();
|
|
overlays.Draw(this, session != null);
|
|
}
|
|
|
|
/// <summary>F7 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 = IsFullyConnected ? "CONNECTED" : IsPeerReady ? "SYNCING..." : "WAITING";
|
|
string text = "MSCO • " + state + " • " + (dedicated ? "Server" : IsHost ? "Host" : "Client") + " • " +
|
|
players.Count + " player(s) • " + objects.Count + " synced objects";
|
|
var rect = new Rect((Screen.width - 520) / 2f, 6, 520, 24);
|
|
if (hudBox == null) hudBox = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.85f), 8);
|
|
GUI.Box(rect, GUIContent.none, hudBox);
|
|
hudStyle.normal.textColor = IsFullyConnected ? new Color(0.24f, 0.84f, 0.55f) : new Color(0.95f, 0.76f, 0.11f);
|
|
GUI.Label(rect, text, hudStyle);
|
|
|
|
DrawNameTags();
|
|
}
|
|
|
|
void EnsureHudStyles()
|
|
{
|
|
if (hudStyle != null && hudBackground != null) return;
|
|
hudBackground = new Texture2D(1, 1);
|
|
hudBackground.SetPixel(0, 0, new Color(0.06f, 0.06f, 0.07f, 0.85f));
|
|
hudBackground.Apply();
|
|
hudBackground.hideFlags = HideFlags.HideAndDontSave;
|
|
|
|
hudStyle = new GUIStyle(GUI.skin.label);
|
|
hudStyle.alignment = TextAnchor.MiddleCenter;
|
|
hudStyle.fontStyle = FontStyle.Bold;
|
|
hudStyle.fontSize = 12;
|
|
|
|
savingStyle = new GUIStyle(GUI.skin.label);
|
|
savingStyle.alignment = TextAnchor.MiddleCenter;
|
|
savingStyle.fontStyle = FontStyle.Bold;
|
|
savingStyle.fontSize = 42;
|
|
savingStyle.normal.textColor = Color.white;
|
|
}
|
|
|
|
void DrawNameTags()
|
|
{
|
|
Camera cam = playerCamera != null ? playerCamera : Camera.main;
|
|
if (cam == null) return;
|
|
if (nameTagStyle == null)
|
|
{
|
|
nameTagStyle = new GUIStyle(GUI.skin.label);
|
|
nameTagStyle.alignment = TextAnchor.MiddleCenter;
|
|
nameTagStyle.fontStyle = FontStyle.Bold;
|
|
nameTagStyle.normal.textColor = Color.white;
|
|
}
|
|
foreach (PlayerInfo p in players.Values)
|
|
{
|
|
if (p.Avatar == null) continue;
|
|
Vector3 screen = cam.WorldToScreenPoint(p.Avatar.HeadPosition + Vector3.up * 0.35f);
|
|
// Hinter der Kamera oder zu weit weg: kein Namensschild; kurz vorher ausblenden
|
|
if (screen.z <= 0f || screen.z > NameTagHideDistance) continue;
|
|
float alpha = Mathf.Clamp01((NameTagHideDistance - screen.z) / NameTagFadeRange);
|
|
GUI.color = new Color(1f, 1f, 1f, alpha);
|
|
GUI.Label(new Rect(screen.x - 100, Screen.height - screen.y - 12, 200, 24), p.Name, nameTagStyle);
|
|
}
|
|
GUI.color = Color.white;
|
|
}
|
|
|
|
void SetStatus(string text)
|
|
{
|
|
status = text;
|
|
if (!string.IsNullOrEmpty(text)) MscoLog.Info(text);
|
|
}
|
|
}
|
|
}
|