0.3.0: Freunde und Lobby, Server-Browser entfernt

- Seite Freunde: hinzufuegen, annehmen, online-Anzeige, Lobby erstellen/einladen/beitreten, Server starten
- SPIELEN in der Lobby: Ticket fuer die Lobby + Relay-Adresse in BSO.json
- Knopf 'Mod herunterladen' holt jederzeit die neueste Mod

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 19:30:22 +01:00
co-authored by Claude Opus 5.5
parent bcc85a51de
commit 3281468bee
10 changed files with 219 additions and 165 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>BSO.Launcher</RootNamespace> <RootNamespace>BSO.Launcher</RootNamespace>
<AssemblyName>BSO-Launcher</AssemblyName> <AssemblyName>BSO-Launcher</AssemblyName>
<Version>0.2.0</Version> <Version>0.3.0</Version>
<Company>MBM.wtf</Company> <Company>MBM.wtf</Company>
<Product>BSO Launcher</Product> <Product>BSO Launcher</Product>
<Description>BSO Launcher - Bus Simulator Online</Description> <Description>BSO Launcher - Bus Simulator Online</Description>
+6
View File
@@ -3,6 +3,12 @@
Neue Version: hier einen Abschnitt `## <Version>` anlegen, `<Version>` in `BSO.Launcher.csproj` erhöhen, dann Neue Version: hier einen Abschnitt `## <Version>` anlegen, `<Version>` in `BSO.Launcher.csproj` erhöhen, dann
`dotnet publish -c Release -o publish -p:BsoRelease=true` – der Abschnitt wird der Changelog auf der Website und im Launcher. `dotnet publish -c Release -o publish -p:BsoRelease=true` – der Abschnitt wird der Changelog auf der Website und im Launcher.
## 0.3.0
- Freunde: per Benutzername hinzufügen, Anfragen annehmen, sehen wer online ist
- Lobby: Freunde einladen, Server starten, zusammen spielen – der Server läuft auf dem BSO-Relay, keine Portfreigabe nötig
- Knopf „Mod herunterladen“ holt jederzeit die neueste Mod
- Server-Browser entfernt
## 0.2.0 ## 0.2.0
- Server-Browser: alle öffentlichen Server mit Spielerzahl und Ping, BEITRETEN startet das Spiel und verbindet automatisch - Server-Browser: alle öffentlichen Server mit Spielerzahl und Ping, BEITRETEN startet das Spiel und verbindet automatisch
- Direkt per Adresse verbinden - Direkt per Adresse verbinden
+46 -38
View File
@@ -19,9 +19,9 @@ public sealed class MainForm : Form
readonly Settings settings = Settings.Load(); readonly Settings settings = Settings.Load();
/// <summary>Befehle, die nur einmal gleichzeitig laufen duerfen (Downloads/Installationen/Start).</summary> /// <summary>Befehle, die nur einmal gleichzeitig laufen duerfen (Downloads/Installationen/Start).</summary>
static readonly HashSet<string> Exclusive = new() { "installMod", "launch", "join", "installLauncherUpdate", "uninstall" }; static readonly HashSet<string> Exclusive = new() { "installMod", "launch", "playLobby", "installLauncherUpdate", "uninstall" };
readonly HashSet<string> running = new(); readonly HashSet<string> running = new();
static readonly HashSet<string> QuietCommands = new() { "window", "status", "me", "init", "releases", "servers" }; static readonly HashSet<string> QuietCommands = new() { "window", "status", "me", "init", "releases", "friends", "lobby" };
bool updateInstalled; bool updateInstalled;
static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
@@ -288,18 +288,23 @@ public sealed class MainForm : Form
return await InstallMod(); return await InstallMod();
case "launch": case "launch":
return await Launch(); return await Launch();
case "servers": case "playLobby":
return await ServerList(); return await Launch(lobby: true);
case "join":
{ // --- Freunde und Lobby (Website, mit dem Konto-Token)
// Beitreten: Server merken, beim Start automatisch verbinden, Spiel starten case "friends": return await Site("GET", "/api/friends");
string address = Arg(args, "address").Trim(); case "friendRequest": return await Site("POST", "/api/friends/request", new { username = Arg(args, "username").Trim() });
if (address.Length == 0 || address.Length > 260) throw new Exception("Ungueltige Serveradresse"); case "friendAccept": return await Site("POST", "/api/friends/accept", new { id = Arg(args, "id") });
settings.Server = address; case "friendRemove": return await Site("POST", "/api/friends/remove", new { id = Arg(args, "id") });
settings.AutoConnect = true; case "lobby": return await Site("GET", "/api/lobby");
settings.Save(); case "lobbyCreate": return await Site("POST", "/api/lobby/create");
return await Launch(); case "lobbyInvite": return await Site("POST", "/api/lobby/invite", new { accountId = Arg(args, "accountId") });
} case "lobbyJoin": return await Site("POST", "/api/lobby/join", new { lobbyId = Arg(args, "lobbyId") });
case "lobbyDecline": return await Site("POST", "/api/lobby/decline", new { lobbyId = Arg(args, "lobbyId") });
case "lobbyLeave": return await Site("POST", "/api/lobby/leave");
case "lobbyKick": return await Site("POST", "/api/lobby/kick", new { accountId = Arg(args, "accountId") });
case "lobbyStart": return await Site("POST", "/api/lobby/start");
case "lobbyStop": return await Site("POST", "/api/lobby/stop");
case "releases": case "releases":
{ {
var r = await Api.Releases(); var r = await Api.Releases();
@@ -375,19 +380,21 @@ public sealed class MainForm : Form
}; };
} }
/// <summary>Serverliste der Website, jeder Server direkt angefragt (Ping, aktuelle Spielerzahl). Nicht erreichbar = ohne Ping.</summary> /// <summary>Anfrage an die Website mit dem Konto-Token. Abgelaufene Anmeldung -> zurueck zum Anmelden.</summary>
static async Task<object> ServerList() async Task<JsonElement> Site(string method, string path, object? body = null)
{ {
var servers = await Api.Servers(); string token = settings.Token ?? throw new Exception("Bitte anmelden");
var pings = await Task.WhenAll(servers.Select(s => ServerPing.Query(s.host, s.port))); var r = method == "GET" ? await Api.Get(path, token) : await Api.Post(path, token, body);
return servers.Select((s, i) => new if (r.Status == 401) { LoggedOut(); throw new Exception("Anmeldung abgelaufen - bitte neu anmelden"); }
if (!r.Ok) throw new Exception(r.Error ?? "Fehlgeschlagen");
return r.Json;
}
void LoggedOut()
{ {
s.name, s.description, s.address, s.version, settings.Token = null;
players = pings[i]?.Players ?? s.players, settings.Save();
maxPlayers = pings[i]?.MaxPlayers ?? s.maxPlayers, Emit(new { @event = "loggedOut" });
ping = pings[i]?.Ping,
compatible = s.protocol == 0 || s.protocol == 2 // Protokoll der Mod, die der Launcher installiert
}).OrderBy(s => s.ping == null).ThenByDescending(s => s.players).ToList();
} }
async Task<object> InstallMod() async Task<object> InstallMod()
@@ -400,7 +407,8 @@ public sealed class MainForm : Form
return new { version = latest.version }; return new { version = latest.version };
} }
async Task<object> Launch() /// <summary>Spiel starten. lobby: auf den Server der eigenen Lobby (laeuft im BSO-Relay), sonst auf settings.Server.</summary>
async Task<object> Launch(bool lobby = false)
{ {
string folder = Game.FindFolder(settings) ?? throw new Exception("Bus-Simulator 2012 nicht gefunden - in den Einstellungen den Spielordner waehlen"); string folder = Game.FindFolder(settings) ?? throw new Exception("Bus-Simulator 2012 nicht gefunden - in den Einstellungen den Spielordner waehlen");
if (!Game.DotnetInstalled()) throw new Exception("Fuer BSO fehlt die .NET 10 Runtime (x64) - unter \"Status\" herunterladen"); if (!Game.DotnetInstalled()) throw new Exception("Fuer BSO fehlt die .NET 10 Runtime (x64) - unter \"Status\" herunterladen");
@@ -415,22 +423,22 @@ public sealed class MainForm : Form
else if (installed == null) throw new Exception("BSO ist nicht installiert und der BSO-Server ist nicht erreichbar"); else if (installed == null) throw new Exception("BSO ist nicht installiert und der BSO-Server ist nicht erreichbar");
} }
// Spiel-Ticket: ohne gueltiges Konto kein Multiplayer (Mod und Server pruefen es) // Spiel-Ticket: ohne gueltiges Konto kein Multiplayer (Mod und Relay pruefen es). Fuer die Lobby gilt es fuer deren Server.
string token = settings.Token ?? throw new Exception("Bitte anmelden"); var t = await Site("POST", "/api/auth/ticket", lobby ? new { lobby = true } : null);
var t = await Api.Ticket(token); string ticket = t.GetProperty("ticket").GetString()!;
if (t.Status == 401) if (lobby)
{ {
settings.Token = null; string lobbyId = t.GetProperty("lobby").GetString()!, label = t.GetProperty("lobbyName").GetString() ?? "Lobby";
settings.Save(); string relay = (await Site("GET", "/api/config")).GetProperty("relay").GetString()!;
Emit(new { @event = "loggedOut" }); Game.WriteModConfig(folder, settings.Username, "relay:" + lobbyId, true, ticket, label, relay);
throw new Exception("Anmeldung abgelaufen - bitte neu anmelden"); LauncherLog.Info($"Spiel gestartet ({label} ueber {relay})");
} }
if (!t.Ok) throw new Exception("Konto konnte nicht geprueft werden: " + t.Error); else
string ticket = t.Json.GetProperty("ticket").GetString()!; {
Game.WriteModConfig(folder, settings.Username, settings.Server, settings.AutoConnect, ticket); Game.WriteModConfig(folder, settings.Username, settings.Server, settings.AutoConnect, ticket);
Game.Launch();
LauncherLog.Info($"Spiel gestartet (Server {settings.Server}, automatisch verbinden {settings.AutoConnect})"); LauncherLog.Info($"Spiel gestartet (Server {settings.Server}, automatisch verbinden {settings.AutoConnect})");
}
Game.Launch();
if (!settings.KeepOpen) if (!settings.KeepOpen)
{ {
await Task.Delay(2500); await Task.Delay(2500);
+7 -3
View File
@@ -4,8 +4,8 @@ Launcher für **BSO – Bus Simulator Online** (Multiplayer-Mod für Bus-Simulat
WinForms-Fenster mit WebView2, die Oberfläche (`ui\`) ist in der .exe eingebettet. WinForms-Fenster mit WebView2, die Oberfläche (`ui\`) ist in der .exe eingebettet.
- Mod: [BSO-Mod](https://git.mbm.wtf/MrMaho/BSO-Mod) - Mod: [BSO-Mod](https://git.mbm.wtf/MrMaho/BSO-Mod)
- Dedizierter Server: [BSO-Server](https://git.mbm.wtf/MrMaho/BSO-Server) - Lobby-Server (ohne Portfreigabe): [BSO-Relay](https://git.mbm.wtf/MrMaho/BSO-Relay)
- 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)
## Features ## Features
@@ -17,7 +17,11 @@ WinForms-Fenster mit WebView2, die Oberfläche (`ui\`) ist in der .exe eingebett
- **Auto-Update:** neueste Mod-Version vor jedem Start (abschaltbar), Selbst-Update des Launchers mit einem Klick. - **Auto-Update:** neueste Mod-Version vor jedem Start (abschaltbar), Selbst-Update des Launchers mit einem Klick.
Downloads sind fortsetzbar, SHA-256-geprüft und müssen mit dem **BSO-Release-Schlüssel signiert** sein. Downloads sind fortsetzbar, SHA-256-geprüft und müssen mit dem **BSO-Release-Schlüssel signiert** sein.
- **Prüft alles:** Spielordner (alle Steam-Bibliotheken), BSO-Version, .NET 10 Runtime (braucht die Mod). - **Prüft alles:** Spielordner (alle Steam-Bibliotheken), BSO-Version, .NET 10 Runtime (braucht die Mod).
- **SPIELEN:** schreibt Name und Server in `BSO.json`, startet Bus-Simulator 2012 über Steam und verbindet automatisch. - **Freunde & Lobby:** Freunde per Benutzername hinzufügen (online-Anzeige), Lobby erstellen, einladen, Server starten.
Der Server läuft im BSO-Relay – niemand hostet, niemand braucht eine Portfreigabe.
- **SPIELEN:** holt ein Spiel-Ticket (in der Lobby: für deren Server) und die Relay-Adresse, schreibt beides in
`BSO.json`, startet Bus-Simulator 2012 über Steam und verbindet automatisch.
- **Mod herunterladen** holt jederzeit die neueste Mod.
- Neuigkeiten (Changelogs), Einstellungen, Deinstallieren. - Neuigkeiten (Changelogs), Einstellungen, Deinstallieren.
## Bauen und veröffentlichen ## Bauen und veröffentlichen
+3 -13
View File
@@ -71,20 +71,10 @@ public static class Api
public static Task<Result> Logout(string token) => Send(HttpMethod.Post, "/api/auth/logout", new { }, token); public static Task<Result> Logout(string token) => Send(HttpMethod.Post, "/api/auth/logout", new { }, token);
/// <summary>Spiel-Ticket fuer die Mod (signiert, 24 h) -> { ticket, username, expiresUtc }</summary>
public static Task<Result> Ticket(string token) => Send(HttpMethod.Post, "/api/auth/ticket", new { }, token);
public sealed record ServerEntry(string name, string description, string address, string host, int port, int players, int maxPlayers,
string version, int protocol, string onlineSinceUtc);
/// <summary>Oeffentliche, erreichbare Server (Serverliste der Website).</summary>
public static async Task<List<ServerEntry>> Servers()
{
var r = await Send(HttpMethod.Get, "/api/servers", null, null);
if (!r.Ok) throw new Exception(r.Error ?? "Serverliste nicht verfuegbar");
return r.Json.GetProperty("servers").Deserialize<List<ServerEntry>>() ?? new List<ServerEntry>();
}
// --- Freunde und Lobby (alles mit dem Konto-Token)
public static Task<Result> Get(string path, string token) => Send(HttpMethod.Get, path, null, token);
public static Task<Result> Post(string path, string token, object? body = null) => Send(HttpMethod.Post, path, body ?? new { }, token);
public static async Task<Release?> Latest(string product) public static async Task<Release?> Latest(string product)
{ {
var r = await Send(HttpMethod.Get, "/api/releases/latest?product=" + product, null, null); var r = await Send(HttpMethod.Get, "/api/releases/latest?product=" + product, null, null);
+4 -2
View File
@@ -85,8 +85,8 @@ public static class Game
LauncherLog.Info("BSO installiert nach " + bin); LauncherLog.Info("BSO installiert nach " + bin);
} }
/// <summary>Einstellungen der Mod (BSO\BSO.json) - Name = Kontoname, Server, automatisch verbinden, Spiel-Ticket.</summary> /// <summary>Einstellungen der Mod (BSO\BSO.json) - Name = Kontoname, Server (Adresse oder relay:&lt;Lobby&gt;), Anzeige, Relay, Spiel-Ticket.</summary>
public static void WriteModConfig(string folder, string? name, string server, bool autoConnect, string? ticket) public static void WriteModConfig(string folder, string? name, string server, bool autoConnect, string? ticket, string? label = null, string? relay = null)
{ {
string file = Path.Combine(Bin(folder), "BSO", "BSO.json"); string file = Path.Combine(Bin(folder), "BSO", "BSO.json");
JsonObject json; JsonObject json;
@@ -96,6 +96,8 @@ public static class Game
json["Server"] = server; json["Server"] = server;
json["AutoConnect"] = autoConnect; json["AutoConnect"] = autoConnect;
json["Ticket"] = ticket; json["Ticket"] = ticket;
json["ServerLabel"] = label;
if (relay != null) json["Relay"] = relay;
Directory.CreateDirectory(Path.GetDirectoryName(file)!); Directory.CreateDirectory(Path.GetDirectoryName(file)!);
File.WriteAllText(file, json.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); File.WriteAllText(file, json.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
} }
-38
View File
@@ -1,38 +0,0 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text;
namespace BSO.Launcher;
/// <summary>
/// Fragt einen BSO-Server direkt ab wie die Mod (wie ServerBrowser.cs bei MSCO): Info senden, InfoReply lesen. Liefert die
/// Laufzeit (Ping) und den aktuellen Stand (Spieler) - die Liste der Website ist bis zu einer Minute alt.
/// Paketaufbau: [int Laenge][byte Typ][Daten], Info = 9, InfoReply = 10 (string Name, string Version, int Spieler, int max).
/// </summary>
public static class ServerPing
{
public sealed record Info(int Ping, string Name, string Version, int Players, int MaxPlayers);
public static async Task<Info?> Query(string host, int port, int timeoutMs = 2500)
{
try
{
using var tcp = new TcpClient { NoDelay = true };
using var cts = new CancellationTokenSource(timeoutMs);
var clock = Stopwatch.StartNew();
await tcp.ConnectAsync(host, port, cts.Token);
var stream = tcp.GetStream();
await stream.WriteAsync(new byte[] { 1, 0, 0, 0, 9 }, cts.Token);
var head = new byte[5];
await stream.ReadExactlyAsync(head, cts.Token);
int ping = (int)clock.ElapsedMilliseconds;
int length = BitConverter.ToInt32(head, 0);
if (head[4] != 10 || length < 1 || length > 4096) return null;
var payload = new byte[length - 1];
await stream.ReadExactlyAsync(payload, cts.Token);
using var r = new BinaryReader(new MemoryStream(payload), Encoding.UTF8);
return new Info(ping, r.ReadString(), r.ReadString(), r.ReadInt32(), r.ReadInt32());
}
catch { return null; }
}
}
+16 -10
View File
@@ -146,16 +146,22 @@ nav button.on { background: var(--panel-2); color: var(--text); }
.dot.bad { background: var(--danger); } .dot.bad { background: var(--danger); }
.row { display: flex; gap: 8px; align-items: center; } .row { display: flex; gap: 8px; align-items: center; }
/* ---------------------------------------------------------------- Server-Browser */ /* ---------------------------------------------------------------- Freunde & Lobby */
.server-list { display: grid; gap: 8px; } .nav-badge { margin-left: auto; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; background: var(--danger); color: #fff;
.server { display: grid; grid-template-columns: 1fr 70px 70px 110px auto; align-items: center; gap: 14px; padding: 14px 18px; font-size: 11px; font-weight: 800; display: grid; place-items: center; }
background: var(--panel); border: 1px solid var(--border); border-radius: 12px; } .social { display: grid; grid-template-columns: 1.15fr 1fr; gap: 16px; align-items: start; }
.server:hover { border-color: #36404f; } .people { display: grid; gap: 6px; }
.server-main { display: grid; min-width: 0; } .person { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 10px; background: var(--panel-2); }
.server-main b, .server-main span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .person .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.server-col { text-align: right; font-variant-numeric: tabular-nums; } .person .crown { color: var(--pending); font-size: 13px; }
.ok { color: var(--online); } .warn { color: var(--pending); } .bad { color: var(--danger); } .person .x { border: 0; background: transparent; color: var(--muted); cursor: pointer; font: 12px var(--icons); padding: 4px 6px; border-radius: 6px; }
.person .x:hover { color: var(--text); background: #2a3140; }
.server-state { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-radius: 12px; background: var(--bg); border: 1px solid var(--border); }
.invite { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-radius: 12px;
background: rgba(47, 155, 255, .1); border: 1px solid rgba(47, 155, 255, .35); }
.invite span { flex: 1; }
.section-title { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin: 6px 0 2px; }
@media (max-width: 1000px) { .social { grid-template-columns: 1fr; } }
/* ---------------------------------------------------------------- Neuigkeiten */ /* ---------------------------------------------------------------- Neuigkeiten */
.release { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; padding: 18px; display: grid; gap: 6px; } .release { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; padding: 18px; display: grid; gap: 6px; }
.release header { display: flex; align-items: baseline; gap: 10px; } .release header { display: flex; align-items: baseline; gap: 10px; }
+132 -56
View File
@@ -140,7 +140,7 @@ function showPage(name) {
document.querySelectorAll("#nav [data-page]").forEach((b) => b.classList.toggle("on", b.dataset.page === name)); document.querySelectorAll("#nav [data-page]").forEach((b) => b.classList.toggle("on", b.dataset.page === name));
const root = $("page"); const root = $("page");
root.replaceChildren(h("p", { class: "muted" }, "Laden …")); root.replaceChildren(h("p", { class: "muted" }, "Laden …"));
({ home: renderHome, servers: renderServers, news: renderNews, settings: renderSettings })[name](root); ({ home: renderHome, friends: renderFriends, news: renderNews, settings: renderSettings })[name](root);
} }
// Anmeldung abgelaufen (z.B. beim Holen des Spiel-Tickets) // Anmeldung abgelaufen (z.B. beim Holen des Spiel-Tickets)
@@ -210,8 +210,10 @@ function modCard(s) {
const value = m.installed ? `Version ${m.installed}` : "Nicht installiert"; const value = m.installed ? `Version ${m.installed}` : "Nicht installiert";
const info = m.updateAvailable ? `Neu: ${m.latest}` : m.latest ? "Aktuell" : "Keine Version auf dem Server"; const info = m.updateAvailable ? `Neu: ${m.latest}` : m.latest ? "Aktuell" : "Keine Version auf dem Server";
const extra = [h("span", { class: "muted small" }, info)]; const extra = [h("span", { class: "muted small" }, info)];
if (m.updateAvailable && s.game.found) { // Immer da: holt die neueste Mod von der Website (bei SPIELEN passiert das sonst automatisch, wenn eingeschaltet)
const b = h("button", { class: "btn small", type: "button" }, m.installed ? "Aktualisieren" : "Installieren"); if (m.latest && s.game.found) {
const label = !m.installed ? "Mod herunterladen" : m.updateAvailable ? "Mod aktualisieren" : "Neu herunterladen";
const b = h("button", { class: "btn small" + (!m.installed || m.updateAvailable ? " primary" : ""), type: "button" }, label);
b.addEventListener("click", () => busy(b, async () => { b.addEventListener("click", () => busy(b, async () => {
const r = await call("installMod"); const r = await call("installMod");
toast("BSO " + r.version + " installiert"); toast("BSO " + r.version + " installiert");
@@ -243,64 +245,138 @@ function serverCard(s) {
h("label", { class: "switch" }, h("span", {}, "Beim Start verbinden", h("small", {}, "sonst im Spiel mit F8")), auto)); h("label", { class: "switch" }, h("span", {}, "Beim Start verbinden", h("small", {}, "sonst im Spiel mit F8")), auto));
} }
// ---------------------------------------------------------------- Server-Browser // ---------------------------------------------------------------- Freunde & Lobby
// Die Lobby liegt auf der Website, ihr Server laeuft im BSO-Relay - keiner hostet, keiner braucht eine Portfreigabe.
let serverList = []; let social = { friends: { friends: [], incoming: [], outgoing: [] }, lobby: { lobby: null, invites: [] } };
const seenNotices = new Set();
async function renderServers(root) { async function loadSocial() {
const search = h("input", { placeholder: "Server suchen …", spellcheck: "false" }); const [friends, lobby] = await Promise.all([call("friends"), call("lobby")]);
const refresh = h("button", { class: "btn small", type: "button" }, "Aktualisieren"); social = { friends, lobby };
const list = h("div", { class: "server-list" }, h("p", { class: "muted" }, "Server werden gesucht …")); const n = friends.incoming.length + lobby.invites.length;
const direct = h("input", { placeholder: "Adresse, z.B. bso.example.de:7777", spellcheck: "false" }); $("friendsBadge").textContent = n;
const directJoin = h("button", { class: "btn small", type: "button" }, "Verbinden"); $("friendsBadge").classList.toggle("hidden", n === 0);
directJoin.addEventListener("click", () => join(directJoin, direct.value)); // Neue Einladungen/Anfragen melden (auch auf anderen Seiten)
for (const i of lobby.invites) notice("l" + i.lobbyId, `${i.owner} lädt dich in die Lobby ein`);
root.replaceChildren( for (const r of friends.incoming) notice("f" + r.id, `${r.username} möchte dein Freund sein`);
h("div", { class: "row", style: "justify-content:space-between;flex-wrap:wrap" }, h("h1", {}, "Server"),
h("div", { class: "row", style: "min-width:320px" }, search, refresh)),
list,
h("div", { class: "card" }, h("h3", {}, "Direkt verbinden"), h("div", { class: "row" }, direct, directJoin)));
const draw = () => {
const q = search.value.trim().toLowerCase();
const rows = serverList.filter((s) => !q || s.name.toLowerCase().includes(q) || s.address.toLowerCase().includes(q));
if (!rows.length) {
list.replaceChildren(h("p", { class: "muted" }, serverList.length ? "Kein Server passt zur Suche." : "Gerade ist kein öffentlicher Server online."));
return;
}
list.replaceChildren(...rows.map((s) => {
const b = h("button", { class: "btn primary small", type: "button" }, "BEITRETEN");
b.disabled = !s.compatible || s.players >= s.maxPlayers;
b.addEventListener("click", () => join(b, s.address));
const ping = s.ping == null ? h("span", { class: "muted" }, "–") : h("span", { class: s.ping < 80 ? "ok" : s.ping < 150 ? "warn" : "bad" }, s.ping + " ms");
return h("div", { class: "server" },
h("div", { class: "server-main" }, h("b", {}, s.name),
h("span", { class: "muted small" }, s.description || s.address)),
h("span", { class: "server-col" }, `${s.players}/${s.maxPlayers}`),
h("span", { class: "server-col" }, ping),
h("span", { class: "server-col muted small" }, s.compatible ? "v" + s.version : "andere Version"),
b);
}));
};
const load = async () => {
refresh.disabled = true;
try { serverList = await call("servers"); draw(); }
catch (e) { list.replaceChildren(h("p", { class: "error" }, e.message)); }
finally { refresh.disabled = false; }
};
search.addEventListener("input", draw);
refresh.addEventListener("click", load);
await load();
} }
/** Beitreten: Server merken, Spiel starten, im Spiel automatisch verbinden. */ function notice(key, text) {
function join(button, address) { if (seenNotices.has(key)) return;
if (!address.trim()) { toast("Adresse eingeben", true); return; } seenNotices.add(key);
busy(button, async () => { if (page !== "friends") toast(text);
await call("join", { address: address.trim() }); }
toast("Bus-Simulator 2012 startet und verbindet mit " + address.trim() + " …");
/** Alle 3 s auf der Seite Freunde, sonst alle 15 s (Badge, Meldungen) - meldet einen auch als "online". */
let socialTick = 0;
setInterval(async () => {
if ($("app").classList.contains("hidden")) return;
if (page !== "friends" && socialTick++ % 5 !== 0) return;
try { await loadSocial(); if (page === "friends") drawSocial(); } catch { }
}, 3000);
async function renderFriends(root) {
root.replaceChildren(h("h1", {}, "Freunde & Lobby"), h("div", { class: "social", id: "socialRoot" }, h("p", { class: "muted" }, "Laden …")));
try { await loadSocial(); }
catch (e) { $("socialRoot").replaceChildren(h("p", { class: "error" }, e.message)); return; }
if (page === "friends") drawSocial();
}
/** Knopf, der eine Aktion ausfuehrt und danach neu laedt. */
function action(label, cls, cmd, args, done) {
const b = h("button", { class: "btn small " + (cls || ""), type: "button" }, label);
b.addEventListener("click", () => busy(b, async () => {
await call(cmd, args || {});
if (done) done();
await loadSocial();
drawSocial();
}));
return b;
}
function drawSocial() {
const root = $("socialRoot");
if (!root) return;
root.replaceChildren(lobbyCard(), friendsCard());
}
function lobbyCard() {
const { lobby, invites } = social.lobby;
const card = h("div", { class: "card" });
for (const i of invites)
card.append(h("div", { class: "invite" }, h("span", {}, h("b", {}, i.owner), " lädt dich in die Lobby ein"),
action("Beitreten", "primary", "lobbyJoin", { lobbyId: i.lobbyId }), action("Ablehnen", "", "lobbyDecline", { lobbyId: i.lobbyId })));
if (!lobby) {
card.append(h("h3", {}, "Lobby"),
h("p", { class: "muted small" }, "Erstelle eine Lobby und lade Freunde ein. Der Server läuft auf dem BSO-Relay – niemand muss etwas hosten oder Ports freigeben."),
h("div", { class: "row" }, action("Lobby erstellen", "primary", "lobbyCreate")));
return card;
}
const play = h("button", { class: "btn primary play", type: "button" }, "SPIELEN");
play.disabled = !lobby.serverOnline;
play.addEventListener("click", () => busy(play, async () => { await call("playLobby"); toast("Bus-Simulator 2012 startet und verbindet mit der Lobby …"); }));
const state = h("div", { class: "server-state" },
h("span", { class: "dot " + (lobby.serverOnline ? "ok" : "bad") }),
h("span", { style: "flex:1" }, lobby.serverOnline ? "Server läuft" : lobby.isOwner ? "Server aus – starte ihn, dann können alle spielen" : `Server aus – warte, bis ${lobby.owner} ihn startet`),
lobby.isOwner ? (lobby.serverOnline ? action("Server stoppen", "", "lobbyStop") : action("Server starten", "primary", "lobbyStart")) : null);
const members = h("div", { class: "people" }, ...lobby.members.map((m) => h("div", { class: "person" },
h("span", { class: "dot " + (m.online ? "ok" : "") }),
h("span", { class: "name" }, m.username),
m.isOwner ? h("span", { class: "crown", title: "Ersteller" }, "★") : null,
lobby.isOwner && !m.isOwner ? action("Entfernen", "ghost", "lobbyKick", { accountId: m.accountId }) : null)));
card.append(h("h3", {}, lobby.isOwner ? "Deine Lobby" : `Lobby von ${lobby.owner}`), state,
h("div", { class: "section-title" }, `Mitglieder (${lobby.members.length})`), members,
lobby.invited.length ? h("p", { class: "muted small" }, `${lobby.invited.length} Einladung(en) offen`) : null,
h("div", { class: "row", style: "justify-content:space-between;margin-top:6px" }, play,
action(lobby.isOwner ? "Lobby auflösen" : "Lobby verlassen", "danger", "lobbyLeave")));
return card;
}
function friendsCard() {
const { friends, incoming, outgoing } = social.friends;
const lobby = social.lobby.lobby;
const name = h("input", { placeholder: "Benutzername", maxlength: "32", spellcheck: "false" });
const add = h("button", { class: "btn small primary", type: "button" }, "Hinzufügen");
const send = () => busy(add, async () => {
if (!name.value.trim()) return;
await call("friendRequest", { username: name.value.trim() });
toast("Anfrage an " + name.value.trim() + " geschickt");
name.value = "";
await loadSocial();
drawSocial();
}); });
add.addEventListener("click", send);
name.addEventListener("keydown", (e) => { if (e.key === "Enter") send(); });
const card = h("div", { class: "card" }, h("h3", {}, "Freunde"), h("div", { class: "row" }, name, add));
if (incoming.length)
card.append(h("div", { class: "section-title" }, "Anfragen"), h("div", { class: "people" }, ...incoming.map((r) => h("div", { class: "person" },
h("span", { class: "name" }, r.username), action("Annehmen", "primary", "friendAccept", { id: r.id }), action("Ablehnen", "", "friendRemove", { id: r.id })))));
card.append(h("div", { class: "section-title" }, `Freunde (${friends.filter((f) => f.online).length} online)`));
if (!friends.length) card.append(h("p", { class: "muted small" }, "Noch keine Freunde – füge jemanden über seinen BSO-Benutzernamen hinzu."));
card.append(h("div", { class: "people" }, ...friends.map((f) => {
const inLobby = lobby && lobby.members.some((m) => m.accountId === f.accountId);
const invited = lobby && lobby.invited.includes(f.accountId);
const remove = h("button", { class: "x", type: "button", title: "Freund entfernen" }, "");
remove.addEventListener("click", () => { if (confirm(`${f.username} als Freund entfernen?`)) busy(remove, async () => { await call("friendRemove", { id: f.id }); await loadSocial(); drawSocial(); }); });
return h("div", { class: "person" },
h("span", { class: "dot " + (f.online ? "ok" : "") }), h("span", { class: "name" }, f.username),
lobby && lobby.isOwner && !inLobby ? (invited ? h("span", { class: "muted small" }, "eingeladen") : action("Einladen", "", "lobbyInvite", { accountId: f.accountId })) : null,
inLobby ? h("span", { class: "muted small" }, "in der Lobby") : null,
remove);
})));
if (outgoing.length)
card.append(h("div", { class: "section-title" }, "Gesendet"), h("div", { class: "people" }, ...outgoing.map((r) => h("div", { class: "person" },
h("span", { class: "name" }, r.username), h("span", { class: "muted small" }, "wartet …"), action("Zurückziehen", "ghost", "friendRemove", { id: r.id })))));
return card;
} }
async function renderNews(root) { async function renderNews(root) {
+1 -1
View File
@@ -67,7 +67,7 @@
</div> </div>
<nav id="nav"> <nav id="nav">
<button type="button" data-page="home" class="on"><span class="nav-icon">&#xE768;</span>Spielen</button> <button type="button" data-page="home" class="on"><span class="nav-icon">&#xE768;</span>Spielen</button>
<button type="button" data-page="servers"><span class="nav-icon">&#xE968;</span>Server</button> <button type="button" data-page="friends"><span class="nav-icon">&#xE716;</span>Freunde<span class="nav-badge hidden" id="friendsBadge"></span></button>
<button type="button" data-page="news"><span class="nav-icon">&#xE789;</span>Neuigkeiten</button> <button type="button" data-page="news"><span class="nav-icon">&#xE789;</span>Neuigkeiten</button>
<button type="button" data-page="settings"><span class="nav-icon">&#xE713;</span>Einstellungen</button> <button type="button" data-page="settings"><span class="nav-icon">&#xE713;</span>Einstellungen</button>
</nav> </nav>