Localization stages 2-3: drafts, publishing, translation editor

- games.main_version_id, version visibility (draft/published) and kind
  (original/translation/mod), version_history; migration 0002
- permissions translations.edit and versions.publish
- "Translate" creates a translation draft; publishing merges the changed
  languages into the main version in place (players keep their saves), the
  previous manifest goes to the history and can be restored
- translation API: overview with per-state stats, entries with filters,
  search and scene context, save, glossary, locale font, PO/CSV export/import
- translation editor page: languages and progress, lines and strings,
  filters, autosave, approve/outdated/fuzzy actions, glossary highlighting,
  "play from here" with the scene restored
- player: ?lang= and ?at= from the editor, per-locale font

(models.py and permissions.py for this landed earlier in 9ee3e1f)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 03:56:19 +03:00
1 parent 4e047f3aa0
commit ac164d1782
20 files changed
+2039 -298

No files matched your search

+57 -3
View File
@@ -407,6 +407,7 @@ export class Engine {
try {
this.tr = await this.loadLocale(lang);
rememberLanguage(lang);
this.applyLocaleFont();
this.notify(languageName(this.game, lang));
} catch (err) {
this.notify(`Не удалось загрузить язык: ${(err as Error).message}`);
@@ -995,7 +996,53 @@ export class Engine {
this.showMainMenu();
}
private newGame() {
/** Preview from a given line (translation editor): game variables keep their defaults. */
startAt(nodeId: string) {
const at = this.findNode(nodeId);
if (!at) {
this.start();
this.notify("Строка не найдена в этой версии");
return;
}
this.newGame(at);
// rebuild the picture: replay the visual statements of the label that come before the line
const body = this.labels.get(at.label)!.label.body;
for (const n of body.slice(0, at.path[0])) {
if (n.op === "scene") {
this.renderer.scene();
if (n.img) this.showNode(n);
} else if (n.op === "show") this.showNode(n);
else if (n.op === "hide") this.renderer.hide(n.tag);
}
this.renderer.commit();
this.notify("Предпросмотр с выбранной строки — переменные игры по умолчанию");
}
private findNode(id: string): Frame | null {
const visit = (nodes: Node[], label: string, prefix: number[]): Frame | null => {
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
const path = [...prefix, i];
if (n.id === id || (n.op === "menu" && n.say?.id === id)) return { label, path };
const children: [Node[], number][] =
n.op === "if" ? n.branches.map((b: any, k: number) => [b.body, k])
: n.op === "menu" ? n.items.map((it: any, k: number) => [it.body, k])
: n.op === "while" ? [[n.body, 0]] : [];
for (const [body, k] of children) {
const hit = visit(body, label, [...path, k]);
if (hit) return hit;
}
}
return null;
};
for (const { label } of this.labels.values()) {
const hit = visit(label.body, label.name, []);
if (hit) return hit;
}
return null;
}
private newGame(at?: Frame) {
this.restart(() => {
this.audio.stopAll();
this.renderer.scene();
@@ -1006,8 +1053,8 @@ export class Engine {
this.history = [];
this.lastSay = null;
this.textbox.classList.add("pg-hidden");
this.cur = { label: this.game.entry, path: [0] };
this.markSeen(this.game.entry);
this.cur = at ?? { label: this.game.entry, path: [0] };
this.markSeen(this.cur.label);
this.settle();
});
}
@@ -1095,6 +1142,7 @@ export class Engine {
if (ui.text_color) stage.style.setProperty("--pg-text", ui.text_color);
this.loadFont("pg-game-font", ui.text_font);
this.loadFont("pg-game-name-font", ui.name_text_font ?? ui.text_font);
this.applyLocaleFont();
this.textbox = el("div", "pg-textbox pg-hidden");
const boxH = px(ui.textbox_height, h, Math.round(h * 0.257));
@@ -1171,6 +1219,12 @@ export class Engine {
}, { passive: true });
}
/** A translation may bring its own font when the game's fonts lack its glyphs. */
private applyLocaleFont() {
this.renderer.stage.classList.remove("pg-locale-font");
if (this.tr.font) this.loadFont("pg-locale-font", this.tr.font);
}
private loadFont(family: string, file?: string) {
const url = file ? this.fileUrl(file) : null;
if (!url) return;