-
-
Subtitles
-
- Per-scene timed subtitle entries for locale {activeLocale} .
- Each entry has a start/end time and text.
-
-
- Full subtitle editor coming in a future release.
-
-
+function DecisionTranslationRow({ decisionKey, existing, onSave, onDelete }: {
+ decisionKey: string
+ existing?: DecisionTranslation
+ onSave: (label: string) => void
+ onDelete?: () => void
+}) {
+ const [label, setLabel] = useState(existing?.label ?? '')
-
-
Decision Labels
-
- Translated button labels for each decision key per scene for locale {activeLocale} .
-
-
- Full translation editor coming in a future release.
-
-
+ useEffect(() => { setLabel(existing?.label ?? '') }, [existing])
-
-
- The localization data model (locales, subtitles, decision translations) is stored in the database.
- The project default locale is set in Project Settings .
-
-
+ return (
+
+
+ {decisionKey}
+ {existing && translated }
+ {!existing && not translated }
+ {existing && onDelete && (
+
+ ×
+
+ )}
+
+
+ setLabel(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') onSave(label) }}
+ placeholder="Translated label…"
+ className="input-base flex-1"
+ />
+ onSave(label)}
+ disabled={!label.trim()}
+ className="font-medium bg-primary text-primary-foreground rounded-lg hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0"
+ style={{ padding: '6px 16px', fontSize: 13 }}
+ >
+ Save
+
)
diff --git a/editor/frontend/src/pages/CanvasPage.tsx b/editor/frontend/src/pages/CanvasPage.tsx
index cc4364d..59e26bd 100644
--- a/editor/frontend/src/pages/CanvasPage.tsx
+++ b/editor/frontend/src/pages/CanvasPage.tsx
@@ -161,7 +161,9 @@ function GraphCanvas() {
)}
{validationPanelOpen && !selectedNodeData && !selectedEdgeId &&
}
{localizationPanelOpen && !selectedNodeData && !selectedEdgeId && (
-
+
+
+
)}
{projectSettingsPanelOpen && (
diff --git a/runtime/src/main/java/com/engine/runtime/RuntimeServer.java b/runtime/src/main/java/com/engine/runtime/RuntimeServer.java
index bdbdd09..e613217 100644
--- a/runtime/src/main/java/com/engine/runtime/RuntimeServer.java
+++ b/runtime/src/main/java/com/engine/runtime/RuntimeServer.java
@@ -72,6 +72,7 @@ public void start() throws Exception {
server.createContext("/api/game/decide", this::handleDecide);
server.createContext("/api/game/restart", this::handleRestart);
server.createContext("/api/game/has-save", this::handleHasSave);
+ server.createContext("/api/game/locales", this::handleLocales);
server.createContext("/hls/", this::handleHls);
server.createContext("/assets/", this::handleAssets);
server.createContext("/", this::handleStatic);
@@ -85,8 +86,9 @@ private void handleState(HttpExchange ex) throws IOException {
if ("OPTIONS".equals(ex.getRequestMethod())) { RequestHelper.handleOptions(ex); return; }
if (!"GET".equals(ex.getRequestMethod())) { RequestHelper.sendError(ex, 405, "Method not allowed"); return; }
+ String locale = queryParam(ex, "locale");
synchronized (stateLock) {
- RequestHelper.sendJson(ex, 200, buildStateResponse(state));
+ RequestHelper.sendJson(ex, 200, buildStateResponse(state, locale));
}
}
@@ -104,6 +106,7 @@ private void handleDecide(HttpExchange ex) throws IOException {
RequestHelper.sendError(ex, 400, "decisionKey is required"); return;
}
+ String locale = queryParam(ex, "locale");
synchronized (stateLock) {
if (state.gameOver) {
RequestHelper.sendError(ex, 409, "Game is over. Call /api/game/restart to play again."); return;
@@ -122,7 +125,7 @@ private void handleDecide(HttpExchange ex) throws IOException {
} else {
resp.put("transition", null);
}
- resp.put("nextState", buildStateResponse(state));
+ resp.put("nextState", buildStateResponse(state, locale));
RequestHelper.sendJson(ex, 200, resp);
}
@@ -140,10 +143,11 @@ private void handleRestart(HttpExchange ex) throws IOException {
if ("OPTIONS".equals(ex.getRequestMethod())) { RequestHelper.handleOptions(ex); return; }
if (!"POST".equals(ex.getRequestMethod())) { RequestHelper.sendError(ex, 405, "Method not allowed"); return; }
+ String locale = queryParam(ex, "locale");
synchronized (stateLock) {
state = new GameState(manifest.rootNodeId);
stateStore.save(state);
- RequestHelper.sendJson(ex, 200, buildStateResponse(state));
+ RequestHelper.sendJson(ex, 200, buildStateResponse(state, locale));
}
}
@@ -158,6 +162,23 @@ private void handleHasSave(HttpExchange ex) throws IOException {
RequestHelper.sendJson(ex, 200, Map.of("hasSave", hasSave));
}
+ // ── GET /api/game/locales ─────────────────────────────────────────────────
+
+ private void handleLocales(HttpExchange ex) throws IOException {
+ if ("OPTIONS".equals(ex.getRequestMethod())) { RequestHelper.handleOptions(ex); return; }
+ if (!"GET".equals(ex.getRequestMethod())) { RequestHelper.sendError(ex, 405, "Method not allowed"); return; }
+
+ Map resp = new LinkedHashMap<>();
+ resp.put("defaultLocaleCode", engine.defaultLocaleCode());
+ resp.put("locales", engine.availableLocales().stream().map(l -> {
+ Map lm = new LinkedHashMap<>();
+ lm.put("code", l.code);
+ lm.put("name", l.name);
+ return lm;
+ }).toList());
+ RequestHelper.sendJson(ex, 200, resp);
+ }
+
// ── GET /hls/{path} — serve HLS files from outputDir ─────────────────────
private void handleHls(HttpExchange ex) throws IOException {
@@ -226,7 +247,7 @@ private void handleStatic(HttpExchange ex) throws IOException {
// ── Helpers ───────────────────────────────────────────────────────────────
- private Map buildStateResponse(GameState s) {
+ private Map buildStateResponse(GameState s, String locale) {
Manifest.NodeData scene = engine.nodeById(s.currentSceneId);
List decisions = engine.availableDecisions(s.currentSceneId);
@@ -264,9 +285,39 @@ private Map buildStateResponse(GameState s) {
}
resp.put("variables", Map.copyOf(s.variables));
+
+ // Localization: subtitles + decision translations for the current scene
+ if (locale != null && !locale.isBlank()) {
+ resp.put("subtitles", engine.getSubtitlesForScene(s.currentSceneId, locale).stream().map(sub -> {
+ Map sm = new LinkedHashMap<>();
+ sm.put("startTime", sub.startTime);
+ sm.put("endTime", sub.endTime);
+ sm.put("text", sub.text);
+ return sm;
+ }).toList());
+
+ Map dtMap = new LinkedHashMap<>();
+ for (Manifest.DecisionTranslationEntry dt : engine.getDecisionTranslationsForScene(s.currentSceneId, locale)) {
+ dtMap.put(dt.decisionKey, dt.label);
+ }
+ resp.put("decisionTranslations", dtMap);
+ }
+
return resp;
}
+ private String queryParam(HttpExchange ex, String name) {
+ String query = ex.getRequestURI().getQuery();
+ if (query == null) return null;
+ for (String pair : query.split("&")) {
+ String[] kv = pair.split("=", 2);
+ if (kv.length == 2 && name.equals(kv[0])) {
+ return java.net.URLDecoder.decode(kv[1], java.nio.charset.StandardCharsets.UTF_8);
+ }
+ }
+ return null;
+ }
+
private static String mimeFor(String filename) {
if (filename.endsWith(".m3u8")) return "application/vnd.apple.mpegurl";
if (filename.endsWith(".ts")) return "video/MP2T";
diff --git a/runtime/src/main/java/com/engine/runtime/game/GameEngine.java b/runtime/src/main/java/com/engine/runtime/game/GameEngine.java
index b0209e1..5bc31c6 100644
--- a/runtime/src/main/java/com/engine/runtime/game/GameEngine.java
+++ b/runtime/src/main/java/com/engine/runtime/game/GameEngine.java
@@ -165,6 +165,34 @@ public List preloadUrlsForScene(String sceneId) {
.toList();
}
+ // ── Localization helpers ────────────────────────────────────────────────────
+
+ public String defaultLocaleCode() {
+ return manifest.project != null ? manifest.project.defaultLocaleCode : null;
+ }
+
+ public List availableLocales() {
+ if (manifest.localization == null || manifest.localization.locales == null) return List.of();
+ return manifest.localization.locales;
+ }
+
+ public List getSubtitlesForScene(String sceneId, String localeCode) {
+ if (manifest.localization == null || manifest.localization.subtitles == null
+ || sceneId == null || localeCode == null) return List.of();
+ return manifest.localization.subtitles.stream()
+ .filter(s -> sceneId.equals(s.sceneId) && localeCode.equals(s.localeCode))
+ .sorted(Comparator.comparingDouble(s -> s.startTime))
+ .toList();
+ }
+
+ public List getDecisionTranslationsForScene(String sceneId, String localeCode) {
+ if (manifest.localization == null || manifest.localization.decisionTranslations == null
+ || sceneId == null || localeCode == null) return List.of();
+ return manifest.localization.decisionTranslations.stream()
+ .filter(dt -> sceneId.equals(dt.sceneId) && localeCode.equals(dt.localeCode))
+ .toList();
+ }
+
// ── SpEL: state-node assignments ──────────────────────────────────────────
private void executeAssignments(Manifest.NodeData stateNode, GameState gameState) {
diff --git a/runtime/src/main/java/com/engine/runtime/game/Manifest.java b/runtime/src/main/java/com/engine/runtime/game/Manifest.java
index b2336bc..a6d4460 100644
--- a/runtime/src/main/java/com/engine/runtime/game/Manifest.java
+++ b/runtime/src/main/java/com/engine/runtime/game/Manifest.java
@@ -4,7 +4,6 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
-import java.util.Map;
/** Deserializes manifest.json written by ManifestService. */
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -14,6 +13,7 @@ public class Manifest {
@JsonProperty("project") public ProjectConfig project;
@JsonProperty("nodes") public List nodes;
@JsonProperty("edges") public List edges;
+ @JsonProperty("localization") public LocalizationData localization;
// ── Project config ─────────────────────────────────────────────────────────
@@ -80,4 +80,38 @@ public static class TransitionData {
@JsonProperty("type") public String type;
@JsonProperty("duration") public double duration;
}
+
+ // ── Localization ──────────────────────────────────────────────────────────
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class LocalizationData {
+ @JsonProperty("locales") public List locales;
+ @JsonProperty("subtitles") public List subtitles;
+ @JsonProperty("decisionTranslations") public List decisionTranslations;
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class LocaleEntry {
+ @JsonProperty("code") public String code;
+ @JsonProperty("name") public String name;
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class SubtitleEntry {
+ @JsonProperty("id") public String id;
+ @JsonProperty("sceneId") public String sceneId;
+ @JsonProperty("localeCode") public String localeCode;
+ @JsonProperty("startTime") public double startTime;
+ @JsonProperty("endTime") public double endTime;
+ @JsonProperty("text") public String text;
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class DecisionTranslationEntry {
+ @JsonProperty("id") public String id;
+ @JsonProperty("decisionKey") public String decisionKey;
+ @JsonProperty("sceneId") public String sceneId;
+ @JsonProperty("localeCode") public String localeCode;
+ @JsonProperty("label") public String label;
+ }
}
diff --git a/runtime/src/main/resources/client/default.css b/runtime/src/main/resources/client/default.css
index 7ce9867..bcdb93d 100644
--- a/runtime/src/main/resources/client/default.css
+++ b/runtime/src/main/resources/client/default.css
@@ -23,6 +23,8 @@
* │ │ ├─ #video-el — main scene
* │ │ ├─ #transition-el — transition overlay
* │ │ ├─ #freeze-canvas — freeze-frame
+ * │ │ ├─ #subtitle-container — subtitle display area
+ * │ │ │ └─ #subtitle-text — the visible subtitle span
* │ │ ├─ #pause-btn — pause button (top-left)
* │ │ ├─ #countdown — decision countdown timer
* │ │ │ ├─ #countdown-arc — circular arc
@@ -218,7 +220,35 @@ body {
z-index: 3;
}
-/* ── Pause Button ────────────────────────────────────────────────────────── */
+/* ── Subtitle Display ────────────────────────────────────────────────── */
+
+#subtitle-container {
+ position: absolute;
+ bottom: 8%;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 8;
+ pointer-events: none;
+ max-width: 80%;
+ text-align: center;
+ transition: opacity 0.2s;
+}
+#subtitle-container.hidden { opacity: 0; }
+
+#subtitle-text {
+ display: inline-block;
+ padding: 6px 16px;
+ border-radius: 6px;
+ background: rgba(0, 0, 0, 0.75);
+ color: #fff;
+ font-size: 18px;
+ line-height: 1.4;
+ text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
+ white-space: pre-wrap;
+}
+#subtitle-text:empty { display: none; }
+
+/* ── Pause Button ────────────────────────────────────────────────────── */
#pause-btn {
position: absolute;
diff --git a/runtime/src/main/resources/client/game.js b/runtime/src/main/resources/client/game.js
index 1a962c9..cf6b28c 100644
--- a/runtime/src/main/resources/client/game.js
+++ b/runtime/src/main/resources/client/game.js
@@ -47,6 +47,8 @@ const errorMsg = $('error-msg');
const endScreen = $('end-screen');
const musicEl = $('music-el');
const pauseBtn = $('pause-btn');
+const subtitleContainer = $('subtitle-container');
+const subtitleText = $('subtitle-text');
// Settings controls
const settingMusicVol = $('setting-music-vol');
@@ -56,6 +58,8 @@ const settingBtnBg = $('setting-btn-bg');
const settingBtnText = $('setting-btn-text');
const settingBtnPos = $('setting-btn-pos');
const settingResolution = $('setting-resolution');
+const settingSubtitlesEnabled = $('setting-subtitles-enabled');
+const settingLocale = $('setting-locale');
const musicVolDisplay = $('music-vol-display');
const videoVolDisplay = $('video-vol-display');
const btnBgDisplay = $('btn-bg-display');
@@ -73,6 +77,8 @@ const defaultSettings = {
btnText: '#ffffff',
btnPosition: 'bottom',
resolution: 'auto',
+ subtitlesEnabled: true,
+ locale: '',
};
let settings = { ...defaultSettings };
@@ -109,6 +115,11 @@ function applySettings() {
// Button position
decisionOverlay.setAttribute('data-position', settings.btnPosition);
+ // Subtitles visibility
+ if (subtitleContainer) {
+ subtitleContainer.classList.toggle('hidden', !settings.subtitlesEnabled);
+ }
+
// Sync settings UI
settingMusicVol.value = Math.round(settings.musicVolume * 100);
settingVideoVol.value = Math.round(settings.videoVolume * 100);
@@ -117,6 +128,8 @@ function applySettings() {
settingBtnText.value = settings.btnText;
settingBtnPos.value = settings.btnPosition;
settingResolution.value = settings.resolution;
+ if (settingSubtitlesEnabled) settingSubtitlesEnabled.checked = settings.subtitlesEnabled;
+ if (settingLocale) settingLocale.value = settings.locale;
musicVolDisplay.textContent = Math.round(settings.musicVolume * 100) + '%';
videoVolDisplay.textContent = Math.round(settings.videoVolume * 100) + '%';
btnBgDisplay.textContent = settings.btnBg;
@@ -170,6 +183,19 @@ settingResolution.addEventListener('change', () => {
settings.resolution = settingResolution.value;
});
+if (settingSubtitlesEnabled) {
+ settingSubtitlesEnabled.addEventListener('change', () => {
+ settings.subtitlesEnabled = settingSubtitlesEnabled.checked;
+ applySettings();
+ });
+}
+
+if (settingLocale) {
+ settingLocale.addEventListener('change', () => {
+ settings.locale = settingLocale.value;
+ });
+}
+
// ── Screen management ────────────────────────────────────────────────────────
function showScreen(screen) {
@@ -191,7 +217,7 @@ $('btn-continue').addEventListener('click', async () => {
showScreen('game');
showSpinner('Loading…');
try {
- const state = await apiFetch('/api/game/state');
+ const state = await apiFetch('/api/game/state' + localeQueryString());
await loadScene(state);
} catch (e) {
showError('Failed to load: ' + (e.message || e));
@@ -265,6 +291,7 @@ function pauseGame() {
videoEl.pause();
musicEl.pause();
clearCountdown();
+ stopSubtitleSync();
showScreen('paused');
}
@@ -273,6 +300,7 @@ function resumeGame() {
showScreen('game');
videoEl.play().catch(() => {});
if (settings.musicEnabled && musicEl.src) musicEl.play().catch(() => {});
+ startSubtitleSync();
}
function pauseVideo() {
@@ -309,13 +337,71 @@ function stopMusic() {
currentMusicUrl = null;
}
+// ── Subtitle engine ─────────────────────────────────────────────────────────
+
+let currentSubtitles = []; // [{startTime, endTime, text}] for current scene
+let subtitleRafId = null;
+
+function setSubtitles(subs) {
+ currentSubtitles = (subs || []).slice().sort((a, b) => a.startTime - b.startTime);
+ if (subtitleText) subtitleText.textContent = '';
+}
+
+function startSubtitleSync() {
+ stopSubtitleSync();
+ if (!currentSubtitles.length || !settings.subtitlesEnabled) return;
+
+ function tick() {
+ const t = videoEl.currentTime;
+ let found = '';
+ for (const s of currentSubtitles) {
+ if (t >= s.startTime && t < s.endTime) { found = s.text; break; }
+ }
+ if (subtitleText) subtitleText.textContent = found;
+ subtitleRafId = requestAnimationFrame(tick);
+ }
+ subtitleRafId = requestAnimationFrame(tick);
+}
+
+function stopSubtitleSync() {
+ if (subtitleRafId) { cancelAnimationFrame(subtitleRafId); subtitleRafId = null; }
+ if (subtitleText) subtitleText.textContent = '';
+}
+
+// ── Locale helpers ──────────────────────────────────────────────────────────
+
+function localeQueryString() {
+ return settings.locale ? '?locale=' + encodeURIComponent(settings.locale) : '';
+}
+
+async function loadLocales() {
+ try {
+ const data = await apiFetch('/api/game/locales');
+ const select = settingLocale;
+ if (!select) return;
+ // Preserve first "None" option, clear the rest
+ while (select.options.length > 1) select.remove(1);
+ (data.locales || []).forEach(l => {
+ const opt = document.createElement('option');
+ opt.value = l.code;
+ opt.textContent = l.name + ' (' + l.code + ')';
+ select.appendChild(opt);
+ });
+ // Auto-select default locale if user hasn't chosen one
+ if (!settings.locale && data.defaultLocaleCode) {
+ settings.locale = data.defaultLocaleCode;
+ }
+ select.value = settings.locale;
+ } catch { /* no locales available */ }
+}
+
// ── Boot ───────────────────────────────────────────────────────────────────────
(async function boot() {
loadSettings();
applySettings();
showScreen('menu');
- await checkContinue();
+ await Promise.all([checkContinue(), loadLocales()]);
})();
async function checkContinue() {
@@ -335,6 +421,7 @@ async function loadScene(state) {
hideDecisions();
hideCountdown();
+ stopSubtitleSync();
endScreen.classList.remove('visible');
showSpinner('Loading scene…');
@@ -358,10 +445,16 @@ async function loadScene(state) {
// Update background music
updateMusic(state.musicUrl);
+ // Load subtitles for this scene
+ setSubtitles(state.subtitles || []);
+
hideSpinner();
videoEl.play().catch(() => {});
+ // Start subtitle sync loop
+ startSubtitleSync();
+
const decisions = state.decisions || [];
const timeout = state.decisionTimeoutSecs || 5;
const isEnd = state.isEnd;
@@ -484,10 +577,13 @@ function showDecisions(decisions, timeoutSecs) {
const defaultDecision = decisions.find(d => d.isDefault) || decisions[0];
+ // Decision translations from the current state response
+ const dtMap = (currentState && currentState.decisionTranslations) || {};
+
for (const d of decisions) {
const btn = document.createElement('button');
btn.className = 'decision-btn' + (d.isDefault ? ' default' : '');
- btn.textContent = d.key;
+ btn.textContent = dtMap[d.key] || d.key;
btn.addEventListener('click', () => {
if (decisionMade) return;
decisionMade = true;
@@ -567,8 +663,9 @@ function hideFreeze() {
async function makeDecision(decisionKey) {
showSpinner('Deciding…');
+ stopSubtitleSync();
try {
- const result = await apiFetch('/api/game/decide', { method: 'POST',
+ const result = await apiFetch('/api/game/decide' + localeQueryString(), { method: 'POST',
body: JSON.stringify({ decisionKey }) });
if (result.transition) {
@@ -639,7 +736,7 @@ async function restartGame() {
stopMusic();
try {
- const state = await apiFetch('/api/game/restart', { method: 'POST', body: '{}' });
+ const state = await apiFetch('/api/game/restart' + localeQueryString(), { method: 'POST', body: '{}' });
await loadScene(state);
} catch (e) {
showError('Restart failed: ' + (e.message || e));
diff --git a/runtime/src/main/resources/client/index.html b/runtime/src/main/resources/client/index.html
index 78b3514..2ca6630 100644
--- a/runtime/src/main/resources/client/index.html
+++ b/runtime/src/main/resources/client/index.html
@@ -35,6 +35,11 @@
+
+
+
+
+
⏸
@@ -150,6 +155,26 @@ Settings