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:
1 parent
4e047f3aa0
commit
ac164d1782
20 files changed
+2039
-298
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user