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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 03:39:40 +03:00
1 parent 5d9ddb6376
commit e1d7b29f77
16 files changed
+4280 -3530

No files matched your search

+94
View File
@@ -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;
}