// Game localisation: per-language line tables (by tid) and a strings table (by source text). import type { GameData, Manifest } from "./types"; export interface LocaleInfo { name: string; renpy: string; modules: Record; strings: string; font?: string | null; lines: number; progress: number; } interface Entry { t: string; state?: string; } const PREF_KEY = "pgvn:lang"; export class Translator { constructor( public readonly lang: string, // BCP-47; equals sourceLang when untranslated public readonly renpyName: string | null, private lines: Map = new Map(), private strings: Map = new Map(), public readonly font: string | null = null, // logical path of the locale's font ) {} static source(game: GameData) { return new Translator(game.sourceLang ?? "und", null); } /** Dialogue line by translation id, falling back to the source text. */ line(tid: string | undefined, source: string): string { return (tid && this.lines.get(tid)) || source; } /** Names, menu choices, `_()` strings: looked up by their source text. */ str(source: string): string { return this.strings.get(source) ?? source; } } export function preferredLanguage(game: GameData): string { const available = [game.sourceLang ?? "und", ...Object.keys(game.locales ?? {})]; let saved: string | null = null; try { saved = localStorage.getItem(PREF_KEY); } catch { /* storage unavailable */ } if (saved && available.includes(saved)) return saved; // browser languages, matched exactly or by primary subtag (ru-RU -> ru) for (const nav of navigator.languages ?? [navigator.language]) { const hit = available.find((a) => a === nav) ?? available.find((a) => a.split("-")[0] === nav.split("-")[0]); if (hit) return hit; } return game.sourceLang ?? "und"; } export function rememberLanguage(lang: string) { try { localStorage.setItem(PREF_KEY, lang); } catch { /* storage unavailable */ } } export async function loadTranslator(game: GameData, manifest: Manifest, lang: string): Promise { const info = game.locales?.[lang]; if (!info) return Translator.source(game); const blob = (path: string) => { const f = manifest.files[path]; if (!f) throw new Error(`в версии нет файла ${path}`); return fetch(`/api/versions/${manifest.versionId}/files/${f.hash}`).then((r) => { if (!r.ok) throw new Error(`${r.status} ${path}`); return r.json() as Promise>; }); }; const [strings, ...modules] = await Promise.all([ info.strings ? blob(info.strings) : Promise.resolve({} as Record), ...Object.values(info.modules).map(blob)]); const lines = new Map(); for (const mod of modules) for (const [tid, e] of Object.entries(mod)) lines.set(tid, e.t); return new Translator(lang, info.renpy, lines, new Map(Object.entries(strings).map(([k, e]) => [k, e.t])), info.font ?? null); } export function languageName(game: GameData, lang: string): string { if (lang === game.sourceLang) { const known: Record = { en: "English", ru: "Русский", fr: "Français", de: "Deutsch", es: "Español", ja: "日本語" }; return `${known[lang] ?? lang} (оригинал)`; } return game.locales?.[lang]?.name ?? lang; }