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;
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface LocaleInfo {
|
||||
renpy: string;
|
||||
modules: Record<string, string>;
|
||||
strings: string;
|
||||
font?: string | null;
|
||||
lines: number;
|
||||
progress: number;
|
||||
}
|
||||
@@ -24,6 +25,7 @@ export class Translator {
|
||||
public readonly renpyName: string | null,
|
||||
private lines: Map<string, string> = new Map(),
|
||||
private strings: Map<string, string> = new Map(),
|
||||
public readonly font: string | null = null, // logical path of the locale's font
|
||||
) {}
|
||||
|
||||
static source(game: GameData) {
|
||||
@@ -77,11 +79,13 @@ export async function loadTranslator(game: GameData, manifest: Manifest, lang: s
|
||||
return r.json() as Promise<Record<string, Entry>>;
|
||||
});
|
||||
};
|
||||
const [strings, ...modules] = await Promise.all([blob(info.strings), ...Object.values(info.modules).map(blob)]);
|
||||
const [strings, ...modules] = await Promise.all([
|
||||
info.strings ? blob(info.strings) : Promise.resolve({} as Record<string, Entry>),
|
||||
...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])));
|
||||
new Map(Object.entries(strings).map(([k, e]) => [k, e.t])), info.font ?? null);
|
||||
}
|
||||
|
||||
export function languageName(game: GameData, lang: string): string {
|
||||
|
||||
@@ -45,7 +45,10 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
|
||||
const manifest = await getJSON<Manifest>(`/api/versions/${versionId}/manifest`);
|
||||
const game = await getJSON<GameData>(blob(manifest, "game.json"));
|
||||
onProgress(`Загрузка скриптов (${game.scripts.length})`);
|
||||
const lang = preferredLanguage(game);
|
||||
// ?lang= and ?at= come from the translation editor ("play from here")
|
||||
const url = new URLSearchParams(location.search);
|
||||
const asked = url.get("lang");
|
||||
const lang = asked && (asked === game.sourceLang || game.locales?.[asked]) ? asked : preferredLanguage(game);
|
||||
const [modules, tr] = await Promise.all([
|
||||
Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s)))),
|
||||
loadTranslator(game, manifest, lang),
|
||||
@@ -53,6 +56,8 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
|
||||
const engine = new Engine(root, game, manifest, modules, saveApi(versionId),
|
||||
`pgvn:persistent:${versionId}`, onExit, tr,
|
||||
(code) => loadTranslator(game, manifest, code));
|
||||
engine.start();
|
||||
const at = url.get("at");
|
||||
if (at) engine.startAt(at);
|
||||
else engine.start();
|
||||
return engine;
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
}
|
||||
.pg-stage.pg-game-font { font-family: "pg-game-font", "Segoe UI", system-ui, sans-serif; }
|
||||
.pg-stage.pg-game-name-font .pg-namebox { font-family: "pg-game-name-font", "Segoe UI", system-ui, sans-serif; }
|
||||
.pg-stage.pg-locale-font, .pg-stage.pg-locale-font .pg-namebox { font-family: "pg-locale-font", "Segoe UI", system-ui, sans-serif; }
|
||||
|
||||
.pg-layer { position: absolute; inset: 0; }
|
||||
.pg-transition { pointer-events: none; }
|
||||
|
||||
Reference in new issue
Block a user