using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using BSO.Net; namespace BSO { /// /// Verbindung zum BSO-Server: automatisch beim Start ueber die Lobby (bricht sie ab, alle 10 s neu), Server und Name stehen in BSO.json. Laeuft im /// Hauptthread: Verbindungsaufbau wird nur abgefragt (nicht blockierend), empfangen wird ueber Connection.Poll(). /// Schickt den eigenen Zustand 10x pro Sekunde, alle 2 s einen Ping. /// public static class Multiplayer { public enum NetStatus { Offline, Connecting, Connected } public class Remote { public int Id; public string Name; public PlayerState State; // zuletzt empfangen public bool HasState; // Weiche Bewegung: vom angezeigten Stand zum neuen Paket gleiten, so lange wie seit dem letzten Paket vergangen ist PlayerState from, to; long startTicks, durationTicks = 1; public void Receive(PlayerState s) { long now = Stopwatch.GetTimestamp(); from = HasState ? Display : s; to = s; long since = now - startTicks; durationTicks = Math.Clamp(since, Stopwatch.Frequency / 20, Stopwatch.Frequency / 4); // 50..250 ms startTicks = now; State = s; HasState = true; } /// Stand fuer dieses Bild (zwischen den 10 Paketen pro Sekunde weich weiterbewegt). public PlayerState Display { get { if (from.Mode != to.Mode) return to; // Ein-/Aussteigen: nicht gleiten float t = Math.Clamp((Stopwatch.GetTimestamp() - startTicks) / (float)durationTicks, 0, 1); PlayerState s = to; s.X = from.X + (to.X - from.X) * t; s.Y = from.Y + (to.Y - from.Y) * t; s.Z = from.Z + (to.Z - from.Z) * t; s.Yaw = Angle(from.Yaw, to.Yaw, t); s.Pitch = Angle(from.Pitch, to.Pitch, t); s.Roll = Angle(from.Roll, to.Roll, t); return s; } } /// Winkel (Grad) auf dem kuerzeren Weg mischen - sonst dreht sich der Bus bei 359 -> 1 einmal ganz herum. static float Angle(float a, float b, float t) { float d = ((b - a) % 360 + 540) % 360 - 180; return a + d * t; } } const int ConnectTimeoutMs = 5000, RelayTimeoutMs = 20000; // Relay: Website + Antwort des Lobby-Servers const int SendIntervalMs = 100, PingIntervalMs = 2000; public static NetStatus Status { get; private set; } /// Warum offline (letzter Fehler / Trennung) - fuer die Anzeige. public static string LastError { get; private set; } = ""; public static string ServerName { get; private set; } = ""; public static int MyId { get; private set; } public static int PingMs { get; private set; } = -1; public static readonly Dictionary Players = new(); static TcpClient tcp; static Task connectTask; static Connection conn; static readonly Stopwatch connectClock = new(), sendClock = new(), pingClock = new(); static readonly Stopwatch retryClock = new(); static bool autoConnectDone, rejected; const int RetryMs = 10000; public static void Update() { // Verbunden wird nur automatisch (Start ueber die Lobby im Launcher). Bricht die Verbindung ab, alle 10 s neu - // ausser der Server hat abgelehnt oder das Konto passt nicht (dann hilft nur neu SPIELEN im Launcher). if (Config.Current.AutoConnect && LocalPlayer.InScene && Status == NetStatus.Offline && !rejected && (!autoConnectDone || retryClock.ElapsedMilliseconds >= RetryMs)) { autoConnectDone = true; retryClock.Restart(); if (AccountProblem == null) Connect(); } if (Status == NetStatus.Connecting) PollConnect(); if (Status != NetStatus.Connected) return; foreach (Message m in conn.Poll()) { try { Handle(m); } catch (Exception e) { Bootstrap.Log($"Nachricht {m.Type} fehlerhaft: {e.Message}"); } if (Status != NetStatus.Connected) return; // z.B. Reject } if (conn.IsClosed) { Disconnect(conn.CloseReason); return; } if (sendClock.ElapsedMilliseconds >= SendIntervalMs) { sendClock.Restart(); PlayerState s = LocalPlayer.State; conn.Send(MsgType.PlayerState, Wire.Build(w => s.Write(w))); } if (pingClock.ElapsedMilliseconds >= PingIntervalMs) { pingClock.Restart(); conn.Send(MsgType.Ping, Wire.Build(w => w.Write(Stopwatch.GetTimestamp()))); } } public static string ServerAddress => Config.Current.Server; /// Fuer die Anzeige: "Lobby von ..." statt relay:<id>. public static string ServerDisplay => Config.Current.ServerLabel ?? (ServerAddress.StartsWith("relay:") ? "Lobby" : ServerAddress); /// /// Gueltiges Spiel-Ticket vom Launcher? Ohne gibt es keinen Multiplayer - der Server prueft es zusaetzlich (die Mod /// allein liesse sich austricksen), hier gibt es nur die verstaendliche Meldung vorab. /// public static string AccountProblem { get { var t = Ticket.Read(Config.Current.Ticket); if (t == null) return "Nicht ueber den BSO Launcher gestartet - Multiplayer gesperrt"; if (t.Expired) return "Spiel-Ticket abgelaufen - im BSO Launcher neu auf SPIELEN druecken"; return null; } } /// Name im Spiel: aus dem Konto (Ticket), sonst aus BSO.json. public static string PlayerName => Ticket.Read(Config.Current.Ticket)?.Username ?? Config.Current.Name; static void Connect() { if (AccountProblem is string problem) { LastError = problem; Bootstrap.Log("Nicht verbunden: " + problem); return; } LastError = ""; tcp = new TcpClient(); if (ServerAddress.StartsWith("relay:")) { // Server der Lobby: laeuft im BSO-Relay, verbunden ueber eine lokale Bruecke - keine Portfreigabe noetig string lobby = ServerAddress.Substring(6), relay = Config.Current.Relay, ticket = Config.Current.Ticket; Bootstrap.Log($"Verbinde ueber {relay} mit Lobby {lobby} ..."); TcpClient client = tcp; connectTask = Task.Run(async () => { int port = await RelayClient.JoinAsync(relay, lobby, ticket, CancellationToken.None); await client.ConnectAsync("127.0.0.1", port); }); } else { (string host, int port) = ParseAddress(ServerAddress); Bootstrap.Log($"Verbinde mit {host}:{port} ..."); connectTask = tcp.ConnectAsync(host, port); } connectClock.Restart(); Status = NetStatus.Connecting; } static void PollConnect() { if (!connectTask.IsCompleted) { if (connectClock.ElapsedMilliseconds > (ServerAddress.StartsWith("relay:") ? RelayTimeoutMs : ConnectTimeoutMs)) Fail("Server antwortet nicht"); return; } if (connectTask.IsFaulted || !tcp.Connected) { Fail(connectTask.Exception?.GetBaseException().Message ?? "Verbindung fehlgeschlagen"); return; } conn = new Connection(tcp); conn.Send(MsgType.Hello, Wire.Build(w => { w.Write(Protocol.Version); w.Write(Bootstrap.ModVersion); w.Write(PlayerName ?? ""); w.Write(Config.Current.Ticket ?? ""); })); // Erst nach Welcome gilt man als verbunden - bis dahin bleibt der Status "Verbinde" Status = NetStatus.Connected; sendClock.Restart(); pingClock.Restart(); ServerName = ""; } static void Fail(string reason) { try { tcp?.Close(); } catch { } Status = NetStatus.Offline; LastError = reason; Bootstrap.Log("Verbindung fehlgeschlagen: " + reason); } static void Handle(Message m) { BinaryReader r = m.Reader(); switch (m.Type) { case MsgType.Welcome: MyId = r.ReadInt32(); ServerName = r.ReadString(); Players.Clear(); int n = r.ReadInt32(); for (int i = 0; i < n; i++) { var p = new Remote { Id = r.ReadInt32(), Name = r.ReadString() }; Players[p.Id] = p; } Bootstrap.Log($"Verbunden mit \"{ServerName}\" als #{MyId}, {n} andere Spieler"); break; case MsgType.Reject: rejected = true; // kein automatisches Neu-Verbinden Disconnect("Abgelehnt: " + r.ReadString()); break; case MsgType.PlayerJoined: var joined = new Remote { Id = r.ReadInt32(), Name = r.ReadString() }; Players[joined.Id] = joined; Bootstrap.Log($"{joined.Name} ist beigetreten"); break; case MsgType.PlayerLeft: int left = r.ReadInt32(); if (Players.Remove(left, out Remote gone)) Bootstrap.Log($"{gone.Name} ist gegangen"); break; case MsgType.PlayerState: int id = r.ReadInt32(); if (!Players.TryGetValue(id, out Remote p2)) return; // PlayerJoined kommt immer vorher p2.Receive(PlayerState.Read(r)); break; case MsgType.Pong: PingMs = (int)Stopwatch.GetElapsedTime(r.ReadInt64()).TotalMilliseconds; break; } } public static void Disconnect(string reason) { if (Status == NetStatus.Offline) return; conn?.Close(reason); conn = null; try { tcp?.Close(); } catch { } Status = NetStatus.Offline; LastError = reason; Players.Clear(); PingMs = -1; retryClock.Restart(); Bootstrap.Log("Getrennt: " + reason); } /// Ist der Server schon angekommen (Welcome)? public static bool Welcomed => Status == NetStatus.Connected && ServerName.Length > 0; static (string, int) ParseAddress(string address) { address = (address ?? "").Trim(); // IPv6: [::1]:7777 oder [::1] if (address.StartsWith("[")) { int end = address.IndexOf(']'); if (end > 1) { string host6 = address.Substring(1, end - 1); return address.Length > end + 2 && address[end + 1] == ':' && int.TryParse(address.Substring(end + 2), out int p6) ? (host6, p6) : (host6, Protocol.DefaultPort); } } int colon = address.LastIndexOf(':'); if (colon > 0 && int.TryParse(address.Substring(colon + 1), out int port)) return (address.Substring(0, colon), port); return (address.Length > 0 ? address : "127.0.0.1", Protocol.DefaultPort); } } }