diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c98cf51..8431306 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: jobs: build: - name: Build JAR (Linux x64) + name: Build editor + runtime JARs runs-on: ubuntu-latest steps: @@ -41,6 +41,11 @@ jobs: cd editor/backend mvn package -DskipTests -q + - name: Build runtime JAR + run: | + cd runtime + mvn package -DskipTests -q + - name: Upload JAR artifact uses: actions/upload-artifact@v4 with: @@ -49,7 +54,7 @@ jobs: retention-days: 14 test: - name: Run tests + name: Run editor + runtime tests runs-on: ubuntu-latest steps: @@ -67,3 +72,8 @@ jobs: run: | cd editor/backend mvn test -q + + - name: Run runtime tests + run: | + cd runtime + mvn test -q diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 586b9e3..4998f98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,6 @@ on: type: string jobs: - # ── Build native binaries on each platform ────────────────────────────────── build-native: name: Native binary — ${{ matrix.name }} runs-on: ${{ matrix.os }} @@ -21,14 +20,29 @@ jobs: matrix: include: - os: ubuntu-latest - name: Linux x64 + name: Editor Linux x64 + component: editor + project_path: editor/backend artifact: arvexis-editor-linux-x64 binary: editor/backend/target/arvexis-editor - - os: windows-latest - name: Windows x64 + name: Editor Windows x64 + component: editor + project_path: editor/backend artifact: arvexis-editor-windows-x64 binary: editor/backend/target/arvexis-editor.exe + - os: ubuntu-latest + name: Runtime Linux x64 + component: runtime + project_path: runtime + artifact: arvexis-runtime-linux-x64 + binary: runtime/target/arvexis-runtime + - os: windows-latest + name: Runtime Windows x64 + component: runtime + project_path: runtime + artifact: arvexis-runtime-windows-x64 + binary: runtime/target/arvexis-runtime.exe steps: - name: Checkout @@ -42,6 +56,7 @@ jobs: cache: maven - name: Set up Node.js 20 + if: matrix.component == 'editor' uses: actions/setup-node@v4 with: node-version: "20" @@ -49,15 +64,15 @@ jobs: cache-dependency-path: editor/frontend/package-lock.json - name: Build frontend + if: matrix.component == 'editor' run: | cd editor/frontend npm ci npm run build - name: Build native binary - run: | - cd editor/backend - mvn -Pnative native:compile -DskipTests -q + working-directory: ${{ matrix.project_path }} + run: mvn -Pnative native:compile -DskipTests -q - name: Make binary executable (Linux) if: runner.os == 'Linux' @@ -69,10 +84,23 @@ jobs: name: ${{ matrix.artifact }} path: ${{ matrix.binary }} - # ── Also build the cross-platform JAR ─────────────────────────────────────── build-jar: - name: JAR (cross-platform) + name: JAR — ${{ matrix.name }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: Editor + component: editor + project_path: editor/backend + artifact: arvexis-editor-jar + jar: editor/backend/target/editor-backend-*.jar + - name: Runtime + component: runtime + project_path: runtime + artifact: arvexis-runtime-jar + jar: runtime/target/runtime-*.jar steps: - name: Checkout @@ -86,27 +114,30 @@ jobs: cache: maven - name: Set up Node.js 20 + if: matrix.component == 'editor' uses: actions/setup-node@v4 with: node-version: "20" cache: npm cache-dependency-path: editor/frontend/package-lock.json - - name: Build frontend + JAR + - name: Build frontend + if: matrix.component == 'editor' run: | cd editor/frontend npm ci npm run build - cd ../backend - mvn package -DskipTests -q + + - name: Build JAR + working-directory: ${{ matrix.project_path }} + run: mvn package -DskipTests -q - name: Upload JAR uses: actions/upload-artifact@v4 with: - name: arvexis-editor-jar - path: editor/backend/target/editor-backend-*.jar + name: ${{ matrix.artifact }} + path: ${{ matrix.jar }} - # ── Create draft GitHub release with all artifacts ────────────────────────── release: name: Create draft release needs: [build-native, build-jar] @@ -131,14 +162,23 @@ jobs: - name: Prepare release files run: | mkdir -p release - # Copy Linux binary - cp artifacts/arvexis-editor-linux-x64/arvexis-editor release/arvexis-editor-linux-x64 || echo "Linux binary not found" + + cp artifacts/arvexis-editor-linux-x64/arvexis-editor release/arvexis-editor-linux-x64 || echo "Editor Linux binary not found" chmod +x release/arvexis-editor-linux-x64 || true - # Copy Windows binary - cp artifacts/arvexis-editor-windows-x64/arvexis-editor.exe release/arvexis-editor-windows-x64.exe || echo "Windows binary not found" - # Copy JAR - JAR=$(find artifacts/arvexis-editor-jar -name "*.jar" | head -1) - cp "$JAR" release/arvexis-editor.jar || echo "JAR not found" + + cp artifacts/arvexis-editor-windows-x64/arvexis-editor.exe release/arvexis-editor-windows-x64.exe || echo "Editor Windows binary not found" + + cp artifacts/arvexis-runtime-linux-x64/arvexis-runtime release/arvexis-runtime-linux-x64 || echo "Runtime Linux binary not found" + chmod +x release/arvexis-runtime-linux-x64 || true + + cp artifacts/arvexis-runtime-windows-x64/arvexis-runtime.exe release/arvexis-runtime-windows-x64.exe || echo "Runtime Windows binary not found" + + EDITOR_JAR=$(find artifacts/arvexis-editor-jar -name "*.jar" | head -1) + if [ -n "$EDITOR_JAR" ]; then cp "$EDITOR_JAR" release/arvexis-editor.jar; else echo "Editor JAR not found"; fi + + RUNTIME_JAR=$(find artifacts/arvexis-runtime-jar -name "runtime-*.jar" ! -name "original-*" | head -1) + if [ -n "$RUNTIME_JAR" ]; then cp "$RUNTIME_JAR" release/arvexis-runtime.jar; else echo "Runtime JAR not found"; fi + ls -la release/ - name: Create draft release @@ -147,9 +187,9 @@ jobs: draft: true generate_release_notes: true tag_name: ${{ github.event.inputs.tag || github.ref_name }} - name: "Arvexis Editor ${{ github.event.inputs.tag || github.ref_name }}" + name: "Arvexis ${{ github.event.inputs.tag || github.ref_name }}" body: | - ## Downloads + ## Editor downloads | Platform | File | |----------|------| @@ -157,21 +197,31 @@ jobs: | Windows x64 (native, no JVM) | `arvexis-editor-windows-x64.exe` | | All platforms (requires Java 21) | `arvexis-editor.jar` | - **Run the JAR:** + ## Runtime downloads + + | Platform | File | + |----------|------| + | Linux x64 (native, no JVM) | `arvexis-runtime-linux-x64` | + | Windows x64 (native, no JVM) | `arvexis-runtime-windows-x64.exe` | + | All platforms (requires Java 21) | `arvexis-runtime.jar` | + + Exported game packages no longer bundle the runtime. Download a runtime artifact from this release and place it next to the exported `start.sh` or `start.bat` launcher before running the package. + + **Run the editor JAR:** ``` java -jar arvexis-editor.jar ``` Then open http://localhost:8080 - **CLI options:** + **Run the runtime JAR inside an exported game folder:** ``` - java -jar arvexis-editor.jar \ - --server.port=9090 \ - --editor.log.path=/custom/logs \ - --logging.level.com.engine.editor=DEBUG + java -jar arvexis-runtime.jar ``` files: | release/arvexis-editor-linux-x64 release/arvexis-editor-windows-x64.exe release/arvexis-editor.jar + release/arvexis-runtime-linux-x64 + release/arvexis-runtime-windows-x64.exe + release/arvexis-runtime.jar fail_on_unmatched_files: false diff --git a/build-native.sh b/build-native.sh index b00ed4e..d47db8c 100755 --- a/build-native.sh +++ b/build-native.sh @@ -9,6 +9,8 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FRONTEND="$ROOT/editor/frontend" BACKEND="$ROOT/editor/backend" +RUNTIME="$ROOT/runtime" + # Verify GraalVM if ! java -version 2>&1 | grep -qi "graalvm\|native"; then @@ -18,12 +20,14 @@ if ! java -version 2>&1 | grep -qi "graalvm\|native"; then exit 1 fi -RUNTIME="$ROOT/runtime" echo "==> [1/3] Building runtime JAR..." cd "$RUNTIME" -mvn clean package -DskipTests -q -cp "$RUNTIME/target/runtime-1.0.0.jar" "$BACKEND/src/main/resources/bundled/runtime.jar" +mvn -Pnative native:compile clean package -DskipTests -q +BINARY="$RUNTIME/target/arvexis-runtime" +echo "Runtime has been built and is available at:" +echo "$BINARY" +echo "It can be used to run the game/interactive-video if placed next to `dist`" echo "==> [2/3] Building frontend..." cd "$FRONTEND" diff --git a/build.sh b/build.sh index f96529c..f1950b1 100755 --- a/build.sh +++ b/build.sh @@ -7,13 +7,17 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FRONTEND="$ROOT/editor/frontend" BACKEND="$ROOT/editor/backend" - RUNTIME="$ROOT/runtime" echo "==> [1/3] Building runtime JAR..." cd "$RUNTIME" mvn clean package -DskipTests -q -cp "$RUNTIME/target/runtime-1.0.0.jar" "$BACKEND/src/main/resources/bundled/runtime.jar" +JAR=$(ls "$RUNTIME/target/runtime-"*.jar 2>/dev/null | grep -v sources | head -1) +cp "$JAR" "$RUNTIME/target/arvexis-runtime.jar" +JAR="$RUNTIME/target/arvexis-runtime.jar" +echo "Runtime has been built and is available at:" +echo " $JAR" +echo "It can be used to run the game/interactive-video if placed next to dist" echo "==> [2/3] Building frontend..." cd "$FRONTEND" diff --git a/editor/backend/pom.xml b/editor/backend/pom.xml index 861abf3..4a3636e 100644 --- a/editor/backend/pom.xml +++ b/editor/backend/pom.xml @@ -13,7 +13,7 @@ com.engine editor-backend - 1.0.0-RELEASE + 1.1.0-RELEASE editor-backend Arvexis - Editor Backend @@ -62,6 +62,15 @@ + + + src/main/resources + + bundled/** + + + + org.springframework.boot diff --git a/editor/backend/src/main/java/com/engine/editor/controller/dto/UpdateNodeRequest.java b/editor/backend/src/main/java/com/engine/editor/controller/dto/UpdateNodeRequest.java index 10479ad..8111db9 100644 --- a/editor/backend/src/main/java/com/engine/editor/controller/dto/UpdateNodeRequest.java +++ b/editor/backend/src/main/java/com/engine/editor/controller/dto/UpdateNodeRequest.java @@ -9,6 +9,9 @@ public record UpdateNodeRequest( String decisionAppearanceConfig, String musicAssetId, Boolean clearMusicAsset, + Boolean hideDecisionButtons, + Boolean showDecisionInputIndicator, + Boolean clearDecisionInputModeOverride, Double posX, Double posY ) {} diff --git a/editor/backend/src/main/java/com/engine/editor/model/GraphNode.java b/editor/backend/src/main/java/com/engine/editor/model/GraphNode.java index abea79a..bc5083d 100644 --- a/editor/backend/src/main/java/com/engine/editor/model/GraphNode.java +++ b/editor/backend/src/main/java/com/engine/editor/model/GraphNode.java @@ -18,6 +18,8 @@ public record NodeExit(String key, String label, boolean isDefault) {} private String backgroundColor; private String decisionAppearanceConfig; private String musicAssetId; + private Boolean hideDecisionButtons; + private Boolean showDecisionInputIndicator; private double posX; private double posY; private List exits; @@ -60,6 +62,16 @@ public void setDecisionAppearanceConfig(String decisionAppearanceConfig) { public String getMusicAssetId() { return musicAssetId; } public void setMusicAssetId(String musicAssetId) { this.musicAssetId = musicAssetId; } + public Boolean getHideDecisionButtons() { return hideDecisionButtons; } + public void setHideDecisionButtons(Boolean hideDecisionButtons) { + this.hideDecisionButtons = hideDecisionButtons; + } + + public Boolean getShowDecisionInputIndicator() { return showDecisionInputIndicator; } + public void setShowDecisionInputIndicator(Boolean showDecisionInputIndicator) { + this.showDecisionInputIndicator = showDecisionInputIndicator; + } + public double getPosX() { return posX; } public void setPosX(double posX) { this.posX = posX; } diff --git a/editor/backend/src/main/java/com/engine/editor/service/CompileService.java b/editor/backend/src/main/java/com/engine/editor/service/CompileService.java index 9c2763c..393b9e7 100644 --- a/editor/backend/src/main/java/com/engine/editor/service/CompileService.java +++ b/editor/backend/src/main/java/com/engine/editor/service/CompileService.java @@ -8,7 +8,6 @@ import org.springframework.stereotype.Service; import java.io.IOException; -import java.io.InputStream; import java.nio.file.*; import java.nio.file.attribute.PosixFilePermission; import java.util.*; @@ -40,6 +39,8 @@ public class CompileService { "dissolve", "dissolve" ); + private static final String RUNTIME_RELEASES_URL = "https://github.com/sepgh/Arvexis/releases"; + private final ManifestService manifestService; private final ProjectService projectService; private final FFmpegVideoProcessor videoProcessor; @@ -227,31 +228,32 @@ private Path buildPackage(PreviewJob job, Path projectDir, Path outputBase, Path manifestFile, Path sourceAssetsDir, Set referencedMusicAssetPaths) throws Exception { Path distDir = projectDir.resolve("dist"); - Files.createDirectories(distDir); + recreateDirectory(distDir); // manifest.json job.setProgress(92, "Packaging: copying manifest…"); Files.copy(manifestFile, distDir.resolve("manifest.json"), StandardCopyOption.REPLACE_EXISTING); - // runtime.jar from classpath resource - job.setProgress(93, "Packaging: extracting runtime…"); - try (InputStream in = getClass().getResourceAsStream("/bundled/runtime.jar")) { - if (in != null) { - Files.copy(in, distDir.resolve("runtime.jar"), StandardCopyOption.REPLACE_EXISTING); - } else { - System.err.println("[CompileService] /bundled/runtime.jar not found in classpath"); - } - } - // start.sh - job.setProgress(94, "Packaging: writing startup scripts…"); + job.setProgress(93, "Packaging: writing startup scripts…"); Path startSh = distDir.resolve("start.sh"); Files.writeString(startSh, "#!/bin/sh\n" + - "# Arvexis — Runtime\n" + - "# Usage: ./start.sh [--port 8090]\n" + - "java -jar runtime.jar \"$@\"\n", + "set -e\n" + + "if [ -x \"./arvexis-runtime\" ]; then\n" + + " exec ./arvexis-runtime \"$@\"\n" + + "fi\n" + + "if [ -x \"./arvexis-runtime-linux-x64\" ]; then\n" + + " exec ./arvexis-runtime-linux-x64 \"$@\"\n" + + "fi\n" + + "if [ -f \"./arvexis-runtime.jar\" ]; then\n" + + " exec java -jar ./arvexis-runtime.jar \"$@\"\n" + + "fi\n" + + "echo \"Arvexis runtime is not bundled with this export.\"\n" + + "echo \"Download a runtime binary for your platform or arvexis-runtime.jar from:\"\n" + + "echo \" " + RUNTIME_RELEASES_URL + "\"\n" + + "exit 1\n", StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); try { Set perms = new HashSet<>( @@ -264,12 +266,26 @@ private Path buildPackage(PreviewJob job, Path projectDir, Path outputBase, // start.bat Files.writeString(distDir.resolve("start.bat"), "@echo off\r\n" + - "REM Arvexis — Runtime\r\n" + - "java -jar runtime.jar %*\r\n", + "if exist arvexis-runtime.exe (\r\n" + + " arvexis-runtime.exe %*\r\n" + + " goto :eof\r\n" + + ")\r\n" + + "if exist arvexis-runtime-windows-x64.exe (\r\n" + + " arvexis-runtime-windows-x64.exe %*\r\n" + + " goto :eof\r\n" + + ")\r\n" + + "if exist arvexis-runtime.jar (\r\n" + + " java -jar arvexis-runtime.jar %*\r\n" + + " goto :eof\r\n" + + ")\r\n" + + "echo Arvexis runtime is not bundled with this export.\r\n" + + "echo Download a runtime binary for your platform or arvexis-runtime.jar from:\r\n" + + "echo " + RUNTIME_RELEASES_URL + "\r\n" + + "exit /b 1\r\n", StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); // README.md - job.setProgress(95, "Packaging: writing README…"); + job.setProgress(94, "Packaging: writing README…"); String projectName = projectDir.getFileName().toString(); Files.writeString(distDir.resolve("README.md"), buildReadme(projectName), @@ -284,7 +300,7 @@ private Path buildPackage(PreviewJob job, Path projectDir, Path outputBase, } // Copy compiled HLS output into dist/output so dist/ can be run directly. - job.setProgress(96, "Packaging: copying runtime media…"); + job.setProgress(95, "Packaging: copying runtime media…"); Path distOutputDir = distDir.resolve("output"); copyDirectoryContents(outputBase, distOutputDir); @@ -306,9 +322,9 @@ private Path buildPackage(PreviewJob job, Path projectDir, Path outputBase, } // Create dist.zip - job.setProgress(97, "Packaging: creating ZIP archive…"); + job.setProgress(96, "Packaging: creating ZIP archive…"); Path zipFile = projectDir.resolve("dist.zip"); - createZip(distDir, outputBase, sourceAssetsDir, referencedMusicAssetPaths, zipFile); + createZip(distDir, zipFile); job.setProgress(100, "Package ready."); return zipFile; @@ -316,17 +332,27 @@ private Path buildPackage(PreviewJob job, Path projectDir, Path outputBase, private String buildReadme(String projectName) { return "# " + projectName + " — Interactive Video\n\n" + + "## Runtime Download\n\n" + + "The runtime executable is no longer bundled with this package.\n\n" + + "Download a runtime artifact from **" + RUNTIME_RELEASES_URL + "** and place it in this folder before starting the game.\n\n" + + "Supported runtime artifact names:\n\n" + + "- `arvexis-runtime.jar` — cross-platform, requires Java 21 or newer\n" + + "- `arvexis-runtime-linux-x64` — Linux x64 native binary\n" + + "- `arvexis-runtime-windows-x64.exe` — Windows x64 native binary\n\n" + + "You can also rename the native binary to `arvexis-runtime` or `arvexis-runtime.exe` to match the launcher defaults.\n\n" + "## Running Locally (Offline)\n\n" + - "**Requirements**: Java 17 or newer\n\n" + + "**Requirements**: a runtime binary for your platform, or Java 21 or newer if you use `arvexis-runtime.jar`\n\n" + "1. Unzip this archive\n" + - "2. Open a terminal in the unzipped folder\n" + - "3. Run `./start.sh` (Linux/Mac) or `start.bat` (Windows)\n" + - "4. Open your browser at **http://localhost:8090/**\n\n" + + "2. Download a runtime artifact from the releases page and place it in the unzipped folder\n" + + "3. Open a terminal in the unzipped folder\n" + + "4. Run `./start.sh` (Linux/Mac) or `start.bat` (Windows)\n" + + "5. Open your browser at **http://localhost:8090/**\n\n" + "To use a different port: `./start.sh --port 9000`\n\n" + "## Running Online (Self-Hosted)\n\n" + "1. Copy the unzipped folder to your server\n" + - "2. Run `java -jar runtime.jar --port 80` (or behind a reverse proxy on port 80/443)\n" + - "3. Point your domain or IP at the server\n\n" + + "2. Download a runtime artifact from the releases page into that folder\n" + + "3. Run the runtime binary for your platform, or `java -jar arvexis-runtime.jar --port 80`\n" + + "4. Point your domain or IP at the server\n\n" + "For HTTPS, use a reverse proxy such as nginx or Caddy in front of the runtime.\n\n" + "### nginx example (reverse proxy to port 8090)\n\n" + "```nginx\n" + @@ -340,6 +366,19 @@ private String buildReadme(String projectName) { "Delete it to reset to the beginning.\n"; } + private void recreateDirectory(Path dir) throws IOException { + if (Files.exists(dir)) { + try (var walk = Files.walk(dir)) { + for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) { + if (!path.equals(dir)) { + Files.deleteIfExists(path); + } + } + } + } + Files.createDirectories(dir); + } + private void copyDirectoryContents(Path sourceDir, Path targetDir) throws IOException { if (!Files.isDirectory(sourceDir)) { return; @@ -358,17 +397,15 @@ private void copyDirectoryContents(Path sourceDir, Path targetDir) throws IOExce } } - private void createZip(Path distDir, Path outputBase, Path assetsDir, - Set referencedMusicAssetPaths, - Path zipFile) throws IOException { + private void createZip(Path distDir, Path zipFile) throws IOException { try (ZipOutputStream zos = new ZipOutputStream( Files.newOutputStream(zipFile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING))) { - // Add dist/ contents (manifest, runtime.jar, scripts, README, CSS files) + // Add dist/ contents (manifest, scripts, README, CSS files, output, assets) try (var walk = Files.walk(distDir)) { walk.filter(Files::isRegularFile).forEach(p -> { - String name = "game/" + distDir.relativize(p).toString(); + String name = "game/" + distDir.relativize(p).toString().replace('\\', '/'); try { zos.putNextEntry(new ZipEntry(name)); Files.copy(p, zos); @@ -376,36 +413,6 @@ private void createZip(Path distDir, Path outputBase, Path assetsDir, } catch (IOException e) { throw new RuntimeException(e); } }); } - - // Add output/ (compiled HLS) under game/output/ - if (Files.isDirectory(outputBase)) { - try (var walk = Files.walk(outputBase)) { - walk.filter(Files::isRegularFile).forEach(p -> { - String name = "game/output/" + outputBase.relativize(p).toString(); - try { - zos.putNextEntry(new ZipEntry(name)); - Files.copy(p, zos); - zos.closeEntry(); - } catch (IOException e) { throw new RuntimeException(e); } - }); - } - } - - // Add only referenced background-music assets under game/assets/ - if (Files.isDirectory(assetsDir) && referencedMusicAssetPaths != null && !referencedMusicAssetPaths.isEmpty()) { - for (String relPath : referencedMusicAssetPaths) { - Path assetPath = assetsDir.resolve(relPath).normalize(); - if (!assetPath.startsWith(assetsDir) || !Files.isRegularFile(assetPath)) { - continue; - } - String name = "game/assets/" + relPath.replace('\\', '/'); - try { - zos.putNextEntry(new ZipEntry(name)); - Files.copy(assetPath, zos); - zos.closeEntry(); - } catch (IOException e) { throw new RuntimeException(e); } - } - } } } diff --git a/editor/backend/src/main/java/com/engine/editor/service/GraphService.java b/editor/backend/src/main/java/com/engine/editor/service/GraphService.java index 7c9d68f..34a7756 100644 --- a/editor/backend/src/main/java/com/engine/editor/service/GraphService.java +++ b/editor/backend/src/main/java/com/engine/editor/service/GraphService.java @@ -91,6 +91,15 @@ public GraphNode updateNode(String id, UpdateNodeRequest req) { : existing.getDecisionAppearanceConfig(); String musicAsset = Boolean.TRUE.equals(req.clearMusicAsset()) ? null : (req.musicAssetId() != null ? req.musicAssetId() : existing.getMusicAssetId()); + Boolean hideDecisionButtons = Boolean.TRUE.equals(req.clearDecisionInputModeOverride()) + ? null + : (req.hideDecisionButtons() != null ? req.hideDecisionButtons() : existing.getHideDecisionButtons()); + Boolean showDecisionInputIndicator = Boolean.TRUE.equals(req.clearDecisionInputModeOverride()) + ? null + : (req.showDecisionInputIndicator() != null ? req.showDecisionInputIndicator() : existing.getShowDecisionInputIndicator()); + if (hideDecisionButtons != null && !hideDecisionButtons) { + showDecisionInputIndicator = false; + } double posX = req.posX() != null ? req.posX() : existing.getPosX(); double posY = req.posY() != null ? req.posY() : existing.getPosY(); @@ -98,9 +107,11 @@ public GraphNode updateNode(String id, UpdateNodeRequest req) { jdbc.update(""" UPDATE nodes SET name=?, is_end=?, auto_continue=?, loop_video=?, background_color=?, - decision_appearance_config=?, music_asset_id=?, pos_x=?, pos_y=? + decision_appearance_config=?, music_asset_id=?, hide_decision_buttons=?, + show_decision_input_indicator=?, pos_x=?, pos_y=? WHERE id=? - """, name, isEnd ? 1 : 0, autoCont ? 1 : 0, loopVid ? 1 : 0, bgColor, dac, musicAsset, posX, posY, id); + """, name, isEnd ? 1 : 0, autoCont ? 1 : 0, loopVid ? 1 : 0, bgColor, dac, musicAsset, + toDbFlag(hideDecisionButtons), toDbFlag(showDecisionInputIndicator), posX, posY, id); return getNode(id); } @@ -233,6 +244,8 @@ private GraphNode mapNode(ResultSet rs, int row) throws SQLException { n.setBackgroundColor(rs.getString("background_color")); n.setDecisionAppearanceConfig(rs.getString("decision_appearance_config")); n.setMusicAssetId(rs.getString("music_asset_id")); + n.setHideDecisionButtons(nullableFlag(rs, "hide_decision_buttons")); + n.setShowDecisionInputIndicator(nullableFlag(rs, "show_decision_input_indicator")); n.setPosX(rs.getDouble("pos_x")); n.setPosY(rs.getDouble("pos_y")); return n; @@ -271,6 +284,16 @@ private void requireNodeExists(JdbcTemplate jdbc, String nodeId) { throw new ProjectException("Node not found: " + nodeId); } + private Integer toDbFlag(Boolean value) { + if (value == null) return null; + return value ? 1 : 0; + } + + private Boolean nullableFlag(ResultSet rs, String columnName) throws SQLException { + int value = rs.getInt(columnName); + return rs.wasNull() ? null : value == 1; + } + private List loadExits(JdbcTemplate jdbc, GraphNode node) { List exits = new ArrayList<>(); if ("scene".equals(node.getType())) { diff --git a/editor/backend/src/main/java/com/engine/editor/service/ManifestService.java b/editor/backend/src/main/java/com/engine/editor/service/ManifestService.java index cfd2308..801cea5 100644 --- a/editor/backend/src/main/java/com/engine/editor/service/ManifestService.java +++ b/editor/backend/src/main/java/com/engine/editor/service/ManifestService.java @@ -149,6 +149,8 @@ private void fillSceneNode(Map n, String nodeId, String cfg = (String) nodeFullRow.get("decision_appearance_config"); n.put("decisionAppearanceConfig", cfg); n.put("loopVideo", intFlag(nodeFullRow.get("loop_video"))); + n.put("hideDecisionButtons", nullableIntFlag(nodeFullRow.get("hide_decision_buttons"))); + n.put("showDecisionInputIndicator", nullableIntFlag(nodeFullRow.get("show_decision_input_indicator"))); // Background music asset String musicAssetId = (String) nodeFullRow.get("music_asset_id"); @@ -434,6 +436,13 @@ private boolean intFlag(Object val) { return "1".equals(val.toString()) || "true".equalsIgnoreCase(val.toString()); } + private Boolean nullableIntFlag(Object val) { + if (val == null) return null; + if (val instanceof Boolean b) return b; + if (val instanceof Number n) return n.intValue() == 1; + return "1".equals(val.toString()) || "true".equalsIgnoreCase(val.toString()); + } + private double computeSceneDuration(List> layers, List> audios) { double max = -1; diff --git a/editor/backend/src/main/resources/bundled/runtime.jar b/editor/backend/src/main/resources/bundled/runtime.jar deleted file mode 100644 index d2d66b0..0000000 Binary files a/editor/backend/src/main/resources/bundled/runtime.jar and /dev/null differ diff --git a/editor/backend/src/main/resources/db/migration/V14__add_scene_decision_input_mode_overrides.sql b/editor/backend/src/main/resources/db/migration/V14__add_scene_decision_input_mode_overrides.sql new file mode 100644 index 0000000..121f5c6 --- /dev/null +++ b/editor/backend/src/main/resources/db/migration/V14__add_scene_decision_input_mode_overrides.sql @@ -0,0 +1,2 @@ +ALTER TABLE nodes ADD COLUMN hide_decision_buttons INTEGER; +ALTER TABLE nodes ADD COLUMN show_decision_input_indicator INTEGER; diff --git a/editor/frontend/src/api/graph.ts b/editor/frontend/src/api/graph.ts index 2a01971..860b4e7 100644 --- a/editor/frontend/src/api/graph.ts +++ b/editor/frontend/src/api/graph.ts @@ -17,6 +17,9 @@ export interface UpdateNodePayload { decisionAppearanceConfig?: string musicAssetId?: string | null clearMusicAsset?: boolean + hideDecisionButtons?: boolean | null + showDecisionInputIndicator?: boolean | null + clearDecisionInputModeOverride?: boolean posX?: number posY?: number } diff --git a/editor/frontend/src/components/editor/NodeEditorPanel.tsx b/editor/frontend/src/components/editor/NodeEditorPanel.tsx index dd9252b..8bc9bf2 100644 --- a/editor/frontend/src/components/editor/NodeEditorPanel.tsx +++ b/editor/frontend/src/components/editor/NodeEditorPanel.tsx @@ -62,6 +62,8 @@ export default function NodeEditorPanel({ nodeData, onConditionsChanged, onNodeU loopVideo={nodeData.loopVideo} backgroundColor={nodeData.backgroundColor ?? null} musicAssetId={nodeData.musicAssetId ?? null} + hideDecisionButtons={nodeData.hideDecisionButtons ?? null} + showDecisionInputIndicator={nodeData.showDecisionInputIndicator ?? null} onNodeUpdated={onNodeUpdated} /> )} diff --git a/editor/frontend/src/components/editor/SceneEditor.tsx b/editor/frontend/src/components/editor/SceneEditor.tsx index 523554a..336f12c 100644 --- a/editor/frontend/src/components/editor/SceneEditor.tsx +++ b/editor/frontend/src/components/editor/SceneEditor.tsx @@ -18,6 +18,8 @@ interface SceneEditorProps { loopVideo: boolean backgroundColor: string | null musicAssetId: string | null + hideDecisionButtons: boolean | null + showDecisionInputIndicator: boolean | null onNodeUpdated?: () => void } @@ -40,10 +42,21 @@ function normalizeCapturedKeyboardKey(key: string): string | null { return key } -export default function SceneEditor({ nodeId, isEnd: initialIsEnd, autoContinue: initialAutoContinue, loopVideo: initialLoopVideo, backgroundColor: initialBg, musicAssetId: initialMusicAssetId, onNodeUpdated }: SceneEditorProps) { - const projectDefaultBackgroundColor = useEditorStore( - (s) => s.projectConfig?.defaultBackgroundColor ?? '#000000' - ) +function describeDecisionInputMode(hideDecisionButtons: boolean, showDecisionInputIndicator: boolean): string { + if (!hideDecisionButtons) { + return 'Decision buttons stay visible in runtime.' + } + if (showDecisionInputIndicator) { + return 'Decision buttons are hidden and the bottom-screen input indicator is shown when hotkeys are available.' + } + return 'Decision buttons are hidden and players choose using assigned keyboard keys.' +} + +export default function SceneEditor({ nodeId, isEnd: initialIsEnd, autoContinue: initialAutoContinue, loopVideo: initialLoopVideo, backgroundColor: initialBg, musicAssetId: initialMusicAssetId, hideDecisionButtons: initialHideDecisionButtons, showDecisionInputIndicator: initialShowDecisionInputIndicator, onNodeUpdated }: SceneEditorProps) { + const projectConfig = useEditorStore((s) => s.projectConfig) + const projectDefaultBackgroundColor = projectConfig?.defaultBackgroundColor ?? '#000000' + const projectHideDecisionButtons = projectConfig?.hideDecisionButtons ?? false + const projectShowDecisionInputIndicator = projectHideDecisionButtons && (projectConfig?.showDecisionInputIndicator ?? false) const [data, setData] = useState(null) const [assets, setAssets] = useState([]) const [loading, setLoading] = useState(true) @@ -57,6 +70,16 @@ export default function SceneEditor({ nodeId, isEnd: initialIsEnd, autoContinue: const [loopVideo, setLoopVideo] = useState(initialLoopVideo) const [bgColor, setBgColor] = useState(initialBg ?? projectDefaultBackgroundColor) const [musicAssetId, setMusicAssetId] = useState(initialMusicAssetId) + const [useSceneDecisionInputMode, setUseSceneDecisionInputMode] = useState( + initialHideDecisionButtons != null || initialShowDecisionInputIndicator != null + ) + const [hideDecisionButtons, setHideDecisionButtons] = useState( + initialHideDecisionButtons ?? projectHideDecisionButtons + ) + const [showDecisionInputIndicator, setShowDecisionInputIndicator] = useState( + (initialHideDecisionButtons ?? projectHideDecisionButtons) + && (initialShowDecisionInputIndicator ?? projectShowDecisionInputIndicator) + ) const [previewJob, setPreviewJob] = useState(null) const [previewing, setPreviewing] = useState(false) @@ -66,7 +89,13 @@ export default function SceneEditor({ nodeId, isEnd: initialIsEnd, autoContinue: setLoopVideo(initialLoopVideo) setBgColor(initialBg ?? projectDefaultBackgroundColor) setMusicAssetId(initialMusicAssetId) - }, [initialIsEnd, initialAutoContinue, initialLoopVideo, initialBg, projectDefaultBackgroundColor, initialMusicAssetId, nodeId]) + setUseSceneDecisionInputMode(initialHideDecisionButtons != null || initialShowDecisionInputIndicator != null) + setHideDecisionButtons(initialHideDecisionButtons ?? projectHideDecisionButtons) + setShowDecisionInputIndicator( + (initialHideDecisionButtons ?? projectHideDecisionButtons) + && (initialShowDecisionInputIndicator ?? projectShowDecisionInputIndicator) + ) + }, [initialIsEnd, initialAutoContinue, initialLoopVideo, initialBg, projectDefaultBackgroundColor, initialMusicAssetId, initialHideDecisionButtons, initialShowDecisionInputIndicator, projectHideDecisionButtons, projectShowDecisionInputIndicator, nodeId]) async function handlePreview() { setPreviewing(true) @@ -355,6 +384,12 @@ export default function SceneEditor({ nodeId, isEnd: initialIsEnd, autoContinue: } else { payload.clearMusicAsset = true } + if (useSceneDecisionInputMode) { + payload.hideDecisionButtons = hideDecisionButtons + payload.showDecisionInputIndicator = hideDecisionButtons && showDecisionInputIndicator + } else { + payload.clearDecisionInputModeOverride = true + } const result = await withSave(() => updateNode(nodeId, payload)) if (result && onNodeUpdated) onNodeUpdated() } @@ -586,6 +621,65 @@ export default function SceneEditor({ nodeId, isEnd: initialIsEnd, autoContinue:

Video replays continuously while waiting for a decision.

+
+
+ Decision input mode +

Override the project decision input mode for this scene only.

+
+ + {!useSceneDecisionInputMode ? ( +
+ Using project default +

+ {describeDecisionInputMode(projectHideDecisionButtons, projectShowDecisionInputIndicator)} +

+
+ ) : ( +
+ + {hideDecisionButtons && ( + + )} +
+ )} +
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

- +