diff --git a/editor/frontend/src/types/index.ts b/editor/frontend/src/types/index.ts
index 38b40f9..097754f 100644
--- a/editor/frontend/src/types/index.ts
+++ b/editor/frontend/src/types/index.ts
@@ -20,6 +20,8 @@ export interface GraphNode {
backgroundColor?: string
decisionAppearanceConfig?: DecisionAppearanceConfig
musicAssetId?: string | null
+ hideDecisionButtons?: boolean | null
+ showDecisionInputIndicator?: boolean | null
posX: number
posY: number
exits: NodeExit[]
diff --git a/runtime/pom.xml b/runtime/pom.xml
index 73ce30b..5459cae 100644
--- a/runtime/pom.xml
+++ b/runtime/pom.xml
@@ -6,7 +6,7 @@
com.engine
runtime
-
1.0.0
+
1.1.0-RELEASE
Arvexis — Compiled Runtime
@@ -75,4 +75,26 @@
+
+
+
+ native
+
+
+
+ org.graalvm.buildtools
+ native-maven-plugin
+ 0.10.2
+
+ arvexis-runtime
+ com.engine.runtime.Main
+
+ -H:+ReportExceptionStackTraces
+
+
+
+
+
+
+
diff --git a/runtime/src/main/java/com/engine/runtime/RuntimeServer.java b/runtime/src/main/java/com/engine/runtime/RuntimeServer.java
index d26b710..6222ed6 100644
--- a/runtime/src/main/java/com/engine/runtime/RuntimeServer.java
+++ b/runtime/src/main/java/com/engine/runtime/RuntimeServer.java
@@ -266,6 +266,8 @@ private Map buildStateResponse(GameState s, String locale) {
Manifest.NodeData scene = engine.nodeById(s.currentSceneId);
List decisions = engine.availableDecisions(s, s.currentSceneId);
boolean hasExplicitDecisions = engine.sceneHasExplicitDecisions(s.currentSceneId);
+ boolean hideDecisionButtons = engine.hideDecisionButtons(s.currentSceneId);
+ boolean showDecisionInputIndicator = engine.showDecisionInputIndicator(s.currentSceneId);
Map resp = new LinkedHashMap<>();
resp.put("currentSceneId", s.currentSceneId);
@@ -275,8 +277,8 @@ private Map buildStateResponse(GameState s, String locale) {
resp.put("duration", scene != null ? scene.computedDuration : null);
resp.put("decisionAppearanceConfig", scene != null ? scene.decisionAppearanceConfig : null);
resp.put("decisionTimeoutSecs", engine.decisionTimeoutSecs());
- resp.put("hideDecisionButtons", engine.hideDecisionButtons());
- resp.put("showDecisionInputIndicator", engine.showDecisionInputIndicator());
+ resp.put("hideDecisionButtons", hideDecisionButtons);
+ resp.put("showDecisionInputIndicator", hideDecisionButtons && showDecisionInputIndicator);
resp.put("hasExplicitDecisions", hasExplicitDecisions);
resp.put("decisions", decisions.stream().map(d -> {
Map dm = new LinkedHashMap<>();
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 80fed61..b44bf2b 100644
--- a/runtime/src/main/java/com/engine/runtime/game/GameEngine.java
+++ b/runtime/src/main/java/com/engine/runtime/game/GameEngine.java
@@ -49,11 +49,19 @@ public double decisionTimeoutSecs() {
return manifest.project != null ? manifest.project.decisionTimeoutSecs : 5.0;
}
- public boolean hideDecisionButtons() {
+ public boolean hideDecisionButtons(String sceneId) {
+ Manifest.NodeData scene = nodeById(sceneId);
+ if (scene != null && scene.hideDecisionButtons != null) {
+ return scene.hideDecisionButtons;
+ }
return manifest.project != null && manifest.project.hideDecisionButtons;
}
- public boolean showDecisionInputIndicator() {
+ public boolean showDecisionInputIndicator(String sceneId) {
+ Manifest.NodeData scene = nodeById(sceneId);
+ if (scene != null && scene.showDecisionInputIndicator != null) {
+ return scene.showDecisionInputIndicator;
+ }
return manifest.project != null && manifest.project.showDecisionInputIndicator;
}
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 3cebf03..9b50869 100644
--- a/runtime/src/main/java/com/engine/runtime/game/Manifest.java
+++ b/runtime/src/main/java/com/engine/runtime/game/Manifest.java
@@ -45,6 +45,8 @@ public static class NodeData {
@JsonProperty("loopVideo") public boolean loopVideo;
@JsonProperty("musicAssetId") public String musicAssetId;
@JsonProperty("musicAssetRelPath") public String musicAssetRelPath;
+ @JsonProperty("hideDecisionButtons") public Boolean hideDecisionButtons;
+ @JsonProperty("showDecisionInputIndicator") public Boolean showDecisionInputIndicator;
}
@JsonIgnoreProperties(ignoreUnknown = true)
diff --git a/runtime/src/main/resources/client/game-main.js b/runtime/src/main/resources/client/game-main.js
new file mode 100644
index 0000000..a12ce5d
--- /dev/null
+++ b/runtime/src/main/resources/client/game-main.js
@@ -0,0 +1,31 @@
+import { apiFetch } from './game/api.js';
+import { createRuntimeContext } from './game/context.js';
+import { createUiController } from './game/ui.js';
+import { createSettingsController } from './game/settings.js';
+import { createPlaybackController } from './game/playback.js';
+import { createDecisionController } from './game/decisions.js';
+import { createSceneController } from './game/scene.js';
+import { createAppController } from './game/app.js';
+
+const ctx = createRuntimeContext();
+const ui = createUiController(ctx);
+const settingsController = createSettingsController(ctx, { apiFetch });
+const playback = createPlaybackController(ctx, ui);
+const decisions = createDecisionController(ctx, ui, playback);
+const scene = createSceneController(ctx, {
+ apiFetch,
+ localeQueryString: settingsController.localeQueryString,
+ ui,
+ playback,
+ decisions,
+});
+const app = createAppController(ctx, {
+ apiFetch,
+ settingsController,
+ ui,
+ playback,
+ decisions,
+ scene,
+});
+
+app.boot();
diff --git a/runtime/src/main/resources/client/game.js b/runtime/src/main/resources/client/game.js
index eabfa66..f7eec07 100644
--- a/runtime/src/main/resources/client/game.js
+++ b/runtime/src/main/resources/client/game.js
@@ -1,984 +1,3 @@
'use strict';
-// ═══════════════════════════════════════════════════════════════════════════════
-// Arvexis Runtime — Game Client
-// ═══════════════════════════════════════════════════════════════════════════════
-
-const API = ''; // same origin
-
-// ── Playback state ───────────────────────────────────────────────────────────
-
-let currentState = null; // last /api/game/state response
-let hlsInstance = null; // current scene Hls instance
-let transHls = null; // transition Hls instance
-let countdownTimer = null;
-let decisionMade = false;
-let preloadedHls = {}; // url → Hls (preloaded transitions)
-let preloadedSceneHls = {}; // url → Hls (preloaded next-scene)
-let currentMusicUrl = null; // currently playing music URL
-let gamePaused = false;
-let loopHandler = null; // persistent 'ended' handler for manual video looping
-let sceneVideoListeners = []; // per-scene listeners attached to videoEl
-let activeDecisionHotkeys = new Map();
-
-// ── App state machine ────────────────────────────────────────────────────────
-// Screens: 'menu' | 'game' | 'paused' | 'settings'
-let appScreen = 'menu';
-let settingsReturnTo = 'menu'; // where to go back from settings
-
-// ── DOM references ───────────────────────────────────────────────────────────
-
-const $ = (id) => document.getElementById(id);
-
-const mainMenu = $('main-menu');
-const gameScreen = $('game-screen');
-const pauseOverlay = $('pause-overlay');
-const settingsOverlay = $('settings-overlay');
-
-const videoEl = $('video-el');
-const transEl = $('transition-el');
-const freezeCanvas = $('freeze-canvas');
-const decisionOverlay = $('decision-overlay');
-const decisionButtons = $('decision-buttons');
-const decisionInputIndicator = $('decision-input-indicator');
-const countdownEl = $('countdown');
-const countdownNum = $('countdown-num');
-const countdownArc = $('countdown-arc');
-const spinner = $('spinner');
-const spinnerText = $('spinner-text');
-const errorBox = $('error-box');
-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');
-const settingVideoVol = $('setting-video-vol');
-const settingMusicEnabled = $('setting-music-enabled');
-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');
-const btnTextDisplay = $('btn-text-display');
-
-// ── Settings persistence (localStorage) ──────────────────────────────────────
-
-const SETTINGS_KEY = 'arvexis_settings';
-
-const defaultSettings = {
- musicVolume: 0.7,
- videoVolume: 1.0,
- musicEnabled: true,
- btnBg: '#000000',
- btnText: '#ffffff',
- btnPosition: 'bottom',
- resolution: 'auto',
- subtitlesEnabled: true,
- locale: '',
-};
-
-let settings = { ...defaultSettings };
-
-function loadSettings() {
- try {
- const saved = localStorage.getItem(SETTINGS_KEY);
- if (saved) settings = { ...defaultSettings, ...JSON.parse(saved) };
- } catch { /* ignore */ }
-}
-
-function saveSettings() {
- try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch {}
-}
-
-function normalizeDecisionHotkey(key) {
- if (!key || key === 'Unidentified') return null;
- if (key === ' ' || key === 'Spacebar') return 'space';
- return String(key).toLowerCase();
-}
-
-function formatDecisionHotkey(key) {
- if (!key) return '';
- if (key === ' ') return 'Space';
- return key;
-}
-
-function setActiveDecisionHotkeys(decisions) {
- activeDecisionHotkeys = new Map();
- for (const d of decisions || []) {
- const normalized = normalizeDecisionHotkey(d.keyboardKey);
- if (normalized) activeDecisionHotkeys.set(normalized, d);
- }
-}
-
-function clearActiveDecisionHotkeys() {
- activeDecisionHotkeys = new Map();
-}
-
-function showDecisionInputIndicator(decisions) {
- if (!decisionInputIndicator) return;
- const shouldShow = !!(currentState && currentState.hideDecisionButtons && currentState.showDecisionInputIndicator);
- const hotkeys = (decisions || [])
- .map((decision) => formatDecisionHotkey(decision.keyboardKey))
- .filter(Boolean);
- const text = shouldShow && hotkeys.length > 0
- ? `Input ready — press ${hotkeys.join(' / ')}`
- : '';
- decisionInputIndicator.textContent = text;
- decisionInputIndicator.classList.toggle('visible', text !== '');
-}
-
-function hideDecisionInputIndicator() {
- if (!decisionInputIndicator) return;
- decisionInputIndicator.textContent = '';
- decisionInputIndicator.classList.remove('visible');
-}
-
-function applySettings() {
- // Music
- musicEl.volume = settings.musicEnabled ? settings.musicVolume : 0;
- if (!settings.musicEnabled && !musicEl.paused) musicEl.pause();
- if (settings.musicEnabled && musicEl.src && musicEl.paused && appScreen === 'game') {
- musicEl.play().catch(() => {});
- }
-
- // Video volume
- videoEl.volume = settings.videoVolume;
-
- // Decision button CSS custom properties
- document.documentElement.style.setProperty('--arvexis-btn-bg',
- hexToRgba(settings.btnBg, 0.65));
- document.documentElement.style.setProperty('--arvexis-btn-text', settings.btnText);
- document.documentElement.style.setProperty('--arvexis-btn-hover-bg',
- hexToRgba(settings.btnText, 0.15));
-
- // 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);
- settingMusicEnabled.checked = settings.musicEnabled;
- settingBtnBg.value = settings.btnBg;
- 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;
- btnTextDisplay.textContent = settings.btnText;
-}
-
-function hexToRgba(hex, alpha) {
- const r = parseInt(hex.slice(1, 3), 16);
- const g = parseInt(hex.slice(3, 5), 16);
- const b = parseInt(hex.slice(5, 7), 16);
- return `rgba(${r}, ${g}, ${b}, ${alpha})`;
-}
-
-// ── Settings UI event listeners ──────────────────────────────────────────────
-
-settingMusicVol.addEventListener('input', () => {
- settings.musicVolume = settingMusicVol.value / 100;
- musicVolDisplay.textContent = settingMusicVol.value + '%';
- musicEl.volume = settings.musicEnabled ? settings.musicVolume : 0;
-});
-
-settingVideoVol.addEventListener('input', () => {
- settings.videoVolume = settingVideoVol.value / 100;
- videoVolDisplay.textContent = settingVideoVol.value + '%';
- videoEl.volume = settings.videoVolume;
-});
-
-settingMusicEnabled.addEventListener('change', () => {
- settings.musicEnabled = settingMusicEnabled.checked;
- applySettings();
-});
-
-settingBtnBg.addEventListener('input', () => {
- settings.btnBg = settingBtnBg.value;
- btnBgDisplay.textContent = settings.btnBg;
- applySettings();
-});
-
-settingBtnText.addEventListener('input', () => {
- settings.btnText = settingBtnText.value;
- btnTextDisplay.textContent = settings.btnText;
- applySettings();
-});
-
-settingBtnPos.addEventListener('change', () => {
- settings.btnPosition = settingBtnPos.value;
- applySettings();
-});
-
-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) {
- appScreen = screen;
-
- mainMenu.classList.toggle('hidden', screen !== 'menu');
- gameScreen.classList.toggle('hidden', screen !== 'game' && screen !== 'paused');
- pauseOverlay.classList.toggle('visible', screen === 'paused');
- settingsOverlay.classList.toggle('visible', screen === 'settings');
-
- // Hide resolution setting during gameplay (fixed once game starts)
- const resGroup = $('resolution-group');
- if (resGroup) resGroup.style.display = (screen === 'settings' && settingsReturnTo === 'menu') ? '' : 'none';
-}
-
-// ── Menu button handlers ─────────────────────────────────────────────────────
-
-$('btn-continue').addEventListener('click', async () => {
- showScreen('game');
- showSpinner('Loading…');
- try {
- const state = await apiFetch('/api/game/state' + localeQueryString());
- await loadScene(state);
- } catch (e) {
- showError('Failed to load: ' + (e.message || e));
- }
-});
-
-$('btn-new-game').addEventListener('click', async () => {
- showScreen('game');
- await restartGame();
-});
-
-$('btn-menu-settings').addEventListener('click', () => {
- settingsReturnTo = 'menu';
- showScreen('settings');
-});
-
-// ── Pause / Resume ───────────────────────────────────────────────────────────
-
-pauseBtn.addEventListener('click', () => pauseGame());
-
-$('btn-resume').addEventListener('click', () => resumeGame());
-
-$('btn-pause-settings').addEventListener('click', () => {
- settingsReturnTo = 'paused';
- showScreen('settings');
-});
-
-$('btn-quit-menu').addEventListener('click', () => {
- resumeGame(); // unpause video/music first
- pauseVideo(); // then pause the video for real
- showScreen('menu');
- checkContinue(); // refresh Continue button state
-});
-
-$('btn-settings-close').addEventListener('click', () => {
- saveSettings();
- applySettings();
- showScreen(settingsReturnTo);
-});
-
-// End screen buttons
-$('end-restart').addEventListener('click', async () => {
- endScreen.classList.remove('visible');
- await restartGame();
-});
-
-$('end-menu').addEventListener('click', () => {
- endScreen.classList.remove('visible');
- stopMusic();
- showScreen('menu');
- checkContinue();
-});
-
-$('error-retry').addEventListener('click', () => location.reload());
-
-// Keyboard: Escape to pause/resume
-document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') {
- if (appScreen === 'game') pauseGame();
- else if (appScreen === 'paused') resumeGame();
- else if (appScreen === 'settings') {
- saveSettings();
- applySettings();
- showScreen(settingsReturnTo);
- }
- return;
- }
-
- if (appScreen !== 'game' || gamePaused || decisionMade) {
- return;
- }
-
- const decision = activeDecisionHotkeys.get(normalizeDecisionHotkey(e.key));
- if (!decision) {
- return;
- }
-
- e.preventDefault();
- e.stopPropagation();
- decisionMade = true;
- clearCountdown();
- hideDecisions();
- makeDecision(decision.key);
-});
-
-function pauseGame() {
- gamePaused = true;
- videoEl.pause();
- musicEl.pause();
- clearCountdown();
- stopSubtitleSync();
- showScreen('paused');
-}
-
-function resumeGame() {
- gamePaused = false;
- showScreen('game');
- videoEl.play().catch(() => {});
- if (settings.musicEnabled && musicEl.src) musicEl.play().catch(() => {});
- startSubtitleSync();
-}
-
-function pauseVideo() {
- videoEl.pause();
-}
-
-// ── Background Music ─────────────────────────────────────────────────────────
-
-function updateMusic(musicUrl) {
- // null/undefined = keep current music playing; explicit new URL = switch
- if (musicUrl === undefined || musicUrl === null) return;
-
- if (musicUrl === currentMusicUrl) return; // same track, no change
-
- currentMusicUrl = musicUrl;
-
- if (!musicUrl) {
- // No music for this scene: stop
- stopMusic();
- return;
- }
-
- musicEl.src = musicUrl;
- musicEl.volume = settings.musicEnabled ? settings.musicVolume : 0;
- if (settings.musicEnabled) {
- musicEl.play().catch(() => {});
- }
-}
-
-function stopMusic() {
- musicEl.pause();
- musicEl.removeAttribute('src');
- musicEl.load();
- 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 Promise.all([checkContinue(), loadLocales(), loadProjectInfo()]);
-})();
-
-async function loadProjectInfo() {
- try {
- const { projectName } = await apiFetch('/api/game/info');
- if (projectName) {
- const titleEl = $('menu-title');
- if (titleEl) titleEl.textContent = projectName;
- document.title = projectName + ' — Interactive Video';
- }
- } catch { /* fallback to default title */ }
-}
-
-async function checkContinue() {
- try {
- const { hasSave } = await apiFetch('/api/game/has-save');
- $('btn-continue').disabled = !hasSave;
- } catch {
- $('btn-continue').disabled = true;
- }
-}
-
-function addSceneVideoListener(type, handler, options) {
- videoEl.addEventListener(type, handler, options);
- const capture = typeof options === 'boolean' ? options : !!(options && options.capture);
- sceneVideoListeners.push({ type, handler, capture });
-}
-
-function clearSceneVideoListeners() {
- for (const l of sceneVideoListeners) {
- videoEl.removeEventListener(l.type, l.handler, l.capture);
- }
- sceneVideoListeners = [];
-}
-
-// ── Scene loading ─────────────────────────────────────────────────────────────
-
-async function loadScene(state) {
- currentState = state;
- decisionMade = false;
- clearActiveDecisionHotkeys();
-
- // Clean up scene listeners from previous scene to avoid stale handlers
- clearSceneVideoListeners();
- loopHandler = null;
-
- hideDecisions();
- hideCountdown();
- stopSubtitleSync();
- endScreen.classList.remove('visible');
-
- // Only show spinner when there is no freeze frame covering the screen (i.e. initial load)
- if (freezeCanvas.style.display === 'none') showSpinner('Loading…');
-
- // Destroy previous HLS
- if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
-
- // Use preloaded scene HLS if available (fast path for auto-continue scenes)
- const sceneUrl = state.sceneHlsUrl;
- // Destroy any preloaded scene HLS — its HTTP fetches already warmed the
- // browser cache, but the instance can't be reliably reattached to a new element.
- if (preloadedSceneHls[sceneUrl]) {
- preloadedSceneHls[sceneUrl].destroy();
- delete preloadedSceneHls[sceneUrl];
- }
-
- await loadHls(videoEl, sceneUrl, (hls) => { hlsInstance = hls; });
-
- // Apply video volume from settings
- videoEl.volume = settings.videoVolume;
-
- // Start preloading transition HLS segments in background
- preloadTransitions(state.preloadUrls || []);
-
- // Preload next scene HLS if an auto-continue decision is set
- if (state.autoContinueNextSceneUrl) {
- preloadScene(state.autoContinueNextSceneUrl);
- }
-
- // Update background music
- updateMusic(state.musicUrl);
-
- // Load subtitles for this scene
- setSubtitles(state.subtitles || []);
-
- const loopVideo = !!state.loopVideo;
- videoEl.loop = false; // always false; looping is handled manually so 'ended' always fires
-
- const decisions = state.decisions || [];
- const hasExplicitDecisions = !!state.hasExplicitDecisions;
- const timeout = state.decisionTimeoutSecs || 5;
- const isEnd = state.isEnd;
-
- // ── Register all event handlers BEFORE play() to avoid race conditions ──
- // (short videos can fire 'ended' before listeners are attached otherwise)
-
- if (state.autoContinue) {
- addSceneVideoListener('ended', async () => {
- captureFreeze();
- if (!decisionMade) {
- decisionMade = true;
- await makeDecision('CONTINUE');
- }
- }, { once: true });
- } else {
- // ── Decision appearance timing ─────────────────────────────────────────
- let appearAt = null; // null = after video ends
- try {
- if (state.decisionAppearanceConfig) {
- const cfg = JSON.parse(state.decisionAppearanceConfig);
- if (cfg.timing === 'at_timestamp' && typeof cfg.timestamp === 'number') {
- appearAt = cfg.timestamp;
- }
- }
- } catch {}
-
- if (decisions.length > 0) {
- if (appearAt !== null) {
- addSceneVideoListener('timeupdate', function onTimeUpdate() {
- if (videoEl.currentTime >= appearAt) {
- videoEl.removeEventListener('timeupdate', onTimeUpdate);
- if (!decisionMade) showDecisions(decisions, timeout);
- }
- });
- }
-
- if (loopVideo) {
- // Looping scene: manually replay video each cycle so 'ended' keeps firing.
- // Show decisions after the first play-through (or via timestamp), then keep looping.
- let decisionsShown = false;
- loopHandler = function onLoop() {
- if (decisionMade) { videoEl.removeEventListener('ended', loopHandler); loopHandler = null; return; }
- if (isEnd) { videoEl.removeEventListener('ended', loopHandler); loopHandler = null; captureFreeze(); showEndScreen(); return; }
- if (!decisionsShown && appearAt === null) { decisionsShown = true; showDecisions(decisions, timeout); }
- if (hlsInstance) hlsInstance.startLoad(0);
- videoEl.currentTime = 0;
- videoEl.play().catch(() => {});
- };
- addSceneVideoListener('ended', loopHandler);
- } else {
- // Non-looping: freeze on last frame and show decisions
- addSceneVideoListener('ended', function onEnded() {
- videoEl.removeEventListener('ended', onEnded);
- captureFreeze();
- if (isEnd) { showEndScreen(); return; }
- if (!decisionMade) showDecisions(decisions, timeout);
- }, { once: true });
- }
- } else if (hasExplicitDecisions) {
- if (appearAt !== null) {
- addSceneVideoListener('timeupdate', function onTimeUpdate() {
- if (videoEl.currentTime >= appearAt) {
- videoEl.removeEventListener('timeupdate', onTimeUpdate);
- showUnavailableDecisionsError();
- }
- });
- }
-
- if (loopVideo) {
- let unavailableShown = false;
- loopHandler = function onLoop() {
- if (decisionMade) { videoEl.removeEventListener('ended', loopHandler); loopHandler = null; return; }
- if (isEnd) { videoEl.removeEventListener('ended', loopHandler); loopHandler = null; captureFreeze(); showEndScreen(); return; }
- if (!unavailableShown && appearAt === null) {
- unavailableShown = true;
- showUnavailableDecisionsError();
- return;
- }
- if (hlsInstance) hlsInstance.startLoad(0);
- videoEl.currentTime = 0;
- videoEl.play().catch(() => {});
- };
- addSceneVideoListener('ended', loopHandler);
- } else {
- addSceneVideoListener('ended', function onEnded() {
- videoEl.removeEventListener('ended', onEnded);
- if (!decisionMade) showUnavailableDecisionsError();
- }, { once: true });
- }
- } else if (isEnd) {
- addSceneVideoListener('ended', () => { captureFreeze(); showEndScreen(); }, { once: true });
- } else {
- addSceneVideoListener('ended', async () => {
- captureFreeze();
- await makeDecision('CONTINUE');
- }, { once: true });
- }
- }
-
- // Start playback and wait for first frame to render before revealing
- videoEl.play().catch(() => {});
- await new Promise(resolve => {
- if (videoEl.readyState >= 3) { resolve(); return; }
- videoEl.addEventListener('playing', resolve, { once: true });
- setTimeout(resolve, 1000);
- });
-
- hideSpinner();
- hideFreeze();
-
- // Start subtitle sync loop
- startSubtitleSync();
-}
-
-// ── HLS loading helper ────────────────────────────────────────────────────────
-
-function loadHls(videoElement, src, onReady) {
- return new Promise((resolve, reject) => {
- function attachNative() {
- videoElement.src = src;
- videoElement.addEventListener('canplay', () => { onReady && onReady(null); resolve(); }, { once: true });
- videoElement.addEventListener('error', (e) => reject(new Error('Video error: ' + e.message)), { once: true });
- }
-
- if (typeof Hls === 'undefined' || !Hls.isSupported()) {
- attachNative();
- } else {
- const hls = new Hls({ enableWorker: false, lowLatencyMode: false });
- let done = false;
- const finish = () => { if (!done) { done = true; resolve(); } };
- hls.loadSource(src);
- hls.attachMedia(videoElement);
- hls.on(Hls.Events.MANIFEST_PARSED, () => { onReady && onReady(hls); });
- hls.on(Hls.Events.FRAG_BUFFERED, finish);
- videoElement.addEventListener('canplay', finish, { once: true });
- hls.on(Hls.Events.ERROR, (_, data) => {
- if (data.fatal) {
- hls.destroy();
- if (!done) { done = true; reject(new Error('HLS fatal error: ' + data.type)); }
- }
- });
- setTimeout(finish, 10000);
- }
- });
-}
-
-// ── Preloading ────────────────────────────────────────────────────────────────
-
-function preloadTransitions(urls) {
- for (const key of Object.keys(preloadedHls)) {
- if (!urls.includes(key)) {
- preloadedHls[key]?.destroy?.();
- delete preloadedHls[key];
- }
- }
-
- for (const url of urls) {
- if (preloadedHls[url]) continue;
- if (typeof Hls === 'undefined' || !Hls.isSupported()) continue;
-
- const hls = new Hls({ enableWorker: false });
- const dummy = document.createElement('video');
- dummy.muted = true;
- hls.loadSource(url);
- hls.attachMedia(dummy);
- preloadedHls[url] = hls;
- }
-}
-
-function preloadScene(url) {
- if (preloadedSceneHls[url]) return;
- if (typeof Hls === 'undefined' || !Hls.isSupported()) return;
-
- const hls = new Hls({ enableWorker: false });
- const dummy = document.createElement('video');
- dummy.muted = true;
- hls.loadSource(url);
- hls.attachMedia(dummy);
- preloadedSceneHls[url] = hls;
-}
-
-// ── Decisions ─────────────────────────────────────────────────────────────────
-
-function showUnavailableDecisionsError() {
- if (decisionMade) return;
- decisionMade = true;
- clearCountdown();
- hideDecisions();
- captureFreeze();
- videoEl.pause();
- stopSubtitleSync();
- showError('No decisions are currently available for this scene.');
-}
-
-function showDecisions(decisions, timeoutSecs) {
- if (!decisions || decisions.length === 0) {
- showUnavailableDecisionsError();
- return;
- }
- decisionButtons.innerHTML = '';
- setActiveDecisionHotkeys(decisions);
-
- const defaultDecision = decisions.find(d => d.isDefault) || decisions[0];
- const hideDecisionButtons = !!(currentState && currentState.hideDecisionButtons);
-
- // Decision translations from the current state response
- const dtMap = (currentState && currentState.decisionTranslations) || {};
-
- if (!hideDecisionButtons) {
- for (const d of decisions) {
- const btn = document.createElement('button');
- btn.className = 'decision-btn' + (d.isDefault ? ' default' : '');
- const label = dtMap[d.key] || d.key;
- btn.textContent = d.keyboardKey ? `${label} [${formatDecisionHotkey(d.keyboardKey)}]` : label;
- btn.addEventListener('click', () => {
- if (decisionMade) return;
- decisionMade = true;
- clearCountdown();
- hideDecisions();
- makeDecision(d.key);
- });
- decisionButtons.appendChild(btn);
- }
- }
-
- decisionOverlay.classList.toggle('visible', !hideDecisionButtons);
- showDecisionInputIndicator(decisions);
- startCountdown(timeoutSecs, () => {
- if (!decisionMade) {
- decisionMade = true;
- hideDecisions();
- makeDecision(defaultDecision.key);
- }
- });
-}
-
-function hideDecisions() {
- clearActiveDecisionHotkeys();
- decisionOverlay.classList.remove('visible');
- decisionButtons.innerHTML = '';
- hideDecisionInputIndicator();
-}
-
-// ── Countdown ─────────────────────────────────────────────────────────────────
-
-function startCountdown(secs, onExpire) {
- clearCountdown();
- const end = Date.now() + secs * 1000;
- countdownEl.classList.add('visible');
-
- function tick() {
- const remaining = Math.max(0, (end - Date.now()) / 1000);
- countdownNum.textContent = remaining.toFixed(1) + 's';
- drawArc(remaining / secs);
- if (remaining <= 0) { clearCountdown(); onExpire(); return; }
- countdownTimer = requestAnimationFrame(tick);
- }
- countdownTimer = requestAnimationFrame(tick);
-}
-
-function clearCountdown() {
- if (countdownTimer) { cancelAnimationFrame(countdownTimer); countdownTimer = null; }
- countdownEl.classList.remove('visible');
-}
-
-function hideCountdown() { clearCountdown(); }
-
-function drawArc(fraction) {
- const ctx = countdownArc.getContext('2d');
- const r = 7, cx = 9, cy = 9;
- ctx.clearRect(0, 0, 18, 18);
- ctx.beginPath();
- ctx.arc(cx, cy, r, -Math.PI / 2, 2 * Math.PI * fraction - Math.PI / 2);
- ctx.strokeStyle = 'rgba(255,255,255,0.7)';
- ctx.lineWidth = 2;
- ctx.stroke();
-}
-
-// ── Freeze frame ──────────────────────────────────────────────────────────────
-
-function captureFreezeFrom(videoElement) {
- try {
- freezeCanvas.width = videoElement.videoWidth || 1280;
- freezeCanvas.height = videoElement.videoHeight || 720;
- const ctx = freezeCanvas.getContext('2d');
- ctx.drawImage(videoElement, 0, 0, freezeCanvas.width, freezeCanvas.height);
- freezeCanvas.style.display = 'block';
- } catch { /* cross-origin or no frame */ }
-}
-
-function captureFreeze() {
- captureFreezeFrom(videoEl);
-}
-
-function hideFreeze() {
- freezeCanvas.style.display = 'none';
-}
-
-// ── Decision handling ─────────────────────────────────────────────────────────
-
-async function makeDecision(decisionKey) {
- captureFreeze();
- videoEl.loop = false; // stop looping immediately on decision
- videoEl.pause();
- stopSubtitleSync();
- try {
- const result = await apiFetch('/api/game/decide' + localeQueryString(), { method: 'POST',
- body: JSON.stringify({ decisionKey }) });
-
- if (result.transition) {
- await playTransition(result.transition);
- }
-
- await loadScene(result.nextState);
-
- } catch (e) {
- showError('Error: ' + (e.message || e));
- }
-}
-
-// ── Transition playback ───────────────────────────────────────────────────────
-
-async function playTransition(trans) {
- if (transHls) { transHls.destroy(); transHls = null; }
-
- const url = trans.transitionHlsUrl;
-
- // Destroy any preloaded HLS for this URL — its HTTP fetches already warmed the
- // browser cache, but the HLS instance can't be reliably reattached to a new element.
- if (preloadedHls[url]) {
- preloadedHls[url].destroy();
- delete preloadedHls[url];
- }
-
- // Load fresh (fast due to browser-cached segments)
- await loadHls(transEl, url, (hls) => { transHls = hls; });
-
- hideSpinner();
- // Apply background colour behind the transition video (for alpha-channel / transparent transitions)
- transEl.style.backgroundColor = trans.backgroundColor || '';
- // transition-el.active has z-index 4 (above freeze-canvas z-index 3) so no need to hide freeze first
- transEl.classList.add('active');
- transEl.play().catch(() => {});
-
- return new Promise(resolve => {
- let cleaned = false;
- function cleanup() {
- if (cleaned) return;
- cleaned = true;
- captureFreezeFrom(transEl);
- transEl.pause();
- transEl.classList.remove('active');
- transEl.style.backgroundColor = '';
- if (transHls) { transHls.destroy(); transHls = null; }
- resolve();
- }
- transEl.addEventListener('ended', cleanup, { once: true });
- // Fallback: if 'ended' never fires, clean up after duration + buffer
- setTimeout(cleanup, ((trans.duration || 2) + 1) * 1000);
- });
-}
-
-// ── Restart ───────────────────────────────────────────────────────────────────
-
-async function restartGame() {
- clearCountdown();
- hideDecisions();
- hideFreeze();
- endScreen.classList.remove('visible');
- showSpinner('Restarting…');
-
- // Destroy HLS instances
- if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
- if (transHls) { transHls.destroy(); transHls = null; }
- for (const h of Object.values(preloadedHls)) h?.destroy?.();
- preloadedHls = {};
- for (const h of Object.values(preloadedSceneHls)) h?.destroy?.();
- preloadedSceneHls = {};
-
- // Stop music so it restarts from the first scene
- stopMusic();
-
- try {
- const state = await apiFetch('/api/game/restart' + localeQueryString(), { method: 'POST', body: '{}' });
- await loadScene(state);
- } catch (e) {
- showError('Restart failed: ' + (e.message || e));
- }
-}
-
-// ── End screen ────────────────────────────────────────────────────────────────
-
-function showEndScreen() {
- hideSpinner();
- endScreen.classList.add('visible');
-}
-
-// ── UI helpers ────────────────────────────────────────────────────────────────
-
-function showSpinner(text) {
- spinnerText.textContent = text || 'Loading…';
- spinner.classList.remove('hidden');
- errorBox.classList.remove('visible');
-}
-
-function hideSpinner() {
- spinner.classList.add('hidden');
-}
-
-function showError(msg) {
- hideSpinner();
- errorMsg.textContent = msg;
- errorBox.classList.add('visible');
-}
-
-// ── API fetch wrapper ─────────────────────────────────────────────────────────
-
-async function apiFetch(path, opts = {}) {
- const resp = await fetch(API + path, {
- headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
- ...opts,
- });
- const data = await resp.json();
- if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
- return data;
-}
+import './game-main.js';
diff --git a/runtime/src/main/resources/client/game/api.js b/runtime/src/main/resources/client/game/api.js
new file mode 100644
index 0000000..a63153d
--- /dev/null
+++ b/runtime/src/main/resources/client/game/api.js
@@ -0,0 +1,11 @@
+const API = '';
+
+export async function apiFetch(path, opts = {}) {
+ const resp = await fetch(API + path, {
+ headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
+ ...opts,
+ });
+ const data = await resp.json();
+ if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
+ return data;
+}
diff --git a/runtime/src/main/resources/client/game/app.js b/runtime/src/main/resources/client/game/app.js
new file mode 100644
index 0000000..b622a51
--- /dev/null
+++ b/runtime/src/main/resources/client/game/app.js
@@ -0,0 +1,147 @@
+export function createAppController(ctx, { apiFetch, settingsController, ui, playback, decisions, scene }) {
+ async function loadProjectInfo() {
+ try {
+ const { projectName } = await apiFetch('/api/game/info');
+ if (projectName) {
+ const titleEl = ctx.$('menu-title');
+ if (titleEl) titleEl.textContent = projectName;
+ document.title = projectName + ' — Interactive Video';
+ }
+ } catch {}
+ }
+
+ async function checkContinue() {
+ try {
+ const { hasSave } = await apiFetch('/api/game/has-save');
+ ctx.$('btn-continue').disabled = !hasSave;
+ } catch {
+ ctx.$('btn-continue').disabled = true;
+ }
+ }
+
+ function pauseGame() {
+ ctx.state.gamePaused = true;
+ ctx.dom.videoEl.pause();
+ ctx.dom.musicEl.pause();
+ decisions.clearCountdown();
+ playback.stopSubtitleSync();
+ ui.showScreen('paused');
+ }
+
+ function resumeGame() {
+ ctx.state.gamePaused = false;
+ ui.showScreen('game');
+ ctx.dom.videoEl.play().catch(() => {});
+ if (ctx.settings.musicEnabled && ctx.dom.musicEl.src) ctx.dom.musicEl.play().catch(() => {});
+ playback.startSubtitleSync();
+ }
+
+ function closeSettings() {
+ settingsController.saveSettings();
+ settingsController.applySettings();
+ ui.showScreen(ctx.state.settingsReturnTo);
+ }
+
+ function bindUiEvents() {
+ ctx.$('btn-continue').addEventListener('click', async () => {
+ ui.showScreen('game');
+ ui.showSpinner('Loading…');
+ try {
+ const state = await apiFetch('/api/game/state' + settingsController.localeQueryString());
+ await scene.loadScene(state);
+ } catch (error) {
+ ui.showError('Failed to load: ' + (error.message || error));
+ }
+ });
+
+ ctx.$('btn-new-game').addEventListener('click', async () => {
+ ui.showScreen('game');
+ await scene.restartGame();
+ });
+
+ ctx.$('btn-menu-settings').addEventListener('click', () => {
+ ctx.state.settingsReturnTo = 'menu';
+ ui.showScreen('settings');
+ });
+
+ ctx.dom.pauseBtn.addEventListener('click', () => pauseGame());
+
+ ctx.$('btn-resume').addEventListener('click', () => resumeGame());
+
+ ctx.$('btn-pause-settings').addEventListener('click', () => {
+ ctx.state.settingsReturnTo = 'paused';
+ ui.showScreen('settings');
+ });
+
+ ctx.$('btn-quit-menu').addEventListener('click', () => {
+ resumeGame();
+ playback.pauseVideo();
+ ui.showScreen('menu');
+ checkContinue();
+ });
+
+ ctx.$('btn-settings-close').addEventListener('click', () => {
+ closeSettings();
+ });
+
+ ctx.$('end-restart').addEventListener('click', async () => {
+ ctx.dom.endScreen.classList.remove('visible');
+ await scene.restartGame();
+ });
+
+ ctx.$('end-menu').addEventListener('click', () => {
+ ctx.dom.endScreen.classList.remove('visible');
+ playback.stopMusic();
+ ui.showScreen('menu');
+ checkContinue();
+ });
+
+ ctx.$('error-retry').addEventListener('click', () => location.reload());
+
+ document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ event.stopPropagation();
+ if (event.repeat) return;
+
+ if (ctx.state.appScreen === 'game') pauseGame();
+ else if (ctx.state.appScreen === 'paused') resumeGame();
+ else if (ctx.state.appScreen === 'settings') closeSettings();
+ return;
+ }
+
+ if (ctx.state.appScreen !== 'game' || ctx.state.gamePaused || ctx.state.decisionMade) {
+ return;
+ }
+
+ const decision = decisions.findDecisionByKey(event.key);
+ if (!decision) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ ctx.state.decisionMade = true;
+ decisions.clearCountdown();
+ decisions.hideDecisions();
+ scene.makeDecision(decision.key);
+ });
+ }
+
+ async function boot() {
+ settingsController.loadSettings();
+ settingsController.applySettings();
+ settingsController.bindSettingsControls();
+ bindUiEvents();
+ ui.showScreen('menu');
+ await Promise.all([checkContinue(), settingsController.loadLocales(), loadProjectInfo()]);
+ }
+
+ return {
+ boot,
+ checkContinue,
+ closeSettings,
+ pauseGame,
+ resumeGame,
+ };
+}
diff --git a/runtime/src/main/resources/client/game/context.js b/runtime/src/main/resources/client/game/context.js
new file mode 100644
index 0000000..c7dfa98
--- /dev/null
+++ b/runtime/src/main/resources/client/game/context.js
@@ -0,0 +1,77 @@
+export function createRuntimeContext(doc = document) {
+ const $ = (id) => doc.getElementById(id);
+
+ const defaultSettings = {
+ musicVolume: 0.7,
+ videoVolume: 1.0,
+ musicEnabled: true,
+ btnBg: '#000000',
+ btnText: '#ffffff',
+ btnPosition: 'bottom',
+ resolution: 'auto',
+ subtitlesEnabled: true,
+ locale: '',
+ };
+
+ return {
+ doc,
+ $,
+ defaultSettings,
+ settings: { ...defaultSettings },
+ state: {
+ currentState: null,
+ hlsInstance: null,
+ transHls: null,
+ countdownTimer: null,
+ decisionMade: false,
+ preloadedHls: {},
+ preloadedSceneHls: {},
+ currentMusicUrl: null,
+ gamePaused: false,
+ loopHandler: null,
+ sceneVideoListeners: [],
+ activeDecisionHotkeys: new Map(),
+ appScreen: 'menu',
+ settingsReturnTo: 'menu',
+ currentSubtitles: [],
+ subtitleRafId: null,
+ },
+ dom: {
+ mainMenu: $('main-menu'),
+ gameScreen: $('game-screen'),
+ pauseOverlay: $('pause-overlay'),
+ settingsOverlay: $('settings-overlay'),
+ videoEl: $('video-el'),
+ transEl: $('transition-el'),
+ freezeCanvas: $('freeze-canvas'),
+ decisionOverlay: $('decision-overlay'),
+ decisionButtons: $('decision-buttons'),
+ decisionInputIndicator: $('decision-input-indicator'),
+ countdownEl: $('countdown'),
+ countdownNum: $('countdown-num'),
+ countdownArc: $('countdown-arc'),
+ spinner: $('spinner'),
+ spinnerText: $('spinner-text'),
+ errorBox: $('error-box'),
+ errorMsg: $('error-msg'),
+ endScreen: $('end-screen'),
+ musicEl: $('music-el'),
+ pauseBtn: $('pause-btn'),
+ subtitleContainer: $('subtitle-container'),
+ subtitleText: $('subtitle-text'),
+ settingMusicVol: $('setting-music-vol'),
+ settingVideoVol: $('setting-video-vol'),
+ settingMusicEnabled: $('setting-music-enabled'),
+ settingBtnBg: $('setting-btn-bg'),
+ settingBtnText: $('setting-btn-text'),
+ settingBtnPos: $('setting-btn-pos'),
+ settingResolution: $('setting-resolution'),
+ settingSubtitlesEnabled: $('setting-subtitles-enabled'),
+ settingLocale: $('setting-locale'),
+ musicVolDisplay: $('music-vol-display'),
+ videoVolDisplay: $('video-vol-display'),
+ btnBgDisplay: $('btn-bg-display'),
+ btnTextDisplay: $('btn-text-display'),
+ },
+ };
+}
diff --git a/runtime/src/main/resources/client/game/decisions.js b/runtime/src/main/resources/client/game/decisions.js
new file mode 100644
index 0000000..c1ac3db
--- /dev/null
+++ b/runtime/src/main/resources/client/game/decisions.js
@@ -0,0 +1,169 @@
+export function createDecisionController(ctx, ui, playback) {
+ function normalizeDecisionHotkey(key) {
+ if (!key || key === 'Unidentified') return null;
+ if (key === ' ' || key === 'Spacebar') return 'space';
+ return String(key).toLowerCase();
+ }
+
+ function formatDecisionHotkey(key) {
+ if (!key) return '';
+ if (key === ' ') return 'Space';
+ return key;
+ }
+
+ function setActiveDecisionHotkeys(decisions) {
+ ctx.state.activeDecisionHotkeys = new Map();
+ for (const decision of decisions || []) {
+ const normalized = normalizeDecisionHotkey(decision.keyboardKey);
+ if (normalized) ctx.state.activeDecisionHotkeys.set(normalized, decision);
+ }
+ }
+
+ function clearActiveDecisionHotkeys() {
+ ctx.state.activeDecisionHotkeys = new Map();
+ }
+
+ function findDecisionByKey(key) {
+ return ctx.state.activeDecisionHotkeys.get(normalizeDecisionHotkey(key));
+ }
+
+ function showDecisionInputIndicator(decisions) {
+ if (!ctx.dom.decisionInputIndicator) return;
+ const shouldShow = !!(
+ ctx.state.currentState &&
+ ctx.state.currentState.hideDecisionButtons &&
+ ctx.state.currentState.showDecisionInputIndicator
+ );
+ const hotkeys = (decisions || [])
+ .map((decision) => formatDecisionHotkey(decision.keyboardKey))
+ .filter(Boolean);
+ const text = shouldShow && hotkeys.length > 0
+ ? `Input ready — press ${hotkeys.join(' / ')}`
+ : '';
+ ctx.dom.decisionInputIndicator.textContent = text;
+ ctx.dom.decisionInputIndicator.classList.toggle('visible', text !== '');
+ }
+
+ function hideDecisionInputIndicator() {
+ if (!ctx.dom.decisionInputIndicator) return;
+ ctx.dom.decisionInputIndicator.textContent = '';
+ ctx.dom.decisionInputIndicator.classList.remove('visible');
+ }
+
+ function startCountdown(seconds, onExpire) {
+ clearCountdown();
+ const end = Date.now() + seconds * 1000;
+ ctx.dom.countdownEl.classList.add('visible');
+
+ function tick() {
+ const remaining = Math.max(0, (end - Date.now()) / 1000);
+ ctx.dom.countdownNum.textContent = remaining.toFixed(1) + 's';
+ drawArc(remaining / seconds);
+ if (remaining <= 0) {
+ clearCountdown();
+ onExpire();
+ return;
+ }
+ ctx.state.countdownTimer = requestAnimationFrame(tick);
+ }
+
+ ctx.state.countdownTimer = requestAnimationFrame(tick);
+ }
+
+ function clearCountdown() {
+ if (ctx.state.countdownTimer) {
+ cancelAnimationFrame(ctx.state.countdownTimer);
+ ctx.state.countdownTimer = null;
+ }
+ ctx.dom.countdownEl.classList.remove('visible');
+ }
+
+ function hideCountdown() {
+ clearCountdown();
+ }
+
+ function drawArc(fraction) {
+ const drawContext = ctx.dom.countdownArc.getContext('2d');
+ const radius = 7;
+ const centerX = 9;
+ const centerY = 9;
+ drawContext.clearRect(0, 0, 18, 18);
+ drawContext.beginPath();
+ drawContext.arc(centerX, centerY, radius, -Math.PI / 2, 2 * Math.PI * fraction - Math.PI / 2);
+ drawContext.strokeStyle = 'rgba(255,255,255,0.7)';
+ drawContext.lineWidth = 2;
+ drawContext.stroke();
+ }
+
+ function hideDecisions() {
+ clearActiveDecisionHotkeys();
+ ctx.dom.decisionOverlay.classList.remove('visible');
+ ctx.dom.decisionButtons.innerHTML = '';
+ hideDecisionInputIndicator();
+ }
+
+ function showUnavailableDecisionsError() {
+ if (ctx.state.decisionMade) return;
+ ctx.state.decisionMade = true;
+ clearCountdown();
+ hideDecisions();
+ ui.captureFreeze();
+ ctx.dom.videoEl.pause();
+ playback.stopSubtitleSync();
+ ui.showError('No decisions are currently available for this scene.');
+ }
+
+ function showDecisions(decisions, timeoutSeconds, onDecision) {
+ if (!decisions || decisions.length === 0) {
+ showUnavailableDecisionsError();
+ return;
+ }
+
+ ctx.dom.decisionButtons.innerHTML = '';
+ setActiveDecisionHotkeys(decisions);
+
+ const defaultDecision = decisions.find((decision) => decision.isDefault) || decisions[0];
+ const hideDecisionButtons = !!(ctx.state.currentState && ctx.state.currentState.hideDecisionButtons);
+ const translations = (ctx.state.currentState && ctx.state.currentState.decisionTranslations) || {};
+
+ if (!hideDecisionButtons) {
+ for (const decision of decisions) {
+ const button = document.createElement('button');
+ button.className = 'decision-btn' + (decision.isDefault ? ' default' : '');
+ const label = translations[decision.key] || decision.key;
+ button.textContent = decision.keyboardKey
+ ? `${label} [${formatDecisionHotkey(decision.keyboardKey)}]`
+ : label;
+ button.addEventListener('click', () => {
+ if (ctx.state.decisionMade) return;
+ ctx.state.decisionMade = true;
+ clearCountdown();
+ hideDecisions();
+ onDecision(decision.key);
+ });
+ ctx.dom.decisionButtons.appendChild(button);
+ }
+ }
+
+ ctx.dom.decisionOverlay.classList.toggle('visible', !hideDecisionButtons);
+ showDecisionInputIndicator(decisions);
+ startCountdown(timeoutSeconds, () => {
+ if (!ctx.state.decisionMade) {
+ ctx.state.decisionMade = true;
+ hideDecisions();
+ onDecision(defaultDecision.key);
+ }
+ });
+ }
+
+ return {
+ clearActiveDecisionHotkeys,
+ clearCountdown,
+ findDecisionByKey,
+ hideCountdown,
+ hideDecisions,
+ normalizeDecisionHotkey,
+ showDecisions,
+ showUnavailableDecisionsError,
+ };
+}
diff --git a/runtime/src/main/resources/client/game/js_doc.md b/runtime/src/main/resources/client/game/js_doc.md
new file mode 100644
index 0000000..0468345
--- /dev/null
+++ b/runtime/src/main/resources/client/game/js_doc.md
@@ -0,0 +1,60 @@
+## Responsibilities after the split
+
+- **[api.js](../api.js)**
+ - fetch wrapper for runtime APIs
+
+- **[context.js](../context.js)**
+ - shared DOM refs, settings defaults, runtime state
+
+- **[ui.js](../ui.js)**
+ - screen switching
+ - spinner/error/end screen
+ - freeze-frame capture
+ - scene video listener bookkeeping
+
+- **[settings.js](../settings.js)**
+ - localStorage settings
+ - locale loading
+ - applying UI/audio/button/subtitle settings
+
+- **[playback.js](../playback.js)**
+ - HLS loading
+ - transition playback
+ - preloading
+ - music
+ - subtitle sync
+
+- **[decisions.js](../decisions.js)**
+ - decision hotkeys
+ - decision overlay
+ - countdown
+ - unavailable-decision handling
+
+- **[scene.js](../scene.js)**
+ - scene loading
+ - scene event flow
+ - auto-continue
+ - looping behavior
+ - decision traversal
+ - restart flow
+
+- **[app.js](../app.js)**
+ - bootstrapping
+ - menu/pause/settings button wiring
+ - keyboard input routing
+
+## `Esc` pauses the game
+
+Centralized keyboard handling in [app.js](../app.js) and made `Escape`:
+
+- pause when screen is `game`
+- resume when screen is `paused`
+- close settings when screen is `settings`
+
+also added:
+
+- `preventDefault()`
+- `stopPropagation()`
+- `event.repeat` guard
+
+That makes `Escape` behavior more reliable and avoids repeated toggle spam while the key is held.
diff --git a/runtime/src/main/resources/client/game/playback.js b/runtime/src/main/resources/client/game/playback.js
new file mode 100644
index 0000000..7b0e8da
--- /dev/null
+++ b/runtime/src/main/resources/client/game/playback.js
@@ -0,0 +1,221 @@
+export function createPlaybackController(ctx, ui) {
+ function getHlsCtor() {
+ return window.Hls;
+ }
+
+ function updateMusic(musicUrl) {
+ if (musicUrl === undefined || musicUrl === null) return;
+ if (musicUrl === ctx.state.currentMusicUrl) return;
+
+ ctx.state.currentMusicUrl = musicUrl;
+
+ if (!musicUrl) {
+ stopMusic();
+ return;
+ }
+
+ ctx.dom.musicEl.src = musicUrl;
+ ctx.dom.musicEl.volume = ctx.settings.musicEnabled ? ctx.settings.musicVolume : 0;
+ if (ctx.settings.musicEnabled) {
+ ctx.dom.musicEl.play().catch(() => {});
+ }
+ }
+
+ function stopMusic() {
+ ctx.dom.musicEl.pause();
+ ctx.dom.musicEl.removeAttribute('src');
+ ctx.dom.musicEl.load();
+ ctx.state.currentMusicUrl = null;
+ }
+
+ function setSubtitles(subtitles) {
+ ctx.state.currentSubtitles = (subtitles || []).slice().sort((a, b) => a.startTime - b.startTime);
+ if (ctx.dom.subtitleText) ctx.dom.subtitleText.textContent = '';
+ }
+
+ function stopSubtitleSync() {
+ if (ctx.state.subtitleRafId) {
+ cancelAnimationFrame(ctx.state.subtitleRafId);
+ ctx.state.subtitleRafId = null;
+ }
+ if (ctx.dom.subtitleText) ctx.dom.subtitleText.textContent = '';
+ }
+
+ function startSubtitleSync() {
+ stopSubtitleSync();
+ if (!ctx.state.currentSubtitles.length || !ctx.settings.subtitlesEnabled) return;
+
+ function tick() {
+ const currentTime = ctx.dom.videoEl.currentTime;
+ let found = '';
+ for (const subtitle of ctx.state.currentSubtitles) {
+ if (currentTime >= subtitle.startTime && currentTime < subtitle.endTime) {
+ found = subtitle.text;
+ break;
+ }
+ }
+ if (ctx.dom.subtitleText) ctx.dom.subtitleText.textContent = found;
+ ctx.state.subtitleRafId = requestAnimationFrame(tick);
+ }
+
+ ctx.state.subtitleRafId = requestAnimationFrame(tick);
+ }
+
+ function pauseVideo() {
+ ctx.dom.videoEl.pause();
+ }
+
+ function loadHls(videoElement, src, onReady) {
+ return new Promise((resolve, reject) => {
+ const HlsCtor = getHlsCtor();
+
+ function attachNative() {
+ videoElement.src = src;
+ videoElement.addEventListener('canplay', () => {
+ if (onReady) onReady(null);
+ resolve();
+ }, { once: true });
+ videoElement.addEventListener('error', (event) => reject(new Error('Video error: ' + event.message)), { once: true });
+ }
+
+ if (!HlsCtor || !HlsCtor.isSupported()) {
+ attachNative();
+ return;
+ }
+
+ const hls = new HlsCtor({ enableWorker: false, lowLatencyMode: false });
+ let done = false;
+ const finish = () => {
+ if (!done) {
+ done = true;
+ resolve();
+ }
+ };
+
+ hls.loadSource(src);
+ hls.attachMedia(videoElement);
+ hls.on(HlsCtor.Events.MANIFEST_PARSED, () => {
+ if (onReady) onReady(hls);
+ });
+ hls.on(HlsCtor.Events.FRAG_BUFFERED, finish);
+ videoElement.addEventListener('canplay', finish, { once: true });
+ hls.on(HlsCtor.Events.ERROR, (_, data) => {
+ if (data.fatal) {
+ hls.destroy();
+ if (!done) {
+ done = true;
+ reject(new Error('HLS fatal error: ' + data.type));
+ }
+ }
+ });
+ setTimeout(finish, 10000);
+ });
+ }
+
+ function preloadTransitions(urls) {
+ const HlsCtor = getHlsCtor();
+
+ for (const key of Object.keys(ctx.state.preloadedHls)) {
+ if (!urls.includes(key)) {
+ ctx.state.preloadedHls[key]?.destroy?.();
+ delete ctx.state.preloadedHls[key];
+ }
+ }
+
+ for (const url of urls) {
+ if (ctx.state.preloadedHls[url]) continue;
+ if (!HlsCtor || !HlsCtor.isSupported()) continue;
+
+ const hls = new HlsCtor({ enableWorker: false });
+ const dummy = document.createElement('video');
+ dummy.muted = true;
+ hls.loadSource(url);
+ hls.attachMedia(dummy);
+ ctx.state.preloadedHls[url] = hls;
+ }
+ }
+
+ function preloadScene(url) {
+ const HlsCtor = getHlsCtor();
+
+ if (ctx.state.preloadedSceneHls[url]) return;
+ if (!HlsCtor || !HlsCtor.isSupported()) return;
+
+ const hls = new HlsCtor({ enableWorker: false });
+ const dummy = document.createElement('video');
+ dummy.muted = true;
+ hls.loadSource(url);
+ hls.attachMedia(dummy);
+ ctx.state.preloadedSceneHls[url] = hls;
+ }
+
+ async function playTransition(transition) {
+ if (ctx.state.transHls) {
+ ctx.state.transHls.destroy();
+ ctx.state.transHls = null;
+ }
+
+ const url = transition.transitionHlsUrl;
+ if (ctx.state.preloadedHls[url]) {
+ ctx.state.preloadedHls[url].destroy();
+ delete ctx.state.preloadedHls[url];
+ }
+
+ await loadHls(ctx.dom.transEl, url, (hls) => {
+ ctx.state.transHls = hls;
+ });
+
+ ui.hideSpinner();
+ ctx.dom.transEl.style.backgroundColor = transition.backgroundColor || '';
+ ctx.dom.transEl.classList.add('active');
+ ctx.dom.transEl.play().catch(() => {});
+
+ return new Promise((resolve) => {
+ let cleaned = false;
+ function cleanup() {
+ if (cleaned) return;
+ cleaned = true;
+ ui.captureFreezeFrom(ctx.dom.transEl);
+ ctx.dom.transEl.pause();
+ ctx.dom.transEl.classList.remove('active');
+ ctx.dom.transEl.style.backgroundColor = '';
+ if (ctx.state.transHls) {
+ ctx.state.transHls.destroy();
+ ctx.state.transHls = null;
+ }
+ resolve();
+ }
+ ctx.dom.transEl.addEventListener('ended', cleanup, { once: true });
+ setTimeout(cleanup, ((transition.duration || 2) + 1) * 1000);
+ });
+ }
+
+ function resetPlaybackState() {
+ if (ctx.state.hlsInstance) {
+ ctx.state.hlsInstance.destroy();
+ ctx.state.hlsInstance = null;
+ }
+ if (ctx.state.transHls) {
+ ctx.state.transHls.destroy();
+ ctx.state.transHls = null;
+ }
+ for (const hls of Object.values(ctx.state.preloadedHls)) hls?.destroy?.();
+ ctx.state.preloadedHls = {};
+ for (const hls of Object.values(ctx.state.preloadedSceneHls)) hls?.destroy?.();
+ ctx.state.preloadedSceneHls = {};
+ }
+
+ return {
+ loadHls,
+ pauseVideo,
+ playTransition,
+ preloadScene,
+ preloadTransitions,
+ resetPlaybackState,
+ setSubtitles,
+ startSubtitleSync,
+ stopMusic,
+ stopSubtitleSync,
+ updateMusic,
+ };
+}
diff --git a/runtime/src/main/resources/client/game/scene.js b/runtime/src/main/resources/client/game/scene.js
new file mode 100644
index 0000000..0b51a45
--- /dev/null
+++ b/runtime/src/main/resources/client/game/scene.js
@@ -0,0 +1,230 @@
+export function createSceneController(ctx, { apiFetch, localeQueryString, ui, playback, decisions }) {
+ async function loadScene(state) {
+ ctx.state.currentState = state;
+ ctx.state.decisionMade = false;
+ decisions.clearActiveDecisionHotkeys();
+
+ ui.clearSceneVideoListeners();
+ ctx.state.loopHandler = null;
+
+ decisions.hideDecisions();
+ decisions.hideCountdown();
+ playback.stopSubtitleSync();
+ ctx.dom.endScreen.classList.remove('visible');
+
+ if (ctx.dom.freezeCanvas.style.display === 'none') ui.showSpinner('Loading…');
+
+ if (ctx.state.hlsInstance) {
+ ctx.state.hlsInstance.destroy();
+ ctx.state.hlsInstance = null;
+ }
+
+ const sceneUrl = state.sceneHlsUrl;
+ if (ctx.state.preloadedSceneHls[sceneUrl]) {
+ ctx.state.preloadedSceneHls[sceneUrl].destroy();
+ delete ctx.state.preloadedSceneHls[sceneUrl];
+ }
+
+ await playback.loadHls(ctx.dom.videoEl, sceneUrl, (hls) => {
+ ctx.state.hlsInstance = hls;
+ });
+
+ ctx.dom.videoEl.volume = ctx.settings.videoVolume;
+
+ playback.preloadTransitions(state.preloadUrls || []);
+ if (state.autoContinueNextSceneUrl) {
+ playback.preloadScene(state.autoContinueNextSceneUrl);
+ }
+
+ playback.updateMusic(state.musicUrl);
+ playback.setSubtitles(state.subtitles || []);
+
+ const loopVideo = !!state.loopVideo;
+ ctx.dom.videoEl.loop = false;
+
+ const availableDecisions = state.decisions || [];
+ const hasExplicitDecisions = !!state.hasExplicitDecisions;
+ const timeoutSeconds = state.decisionTimeoutSecs || 5;
+ const isEnd = state.isEnd;
+
+ if (state.autoContinue) {
+ ui.addSceneVideoListener('ended', async () => {
+ ui.captureFreeze();
+ if (!ctx.state.decisionMade) {
+ ctx.state.decisionMade = true;
+ await makeDecision('CONTINUE');
+ }
+ }, { once: true });
+ } else {
+ let appearAt = null;
+ try {
+ if (state.decisionAppearanceConfig) {
+ const config = JSON.parse(state.decisionAppearanceConfig);
+ if (config.timing === 'at_timestamp' && typeof config.timestamp === 'number') {
+ appearAt = config.timestamp;
+ }
+ }
+ } catch {}
+
+ if (availableDecisions.length > 0) {
+ if (appearAt !== null) {
+ ui.addSceneVideoListener('timeupdate', function onTimeUpdate() {
+ if (ctx.dom.videoEl.currentTime >= appearAt) {
+ ctx.dom.videoEl.removeEventListener('timeupdate', onTimeUpdate);
+ if (!ctx.state.decisionMade) decisions.showDecisions(availableDecisions, timeoutSeconds, makeDecision);
+ }
+ });
+ }
+
+ if (loopVideo) {
+ let decisionsShown = false;
+ ctx.state.loopHandler = function onLoop() {
+ if (ctx.state.decisionMade) {
+ ctx.dom.videoEl.removeEventListener('ended', ctx.state.loopHandler);
+ ctx.state.loopHandler = null;
+ return;
+ }
+ if (isEnd) {
+ ctx.dom.videoEl.removeEventListener('ended', ctx.state.loopHandler);
+ ctx.state.loopHandler = null;
+ ui.captureFreeze();
+ ui.showEndScreen();
+ return;
+ }
+ if (!decisionsShown && appearAt === null) {
+ decisionsShown = true;
+ decisions.showDecisions(availableDecisions, timeoutSeconds, makeDecision);
+ }
+ if (ctx.state.hlsInstance) ctx.state.hlsInstance.startLoad(0);
+ ctx.dom.videoEl.currentTime = 0;
+ ctx.dom.videoEl.play().catch(() => {});
+ };
+ ui.addSceneVideoListener('ended', ctx.state.loopHandler);
+ } else {
+ ui.addSceneVideoListener('ended', function onEnded() {
+ ctx.dom.videoEl.removeEventListener('ended', onEnded);
+ ui.captureFreeze();
+ if (isEnd) {
+ ui.showEndScreen();
+ return;
+ }
+ if (!ctx.state.decisionMade) decisions.showDecisions(availableDecisions, timeoutSeconds, makeDecision);
+ }, { once: true });
+ }
+ } else if (hasExplicitDecisions) {
+ if (appearAt !== null) {
+ ui.addSceneVideoListener('timeupdate', function onTimeUpdate() {
+ if (ctx.dom.videoEl.currentTime >= appearAt) {
+ ctx.dom.videoEl.removeEventListener('timeupdate', onTimeUpdate);
+ decisions.showUnavailableDecisionsError();
+ }
+ });
+ }
+
+ if (loopVideo) {
+ let unavailableShown = false;
+ ctx.state.loopHandler = function onLoop() {
+ if (ctx.state.decisionMade) {
+ ctx.dom.videoEl.removeEventListener('ended', ctx.state.loopHandler);
+ ctx.state.loopHandler = null;
+ return;
+ }
+ if (isEnd) {
+ ctx.dom.videoEl.removeEventListener('ended', ctx.state.loopHandler);
+ ctx.state.loopHandler = null;
+ ui.captureFreeze();
+ ui.showEndScreen();
+ return;
+ }
+ if (!unavailableShown && appearAt === null) {
+ unavailableShown = true;
+ decisions.showUnavailableDecisionsError();
+ return;
+ }
+ if (ctx.state.hlsInstance) ctx.state.hlsInstance.startLoad(0);
+ ctx.dom.videoEl.currentTime = 0;
+ ctx.dom.videoEl.play().catch(() => {});
+ };
+ ui.addSceneVideoListener('ended', ctx.state.loopHandler);
+ } else {
+ ui.addSceneVideoListener('ended', function onEnded() {
+ ctx.dom.videoEl.removeEventListener('ended', onEnded);
+ if (!ctx.state.decisionMade) decisions.showUnavailableDecisionsError();
+ }, { once: true });
+ }
+ } else if (isEnd) {
+ ui.addSceneVideoListener('ended', () => {
+ ui.captureFreeze();
+ ui.showEndScreen();
+ }, { once: true });
+ } else {
+ ui.addSceneVideoListener('ended', async () => {
+ ui.captureFreeze();
+ await makeDecision('CONTINUE');
+ }, { once: true });
+ }
+ }
+
+ ctx.dom.videoEl.play().catch(() => {});
+ await new Promise((resolve) => {
+ if (ctx.dom.videoEl.readyState >= 3) {
+ resolve();
+ return;
+ }
+ ctx.dom.videoEl.addEventListener('playing', resolve, { once: true });
+ setTimeout(resolve, 1000);
+ });
+
+ ui.hideSpinner();
+ ui.hideFreeze();
+ playback.startSubtitleSync();
+ }
+
+ async function makeDecision(decisionKey) {
+ ui.captureFreeze();
+ ctx.dom.videoEl.loop = false;
+ ctx.dom.videoEl.pause();
+ playback.stopSubtitleSync();
+ try {
+ const result = await apiFetch('/api/game/decide' + localeQueryString(), {
+ method: 'POST',
+ body: JSON.stringify({ decisionKey }),
+ });
+
+ if (result.transition) {
+ await playback.playTransition(result.transition);
+ }
+
+ await loadScene(result.nextState);
+ } catch (error) {
+ ui.showError('Error: ' + (error.message || error));
+ }
+ }
+
+ async function restartGame() {
+ decisions.clearCountdown();
+ decisions.hideDecisions();
+ ui.hideFreeze();
+ ctx.dom.endScreen.classList.remove('visible');
+ ui.showSpinner('Restarting…');
+
+ playback.resetPlaybackState();
+ playback.stopMusic();
+
+ try {
+ const state = await apiFetch('/api/game/restart' + localeQueryString(), {
+ method: 'POST',
+ body: '{}',
+ });
+ await loadScene(state);
+ } catch (error) {
+ ui.showError('Restart failed: ' + (error.message || error));
+ }
+ }
+
+ return {
+ loadScene,
+ makeDecision,
+ restartGame,
+ };
+}
diff --git a/runtime/src/main/resources/client/game/settings.js b/runtime/src/main/resources/client/game/settings.js
new file mode 100644
index 0000000..5bb2bf5
--- /dev/null
+++ b/runtime/src/main/resources/client/game/settings.js
@@ -0,0 +1,142 @@
+const SETTINGS_KEY = 'arvexis_settings';
+
+export function createSettingsController(ctx, { apiFetch }) {
+ function loadSettings() {
+ try {
+ const saved = localStorage.getItem(SETTINGS_KEY);
+ if (saved) ctx.settings = { ...ctx.defaultSettings, ...JSON.parse(saved) };
+ } catch {}
+ }
+
+ function saveSettings() {
+ try {
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(ctx.settings));
+ } catch {}
+ }
+
+ function hexToRgba(hex, alpha) {
+ const r = parseInt(hex.slice(1, 3), 16);
+ const g = parseInt(hex.slice(3, 5), 16);
+ const b = parseInt(hex.slice(5, 7), 16);
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+ }
+
+ function applySettings() {
+ ctx.dom.musicEl.volume = ctx.settings.musicEnabled ? ctx.settings.musicVolume : 0;
+ if (!ctx.settings.musicEnabled && !ctx.dom.musicEl.paused) ctx.dom.musicEl.pause();
+ if (ctx.settings.musicEnabled && ctx.dom.musicEl.src && ctx.dom.musicEl.paused && ctx.state.appScreen === 'game') {
+ ctx.dom.musicEl.play().catch(() => {});
+ }
+
+ ctx.dom.videoEl.volume = ctx.settings.videoVolume;
+
+ document.documentElement.style.setProperty('--arvexis-btn-bg', hexToRgba(ctx.settings.btnBg, 0.65));
+ document.documentElement.style.setProperty('--arvexis-btn-text', ctx.settings.btnText);
+ document.documentElement.style.setProperty('--arvexis-btn-hover-bg', hexToRgba(ctx.settings.btnText, 0.15));
+
+ ctx.dom.decisionOverlay.setAttribute('data-position', ctx.settings.btnPosition);
+
+ if (ctx.dom.subtitleContainer) {
+ ctx.dom.subtitleContainer.classList.toggle('hidden', !ctx.settings.subtitlesEnabled);
+ }
+
+ ctx.dom.settingMusicVol.value = Math.round(ctx.settings.musicVolume * 100);
+ ctx.dom.settingVideoVol.value = Math.round(ctx.settings.videoVolume * 100);
+ ctx.dom.settingMusicEnabled.checked = ctx.settings.musicEnabled;
+ ctx.dom.settingBtnBg.value = ctx.settings.btnBg;
+ ctx.dom.settingBtnText.value = ctx.settings.btnText;
+ ctx.dom.settingBtnPos.value = ctx.settings.btnPosition;
+ ctx.dom.settingResolution.value = ctx.settings.resolution;
+ if (ctx.dom.settingSubtitlesEnabled) ctx.dom.settingSubtitlesEnabled.checked = ctx.settings.subtitlesEnabled;
+ if (ctx.dom.settingLocale) ctx.dom.settingLocale.value = ctx.settings.locale;
+ ctx.dom.musicVolDisplay.textContent = Math.round(ctx.settings.musicVolume * 100) + '%';
+ ctx.dom.videoVolDisplay.textContent = Math.round(ctx.settings.videoVolume * 100) + '%';
+ ctx.dom.btnBgDisplay.textContent = ctx.settings.btnBg;
+ ctx.dom.btnTextDisplay.textContent = ctx.settings.btnText;
+ }
+
+ function localeQueryString() {
+ return ctx.settings.locale ? '?locale=' + encodeURIComponent(ctx.settings.locale) : '';
+ }
+
+ async function loadLocales() {
+ try {
+ const data = await apiFetch('/api/game/locales');
+ const select = ctx.dom.settingLocale;
+ if (!select) return;
+ while (select.options.length > 1) select.remove(1);
+ (data.locales || []).forEach((locale) => {
+ const opt = document.createElement('option');
+ opt.value = locale.code;
+ opt.textContent = locale.name + ' (' + locale.code + ')';
+ select.appendChild(opt);
+ });
+ if (!ctx.settings.locale && data.defaultLocaleCode) {
+ ctx.settings.locale = data.defaultLocaleCode;
+ }
+ select.value = ctx.settings.locale;
+ } catch {}
+ }
+
+ function bindSettingsControls() {
+ ctx.dom.settingMusicVol.addEventListener('input', () => {
+ ctx.settings.musicVolume = ctx.dom.settingMusicVol.value / 100;
+ ctx.dom.musicVolDisplay.textContent = ctx.dom.settingMusicVol.value + '%';
+ ctx.dom.musicEl.volume = ctx.settings.musicEnabled ? ctx.settings.musicVolume : 0;
+ });
+
+ ctx.dom.settingVideoVol.addEventListener('input', () => {
+ ctx.settings.videoVolume = ctx.dom.settingVideoVol.value / 100;
+ ctx.dom.videoVolDisplay.textContent = ctx.dom.settingVideoVol.value + '%';
+ ctx.dom.videoEl.volume = ctx.settings.videoVolume;
+ });
+
+ ctx.dom.settingMusicEnabled.addEventListener('change', () => {
+ ctx.settings.musicEnabled = ctx.dom.settingMusicEnabled.checked;
+ applySettings();
+ });
+
+ ctx.dom.settingBtnBg.addEventListener('input', () => {
+ ctx.settings.btnBg = ctx.dom.settingBtnBg.value;
+ ctx.dom.btnBgDisplay.textContent = ctx.settings.btnBg;
+ applySettings();
+ });
+
+ ctx.dom.settingBtnText.addEventListener('input', () => {
+ ctx.settings.btnText = ctx.dom.settingBtnText.value;
+ ctx.dom.btnTextDisplay.textContent = ctx.settings.btnText;
+ applySettings();
+ });
+
+ ctx.dom.settingBtnPos.addEventListener('change', () => {
+ ctx.settings.btnPosition = ctx.dom.settingBtnPos.value;
+ applySettings();
+ });
+
+ ctx.dom.settingResolution.addEventListener('change', () => {
+ ctx.settings.resolution = ctx.dom.settingResolution.value;
+ });
+
+ if (ctx.dom.settingSubtitlesEnabled) {
+ ctx.dom.settingSubtitlesEnabled.addEventListener('change', () => {
+ ctx.settings.subtitlesEnabled = ctx.dom.settingSubtitlesEnabled.checked;
+ applySettings();
+ });
+ }
+
+ if (ctx.dom.settingLocale) {
+ ctx.dom.settingLocale.addEventListener('change', () => {
+ ctx.settings.locale = ctx.dom.settingLocale.value;
+ });
+ }
+ }
+
+ return {
+ applySettings,
+ bindSettingsControls,
+ loadLocales,
+ loadSettings,
+ localeQueryString,
+ saveSettings,
+ };
+}
diff --git a/runtime/src/main/resources/client/game/ui.js b/runtime/src/main/resources/client/game/ui.js
new file mode 100644
index 0000000..79f27b0
--- /dev/null
+++ b/runtime/src/main/resources/client/game/ui.js
@@ -0,0 +1,80 @@
+export function createUiController(ctx) {
+ function showScreen(screen) {
+ ctx.state.appScreen = screen;
+
+ ctx.dom.mainMenu.classList.toggle('hidden', screen !== 'menu');
+ ctx.dom.gameScreen.classList.toggle('hidden', screen !== 'game' && screen !== 'paused');
+ ctx.dom.pauseOverlay.classList.toggle('visible', screen === 'paused');
+ ctx.dom.settingsOverlay.classList.toggle('visible', screen === 'settings');
+
+ const resGroup = ctx.$('resolution-group');
+ if (resGroup) {
+ resGroup.style.display = (screen === 'settings' && ctx.state.settingsReturnTo === 'menu') ? '' : 'none';
+ }
+ }
+
+ function showSpinner(text) {
+ ctx.dom.spinnerText.textContent = text || 'Loading…';
+ ctx.dom.spinner.classList.remove('hidden');
+ ctx.dom.errorBox.classList.remove('visible');
+ }
+
+ function hideSpinner() {
+ ctx.dom.spinner.classList.add('hidden');
+ }
+
+ function showError(msg) {
+ hideSpinner();
+ ctx.dom.errorMsg.textContent = msg;
+ ctx.dom.errorBox.classList.add('visible');
+ }
+
+ function showEndScreen() {
+ hideSpinner();
+ ctx.dom.endScreen.classList.add('visible');
+ }
+
+ function captureFreezeFrom(videoElement) {
+ try {
+ ctx.dom.freezeCanvas.width = videoElement.videoWidth || 1280;
+ ctx.dom.freezeCanvas.height = videoElement.videoHeight || 720;
+ const freezeContext = ctx.dom.freezeCanvas.getContext('2d');
+ freezeContext.drawImage(videoElement, 0, 0, ctx.dom.freezeCanvas.width, ctx.dom.freezeCanvas.height);
+ ctx.dom.freezeCanvas.style.display = 'block';
+ } catch {}
+ }
+
+ function captureFreeze() {
+ captureFreezeFrom(ctx.dom.videoEl);
+ }
+
+ function hideFreeze() {
+ ctx.dom.freezeCanvas.style.display = 'none';
+ }
+
+ function addSceneVideoListener(type, handler, options) {
+ ctx.dom.videoEl.addEventListener(type, handler, options);
+ const capture = typeof options === 'boolean' ? options : !!(options && options.capture);
+ ctx.state.sceneVideoListeners.push({ type, handler, capture });
+ }
+
+ function clearSceneVideoListeners() {
+ for (const listener of ctx.state.sceneVideoListeners) {
+ ctx.dom.videoEl.removeEventListener(listener.type, listener.handler, listener.capture);
+ }
+ ctx.state.sceneVideoListeners = [];
+ }
+
+ return {
+ addSceneVideoListener,
+ captureFreeze,
+ captureFreezeFrom,
+ clearSceneVideoListeners,
+ hideFreeze,
+ hideSpinner,
+ showEndScreen,
+ showError,
+ showScreen,
+ showSpinner,
+ };
+}
diff --git a/runtime/src/main/resources/client/index.html b/runtime/src/main/resources/client/index.html
index 19f775b..71e813c 100644
--- a/runtime/src/main/resources/client/index.html
+++ b/runtime/src/main/resources/client/index.html
@@ -200,6 +200,6 @@ Settings