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
+133 -57
View File
@@ -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();
})();
})();