Verbunden wird nur beim Start ueber die Lobby, bricht die Verbindung ab alle 10 s neu (nicht nach Ablehnung oder Konto-Problem). release.ps1 legt jetzt Gitea-Releases mit bso-release.json an. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
302 lines
12 KiB
C#
302 lines
12 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Stand fuer dieses Bild (zwischen den 10 Paketen pro Sekunde weich weiterbewegt).</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Winkel (Grad) auf dem kuerzeren Weg mischen - sonst dreht sich der Bus bei 359 -> 1 einmal ganz herum.</summary>
|
|
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; }
|
|
/// <summary>Warum offline (letzter Fehler / Trennung) - fuer die Anzeige.</summary>
|
|
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<int, Remote> 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;
|
|
/// <summary>Fuer die Anzeige: "Lobby von ..." statt relay:<id>.</summary>
|
|
public static string ServerDisplay => Config.Current.ServerLabel ?? (ServerAddress.StartsWith("relay:") ? "Lobby" : ServerAddress);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Name im Spiel: aus dem Konto (Ticket), sonst aus BSO.json.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Ist der Server schon angekommen (Welcome)?</summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|