diff --git a/BSO.Launcher.csproj b/BSO.Launcher.csproj index 36f40ce..5fa9338 100644 --- a/BSO.Launcher.csproj +++ b/BSO.Launcher.csproj @@ -8,7 +8,7 @@ enable BSO.Launcher BSO-Launcher - 0.2.0 + 0.3.0 MBM.wtf BSO Launcher BSO Launcher - Bus Simulator Online diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e8150c..f928d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ Neue Version: hier einen Abschnitt `## ` anlegen, `` 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. +## 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 - Server-Browser: alle öffentlichen Server mit Spielerzahl und Ping, BEITRETEN startet das Spiel und verbindet automatisch - Direkt per Adresse verbinden diff --git a/MainForm.cs b/MainForm.cs index 001883d..4c3a85b 100644 --- a/MainForm.cs +++ b/MainForm.cs @@ -19,9 +19,9 @@ public sealed class MainForm : Form readonly Settings settings = Settings.Load(); /// Befehle, die nur einmal gleichzeitig laufen duerfen (Downloads/Installationen/Start). - static readonly HashSet Exclusive = new() { "installMod", "launch", "join", "installLauncherUpdate", "uninstall" }; + static readonly HashSet Exclusive = new() { "installMod", "launch", "playLobby", "installLauncherUpdate", "uninstall" }; readonly HashSet running = new(); - static readonly HashSet QuietCommands = new() { "window", "status", "me", "init", "releases", "servers" }; + static readonly HashSet QuietCommands = new() { "window", "status", "me", "init", "releases", "friends", "lobby" }; bool updateInstalled; static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; @@ -288,18 +288,23 @@ public sealed class MainForm : Form return await InstallMod(); case "launch": return await Launch(); - case "servers": - return await ServerList(); - case "join": - { - // Beitreten: Server merken, beim Start automatisch verbinden, Spiel starten - string address = Arg(args, "address").Trim(); - if (address.Length == 0 || address.Length > 260) throw new Exception("Ungueltige Serveradresse"); - settings.Server = address; - settings.AutoConnect = true; - settings.Save(); - return await Launch(); - } + case "playLobby": + return await Launch(lobby: true); + + // --- Freunde und Lobby (Website, mit dem Konto-Token) + case "friends": return await Site("GET", "/api/friends"); + case "friendRequest": return await Site("POST", "/api/friends/request", new { username = Arg(args, "username").Trim() }); + case "friendAccept": return await Site("POST", "/api/friends/accept", new { id = Arg(args, "id") }); + case "friendRemove": return await Site("POST", "/api/friends/remove", new { id = Arg(args, "id") }); + case "lobby": return await Site("GET", "/api/lobby"); + case "lobbyCreate": return await Site("POST", "/api/lobby/create"); + 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": { var r = await Api.Releases(); @@ -375,19 +380,21 @@ public sealed class MainForm : Form }; } - /// Serverliste der Website, jeder Server direkt angefragt (Ping, aktuelle Spielerzahl). Nicht erreichbar = ohne Ping. - static async Task ServerList() + /// Anfrage an die Website mit dem Konto-Token. Abgelaufene Anmeldung -> zurueck zum Anmelden. + async Task Site(string method, string path, object? body = null) { - var servers = await Api.Servers(); - var pings = await Task.WhenAll(servers.Select(s => ServerPing.Query(s.host, s.port))); - return servers.Select((s, i) => new - { - s.name, s.description, s.address, s.version, - players = pings[i]?.Players ?? s.players, - maxPlayers = pings[i]?.MaxPlayers ?? s.maxPlayers, - 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(); + string token = settings.Token ?? throw new Exception("Bitte anmelden"); + var r = method == "GET" ? await Api.Get(path, token) : await Api.Post(path, token, body); + 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() + { + settings.Token = null; + settings.Save(); + Emit(new { @event = "loggedOut" }); } async Task InstallMod() @@ -400,7 +407,8 @@ public sealed class MainForm : Form return new { version = latest.version }; } - async Task Launch() + /// Spiel starten. lobby: auf den Server der eigenen Lobby (laeuft im BSO-Relay), sonst auf settings.Server. + async Task Launch(bool lobby = false) { 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"); @@ -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"); } - // Spiel-Ticket: ohne gueltiges Konto kein Multiplayer (Mod und Server pruefen es) - string token = settings.Token ?? throw new Exception("Bitte anmelden"); - var t = await Api.Ticket(token); - if (t.Status == 401) + // Spiel-Ticket: ohne gueltiges Konto kein Multiplayer (Mod und Relay pruefen es). Fuer die Lobby gilt es fuer deren Server. + var t = await Site("POST", "/api/auth/ticket", lobby ? new { lobby = true } : null); + string ticket = t.GetProperty("ticket").GetString()!; + if (lobby) { - settings.Token = null; - settings.Save(); - Emit(new { @event = "loggedOut" }); - throw new Exception("Anmeldung abgelaufen - bitte neu anmelden"); + string lobbyId = t.GetProperty("lobby").GetString()!, label = t.GetProperty("lobbyName").GetString() ?? "Lobby"; + string relay = (await Site("GET", "/api/config")).GetProperty("relay").GetString()!; + Game.WriteModConfig(folder, settings.Username, "relay:" + lobbyId, true, ticket, label, relay); + LauncherLog.Info($"Spiel gestartet ({label} ueber {relay})"); + } + else + { + Game.WriteModConfig(folder, settings.Username, settings.Server, settings.AutoConnect, ticket); + LauncherLog.Info($"Spiel gestartet (Server {settings.Server}, automatisch verbinden {settings.AutoConnect})"); } - if (!t.Ok) throw new Exception("Konto konnte nicht geprueft werden: " + t.Error); - string ticket = t.Json.GetProperty("ticket").GetString()!; - - Game.WriteModConfig(folder, settings.Username, settings.Server, settings.AutoConnect, ticket); Game.Launch(); - LauncherLog.Info($"Spiel gestartet (Server {settings.Server}, automatisch verbinden {settings.AutoConnect})"); if (!settings.KeepOpen) { await Task.Delay(2500); diff --git a/README.md b/README.md index 06bcd79..31ea3d6 100644 --- a/README.md +++ b/README.md @@ -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. - Mod: [BSO-Mod](https://git.mbm.wtf/MrMaho/BSO-Mod) -- Dedizierter Server: [BSO-Server](https://git.mbm.wtf/MrMaho/BSO-Server) -- Website, Konten und Releases: [BSO-Website](https://git.mbm.wtf/MrMaho/BSO-Website) +- Lobby-Server (ohne Portfreigabe): [BSO-Relay](https://git.mbm.wtf/MrMaho/BSO-Relay) +- Website, Konten, Freunde, Lobbys, Releases: [BSO-Website](https://git.mbm.wtf/MrMaho/BSO-Website) ## 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. 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). -- **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. ## Bauen und veröffentlichen diff --git a/Services/Api.cs b/Services/Api.cs index 021e063..647fd5d 100644 --- a/Services/Api.cs +++ b/Services/Api.cs @@ -71,20 +71,10 @@ public static class Api public static Task Logout(string token) => Send(HttpMethod.Post, "/api/auth/logout", new { }, token); - /// Spiel-Ticket fuer die Mod (signiert, 24 h) -> { ticket, username, expiresUtc } - public static Task 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); - - /// Oeffentliche, erreichbare Server (Serverliste der Website). - public static async Task> 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>() ?? new List(); - } + // --- Freunde und Lobby (alles mit dem Konto-Token) + public static Task Get(string path, string token) => Send(HttpMethod.Get, path, null, token); + public static Task Post(string path, string token, object? body = null) => Send(HttpMethod.Post, path, body ?? new { }, token); public static async Task Latest(string product) { var r = await Send(HttpMethod.Get, "/api/releases/latest?product=" + product, null, null); diff --git a/Services/Game.cs b/Services/Game.cs index 70f759b..7aba3ce 100644 --- a/Services/Game.cs +++ b/Services/Game.cs @@ -85,8 +85,8 @@ public static class Game LauncherLog.Info("BSO installiert nach " + bin); } - /// Einstellungen der Mod (BSO\BSO.json) - Name = Kontoname, Server, automatisch verbinden, Spiel-Ticket. - public static void WriteModConfig(string folder, string? name, string server, bool autoConnect, string? ticket) + /// Einstellungen der Mod (BSO\BSO.json) - Name = Kontoname, Server (Adresse oder relay:<Lobby>), Anzeige, Relay, Spiel-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"); JsonObject json; @@ -96,6 +96,8 @@ public static class Game json["Server"] = server; json["AutoConnect"] = autoConnect; json["Ticket"] = ticket; + json["ServerLabel"] = label; + if (relay != null) json["Relay"] = relay; Directory.CreateDirectory(Path.GetDirectoryName(file)!); File.WriteAllText(file, json.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); } diff --git a/Services/ServerPing.cs b/Services/ServerPing.cs deleted file mode 100644 index 6b10d34..0000000 --- a/Services/ServerPing.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Diagnostics; -using System.Net.Sockets; -using System.Text; - -namespace BSO.Launcher; - -/// -/// 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). -/// -public static class ServerPing -{ - public sealed record Info(int Ping, string Name, string Version, int Players, int MaxPlayers); - - public static async Task 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; } - } -} diff --git a/ui/app.css b/ui/app.css index a89eb52..bfb4164 100644 --- a/ui/app.css +++ b/ui/app.css @@ -146,16 +146,22 @@ nav button.on { background: var(--panel-2); color: var(--text); } .dot.bad { background: var(--danger); } .row { display: flex; gap: 8px; align-items: center; } -/* ---------------------------------------------------------------- Server-Browser */ -.server-list { display: grid; gap: 8px; } -.server { display: grid; grid-template-columns: 1fr 70px 70px 110px auto; align-items: center; gap: 14px; padding: 14px 18px; - background: var(--panel); border: 1px solid var(--border); border-radius: 12px; } -.server:hover { border-color: #36404f; } -.server-main { display: grid; min-width: 0; } -.server-main b, .server-main span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.server-col { text-align: right; font-variant-numeric: tabular-nums; } -.ok { color: var(--online); } .warn { color: var(--pending); } .bad { color: var(--danger); } - +/* ---------------------------------------------------------------- Freunde & Lobby */ +.nav-badge { margin-left: auto; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; background: var(--danger); color: #fff; + font-size: 11px; font-weight: 800; display: grid; place-items: center; } +.social { display: grid; grid-template-columns: 1.15fr 1fr; gap: 16px; align-items: start; } +.people { display: grid; gap: 6px; } +.person { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 10px; background: var(--panel-2); } +.person .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; } +.person .crown { color: var(--pending); font-size: 13px; } +.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 */ .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; } diff --git a/ui/app.js b/ui/app.js index bbf9aa7..fa7a576 100644 --- a/ui/app.js +++ b/ui/app.js @@ -140,7 +140,7 @@ function showPage(name) { document.querySelectorAll("#nav [data-page]").forEach((b) => b.classList.toggle("on", b.dataset.page === name)); const root = $("page"); 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) @@ -210,8 +210,10 @@ function modCard(s) { 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 extra = [h("span", { class: "muted small" }, info)]; - if (m.updateAvailable && s.game.found) { - const b = h("button", { class: "btn small", type: "button" }, m.installed ? "Aktualisieren" : "Installieren"); + // Immer da: holt die neueste Mod von der Website (bei SPIELEN passiert das sonst automatisch, wenn eingeschaltet) + 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 () => { const r = await call("installMod"); 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)); } -// ---------------------------------------------------------------- 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) { - const search = h("input", { placeholder: "Server suchen …", spellcheck: "false" }); - const refresh = h("button", { class: "btn small", type: "button" }, "Aktualisieren"); - const list = h("div", { class: "server-list" }, h("p", { class: "muted" }, "Server werden gesucht …")); - const direct = h("input", { placeholder: "Adresse, z.B. bso.example.de:7777", spellcheck: "false" }); - const directJoin = h("button", { class: "btn small", type: "button" }, "Verbinden"); - directJoin.addEventListener("click", () => join(directJoin, direct.value)); - - root.replaceChildren( - 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(); +async function loadSocial() { + const [friends, lobby] = await Promise.all([call("friends"), call("lobby")]); + social = { friends, lobby }; + const n = friends.incoming.length + lobby.invites.length; + $("friendsBadge").textContent = n; + $("friendsBadge").classList.toggle("hidden", n === 0); + // 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`); + for (const r of friends.incoming) notice("f" + r.id, `${r.username} möchte dein Freund sein`); } -/** Beitreten: Server merken, Spiel starten, im Spiel automatisch verbinden. */ -function join(button, address) { - if (!address.trim()) { toast("Adresse eingeben", true); return; } - busy(button, async () => { - await call("join", { address: address.trim() }); - toast("Bus-Simulator 2012 startet und verbindet mit " + address.trim() + " …"); +function notice(key, text) { + if (seenNotices.has(key)) return; + seenNotices.add(key); + if (page !== "friends") toast(text); +} + +/** 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) { @@ -362,4 +438,4 @@ async function renderSettings(root) { const me = await call("me"); if (me.loggedIn) showApp(me.username); else showAuth(); -})(); +})(); \ No newline at end of file diff --git a/ui/index.html b/ui/index.html index c52ffea..d6c1502 100644 --- a/ui/index.html +++ b/ui/index.html @@ -67,7 +67,7 @@