- Seite Server: Liste der Website, Ping/Spieler direkt abgefragt, BEITRETEN und Direkt verbinden - Holt beim Spielen ein Spiel-Ticket und schreibt es in BSO.json Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
366 lines
16 KiB
JavaScript
366 lines
16 KiB
JavaScript
"use strict";
|
||
// BSO Launcher - Oberflaeche. Spricht ueber window.chrome.webview mit dem Launcher (C#), nie direkt mit dem Netz.
|
||
|
||
// ---------------------------------------------------------------- Verbindung zum Launcher
|
||
|
||
let nextId = 1;
|
||
const pending = new Map();
|
||
const listeners = new Map();
|
||
|
||
function call(cmd, args) {
|
||
return new Promise((resolve, reject) => {
|
||
const id = nextId++;
|
||
pending.set(id, { resolve, reject });
|
||
window.chrome.webview.postMessage({ id, cmd, args: args || {} });
|
||
});
|
||
}
|
||
|
||
window.chrome.webview.addEventListener("message", (e) => {
|
||
const msg = e.data;
|
||
if (msg.event) { (listeners.get(msg.event) || []).forEach((f) => f(msg)); return; }
|
||
const p = pending.get(msg.id);
|
||
if (!p) return;
|
||
pending.delete(msg.id);
|
||
msg.ok ? p.resolve(msg.result) : p.reject(new Error(msg.error));
|
||
});
|
||
|
||
function on(event, f) {
|
||
if (!listeners.has(event)) listeners.set(event, []);
|
||
listeners.get(event).push(f);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Hilfen
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
function h(tag, attrs, ...children) {
|
||
const el = document.createElement(tag);
|
||
for (const [k, v] of Object.entries(attrs || {})) {
|
||
if (v == null || v === false) continue;
|
||
if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
|
||
else if (k === "class") el.className = v;
|
||
else el.setAttribute(k, v === true ? "" : v);
|
||
}
|
||
for (const c of children.flat()) if (c != null && c !== false) el.append(c.nodeType ? c : String(c));
|
||
return el;
|
||
}
|
||
|
||
function toast(text, error) {
|
||
const t = h("div", { class: "toast" + (error ? " error" : "") }, text);
|
||
$("toasts").append(t);
|
||
setTimeout(() => t.remove(), error ? 7000 : 4000);
|
||
}
|
||
|
||
/** Knopf waehrend einer Aktion sperren; Fehler als Meldung. */
|
||
async function busy(button, action) {
|
||
button.disabled = true;
|
||
try { return await action(); }
|
||
catch (e) { toast(e.message, true); }
|
||
finally { button.disabled = false; }
|
||
}
|
||
|
||
const date = (iso) => new Date(iso).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||
|
||
// ---------------------------------------------------------------- Fenster
|
||
|
||
document.querySelectorAll("[data-window]").forEach((b) => b.addEventListener("click", () => call("window", { action: b.dataset.window })));
|
||
$("titlebarDrag").addEventListener("mousedown", (e) => { if (e.button === 0 && e.detail === 1) call("window", { action: "drag" }); });
|
||
$("titlebarDrag").addEventListener("dblclick", () => call("window", { action: "maximize" }));
|
||
on("window", (m) => { $("maximizeButton").innerHTML = m.maximized ? "" : ""; });
|
||
|
||
// ---------------------------------------------------------------- Anmeldung
|
||
|
||
let registerMode = false;
|
||
|
||
function setAuthMode(register) {
|
||
registerMode = register;
|
||
$("tabLogin").classList.toggle("on", !register);
|
||
$("tabRegister").classList.toggle("on", register);
|
||
$("emailRow").classList.toggle("hidden", !register);
|
||
$("password2Row").classList.toggle("hidden", !register);
|
||
$("authButton").textContent = register ? "Konto erstellen" : "Anmelden";
|
||
$("password").autocomplete = register ? "new-password" : "current-password";
|
||
$("authError").classList.add("hidden");
|
||
}
|
||
|
||
$("tabLogin").addEventListener("click", () => setAuthMode(false));
|
||
$("tabRegister").addEventListener("click", () => setAuthMode(true));
|
||
|
||
$("authForm").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const error = $("authError");
|
||
error.classList.add("hidden");
|
||
if (registerMode && $("password").value !== $("password2").value) {
|
||
error.textContent = "Die Passwoerter sind nicht gleich";
|
||
error.classList.remove("hidden");
|
||
return;
|
||
}
|
||
$("authButton").disabled = true;
|
||
try {
|
||
const r = await call(registerMode ? "register" : "login",
|
||
{ username: $("username").value, password: $("password").value, email: $("email").value });
|
||
$("password").value = $("password2").value = "";
|
||
showApp(r.username);
|
||
if (registerMode) toast("Willkommen bei BSO, " + r.username + "!");
|
||
} catch (err) {
|
||
error.textContent = err.message;
|
||
error.classList.remove("hidden");
|
||
} finally {
|
||
$("authButton").disabled = false;
|
||
}
|
||
});
|
||
|
||
$("logout").addEventListener("click", async () => {
|
||
await call("logout");
|
||
showAuth();
|
||
});
|
||
|
||
function showAuth() {
|
||
$("app").classList.add("hidden");
|
||
$("auth").classList.remove("hidden");
|
||
setAuthMode(false);
|
||
$("username").focus();
|
||
}
|
||
|
||
function showApp(username) {
|
||
$("auth").classList.add("hidden");
|
||
$("app").classList.remove("hidden");
|
||
$("meName").textContent = username;
|
||
$("meAvatar").textContent = (username || "?").slice(0, 1).toUpperCase();
|
||
showPage("home");
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Seiten
|
||
|
||
let page = "home";
|
||
document.querySelectorAll("#nav [data-page]").forEach((b) => b.addEventListener("click", () => showPage(b.dataset.page)));
|
||
|
||
function showPage(name) {
|
||
page = 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);
|
||
}
|
||
|
||
// Anmeldung abgelaufen (z.B. beim Holen des Spiel-Tickets)
|
||
on("loggedOut", () => showAuth());
|
||
|
||
// Fortschritt (Downloads)
|
||
let progressBox = null;
|
||
on("progress", (m) => {
|
||
if (!progressBox) return;
|
||
progressBox.classList.remove("hidden");
|
||
progressBox.querySelector(".bar > div").style.width = Math.round(m.value * 100) + "%";
|
||
progressBox.querySelector("span").textContent = m.text + " " + Math.round(m.value * 100) + " %";
|
||
});
|
||
|
||
async function renderHome(root) {
|
||
let s;
|
||
try { s = await call("status"); }
|
||
catch (e) { root.replaceChildren(h("p", { class: "error" }, e.message)); return; }
|
||
if (page !== "home") return;
|
||
|
||
progressBox = h("div", { class: "progress hidden" }, h("span", { class: "small muted" }), h("div", { class: "bar" }, h("div")));
|
||
const play = h("button", { class: "btn primary play", type: "button" }, s.game.running ? "LAEUFT" : "SPIELEN");
|
||
play.disabled = s.game.running;
|
||
play.addEventListener("click", () => busy(play, async () => {
|
||
await call("launch");
|
||
toast("Bus-Simulator 2012 startet …");
|
||
}));
|
||
|
||
const ready = s.game.found && s.dotnet && (s.mod.installed || s.mod.latest);
|
||
const line = s.mod.installed ? `BSO ${s.mod.installed} installiert · Server ${s.settings.server}` : "BSO wird beim ersten Start installiert";
|
||
|
||
const children = [
|
||
h("div", { class: "hero" },
|
||
h("div", {}, h("h1", {}, "Bus Simulator Online"), h("p", {}, "Multiplayer fuer Bus-Simulator 2012")),
|
||
h("img", { src: "logo.svg", alt: "" }),
|
||
h("div", { class: "hero-actions" }, play, h("span", { class: "muted small" }, ready ? line : "Vor dem Start fehlt noch etwas - siehe unten"))),
|
||
progressBox
|
||
];
|
||
|
||
if (s.launcherUpdate) {
|
||
const b = h("button", { class: "btn primary small", type: "button" }, "Jetzt aktualisieren");
|
||
b.addEventListener("click", () => busy(b, async () => { await call("installLauncherUpdate"); toast("Launcher startet neu …"); }));
|
||
children.push(h("div", { class: "banner" }, h("span", {}, h("b", {}, "Launcher " + s.launcherUpdate), " ist da."), b));
|
||
}
|
||
if (!s.masterReachable)
|
||
children.push(h("div", { class: "banner warn" }, h("span", {}, "BSO-Server nicht erreichbar - Updates werden spaeter geprueft.")));
|
||
|
||
children.push(h("div", { class: "cards" }, gameCard(s), modCard(s), dotnetCard(s), serverCard(s)));
|
||
root.replaceChildren(...children);
|
||
}
|
||
|
||
function card(title, state, value, ...extra) {
|
||
return h("div", { class: "card" }, h("h3", {}, h("span", { class: "dot " + state }), title), h("div", { class: "value" }, value), ...extra);
|
||
}
|
||
|
||
function gameCard(s) {
|
||
const choose = h("button", { class: "btn small", type: "button" }, s.game.found ? "Anderen Ordner waehlen" : "Ordner waehlen");
|
||
choose.addEventListener("click", () => busy(choose, async () => { if ((await call("chooseGameFolder")).changed) showPage("home"); }));
|
||
return card("Bus-Simulator 2012", s.game.found ? "ok" : "bad", s.game.found ? "Gefunden" : "Nicht gefunden",
|
||
s.game.found ? h("span", { class: "mono muted" }, s.game.folder) : h("span", { class: "muted small" }, "Ueber Steam installiert? Sonst den Ordner von Hand waehlen."),
|
||
h("div", { class: "row" }, choose));
|
||
}
|
||
|
||
function modCard(s) {
|
||
const m = s.mod;
|
||
const state = m.installed && !m.updateAvailable ? "ok" : m.latest ? "warn" : "bad";
|
||
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");
|
||
b.addEventListener("click", () => busy(b, async () => {
|
||
const r = await call("installMod");
|
||
toast("BSO " + r.version + " installiert");
|
||
showPage("home");
|
||
}));
|
||
extra.push(h("div", { class: "row" }, b));
|
||
}
|
||
return card("BSO-Mod", state, value, ...extra);
|
||
}
|
||
|
||
function dotnetCard(s) {
|
||
if (s.dotnet) return card(".NET 10 Runtime", "ok", "Installiert", h("span", { class: "muted small" }, "Die Mod laeuft mit .NET 10 (x64)."));
|
||
const b = h("button", { class: "btn small", type: "button" }, "Herunterladen");
|
||
b.addEventListener("click", () => call("openDotnet"));
|
||
return card(".NET 10 Runtime", "bad", "Fehlt",
|
||
h("span", { class: "muted small" }, "BSO braucht die .NET 10 Runtime (x64) - \".NET Runtime\" fuer Windows x64 installieren."),
|
||
h("div", { class: "row" }, b));
|
||
}
|
||
|
||
function serverCard(s) {
|
||
const input = h("input", { value: s.settings.server, placeholder: "host:7777", spellcheck: "false" });
|
||
const auto = h("input", { type: "checkbox" });
|
||
auto.checked = s.settings.autoConnect;
|
||
const save = () => call("setSettings", { server: input.value.trim(), autoConnect: auto.checked }).then(() => toast("Gespeichert"));
|
||
input.addEventListener("change", save);
|
||
auto.addEventListener("change", save);
|
||
return card("Server", "ok", "Mitspielen",
|
||
h("label", {}, h("span", {}, "Adresse"), input),
|
||
h("label", { class: "switch" }, h("span", {}, "Beim Start verbinden", h("small", {}, "sonst im Spiel mit F8")), auto));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Server-Browser
|
||
|
||
let serverList = [];
|
||
|
||
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();
|
||
}
|
||
|
||
/** 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() + " …");
|
||
});
|
||
}
|
||
|
||
async function renderNews(root) {
|
||
let list;
|
||
try { list = await call("releases"); }
|
||
catch (e) { root.replaceChildren(h("h1", {}, "Neuigkeiten"), h("p", { class: "error" }, e.message)); return; }
|
||
if (page !== "news") return;
|
||
const names = { mod: "Mod", launcher: "Launcher", server: "Server" };
|
||
root.replaceChildren(h("h1", {}, "Neuigkeiten"),
|
||
...(list.length ? list.map((r) => h("article", { class: "release" },
|
||
h("header", {}, h("span", { class: "tag" }, names[r.product] || r.product), h("h2", {}, r.version), h("span", { class: "muted small" }, date(r.date))),
|
||
h("pre", {}, r.changelog || "Keine Beschreibung"))) : [h("p", { class: "muted" }, "Noch keine Versionen veroeffentlicht.")]));
|
||
}
|
||
|
||
async function renderSettings(root) {
|
||
const [init, s] = await Promise.all([call("init"), call("status").catch(() => null)]);
|
||
if (page !== "settings") return;
|
||
const toggle = (label, hint, key, value) => {
|
||
const box = h("input", { type: "checkbox" });
|
||
box.checked = value;
|
||
box.addEventListener("change", () => call("setSettings", { [key]: box.checked }).then(() => toast("Gespeichert")));
|
||
return h("label", { class: "switch" }, h("span", {}, label, h("small", {}, hint)), box);
|
||
};
|
||
const folder = h("button", { class: "btn small", type: "button" }, "Spielordner waehlen");
|
||
folder.addEventListener("click", () => busy(folder, async () => { if ((await call("chooseGameFolder")).changed) { toast("Spielordner gespeichert"); showPage("settings"); } }));
|
||
const log = h("button", { class: "btn small", type: "button" }, "Protokoll-Ordner oeffnen");
|
||
log.addEventListener("click", () => call("openLog"));
|
||
const uninstall = h("button", { class: "btn small danger", type: "button" }, "BSO deinstallieren");
|
||
uninstall.addEventListener("click", () => busy(uninstall, () => call("uninstall")));
|
||
|
||
root.replaceChildren(h("h1", {}, "Einstellungen"), h("div", { class: "settings" },
|
||
h("div", { class: "card" },
|
||
toggle("BSO automatisch aktualisieren", "Vor jedem Start die neueste Mod-Version installieren", "autoUpdateMod", s?.settings.autoUpdateMod ?? true),
|
||
toggle("Launcher offen lassen", "Sonst schliesst er sich nach dem Spielstart", "keepOpen", s?.settings.keepOpen ?? false)),
|
||
h("div", { class: "card" },
|
||
h("h3", {}, "Spiel"),
|
||
h("span", { class: "mono muted" }, s?.game.folder || "Nicht gefunden"),
|
||
h("div", { class: "row" }, folder)),
|
||
h("div", { class: "card" },
|
||
h("h3", {}, "Launcher"),
|
||
h("span", { class: "muted small" }, `Version ${init.version} · Server ${init.master}`),
|
||
h("div", { class: "row" }, log, uninstall))));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Start
|
||
|
||
(async () => {
|
||
const init = await call("init");
|
||
$("meVersion").textContent = "Launcher " + init.version;
|
||
if (init.dev) {
|
||
// -dev: gegen die lokale Test-Website - gut sichtbar, damit niemand das verwechselt
|
||
const host = init.master.replace(/^https?:\/\//, "");
|
||
$("devBadge").textContent = "DEV · " + host;
|
||
$("devBadge").classList.remove("hidden");
|
||
$("meVersion").textContent += " · DEV";
|
||
}
|
||
$("authServer").textContent = "Server: " + init.master.replace(/^https?:\/\//, "");
|
||
if (!init.loggedIn) { showAuth(); return; }
|
||
const me = await call("me");
|
||
if (me.loggedIn) showApp(me.username);
|
||
else showAuth();
|
||
})();
|