Localization stage 1: stable IDs, import of tl/ translations, language picker
- every script node gets a permanent id, say nodes a Ren'Py-style tid - all languages from the game's tl/ are imported at conversion: dialogue blocks matched by their source-location comment or by label + original text, `translate strings` become a per-language strings table - source language from config.language or detected from the dialogue - manifest stores locales/<lang>/<module>.json and strings.json; game.json lists sourceLang and available locales with progress - player: language picker in main menu and settings, fallback to source, __(), [var!t] and _preferences.language - parser: `default` with init priority, and one bad statement no longer drops the whole file - docs/LOCALIZATION.md: design for translations, publishing and mods Line endings of several converter/player files go back to LF: an earlier commit accidentally stored them as CRLF. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5d9ddb6376
commit
e1d7b29f77
16 files changed
+4280
-3530
No files matched your search
@@ -0,0 +1,94 @@
|
||||
// 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<string, string>;
|
||||
strings: string;
|
||||
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<string, string> = new Map(),
|
||||
private strings: Map<string, string> = new Map(),
|
||||
) {}
|
||||
|
||||
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<Translator> {
|
||||
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(`/blobs/${f.hash}`).then((r) => {
|
||||
if (!r.ok) throw new Error(`${r.status} ${path}`);
|
||||
return r.json() as Promise<Record<string, Entry>>;
|
||||
});
|
||||
};
|
||||
const [strings, ...modules] = await Promise.all([blob(info.strings), ...Object.values(info.modules).map(blob)]);
|
||||
const lines = new Map<string, string>();
|
||||
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])));
|
||||
}
|
||||
|
||||
export function languageName(game: GameData, lang: string): string {
|
||||
if (lang === game.sourceLang) {
|
||||
const known: Record<string, string> = { en: "English", ru: "Русский", fr: "Français", de: "Deutsch",
|
||||
es: "Español", ja: "日本語" };
|
||||
return `${known[lang] ?? lang} (оригинал)`;
|
||||
}
|
||||
return game.locales?.[lang]?.name ?? lang;
|
||||
}
|
||||
Reference in new issue
Block a user