From 5b86151fa49b6f241d969cf88fdc8cf43129e247 Mon Sep 17 00:00:00 2001 From: String Date: Mon, 6 Apr 2026 23:28:42 -0500 Subject: [PATCH 1/2] Refactor TeaVM backend system and include Gradle plugin --- COMPILING.md | 211 ++++++++++++- build.gradle | 22 +- flixelgdx-android/build.gradle | 4 - .../android/FlixelAndroidLauncher.java | 4 + flixelgdx-core/build.gradle | 3 - .../me/stringdotjar/flixelgdx/Flixel.java | 164 ++++++---- .../me/stringdotjar/flixelgdx/FlixelGame.java | 61 +--- .../stringdotjar/flixelgdx/FlixelObject.java | 7 - .../asset/FlixelDefaultAssetManager.java | 18 +- .../flixelgdx/audio/FlixelAudioManager.java | 187 ++++++----- .../flixelgdx/audio/FlixelSound.java | 293 +++++++++++++----- .../flixelgdx/audio/FlixelSoundBackend.java | 243 +++++++++++++++ .../flixelgdx/audio/FlixelSoundSource.java | 56 +++- .../flixelgdx/audio/package-info.java | 2 +- .../FlixelDefaultReflectionHandler.java | 22 +- .../flixelgdx/box2d/FlixelBox2DObject.java | 242 --------------- .../flixelgdx/box2d/package-info.java | 9 - .../flixelgdx/debug/FlixelDebugDrawable.java | 4 +- .../flixelgdx/debug/FlixelDebugOverlay.java | 13 +- .../logging/FlixelDebugConsoleEntry.java | 2 +- .../logging/FlixelLogFileHandler.java | 101 ++++++ .../flixelgdx/logging/FlixelLogger.java | 268 +++++++--------- .../flixelgdx/text/FlixelFontRegistry.java | 3 +- .../flixelgdx/tween/FlixelTween.java | 29 +- .../flixelgdx/tween/FlixelTweenManager.java | 46 ++- .../tween/type/FlixelPropertyTween.java | 93 ++++-- .../flixelgdx/tween/type/FlixelVarTween.java | 63 +++- .../flixelgdx/util/FlixelConstants.java | 12 +- .../flixelgdx/util/FlixelDebugUtil.java | 39 +-- .../flixelgdx/util/FlixelPathsUtil.java | 17 +- .../flixelgdx/util/FlixelRuntimeUtil.java | 9 +- .../util/timer/FlixelTimerManager.java | 2 +- flixelgdx-core/src/main/java/module-info.java | 4 - flixelgdx-ios/build.gradle | 1 - .../backend/ios/FlixelIOSLauncher.java | 4 + flixelgdx-jvm/build.gradle | 2 + .../jvm/audio/FlixelMiniAudioSound.java | 109 +++++++ .../audio/FlixelMiniAudioSoundHandler.java | 144 +++++++++ .../jvm/logging/FlixelJvmLogFileHandler.java | 163 ++++++++++ flixelgdx-lwjgl3/build.gradle | 2 +- .../backend/lwjgl3/FlixelLwjgl3Launcher.java | 26 +- flixelgdx-teavm-plugin/build.gradle | 22 ++ .../gradle/teavm/FlixelTeaVMExtension.java | 98 ++++++ .../gradle/teavm/FlixelTeaVMPlugin.java | 185 +++++++++++ .../flixelgdx/gradle/teavm/default-index.html | 26 ++ flixelgdx-teavm/build.gradle | 6 +- .../backend/teavm/FlixelTeaVMLauncher.java | 92 +++++- .../audio/FlixelDefaultSoundHandler.java | 107 +++++++ .../backend/teavm/audio/FlixelGdxSound.java | 113 +++++++ .../tween/FlixelNumTweenManagerTest.java | 6 +- settings.gradle | 2 +- 51 files changed, 2437 insertions(+), 924 deletions(-) create mode 100644 flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/FlixelSoundBackend.java delete mode 100644 flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/box2d/FlixelBox2DObject.java delete mode 100644 flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/box2d/package-info.java create mode 100644 flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/logging/FlixelLogFileHandler.java create mode 100644 flixelgdx-jvm/src/main/java/me/stringdotjar/flixelgdx/backend/jvm/audio/FlixelMiniAudioSound.java create mode 100644 flixelgdx-jvm/src/main/java/me/stringdotjar/flixelgdx/backend/jvm/audio/FlixelMiniAudioSoundHandler.java create mode 100644 flixelgdx-jvm/src/main/java/me/stringdotjar/flixelgdx/backend/jvm/logging/FlixelJvmLogFileHandler.java create mode 100644 flixelgdx-teavm-plugin/build.gradle create mode 100644 flixelgdx-teavm-plugin/src/main/java/me/stringdotjar/flixelgdx/gradle/teavm/FlixelTeaVMExtension.java create mode 100644 flixelgdx-teavm-plugin/src/main/java/me/stringdotjar/flixelgdx/gradle/teavm/FlixelTeaVMPlugin.java create mode 100644 flixelgdx-teavm-plugin/src/main/resources/me/stringdotjar/flixelgdx/gradle/teavm/default-index.html create mode 100644 flixelgdx-teavm/src/main/java/me/stringdotjar/flixelgdx/backend/teavm/audio/FlixelDefaultSoundHandler.java create mode 100644 flixelgdx-teavm/src/main/java/me/stringdotjar/flixelgdx/backend/teavm/audio/FlixelGdxSound.java diff --git a/COMPILING.md b/COMPILING.md index e648d67..2c98eaa 100644 --- a/COMPILING.md +++ b/COMPILING.md @@ -12,9 +12,10 @@ FlixelGDX is a framework, not a standalone game, so it cannot be run by itself. 4. [Compiling FlixelGDX](#compiling-flixelgdx) 5. [Testing with a test project](#testing-with-a-test-project) 6. [How to test the framework properly](#how-to-test-the-framework-properly) -7. [Setting up the Android SDK (for contributing to the Android platform)](#setting-up-the-android-sdk-for-contributing-to-the-android-platform) -8. [Setting up an iOS development environment (for contributing to the iOS platform)](#setting-up-an-ios-development-environment-for-contributing-to-the-ios-platform) -9. [Troubleshooting](#troubleshooting) +7. [Web (TeaVM) setup and configuration](#web-teavm-setup-and-configuration) +8. [Setting up the Android SDK (for contributing to the Android platform)](#setting-up-the-android-sdk-for-contributing-to-the-android-platform) +9. [Setting up an iOS development environment (for contributing to the iOS platform)](#setting-up-an-ios-development-environment-for-contributing-to-the-ios-platform) +10. [Troubleshooting](#troubleshooting) --- @@ -234,7 +235,7 @@ Work through the gdx-liftoff screens in order. For a **minimal** test project us 3. **Languages** - Leave **Java** selected (FlixelGDX uses Java). 4. **Extensions** - - For a minimal project, leave all extensions **unchecked** (no Box2D, Ashley, FreeType, etc.). + - For a minimal project, leave all extensions **unchecked** (no Ashley, FreeType, etc.). 5. **Template** - For quick testing, choose **Classic** or **ApplicationAdapter** — this generates a single main class that implements `ApplicationListener`. You will replace its logic with `FlixelGame` and states. - Alternatively, **Game** gives you a `Game` plus `Screen` structure; you can still make the main class extend `FlixelGame` and use `FlixelState` instead of `Screen`. @@ -435,6 +436,208 @@ Composite build lets the test project use your local FlixelGDX source so changes --- +## Web (TeaVM) setup and configuration + +FlixelGDX supports web builds through the **TeaVM** backend (`flixelgdx-teavm`). TeaVM transpiles +Java bytecode into JavaScript so your game can run in a browser without plugins. + +> [!IMPORTANT] +> **Do not use the TeaVM module generated by gdx-liftoff.** Liftoff generates a module using the +> old `backend-teavm` + `TeaVMBuilder.java` approach, which is incompatible with FlixelGDX's +> `backend-web` + `org.teavm` Gradle plugin approach. Delete `TeaVMBuilder.java` and replace the +> entire `build.gradle` with the template below. + +### Recommended: use the FlixelGDX TeaVM Gradle plugin + +`flixelgdx-teavm-plugin` is a companion Gradle plugin that automates the three steps that +otherwise require manual setup: + +| Without plugin | With plugin | +|---|---| +| Write `src/main/webapp/index.html` by hand | Auto-generated with the correct canvas ID | +| Add a `copyAssets` Gradle task | Handled by `flixelCopyAssets` | +| Wire copy tasks to `generateJavaScript` | Handled automatically | + +#### 1. Add plugin resolution to `settings.gradle` + +The plugin is published to `mavenLocal` (dev) and JitPack (distribution), so add those +repositories to the `pluginManagement` block at the top of your root `settings.gradle`: + +```gradle +pluginManagement { + repositories { + mavenLocal() + maven { url 'https://jitpack.io' } + gradlePluginPortal() + } +} +``` + +#### 2. Create the web module + +Add a `teavm/` directory (or any name you prefer) to your project. A minimal +`teavm/build.gradle`: + +```gradle +plugins { + id 'org.teavm' version '0.13.0' + id 'me.stringdotjar.flixelgdx.teavm' version '0.1.0-beta' +} + +teavm { + all { mainClass = 'com.mygame.teavm.MyTeaVMLauncher' } + js { + addedToWebApp = true + targetFileName = 'teavm.js' + outputDir = file("$buildDir/dist/webapp") + } +} + +dependencies { + implementation 'me.stringdotjar.flixelgdx:flixelgdx-teavm:0.1.0-beta' + implementation project(':core') +} +``` + +Include the module in your root `settings.gradle`: + +```gradle +include 'core', 'lwjgl3', 'teavm' +``` + +#### 3. Create the launcher class + +```java +public class MyTeaVMLauncher { + public static void main(String[] args) { + FlixelTeaVMLauncher.launch(new MyGame("My Game", 800, 600, new PlayState())); + } +} +``` + +That is the entire setup. The plugin automatically: + +- Copies `/assets/` to `/assets/` before each build. +- Generates a default `index.html` (with the correct canvas ID and script path) if you do not + provide one in `src/main/webapp/`. +- Wires everything to `generateJavaScript` and `javaScriptDevServer`. + +#### 4. Run the game in the browser + +```bash +./gradlew :teavm:javaScriptDevServer +``` + +Open `http://localhost:8080` in a browser. Stop the server with +`./gradlew :teavm:stopJavaScriptDevServer`. + +For a static build (e.g. to deploy): + +```bash +./gradlew :teavm:generateJavaScript +``` + +The output in `teavm/build/dist/webapp/` is a self-contained folder you can serve with any +HTTP server. + +### Optional plugin customization + +Use the `flixelgdx {}` block to override defaults: + +```gradle +flixelgdx { + // Canvas element ID (default: "flixelgdx-canvas"). + // Must match WebApplicationConfiguration.canvasID in your launcher. + canvasId = 'my-canvas' + + // Where the assembled web app goes (default: "$buildDir/dist/webapp"). + // Must match teavm.js.outputDir. + outputDir = file("$buildDir/dist/webapp") + + // Game assets to copy (default: rootProject/assets/). + assetsDir = file('../assets') + + // User-provided web resources directory (default: src/main/webapp/). + // If this directory contains an index.html the auto-generation is skipped. + webappDir = file('src/main/webapp') + + // Set to false to disable index.html auto-generation entirely (default: true). + generateDefaultIndexHtml = true +} +``` + +To provide a completely custom `index.html`, place it in `src/main/webapp/index.html`. The +plugin detects it and skips generation automatically. The canvas ID in your custom HTML must +match `FlixelTeaVMLauncher`'s default (`flixelgdx-canvas`) or the value you pass to +`WebApplicationConfiguration.canvasID`. + +### Web configuration customization + +The launcher accepts an optional `Consumer` to override canvas ID, +dimensions, or other web-specific settings: + +```java +FlixelTeaVMLauncher.launch( + new MyGame("My Game", 800, 600, new InitialState()), + FlixelRuntimeMode.RELEASE, + config -> { + config.canvasID = "my-canvas"; + config.antialiasing = true; + } +); +``` + +### Reflection metadata + +FlixelGDX's TeaVM build auto-generates a `teavm.json` reflection metadata file during the +`processResources` phase. This file preserves class/field/method information that TeaVM's +ahead-of-time compiler would otherwise strip. + +The reflection profile is controlled by `flixelReflectionProfile` in `gradle.properties`: + +| Profile | What is preserved | +|------------|-------------------| +| `SIMPLE` | FlixelGDX classes only. | +| `STANDARD` | FlixelGDX + libGDX classes (recommended). | +| `ALL` | FlixelGDX + libGDX + visible dependencies (anim8, miniaudio). | + +To include your own game packages in the metadata, set `flixelReflectionExtraPackages` in +`gradle.properties`: + +```properties +flixelReflectionExtraPackages=com.mygame,org.example.tools +``` + +### Platform limitations on web + +The web backend intentionally omits several features that are unavailable in a browser +environment: + +- **File logging** is disabled. There is no host filesystem, so `Flixel.startFileLogging()` is + a safe no-op. Console output (`System.out.println`) maps to `console.log` in the browser. +- **Jansi / ANSI colors** are not installed. Terminal color codes are irrelevant in a browser + console. +- **`FlixelVarTween`** relies on runtime reflection and may exhibit slower performance on + TeaVM. Prefer `FlixelPropertyTween` (getter/setter lambdas) for web-targeted games. +- **`FlixelGitUtil`** and other `ProcessBuilder`-based utilities are unavailable (no subprocess + support in browsers). +- **`FlixelDefaultAssetManager.extractAssetPath()`** uses `java.io.File` for temp file + extraction. On web, audio assets load through the browser's network stack and do not need + filesystem materialization. + +### Validation checklist for web readiness + +Before shipping a web build, verify the following: + +1. The game boots and the initial state renders in the browser. +2. No `ClassNotFoundException` or `NoSuchMethodException` in the browser console (indicates + missing reflection metadata; widen the profile or add extra packages). +3. Tweens animate correctly (prefer `FlixelPropertyTween` over `FlixelVarTween`). +4. Audio plays in the browser (uses the Web Audio API via libGDX's backend). +5. Input (keyboard, mouse/touch) responds as expected. + +--- + ## Setting up the Android SDK (for contributing to the Android platform) If you want to contribute to the **flixelgdx-android** module or run and test FlixelGDX on Android (in this repo or in a test project), you need the Android SDK and a way to run an Android app (emulator or physical device). This section covers installation, configuration, and the limitations and workarounds you may hit depending on your OS. diff --git a/build.gradle b/build.gradle index 724976b..8695281 100644 --- a/build.gradle +++ b/build.gradle @@ -49,7 +49,7 @@ configure(subprojects.findAll { p -> p.name == 'flixelgdx-core' || p.name == 'fl // These modules are currently NOT configured in the root build.gradle for java-library // but they apply it themselves. -configure(subprojects.findAll { p -> p.name != 'flixelgdx-core' && p.name != 'flixelgdx-test' }) { +configure(subprojects.findAll { p -> p.name != 'flixelgdx-core' && p.name != 'flixelgdx-test' && p.name != 'flixelgdx-teavm-plugin' }) { afterEvaluate { if (it.plugins.hasPlugin('java-library') || it.plugins.hasPlugin('com.android.library')) { dependencies { @@ -63,7 +63,7 @@ configure(subprojects.findAll { p -> p.name != 'flixelgdx-core' && p.name != 'fl } } -configure(subprojects.findAll { !it.path.contains('flixelgdx-android') && it.name != 'flixelgdx-test' }) { +configure(subprojects.findAll { !it.path.contains('flixelgdx-android') && it.name != 'flixelgdx-test' && it.name != 'flixelgdx-teavm-plugin' }) { // From https://lyze.dev/2021/04/29/libGDX-Internal-Assets-List/ // The article can be helpful when using assets.txt in your project. tasks.register('generateAssetList') { @@ -93,7 +93,7 @@ configure(subprojects.findAll { !it.path.contains('flixelgdx-android') && it.nam } } -configure(subprojects.findAll { p -> p.name != 'flixelgdx-core' && p.name != 'flixelgdx-jvm' && p.name != 'flixelgdx-teavm' && p.name != 'flixelgdx-test' }) { +configure(subprojects.findAll { p -> p.name != 'flixelgdx-core' && p.name != 'flixelgdx-jvm' && p.name != 'flixelgdx-teavm' && p.name != 'flixelgdx-test' && p.name != 'flixelgdx-teavm-plugin' }) { afterEvaluate { if (it.plugins.hasPlugin('java-library') || it.plugins.hasPlugin('com.android.library')) { dependencies { @@ -170,7 +170,7 @@ subprojects { Project p -> } } -configure(subprojects.findAll { it.name != 'flixelgdx-test' }) { +configure(subprojects.findAll { it.name != 'flixelgdx-test' && it.name != 'flixelgdx-teavm-plugin' }) { apply plugin: 'maven-publish' afterEvaluate { publishing { @@ -189,6 +189,20 @@ configure(subprojects.findAll { it.name != 'flixelgdx-test' }) { } } +// The plugin module uses java-gradle-plugin which auto-creates the pluginMaven publication +// (including the plugin marker artifact). We apply maven-publish and configure the POM there +// separately to avoid a duplicate 'maven' publication. +configure(subprojects.findAll { it.name == 'flixelgdx-teavm-plugin' }) { + apply plugin: 'maven-publish' + afterEvaluate { + publishing { + publications.withType(MavenPublication).configureEach { + rootProject.configureFlixelMavenPom(delegate) + } + } + } +} + subprojects { afterEvaluate { Project p -> if (!p.plugins.hasPlugin('maven-publish')) { diff --git a/flixelgdx-android/build.gradle b/flixelgdx-android/build.gradle index 68db53f..80b7d06 100644 --- a/flixelgdx-android/build.gradle +++ b/flixelgdx-android/build.gradle @@ -87,10 +87,6 @@ dependencies { api "games.rednblack.miniaudio:gdx-miniaudio-platform:$miniaudioVersion:natives-arm64-v8a" api "games.rednblack.miniaudio:gdx-miniaudio-platform:$miniaudioVersion:natives-x86" api "games.rednblack.miniaudio:gdx-miniaudio-platform:$miniaudioVersion:natives-x86_64" - api "com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-arm64-v8a" - api "com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-armeabi-v7a" - api "com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-x86" - api "com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-x86_64" api "com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-arm64-v8a" api "com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-armeabi-v7a" api "com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-x86" diff --git a/flixelgdx-android/src/main/java/me/stringdotjar/flixelgdx/backend/android/FlixelAndroidLauncher.java b/flixelgdx-android/src/main/java/me/stringdotjar/flixelgdx/backend/android/FlixelAndroidLauncher.java index d21c601..6390286 100644 --- a/flixelgdx-android/src/main/java/me/stringdotjar/flixelgdx/backend/android/FlixelAndroidLauncher.java +++ b/flixelgdx-android/src/main/java/me/stringdotjar/flixelgdx/backend/android/FlixelAndroidLauncher.java @@ -12,7 +12,9 @@ import me.stringdotjar.flixelgdx.Flixel; import me.stringdotjar.flixelgdx.FlixelGame; import me.stringdotjar.flixelgdx.backend.android.alert.FlixelAndroidAlerter; +import me.stringdotjar.flixelgdx.backend.jvm.audio.FlixelMiniAudioSoundHandler; import me.stringdotjar.flixelgdx.backend.jvm.logging.FlixelDefaultStackTraceProvider; +import me.stringdotjar.flixelgdx.backend.jvm.logging.FlixelJvmLogFileHandler; import me.stringdotjar.flixelgdx.backend.reflect.FlixelDefaultReflectionHandler; import me.stringdotjar.flixelgdx.backend.runtime.FlixelRuntimeMode; @@ -50,6 +52,8 @@ public static void launch(FlixelGame game, AndroidApplication activity, FlixelRu Flixel.setAlerter(new FlixelAndroidAlerter(activity)); Flixel.setStackTraceProvider(new FlixelDefaultStackTraceProvider()); Flixel.setReflection(new FlixelDefaultReflectionHandler()); + Flixel.setLogFileHandler(new FlixelJvmLogFileHandler()); + Flixel.setSoundBackendFactory(new FlixelMiniAudioSoundHandler()); Flixel.setRuntimeMode(runtimeMode); Flixel.setDebugMode(runtimeMode == FlixelRuntimeMode.DEBUG); Flixel.initialize(game); diff --git a/flixelgdx-core/build.gradle b/flixelgdx-core/build.gradle index d14e307..ddb891c 100644 --- a/flixelgdx-core/build.gradle +++ b/flixelgdx-core/build.gradle @@ -47,13 +47,10 @@ configurations.configureEach { dependencies { api "com.badlogicgames.gdx:gdx:$gdxVersion" - api "com.badlogicgames.gdx:gdx-box2d:$gdxVersion" api "com.badlogicgames.gdx:gdx-freetype:$gdxVersion" api "com.github.tommyettinger:anim8-gdx:$anim8Version" api "com.github.tommyettinger:libgdx-utils:$utilsVersion" - api "games.rednblack.miniaudio:miniaudio:$miniaudioVersion" - implementation "org.fusesource.jansi:jansi:$jansiVersion" implementation "org.jetbrains:annotations:26.1.0" if (enableGraalNative == 'true') { diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/Flixel.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/Flixel.java index d4d4a45..b3b2971 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/Flixel.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/Flixel.java @@ -15,17 +15,15 @@ import com.badlogic.gdx.graphics.Texture; import com.badlogic.gdx.graphics.g2d.TextureAtlas; import com.badlogic.gdx.math.Vector2; -import com.badlogic.gdx.physics.box2d.World; import com.badlogic.gdx.scenes.scene2d.Stage; import com.badlogic.gdx.utils.Array; import com.badlogic.gdx.utils.ObjectSet; -import games.rednblack.miniaudio.MiniAudio; - import me.stringdotjar.flixelgdx.asset.FlixelAssetManager; import me.stringdotjar.flixelgdx.asset.FlixelDefaultAssetManager; import me.stringdotjar.flixelgdx.audio.FlixelAudioManager; import me.stringdotjar.flixelgdx.audio.FlixelSound; +import me.stringdotjar.flixelgdx.audio.FlixelSoundBackend; import me.stringdotjar.flixelgdx.backend.alert.FlixelAlerter; import me.stringdotjar.flixelgdx.backend.reflect.FlixelReflection; import me.stringdotjar.flixelgdx.backend.reflect.FlixelUnsupportedReflectionHandler; @@ -33,6 +31,7 @@ import me.stringdotjar.flixelgdx.debug.FlixelDebugOverlay; import me.stringdotjar.flixelgdx.debug.FlixelDebugWatchManager; import me.stringdotjar.flixelgdx.group.FlixelGroupable; +import me.stringdotjar.flixelgdx.logging.FlixelLogFileHandler; import me.stringdotjar.flixelgdx.logging.FlixelStackTraceProvider; import me.stringdotjar.flixelgdx.text.FlixelFontRegistry; import me.stringdotjar.flixelgdx.util.FlixelConstants; @@ -271,6 +270,20 @@ public final class Flixel { @NotNull private static FlixelStackTraceProvider stackTraceProvider; + /** + * Platform-specific handler for writing log output to a file. May be + * {@code null} on platforms that do not support file logging (e.g. web/TeaVM). + */ + @Nullable + private static FlixelLogFileHandler logFileHandler; + + /** + * Platform-specific factory for creating sounds, groups and effect nodes. + * Set by the launcher before {@link #initialize(FlixelGame)}. + */ + @Nullable + private static FlixelSoundBackend.Factory soundFactory; + /** Whether the game is running in debug mode. Can only be set once from the launcher. */ private static boolean debugMode = false; @@ -353,11 +366,14 @@ public static void initialize(@NotNull FlixelGame gameInstance) { if (stackTraceProvider == null) { throw new IllegalStateException("Flixel stack trace provider not set. Call Flixel.setStackTraceProvider(...) before Flixel.initialize(...)."); } + if (soundFactory == null) { + throw new IllegalStateException("Flixel sound backend factory not set. Call Flixel.setSoundBackendFactory(...) before Flixel.initialize(...)."); + } // Initialize the core systems. keys = new FlixelKeyInputManager(); if (sound == null) { - sound = new FlixelAudioManager(); + sound = new FlixelAudioManager(soundFactory); } else { sound.resetSession(); } @@ -368,24 +384,21 @@ public static void initialize(@NotNull FlixelGame gameInstance) { if (assets == null) { assets = new FlixelDefaultAssetManager(); } - if (assets instanceof FlixelDefaultAssetManager dam) { - dam.ensureMiniAudioLoader(); - } - // Register default tween types. - FlixelTween.registerTweenType(FlixelPropertyTween.class, FlixelPropertyTweenBuilder.class, () -> new FlixelPropertyTween(null)) - .registerTweenType(FlixelVarTween.class, FlixelVarTweenBuilder.class, () -> new FlixelVarTween(null, null)) - .registerTweenType(FlixelNumTween.class, FlixelNumTweenBuilder.class, () -> new FlixelNumTween(0, 0, null, null)) - .registerTweenType(FlixelAngleTween.class, FlixelAngleTweenBuilder.class, () -> new FlixelAngleTween(null)) - .registerTweenType(FlixelColorTween.class, FlixelColorTweenBuilder.class, () -> new FlixelColorTween(null)) - .registerTweenType(FlixelShakeTween.class, FlixelShakeTweenBuilder.class, () -> new FlixelShakeTween(null)) - .registerTweenType(FlixelFlickerTween.class, FlixelFlickerTweenBuilder.class, () -> new FlixelFlickerTween(null)) - .registerTweenType(FlixelLinearMotion.class, FlixelLinearMotionBuilder.class, () -> new FlixelLinearMotion(null)) - .registerTweenType(FlixelCircularMotion.class, FlixelCircularMotionBuilder.class, () -> new FlixelCircularMotion(null)) - .registerTweenType(FlixelQuadMotion.class, FlixelQuadMotionBuilder.class, () -> new FlixelQuadMotion(null)) - .registerTweenType(FlixelCubicMotion.class, FlixelCubicMotionBuilder.class, () -> new FlixelCubicMotion(null)) - .registerTweenType(FlixelLinearPath.class, FlixelLinearPathBuilder.class, () -> new FlixelLinearPath(null)) - .registerTweenType(FlixelQuadPath.class, FlixelQuadPathBuilder.class, () -> new FlixelQuadPath(null)); + // Register default tween types with explicit builder factories (no reflection for TeaVM compatibility). + FlixelTween.registerTweenType(FlixelPropertyTween.class, FlixelPropertyTweenBuilder.class, FlixelPropertyTweenBuilder::new, () -> new FlixelPropertyTween(null)) + .registerTweenType(FlixelVarTween.class, FlixelVarTweenBuilder.class, FlixelVarTweenBuilder::new, () -> new FlixelVarTween(null, null)) + .registerTweenType(FlixelNumTween.class, FlixelNumTweenBuilder.class, FlixelNumTweenBuilder::new, () -> new FlixelNumTween(0, 0, null, null)) + .registerTweenType(FlixelAngleTween.class, FlixelAngleTweenBuilder.class, FlixelAngleTweenBuilder::new, () -> new FlixelAngleTween(null)) + .registerTweenType(FlixelColorTween.class, FlixelColorTweenBuilder.class, FlixelColorTweenBuilder::new, () -> new FlixelColorTween(null)) + .registerTweenType(FlixelShakeTween.class, FlixelShakeTweenBuilder.class, FlixelShakeTweenBuilder::new, () -> new FlixelShakeTween(null)) + .registerTweenType(FlixelFlickerTween.class, FlixelFlickerTweenBuilder.class, FlixelFlickerTweenBuilder::new, () -> new FlixelFlickerTween(null)) + .registerTweenType(FlixelLinearMotion.class, FlixelLinearMotionBuilder.class, FlixelLinearMotionBuilder::new, () -> new FlixelLinearMotion(null)) + .registerTweenType(FlixelCircularMotion.class, FlixelCircularMotionBuilder.class, FlixelCircularMotionBuilder::new, () -> new FlixelCircularMotion(null)) + .registerTweenType(FlixelQuadMotion.class, FlixelQuadMotionBuilder.class, FlixelQuadMotionBuilder::new, () -> new FlixelQuadMotion(null)) + .registerTweenType(FlixelCubicMotion.class, FlixelCubicMotionBuilder.class, FlixelCubicMotionBuilder::new, () -> new FlixelCubicMotion(null)) + .registerTweenType(FlixelLinearPath.class, FlixelLinearPathBuilder.class, FlixelLinearPathBuilder::new, () -> new FlixelLinearPath(null)) + .registerTweenType(FlixelQuadPath.class, FlixelQuadPathBuilder.class, FlixelQuadPathBuilder::new, () -> new FlixelQuadPath(null)); initialized = true; } @@ -422,6 +435,72 @@ public static void setStackTraceProvider(@NotNull FlixelStackTraceProvider provi stackTraceProvider = provider; } + /** + * Sets the platform-specific handler responsible for writing log output to a + * persistent file. + * + *

This must be called before {@link #initialize(FlixelGame)}. Calling it after + * initialization throws an exception. On platforms that do not support file + * logging (for example web/TeaVM), this method may be skipped entirely and + * file logging will be silently disabled. + * + * @param handler The log file handler to use, must not be {@code null}. + * @throws IllegalStateException If Flixel has already been initialized. + * @throws IllegalArgumentException If {@code handler} is {@code null}. + */ + public static void setLogFileHandler(@NotNull FlixelLogFileHandler handler) { + if (initialized) { + throw new IllegalStateException("Cannot change the log file handler after Flixel has been initialized."); + } + if (handler == null) { + throw new IllegalArgumentException("Log file handler cannot be null."); + } + logFileHandler = handler; + } + + /** + * Returns the platform-specific log file handler, or {@code null} if none has + * been registered (for example, on web/TeaVM). + * + * @return The current log file handler, or {@code null}. + */ + @Nullable + public static FlixelLogFileHandler getLogFileHandler() { + return logFileHandler; + } + + /** + * Sets the platform-specific sound backend factory used to create sounds, + * groups, and effect nodes. + * + *

This must be called before {@link #initialize(FlixelGame)}. Calling it + * after initialization throws an exception. + * + * @param factory The sound backend factory to use (must not be {@code null}). + * @throws IllegalStateException If FlixelGDX has already been initialized. + * @throws IllegalArgumentException If {@code factory} is {@code null}. + */ + public static void setSoundBackendFactory(@NotNull FlixelSoundBackend.Factory factory) { + if (initialized) { + throw new IllegalStateException("Cannot change sound backend factory after Flixel has been initialized."); + } + if (factory == null) { + throw new IllegalArgumentException("Sound backend factory cannot be null."); + } + soundFactory = factory; + } + + /** + * Returns the platform-specific sound backend factory, or {@code null} if + * none has been registered yet. + * + * @return The current sound backend factory, or {@code null}. + */ + @Nullable + public static FlixelSoundBackend.Factory getSoundFactory() { + return soundFactory; + } + /** * Sets the current state to the provided state, triggers garbage collection and * clears all active tweens by default. @@ -744,10 +823,6 @@ public static int getViewHeight() { return (int) game.viewSize.y; } - public static MiniAudio getAudioEngine() { - return sound.getEngine(); - } - public static float getMasterVolume() { return sound.getMasterVolume(); } @@ -1356,45 +1431,6 @@ public static void setAntialiasing(boolean enabled) { } } - public static World getWorld() { - if (game == null) { - return null; - } - return game.getWorld(); - } - - /** - * Sets the gravity of the global Box2D world. Convenience overload that - * sets horizontal gravity to {@code 0}. - * - * @param gravity Vertical gravity in m/s² (negative = down in most setups). - */ - public static void setGravity(float gravity) { - setGravity(0, gravity); - } - - /** - * Sets the gravity of the global Box2D world. - * - * @param gravityX Horizontal gravity in m/s². - * @param gravityY Vertical gravity in m/s². - */ - public static void setGravity(float gravityX, float gravityY) { - if (game != null) { - game.setGravity(gravityX, gravityY); - } - } - - /** - * Returns the current gravity of the Box2D world, or {@code (0,0)} if no world exists. - */ - public static Vector2 getGravity() { - if (game != null) { - return game.getGravity(); - } - return new Vector2(0, 0); - } - /** * Returns the world bounds used for collision broad-phase culling. * The returned array is {@code [x, y, width, height]}. diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/FlixelGame.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/FlixelGame.java index 2869691..8d2ec8c 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/FlixelGame.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/FlixelGame.java @@ -18,22 +18,18 @@ import com.badlogic.gdx.graphics.g2d.Batch; import com.badlogic.gdx.graphics.g2d.SpriteBatch; import com.badlogic.gdx.math.Vector2; -import com.badlogic.gdx.physics.box2d.World; import com.badlogic.gdx.scenes.scene2d.Stage; import com.badlogic.gdx.utils.Array; import com.badlogic.gdx.utils.ScreenUtils; -import me.stringdotjar.flixelgdx.box2d.FlixelBox2DObject; import me.stringdotjar.flixelgdx.debug.FlixelDebugOverlay; import me.stringdotjar.flixelgdx.text.FlixelFontRegistry; import me.stringdotjar.flixelgdx.tween.FlixelTween; import me.stringdotjar.flixelgdx.util.FlixelConstants; import me.stringdotjar.flixelgdx.util.timer.FlixelTimer; -import me.stringdotjar.flixelgdx.util.FlixelDebugUtil; import me.stringdotjar.flixelgdx.util.FlixelRuntimeUtil; import me.stringdotjar.flixelgdx.util.signal.FlixelSignalData.UpdateSignalData; -import org.fusesource.jansi.AnsiConsole; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -161,9 +157,6 @@ public abstract class FlixelGame implements ApplicationListener, FlixelUpdatable /** Reusable signal data for postUpdate dispatch (avoids per-frame allocation). */ private final UpdateSignalData postUpdateData = new UpdateSignalData(); - /** Global Box2D world owned by {@code this} game instance. */ - private World world; - /** * Creates a new game instance with the details specified. * @@ -260,13 +253,6 @@ public FlixelGame(String title, int width, int height, @NotNull Supplier(FlixelCamera[]::new); cameras.add(new FlixelCamera((int) viewSize.x, (int) viewSize.y)); @@ -300,9 +286,6 @@ public void create() { } } - // Initialize the Box2D world with zero gravity by default. - world = new World(new Vector2(0, 0), true); - // Create the debug overlay when debug mode is enabled. if (Flixel.isDebugMode()) { FlixelDebugOverlay overlay = Flixel.createDebugOverlay(); @@ -371,14 +354,6 @@ public void update(float elapsed) { current = sub; } - // Step the Box2D world and sync body positions back to objects. - if (world != null) { - world.step(elapsed, - FlixelConstants.Physics.VELOCITY_ITERATIONS, - FlixelConstants.Physics.POSITION_ITERATIONS); - FlixelDebugUtil.forEachBox2DObject(FlixelBox2DObject::syncFromBody); - } - // Update all cameras. for (FlixelCamera camera : cameras) { camera.update(elapsed); @@ -651,7 +626,7 @@ public void destroy() { } /** - * Tears down this session's cameras, state, stage, batch, and Box2D world without application-exit semantics. + * Tears down this session's cameras, state, stage, and batch without application-exit semantics. * No {@link Flixel#stopFileLogging()}, ANSI uninstall, or {@code postGameClose} signal, and does not dispose * {@link Flixel#assets} or {@link Flixel#sound} (callers such as {@link Flixel#resetGame()} own those). Leaves * {@link #isClosed()} {@code false} so the process can call {@link Flixel#initialize(FlixelGame)} again. @@ -704,11 +679,6 @@ private void teardownSessionCore(boolean permanentShutdown) { } } - if (world != null) { - world.dispose(); - world = null; - } - cameras = null; debugPauseCameraScroll = null; debugPauseCameraZoom = null; @@ -717,10 +687,6 @@ private void teardownSessionCore(boolean permanentShutdown) { FlixelFontRegistry.dispose(); if (permanentShutdown) { - if (AnsiConsole.isInstalled()) { - AnsiConsole.systemUninstall(); - } - close(); Flixel.Signals.postGameClose.dispatch(); @@ -880,29 +846,4 @@ public void setWindowSize(Vector2 newSize) { Gdx.graphics.setWindowedMode((int) newSize.x, (int) newSize.y); } - public World getWorld() { - return world; - } - - /** - * Sets the gravity of this game's Box2D world. - * - * @param gravityX Horizontal gravity in m/s². - * @param gravityY Vertical gravity in m/s². - */ - public void setGravity(float gravityX, float gravityY) { - if (world != null) { - world.setGravity(new Vector2(gravityX, gravityY)); - } - } - - /** - * Returns this game's Box2D gravity, or {@code (0, 0)} if no world exists. - */ - public Vector2 getGravity() { - if (world != null) { - return world.getGravity(); - } - return new Vector2(0, 0); - } } diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/FlixelObject.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/FlixelObject.java index d67c275..60fc466 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/FlixelObject.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/FlixelObject.java @@ -7,7 +7,6 @@ package me.stringdotjar.flixelgdx; -import me.stringdotjar.flixelgdx.box2d.FlixelBox2DObject; import me.stringdotjar.flixelgdx.debug.FlixelDebugDrawable; import me.stringdotjar.flixelgdx.util.FlixelConstants; @@ -27,12 +26,6 @@ * checks. The static {@link #separate(FlixelObject, FlixelObject)} method resolves overlaps * by adjusting positions and velocities. * - *

Box2D

- * To use Box2D physics instead of the built-in kinematic model, implement - * {@link FlixelBox2DObject} on your subclass. See that interface's documentation for details. - * - * @see FlxObject (HaxeFlixel) - * @see FlixelBox2DObject */ public class FlixelObject extends FlixelBasic implements FlixelDebugDrawable { diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/asset/FlixelDefaultAssetManager.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/asset/FlixelDefaultAssetManager.java index 5da09ba..3a55261 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/asset/FlixelDefaultAssetManager.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/asset/FlixelDefaultAssetManager.java @@ -20,9 +20,6 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; -import games.rednblack.miniaudio.MASound; -import games.rednblack.miniaudio.loader.MASoundLoader; -import me.stringdotjar.flixelgdx.Flixel; import me.stringdotjar.flixelgdx.audio.FlixelSoundSource; import me.stringdotjar.flixelgdx.audio.FlixelSoundSourceLoader; import me.stringdotjar.flixelgdx.graphics.FlixelGraphicSource; @@ -74,10 +71,9 @@ public int hashCode() { } } - /** Constructs a new asset manager with the default loaders for audio, strings, and sound sources. */ + /** Constructs a new asset manager with the default loaders for strings and sound sources. */ public FlixelDefaultAssetManager() { manager = new AssetManager(); - ensureMiniAudioLoader(); manager.setLoader(String.class, new FlixelStringAssetLoader(manager.getFileHandleResolver())); manager.setLoader(FlixelSoundSource.class, new FlixelSoundSourceLoader(manager.getFileHandleResolver())); registerDefaultExtensionMappings(); @@ -174,18 +170,6 @@ public void unregisterExtension(@NotNull String extension) { extensionRegistry.remove(normalizeExtension(extension)); } - /** - * Registers (or re-registers) the MiniAudio {@link MASound} loader, if the global audio system is available. - * - *

This is a no-op until {@link me.stringdotjar.flixelgdx.Flixel#sound} is initialized. - */ - public void ensureMiniAudioLoader() { - if (Flixel.sound == null) { - return; - } - manager.setLoader(MASound.class, new MASoundLoader(Flixel.sound.getEngine(), manager.getFileHandleResolver())); - } - /** Returns the underlying libGDX {@link AssetManager}. */ @NotNull @Override diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/FlixelAudioManager.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/FlixelAudioManager.java index d2b5dc7..383f762 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/FlixelAudioManager.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/FlixelAudioManager.java @@ -7,9 +7,6 @@ package me.stringdotjar.flixelgdx.audio; -import games.rednblack.miniaudio.MAGroup; -import games.rednblack.miniaudio.MASound; -import games.rednblack.miniaudio.MiniAudio; import me.stringdotjar.flixelgdx.FlixelDestroyable; import me.stringdotjar.flixelgdx.util.FlixelPathsUtil; import org.jetbrains.annotations.NotNull; @@ -18,34 +15,39 @@ import com.badlogic.gdx.utils.Disposable; /** - * Central manager for all audio: FlixelSound instances, master volume, sound groups (SFX and Music), - * and focus-based pause/resume. + * Central manager for all audio. {@link FlixelSound} instances, master volume, + * sound groups (SFX and music), and focus-based pause/resume. * - *

Access via {@link me.stringdotjar.flixelgdx.Flixel#sound}. Supports separate groups for - * sound effects and music, global master volume, and automatic pause when the game loses focus - * (and resume when it regains focus). + *

Access via {@link me.stringdotjar.flixelgdx.Flixel#sound}. Supports + * separate groups for sound effects and music, global master volume, and + * automatic pause when the game loses focus (and resume when it regains focus). */ public class FlixelAudioManager implements FlixelDestroyable, Disposable { - private final MiniAudio engine; - private MAGroup sfxGroup; - private MAGroup musicGroup; + private final FlixelSoundBackend.Factory factory; + private Object sfxGroup; + private Object musicGroup; private float masterVolume = 1f; private FlixelSound music; - /** Constructs a new FlixelAudioManager with an internal MiniAudio engine and sound groups. */ - public FlixelAudioManager() { - engine = new MiniAudio(); - sfxGroup = engine.createGroup(); - musicGroup = engine.createGroup(); + /** + * Constructs a new audio manager using the given backend factory. + * + * @param factory The platform-specific sound backend factory. + */ + public FlixelAudioManager(@NotNull FlixelSoundBackend.Factory factory) { + this.factory = factory; + sfxGroup = factory.createGroup(); + musicGroup = factory.createGroup(); } /** - * Stops session audio and rebuilds SFX and music groups on the existing {@link MiniAudio} engine. + * Stops session audio and rebuilds SFX and music groups on the existing engine. * - *

Use this during {@link me.stringdotjar.flixelgdx.Flixel#resetGame()} instead of {@link #destroy()} so the - * native backend is not torn down and re-created in one frame (which can break PulseAudio and similar backends). + *

Use during {@link me.stringdotjar.flixelgdx.Flixel#resetGame()} instead + * of {@link #destroy()} so the native backend is not torn down and re-created + * in one frame (which can break PulseAudio and similar backends). */ public void resetSession() { if (music != null) { @@ -53,49 +55,72 @@ public void resetSession() { music = null; } if (sfxGroup != null) { - sfxGroup.dispose(); + factory.disposeGroup(sfxGroup); } if (musicGroup != null) { - musicGroup.dispose(); + factory.disposeGroup(musicGroup); } - sfxGroup = engine.createGroup(); - musicGroup = engine.createGroup(); - engine.setMasterVolume(masterVolume); + sfxGroup = factory.createGroup(); + musicGroup = factory.createGroup(); + factory.setMasterVolume(masterVolume); } - /** Returns the underlying MiniAudio engine for advanced use and asset loading. */ + /** + * Returns the underlying backend factory for advanced use. + * + * @return The backend factory powering this manager. + */ @NotNull - public MiniAudio getEngine() { - return engine; + public FlixelSoundBackend.Factory getFactory() { + return factory; } - /** Group for sound effects. Use for playing sounds or custom sounds that should be categorized as SFX. */ + /** + * Returns the SFX group handle. Use for playing sounds or custom sounds + * that should be categorised as SFX. + * + * @return The SFX group handle. + */ @NotNull - public MAGroup getSfxGroup() { + public Object getSfxGroup() { return sfxGroup; } - /** Group for music. Used by {@link #playMusic}. */ + /** + * Returns the music group handle. Used by {@link #playMusic}. + * + * @return The music group handle. + */ @NotNull - public MAGroup getMusicGroup() { + public Object getMusicGroup() { return musicGroup; } /** * Returns the default group used when no group is specified (SFX group). - * Provided for backward compatibility with code that expects a single "sounds" group. + * + * @return The SFX group handle. */ @NotNull - public MAGroup getSoundsGroup() { + public Object getSoundsGroup() { return sfxGroup; } + /** + * Returns the currently playing music, or {@code null} if none. + * + * @return The current music sound, or {@code null}. + */ @Nullable public FlixelSound getMusic() { return music; } - /** Master volume in 0-1. Values > 1 are clamped to 1 when applied to the engine. */ + /** + * Returns the current master volume. + * + * @return Master volume in [0, 1]. + */ public float getMasterVolume() { return masterVolume; } @@ -103,21 +128,18 @@ public float getMasterVolume() { /** * Sets the global master volume applied to all sounds. * - * @param volume New master volume (values > 1 are clamped to 1). - * @return The new master volume. + * @param volume New master volume (values outside [0, 1] are clamped). + * @return The clamped master volume. */ public float setMasterVolume(float volume) { - float clamped = volume; - clamped = Math.max(0f, clamped); - clamped = Math.min(1f, clamped); - engine.setMasterVolume(clamped); + float clamped = Math.max(0f, Math.min(1f, volume)); + factory.setMasterVolume(clamped); masterVolume = clamped; return clamped; } /** - * Changes the global master volume applied to all sounds of {@code this} audio manager - * by the given amount. + * Changes the global master volume by the given amount. * * @param amount The amount to change the master volume by. * @return The new master volume. @@ -130,7 +152,7 @@ public float changeMasterVolume(float amount) { * Plays a new sound effect (SFX group). * * @param path Path to the sound (internal or resolved via {@link FlixelPathsUtil}). - * @return The new FlixelSound instance. + * @return The new {@link FlixelSound} instance. */ @NotNull public FlixelSound play(@NotNull String path) { @@ -142,7 +164,7 @@ public FlixelSound play(@NotNull String path) { * * @param path Path to the sound. * @param volume Volume to play with. - * @return The new FlixelSound instance. + * @return The new {@link FlixelSound} instance. */ @NotNull public FlixelSound play(@NotNull String path, float volume) { @@ -155,15 +177,24 @@ public FlixelSound play(@NotNull String path, float volume) { * @param path Path to the sound. * @param volume Volume to play with. * @param looping Whether to loop. + * @return The new {@link FlixelSound} instance. */ @NotNull public FlixelSound play(@NotNull String path, float volume, boolean looping) { return play(path, volume, looping, null, false); } - /** @see #play(String, float, boolean, MAGroup, boolean) */ + /** + * Plays a new sound effect. + * + * @param path Path to the sound. + * @param volume Volume to play with. + * @param looping Whether to loop. + * @param group Sound group, or {@code null} to use the default SFX group. + * @return The new {@link FlixelSound} instance. + */ @NotNull - public FlixelSound play(@NotNull String path, float volume, boolean looping, @Nullable MAGroup group) { + public FlixelSound play(@NotNull String path, float volume, boolean looping, @Nullable Object group) { return play(path, volume, looping, group, false); } @@ -173,16 +204,17 @@ public FlixelSound play(@NotNull String path, float volume, boolean looping, @Nu * @param path Path to the sound. * @param volume Volume to play with. * @param looping Whether to loop. - * @param group Sound group, or null to use the default SFX group. - * @param external If true, path is used as-is (for external files, e.g. mobile). - * @return The new FlixelSound instance. + * @param group Sound group, or {@code null} to use the default SFX group. + * @param external If {@code true}, the path is used as-is (for external files). + * @return The new {@link FlixelSound} instance. */ @NotNull - public FlixelSound play(@NotNull String path, float volume, boolean looping, @Nullable MAGroup group, boolean external) { + public FlixelSound play(@NotNull String path, float volume, boolean looping, + @Nullable Object group, boolean external) { String resolvedPath = external ? path : FlixelPathsUtil.resolveAudioPath(path); - MAGroup targetGroup = (group != null) ? group : sfxGroup; - MASound sound = engine.createSound(resolvedPath, (short) 0, targetGroup, external); - FlixelSound flixelSound = new FlixelSound(sound); + Object targetGroup = (group != null) ? group : sfxGroup; + FlixelSoundBackend backend = factory.createSound(resolvedPath, (short) 0, targetGroup, external); + FlixelSound flixelSound = new FlixelSound(backend); flixelSound.setVolume(volume); flixelSound.setLooped(looping); flixelSound.play(); @@ -193,20 +225,33 @@ public FlixelSound play(@NotNull String path, float volume, boolean looping, @Nu * Sets and plays the current music (music group). Stops any previous music. * * @param path Path to the music file. - * @return The new music FlixelSound instance. + * @return The new music {@link FlixelSound} instance. */ @NotNull public FlixelSound playMusic(@NotNull String path) { return playMusic(path, 1f, true, false); } - /** @see #playMusic(String, float, boolean, boolean) */ + /** + * Sets and plays the current music. Stops any previous music. + * + * @param path Path to the music file. + * @param volume Volume. + * @return The new music {@link FlixelSound} instance. + */ @NotNull public FlixelSound playMusic(@NotNull String path, float volume) { return playMusic(path, volume, true, false); } - /** @see #playMusic(String, float, boolean, boolean) */ + /** + * Sets and plays the current music. Stops any previous music. + * + * @param path Path to the music file. + * @param volume Volume. + * @param looping Whether to loop. + * @return The new music {@link FlixelSound} instance. + */ @NotNull public FlixelSound playMusic(@NotNull String path, float volume, boolean looping) { return playMusic(path, volume, looping, false); @@ -218,8 +263,8 @@ public FlixelSound playMusic(@NotNull String path, float volume, boolean looping * @param path Path to the music file. * @param volume Volume. * @param looping Whether to loop. - * @param external If true, path is used as-is (e.g. for mobile external storage). - * @return The new music FlixelSound instance. + * @param external If {@code true}, the path is used as-is (e.g. for mobile external storage). + * @return The new music {@link FlixelSound} instance. */ @NotNull public FlixelSound playMusic(@NotNull String path, float volume, boolean looping, boolean external) { @@ -227,8 +272,8 @@ public FlixelSound playMusic(@NotNull String path, float volume, boolean looping music.stop(); } String resolvedPath = external ? path : FlixelPathsUtil.resolveAudioPath(path); - MASound sound = engine.createSound(resolvedPath, (short) 0, musicGroup, external); - music = new FlixelSound(sound); + FlixelSoundBackend backend = factory.createSound(resolvedPath, (short) 0, musicGroup, external); + music = new FlixelSound(backend); music.setVolume(volume); music.setLooped(looping); music.play(); @@ -236,20 +281,22 @@ public FlixelSound playMusic(@NotNull String path, float volume, boolean looping } /** - * Pauses all currently playing sounds. Used when the game loses focus or is minimized. - * Only sounds that were playing are paused; they can be resumed with {@link #resume()}. + * Pauses all currently playing sounds. Used when the game loses focus or + * is minimized. Only sounds that were playing are paused; they can be + * resumed with {@link #resume()}. */ public void pause() { - sfxGroup.pause(); - musicGroup.pause(); + factory.groupPause(sfxGroup); + factory.groupPause(musicGroup); } /** - * Resumes all sounds that were paused by {@link #pause()}. Called when the game regains focus. + * Resumes all sounds that were paused by {@link #pause()}. Called when the + * game regains focus. */ public void resume() { - sfxGroup.play(); - musicGroup.play(); + factory.groupPlay(sfxGroup); + factory.groupPlay(musicGroup); } @Override @@ -258,9 +305,9 @@ public void destroy() { music.dispose(); music = null; } - sfxGroup.dispose(); - musicGroup.dispose(); - engine.dispose(); + factory.disposeGroup(sfxGroup); + factory.disposeGroup(musicGroup); + factory.disposeEngine(); } @Override diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/FlixelSound.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/FlixelSound.java index a0f547f..da88f02 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/FlixelSound.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/FlixelSound.java @@ -7,12 +7,6 @@ package me.stringdotjar.flixelgdx.audio; -import games.rednblack.miniaudio.MANode; -import games.rednblack.miniaudio.MASound; -import games.rednblack.miniaudio.MiniAudio; -import games.rednblack.miniaudio.effect.MADelayNode; -import games.rednblack.miniaudio.effect.MAReverbNode; -import games.rednblack.miniaudio.filter.MALowPassFilter; import me.stringdotjar.flixelgdx.Flixel; import me.stringdotjar.flixelgdx.FlixelBasic; import me.stringdotjar.flixelgdx.asset.FlixelAsset; @@ -29,22 +23,23 @@ import com.badlogic.gdx.utils.Array; /** - * Flixel sound object that wraps an {@link MASound} from the miniaudio library. + * Flixel sound object that wraps a platform-specific {@link FlixelSoundBackend}. * - *

Provides volume, pitch, pan, play/pause/stop/resume, fade-in/fade-out, position (time), optional - * miniaudio graph effects ({@link #addReverb}, {@link #addEcho}, {@link #addLowPassMuffle}, - * {@link #attachCustomNode}), and an {@link #onComplete} signal when the sound finishes (for non-looping sounds). + *

Provides volume, pitch, pan, play/pause/stop/resume, fade-in/fade-out, + * position (time), optional audio-graph effects ({@link #addReverb}, + * {@link #addEcho}, {@link #addLowPassMuffle}, {@link #attachCustomNode}), + * and an {@link #onComplete} signal when the sound finishes (for non-looping + * sounds). * - *

This class implements {@link FlixelAsset}{@code } for a shared refcount / {@code persist} - * contract. {@link #persist} controls whether this {@code FlixelSound} is treated as long-lived in game state - * (e.g. not killed on substate switches). It is separate from {@link me.stringdotjar.flixelgdx.asset.FlixelAssetManager#clearNonPersist()}, - * which clears pooled typed handles and wrappers on the global asset manager—not miniaudio instances created - * directly from a {@link com.badlogic.gdx.files.FileHandle}. Use {@link #retain()} / {@link #release()} if you mirror - * pooled-asset semantics for sounds you manage manually. + *

This class implements {@link FlixelAsset}{@code } for + * a shared refcount / {@code persist} contract. {@link #persist} controls + * whether this {@code FlixelSound} is treated as long-lived in game state (e.g. + * not killed on substate switches). Use {@link #retain()} / {@link #release()} + * if you mirror pooled-asset semantics for sounds you manage manually. * * @see me.stringdotjar.flixelgdx.asset.FlixelAssetManager#resolveAudioPath(String) */ -public class FlixelSound extends FlixelBasic implements FlixelAsset { +public class FlixelSound extends FlixelBasic implements FlixelAsset { private static final float SEC_TO_MS = 1000f; private static final float MS_TO_SEC = 1f / SEC_TO_MS; @@ -52,20 +47,18 @@ public class FlixelSound extends FlixelBasic implements FlixelAsset { @NotNull private final String assetKey; - /** The underlying miniaudio sound. Use {@link #getMASound()} for external access. */ @NotNull - private final MASound sound; + private final FlixelSoundBackend sound; - /** The manager that {@code this} sound is a member of. */ @Nullable private FlixelAudioManager manager; private int refCount; - /** Cached pitch (MASound has no getPitch). */ + /** Cached pitch (some backends have no getPitch). */ private float pitch = 1f; - /** Cached pan (MASound has no getPan). */ + /** Cached pan (some backends have no getPan). */ private float pan = 0f; /** World x position for proximity/panning. */ @@ -88,8 +81,8 @@ public class FlixelSound extends FlixelBasic implements FlixelAsset { @Nullable private FlixelTween fadeTween; - /** Tail-ordered effect nodes: each attaches to the previous node or to {@link #sound}. */ - private final Array audioEffectNodes = new Array<>(4); + /** Tail-ordered effect nodes attached to the audio graph. */ + private final Array audioEffectNodes = new Array<>(4); /** Signal dispatched when the sound reaches its end (non-looping). */ @NotNull @@ -105,30 +98,31 @@ public FlixelSound(@NotNull FileHandle path) { } /** - * Creates a Flixel sound wrapping the given MASound. + * Creates a Flixel sound wrapping the given backend. * - * @param sound The miniaudio sound to wrap (must not be null). + * @param sound The platform-specific sound backend to wrap (must not be null). */ - public FlixelSound(@NotNull MASound sound) { + public FlixelSound(@NotNull FlixelSoundBackend sound) { super(); this.sound = sound; this.assetKey = "__flixel_sound__/" + ID; } /** - * Returns the underlying MASound for advanced use. Prefer the FlixelSound API when possible. + * Returns the underlying sound backend for advanced use. Prefer the + * {@code FlixelSound} API when possible. * - * @return The wrapped MASound instance. + * @return The wrapped backend instance. */ @NotNull - public MASound getMASound() { + public FlixelSoundBackend getBackend() { return sound; } /** * Returns the manager that {@code this} sound is a member of. * - * @return The manager that {@code this} sound is a member of, or {@code null} if it is not a member of any manager. + * @return The manager, or {@code null} if not assigned to one. */ @Nullable public FlixelAudioManager getManager() { @@ -155,8 +149,8 @@ public String getAssetKey() { @NotNull @Override - public Class getType() { - return MASound.class; + public Class getType() { + return FlixelSoundBackend.class; } @Override @@ -183,50 +177,77 @@ public FlixelSound release() { @Override public void queueLoad() { - // Sound is created eagerly in constructors; nothing to queue on the libGDX AssetManager. + // Sound is created eagerly in constructors, nothing to queue. } @NotNull @Override - public MASound require() { + public FlixelSoundBackend require() { return sound; } @NotNull @Override - public MASound loadNow() { + public FlixelSoundBackend loadNow() { return sound; } - /** Volume in [0, 1] (values > 1 are allowed for louder). */ + /** + * Returns the current volume. + * + * @return Volume level (0 = silent, 1 = default, values above 1 are allowed). + */ public float getVolume() { return sound.getVolume(); } - /** Sets volume (0 = silent, 1 = default, >1 = louder). */ + /** + * Sets the volume. + * + * @param volume Volume level (0 = silent, 1 = default, values above 1 amplify). + * @return {@code this} for chaining. + */ public FlixelSound setVolume(float volume) { sound.setVolume(volume); return this; } - /** Pitch multiplier; 1 = default, >1 = higher. */ + /** + * Returns the cached pitch multiplier. + * + * @return Pitch multiplier; 1 = default, values above 1 raise pitch. + */ public float getPitch() { return pitch; } - /** Sets pitch; must be > 0. */ + /** + * Sets the pitch multiplier. + * + * @param pitch Pitch value; must be greater than 0. + * @return {@code this} for chaining. + */ public FlixelSound setPitch(float pitch) { this.pitch = pitch; sound.setPitch(pitch); return this; } - /** Pan in [-1, 1]; -1 = left, 0 = center, 1 = right. */ + /** + * Returns the cached pan value. + * + * @return Pan in [-1, 1]; -1 = left, 0 = center, 1 = right. + */ public float getPan() { return pan; } - /** Sets pan in [-1, 1]. */ + /** + * Sets the stereo pan. + * + * @param pan Pan value in [-1, 1]. + * @return {@code this} for chaining. + */ public FlixelSound setPan(float pan) { this.pan = pan; sound.setPan(pan); @@ -234,9 +255,11 @@ public FlixelSound setPan(float pan) { } /** - * Current playback position in milliseconds. + * Returns the current playback position in milliseconds. * *

If set while paused, the change takes effect after {@link #resume()}. + * + * @return Playback position in milliseconds. */ public float getTime() { return sound.getCursorPosition() * SEC_TO_MS; @@ -253,19 +276,40 @@ public FlixelSound setTime(float timeMs) { return this; } + /** + * Returns the total length of the sound in milliseconds. + * + * @return Duration in milliseconds, or 0 if unknown. + */ public float getLength() { return sound.getLength() * SEC_TO_MS; } + /** + * Returns whether this sound is set to loop. + * + * @return {@code true} if looping is enabled. + */ public boolean isLooped() { return sound.isLooping(); } + /** + * Enables or disables looping. + * + * @param looped {@code true} to loop, {@code false} to play once. + * @return {@code this} for chaining. + */ public FlixelSound setLooped(boolean looped) { sound.setLooping(looped); return this; } + /** + * Returns whether this sound is currently playing. + * + * @return {@code true} if the sound is actively playing. + */ public boolean isPlaying() { return sound.isPlaying(); } @@ -308,14 +352,22 @@ public FlixelSound play(boolean forceRestart, float startTimeMs) { return this; } - /** Pauses the sound. */ + /** + * Pauses the sound at its current position. + * + * @return {@code this} for chaining. + */ @NotNull public FlixelSound pause() { sound.pause(); return this; } - /** Stops the sound and resets position to 0. */ + /** + * Stops the sound and resets position to 0. + * + * @return {@code this} for chaining. + */ @NotNull public FlixelSound stop() { cancelFadeTween(); @@ -323,26 +375,45 @@ public FlixelSound stop() { return this; } - /** Resumes from the current position after a pause. */ + /** + * Resumes from the current position after a pause. + * + * @return {@code this} for chaining. + */ @NotNull public FlixelSound resume() { sound.play(); return this; } - /** Stops playback at this position (ms). Null = play to end. */ + /** + * Returns the position (in milliseconds) at which playback will stop, or + * {@code null} if the sound will play to the end. + * + * @return End time in milliseconds, or {@code null}. + */ @Nullable public Float getEndTime() { return endTimeMs; } - /** Sets the position (ms) at which to stop. Null = play to end. */ + /** + * Sets the position (ms) at which to stop. {@code null} means play to the end. + * + * @param endTimeMs End time in milliseconds, or {@code null}. + * @return {@code this} for chaining. + */ public FlixelSound setEndTime(@Nullable Float endTimeMs) { this.endTimeMs = endTimeMs; return this; } - /** Fades in from 0 to 1 over the given duration (seconds). */ + /** + * Fades in from 0 to 1 over the given duration (seconds). + * + * @param durationSeconds Fade duration in seconds. + * @return {@code this} for chaining. + */ @NotNull public FlixelSound fadeIn(float durationSeconds) { return fadeIn(durationSeconds, 0f, 1f); @@ -367,7 +438,12 @@ public FlixelSound fadeIn(float durationSeconds, float from, float to) { return this; } - /** Fades out to 0 over the given duration (seconds). */ + /** + * Fades out to 0 over the given duration (seconds). + * + * @param durationSeconds Fade duration in seconds. + * @return {@code this} for chaining. + */ @NotNull public FlixelSound fadeOut(float durationSeconds) { return fadeOut(durationSeconds, 0f); @@ -378,7 +454,7 @@ public FlixelSound fadeOut(float durationSeconds) { * * @param durationSeconds Fade duration in seconds. * @param to Target volume (typically 0). - * @return this, for chaining. + * @return {@code this} for chaining. */ @NotNull public FlixelSound fadeOut(float durationSeconds, float to) { @@ -390,7 +466,11 @@ public FlixelSound fadeOut(float durationSeconds, float to) { return this; } - /** The tween used for fade-in/fade-out, if any. */ + /** + * Returns the tween used for fade-in/fade-out, if any. + * + * @return The active fade tween, or {@code null}. + */ @Nullable public FlixelTween getFadeTween() { return fadeTween; @@ -403,17 +483,31 @@ private void cancelFadeTween() { } } - /** X position in world coordinates (for proximity/panning). */ + /** + * Returns the X position in world coordinates (for proximity/panning). + * + * @return World X position. + */ public float getX() { return x; } - /** Y position in world coordinates (for proximity/panning). */ + /** + * Returns the Y position in world coordinates (for proximity/panning). + * + * @return World Y position. + */ public float getY() { return y; } - /** Sets world position for proximity/panning. */ + /** + * Sets world position for proximity/panning. + * + * @param x World X coordinate. + * @param y World Y coordinate. + * @return {@code this} for chaining. + */ public FlixelSound setPosition(float x, float y) { this.x = x; this.y = y; @@ -421,10 +515,21 @@ public FlixelSound setPosition(float x, float y) { return this; } + /** + * Returns whether this sound auto-destroys when playback completes. + * + * @return {@code true} if auto-destroy is enabled. + */ public boolean isAutoDestroy() { return autoDestroy; } + /** + * Sets whether this sound auto-destroys when playback completes. + * + * @param autoDestroy {@code true} to enable auto-destroy. + * @return {@code this} for chaining. + */ public FlixelSound setAutoDestroy(boolean autoDestroy) { this.autoDestroy = autoDestroy; return this; @@ -466,45 +571,51 @@ public void update(float elapsed) { } /** - * Detaches and disposes every node in {@link #audioEffectNodes} (reverse order). Called from - * {@link #reset()} and {@link #destroy()}. + * Detaches and disposes every node in the effect chain (reverse order). + * Called from {@link #destroy()}. */ public void clearAudioEffectChain() { - MiniAudio mini = Flixel.getAudioEngine(); + FlixelSoundBackend.Factory factory = Flixel.getSoundFactory(); for (int i = audioEffectNodes.size - 1; i >= 0; i--) { - MANode n = audioEffectNodes.get(i); + FlixelSoundBackend.EffectNode n = audioEffectNodes.get(i); n.detach(0); n.dispose(); } audioEffectNodes.clear(); - // Effects replace the default sound -> endpoint route; restore it when the chain is empty. - mini.attachToEngineOutput(sound, 0); + if (factory != null) { + factory.attachToEngineOutput(sound, 0); + } } /** - * Appends a reverb node with the given wet amount in {@code [0, 1]} (dry is set to {@code 1 - wet}). - * Build effect chains in load/setup code, not every frame. + * Appends a reverb node with the given wet amount in {@code [0, 1]} + * (dry is set to {@code 1 - wet}). Build effect chains in load/setup + * code, not every frame. + * + * @param wetAmount Wet signal level in [0, 1]. + * @return {@code this} for chaining. */ @NotNull public FlixelSound addReverb(float wetAmount) { - MiniAudio engine = Flixel.getAudioEngine(); - MAReverbNode rev = new MAReverbNode(engine); - float w = Math.max(0f, Math.min(1f, wetAmount)); - rev.setWet(w); - rev.setDry(1f - w); - attachEffectNode(rev); + FlixelSoundBackend.Factory factory = Flixel.getSoundFactory(); + if (factory == null) return this; + FlixelSoundBackend.EffectNode node = factory.createReverbNode(wetAmount); + attachEffectNode(node); return this; } /** - * Appends a stereo delay/echo node (miniaudio {@link MADelayNode}). + * Appends a stereo delay/echo node. * * @param delaySeconds Delay time in seconds. * @param decay Decay factor for the delayed signal. + * @return {@code this} for chaining. */ @NotNull public FlixelSound addEcho(float delaySeconds, float decay) { - MADelayNode node = new MADelayNode(Flixel.getAudioEngine(), delaySeconds, decay); + FlixelSoundBackend.Factory factory = Flixel.getSoundFactory(); + if (factory == null) return this; + FlixelSoundBackend.EffectNode node = factory.createDelayNode(delaySeconds, decay); attachEffectNode(node); return this; } @@ -513,31 +624,43 @@ public FlixelSound addEcho(float delaySeconds, float decay) { * Appends a 2nd-order low-pass filter (muffled / distant sound). * * @param cutoffHz Cutoff frequency in Hz. + * @return {@code this} for chaining. */ @NotNull public FlixelSound addLowPassMuffle(double cutoffHz) { - MALowPassFilter lp = new MALowPassFilter(Flixel.getAudioEngine(), cutoffHz, 2); - attachEffectNode(lp); + FlixelSoundBackend.Factory factory = Flixel.getSoundFactory(); + if (factory == null) return this; + FlixelSoundBackend.EffectNode node = factory.createLowPassFilter(cutoffHz, 2); + attachEffectNode(node); return this; } /** - * Expert escape hatch: append any {@link MANode} to the effect chain. {@code this} sound disposes the node - * when {@link #clearAudioEffectChain} runs unless you remove it yourself first. + * Expert escape hatch: append any effect node to the chain. {@code this} + * sound disposes the node when {@link #clearAudioEffectChain()} runs unless + * you remove it yourself first. + * + * @param node The effect node to attach. + * @return {@code this} for chaining. */ @NotNull - public FlixelSound attachCustomNode(@NotNull MANode node) { + public FlixelSound attachCustomNode(@NotNull FlixelSoundBackend.EffectNode node) { attachEffectNode(node); return this; } - private void attachEffectNode(@NotNull MANode node) { - MiniAudio mini = Flixel.getAudioEngine(); - MANode upstream = audioEffectNodes.size == 0 ? sound : audioEffectNodes.peek(); - node.attachToThisNode(upstream, 0); + private void attachEffectNode(@NotNull FlixelSoundBackend.EffectNode node) { + FlixelSoundBackend.Factory factory = Flixel.getSoundFactory(); + FlixelSoundBackend upstream = audioEffectNodes.size == 0 + ? sound + : null; + if (upstream != null) { + node.attachToUpstream(upstream, 0); + } audioEffectNodes.add(node); - // Wiring upstream -> node removes the previous output attachment (e.g. sound or prior tail -> endpoint). - mini.attachToEngineOutput(node, 0); + if (factory != null) { + factory.attachToEngineOutput(sound, 0); + } } @Override @@ -559,8 +682,10 @@ public void destroy() { persist = false; } - private static MASound createSoundForHandle(@NotNull FileHandle path) { + private static FlixelSoundBackend createSoundForHandle(@NotNull FileHandle path) { String resolvedPath = FlixelPathsUtil.resolveAudioPath(path.path()); - return Flixel.getAudioEngine().createSound(resolvedPath, (short) 0, Flixel.sound.getSfxGroup(), false); + FlixelSoundBackend.Factory factory = Flixel.getSoundFactory(); + Object sfxGroup = Flixel.sound != null ? Flixel.sound.getSfxGroup() : null; + return factory.createSound(resolvedPath, (short) 0, sfxGroup, false); } } diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/FlixelSoundBackend.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/FlixelSoundBackend.java new file mode 100644 index 0000000..ab26f4e --- /dev/null +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/FlixelSoundBackend.java @@ -0,0 +1,243 @@ +/********************************************************************************** + * Copyright (c) 2025-2026 stringdotjar + * + * This file is part of the FlixelGDX framework, licensed under the MIT License. + * See the LICENSE file in the repository root for full license information. + **********************************************************************************/ + +package me.stringdotjar.flixelgdx.audio; + +/** + * Platform-agnostic abstraction over a single sound instance. + * + *

On JVM platforms the default implementation wraps MiniAudio ({@code MASound}); + * on TeaVM/web the implementation falls back to libGDX {@code Gdx.audio}. + * + *

Obtain instances through {@link Factory#createSound}. + * + * @see Factory + */ +public interface FlixelSoundBackend { + + /** Starts or resumes playback. */ + void play(); + + /** Pauses playback at the current cursor position. */ + void pause(); + + /** Stops playback and resets the cursor to the beginning. */ + void stop(); + + /** + * Returns whether this sound is currently playing. + * + * @return {@code true} if the sound is actively playing. + */ + boolean isPlaying(); + + /** + * Returns whether this sound has reached its end. + * + * @return {@code true} if the cursor is at or past the end of the stream. + */ + boolean isEnd(); + + /** + * Returns the current volume. + * + * @return Volume level ({@code 0} = silent, {@code 1} = default, values above {@code 1} are allowed). + */ + float getVolume(); + + /** + * Sets the volume. + * + * @param volume Volume level ({@code 0} = silent, {@code 1} = default). + */ + void setVolume(float volume); + + /** + * Sets the pitch multiplier. + * + * @param pitch Pitch multiplier. Must be greater than {@code 0}. {@code 1} = default. + */ + void setPitch(float pitch); + + /** + * Sets the stereo pan. + * + * @param pan Pan value in {@code [-1, 1]}; {@code -1} = full left, {@code 0} = center, {@code 1} = full right. + */ + void setPan(float pan); + + /** + * Returns the current cursor position in seconds. + * + * @return Playback position in seconds. + */ + float getCursorPosition(); + + /** + * Seeks to the given position. + * + * @param seconds Target position in seconds. + */ + void seekTo(float seconds); + + /** + * Returns the total length of the sound in seconds. + * + * @return Duration in seconds, or 0 if unknown. + */ + float getLength(); + + /** + * Returns whether this sound is set to loop. + * + * @return {@code true} if the sound will loop when it reaches the end. + */ + boolean isLooping(); + + /** + * Enables or disables looping. + * + * @param looping {@code true} to loop, {@code false} to play once. + */ + void setLooping(boolean looping); + + /** + * Sets the 3-D position of this sound for spatial audio. + * Implementations that do not support spatial audio should ignore this call. + * + * @param x X position. + * @param y Y position. + * @param z Z position. + */ + void setPosition(float x, float y, float z); + + /** Releases native resources held by this sound. */ + void dispose(); + + /** + * Platform-specific factory for creating sounds, groups, and effect nodes. + * + *

One instance is injected into {@link me.stringdotjar.flixelgdx.Flixel} at startup + * via {@code Flixel.setSoundBackendFactory(...)}, and is shared by + * {@link FlixelAudioManager} and {@link FlixelSound}. + */ + interface Factory { + + /** + * Creates a new sound from a resolved file path. + * + * @param path Resolved (absolute or internal) path to the audio file. + * @param flags Implementation-specific flags (typically 0). + * @param group A group handle previously obtained from {@link #createGroup()}, + * or {@code null} for the default group. + * @param external {@code true} if the path is an absolute external path. + * @return A new backend sound instance. + */ + FlixelSoundBackend createSound(String path, short flags, Object group, boolean external); + + /** + * Creates a new sound group. The returned object is opaque; pass it back to + * group methods or to {@link #createSound}. + * + * @return A new group handle. + */ + Object createGroup(); + + /** + * Disposes a group previously created by {@link #createGroup()}. + * + * @param group The group handle to dispose. + */ + void disposeGroup(Object group); + + /** + * Pauses all sounds in the given group. + * + * @param group The group handle. + */ + void groupPause(Object group); + + /** + * Resumes (plays) all sounds in the given group. + * + * @param group The group handle. + */ + void groupPlay(Object group); + + /** + * Sets the global master volume for the audio engine. + * + * @param volume Master volume in [0, 1]. + */ + void setMasterVolume(float volume); + + /** Disposes the underlying audio engine and releases all native resources. */ + void disposeEngine(); + + /** + * Attaches a sound (or effect node output) to the engine endpoint. + * Implementations that do not support an audio graph should no-op. + * + * @param sound The sound backend whose output to route. + * @param outputBusIndex Output bus index (typically 0). + */ + void attachToEngineOutput(FlixelSoundBackend sound, int outputBusIndex); + + /** + * Creates a reverb effect node. + * + * @param wet Wet amount in [0, 1]. + * @return A new effect node, or a no-op stub on unsupported platforms. + */ + EffectNode createReverbNode(float wet); + + /** + * Creates a delay / echo effect node. + * + * @param delaySeconds Delay time in seconds. + * @param decay Decay factor for the delayed signal. + * @return A new effect node, or a no-op stub on unsupported platforms. + */ + EffectNode createDelayNode(float delaySeconds, float decay); + + /** + * Creates a low-pass filter effect node. + * + * @param cutoffHz Cutoff frequency in hertz. + * @param order Filter order (e.g. 2 for a second-order filter). + * @return A new effect node, or a no-op stub on unsupported platforms. + */ + EffectNode createLowPassFilter(double cutoffHz, int order); + } + + /** + * An opaque handle to an audio-graph effect node (reverb, delay, low-pass, etc.). + * + *

On platforms that do not support an audio graph (e.g. TeaVM) the returned + * instances are no-op stubs. + */ + interface EffectNode { + + /** + * Wires an upstream source into this node. + * + * @param upstream The upstream sound or node output. + * @param bus Input bus index (typically 0). + */ + void attachToUpstream(FlixelSoundBackend upstream, int bus); + + /** + * Detaches this node from its input bus. + * + * @param bus The bus index to detach. + */ + void detach(int bus); + + /** Releases native resources held by this effect node. */ + void dispose(); + } +} diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/FlixelSoundSource.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/FlixelSoundSource.java index 6ddb04a..5b7cfdd 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/FlixelSoundSource.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/FlixelSoundSource.java @@ -8,8 +8,6 @@ package me.stringdotjar.flixelgdx.audio; import com.badlogic.gdx.files.FileHandle; -import games.rednblack.miniaudio.MAGroup; -import games.rednblack.miniaudio.MASound; import me.stringdotjar.flixelgdx.Flixel; import me.stringdotjar.flixelgdx.asset.FlixelSource; import me.stringdotjar.flixelgdx.util.FlixelPathsUtil; @@ -19,8 +17,9 @@ /** * Cached sound "source" (asset) that can spawn fresh {@link FlixelSound} instances on demand. * - *

Do not cache {@link FlixelSound} playback objects directly: a playback object has mutable state - * (volume/pan/time/playing) and cannot be safely shared across callers or overlapping plays. + *

Do not cache {@link FlixelSound} playback objects directly: a playback + * object has mutable state (volume/pan/time/playing) and cannot be safely + * shared across callers or overlapping plays. */ public final class FlixelSoundSource implements FlixelSource { @@ -29,10 +28,21 @@ public final class FlixelSoundSource implements FlixelSource private final boolean external; + /** + * Creates a sound source with the given asset key. + * + * @param assetKey The path or key identifying the audio asset. + */ public FlixelSoundSource(@NotNull String assetKey) { this(assetKey, false); } + /** + * Creates a sound source with the given asset key and external flag. + * + * @param assetKey The path or key identifying the audio asset. + * @param external {@code true} if the path is an absolute external path. + */ public FlixelSoundSource(@NotNull String assetKey, boolean external) { if (assetKey == null || assetKey.isEmpty()) { throw new IllegalArgumentException("assetKey cannot be null/empty"); @@ -51,30 +61,50 @@ public Class getType() { return FlixelSoundSource.class; } + /** + * Returns whether this source uses an external (absolute) path. + * + * @return {@code true} if external. + */ public boolean isExternal() { return external; } - /** Creates a new playable {@link FlixelSound} instance using the provided group (or SFX group if null). */ + /** + * Creates a new playable {@link FlixelSound} instance using the provided + * group (or the default SFX group if {@code null}). + * + * @param group Group handle from the backend factory, or {@code null}. + * @return A new sound instance. + */ @NotNull - public FlixelSound create(@Nullable MAGroup group) { + public FlixelSound create(@Nullable Object group) { String resolvedPath = external ? assetKey : FlixelPathsUtil.resolveAudioPath(assetKey); - MAGroup targetGroup = (group != null) ? group : Flixel.sound.getSfxGroup(); - - MASound ma = Flixel.getAudioEngine().createSound(resolvedPath, (short) 0, targetGroup, external); - return new FlixelSound(ma); + Object targetGroup = (group != null) ? group : Flixel.sound.getSfxGroup(); + FlixelSoundBackend.Factory factory = Flixel.getSoundFactory(); + FlixelSoundBackend backend = factory.createSound(resolvedPath, (short) 0, targetGroup, external); + return new FlixelSound(backend); } - /** Convenience overload using the default SFX group. */ + /** + * Convenience overload using the default SFX group. + * + * @return A new sound instance. + */ @NotNull public FlixelSound create() { return create(null); } - /** Convenience constructor from a libGDX file handle (uses {@code handle.path()} as key). */ + /** + * Convenience constructor from a libGDX file handle (uses + * {@code handle.path()} as key). + * + * @param handle The file handle to the audio file. + * @return A new sound source. + */ @NotNull public static FlixelSoundSource fromFile(@NotNull FileHandle handle) { return new FlixelSoundSource(handle.path(), false); } } - diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/package-info.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/package-info.java index 5800d2d..e746df7 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/package-info.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/audio/package-info.java @@ -1,7 +1,7 @@ /** * Audio support for FlixelGDX. * - *

This package provides Flixel style audio APIs on top of MiniAudio and libGDX integration. + *

This package provides simple audio APIs on top of a platform-agnostic sound backend. * It includes playback objects, cached sources, and managers for controlling sound and music. * *

Key types: diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/backend/reflect/FlixelDefaultReflectionHandler.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/backend/reflect/FlixelDefaultReflectionHandler.java index 5a381bc..1c62c40 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/backend/reflect/FlixelDefaultReflectionHandler.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/backend/reflect/FlixelDefaultReflectionHandler.java @@ -7,6 +7,7 @@ package me.stringdotjar.flixelgdx.backend.reflect; +import java.lang.reflect.AccessibleObject; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; @@ -219,12 +220,12 @@ private CachedClassMetadata buildMetadata(Class type) { for (Class current = type; current != null; current = current.getSuperclass()) { for (Field field : current.getDeclaredFields()) { - field.trySetAccessible(); + tryMakeAccessible(field); allFields.add(field); fieldsByName.putIfAbsent(field.getName(), field); } for (Method method : current.getDeclaredMethods()) { - method.trySetAccessible(); + tryMakeAccessible(method); methodsByName.computeIfAbsent(method.getName(), k -> new ArrayList<>()).add(method); if (method.getParameterCount() == 0 && method.getReturnType() != void.class) { String property = getterPropertyName(method.getName()); @@ -238,7 +239,7 @@ private CachedClassMetadata buildMetadata(Class type) { try { noArgConstructor = type.getDeclaredConstructor(); - noArgConstructor.trySetAccessible(); + tryMakeAccessible(noArgConstructor); } catch (NoSuchMethodException ignored) { // Optional for copy(). } @@ -344,6 +345,21 @@ private static String normalizeProperty(String segment) { return Character.toLowerCase(segment.charAt(0)) + segment.substring(1); } + /** + * Attempts to make the given accessible object (field, method, or constructor) + * accessible. Uses {@code setAccessible(true)} instead of the JDK 9+ + * {@code trySetAccessible()} method, which is not available on TeaVM. + * + * @param obj the accessible object to unlock. + */ + private static void tryMakeAccessible(AccessibleObject obj) { + try { + obj.setAccessible(true); + } catch (RuntimeException ignored) { + // Some environments restrict reflective access, so proceed without it. + } + } + private record CachedClassMetadata( List allFields, Map fieldsByName, diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/box2d/FlixelBox2DObject.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/box2d/FlixelBox2DObject.java deleted file mode 100644 index b003783..0000000 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/box2d/FlixelBox2DObject.java +++ /dev/null @@ -1,242 +0,0 @@ -/********************************************************************************** - * Copyright (c) 2025-2026 stringdotjar - * - * This file is part of the FlixelGDX framework, licensed under the MIT License. - * See the LICENSE file in the repository root for full license information. - **********************************************************************************/ - -package me.stringdotjar.flixelgdx.box2d; - -import com.badlogic.gdx.math.Vector2; -import com.badlogic.gdx.physics.box2d.Body; -import com.badlogic.gdx.physics.box2d.BodyDef; -import com.badlogic.gdx.physics.box2d.FixtureDef; -import com.badlogic.gdx.physics.box2d.PolygonShape; -import com.badlogic.gdx.physics.box2d.World; - -import me.stringdotjar.flixelgdx.FlixelObject; -import me.stringdotjar.flixelgdx.util.FlixelConstants; - -/** - * Opt-in interface for integrating libGDX Box2D physics with any {@link FlixelObject} - * subclass. Implementors get a managed {@link Body} whose position and angle are - * automatically synced back to the object's {@code x}, {@code y}, and {@code angle} - * each frame (via {@link #syncFromBody()}). - * - *

Usage

- *
{@code
- * public class Crate extends FlixelSprite implements FlixelBox2DObject {
- *   private Body body;
- *
- *   public Body getBody() { return body; }
- *   public void setBody(Body body) { this.body = body; }
- *
- *   public void create() {
- *     loadGraphic(...);
- *     createRectangularBody(Flixel.getWorld());
- *   }
- * }
- * }
- * - *

Pixel-to-Meter Scale

- * All default helpers use {@link FlixelConstants.Physics#PIXELS_PER_METER} - * (100 px = 1 m). Override individual methods if your game uses a different scale. - * - *

Motion

- * When a Box2D body is present, the implementor should not rely on - * {@link FlixelObject#updateMotion(float)} for movement; instead the - * {@link World} step drives the body and {@link #syncFromBody()} copies the result - * back to the object. If you still set {@code moves = true} alongside a body, the - * custom integration will run after the body sync and may produce - * unexpected results. - * - * @see libGDX Box2D - */ -public interface FlixelBox2DObject { - - /** - * Returns the Box2D body attached to this object, or {@code null} if no body - * has been created yet. - */ - Body getBody(); - - /** - * Stores a reference to the given Box2D body. Called by the default - * {@code createRectangularBody} helpers; implementors must provide the - * backing field. - */ - void setBody(Body body); - - /** - * Creates a dynamic rectangular body from the object's current - * {@code x}, {@code y}, {@code width}, and {@code height}. - * - * @param world The Box2D world to create the body in. - * @return The created body, or {@code null} if {@code this} is not a {@link FlixelObject}. - */ - default Body createRectangularBody(World world) { - BodyDef def = new BodyDef(); - def.type = BodyDef.BodyType.DynamicBody; - return createRectangularBody(world, def); - } - - /** - * Creates a rectangular body with a custom {@link BodyDef} from the object's - * current dimensions. A default box {@link PolygonShape} and - * {@link FixtureDef} (density = 1, friction = 0.3, restitution = 0) are applied. Override - * {@link #createRectangularBody(World, BodyDef, FixtureDef)} for full control. - * - * @param world The Box2D world. - * @param bodyDef The body definition to use. - * @return The created body, or {@code null} if {@code this} is not a {@link FlixelObject}. - */ - default Body createRectangularBody(World world, BodyDef bodyDef) { - FixtureDef fixtureDef = new FixtureDef(); - fixtureDef.density = 1f; - fixtureDef.friction = 0.3f; - fixtureDef.restitution = 0f; - return createRectangularBody(world, bodyDef, fixtureDef); - } - - /** - * Creates a rectangular body with full control over body and fixture - * definitions. The body is positioned at the center of the object's - * bounding box (converted to meters) and a box shape is created from the - * object's width and height. - * - * @param world The Box2D world. - * @param bodyDef The body definition. - * @param fixtureDef The fixture definition. The shape will be set automatically. - * @return The created body, or {@code null} if {@code this} is not a {@link FlixelObject}. - */ - default Body createRectangularBody(World world, BodyDef bodyDef, FixtureDef fixtureDef) { - if (!(this instanceof FlixelObject obj)) { - return null; - } - - float ppm = FlixelConstants.Physics.PIXELS_PER_METER; - float halfW = (obj.getWidth() / ppm) / 2f; - float halfH = (obj.getHeight() / ppm) / 2f; - float centerX = (obj.getX() + obj.getWidth() / 2f) / ppm; - float centerY = (obj.getY() + obj.getHeight() / 2f) / ppm; - - bodyDef.position.set(centerX, centerY); - - Body body = world.createBody(bodyDef); - - PolygonShape shape = new PolygonShape(); - shape.setAsBox(halfW, halfH); - fixtureDef.shape = shape; - body.createFixture(fixtureDef); - shape.dispose(); - - setBody(body); - return body; - } - - /** - * Copies the Box2D body's position and angle back to the {@link FlixelObject}'s - * {@code x}, {@code y}, and {@code angle}. Called automatically by the engine - * after each world step for every active {@code FlixelBox2DObject}. - * - *

Box2D positions represent the body center; this method converts - * to the top-left convention used by {@link FlixelObject}. - */ - default void syncFromBody() { - if (!(this instanceof FlixelObject obj)) { - return; - } - - Body body = getBody(); - if (body == null) { - return; - } - - float ppm = FlixelConstants.Physics.PIXELS_PER_METER; - Vector2 pos = body.getPosition(); - obj.setX(pos.x * ppm - obj.getWidth() / 2f); - obj.setY(pos.y * ppm - obj.getHeight() / 2f); - obj.setAngle((float) Math.toDegrees(body.getAngle())); - } - - /** - * Copies the {@link FlixelObject}'s current {@code x}, {@code y}, and - * {@code angle} to the Box2D body. Useful after teleporting an object. - */ - default void syncToBody() { - if (!(this instanceof FlixelObject obj)) { - return; - } - - Body body = getBody(); - if (body == null) { - return; - } - - float ppm = FlixelConstants.Physics.PIXELS_PER_METER; - float centerX = (obj.getX() + obj.getWidth() / 2f) / ppm; - float centerY = (obj.getY() + obj.getHeight() / 2f) / ppm; - body.setTransform(centerX, centerY, (float) Math.toRadians(obj.getAngle())); - } - - /** - * Applies the object's current {@code velocityX} / {@code velocityY} to the - * body as a linear velocity (converted from px/s to m/s). - */ - default void applyVelocityToBody() { - if (!(this instanceof FlixelObject obj)) { - return; - } - - Body body = getBody(); - if (body == null) { - return; - } - - float ppm = FlixelConstants.Physics.PIXELS_PER_METER; - body.setLinearVelocity(obj.getVelocityX() / ppm, obj.getVelocityY() / ppm); - } - - /** - * Applies an impulse at the body's center of mass. - * - * @param impulseX Horizontal impulse in Newton-seconds. - * @param impulseY Vertical impulse in Newton-seconds. - */ - default void applyLinearImpulse(float impulseX, float impulseY) { - Body body = getBody(); - if (body == null) { - return; - } - body.applyLinearImpulse(impulseX, impulseY, body.getWorldCenter().x, body.getWorldCenter().y, true); - } - - /** - * Applies a force at the body's center of mass. - * - * @param forceX Horizontal force in Newtons. - * @param forceY Vertical force in Newtons. - */ - default void applyForce(float forceX, float forceY) { - Body body = getBody(); - if (body == null) { - return; - } - body.applyForceToCenter(forceX, forceY, true); - } - - /** - * Destroys the body in the given world and sets the internal reference to - * {@code null}. Safe to call when the body is already {@code null}. - * - * @param world The Box2D world the body belongs to. - */ - default void destroyBody(World world) { - Body body = getBody(); - if (body == null) { - return; - } - world.destroyBody(body); - setBody(null); - } -} diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/box2d/package-info.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/box2d/package-info.java deleted file mode 100644 index 858125f..0000000 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/box2d/package-info.java +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Box2D integration for FlixelGDX. - * - *

This package contains utilities and helpers for working with libGDX Box2D alongside FlixelGDX - * objects and states. The core world object is owned by {@link me.stringdotjar.flixelgdx.FlixelGame} - * and can be accessed through {@link me.stringdotjar.flixelgdx.Flixel#getWorld()} when enabled by - * a particular game setup. - */ -package me.stringdotjar.flixelgdx.box2d; diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/debug/FlixelDebugDrawable.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/debug/FlixelDebugDrawable.java index c9a70e6..00a0338 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/debug/FlixelDebugDrawable.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/debug/FlixelDebugDrawable.java @@ -17,8 +17,8 @@ * without being hard-coded into the overlay. * *

{@link FlixelObject} implements this by default, using collision state to - * pick an appropriate color. Custom objects (including Box2D bodies) can implement - * this interface to provide their own debug visualization. + * pick an appropriate color. Custom objects can implement this interface to provide + * their own debug visualization. */ public interface FlixelDebugDrawable { diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/debug/FlixelDebugOverlay.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/debug/FlixelDebugOverlay.java index 5825d60..ae31367 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/debug/FlixelDebugOverlay.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/debug/FlixelDebugOverlay.java @@ -38,7 +38,6 @@ import java.util.ArrayDeque; import java.util.Deque; import java.util.Iterator; -import java.util.List; import java.util.function.Consumer; /** @@ -365,25 +364,25 @@ private void rebuildCachedConsoleBlocks() { if (logger == null) { return; } - List entries = logger.getConsoleEntries(); - if (entries == null || entries.isEmpty()) { + FlixelDebugConsoleEntry[] entries = logger.getConsoleEntries(); + if (entries == null || entries.length == 0) { return; } for (FlixelDebugConsoleEntry entry : entries) { - List lines = entry.getConsoleLines(); - if (lines == null || lines.isEmpty()) { + String[] lines = entry.getConsoleLines(); + if (lines == null || lines.length == 0) { continue; } CachedConsoleBlock block = obtainConsoleBlock(); StringBuilder header = block.header; header.setLength(0); header.append("[#AADDFF]<").append(entry.getName()).append('>'); - int n = lines.size(); + int n = lines.length; block.ensureBodyLineCount(n); for (int i = 0; i < n; i++) { StringBuilder body = block.bodies[i]; body.setLength(0); - body.append(CONSOLE_BODY_LINE_PREFIX).append(lines.get(i)); + body.append(CONSOLE_BODY_LINE_PREFIX).append(lines[i]); } block.bodyCount = n; cachedConsoleBlocks.add(block); diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/logging/FlixelDebugConsoleEntry.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/logging/FlixelDebugConsoleEntry.java index 27bcc70..6035d1c 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/logging/FlixelDebugConsoleEntry.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/logging/FlixelDebugConsoleEntry.java @@ -57,7 +57,7 @@ public String getName() { * * @return An unmodifiable list of display lines, never {@code null}. */ - public abstract List getConsoleLines(); + public abstract String[] getConsoleLines(); /** * Convenience method for entries that only need a single line. diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/logging/FlixelLogFileHandler.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/logging/FlixelLogFileHandler.java new file mode 100644 index 0000000..3d9b3e3 --- /dev/null +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/logging/FlixelLogFileHandler.java @@ -0,0 +1,101 @@ +/********************************************************************************** + * Copyright (c) 2025-2026 stringdotjar + * + * This file is part of the FlixelGDX framework, licensed under the MIT License. + * See the LICENSE file in the repository root for full license information. + **********************************************************************************/ + +package me.stringdotjar.flixelgdx.logging; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Platform-specific handler for writing log output to a persistent file. + * + *

Implementations are responsible for the entire file-logging lifecycle: creating + * the log file, pruning old files when the maximum is exceeded, writing individual + * log lines (potentially on a background thread to avoid blocking the game loop), + * and shutting down cleanly on game exit so that all buffered output is flushed. + * + *

On platforms where file logging is not feasible (for example, web/TeaVM), no + * handler needs to be registered and the logger will simply skip file output. + * + *

Register an implementation before {@link me.stringdotjar.flixelgdx.Flixel#initialize} + * by calling {@link me.stringdotjar.flixelgdx.Flixel#setLogFileHandler}. The handler + * follows the same injection pattern used by + * {@link me.stringdotjar.flixelgdx.backend.alert.FlixelAlerter} and + * {@link FlixelStackTraceProvider}. + * + * @see FlixelLogger + * @see me.stringdotjar.flixelgdx.Flixel#setLogFileHandler(FlixelLogFileHandler) + */ +public interface FlixelLogFileHandler { + + /** + * Starts file logging in the specified folder, keeping at most {@code maxLogFiles} + * log files. Older files beyond the limit are deleted before the new file is + * created. + * + *

If {@code logsFolderPath} is {@code null}, the implementation should fall + * back to a platform-appropriate default (for example, next to the running JAR + * or in the project root during development). + * + *

Implementations that perform file writes on a background thread should + * start that thread here. + * + * @param logsFolderPath The absolute path to the directory where log files are + * stored, or {@code null} to use the platform default. + * @param maxLogFiles The maximum number of log files to retain. When the + * folder already contains this many files, the oldest are deleted before a + * new file is created. + */ + void start(@Nullable String logsFolderPath, int maxLogFiles); + + /** + * Shuts down the file handler, flushing any buffered log lines and releasing + * resources such as background threads and file handles. + * + *

This method should block briefly (for example, up to five seconds) to + * allow the write queue to drain so that logs written during shutdown are + * persisted. After this method returns, subsequent calls to + * {@link #write(String)} are silently ignored. + */ + void stop(); + + /** + * Writes a single pre-formatted log line to the current log file. + * + *

Implementations should enqueue the line for asynchronous writing when a + * background thread is in use, so that the calling game thread is not blocked + * by disk I/O. If the handler has not been started or has already been stopped, + * the call is silently ignored. + * + * @param logLine The fully formatted, plain-text log line to write. A trailing + * newline is appended by the handler if necessary. + */ + void write(@NotNull String logLine); + + /** + * Returns whether the handler is currently active and accepting log lines. + * + *

A handler is active between a successful {@link #start} call and the + * completion of {@link #stop}. + * + * @return {@code true} if file logging is active, {@code false} otherwise. + */ + boolean isActive(); + + /** + * Returns the platform-appropriate default directory for log files, or + * {@code null} if the platform does not support file logging. + * + *

On JVM platforms this typically resolves to a {@code logs/} folder next + * to the running JAR or in the project root when running from an IDE. + * + * @return An absolute path to the default logs directory, or {@code null} + * when file logging is not supported on this platform. + */ + @Nullable + String getDefaultLogsFolderPath(); +} diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/logging/FlixelLogger.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/logging/FlixelLogger.java index b00c7ae..6cfd5c8 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/logging/FlixelLogger.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/logging/FlixelLogger.java @@ -7,22 +7,15 @@ package me.stringdotjar.flixelgdx.logging; -import com.badlogic.gdx.Gdx; -import com.badlogic.gdx.files.FileHandle; - import me.stringdotjar.flixelgdx.Flixel; import me.stringdotjar.flixelgdx.util.FlixelConstants; -import me.stringdotjar.flixelgdx.util.FlixelRuntimeUtil; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.Arrays; -import java.util.Comparator; -import java.util.List; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; +import com.badlogic.gdx.utils.Array; + /** * Logger instance for Flixel that formats and outputs log messages to the console and optionally * to a file. Console output respects the current {@link FlixelLogMode}; file output always uses @@ -50,82 +43,68 @@ public class FlixelLogger { /** Default tag to use when logging without a specific tag. */ private String defaultTag = ""; - /** Where the log file is written (for reference); actual writes go through the file line consumer. */ - private FileHandle logFileLocation; - /** Log mode for console output. File output always uses {@link FlixelLogMode#DETAILED}. */ private FlixelLogMode logMode; - /** Callback for when a log message is written to the file. */ - private Consumer fileLineConsumer; - /** Provider for collecting stack trace information for the logger. */ private FlixelStackTraceProvider stackTraceProvider; - private final ConcurrentLinkedQueue logQueue = new ConcurrentLinkedQueue<>(); - private final Object logQueueLock = new Object(); - private volatile boolean logWriterShutdownRequested = false; - private Thread logThread; - private String customLogsFolderPath = null; // Null means use default (IDE root or JAR dir). + /** Custom logs folder path, or {@code null} to use the platform default. */ + private String customLogsFolderPath = null; /** Listeners notified whenever a log message is produced (used by the debug overlay). */ - private final List> logListeners = new CopyOnWriteArrayList<>(); + private final Array> logListeners = new Array<>(Consumer[]::new); /** Registered debug console entries that supply custom lines to the overlay console. */ - private final List consoleEntries = new CopyOnWriteArrayList<>(); + private final Array consoleEntries = new Array<>(FlixelDebugConsoleEntry[]::new); /** - * Creates a logger that outputs to the console and a file. + * Creates a logger that outputs to the console and optionally to a file + * (when a {@link FlixelLogFileHandler} is registered on + * {@link Flixel#setLogFileHandler}). * - * @param logMode The mode used for console output. + * @param logMode The mode used for console output formatting. */ public FlixelLogger(FlixelLogMode logMode) { - this(null, logMode, null); + this.logMode = logMode != null ? logMode : FlixelLogMode.SIMPLE; + this.stackTraceProvider = Flixel.getStackTraceProvider(); } /** - * Creates a logger with an optional log file location, optional consumer for file lines, and - * a custom stack trace provider. + * Returns the current log mode used for console output formatting. * - * @param logFileLocation Where the log file is stored (which might be null). - * @param logMode The mode used for console output. - * @param fileLineConsumer Callback for when a log message is written to the file. + * @return The active log mode, never {@code null}. */ - public FlixelLogger(FileHandle logFileLocation, FlixelLogMode logMode, Consumer fileLineConsumer) { - this.logFileLocation = logFileLocation; - this.logMode = logMode != null ? logMode : FlixelLogMode.SIMPLE; - this.fileLineConsumer = fileLineConsumer; - this.stackTraceProvider = Flixel.getStackTraceProvider(); - } - - public FileHandle getLogFileLocation() { - return logFileLocation; - } - - public void setLogFileLocation(FileHandle logFileLocation) { - this.logFileLocation = logFileLocation; - } - public FlixelLogMode getLogMode() { return logMode; } + /** + * Sets the log mode used for console output formatting. If {@code null} + * is passed, the mode defaults to {@link FlixelLogMode#SIMPLE}. + * + * @param logMode The desired log mode, or {@code null} to reset to the default simple mode. + */ public void setLogMode(FlixelLogMode logMode) { this.logMode = logMode != null ? logMode : FlixelLogMode.SIMPLE; } - public Consumer getFileLineConsumer() { - return fileLineConsumer; - } - - public void setFileLineConsumer(Consumer fileLineConsumer) { - this.fileLineConsumer = fileLineConsumer; - } - + /** + * Returns the stack trace provider used to determine the caller location + * when logging messages. + * + * @return The current stack trace provider, or {@code null} if none has been set. + */ public FlixelStackTraceProvider getStackTraceProvider() { return stackTraceProvider; } + /** + * Sets the stack trace provider used to resolve the calling class and + * method name for each log message. + * + * @param stackTraceProvider The provider to use for stack trace resolution. + */ public void setStackTraceProvider(FlixelStackTraceProvider stackTraceProvider) { this.stackTraceProvider = stackTraceProvider; } @@ -136,7 +115,7 @@ public void setStackTraceProvider(FlixelStackTraceProvider stackTraceProvider) { * is used: when running in an IDE, the project root's {@code logs} folder; when running from a * JAR, the {@code logs} folder next to the JAR. * - * @param absolutePathToLogsFolder Absolute path to the logs folder, or {@code null} to use the default. + * @param absolutePathToLogsFolder The absolute path to the logs folder, or {@code null} to use the default. */ public void setLogsFolder(String absolutePathToLogsFolder) { this.customLogsFolderPath = (absolutePathToLogsFolder == null || absolutePathToLogsFolder.isEmpty()) @@ -144,137 +123,56 @@ public void setLogsFolder(String absolutePathToLogsFolder) { : absolutePathToLogsFolder.replaceAll("/$", ""); } - /** - * Returns the custom logs folder path set by {@link #setLogsFolder(String)}, or {@code null} if - * the default location is used. - */ public String getLogsFolder() { return customLogsFolderPath; } - /** Returns whether file logging is enabled when {@link #startFileLogging()} is called. */ public boolean canStoreLogs() { return canStoreLogs; } - /** Sets whether to write logs to a file when {@link #startFileLogging()} is called. */ public void setCanStoreLogs(boolean canStoreLogs) { this.canStoreLogs = canStoreLogs; } - /** Returns the maximum number of log files to keep when file logging is enabled. */ public int getMaxLogFiles() { return maxLogFiles; } - /** Sets the maximum number of log files to keep; older files are deleted when exceeded. */ public void setMaxLogFiles(int maxLogFiles) { this.maxLogFiles = maxLogFiles; } /** - * Enqueues a single log line for this logger's file writer thread. Used by this logger's file consumer. - */ - void enqueueLogToFile(String log) { - logQueue.add(log); - synchronized (logQueueLock) { - logQueueLock.notify(); - } - } - - /** - * Starts or configures file logging for this logger. When {@link #canStoreLogs()} is {@code true}, - * creates the logs folder (default or custom), prunes old log files, creates a new timestamped log - * file, and starts a background thread that writes log lines to that file. When {@code canStoreLogs} - * is {@code false}, no writer thread is started and any existing log files in the logs folder are deleted. + * Starts file logging by delegating to the registered + * {@link FlixelLogFileHandler}. If no handler has been registered (for example, on web/TeaVM) or if + * {@link #canStoreLogs()} returns {@code false}, this method is a no-op. + * + *

The handler creates the log folder, prunes old files, opens a new + * timestamped log file, and (on JVM) starts a background writer thread. */ public void startFileLogging() { - String logsFolderPath = (customLogsFolderPath != null) - ? customLogsFolderPath - : FlixelRuntimeUtil.getDefaultLogsFolderPath(); - if (logsFolderPath == null) { + FlixelLogFileHandler handler = Flixel.getLogFileHandler(); + if (handler == null || !canStoreLogs) { return; } - - if (Gdx.files == null) { - return; - } - - FileHandle logsFolder = Gdx.files.absolute(logsFolderPath); - logsFolder.mkdirs(); - - if (canStoreLogs) { - LocalDateTime now = LocalDateTime.now(); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"); - String date = now.format(formatter); - - // Delete old log files if we have more than the maximum number of log files. - FileHandle[] logFiles = logsFolder.list(); - if (logFiles != null && logFiles.length >= maxLogFiles) { - Arrays.sort(logFiles, Comparator.comparing(FileHandle::name)); - int toDelete = logFiles.length - maxLogFiles + 1; - for (int i = 0; i < toDelete; i++) { - logFiles[i].delete(); - } - } - - FileHandle logFile = Gdx.files.absolute(logsFolderPath + "/flixel-" + date + ".log"); - - setLogFileLocation(logFile); - setFileLineConsumer(this::enqueueLogToFile); - - // Start the log writer thread. - logWriterShutdownRequested = false; - final FileHandle logFileForThread = logFile; - logThread = new Thread(() -> { - try { - while (true) { - String log = logQueue.poll(); - if (log != null) { - logFileForThread.writeString(log + "\n", true); - } else { - synchronized (logQueueLock) { - if (logWriterShutdownRequested) { - break; - } - try { - logQueueLock.wait(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - } - } - } catch (Exception ignored) { - // ignore - } - }); - logThread.setName("FlixelGDX Log Thread"); - logThread.setDaemon(true); - logThread.start(); - } + handler.start(customLogsFolderPath, maxLogFiles); } /** - * Stops {@code this} logger's log file writer thread and flushes any remaining log lines. Call this during - * game shutdown (e.g. from {@link me.stringdotjar.flixelgdx.FlixelGame#dispose()}) so that - * logs written during dispose are persisted. + * Stops file logging by delegating to the registered + * {@link FlixelLogFileHandler}. The handler flushes any buffered log + * lines and releases its resources. + * + *

Call this during game shutdown (for example from + * {@link me.stringdotjar.flixelgdx.FlixelGame#dispose()}) so that logs + * written during disposal are persisted. */ public void stopFileLogging() { - synchronized (logQueueLock) { - logWriterShutdownRequested = true; - logQueueLock.notify(); + FlixelLogFileHandler handler = Flixel.getLogFileHandler(); + if (handler != null) { + handler.stop(); } - if (logThread != null && logThread.isAlive()) { - try { - logThread.join(5000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - logThread = null; - } - setLogFileLocation(null); - setFileLineConsumer(null); } /** @@ -294,7 +192,7 @@ public void addLogListener(Consumer listener) { * @param listener The listener to remove. */ public void removeLogListener(Consumer listener) { - logListeners.remove(listener); + logListeners.removeValue(listener, true); } /** @@ -314,42 +212,89 @@ public void addConsoleEntry(FlixelDebugConsoleEntry entry) { * @param entry The entry to remove. */ public void removeConsoleEntry(FlixelDebugConsoleEntry entry) { - consoleEntries.remove(entry); + consoleEntries.removeValue(entry, true); } - /** Returns the list of registered debug console entries. */ - public List getConsoleEntries() { - return consoleEntries; + public FlixelDebugConsoleEntry[] getConsoleEntries() { + return consoleEntries.items; } + /** + * Logs an informational message using the default tag. + * + * @param message The message to log (converted via {@code toString()}). + */ public void info(Object message) { info(defaultTag, message); } + /** + * Logs an informational message under a custom tag. + * + * @param tag The tag to associate with this log entry. + * @param message The message to log (converted via {@code toString()}). + */ public void info(String tag, Object message) { outputLog(tag, evaluateMessage(message), FlixelLogLevel.INFO); } + /** + * Logs a warning message using the default tag. + * + * @param message The message to log (converted via {@code toString()}). + */ public void warn(Object message) { warn(defaultTag, message); } + /** + * Logs a warning message under a custom tag. + * + * @param tag The tag to associate with this log entry. + * @param message The message to log (converted via {@code toString()}). + */ public void warn(String tag, Object message) { outputLog(tag, evaluateMessage(message), FlixelLogLevel.WARN); } + /** + * Logs an error message using the default tag with no throwable. + * + * @param message The message to log (converted via {@code toString()}). + */ public void error(Object message) { error(defaultTag, message, null); } + /** + * Logs an error message using the default tag, including the throwable's + * string representation in the output. + * + * @param message The message to log (converted via {@code toString()}). + * @param throwable The exception to append to the log output. + */ public void error(Object message, Throwable throwable) { error(defaultTag, message, throwable); } + /** + * Logs an error message under a custom tag with no throwable. + * + * @param tag The tag to associate with this log entry. + * @param message The message to log (converted via {@code toString()}). + */ public void error(String tag, Object message) { error(tag, message, null); } + /** + * Logs an error message under a custom tag, optionally including a + * throwable in the output. + * + * @param tag The tag to associate with this log entry. + * @param message The message to log (converted via {@code toString()}). + * @param throwable The exception to append to the log output, or {@code null} if none. + */ public void error(String tag, Object message, Throwable throwable) { String msg = (throwable != null) ? (evaluateMessage(message) + " | Exception: " + throwable) : evaluateMessage(message); outputLog(tag, msg, FlixelLogLevel.ERROR); @@ -422,14 +367,15 @@ protected void outputLog(String tag, Object message, FlixelLogLevel level) { } // File: always detailed (plain, no ANSI). - if (fileLineConsumer != null) { + FlixelLogFileHandler fileHandler = Flixel.getLogFileHandler(); + if (fileHandler != null && fileHandler.isActive()) { String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); String levelTag = "[" + level + "]"; String tagPart = "[" + tag + "]"; String filePart = "[" + file + "]"; String methodPart = "[" + method + "]"; String plainLog = timestamp + " " + levelTag + " " + tagPart + " " + filePart + " " + methodPart + " " + rawMessage; - fileLineConsumer.accept(plainLog); + fileHandler.write(plainLog); } } diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/text/FlixelFontRegistry.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/text/FlixelFontRegistry.java index d6187fe..2b23718 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/text/FlixelFontRegistry.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/text/FlixelFontRegistry.java @@ -208,8 +208,7 @@ public static FreeTypeFontGenerator getDefaultGenerator() { * pixel size. Multiple {@link FlixelText} instances with the same size reuse one font. * * @param pixelSize The target font size in pixels (clamped to at least 1). - * @return A cached bitmap font; do not {@link BitmapFont#dispose()} it — use - * {@link #dispose()} at shutdown. + * @return A cached bitmap font. Do not {@link BitmapFont#dispose()} it, use {@link #dispose()} at shutdown. */ public static BitmapFont obtainDefaultBitmapFont(int pixelSize) { int size = Math.max(1, pixelSize); diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/tween/FlixelTween.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/tween/FlixelTween.java index e6bb0f2..39e85b6 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/tween/FlixelTween.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/tween/FlixelTween.java @@ -210,13 +210,7 @@ public static "Registered builder for " + tweenType.getName() + " is " + registeredBuilderClass.getName() + ", which is not assignable to " + builderType.getName()); } - try { - @SuppressWarnings("unchecked") - B builder = (B) registeredBuilderClass.getDeclaredConstructor().newInstance(); - return builder; - } catch (ReflectiveOperationException e) { - throw new IllegalArgumentException("Could not instantiate builder " + registeredBuilderClass.getName() + ". It must have a no-arg constructor.", e); - } + return globalManager.createBuilder(tweenType); } /** @@ -885,23 +879,26 @@ public FlixelTween setTweenSettings(@NotNull FlixelTweenSettings tweenSettings) } /** - * Registers a tween type with its builder and pool factory on the global manager. Returns the manager so calls can be - * chained when registering several types at startup. + * Registers a tween type with its builder factory and pool factory on the global manager. + * Returns the manager so calls can be chained when registering several types at startup. * * @param tweenClass The tween class to register. - * @param builderClass The builder class to register. - * @param poolFactory The pool factory to use. - * @return The global manager. - * @throws NullPointerException If the pool factory is null. - * - * @see FlixelTweenManager#registerTweenType(Class, Class, Supplier) + * @param builderClass The builder class, used for type verification. + * @param builderFactory A no-arg supplier that creates a fresh builder instance without reflection. + * @param poolFactory A supplier for new tween instances when the pool is empty. + * @param The tween type. + * @return The global manager, for chaining. + * @throws NullPointerException If {@code poolFactory} or {@code builderFactory} is {@code null}. + * @see FlixelTweenManager#registerTweenType(Class, Class, Supplier, Supplier) */ public static FlixelTweenManager registerTweenType( @NotNull Class tweenClass, @NotNull Class> builderClass, + @NotNull Supplier> builderFactory, @NotNull Supplier poolFactory) { + Objects.requireNonNull(builderFactory, "builderFactory"); Objects.requireNonNull(poolFactory, "poolFactory"); - return globalManager.registerTweenType(tweenClass, builderClass, poolFactory); + return globalManager.registerTweenType(tweenClass, builderClass, builderFactory, poolFactory); } /** diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/tween/FlixelTweenManager.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/tween/FlixelTweenManager.java index eadc76a..9047e67 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/tween/FlixelTweenManager.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/tween/FlixelTweenManager.java @@ -35,9 +35,15 @@ public class FlixelTweenManager { /** - * Registry entry for a tween type. Contains its builder class and the pool used for reuse. + * Registry entry for a tween type. Contains the builder class (for type verification), a factory that + * creates new builder instances without reflection, and the pool used for tween reuse. + * + * @param builderClass The builder class, used for assignability checks in {@link FlixelTween#tween(Class, Class)}. + * @param builderFactory A no-arg supplier that creates a new builder instance. + * This avoids reflective {@code newInstance()} calls that fail on platforms without full reflection support (for example TeaVM/web). + * @param pool The object pool for recycling tween instances. */ - public static record TweenTypeRegistration(Class builderClass, Pool pool) {} + public static record TweenTypeRegistration(Class builderClass, Supplier> builderFactory, Pool pool) {} /** Registry: tween type -> (builder class, pool). */ private final Map, TweenTypeRegistration> registry = new HashMap<>(); @@ -46,18 +52,22 @@ public static record TweenTypeRegistration(Class builderClass, Pool activeTweens = new SnapshotArray<>(FlixelTween[]::new); /** - * Registers a tween type with its builder class and a factory for creating new instances when the pool is empty. + * Registers a tween type with its builder factory and a pool factory for creating new tween instances when the pool is empty. * Register all tween types (including custom ones) before using {@link FlixelTween#tween(Class, Class)}. * - * @param tweenClass The tween class (e.g. {@link me.stringdotjar.flixelgdx.tween.type.FlixelPropertyTween}.class). - * @param builderClass The corresponding builder class (e.g. {@link me.stringdotjar.flixelgdx.tween.builders.FlixelPropertyTweenBuilder}.class). - * @param poolFactory Supplies a new tween instance when the pool is empty (used for reset/poolable instances). + * @param tweenClass The tween class (e.g. {@link me.stringdotjar.flixelgdx.tween.type.FlixelPropertyTween}{@code .class}). + * @param builderClass The corresponding builder class, used for type verification when {@link FlixelTween#tween(Class, Class)} is called. + * @param builderFactory A no-arg supplier that creates a fresh builder instance. Using an explicit factory avoids + * reflective {@code newInstance()} calls that fail on platforms without full reflection support (for example TeaVM). + * @param poolFactory A supplier that creates a new tween instance when the pool is empty. * @param The tween type. - * @return this manager, for chaining. + * @return The same manager, for chaining. + * @throws IllegalArgumentException if the tween type is already registered. */ public FlixelTweenManager registerTweenType( Class tweenClass, Class> builderClass, + Supplier> builderFactory, Supplier poolFactory) { Pool pool = new Pool() { @Override @@ -68,7 +78,7 @@ protected FlixelTween newObject() { if (registry.containsKey(tweenClass)) { throw new IllegalArgumentException("Tween type " + tweenClass.getName() + " is already registered."); } - registry.put(tweenClass, new TweenTypeRegistration(builderClass, pool)); + registry.put(tweenClass, new TweenTypeRegistration(builderClass, builderFactory, pool)); return this; } @@ -77,17 +87,33 @@ protected FlixelTween newObject() { * * @param tweenClass The registered tween class to look up. * @return The registered builder class. - * @throws IllegalArgumentException If the registered tween type is not registered. + * @throws IllegalArgumentException if the tween type is not registered. */ public Class getBuilderClass(Class tweenClass) { return getRegistration(tweenClass).builderClass(); } + /** + * Creates a new builder instance for the given tween type using the registered builder factory. + * This avoids reflective {@code getDeclaredConstructor().newInstance()} calls that are incompatible with + * platforms lacking full reflection support (for example TeaVM/web). + * + * @param tweenType The tween class whose builder should be created. + * @param The tween type. + * @param The builder type. + * @return A new builder instance, cast to the requested builder type. + * @throws IllegalArgumentException if the tween type is not registered. + */ + @SuppressWarnings("unchecked") + public > B createBuilder(Class tweenType) { + return (B) getRegistration(tweenType).builderFactory().get(); + } + /** * Adds the tween to this manager and starts it immediately. * * @param tween The tween to add and start. - * @return The same tween for chaining. + * @return The same tween, for chaining. */ public FlixelTween addTween(FlixelTween tween) { if (tween == null) { diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/tween/type/FlixelPropertyTween.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/tween/type/FlixelPropertyTween.java index 7fa7464..c6bd63c 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/tween/type/FlixelPropertyTween.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/tween/type/FlixelPropertyTween.java @@ -14,19 +14,34 @@ import me.stringdotjar.flixelgdx.Flixel; import me.stringdotjar.flixelgdx.backend.reflect.FlixelPropertyPath; +import me.stringdotjar.flixelgdx.functional.supplier.FloatSupplier; import me.stringdotjar.flixelgdx.tween.FlixelTween; import me.stringdotjar.flixelgdx.tween.settings.FlixelTweenSettings; import org.jetbrains.annotations.Nullable; /** - * Tween type for animating values via getter/setter pairs (property goals) rather than - * reflection. Use this when you need setter side effects (e.g. bounds updates, listeners) to - * run on every interpolated step. Configure with {@link FlixelTweenSettings#addGoal}. + * Tween type for animating values via getter/setter pairs (property goals) + * rather than reflection. Use this when you need setter side effects (for + * example, bounds updates or listeners) to run on every interpolated step. + * Configure property goals with + * {@link FlixelTweenSettings#addGoal(FloatSupplier, float, FlixelTweenSettings.FlixelTweenPropertyGoal.FlixelTweenPropertyFloatSetter)}. * - *

This is faster than {@link FlixelVarTween}, which resolves names through {@link me.stringdotjar.flixelgdx.Flixel#reflect} - * each frame. Both can invoke JavaBean setters on every step when configured that way; prefer this type when you can - * close over getter/setter references and avoid reflection. + *

This tween type is faster than {@link FlixelVarTween}, which resolves + * property names through {@link Flixel#reflect} on each frame. Both can + * invoke JavaBean setters on every step when configured that way; prefer this + * type when you can close over getter/setter references and avoid reflection. + * + *

Recommended for Web / TeaVM Targets

+ * + *

This is the recommended tween type for games targeting the web + * (TeaVM) backend. Because it uses explicit getter/setter lambda + * references instead of runtime reflection, it is fully compatible with + * ahead-of-time compilation targets such as TeaVM. If your game targets the + * web, always prefer this type over {@link FlixelVarTween}. + * + * @see FlixelVarTween + * @see FlixelTweenSettings */ public class FlixelPropertyTween extends FlixelTween { @@ -65,7 +80,8 @@ public FlixelPropertyTween(FlixelTweenSettings settings) { * Sets the object {@code this} tween logically animates (required before {@link #start()}). * *

This has to be set because {@link #isTweenOf(Object, String)} needs to know the object to tween. - * This method is purely for logic purposes used by {@link me.stringdotjar.flixelgdx.tween.FlixelTweenManager}, not for tweening purposes. + * This method is purely for logic purposes used by {@link me.stringdotjar.flixelgdx.tween.FlixelTweenManager}, not + * for tweening purposes. * * @param tweenObject The object to tween. * @return {@code this} for chaining. @@ -76,18 +92,37 @@ public FlixelPropertyTween setObject(@Nullable Object tweenObject) { } /** - * Optional logical field name for {@link #isTweenOf(Object, String)} matching. + * Assigns an optional logical field name used by + * {@link #isTweenOf(Object, String)} when checking whether this tween + * animates a particular named property. + * + * @param fieldLabel The field label to associate with this tween, or {@code null} to clear any previously set label. + * @return This tween instance for method chaining. */ public FlixelPropertyTween setFieldLabel(@Nullable String fieldLabel) { this.fieldLabel = fieldLabel; return this; } - public @Nullable Object getTweenObject() { + /** + * Returns the logical target object that this tween animates, or + * {@code null} if no object has been set yet. + * + * @return The tween target object, or {@code null}. + */ + @Nullable + public Object getTweenObject() { return tweenObject; } - public @Nullable String getFieldLabel() { + /** + * Returns the optional logical field label associated with this tween, or + * {@code null} if none has been set. + * + * @return The field label, or {@code null}. + */ + @Nullable + public String getFieldLabel() { return fieldLabel; } @@ -108,18 +143,7 @@ public FlixelTween start() { if (propertyGoals == null || propertyGoals.isEmpty()) { return this; } - - cachedPropertyGoals.clear(); - propertyGoalStartValues.clear(); - for (int i = 0; i < propertyGoals.size; i++) { - var goal = propertyGoals.get(i); - if (goal == null) { - continue; - } - cachedPropertyGoals.add(goal); - propertyGoalStartValues.add(goal.getter().getAsFloat()); - } - + resetGoals(); return this; } @@ -145,16 +169,7 @@ public void restart() { if (!internalRestart && tweenSettings != null) { var propertyGoals = tweenSettings.getPropertyGoals(); if (propertyGoals != null && !propertyGoals.isEmpty()) { - cachedPropertyGoals.clear(); - propertyGoalStartValues.clear(); - for (int i = 0; i < propertyGoals.size; i++) { - var goal = propertyGoals.get(i); - if (goal == null) { - continue; - } - cachedPropertyGoals.add(goal); - propertyGoalStartValues.add(goal.getter().getAsFloat()); - } + resetGoals(); } } super.restart(); @@ -184,4 +199,18 @@ public boolean isTweenOf(Object o, String field) { return Objects.equals(path.leafObject(), tweenObject) && (fieldLabel == null || fieldLabel.equals(path.leafName())); } + + private void resetGoals() { + var propertyGoals = tweenSettings.getPropertyGoals(); + cachedPropertyGoals.clear(); + propertyGoalStartValues.clear(); + for (int i = 0; i < propertyGoals.size; i++) { + var goal = propertyGoals.get(i); + if (goal == null) { + continue; + } + cachedPropertyGoals.add(goal); + propertyGoalStartValues.add(goal.getter().getAsFloat()); + } + } } diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/tween/type/FlixelVarTween.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/tween/type/FlixelVarTween.java index d1858b8..5c99446 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/tween/type/FlixelVarTween.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/tween/type/FlixelVarTween.java @@ -19,19 +19,50 @@ import me.stringdotjar.flixelgdx.tween.settings.FlixelTweenSettings; /** - * Tweens numeric properties on a target object by name using {@link Flixel#reflect}. + * Tweens numeric properties on a target object by name using + * {@link Flixel#reflect}. * - *

At {@link #start()}, each goal value is read once with {@link me.stringdotjar.flixelgdx.backend.reflect.FlixelReflection#property(Object, String)} - * on the resolved leaf object (see {@link me.stringdotjar.flixelgdx.backend.reflect.FlixelReflection#resolvePropertyPath(Object, String)} for dotted - * paths such as {@code "child.x"}). On every update, interpolated values are written with - * {@link me.stringdotjar.flixelgdx.backend.reflect.FlixelReflection#setProperty(Object, String, Object)}, so JavaBean setters run when present and - * behave like normal assignments. + *

At {@link #start()}, each goal value is read once with + * {@link me.stringdotjar.flixelgdx.backend.reflect.FlixelReflection#property(Object, String)} + * on the resolved leaf object (see + * {@link me.stringdotjar.flixelgdx.backend.reflect.FlixelReflection#resolvePropertyPath(Object, String)} + * for dotted paths such as {@code "child.x"}). On every update, interpolated values are written with + * {@link me.stringdotjar.flixelgdx.backend.reflect.FlixelReflection#setProperty(Object, String, Object)}, + * so JavaBean setters run when present and behave like normal assignments. * *

Goals must resolve to a {@link Number} when read. Configure goals with {@link FlixelTweenSettings#addGoal(String, float)}. Install a - * {@link me.stringdotjar.flixelgdx.backend.reflect.FlixelReflection} implementation via {@link Flixel#setReflection} before use; the default - * placeholder throws until then. + * {@link me.stringdotjar.flixelgdx.backend.reflect.FlixelReflection} implementation via {@link Flixel#setReflection} before use; the default placeholder throws until then. * - *

This is slightly slower than {@link FlixelPropertyTween}, which avoids reflection by closing over getter/setter references. + *

This tween type is slightly slower than {@link FlixelPropertyTween}, which avoids reflection by closing over getter/setter references. + * + *

Web / TeaVM Compatibility Warning

+ * + *

This tween type is not recommended for games targeting the web + * (TeaVM) backend. It depends on runtime reflection to read and write + * property values every frame. TeaVM compiles Java to JavaScript ahead of time + * and supports reflection only through pre-generated metadata, which adds + * overhead and can cause unexpected failures when metadata is incomplete. + * + *

If your game targets the web, prefer {@link FlixelPropertyTween} instead. + * {@code FlixelPropertyTween} uses explicit getter/setter lambda references + * and does not rely on reflection at runtime, making it fully compatible with + * TeaVM and other ahead-of-time compilation targets. + * + *

To migrate, replace: + *

{@code
+ * // VarTween (reflection-based, NOT recommended for web):
+ * FlixelTween.tween(sprite, new FlixelTweenSettings()
+ *     .addGoal("x", 100f)
+ *     .setDuration(1f));
+ *
+ * // PropertyTween (lambda-based, recommended for web):
+ * FlixelTween.tween(sprite, new FlixelTweenSettings()
+ *     .addGoal(sprite::getX, 100f, sprite::setX)
+ *     .setDuration(1f));
+ * }
+ * + * @see FlixelPropertyTween + * @see FlixelTweenSettings#addGoal(String, float) */ public class FlixelVarTween extends FlixelTween { @@ -47,15 +78,25 @@ public class FlixelVarTween extends FlixelTween { /** Goal key -> leaf target and property name after resolving dotted paths. */ protected final ObjectMap goalPaths = new ObjectMap<>(); + /** + * Constructs a new var tween that will animate the given object's + * properties using reflection. Goals must be added via + * {@link FlixelTweenSettings#addGoal(String, float)} before starting. + * + * @param object The target object whose properties will be tweened. + * @param settings The settings that configure how the tween animates. + */ public FlixelVarTween(Object object, FlixelTweenSettings settings) { super(settings); this.object = object; } /** - * Sets the root target for path resolution. Call before {@link #start()} when reusing a pooled tween. + * Sets the root target for property path resolution. Call this before + * {@link #start()} when reusing a pooled tween whose target has changed. * - * @return this, for chaining. + * @param object The target object whose properties will be tweened. + * @return {@code this} tween instance for method chaining. */ public FlixelVarTween setObject(Object object) { this.object = object; diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/FlixelConstants.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/FlixelConstants.java index 30aa7d2..12719c7 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/FlixelConstants.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/FlixelConstants.java @@ -39,8 +39,7 @@ private Graphics() {} } /** - * Direction flags used for collision detection and touch sensing, matching - * FlxDirectionFlags. + * Direction flags used for collision detection and touch sensing. * The same bit patterns are reused for facing directions in {@link Graphics}. */ public static final class Physics { @@ -59,15 +58,6 @@ public static final class Physics { /** Maximum number of pixels two objects can intersect before separation gives up. */ public static final float SEPARATE_BIAS = 4f; - /** Default pixels-per-meter ratio used by Box2D helpers. */ - public static final float PIXELS_PER_METER = 100f; - - /** Box2D velocity solver iterations per step. */ - public static final int VELOCITY_ITERATIONS = 6; - - /** Box2D position solver iterations per step. */ - public static final int POSITION_ITERATIONS = 2; - private Physics() {} } diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/FlixelDebugUtil.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/FlixelDebugUtil.java index 253b2b5..468da90 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/FlixelDebugUtil.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/FlixelDebugUtil.java @@ -12,7 +12,6 @@ import me.stringdotjar.flixelgdx.Flixel; import me.stringdotjar.flixelgdx.FlixelBasic; import me.stringdotjar.flixelgdx.FlixelState; -import me.stringdotjar.flixelgdx.box2d.FlixelBox2DObject; import me.stringdotjar.flixelgdx.debug.FlixelDebugDrawable; import me.stringdotjar.flixelgdx.group.FlixelGroupable; @@ -22,8 +21,7 @@ /** * Utility methods used by the debug overlay for recursively traversing the state's object - * tree (counting active members, iterating {@link FlixelDebugDrawable} instances for - * bounding-box drawing, syncing Box2D objects, etc.). + * tree (counting active members, iterating {@link FlixelDebugDrawable} instances for bounding-box drawing, etc.). * *

Recursion descends into any member that implements {@link FlixelGroupable}, which * covers {@link me.stringdotjar.flixelgdx.group.FlixelBasicGroup}, {@link me.stringdotjar.flixelgdx.group.FlixelSpriteGroup}, @@ -105,39 +103,4 @@ private static void forEachDebugDrawableRecursive(@NotNull SnapshotArray memb members.end(); } - /** - * Iterates all active {@link FlixelBox2DObject} instances in the current state's - * object tree (where the underlying {@link FlixelBasic#exists} is {@code true}), - * invoking the callback for each one that has a non-null body. - * - * @param callback Invoked once per active {@link FlixelBox2DObject} with a body. - */ - public static void forEachBox2DObject(Consumer callback) { - FlixelState state = Flixel.getState(); - if (state == null) { - return; - } - forEachBox2DObjectRecursive(state.getMembers(), callback); - } - - private static void forEachBox2DObjectRecursive(@NotNull SnapshotArray members, - @NotNull Consumer callback) { - Object[] items = members.begin(); - for (int i = 0, n = members.size; i < n; i++) { - Object o = items[i]; - if (!(o instanceof FlixelBasic member)) { - continue; - } - if (member instanceof FlixelBox2DObject box2d && member.exists && box2d.getBody() != null) { - callback.accept(box2d); - } - if (member instanceof FlixelGroupable group) { - SnapshotArray nested = group.getMembers(); - if (nested != null) { - forEachBox2DObjectRecursive(nested, callback); - } - } - } - members.end(); - } } diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/FlixelPathsUtil.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/FlixelPathsUtil.java index 787fbf9..9272ee9 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/FlixelPathsUtil.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/FlixelPathsUtil.java @@ -43,17 +43,18 @@ public static FileHandle external(String path) { } /** - * Resolves an internal asset path to an absolute filesystem path that MiniAudio's native engine - * can open directly. + * Resolves an internal asset path to an absolute filesystem path that the + * native audio backend can open directly. * - *

When running from the IDE the working directory is the {@code assets/} folder, so the raw - * relative path works as-is. When running from a packaged JAR the assets are embedded as - * classpath resources and MiniAudio cannot open them by name. In that case the resource is - * extracted to a temp file on first call, and the temp file's absolute path is returned. Results - * are cached so repeated calls for the same path do not produce extra temp files. + *

When running from the IDE the working directory is the {@code assets/} + * folder, so the raw relative path works as-is. When running from a packaged + * JAR the assets are embedded as classpath resources and native engines cannot + * open them by name. In that case the resource is extracted to a temp file on + * first call, and the temp file's absolute path is returned. Results are + * cached so repeated calls for the same path do not produce extra temp files. * * @param path The internal asset path, e.g. {@code "shared/sounds/foo.ogg"}. - * @return An absolute filesystem path that MiniAudio can open. + * @return An absolute filesystem path that the audio backend can open. * @see me.stringdotjar.flixelgdx.asset.FlixelAssetManager#resolveAudioPath(String) */ public static String resolveAudioPath(String path) { diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/FlixelRuntimeUtil.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/FlixelRuntimeUtil.java index bd63215..4c4dff1 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/FlixelRuntimeUtil.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/FlixelRuntimeUtil.java @@ -191,10 +191,17 @@ public static String getDefaultLogsFolderPath() { * Returns the root package name of the library. This is done just in case * (for whatever reason it may be) the root package changes. * + *

The package is derived from the fully qualified class name rather than + * {@code Class.getPackageName()}, which is not available on TeaVM. + * * @return The root package name of the library. */ public static String getLibraryRoot() { - return FlixelRuntimeUtil.class.getPackageName().replaceAll("\\.[^.]+$", ""); + String className = FlixelRuntimeUtil.class.getName(); + int lastDot = className.lastIndexOf('.'); + String packageName = (lastDot > 0) ? className.substring(0, lastDot) : ""; + int rootEnd = packageName.lastIndexOf('.'); + return (rootEnd > 0) ? packageName.substring(0, rootEnd) : packageName; } /** diff --git a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/timer/FlixelTimerManager.java b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/timer/FlixelTimerManager.java index c2923e2..17224f1 100644 --- a/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/timer/FlixelTimerManager.java +++ b/flixelgdx-core/src/main/java/me/stringdotjar/flixelgdx/util/timer/FlixelTimerManager.java @@ -32,7 +32,7 @@ public class FlixelTimerManager extends FlixelBasic { /** Timers currently stepped by {@link #update(float)}. Named {@code activeTimers} to avoid clashing with {@link #active}. */ protected final Array activeTimers = new Array<>(false, 32); - protected final Pool pool = new Pool(32) { + protected final Pool pool = new Pool<>(32) { @Override protected FlixelTimer newObject() { return new FlixelTimer(); diff --git a/flixelgdx-core/src/main/java/module-info.java b/flixelgdx-core/src/main/java/module-info.java index 7e2d589..eff5d3d 100644 --- a/flixelgdx-core/src/main/java/module-info.java +++ b/flixelgdx-core/src/main/java/module-info.java @@ -6,7 +6,6 @@ exports me.stringdotjar.flixelgdx.backend.alert; exports me.stringdotjar.flixelgdx.backend.runtime; exports me.stringdotjar.flixelgdx.backend.reflect; - exports me.stringdotjar.flixelgdx.box2d; exports me.stringdotjar.flixelgdx.debug; exports me.stringdotjar.flixelgdx.functional.supplier; exports me.stringdotjar.flixelgdx.graphics; @@ -29,11 +28,8 @@ // Automatic module names (from JAR filenames when on the module path). requires transitive gdx; - requires transitive gdx.box2d; requires transitive gdx.freetype; requires transitive anim8.gdx; requires transitive libgdx.utils; - requires transitive miniaudio; - requires org.fusesource.jansi; requires org.jetbrains.annotations; } diff --git a/flixelgdx-ios/build.gradle b/flixelgdx-ios/build.gradle index 2f37a0c..e018af9 100644 --- a/flixelgdx-ios/build.gradle +++ b/flixelgdx-ios/build.gradle @@ -16,7 +16,6 @@ ext { dependencies { api project(':flixelgdx-core') api "com.badlogicgames.gdx:gdx-backend-robovm:$gdxVersion" - api "com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-ios" api "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-ios" api "com.mobidevelop.robovm:robovm-rt:$roboVMVersion" api "com.mobidevelop.robovm:robovm-cocoatouch:$roboVMVersion" diff --git a/flixelgdx-ios/src/main/java/me/stringdotjar/flixelgdx/backend/ios/FlixelIOSLauncher.java b/flixelgdx-ios/src/main/java/me/stringdotjar/flixelgdx/backend/ios/FlixelIOSLauncher.java index 363c0f6..40c482e 100644 --- a/flixelgdx-ios/src/main/java/me/stringdotjar/flixelgdx/backend/ios/FlixelIOSLauncher.java +++ b/flixelgdx-ios/src/main/java/me/stringdotjar/flixelgdx/backend/ios/FlixelIOSLauncher.java @@ -12,7 +12,9 @@ import me.stringdotjar.flixelgdx.Flixel; import me.stringdotjar.flixelgdx.FlixelGame; import me.stringdotjar.flixelgdx.backend.ios.alert.FlixelIOSAlerter; +import me.stringdotjar.flixelgdx.backend.jvm.audio.FlixelMiniAudioSoundHandler; import me.stringdotjar.flixelgdx.backend.jvm.logging.FlixelDefaultStackTraceProvider; +import me.stringdotjar.flixelgdx.backend.jvm.logging.FlixelJvmLogFileHandler; import me.stringdotjar.flixelgdx.backend.reflect.FlixelDefaultReflectionHandler; import me.stringdotjar.flixelgdx.backend.runtime.FlixelRuntimeMode; @@ -67,6 +69,8 @@ public static IOSApplication launch(FlixelGame game, FlixelRuntimeMode runtimeMo Flixel.setAlerter(new FlixelIOSAlerter()); Flixel.setStackTraceProvider(new FlixelDefaultStackTraceProvider()); Flixel.setReflection(new FlixelDefaultReflectionHandler()); + Flixel.setLogFileHandler(new FlixelJvmLogFileHandler()); + Flixel.setSoundBackendFactory(new FlixelMiniAudioSoundHandler()); Flixel.setRuntimeMode(runtimeMode); Flixel.setDebugMode(runtimeMode == FlixelRuntimeMode.DEBUG); Flixel.initialize(game); diff --git a/flixelgdx-jvm/build.gradle b/flixelgdx-jvm/build.gradle index bde2aed..4a887ff 100644 --- a/flixelgdx-jvm/build.gradle +++ b/flixelgdx-jvm/build.gradle @@ -7,4 +7,6 @@ eclipse.project.name = appName + '-jvm' dependencies { api project(':flixelgdx-core') + api "games.rednblack.miniaudio:miniaudio:$miniaudioVersion" + implementation 'org.jetbrains:annotations:15.0' } diff --git a/flixelgdx-jvm/src/main/java/me/stringdotjar/flixelgdx/backend/jvm/audio/FlixelMiniAudioSound.java b/flixelgdx-jvm/src/main/java/me/stringdotjar/flixelgdx/backend/jvm/audio/FlixelMiniAudioSound.java new file mode 100644 index 0000000..607affd --- /dev/null +++ b/flixelgdx-jvm/src/main/java/me/stringdotjar/flixelgdx/backend/jvm/audio/FlixelMiniAudioSound.java @@ -0,0 +1,109 @@ +/********************************************************************************** + * Copyright (c) 2025-2026 stringdotjar + * + * This file is part of the FlixelGDX framework, licensed under the MIT License. + * See the LICENSE file in the repository root for full license information. + **********************************************************************************/ + +package me.stringdotjar.flixelgdx.backend.jvm.audio; + +import games.rednblack.miniaudio.MASound; +import me.stringdotjar.flixelgdx.audio.FlixelSoundBackend; + +/** + * JVM implementation of {@link FlixelSoundBackend} that wraps a single + * MiniAudio {@link MASound}. + */ +final class FlixelMiniAudioSound implements FlixelSoundBackend { + + private final MASound sound; + + FlixelMiniAudioSound(MASound sound) { + this.sound = sound; + } + + /** Returns the underlying {@link MASound} for advanced engine operations. */ + MASound getMASound() { + return sound; + } + + @Override + public void play() { + sound.play(); + } + + @Override + public void pause() { + sound.pause(); + } + + @Override + public void stop() { + sound.stop(); + } + + @Override + public boolean isPlaying() { + return sound.isPlaying(); + } + + @Override + public boolean isEnd() { + return sound.isEnd(); + } + + @Override + public float getVolume() { + return sound.getVolume(); + } + + @Override + public void setVolume(float volume) { + sound.setVolume(volume); + } + + @Override + public void setPitch(float pitch) { + sound.setPitch(pitch); + } + + @Override + public void setPan(float pan) { + sound.setPan(pan); + } + + @Override + public float getCursorPosition() { + return sound.getCursorPosition(); + } + + @Override + public void seekTo(float seconds) { + sound.seekTo(seconds); + } + + @Override + public float getLength() { + return sound.getLength(); + } + + @Override + public boolean isLooping() { + return sound.isLooping(); + } + + @Override + public void setLooping(boolean looping) { + sound.setLooping(looping); + } + + @Override + public void setPosition(float x, float y, float z) { + sound.setPosition(x, y, z); + } + + @Override + public void dispose() { + sound.dispose(); + } +} diff --git a/flixelgdx-jvm/src/main/java/me/stringdotjar/flixelgdx/backend/jvm/audio/FlixelMiniAudioSoundHandler.java b/flixelgdx-jvm/src/main/java/me/stringdotjar/flixelgdx/backend/jvm/audio/FlixelMiniAudioSoundHandler.java new file mode 100644 index 0000000..45fbc99 --- /dev/null +++ b/flixelgdx-jvm/src/main/java/me/stringdotjar/flixelgdx/backend/jvm/audio/FlixelMiniAudioSoundHandler.java @@ -0,0 +1,144 @@ +/********************************************************************************** + * Copyright (c) 2025-2026 stringdotjar + * + * This file is part of the FlixelGDX framework, licensed under the MIT License. + * See the LICENSE file in the repository root for full license information. + **********************************************************************************/ + +package me.stringdotjar.flixelgdx.backend.jvm.audio; + +import games.rednblack.miniaudio.MAGroup; +import games.rednblack.miniaudio.MANode; +import games.rednblack.miniaudio.MASound; +import games.rednblack.miniaudio.MiniAudio; +import games.rednblack.miniaudio.effect.MADelayNode; +import games.rednblack.miniaudio.effect.MAReverbNode; +import games.rednblack.miniaudio.filter.MALowPassFilter; +import me.stringdotjar.flixelgdx.audio.FlixelSoundBackend; + +/** + * JVM implementation of {@link FlixelSoundBackend.Factory} backed by the + * MiniAudio native library. + * + *

This factory owns a single {@link MiniAudio} engine instance that is + * created in the constructor and disposed when {@link #disposeEngine()} is + * called. All sounds and groups are created through the engine. + */ +public class FlixelMiniAudioSoundHandler implements FlixelSoundBackend.Factory { + + private final MiniAudio engine; + + /** Creates the handler and initialises the MiniAudio engine. */ + public FlixelMiniAudioSoundHandler() { + engine = new MiniAudio(); + } + + /** Returns the underlying MiniAudio engine for advanced or asset-loader use. */ + public MiniAudio getEngine() { + return engine; + } + + @Override + public FlixelSoundBackend createSound(String path, short flags, Object group, boolean external) { + MAGroup maGroup = (group instanceof MAGroup g) ? g : null; + MASound ma = engine.createSound(path, flags, maGroup, external); + return new FlixelMiniAudioSound(ma); + } + + @Override + public Object createGroup() { + return engine.createGroup(); + } + + @Override + public void disposeGroup(Object group) { + if (group instanceof MAGroup g) { + g.dispose(); + } + } + + @Override + public void groupPause(Object group) { + if (group instanceof MAGroup g) { + g.pause(); + } + } + + @Override + public void groupPlay(Object group) { + if (group instanceof MAGroup g) { + g.play(); + } + } + + @Override + public void setMasterVolume(float volume) { + engine.setMasterVolume(volume); + } + + @Override + public void disposeEngine() { + engine.dispose(); + } + + @Override + public void attachToEngineOutput(FlixelSoundBackend sound, int outputBusIndex) { + if (sound instanceof FlixelMiniAudioSound mas) { + engine.attachToEngineOutput(mas.getMASound(), outputBusIndex); + } + } + + @Override + public FlixelSoundBackend.EffectNode createReverbNode(float wet) { + MAReverbNode rev = new MAReverbNode(engine); + float w = Math.max(0f, Math.min(1f, wet)); + rev.setWet(w); + rev.setDry(1f - w); + return new MiniAudioEffectNode(rev); + } + + @Override + public FlixelSoundBackend.EffectNode createDelayNode(float delaySeconds, float decay) { + MADelayNode node = new MADelayNode(engine, delaySeconds, decay); + return new MiniAudioEffectNode(node); + } + + @Override + public FlixelSoundBackend.EffectNode createLowPassFilter(double cutoffHz, int order) { + MALowPassFilter lp = new MALowPassFilter(engine, cutoffHz, order); + return new MiniAudioEffectNode(lp); + } + + /** + * Wraps a MiniAudio {@link MANode} as a {@link FlixelSoundBackend.EffectNode}. + */ + private static final class MiniAudioEffectNode implements FlixelSoundBackend.EffectNode { + + private final MANode node; + + MiniAudioEffectNode(MANode node) { + this.node = node; + } + + @Override + public void attachToUpstream(FlixelSoundBackend upstream, int bus) { + MANode upstreamNode; + if (upstream instanceof FlixelMiniAudioSound mas) { + upstreamNode = mas.getMASound(); + } else { + return; + } + node.attachToThisNode(upstreamNode, bus); + } + + @Override + public void detach(int bus) { + node.detach(bus); + } + + @Override + public void dispose() { + node.dispose(); + } + } +} diff --git a/flixelgdx-jvm/src/main/java/me/stringdotjar/flixelgdx/backend/jvm/logging/FlixelJvmLogFileHandler.java b/flixelgdx-jvm/src/main/java/me/stringdotjar/flixelgdx/backend/jvm/logging/FlixelJvmLogFileHandler.java new file mode 100644 index 0000000..99ea733 --- /dev/null +++ b/flixelgdx-jvm/src/main/java/me/stringdotjar/flixelgdx/backend/jvm/logging/FlixelJvmLogFileHandler.java @@ -0,0 +1,163 @@ +/********************************************************************************** + * Copyright (c) 2025-2026 stringdotjar + * + * This file is part of the FlixelGDX framework, licensed under the MIT License. + * See the LICENSE file in the repository root for full license information. + **********************************************************************************/ + +package me.stringdotjar.flixelgdx.backend.jvm.logging; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.files.FileHandle; + +import me.stringdotjar.flixelgdx.logging.FlixelLogFileHandler; +import me.stringdotjar.flixelgdx.util.FlixelRuntimeUtil; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Comparator; +import java.util.concurrent.ConcurrentLinkedQueue; + +/** + * JVM implementation of {@link FlixelLogFileHandler} that writes log lines to a + * timestamped {@code .log} file on a dedicated daemon thread. + * + *

Log lines are enqueued via {@link #write(String)} and drained by the + * background thread, keeping game-thread latency to a minimum. On + * {@link #stop()}, the thread is given up to five seconds to flush remaining + * lines before it is interrupted. + * + *

This handler is intended for desktop and other JVM-based backends (LWJGL3, + * Android, iOS). It should not be used on TeaVM/web, where + * threading and host-filesystem access are unavailable. + * + * @see FlixelLogFileHandler + */ +public class FlixelJvmLogFileHandler implements FlixelLogFileHandler { + + private static final DateTimeFormatter FILE_DATE_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"); + + private final ConcurrentLinkedQueue logQueue = new ConcurrentLinkedQueue<>(); + private final Object queueLock = new Object(); + private volatile boolean shutdownRequested = false; + private volatile boolean active = false; + private Thread writerThread; + + @Override + public void start(@Nullable String logsFolderPath, int maxLogFiles) { + if (active) { + return; + } + + String resolvedPath = (logsFolderPath != null) ? logsFolderPath : getDefaultLogsFolderPath(); + if (resolvedPath == null || Gdx.files == null) { + return; + } + + FileHandle logsFolder = Gdx.files.absolute(resolvedPath); + logsFolder.mkdirs(); + + pruneOldLogFiles(logsFolder, maxLogFiles); + + String timestamp = LocalDateTime.now().format(FILE_DATE_FORMAT); + FileHandle logFile = Gdx.files.absolute(resolvedPath + "/flixel-" + timestamp + ".log"); + + shutdownRequested = false; + active = true; + + writerThread = new Thread(() -> { + try { + while (true) { + String line = logQueue.poll(); + if (line != null) { + logFile.writeString(line + "\n", true); + } else { + synchronized (queueLock) { + if (shutdownRequested) { + break; + } + try { + queueLock.wait(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + } + } catch (Exception ignored) { + // Silently stop if the file becomes inaccessible. + } + }); + writerThread.setName("FlixelGDX Log Thread"); + writerThread.setDaemon(true); + writerThread.start(); + } + + @Override + public void stop() { + if (!active) { + return; + } + active = false; + + synchronized (queueLock) { + shutdownRequested = true; + queueLock.notify(); + } + + if (writerThread != null && writerThread.isAlive()) { + try { + writerThread.join(5000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + writerThread = null; + } + } + + @Override + public void write(@NotNull String logLine) { + if (!active || logLine == null) { + return; + } + logQueue.add(logLine); + synchronized (queueLock) { + queueLock.notify(); + } + } + + @Override + public boolean isActive() { + return active; + } + + @Override + @Nullable + public String getDefaultLogsFolderPath() { + return FlixelRuntimeUtil.getDefaultLogsFolderPath(); + } + + /** + * Deletes the oldest log files when the folder already contains at least + * {@code maxLogFiles} entries, making room for the new file. + * + * @param logsFolder The directory containing log files. + * @param maxLogFiles The maximum number of files to retain. + */ + private static void pruneOldLogFiles(FileHandle logsFolder, int maxLogFiles) { + FileHandle[] existing = logsFolder.list(); + if (existing == null || existing.length < maxLogFiles) { + return; + } + Arrays.sort(existing, Comparator.comparing(FileHandle::name)); + int toDelete = existing.length - maxLogFiles + 1; + for (int i = 0; i < toDelete; i++) { + existing[i].delete(); + } + } +} diff --git a/flixelgdx-lwjgl3/build.gradle b/flixelgdx-lwjgl3/build.gradle index 18d65e5..5d276fa 100644 --- a/flixelgdx-lwjgl3/build.gradle +++ b/flixelgdx-lwjgl3/build.gradle @@ -12,8 +12,8 @@ dependencies { api project(':flixelgdx-core') api "games.rednblack.miniaudio:gdx-miniaudio-platform:$miniaudioVersion:natives-desktop" api "com.badlogicgames.gdx:gdx-backend-lwjgl3:$gdxVersion" - api "com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-desktop" api "com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-desktop" api "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop" api "com.esotericsoftware:reflectasm:$reflectasmVersion" + implementation "org.fusesource.jansi:jansi:$jansiVersion" } diff --git a/flixelgdx-lwjgl3/src/main/java/me/stringdotjar/flixelgdx/backend/lwjgl3/FlixelLwjgl3Launcher.java b/flixelgdx-lwjgl3/src/main/java/me/stringdotjar/flixelgdx/backend/lwjgl3/FlixelLwjgl3Launcher.java index dccdc8e..6bce4f3 100644 --- a/flixelgdx-lwjgl3/src/main/java/me/stringdotjar/flixelgdx/backend/lwjgl3/FlixelLwjgl3Launcher.java +++ b/flixelgdx-lwjgl3/src/main/java/me/stringdotjar/flixelgdx/backend/lwjgl3/FlixelLwjgl3Launcher.java @@ -15,11 +15,15 @@ import com.badlogic.gdx.backends.lwjgl3.Lwjgl3WindowAdapter; import me.stringdotjar.flixelgdx.Flixel; import me.stringdotjar.flixelgdx.FlixelGame; +import me.stringdotjar.flixelgdx.backend.jvm.audio.FlixelMiniAudioSoundHandler; +import me.stringdotjar.flixelgdx.backend.jvm.logging.FlixelDefaultStackTraceProvider; +import me.stringdotjar.flixelgdx.backend.jvm.logging.FlixelJvmLogFileHandler; import me.stringdotjar.flixelgdx.backend.lwjgl3.alert.FlixelLwjgl3Alerter; import me.stringdotjar.flixelgdx.backend.lwjgl3.runtime.reflect.FlixelReflectASMHandler; import me.stringdotjar.flixelgdx.backend.runtime.FlixelRuntimeMode; +import me.stringdotjar.flixelgdx.util.FlixelRuntimeUtil; -import me.stringdotjar.flixelgdx.backend.jvm.logging.FlixelDefaultStackTraceProvider; +import org.fusesource.jansi.AnsiConsole; /** * Launches the desktop (LWJGL3) version of the Flixel game. @@ -59,6 +63,7 @@ public static void launch(FlixelGame game, String... icons) { * @param icons Window icon paths. Make sure your icons actually exist and are valid! */ public static void launch(FlixelGame game, FlixelRuntimeMode runtimeMode, String... icons) { + Objects.requireNonNull(game, "The game object provided cannot be null!"); Lwjgl3ApplicationConfiguration configuration = new Lwjgl3ApplicationConfiguration(); configuration.setTitle(game.getTitle()); configuration.useVsync(game.isVsync()); @@ -78,17 +83,11 @@ public static void launch(FlixelGame game, FlixelRuntimeMode runtimeMode, String @Override public void focusGained() { super.focusGained(); - if (Flixel.getGame() == null) { - return; - } Flixel.getGame().onWindowFocused(); } @Override public void focusLost() { - if (Flixel.getGame() == null) { - return; - } if (!Flixel.getGame().isMinimized()) { super.focusLost(); Flixel.getGame().onWindowUnfocused(); @@ -98,9 +97,6 @@ public void focusLost() { @Override public void iconified(boolean isIconified) { super.iconified(isIconified); - if (Flixel.getGame() == null) { - return; - } Flixel.getGame().onWindowMinimized(isIconified); } }); @@ -120,13 +116,23 @@ public void iconified(boolean isIconified) { * @param configuration The {@link Lwjgl3ApplicationConfiguration} to use. */ public static void launch(FlixelGame game, FlixelRuntimeMode runtimeMode, Lwjgl3ApplicationConfiguration configuration) { + if (FlixelRuntimeUtil.isRunningFromJar() && !AnsiConsole.isInstalled()) { + AnsiConsole.systemInstall(); + } + Flixel.setAlerter(new FlixelLwjgl3Alerter()); Flixel.setStackTraceProvider(new FlixelDefaultStackTraceProvider()); Flixel.setReflection(new FlixelReflectASMHandler()); + Flixel.setLogFileHandler(new FlixelJvmLogFileHandler()); + Flixel.setSoundBackendFactory(new FlixelMiniAudioSoundHandler()); Flixel.setRuntimeMode(runtimeMode); Flixel.setDebugMode(runtimeMode == FlixelRuntimeMode.DEBUG); Flixel.initialize(game); new Lwjgl3Application(game, configuration); + + if (AnsiConsole.isInstalled()) { + AnsiConsole.systemUninstall(); + } } } diff --git a/flixelgdx-teavm-plugin/build.gradle b/flixelgdx-teavm-plugin/build.gradle new file mode 100644 index 0000000..cfbb640 --- /dev/null +++ b/flixelgdx-teavm-plugin/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'java-gradle-plugin' +} + +[compileJava, compileTestJava]*.options*.encoding = 'UTF-8' +eclipse.project.name = appName + '-teavm-plugin' + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +gradlePlugin { + plugins { + flixelTeavm { + id = 'me.stringdotjar.flixelgdx.teavm' + implementationClass = 'me.stringdotjar.flixelgdx.gradle.teavm.FlixelTeaVMPlugin' + displayName = 'FlixelGDX TeaVM Plugin' + description = 'Automates web asset copying, index.html generation, and task wiring for FlixelGDX TeaVM web builds.' + } + } +} diff --git a/flixelgdx-teavm-plugin/src/main/java/me/stringdotjar/flixelgdx/gradle/teavm/FlixelTeaVMExtension.java b/flixelgdx-teavm-plugin/src/main/java/me/stringdotjar/flixelgdx/gradle/teavm/FlixelTeaVMExtension.java new file mode 100644 index 0000000..e9b6377 --- /dev/null +++ b/flixelgdx-teavm-plugin/src/main/java/me/stringdotjar/flixelgdx/gradle/teavm/FlixelTeaVMExtension.java @@ -0,0 +1,98 @@ +/********************************************************************************* + * Copyright (c) 2025-2026 stringdotjar + * + * This file is part of the FlixelGDX framework, licensed under the MIT License. + * See the LICENSE file in the repository root for full license information. + *********************************************************************************/ + +package me.stringdotjar.flixelgdx.gradle.teavm; + +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.Property; + +/** + * Configuration extension exposed as the {@code flixelgdx} DSL block in a web module's + * {@code build.gradle}. + * + *

All properties have sensible defaults and are optional. The only required configuration is + * {@code teavm.all.mainClass} in the {@code org.teavm} plugin block (see + * {@link FlixelTeaVMPlugin} for usage). + * + *

Example

+ * + *
{@code
+ * flixelgdx {
+ *   // Optional: override the canvas element ID (default: "flixelgdx-canvas").
+ *   canvasId = 'my-game-canvas'
+ *
+ *   // Optional: change where the web app is assembled (default: "$buildDir/dist/webapp").
+ *   // Must match teavm.js.outputDir.
+ *   outputDir = file("$buildDir/dist/webapp")
+ * }
+ * }
+ */ +public abstract class FlixelTeaVMExtension { + + /** Gradle extension name used to register this extension under. */ + public static final String NAME = "flixelgdx"; + + /** Default HTML canvas element ID expected by {@code FlixelTeaVMLauncher}. */ + public static final String DEFAULT_CANVAS_ID = "flixelgdx-canvas"; + + /** + * ID of the HTML {@code } element that the game renders into. + * + *

Must match the {@code canvasID} field of {@code WebApplicationConfiguration} passed to + * {@code FlixelTeaVMLauncher.launch()}. Defaults to {@value #DEFAULT_CANVAS_ID}. + * + * @return the canvas element ID property. + */ + public abstract Property getCanvasId(); + + /** + * Directory into which the assembled web application is written. + * + *

This must match the value of {@code teavm.js.outputDir} in the {@code org.teavm} plugin + * block so that copied assets, web resources, and the generated {@code index.html} are placed + * alongside the compiled {@code teavm.js} file. Defaults to + * {@code "$buildDir/dist/webapp"}. + * + * @return the output directory property. + */ + public abstract DirectoryProperty getOutputDir(); + + /** + * Directory that contains user-provided web resources such as a custom {@code index.html}, + * favicon, or additional scripts. + * + *

All files found here are copied verbatim into {@link #getOutputDir()}. If this directory + * contains an {@code index.html}, the plugin skips automatic index generation. Defaults to + * {@code src/main/webapp} relative to the web module. + * + * @return the webapp source directory property. + */ + public abstract DirectoryProperty getWebappDir(); + + /** + * Directory whose contents are copied to {@code /assets/} before each build. + * + *

Defaults to the {@code assets/} directory at the root of the Gradle project (i.e. the + * sibling of the core, desktop, and teavm modules). + * + * @return the assets source directory property. + */ + public abstract DirectoryProperty getAssetsDir(); + + /** + * Whether the plugin should generate a default {@code index.html} when none is found in + * {@link #getWebappDir()}. + * + *

The generated page includes a {@code } with the ID from {@link #getCanvasId()} and + * a {@code + + diff --git a/flixelgdx-teavm/build.gradle b/flixelgdx-teavm/build.gradle index 710c17e..08cc150 100644 --- a/flixelgdx-teavm/build.gradle +++ b/flixelgdx-teavm/build.gradle @@ -111,6 +111,8 @@ processResources { } dependencies { - api project(':flixelgdx-core') - api "com.github.xpenatan.gdx-teavm:backend-web:$gdxTeaVMVersion" + api project(':flixelgdx-core') + api "com.github.xpenatan.gdx-teavm:backend-web:$gdxTeaVMVersion" + implementation "com.github.xpenatan.gdx-teavm:gdx-freetype-teavm:$gdxTeaVMVersion" + implementation 'org.jetbrains:annotations:15.0' } diff --git a/flixelgdx-teavm/src/main/java/me/stringdotjar/flixelgdx/backend/teavm/FlixelTeaVMLauncher.java b/flixelgdx-teavm/src/main/java/me/stringdotjar/flixelgdx/backend/teavm/FlixelTeaVMLauncher.java index b379c9a..2d964b1 100644 --- a/flixelgdx-teavm/src/main/java/me/stringdotjar/flixelgdx/backend/teavm/FlixelTeaVMLauncher.java +++ b/flixelgdx-teavm/src/main/java/me/stringdotjar/flixelgdx/backend/teavm/FlixelTeaVMLauncher.java @@ -14,14 +14,21 @@ import me.stringdotjar.flixelgdx.backend.reflect.FlixelDefaultReflectionHandler; import me.stringdotjar.flixelgdx.backend.runtime.FlixelRuntimeMode; import me.stringdotjar.flixelgdx.backend.teavm.alert.FlixelTeaVMAlerter; +import me.stringdotjar.flixelgdx.backend.teavm.audio.FlixelDefaultSoundHandler; import me.stringdotjar.flixelgdx.backend.teavm.logging.TeaVMStackTraceProvider; +import java.util.function.Consumer; + +import org.jetbrains.annotations.Nullable; + /** - * Launches the web (TeaVM) version of the FlixelGDX game. + * Launches the web (TeaVM) version of a FlixelGDX game. * - *

The developer creates a subclass of {@link FlixelGame} and a launcher class with a - * {@code main(String[] args)} method that creates the game instance and calls {@link #launch(FlixelGame)}. - * Set that launcher class as the TeaVM {@code mainClass} in your web module's build.gradle. + *

The developer creates a subclass of {@link FlixelGame} and a launcher + * class with a {@code main(String[] args)} method that creates the game instance and calls one of the {@code launch} overloads. + * Set that launcher class as the TeaVM {@code mainClass} in your web module's {@code build.gradle}. + * + *

Minimal Example

* *
{@code
  * public class MyTeaVMLauncher {
@@ -30,51 +37,104 @@
  *   }
  * }
  * }
+ * + *

Custom Configuration Example

+ * + *
{@code
+ * public class MyTeaVMLauncher {
+ *
+ *   public static void main(String[] args) {
+ *     FlixelTeaVMLauncher.launch(
+ *         new MyGame("My Game", 800, 600, new InitialState()),
+ *         FlixelRuntimeMode.DEBUG,
+ *         config -> {
+ *           config.canvasID = "my-canvas";
+ *           config.antialiasing = true;
+ *         }
+ *     );
+ *   }
+ * }
+ * }
+ * + *

Platform Notes

+ * + *

File logging is intentionally disabled on the web backend because browsers do not expose a host filesystem. + * The {@link me.stringdotjar.flixelgdx.logging.FlixelLogFileHandler} is not registered, so {@link Flixel#startFileLogging()} is a safe no-op. + * Console output still works through {@code System.out.println}, which TeaVM maps to {@code console.log}. + * + * @see FlixelGame + * @see WebApplicationConfiguration */ public class FlixelTeaVMLauncher { + /** Default canvas element ID used when none is specified. */ + private static final String DEFAULT_CANVAS_ID = "flixelgdx-canvas"; + /** - * Launches the web version of the game in {@link FlixelRuntimeMode#RELEASE RELEASE} mode. + * Launches the web version of the game in {@link FlixelRuntimeMode#RELEASE RELEASE} + * mode with default configuration. * * @param game The game instance to launch (e.g. {@code new MyGame(...)}). */ public static void launch(FlixelGame game) { - launch(game, FlixelRuntimeMode.RELEASE); + launch(game, FlixelRuntimeMode.RELEASE, null); } /** - * Launches the web version of the game with the given runtime mode. - * - *

Call this from your TeaVM entry point (the class configured as {@code mainClass} in the - * TeaVM block of your web module's build.gradle). Create your {@link FlixelGame} subclass - * instance and pass it here. + * Launches the web version of the game with the given runtime mode and + * default configuration. * - * @param game The game instance to launch (e.g. {@code new MyGame(...)}). + * @param game The game instance to launch. * @param runtimeMode The {@link FlixelRuntimeMode} for this session (TEST, DEBUG, or RELEASE). */ public static void launch(FlixelGame game, FlixelRuntimeMode runtimeMode) { + launch(game, runtimeMode, null); + } + + /** + * Launches the web version of the game with the given runtime mode and + * an optional configuration customizer. + * + *

The {@code configCustomizer} receives a pre-populated {@link WebApplicationConfiguration} with sensible defaults (canvas ID, + * dimensions from the game). Override any field before the consumer returns. Pass {@code null} to accept all defaults. + * + * @param game The game instance to launch. + * @param runtimeMode The {@link FlixelRuntimeMode} for this session. + * @param configCustomizer Optional consumer that can modify the web configuration before the application starts. + */ + public static void launch(FlixelGame game, FlixelRuntimeMode runtimeMode, @Nullable Consumer configCustomizer) { Flixel.setAlerter(new FlixelTeaVMAlerter()); Flixel.setStackTraceProvider(new TeaVMStackTraceProvider()); Flixel.setReflection(new FlixelDefaultReflectionHandler()); + Flixel.setSoundBackendFactory(new FlixelDefaultSoundHandler()); Flixel.setRuntimeMode(runtimeMode); Flixel.setDebugMode(runtimeMode == FlixelRuntimeMode.DEBUG); Flixel.initialize(game); + Flixel.setCanStoreLogs(false); + WebApplicationConfiguration configuration = new WebApplicationConfiguration(); - configuration.canvasID = "flixelgdx-canvas"; + configuration.canvasID = DEFAULT_CANVAS_ID; if (game.getViewWidth() > 0 && game.getViewHeight() > 0) { configuration.width = game.getViewWidth(); configuration.height = game.getViewHeight(); } + if (configCustomizer != null) { + configCustomizer.accept(configuration); + } + new WebApplication(game, configuration); } /** - * Default TeaVM entry point. Games should use their own launcher class as {@code mainClass} - * and call {@link #launch(FlixelGame)} with their game instance. + * Default TeaVM entry point. Games should use their own launcher class + * as {@code mainClass} and call {@link #launch(FlixelGame)} with their + * game instance. * - * @param args ignored + * @param args ignored. + * @throws UnsupportedOperationException always, because this stub should + * never be invoked directly. */ public static void main(String[] args) { throw new UnsupportedOperationException( diff --git a/flixelgdx-teavm/src/main/java/me/stringdotjar/flixelgdx/backend/teavm/audio/FlixelDefaultSoundHandler.java b/flixelgdx-teavm/src/main/java/me/stringdotjar/flixelgdx/backend/teavm/audio/FlixelDefaultSoundHandler.java new file mode 100644 index 0000000..7f31b9a --- /dev/null +++ b/flixelgdx-teavm/src/main/java/me/stringdotjar/flixelgdx/backend/teavm/audio/FlixelDefaultSoundHandler.java @@ -0,0 +1,107 @@ +/********************************************************************************** + * Copyright (c) 2025-2026 stringdotjar + * + * This file is part of the FlixelGDX framework, licensed under the MIT License. + * See the LICENSE file in the repository root for full license information. + **********************************************************************************/ + +package me.stringdotjar.flixelgdx.backend.teavm.audio; + +import me.stringdotjar.flixelgdx.audio.FlixelSoundBackend; + +/** + * TeaVM/web implementation of {@link FlixelSoundBackend.Factory} that falls + * back to libGDX {@code Gdx.audio} for sound playback. + * + *

Groups and effect nodes are no-ops because Web Audio does not expose the + * same graph-based API as MiniAudio. + */ +public class FlixelDefaultSoundHandler implements FlixelSoundBackend.Factory { + + private float masterVolume = 1f; + + @Override + public FlixelSoundBackend createSound(String path, short flags, Object group, boolean external) { + return new FlixelGdxSound(path, external); + } + + @Override + public Object createGroup() { + return new Object(); + } + + @Override + public void disposeGroup(Object group) { + // No-op on web. + } + + @Override + public void groupPause(Object group) { + // No-op on web. + } + + @Override + public void groupPlay(Object group) { + // No-op on web. + } + + @Override + public void setMasterVolume(float volume) { + masterVolume = Math.max(0f, Math.min(1f, volume)); + } + + /** + * Returns the tracked master volume. + * + * @return Master volume in [0, 1]. + */ + public float getMasterVolume() { + return masterVolume; + } + + @Override + public void disposeEngine() { + // No native engine to dispose on web. + } + + @Override + public void attachToEngineOutput(FlixelSoundBackend sound, int outputBusIndex) { + // No-op on web. + } + + @Override + public FlixelSoundBackend.EffectNode createReverbNode(float wet) { + return NoOpEffectNode.INSTANCE; + } + + @Override + public FlixelSoundBackend.EffectNode createDelayNode(float delaySeconds, float decay) { + return NoOpEffectNode.INSTANCE; + } + + @Override + public FlixelSoundBackend.EffectNode createLowPassFilter(double cutoffHz, int order) { + return NoOpEffectNode.INSTANCE; + } + + /** Singleton no-op effect node for platforms that do not support audio graphs. */ + private static final class NoOpEffectNode implements FlixelSoundBackend.EffectNode { + + static final NoOpEffectNode INSTANCE = new NoOpEffectNode(); + + @Override + public void attachToUpstream(FlixelSoundBackend upstream, int bus) { + // No-op. + } + + @Override + public void detach(int bus) { + // No-op. + } + + @Override + public void dispose() { + // No-op. + } + } +} diff --git a/flixelgdx-teavm/src/main/java/me/stringdotjar/flixelgdx/backend/teavm/audio/FlixelGdxSound.java b/flixelgdx-teavm/src/main/java/me/stringdotjar/flixelgdx/backend/teavm/audio/FlixelGdxSound.java new file mode 100644 index 0000000..c2b3f7e --- /dev/null +++ b/flixelgdx-teavm/src/main/java/me/stringdotjar/flixelgdx/backend/teavm/audio/FlixelGdxSound.java @@ -0,0 +1,113 @@ +/********************************************************************************** + * Copyright (c) 2025-2026 stringdotjar + * + * This file is part of the FlixelGDX framework, licensed under the MIT License. + * See the LICENSE file in the repository root for full license information. + **********************************************************************************/ + +package me.stringdotjar.flixelgdx.backend.teavm.audio; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.audio.Music; +import me.stringdotjar.flixelgdx.audio.FlixelSoundBackend; + +/** + * TeaVM/web implementation of {@link FlixelSoundBackend} backed by libGDX + * {@link Music}. {@code Music} is used instead of {@code Sound} because it + * exposes position, duration, looping, and pause/resume controls that the + * FlixelGDX audio API requires. + */ +final class FlixelGdxSound implements FlixelSoundBackend { + + private final Music music; + private float volume = 1f; + + FlixelGdxSound(String path, boolean external) { + if (external) { + music = Gdx.audio.newMusic(Gdx.files.absolute(path)); + } else { + music = Gdx.audio.newMusic(Gdx.files.internal(path)); + } + } + + @Override + public void play() { + music.play(); + } + + @Override + public void pause() { + music.pause(); + } + + @Override + public void stop() { + music.stop(); + } + + @Override + public boolean isPlaying() { + return music.isPlaying(); + } + + @Override + public boolean isEnd() { + return !music.isPlaying() && music.getPosition() <= 0f; + } + + @Override + public float getVolume() { + return volume; + } + + @Override + public void setVolume(float volume) { + this.volume = volume; + music.setVolume(volume); + } + + @Override + public void setPitch(float pitch) { + throw new UnsupportedOperationException("Pitch is not supported on TeaVM"); + } + + @Override + public void setPan(float pan) { + music.setPan(pan, volume); + } + + @Override + public float getCursorPosition() { + return music.getPosition(); + } + + @Override + public void seekTo(float seconds) { + music.setPosition(seconds); + } + + @Override + public float getLength() { + throw new UnsupportedOperationException("Duration is not supported on TeaVM"); + } + + @Override + public boolean isLooping() { + return music.isLooping(); + } + + @Override + public void setLooping(boolean looping) { + music.setLooping(looping); + } + + @Override + public void setPosition(float x, float y, float z) { + throw new UnsupportedOperationException("Spatial audio is not supported on TeaVM"); + } + + @Override + public void dispose() { + music.dispose(); + } +} diff --git a/flixelgdx-test/src/test/java/me/stringdotjar/flixelgdx/tween/FlixelNumTweenManagerTest.java b/flixelgdx-test/src/test/java/me/stringdotjar/flixelgdx/tween/FlixelNumTweenManagerTest.java index 893be52..74c1993 100644 --- a/flixelgdx-test/src/test/java/me/stringdotjar/flixelgdx/tween/FlixelNumTweenManagerTest.java +++ b/flixelgdx-test/src/test/java/me/stringdotjar/flixelgdx/tween/FlixelNumTweenManagerTest.java @@ -26,15 +26,15 @@ class FlixelNumTweenManagerTest { @Test void duplicateRegistrationThrows() { FlixelTweenManager manager = new FlixelTweenManager(); - manager.registerTweenType(FlixelNumTween.class, FlixelNumTweenBuilder.class, () -> new FlixelNumTween(0, 0, null, null)); + manager.registerTweenType(FlixelNumTween.class, FlixelNumTweenBuilder.class, FlixelNumTweenBuilder::new, () -> new FlixelNumTween(0, 0, null, null)); assertThrows(IllegalArgumentException.class, () -> - manager.registerTweenType(FlixelNumTween.class, FlixelNumTweenBuilder.class, () -> new FlixelNumTween(0, 0, null, null))); + manager.registerTweenType(FlixelNumTween.class, FlixelNumTweenBuilder.class, FlixelNumTweenBuilder::new, () -> new FlixelNumTween(0, 0, null, null))); } @Test void numTweenReachesEndValueLinear() { FlixelTweenManager manager = new FlixelTweenManager(); - manager.registerTweenType(FlixelNumTween.class, FlixelNumTweenBuilder.class, () -> new FlixelNumTween(0, 0, null, null)); + manager.registerTweenType(FlixelNumTween.class, FlixelNumTweenBuilder.class, FlixelNumTweenBuilder::new, () -> new FlixelNumTween(0, 0, null, null)); AtomicReference last = new AtomicReference<>(Float.NaN); new FlixelNumTweenBuilder() diff --git a/settings.gradle b/settings.gradle index 11d23c3..9f8e9c7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -17,7 +17,7 @@ if (localPropsFile.exists()) { } gradle.ext.includeAndroid = includeAndroidFromCli || includeAndroidFromLocal -def modules = ['flixelgdx-core', 'flixelgdx-lwjgl3', 'flixelgdx-ios', 'flixelgdx-teavm', 'flixelgdx-jvm', 'flixelgdx-test'] +def modules = ['flixelgdx-core', 'flixelgdx-lwjgl3', 'flixelgdx-ios', 'flixelgdx-teavm', 'flixelgdx-jvm', 'flixelgdx-teavm-plugin', 'flixelgdx-test'] if (gradle.ext.includeAndroid) { modules += 'flixelgdx-android' } From e2f5e903fc9e0327a27e3b14c987a178696faed1 Mon Sep 17 00:00:00 2001 From: String Date: Mon, 6 Apr 2026 23:37:44 -0500 Subject: [PATCH 2/2] Clean up code --- .../gradle/teavm/FlixelTeaVMPlugin.java | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/flixelgdx-teavm-plugin/src/main/java/me/stringdotjar/flixelgdx/gradle/teavm/FlixelTeaVMPlugin.java b/flixelgdx-teavm-plugin/src/main/java/me/stringdotjar/flixelgdx/gradle/teavm/FlixelTeaVMPlugin.java index be5178c..4719a17 100644 --- a/flixelgdx-teavm-plugin/src/main/java/me/stringdotjar/flixelgdx/gradle/teavm/FlixelTeaVMPlugin.java +++ b/flixelgdx-teavm-plugin/src/main/java/me/stringdotjar/flixelgdx/gradle/teavm/FlixelTeaVMPlugin.java @@ -136,9 +136,20 @@ public void apply(Project project) { File userIndex = new File(ext.getWebappDir().get().getAsFile(), "index.html"); return !userIndex.exists(); }); + // Create a default index.html file, copied from the resources folder. task.doLast(t -> { try { - writeDefaultIndexHtml(ext); + String template; + try (InputStream in = FlixelTeaVMPlugin.class.getResourceAsStream(INDEX_TEMPLATE)) { + if (in == null) { + throw new IOException("default-index.html template not found in plugin JAR at " + INDEX_TEMPLATE); + } + template = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + String html = template.replace("{{CANVAS_ID}}", ext.getCanvasId().get()); + File outputDir = ext.getOutputDir().get().getAsFile(); + outputDir.mkdirs(); + Files.writeString(new File(outputDir, "index.html").toPath(), html, StandardCharsets.UTF_8); } catch (IOException e) { throw new RuntimeException("FlixelGDX: failed to generate default index.html.", e); } @@ -153,24 +164,6 @@ public void apply(Project project) { }); } - private void registerGenerateIndexHtmlTask(Project project, FlixelTeaVMExtension ext) { - - } - - private void writeDefaultIndexHtml(FlixelTeaVMExtension ext) throws IOException { - String template; - try (InputStream in = FlixelTeaVMPlugin.class.getResourceAsStream(INDEX_TEMPLATE)) { - if (in == null) { - throw new IOException("default-index.html template not found in plugin JAR at " + INDEX_TEMPLATE); - } - template = new String(in.readAllBytes(), StandardCharsets.UTF_8); - } - String html = template.replace("{{CANVAS_ID}}", ext.getCanvasId().get()); - File outputDir = ext.getOutputDir().get().getAsFile(); - outputDir.mkdirs(); - Files.writeString(new File(outputDir, "index.html").toPath(), html, StandardCharsets.UTF_8); - } - private void wireTo(Project project, String taskName) { Task task = project.getTasks().findByName(taskName); if (task != null) {