diff --git a/BSO.Launcher.csproj b/BSO.Launcher.csproj index 5fa9338..601f513 100644 --- a/BSO.Launcher.csproj +++ b/BSO.Launcher.csproj @@ -1,4 +1,4 @@ - + WinExe @@ -8,7 +8,7 @@ enable BSO.Launcher BSO-Launcher - 0.3.0 + 0.3.1 MBM.wtf BSO Launcher BSO Launcher - Bus Simulator Online diff --git a/CHANGELOG.md b/CHANGELOG.md index f928d12..20ef7c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ 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.1 +- Sprache: Deutsch oder Englisch (Einstellungen oder Umschalter auf der Anmeldeseite) – auch Meldungen und Fehler der Website + ## 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 diff --git a/MainForm.cs b/MainForm.cs index 4c3a85b..0611aa9 100644 --- a/MainForm.cs +++ b/MainForm.cs @@ -32,6 +32,7 @@ public sealed class MainForm : Form ClientSize = new Size(1120, 700); MinimumSize = new Size(900, 600); StartPosition = FormStartPosition.CenterScreen; + Lang.Set(settings.Language); LauncherLog.Info("BSO Launcher " + SelfUpdate.CurrentVersion + " gestartet" + (Api.Dev ? " (-dev)" : "") + ", Website " + Api.Base); if (Api.Base != Api.BaseUrl) Text += " (DEV)"; try { Icon = Icon.ExtractAssociatedIcon(Environment.ProcessPath ?? Application.ExecutablePath); } catch { } @@ -120,7 +121,7 @@ public sealed class MainForm : Form } catch (Exception e) { - var answer = MessageBox.Show("Die Microsoft Edge WebView2 Runtime fehlt (" + e.Message + ").\n\nDownload-Seite oeffnen?", + var answer = MessageBox.Show(Lang.T("Die Microsoft Edge WebView2 Runtime fehlt (" + e.Message + ").\n\nDownload-Seite oeffnen?"), Installer.AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Error); if (answer == DialogResult.Yes) OpenUrl("https://developer.microsoft.com/microsoft-edge/webview2/"); Close(); @@ -189,7 +190,7 @@ public sealed class MainForm : Form else web.CoreWebView2?.PostWebMessageAsJson(json); } - void Progress(string task, double value, string text) => Emit(new { @event = "progress", task, value, text }); + void Progress(string task, double value, string text) => Emit(new { @event = "progress", task, value, text = Lang.T(text) }); async Task OnMessage(string raw) { @@ -220,7 +221,7 @@ public sealed class MainForm : Form catch (Exception e) { LauncherLog.Error(e.Message); - Emit(new { id, ok = false, error = e.Message }); + Emit(new { id, ok = false, error = Lang.T(e.Message) }); } } @@ -246,6 +247,7 @@ public sealed class MainForm : Form username = settings.Username, loggedIn = settings.Token != null, master = Api.Base, + language = Lang.Current, dev = Api.Dev || Api.Base != Api.BaseUrl }; @@ -328,11 +330,12 @@ public sealed class MainForm : Form settings.AutoConnect = BoolArg(args, "autoConnect") ?? settings.AutoConnect; settings.KeepOpen = BoolArg(args, "keepOpen") ?? settings.KeepOpen; settings.AutoUpdateMod = BoolArg(args, "autoUpdateMod") ?? settings.AutoUpdateMod; + if (Arg(args, "language") is "de" or "en") { settings.Language = Arg(args, "language"); Lang.Set(settings.Language); } settings.Save(); return null; case "chooseGameFolder": { - using var dialog = new FolderBrowserDialog { Description = "Ordner \"Bus-Simulator 2012\" waehlen", UseDescriptionForTitle = true }; + using var dialog = new FolderBrowserDialog { Description = Lang.T("Ordner \"Bus-Simulator 2012\" waehlen"), UseDescriptionForTitle = true }; if (dialog.ShowDialog(this) != DialogResult.OK) return new { changed = false }; string folder = dialog.SelectedPath; // Auch Bin_High_Win64 selbst gewaehlt? Dann den Ordner darueber nehmen diff --git a/Program.cs b/Program.cs index 01e6920..316bf94 100644 --- a/Program.cs +++ b/Program.cs @@ -6,6 +6,7 @@ static class Program static int Main(string[] args) { ApplicationConfiguration.Initialize(); + Lang.Set(Settings.Load().Language); // Deinstallieren (aus "Apps & Features") if (args.Contains("--uninstall")) @@ -33,7 +34,7 @@ static class Program } if (!first) { - MessageBox.Show("Der BSO Launcher laeuft schon.", Installer.AppName, MessageBoxButtons.OK, MessageBoxIcon.Information); + MessageBox.Show(Lang.T("Der BSO Launcher laeuft schon."), Installer.AppName, MessageBoxButtons.OK, MessageBoxIcon.Information); return 0; } SelfUpdate.CleanupOldVersion(); diff --git a/Services/Installer.cs b/Services/Installer.cs index ca5f3fb..511b88d 100644 --- a/Services/Installer.cs +++ b/Services/Installer.cs @@ -45,7 +45,7 @@ public static class Installer /// Installieren. progress(0..1, Text). public static void Install(Action progress) { - progress(0.1, "Laufenden Launcher schliessen …"); + progress(0.1, Lang.T("Laufenden Launcher schliessen …")); foreach (var p in Process.GetProcessesByName("BSO-Launcher")) { try @@ -58,19 +58,19 @@ public static class Installer catch { } } - progress(0.3, "Dateien kopieren …"); + progress(0.3, Lang.T("Dateien kopieren …")); Directory.CreateDirectory(InstallDir); string tmp = InstalledExe + ".new"; File.Copy(Self, tmp, true); File.Move(tmp, InstalledExe, true); - progress(0.6, "Verknuepfungen anlegen …"); + progress(0.6, Lang.T("Verknuepfungen anlegen …")); CreateShortcut(StartMenuShortcut); CreateShortcut(DesktopShortcut); - progress(0.8, "Registrieren …"); + progress(0.8, Lang.T("Registrieren …")); Register(); - progress(1.0, "Fertig"); + progress(1.0, Lang.T("Fertig")); } /// Eintrag unter "Apps & Features" (bzw. dessen Version nach einem Selbst-Update). @@ -141,13 +141,13 @@ public static class Installer if (!quiet) { var answer = MessageBox.Show( - "BSO komplett deinstallieren?\n\nDas entfernt den Launcher, BSO aus dem Bus-Simulator-2012-Ordner (dinput8.dll, BSO-Ordner) " + - "und deine Anmeldung im Launcher.", + Lang.T("BSO komplett deinstallieren?\n\nDas entfernt den Launcher, BSO aus dem Bus-Simulator-2012-Ordner (dinput8.dll, BSO-Ordner) " + + "und deine Anmeldung im Launcher."), AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2); if (answer != DialogResult.Yes) return false; while (Game.IsRunning()) { - if (MessageBox.Show("Erst Bus-Simulator 2012 schliessen - solange es laeuft, sind die Dateien in Benutzung.", AppName, + if (MessageBox.Show(Lang.T("Erst Bus-Simulator 2012 schliessen - solange es laeuft, sind die Dateien in Benutzung."), AppName, MessageBoxButtons.RetryCancel, MessageBoxIcon.Warning) != DialogResult.Retry) return false; } } @@ -162,9 +162,9 @@ public static class Installer // Die laufende .exe laesst sich nicht selbst loeschen: aufraeumen, sobald dieser Prozess beendet ist RemoveAfterExit(dir, Settings.Folder); if (!quiet) - MessageBox.Show(problems.Count == 0 + MessageBox.Show(Lang.T(problems.Count == 0 ? "BSO wurde komplett deinstalliert." - : "BSO wurde deinstalliert. Einiges liess sich nicht entfernen:\n\n" + string.Join("\n", problems), + : "BSO wurde deinstalliert. Einiges liess sich nicht entfernen:\n\n" + string.Join("\n", problems)), AppName, MessageBoxButtons.OK, problems.Count == 0 ? MessageBoxIcon.Information : MessageBoxIcon.Warning); return true; } @@ -198,7 +198,7 @@ public sealed class InstallForm : Form status.Font = new Font("Segoe UI", 9.5f); status.TextAlign = ContentAlignment.MiddleCenter; status.SetBounds(20, 178, 380, 24); - status.Text = "Vorbereiten …"; + status.Text = Lang.T("Vorbereiten …"); Controls.Add(status); Shown += async (_, _) => await Run(); } @@ -238,7 +238,7 @@ public sealed class InstallForm : Form { error = e.Message; status.ForeColor = Color.FromArgb(0xff, 0x8a, 0x8d); - status.Text = "Installation fehlgeschlagen: " + e.Message; + status.Text = Lang.T("Installation fehlgeschlagen: " + e.Message); Invalidate(); await Task.Delay(6000); Close(); diff --git a/Services/Lang.cs b/Services/Lang.cs new file mode 100644 index 0000000..e010de8 --- /dev/null +++ b/Services/Lang.cs @@ -0,0 +1,83 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace BSO.Launcher; + +/// +/// Sprache der Meldungen, die vom Launcher selbst kommen (Fehler, Fortschritt, Dialoge). Im Code steht Deutsch; T() sucht +/// die englische Fassung - auch fuer Texte mit eingesetzten Werten ({0} im Muster). Die Oberflaeche hat ihre eigene Liste +/// (ui/i18n.js), Fehler der Website kommen dank Accept-Language schon in der richtigen Sprache. +/// +public static class Lang +{ + /// "de" oder "en" - aus den Einstellungen, beim ersten Start aus der Windows-Sprache. + public static string Current { get; set; } = Default; + + public static string Default => CultureInfo.CurrentUICulture.TwoLetterISOLanguageName == "de" ? "de" : "en"; + + static readonly (string de, string en)[] Texts = + { + ("Der BSO Launcher laeuft schon.", "The BSO Launcher is already running."), + ("Die Microsoft Edge WebView2 Runtime fehlt ({0}).\n\nDownload-Seite oeffnen?", "The Microsoft Edge WebView2 Runtime is missing ({0}).\n\nOpen the download page?"), + ("Laeuft schon - bitte warten", "Already in progress - please wait"), + ("Kein Launcher-Update gefunden", "No launcher update found"), + ("Launcher-Update wird geladen …", "Downloading launcher update …"), + ("In diesem Ordner ist kein Bus-Simulator 2012 (Bin_High_Win64\\BusSimulator2012.exe fehlt)", "There is no Bus-Simulator 2012 in this folder (Bin_High_Win64\\BusSimulator2012.exe is missing)"), + ("Unbekannter Befehl: {0}", "Unknown command: {0}"), + ("Bitte anmelden", "Please sign in"), + ("Anmeldung abgelaufen - bitte neu anmelden", "Session expired - please sign in again"), + ("Fehlgeschlagen", "Failed"), + ("Bus-Simulator 2012 nicht gefunden - in den Einstellungen den Spielordner waehlen", "Bus-Simulator 2012 not found - choose the game folder in the settings"), + ("Noch keine BSO-Version auf dem Server", "No BSO version on the server yet"), + ("BSO {0} wird geladen …", "Downloading BSO {0} …"), + ("Installieren …", "Installing …"), + ("Fuer BSO fehlt die .NET 10 Runtime (x64) - unter \"Status\" herunterladen", "BSO needs the .NET 10 Runtime (x64) - download it on the Play page"), + ("Bus-Simulator 2012 laeuft schon", "Bus-Simulator 2012 is already running"), + ("BSO ist nicht installiert und der BSO-Server ist nicht erreichbar", "BSO is not installed and the BSO server is not reachable"), + ("{0} {1} ist nicht von BSO signiert - nicht installiert", "{0} {1} is not signed by BSO - not installed"), + ("Ungueltige Pruefsumme", "Invalid checksum"), + ("Download abgebrochen (keine Verbindung) - nochmal versuchen, er macht dort weiter", "Download interrupted (no connection) - try again, it continues where it stopped"), + ("Der Download ist beschaedigt (Pruefsumme passt nicht) - bitte nochmal versuchen", "The download is damaged (checksum mismatch) - please try again"), + ("BSO-Server nicht erreichbar ({0})", "BSO server not reachable ({0})"), + ("Erst Bus-Simulator 2012 schliessen - solange es laeuft, sind die Mod-Dateien gesperrt", "Close Bus-Simulator 2012 first - the mod files are locked while it runs"), + ("Ungueltiger Pfad im Mod-Paket: {0}", "Invalid path in the mod package: {0}"), + ("Laufenden Launcher schliessen …", "Closing the running launcher …"), + ("Dateien kopieren …", "Copying files …"), + ("Verknuepfungen anlegen …", "Creating shortcuts …"), + ("Registrieren …", "Registering …"), + ("Fertig", "Done"), + ("Vorbereiten …", "Preparing …"), + ("Installation fehlgeschlagen: {0}", "Installation failed: {0}"), + ("BSO komplett deinstallieren?\n\nDas entfernt den Launcher, BSO aus dem Bus-Simulator-2012-Ordner (dinput8.dll, BSO-Ordner) und deine Anmeldung im Launcher.", + "Uninstall BSO completely?\n\nThis removes the launcher, BSO from the Bus-Simulator 2012 folder (dinput8.dll, BSO folder) and your launcher sign-in."), + ("Erst Bus-Simulator 2012 schliessen - solange es laeuft, sind die Dateien in Benutzung.", "Close Bus-Simulator 2012 first - its files are in use while it runs."), + ("BSO wurde komplett deinstalliert.", "BSO was uninstalled completely."), + ("BSO wurde deinstalliert. Einiges liess sich nicht entfernen:\n\n{0}", "BSO was uninstalled. Some things could not be removed:\n\n{0}"), + ("Neuigkeiten nicht verfuegbar", "News not available"), + ("Ordner \"Bus-Simulator 2012\" waehlen", "Choose the \"Bus-Simulator 2012\" folder"), + ("Konto konnte nicht geprueft werden: {0}", "Account could not be checked: {0}") + }; + + static readonly List<(Regex pattern, string en)> patterns = Texts.Select(x => (new Regex("^" + Regex.Replace(Regex.Escape(x.de), @"\\\{(\d)}", m => $"(?.*?)") + "$", RegexOptions.Singleline), x.en)).ToList(); + + /// Deutschen Text (auch mit eingesetzten Werten) in die aktuelle Sprache. Unbekannt -> unveraendert. + public static string T(string de) + { + if (Current != "en" || string.IsNullOrEmpty(de)) return de; + foreach ((Regex pattern, string en) in patterns) + { + var m = pattern.Match(de); + if (!m.Success) continue; + return Regex.Replace(en, @"\{(\d)}", x => m.Groups["a" + x.Groups[1].Value].Value); + } + return de; + } + + /// Sprache wechseln - auch fuer die Anfragen an die Website (Accept-Language: deren Fehler kommen passend). + public static void Set(string? lang) + { + Current = lang == "de" || lang == "en" ? lang : Default; + Api.Http.DefaultRequestHeaders.AcceptLanguage.Clear(); + Api.Http.DefaultRequestHeaders.AcceptLanguage.ParseAdd(Current); + } +} diff --git a/Services/Settings.cs b/Services/Settings.cs index 2cc2ce4..70b5b17 100644 --- a/Services/Settings.cs +++ b/Services/Settings.cs @@ -23,6 +23,8 @@ public sealed class Settings public bool AutoConnect { get; set; } = true; public bool KeepOpen { get; set; } public bool AutoUpdateMod { get; set; } = true; + /// "de" oder "en" - null = nach der Windows-Sprache (Lang.Default). + public string? Language { get; set; } [System.Text.Json.Serialization.JsonIgnore] public string? Token diff --git a/ui/app.css b/ui/app.css index bfb4164..3af8705 100644 --- a/ui/app.css +++ b/ui/app.css @@ -146,6 +146,15 @@ nav button.on { background: var(--panel-2); color: var(--text); } .dot.bad { background: var(--danger); } .row { display: flex; gap: 8px; align-items: center; } +/* ---------------------------------------------------------------- Sprache */ +.lang-switch { display: inline-flex; padding: 3px; border-radius: 9px; background: var(--bg); border: 1px solid var(--border); gap: 2px; } +.lang-switch button { border: 0; background: transparent; color: var(--muted); font: inherit; font-size: 12px; font-weight: 800; letter-spacing: .04em; + padding: 4px 10px; border-radius: 7px; cursor: pointer; } +.lang-switch button.on { background: var(--panel-2); color: var(--text); } +.sidebar-foot { display: grid; gap: 10px; justify-items: start; } +.sidebar-foot .me-card { justify-self: stretch; } +.auth-lang { position: fixed; top: calc(var(--titlebar) + 14px); right: 14px; } + /* ---------------------------------------------------------------- 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; } diff --git a/ui/app.js b/ui/app.js index fa7a576..de80744 100644 --- a/ui/app.js +++ b/ui/app.js @@ -1,5 +1,6 @@ "use strict"; // BSO Launcher - Oberflaeche. Spricht ueber window.chrome.webview mit dem Launcher (C#), nie direkt mit dem Netz. +// Texte auf Deutsch im Code, t() uebersetzt (i18n.js). // ---------------------------------------------------------------- Verbindung zum Launcher @@ -46,9 +47,9 @@ function h(tag, attrs, ...children) { } function toast(text, error) { - const t = h("div", { class: "toast" + (error ? " error" : "") }, text); - $("toasts").append(t); - setTimeout(() => t.remove(), error ? 7000 : 4000); + const el = h("div", { class: "toast" + (error ? " error" : "") }, text); + $("toasts").append(el); + setTimeout(() => el.remove(), error ? 7000 : 4000); } /** Knopf waehrend einer Aktion sperren; Fehler als Meldung. */ @@ -59,7 +60,19 @@ async function busy(button, action) { finally { button.disabled = false; } } -const date = (iso) => new Date(iso).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }); +const date = (iso) => new Date(iso).toLocaleDateString(lang === "en" ? "en-GB" : "de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }); + +// ---------------------------------------------------------------- Sprache + +/** Sprache wechseln: im Launcher speichern (auch fuer seine eigenen Meldungen), alles neu beschriften. */ +async function setLang(l) { + lang = l; + await call("setSettings", { language: l }); + translateStatic(); + renderLangSwitch(setLang); + setAuthMode(registerMode); + if (!$("app").classList.contains("hidden")) showPage(page); +} // ---------------------------------------------------------------- Fenster @@ -78,7 +91,7 @@ function setAuthMode(register) { $("tabRegister").classList.toggle("on", register); $("emailRow").classList.toggle("hidden", !register); $("password2Row").classList.toggle("hidden", !register); - $("authButton").textContent = register ? "Konto erstellen" : "Anmelden"; + $("authButton").textContent = register ? t("Konto erstellen") : t("Anmelden"); $("password").autocomplete = register ? "new-password" : "current-password"; $("authError").classList.add("hidden"); } @@ -91,7 +104,7 @@ $("authForm").addEventListener("submit", async (e) => { const error = $("authError"); error.classList.add("hidden"); if (registerMode && $("password").value !== $("password2").value) { - error.textContent = "Die Passwoerter sind nicht gleich"; + error.textContent = t("Die Passwoerter sind nicht gleich"); error.classList.remove("hidden"); return; } @@ -101,7 +114,7 @@ $("authForm").addEventListener("submit", async (e) => { { username: $("username").value, password: $("password").value, email: $("email").value }); $("password").value = $("password2").value = ""; showApp(r.username); - if (registerMode) toast("Willkommen bei BSO, " + r.username + "!"); + if (registerMode) toast(t("Willkommen bei BSO, {0}!", r.username)); } catch (err) { error.textContent = err.message; error.classList.remove("hidden"); @@ -139,14 +152,14 @@ 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 …")); + root.replaceChildren(h("p", { class: "muted" }, t("Laden …"))); ({ home: renderHome, friends: renderFriends, news: renderNews, settings: renderSettings })[name](root); } // Anmeldung abgelaufen (z.B. beim Holen des Spiel-Tickets) on("loggedOut", () => showAuth()); -// Fortschritt (Downloads) +// Fortschritt (Downloads) - der Text kommt schon uebersetzt vom Launcher let progressBox = null; on("progress", (m) => { if (!progressBox) return; @@ -162,31 +175,31 @@ async function renderHome(root) { 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"); + const play = h("button", { class: "btn primary play", type: "button" }, s.game.running ? t("LAEUFT") : t("SPIELEN")); play.disabled = s.game.running; play.addEventListener("click", () => busy(play, async () => { await call("launch"); - toast("Bus-Simulator 2012 startet …"); + toast(t("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 line = s.mod.installed ? t("BSO {0} installiert · Server {1}", s.mod.installed, s.settings.server) : t("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("div", {}, h("h1", {}, "Bus Simulator Online"), h("p", {}, t("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"))), + h("div", { class: "hero-actions" }, play, h("span", { class: "muted small" }, ready ? line : t("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)); + const b = h("button", { class: "btn primary small", type: "button" }, t("Jetzt aktualisieren")); + b.addEventListener("click", () => busy(b, async () => { await call("installLauncherUpdate"); toast(t("Launcher startet neu …")); })); + children.push(h("div", { class: "banner" }, h("span", {}, h("b", {}, t("Launcher {0}", s.launcherUpdate)), t(" 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: "banner warn" }, h("span", {}, t("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); @@ -197,26 +210,26 @@ function card(title, state, value, ...extra) { } function gameCard(s) { - const choose = h("button", { class: "btn small", type: "button" }, s.game.found ? "Anderen Ordner waehlen" : "Ordner waehlen"); + const choose = h("button", { class: "btn small", type: "button" }, s.game.found ? t("Anderen Ordner waehlen") : t("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."), + return card("Bus-Simulator 2012", s.game.found ? "ok" : "bad", s.game.found ? t("Gefunden") : t("Nicht gefunden"), + s.game.found ? h("span", { class: "mono muted" }, s.game.folder) : h("span", { class: "muted small" }, t("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 value = m.installed ? t("Version {0}", m.installed) : t("Nicht installiert"); + const info = m.updateAvailable ? t("Neu: {0}", m.latest) : m.latest ? t("Aktuell") : t("Keine Version auf dem Server"); const extra = [h("span", { class: "muted small" }, info)]; // 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 label = !m.installed ? t("Mod herunterladen") : m.updateAvailable ? t("Mod aktualisieren") : t("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"); + toast(t("BSO {0} installiert", r.version)); showPage("home"); })); extra.push(h("div", { class: "row" }, b)); @@ -225,11 +238,11 @@ function modCard(s) { } 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"); + if (s.dotnet) return card(".NET 10 Runtime", "ok", t("Installiert"), h("span", { class: "muted small" }, t("Die Mod laeuft mit .NET 10 (x64)."))); + const b = h("button", { class: "btn small", type: "button" }, t("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."), + return card(".NET 10 Runtime", "bad", t("Fehlt"), + h("span", { class: "muted small" }, t("BSO braucht die .NET 10 Runtime (x64) - \".NET Runtime\" fuer Windows x64 installieren.")), h("div", { class: "row" }, b)); } @@ -237,12 +250,12 @@ 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")); + const save = () => call("setSettings", { server: input.value.trim(), autoConnect: auto.checked }).then(() => toast(t("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)); + return card("Server", "ok", t("Mitspielen"), + h("label", {}, h("span", {}, t("Adresse")), input), + h("label", { class: "switch" }, h("span", {}, t("Beim Start verbinden"), h("small", {}, t("sonst im Spiel mit F8"))), auto)); } // ---------------------------------------------------------------- Freunde & Lobby @@ -258,8 +271,8 @@ async function loadSocial() { $("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`); + for (const i of lobby.invites) notice("l" + i.lobbyId, t("{0} lädt dich in die Lobby ein", i.owner)); + for (const r of friends.incoming) notice("f" + r.id, t("{0} möchte dein Freund sein", r.username)); } function notice(key, text) { @@ -277,7 +290,7 @@ setInterval(async () => { }, 3000); async function renderFriends(root) { - root.replaceChildren(h("h1", {}, "Freunde & Lobby"), h("div", { class: "social", id: "socialRoot" }, h("p", { class: "muted" }, "Laden …"))); + root.replaceChildren(h("h1", {}, t("Freunde & Lobby")), h("div", { class: "social", id: "socialRoot" }, h("p", { class: "muted" }, t("Laden …")))); try { await loadSocial(); } catch (e) { $("socialRoot").replaceChildren(h("p", { class: "error" }, e.message)); return; } if (page === "friends") drawSocial(); @@ -305,48 +318,48 @@ 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 }))); + card.append(h("div", { class: "invite" }, h("span", {}, h("b", {}, i.owner), t(" lädt dich in die Lobby ein")), + action(t("Beitreten"), "primary", "lobbyJoin", { lobbyId: i.lobbyId }), action(t("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"))); + card.append(h("h3", {}, t("Lobby")), + h("p", { class: "muted small" }, t("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(t("Lobby erstellen"), "primary", "lobbyCreate"))); return card; } - const play = h("button", { class: "btn primary play", type: "button" }, "SPIELEN"); + const play = h("button", { class: "btn primary play", type: "button" }, t("SPIELEN")); play.disabled = !lobby.serverOnline; - play.addEventListener("click", () => busy(play, async () => { await call("playLobby"); toast("Bus-Simulator 2012 startet und verbindet mit der Lobby …"); })); + play.addEventListener("click", () => busy(play, async () => { await call("playLobby"); toast(t("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); + h("span", { style: "flex:1" }, lobby.serverOnline ? t("Server läuft") : lobby.isOwner ? t("Server aus – starte ihn, dann können alle spielen") : t("Server aus – warte, bis {0} ihn startet", lobby.owner)), + lobby.isOwner ? (lobby.serverOnline ? action(t("Server stoppen"), "", "lobbyStop") : action(t("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))); + m.isOwner ? h("span", { class: "crown", title: t("Ersteller") }, "★") : null, + lobby.isOwner && !m.isOwner ? action(t("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, + card.append(h("h3", {}, lobby.isOwner ? t("Deine Lobby") : t("Lobby von {0}", lobby.owner)), state, + h("div", { class: "section-title" }, t("Mitglieder ({0})", lobby.members.length)), members, + lobby.invited.length ? h("p", { class: "muted small" }, t("{0} Einladung(en) offen", lobby.invited.length)) : null, h("div", { class: "row", style: "justify-content:space-between;margin-top:6px" }, play, - action(lobby.isOwner ? "Lobby auflösen" : "Lobby verlassen", "danger", "lobbyLeave"))); + action(lobby.isOwner ? t("Lobby auflösen") : t("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 name = h("input", { placeholder: t("Benutzername"), maxlength: "32", spellcheck: "false" }); + const add = h("button", { class: "btn small primary", type: "button" }, t("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"); + toast(t("Anfrage an {0} geschickt", name.value.trim())); name.value = ""; await loadSocial(); drawSocial(); @@ -354,41 +367,44 @@ function friendsCard() { 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)); + const card = h("div", { class: "card" }, h("h3", {}, t("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" }, t("Anfragen")), h("div", { class: "people" }, ...incoming.map((r) => h("div", { class: "person" }, + h("span", { class: "name" }, r.username), action(t("Annehmen"), "primary", "friendAccept", { id: r.id }), action(t("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: "section-title" }, t("Freunde ({0} online)", friends.filter((f) => f.online).length))); + if (!friends.length) card.append(h("p", { class: "muted small" }, t("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(); }); }); + const remove = h("button", { class: "x", type: "button", title: t("Freund entfernen") }, ""); + remove.addEventListener("click", () => { if (confirm(t("{0} als Freund entfernen?", f.username))) 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, + lobby && lobby.isOwner && !inLobby ? (invited ? h("span", { class: "muted small" }, t("eingeladen")) : action(t("Einladen"), "", "lobbyInvite", { accountId: f.accountId })) : null, + inLobby ? h("span", { class: "muted small" }, t("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 }))))); + card.append(h("div", { class: "section-title" }, t("Gesendet")), h("div", { class: "people" }, ...outgoing.map((r) => h("div", { class: "person" }, + h("span", { class: "name" }, r.username), h("span", { class: "muted small" }, t("wartet …")), action(t("Zurückziehen"), "ghost", "friendRemove", { id: r.id }))))); return card; } +// ---------------------------------------------------------------- Neuigkeiten, Einstellungen + 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; } + catch (e) { root.replaceChildren(h("h1", {}, t("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 = list.filter((r) => r.product !== "server"); // Server laufen jetzt im BSO-Relay + root.replaceChildren(h("h1", {}, t("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.")])); + h("pre", {}, r.changelog || t("Keine Beschreibung")))) : [h("p", { class: "muted" }, t("Noch keine Versionen veroeffentlicht."))])); } async function renderSettings(root) { @@ -397,34 +413,41 @@ async function renderSettings(root) { 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"))); + box.addEventListener("change", () => call("setSettings", { [key]: box.checked }).then(() => toast(t("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"); + const folder = h("button", { class: "btn small", type: "button" }, t("Spielordner waehlen")); + folder.addEventListener("click", () => busy(folder, async () => { if ((await call("chooseGameFolder")).changed) { toast(t("Spielordner gespeichert")); showPage("settings"); } })); + const log = h("button", { class: "btn small", type: "button" }, t("Protokoll-Ordner oeffnen")); log.addEventListener("click", () => call("openLog")); - const uninstall = h("button", { class: "btn small danger", type: "button" }, "BSO deinstallieren"); + const uninstall = h("button", { class: "btn small danger", type: "button" }, t("BSO deinstallieren")); uninstall.addEventListener("click", () => busy(uninstall, () => call("uninstall"))); + const langBox = h("div", { class: "lang-switch", "data-lang-switch": true }); - root.replaceChildren(h("h1", {}, "Einstellungen"), h("div", { class: "settings" }, + root.replaceChildren(h("h1", {}, t("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)), + toggle(t("BSO automatisch aktualisieren"), t("Vor jedem Start die neueste Mod-Version installieren"), "autoUpdateMod", s?.settings.autoUpdateMod ?? true), + toggle(t("Launcher offen lassen"), t("Sonst schliesst er sich nach dem Spielstart"), "keepOpen", s?.settings.keepOpen ?? false), + h("div", { class: "switch" }, h("span", {}, t("Sprache")), langBox)), h("div", { class: "card" }, - h("h3", {}, "Spiel"), - h("span", { class: "mono muted" }, s?.game.folder || "Nicht gefunden"), + h("h3", {}, t("Spiel")), + h("span", { class: "mono muted" }, s?.game.folder || t("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("span", { class: "muted small" }, t("Version {0} · Server {1}", init.version, init.master)), h("div", { class: "row" }, log, uninstall)))); + renderLangSwitch(setLang); } // ---------------------------------------------------------------- Start (async () => { const init = await call("init"); + lang = init.language === "en" ? "en" : "de"; + translateStatic(); + renderLangSwitch(setLang); + setAuthMode(false); $("meVersion").textContent = "Launcher " + init.version; if (init.dev) { // -dev: gegen die lokale Test-Website - gut sichtbar, damit niemand das verwechselt @@ -433,9 +456,9 @@ async function renderSettings(root) { $("devBadge").classList.remove("hidden"); $("meVersion").textContent += " · DEV"; } - $("authServer").textContent = "Server: " + init.master.replace(/^https?:\/\//, ""); + $("authServer").textContent = t("Server: {0}", init.master.replace(/^https?:\/\//, "")); if (!init.loggedIn) { showAuth(); return; } const me = await call("me"); if (me.loggedIn) showApp(me.username); else showAuth(); -})(); \ No newline at end of file +})(); diff --git a/ui/i18n.js b/ui/i18n.js new file mode 100644 index 0000000..1a16f61 --- /dev/null +++ b/ui/i18n.js @@ -0,0 +1,89 @@ +"use strict"; +// Sprachen der Oberflaeche. Deutsch ist der Text im Code selbst, hier steht nur Englisch dazu. +// t("Text mit {0}", wert) - fehlt eine Uebersetzung, bleibt der deutsche Text stehen. + +const EN = { + // Fenster, Anmeldung + "Minimieren": "Minimize", "Maximieren": "Maximize", "Schliessen": "Close", + "Anmelden": "Sign in", "Konto erstellen": "Create account", "Benutzername": "Username", "E-Mail (optional)": "Email (optional)", + "Passwort": "Password", "Passwort wiederholen": "Repeat password", "Abmelden": "Sign out", + "Dein Benutzername ist auch dein Name im Spiel. Passwoerter liegen nur verschluesselt auf dem BSO-Server.": + "Your username is also your name in the game. Passwords are only stored encrypted on the BSO server.", + "Die Passwoerter sind nicht gleich": "The passwords don't match", + "Willkommen bei BSO, {0}!": "Welcome to BSO, {0}!", + "Server: {0}": "Server: {0}", + // Navigation + "Spielen": "Play", "Freunde": "Friends", "Neuigkeiten": "News", "Einstellungen": "Settings", "Laden …": "Loading …", + // Spielen + "SPIELEN": "PLAY", "LAEUFT": "RUNNING", "Bus-Simulator 2012 startet …": "Bus-Simulator 2012 is starting …", + "BSO {0} installiert · Server {1}": "BSO {0} installed · Server {1}", "BSO wird beim ersten Start installiert": "BSO will be installed on first start", + "Multiplayer fuer Bus-Simulator 2012": "Multiplayer for Bus-Simulator 2012", + "Vor dem Start fehlt noch etwas - siehe unten": "Something is still missing - see below", + "Jetzt aktualisieren": "Update now", "Launcher startet neu …": "Launcher is restarting …", "Launcher {0}": "Launcher {0}", " ist da.": " is available.", + "BSO-Server nicht erreichbar - Updates werden spaeter geprueft.": "BSO server not reachable - updates will be checked later.", + "Anderen Ordner waehlen": "Choose another folder", "Ordner waehlen": "Choose folder", "Gefunden": "Found", "Nicht gefunden": "Not found", + "Ueber Steam installiert? Sonst den Ordner von Hand waehlen.": "Installed via Steam? Otherwise choose the folder manually.", + "Version {0}": "Version {0}", "Nicht installiert": "Not installed", "Neu: {0}": "New: {0}", "Aktuell": "Up to date", + "Keine Version auf dem Server": "No version on the server", "Mod herunterladen": "Download mod", "Mod aktualisieren": "Update mod", + "Neu herunterladen": "Download again", "BSO {0} installiert": "BSO {0} installed", + "Installiert": "Installed", "Die Mod laeuft mit .NET 10 (x64).": "The mod runs on .NET 10 (x64).", "Herunterladen": "Download", "Fehlt": "Missing", + "BSO braucht die .NET 10 Runtime (x64) - \".NET Runtime\" fuer Windows x64 installieren.": "BSO needs the .NET 10 Runtime (x64) - install \".NET Runtime\" for Windows x64.", + "Mitspielen": "Play together", "Adresse": "Address", "Gespeichert": "Saved", "Beim Start verbinden": "Connect on start", "sonst im Spiel mit F8": "otherwise in game with F8", + // Freunde & Lobby + "Freunde & Lobby": "Friends & lobby", "{0} lädt dich in die Lobby ein": "{0} invites you to their lobby", " lädt dich in die Lobby ein": " invites you to their lobby", + "{0} möchte dein Freund sein": "{0} wants to be your friend", "Beitreten": "Join", "Ablehnen": "Decline", "Lobby": "Lobby", + "Erstelle eine Lobby und lade Freunde ein. Der Server läuft auf dem BSO-Relay – niemand muss etwas hosten oder Ports freigeben.": + "Create a lobby and invite friends. The server runs on the BSO relay – nobody has to host anything or forward ports.", + "Lobby erstellen": "Create lobby", "Bus-Simulator 2012 startet und verbindet mit der Lobby …": "Bus-Simulator 2012 is starting and connecting to the lobby …", + "Server läuft": "Server is running", "Server aus – starte ihn, dann können alle spielen": "Server is off – start it so everyone can play", + "Server aus – warte, bis {0} ihn startet": "Server is off – wait until {0} starts it", "Server stoppen": "Stop server", "Server starten": "Start server", + "Ersteller": "Owner", "Entfernen": "Remove", "Deine Lobby": "Your lobby", "Lobby von {0}": "{0}'s lobby", "Mitglieder ({0})": "Members ({0})", + "{0} Einladung(en) offen": "{0} invitation(s) pending", "Lobby auflösen": "Close lobby", "Lobby verlassen": "Leave lobby", + "Hinzufügen": "Add", "Anfrage an {0} geschickt": "Request sent to {0}", "Anfragen": "Requests", "Annehmen": "Accept", + "Freunde ({0} online)": "Friends ({0} online)", "Noch keine Freunde – füge jemanden über seinen BSO-Benutzernamen hinzu.": "No friends yet – add someone by their BSO username.", + "Freund entfernen": "Remove friend", "{0} als Freund entfernen?": "Remove {0} as a friend?", "eingeladen": "invited", "Einladen": "Invite", + "in der Lobby": "in the lobby", "Gesendet": "Sent", "wartet …": "pending …", "Zurückziehen": "Withdraw", + // Neuigkeiten + "Keine Beschreibung": "No description", "Noch keine Versionen veroeffentlicht.": "No versions published yet.", + // Einstellungen + "Spielordner waehlen": "Choose game folder", "Spielordner gespeichert": "Game folder saved", "Protokoll-Ordner oeffnen": "Open log folder", + "BSO deinstallieren": "Uninstall BSO", "BSO automatisch aktualisieren": "Update BSO automatically", + "Vor jedem Start die neueste Mod-Version installieren": "Install the newest mod version before every start", + "Launcher offen lassen": "Keep launcher open", "Sonst schliesst er sich nach dem Spielstart": "Otherwise it closes after the game starts", + "Spiel": "Game", "Version {0} · Server {1}": "Version {0} · Server {1}", "Sprache": "Language" +}; + +let lang = "de"; + +function t(de, ...args) { + let s = lang === "en" && EN[de] != null ? EN[de] : de; + args.forEach((a, i) => { s = s.split("{" + i + "}").join(String(a)); }); + return s; +} + +/** Feste Texte in index.html: data-t (Text), data-t-title (Tooltip), data-t-placeholder - Original bleibt in data-de. */ +function translateStatic() { + document.querySelectorAll("[data-t]").forEach((el) => { + if (el.dataset.de == null) el.dataset.de = el.textContent; + el.textContent = t(el.dataset.de); + }); + document.querySelectorAll("[data-t-title]").forEach((el) => { + el.title = t(el.dataset.tTitle); + el.setAttribute("aria-label", el.title); + }); + document.documentElement.lang = lang; +} + +/** Umschalter DE | EN (in jedes Element mit data-lang-switch). */ +function renderLangSwitch(onChange) { + document.querySelectorAll("[data-lang-switch]").forEach((box) => { + box.replaceChildren(...["de", "en"].map((l) => { + const b = document.createElement("button"); + b.type = "button"; + b.textContent = l.toUpperCase(); + b.className = l === lang ? "on" : ""; + b.addEventListener("click", () => onChange(l)); + return b; + })); + }); +} diff --git a/ui/index.html b/ui/index.html index d6c1502..47a7666 100644 --- a/ui/index.html +++ b/ui/index.html @@ -5,20 +5,21 @@ BSO Launcher + - +
BSO Launcher - +
- - - + + +
@@ -33,26 +34,27 @@
- - + +
-