0.5.0: Lobby ueber das BSO-Relay (ohne Portfreigabe)

- Server relay:<Lobby> verbindet per WebSocket ueber eine lokale Bruecke (RelayClient)
- Ticket mit Lobby, Anzeige 'Lobby von ...'
- Nach Szenenwechsel Mitspieler-Modelle erst nach 3 s neu anlegen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 19:30:20 +01:00
co-authored by Claude Opus 5.5
parent 1dca505e3c
commit 8a1329f5fa
9 changed files with 170 additions and 21 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<RootNamespace>BSO</RootNamespace> <RootNamespace>BSO</RootNamespace>
<AssemblyName>BSO</AssemblyName> <AssemblyName>BSO</AssemblyName>
<Version>0.4.0</Version> <Version>0.5.0</Version>
<PlatformTarget>x64</PlatformTarget> <PlatformTarget>x64</PlatformTarget>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>disable</Nullable> <Nullable>disable</Nullable>
+1 -1
View File
@@ -19,7 +19,7 @@ namespace BSO
/// </summary> /// </summary>
public static unsafe class Bootstrap public static unsafe class Bootstrap
{ {
public const string ModVersion = "0.4.0"; public const string ModVersion = "0.5.0";
static HostApi* host; static HostApi* host;
static int frameErrors; static int frameErrors;
+5 -1
View File
@@ -3,10 +3,14 @@
Neue Version: hier einen Abschnitt `## <Version>` anlegen, `<Version>` in `BSO.csproj` und `Bootstrap.ModVersion` Neue Version: hier einen Abschnitt `## <Version>` anlegen, `<Version>` in `BSO.csproj` und `Bootstrap.ModVersion`
erhöhen, dann `.\build.ps1 -Release`. erhöhen, dann `.\build.ps1 -Release`.
## 0.5.0
- Lobby: mit Freunden auf dem Lobby-Server spielen - ueber die BSO-Website, ohne Portfreigabe
- Anzeige "Lobby von ..." statt der Adresse
## 0.4.0 ## 0.4.0
- Mitspieler in der Welt: Bus bzw. Spielfigur mit Namensschild und Entfernung, weich bewegt - Mitspieler in der Welt: Bus bzw. Spielfigur mit Namensschild und Entfernung, weich bewegt
- BSO-Konto: ohne Start über den Launcher kein Multiplayer, der Name im Spiel kommt aus dem Konto - BSO-Konto: ohne Start über den Launcher kein Multiplayer, der Name im Spiel kommt aus dem Konto
- Beitreten über den Server-Browser im Launcher (verbindet automatisch) - Beitreten über den Launcher (verbindet automatisch)
## 0.2.0 ## 0.2.0
- Mitspieler-Liste: Name, zu Fuß/Bus, Entfernung, Tempo - Mitspieler-Liste: Name, zu Fuß/Bus, Entfernung, Tempo
+5 -1
View File
@@ -9,8 +9,12 @@ namespace BSO
{ {
/// <summary>Name, den die anderen sehen.</summary> /// <summary>Name, den die anderen sehen.</summary>
public string Name { get; set; } = Environment.UserName; public string Name { get; set; } = Environment.UserName;
/// <summary>Server als host oder host:port.</summary> /// <summary>Server als host oder host:port - oder relay:&lt;Lobby-ID&gt; (Server der Lobby im BSO-Relay).</summary>
public string Server { get; set; } = "127.0.0.1:7777"; public string Server { get; set; } = "127.0.0.1:7777";
/// <summary>Anzeige fuer den Server, z.B. "Lobby von MrMaho" (schreibt der Launcher).</summary>
public string ServerLabel { get; set; }
/// <summary>BSO-Relay (Lobby-Server) - schreibt der Launcher.</summary>
public string Relay { get; set; } = "https://bso-relay.mbm.wtf";
/// <summary>Beim Spielstart automatisch verbinden (sonst F8).</summary> /// <summary>Beim Spielstart automatisch verbinden (sonst F8).</summary>
public bool AutoConnect { get; set; } public bool AutoConnect { get; set; }
/// <summary>Spiel-Ticket der BSO-Website - schreibt der Launcher bei SPIELEN hinein (24 h gueltig).</summary> /// <summary>Spiel-Ticket der BSO-Website - schreibt der Launcher bei SPIELEN hinein (24 h gueltig).</summary>
+22 -4
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Net.Sockets; using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BSO.Net; using BSO.Net;
@@ -66,7 +67,7 @@ namespace BSO
} }
} }
const int ConnectTimeoutMs = 5000; const int ConnectTimeoutMs = 5000, RelayTimeoutMs = 20000; // Relay: Website + Antwort des Lobby-Servers
const int SendIntervalMs = 100, PingIntervalMs = 2000; const int SendIntervalMs = 100, PingIntervalMs = 2000;
public static NetStatus Status { get; private set; } public static NetStatus Status { get; private set; }
@@ -125,6 +126,8 @@ namespace BSO
} }
public static string ServerAddress => Config.Current.Server; public static string ServerAddress => Config.Current.Server;
/// <summary>Fuer die Anzeige: "Lobby von ..." statt relay:&lt;id&gt;.</summary>
public static string ServerDisplay => Config.Current.ServerLabel ?? (ServerAddress.StartsWith("relay:") ? "Lobby" : ServerAddress);
/// <summary> /// <summary>
/// Gueltiges Spiel-Ticket vom Launcher? Ohne gibt es keinen Multiplayer - der Server prueft es zusaetzlich (die Mod /// Gueltiges Spiel-Ticket vom Launcher? Ohne gibt es keinen Multiplayer - der Server prueft es zusaetzlich (die Mod
@@ -152,11 +155,26 @@ namespace BSO
Bootstrap.Log("Nicht verbunden: " + problem); Bootstrap.Log("Nicht verbunden: " + problem);
return; return;
} }
(string host, int port) = ParseAddress(ServerAddress);
Bootstrap.Log($"Verbinde mit {host}:{port} ...");
LastError = ""; LastError = "";
tcp = new TcpClient(); 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); connectTask = tcp.ConnectAsync(host, port);
}
connectClock.Restart(); connectClock.Restart();
Status = NetStatus.Connecting; Status = NetStatus.Connecting;
} }
@@ -165,7 +183,7 @@ namespace BSO
{ {
if (!connectTask.IsCompleted) if (!connectTask.IsCompleted)
{ {
if (connectClock.ElapsedMilliseconds > ConnectTimeoutMs) Fail("Server antwortet nicht"); if (connectClock.ElapsedMilliseconds > (ServerAddress.StartsWith("relay:") ? RelayTimeoutMs : ConnectTimeoutMs)) Fail("Server antwortet nicht");
return; return;
} }
if (connectTask.IsFaulted || !tcp.Connected) if (connectTask.IsFaulted || !tcp.Connected)
+3 -3
View File
@@ -63,16 +63,16 @@ namespace BSO
text.Add(Multiplayer.AccountProblem); text.Add(Multiplayer.AccountProblem);
return; return;
case Multiplayer.NetStatus.Offline: case Multiplayer.NetStatus.Offline:
text.Add($"Offline - [F8] verbinden mit {Multiplayer.ServerAddress} als {Multiplayer.PlayerName}"); text.Add($"Offline - [F8] verbinden mit {Multiplayer.ServerDisplay} als {Multiplayer.PlayerName}");
if (Multiplayer.LastError.Length > 0) text.Add(" " + Multiplayer.LastError); if (Multiplayer.LastError.Length > 0) text.Add(" " + Multiplayer.LastError);
return; return;
case Multiplayer.NetStatus.Connecting: case Multiplayer.NetStatus.Connecting:
text.Add($"Verbinde mit {Multiplayer.ServerAddress} ..."); text.Add($"Verbinde mit {Multiplayer.ServerDisplay} ...");
return; return;
} }
if (!Multiplayer.Welcomed) if (!Multiplayer.Welcomed)
{ {
text.Add($"Verbinde mit {Multiplayer.ServerAddress} ... warte auf Server"); text.Add($"Verbinde mit {Multiplayer.ServerDisplay} ... warte auf Server");
return; return;
} }
+9 -7
View File
@@ -4,12 +4,12 @@ Multiplayer-Mod für **Bus-Simulator 2012** (European Bus Simulator 2012, TML-St
eigenständiger Loader im Spielordner, die Mod selbst in C# (.NET 10). eigenständiger Loader im Spielordner, die Mod selbst in C# (.NET 10).
- Mod: dieses Repository - Mod: dieses Repository
- Dedizierter Server: [BSO-Server](https://git.mbm.wtf/MrMaho/BSO-Server) - Lobby-Server (ohne Portfreigabe): [BSO-Relay](https://git.mbm.wtf/MrMaho/BSO-Relay)
- Launcher (installiert und aktualisiert die Mod): [BSO-Launcher](https://git.mbm.wtf/MrMaho/BSO-Launcher) - Launcher (installiert und aktualisiert die Mod): [BSO-Launcher](https://git.mbm.wtf/MrMaho/BSO-Launcher)
- Website, Konten und Releases: [BSO-Website](https://git.mbm.wtf/MrMaho/BSO-Website) - Website, Konten, Freunde, Lobbys, Releases: [BSO-Website](https://git.mbm.wtf/MrMaho/BSO-Website)
**Stand:** Spieler verbinden sich per IP mit einem Server und sehen die anderen als Liste (Name, zu Fuß/Bus, **Stand:** Freunde spielen zusammen in einer Lobby – der Server läuft im BSO-Relay, die Mod verbindet sich per WebSocket,
Entfernung, Tempo). Als Nächstes kommen sichtbare Busse der Mitspieler in der Welt. niemand braucht eine Portfreigabe. Mitspieler erscheinen als Bus bzw. Spielfigur mit Namensschild in der Welt.
## Wie es funktioniert ## Wie es funktioniert
@@ -24,7 +24,8 @@ Entfernung, Tempo). Als Nächstes kommen sichtbare Busse der Mitspieler in der W
| `Config.cs` | `BSO.json` (Name, Server, AutoConnect) | – | | `Config.cs` | `BSO.json` (Name, Server, AutoConnect) | – |
| `PositionHud.cs` | Anzeige im Spiel | `Overlays.cs` | | `PositionHud.cs` | Anzeige im Spiel | `Overlays.cs` |
| `BorderlessWindow.cs` | Vollbild im Fenster (F11) | – | | `BorderlessWindow.cs` | Vollbild im Fenster (F11) | – |
| `Shared\*.cs` | Protokoll, `PacketReader`, `Connection`/`FrameParser` – **gleiche Dateien wie in BSO-Server** | `NetSession.cs` | | `RemotePlayers.cs`, `Models.cs` | Mitspieler als Bus/Spielfigur in der Welt, Namensschild | `RemotePlayer.cs` |
| `Shared\*.cs` | Protokoll, `Connection`, `Ticket`, `RelayClient` (WebSocket-Brücke) – **gleiche Dateien wie in BSO-Relay** | `NetSession.cs` |
- **Bild-Takt:** Das Spiel fragt die Tastatur mehrmals pro Bild über DirectInput ab. Der Loader hängt sich dort ein - **Bild-Takt:** Das Spiel fragt die Tastatur mehrmals pro Bild über DirectInput ab. Der Loader hängt sich dort ein
und ruft `Bootstrap.OnFrame` im Hauptthread auf. Gerechnet wird, wenn die Engine-Uhr weiterläuft; Text wird bei und ruft `Bootstrap.OnFrame` im Hauptthread auf. Gerechnet wird, wenn die Engine-Uhr weiterläuft; Text wird bei
@@ -82,5 +83,6 @@ Protokoll: `Bin_High_Win64\BSO\BSO_Loader.txt`
1. ~~Mod lädt, Busposition anzeigen~~ 1. ~~Mod lädt, Busposition anzeigen~~
2. ~~Netzwerk: Server + Positionen austauschen, Mitspieler als Text~~ 2. ~~Netzwerk: Server + Positionen austauschen, Mitspieler als Text~~
3. Mitspieler-Busse in der Welt (`VisGame_cl::CreateEntity`), Namensschild mit `VisMessage_cl::DrawMessage3D` 3. ~~Mitspieler-Busse in der Welt, Namensschild~~
4. Chat, Konsole, Steam-P2P, Serverliste 4. ~~Konten, Freunde, Lobby ohne Portfreigabe (BSO-Relay)~~
5. Direkt ins Spiel laden, Chat, Türen/Lichter
+116
View File
@@ -0,0 +1,116 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
namespace BSO.Net
{
/// <summary>
/// BSO-Relay (Lobby-Server ohne Portfreigabe): eine TCP-Verbindung wird ueber einen WebSocket zum Relay
/// durchgereicht. Die Mod spricht weiter ganz normal TCP (Connection) - nur eben mit 127.0.0.1, die Bruecke
/// schickt alles ueber das Relay. Angemeldet wird mit dem Spiel-Ticket der Lobby (Kopfzeile X-BSO-Ticket).
/// </summary>
public static class RelayClient
{
/// <summary>https://site/relay/... -> wss://site/relay/...</summary>
public static Uri WsUri(string master, string pathAndQuery)
{
string b = master.TrimEnd('/');
if (b.StartsWith("https://")) b = "wss://" + b.Substring(8);
else if (b.StartsWith("http://")) b = "ws://" + b.Substring(7);
return new Uri(b + pathAndQuery);
}
public static async Task<ClientWebSocket> ConnectAsync(string master, string pathAndQuery, string ticket, CancellationToken ct)
{
var ws = new ClientWebSocket();
ws.Options.SetRequestHeader("X-BSO-Ticket", ticket ?? "");
ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(20);
try
{
await ws.ConnectAsync(WsUri(master, pathAndQuery), ct);
return ws;
}
catch (WebSocketException e)
{
ws.Dispose();
string msg = e.Message ?? "";
if (msg.Contains("404")) throw new Exception("Der Server der Lobby laeuft gerade nicht");
if (msg.Contains("403")) throw new Exception("Kein Zugang zu dieser Lobby - im Launcher neu auf SPIELEN druecken");
throw new Exception("BSO-Relay nicht erreichbar: " + msg);
}
}
/// <summary>
/// Mitspieler: mit dem Lobby-Server verbinden und eine lokale Bruecke oeffnen. Gibt den Port auf 127.0.0.1 zurueck,
/// mit dem man sich dann normal per TCP verbindet. Die Bruecke nimmt genau eine Verbindung an.
/// </summary>
public static async Task<int> JoinAsync(string master, string lobbyId, string ticket, CancellationToken ct)
{
ClientWebSocket ws = await ConnectAsync(master, "/join?lobby=" + Uri.EscapeDataString(lobbyId), ticket, ct);
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
_ = Task.Run(async () =>
{
try
{
using var accept = new CancellationTokenSource(TimeSpan.FromSeconds(15));
using TcpClient tcp = await listener.AcceptTcpClientAsync(accept.Token);
listener.Stop();
await BridgeAsync(tcp, ws);
}
catch { }
finally
{
try { listener.Stop(); } catch { }
ws.Dispose();
}
});
return port;
}
/// <summary>Daten zwischen TCP und WebSocket in beide Richtungen, bis eine Seite trennt.</summary>
public static async Task BridgeAsync(TcpClient tcp, WebSocket ws)
{
tcp.NoDelay = true;
NetworkStream stream = tcp.GetStream();
using var cts = new CancellationTokenSource();
async Task TcpToWs()
{
var buffer = new byte[64 * 1024];
try
{
int n;
while ((n = await stream.ReadAsync(buffer, cts.Token)) > 0)
await ws.SendAsync(new ArraySegment<byte>(buffer, 0, n), WebSocketMessageType.Binary, true, cts.Token);
}
catch { }
cts.Cancel();
}
async Task WsToTcp()
{
var buffer = new byte[64 * 1024];
try
{
while (!cts.IsCancellationRequested)
{
var r = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), cts.Token);
if (r.MessageType == WebSocketMessageType.Close) break;
await stream.WriteAsync(buffer, 0, r.Count, cts.Token);
}
}
catch { }
cts.Cancel();
}
await Task.WhenAll(TcpToWs(), WsToTcp());
try { tcp.Close(); } catch { }
try { if (ws.State == WebSocketState.Open) await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); } catch { }
}
}
}
+7 -2
View File
@@ -6,7 +6,7 @@ using System.Text.Json;
namespace BSO.Net namespace BSO.Net
{ {
/// <summary> /// <summary>
/// Spiel-Ticket der BSO-Website: base64url(JSON {u, id, iat, exp}) + "." + base64url(RSA-SHA256-Signatur). Der Launcher /// Spiel-Ticket der BSO-Website: base64url(JSON {u, id, iat, exp, lb?, ln?}) + "." + base64url(RSA-SHA256-Signatur). Der Launcher
/// holt es beim Spielen und schreibt es in BSO.json, die Mod schickt es im Hello mit. Die Mod liest es nur (Name, Ablauf), /// holt es beim Spielen und schreibt es in BSO.json, die Mod schickt es im Hello mit. Die Mod liest es nur (Name, Ablauf),
/// pruefen kann es nur der Server mit dem oeffentlichen Schluessel der Website (Verify) - wer die Mod veraendert, kommt so /// pruefen kann es nur der Server mit dem oeffentlichen Schluessel der Website (Verify) - wer die Mod veraendert, kommt so
/// trotzdem nicht ohne gueltiges Konto auf einen Server. /// trotzdem nicht ohne gueltiges Konto auf einen Server.
@@ -16,6 +16,9 @@ namespace BSO.Net
public string Username { get; private set; } public string Username { get; private set; }
public string AccountId { get; private set; } public string AccountId { get; private set; }
public DateTime Expires { get; private set; } public DateTime Expires { get; private set; }
/// <summary>Lobby, fuer die das Ticket gilt (null = keine) und ihr Name ("Lobby von ...").</summary>
public string Lobby { get; private set; }
public string LobbyName { get; private set; }
public bool Expired => DateTime.UtcNow >= Expires; public bool Expired => DateTime.UtcNow >= Expires;
/// <summary>Inhalt lesen, ohne die Signatur zu pruefen. null = kein Ticket / kaputt.</summary> /// <summary>Inhalt lesen, ohne die Signatur zu pruefen. null = kein Ticket / kaputt.</summary>
@@ -32,7 +35,9 @@ namespace BSO.Net
{ {
Username = r.GetProperty("u").GetString(), Username = r.GetProperty("u").GetString(),
AccountId = r.GetProperty("id").GetString(), AccountId = r.GetProperty("id").GetString(),
Expires = DateTimeOffset.FromUnixTimeSeconds(r.GetProperty("exp").GetInt64()).UtcDateTime Expires = DateTimeOffset.FromUnixTimeSeconds(r.GetProperty("exp").GetInt64()).UtcDateTime,
Lobby = r.TryGetProperty("lb", out var lb) ? lb.GetString() : null,
LobbyName = r.TryGetProperty("ln", out var ln) ? ln.GetString() : null
}; };
} }
catch { return null; } catch { return null; }