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:
+1
-1
@@ -8,7 +8,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>BSO.Launcher</RootNamespace>
|
||||
<AssemblyName>BSO-Launcher</AssemblyName>
|
||||
<Version>0.2.0</Version>
|
||||
<Version>0.3.0</Version>
|
||||
<Company>MBM.wtf</Company>
|
||||
<Product>BSO Launcher</Product>
|
||||
<Description>BSO Launcher - Bus Simulator Online</Description>
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
+46
-38
@@ -19,9 +19,9 @@ public sealed class MainForm : Form
|
||||
readonly Settings settings = Settings.Load();
|
||||
|
||||
/// <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();
|
||||
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;
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serverliste der Website, jeder Server direkt angefragt (Ping, aktuelle Spielerzahl). Nicht erreichbar = ohne Ping.</summary>
|
||||
static async Task<object> ServerList()
|
||||
/// <summary>Anfrage an die Website mit dem Konto-Token. Abgelaufene Anmeldung -> zurueck zum Anmelden.</summary>
|
||||
async Task<JsonElement> 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
|
||||
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()
|
||||
{
|
||||
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();
|
||||
settings.Token = null;
|
||||
settings.Save();
|
||||
Emit(new { @event = "loggedOut" });
|
||||
}
|
||||
|
||||
async Task<object> InstallMod()
|
||||
@@ -400,7 +407,8 @@ public sealed class MainForm : Form
|
||||
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");
|
||||
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})");
|
||||
}
|
||||
if (!t.Ok) throw new Exception("Konto konnte nicht geprueft werden: " + t.Error);
|
||||
string ticket = t.Json.GetProperty("ticket").GetString()!;
|
||||
|
||||
else
|
||||
{
|
||||
Game.WriteModConfig(folder, settings.Username, settings.Server, settings.AutoConnect, ticket);
|
||||
Game.Launch();
|
||||
LauncherLog.Info($"Spiel gestartet (Server {settings.Server}, automatisch verbinden {settings.AutoConnect})");
|
||||
}
|
||||
Game.Launch();
|
||||
if (!settings.KeepOpen)
|
||||
{
|
||||
await Task.Delay(2500);
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-13
@@ -71,20 +71,10 @@ public static class Api
|
||||
|
||||
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)
|
||||
{
|
||||
var r = await Send(HttpMethod.Get, "/api/releases/latest?product=" + product, null, null);
|
||||
|
||||
+4
-2
@@ -85,8 +85,8 @@ public static class Game
|
||||
LauncherLog.Info("BSO installiert nach " + bin);
|
||||
}
|
||||
|
||||
/// <summary>Einstellungen der Mod (BSO\BSO.json) - Name = Kontoname, Server, automatisch verbinden, Spiel-Ticket.</summary>
|
||||
public static void WriteModConfig(string folder, string? name, string server, bool autoConnect, string? ticket)
|
||||
/// <summary>Einstellungen der Mod (BSO\BSO.json) - Name = Kontoname, Server (Adresse oder relay:<Lobby>), Anzeige, Relay, Spiel-Ticket.</summary>
|
||||
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 }));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
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`);
|
||||
}
|
||||
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);
|
||||
|
||||
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();
|
||||
}));
|
||||
};
|
||||
|
||||
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();
|
||||
return b;
|
||||
}
|
||||
|
||||
/** 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 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) {
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@
|
||||
</div>
|
||||
<nav id="nav">
|
||||
<button type="button" data-page="home" class="on"><span class="nav-icon"></span>Spielen</button>
|
||||
<button type="button" data-page="servers"><span class="nav-icon"></span>Server</button>
|
||||
<button type="button" data-page="friends"><span class="nav-icon"></span>Freunde<span class="nav-badge hidden" id="friendsBadge"></span></button>
|
||||
<button type="button" data-page="news"><span class="nav-icon"></span>Neuigkeiten</button>
|
||||
<button type="button" data-page="settings"><span class="nav-icon"></span>Einstellungen</button>
|
||||
</nav>
|
||||
|
||||
Reference in New Issue
Block a user