diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bf7f594c..08e8904ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev + sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev libudev-dev - name: Set up Python (for codegen) uses: actions/setup-python@v6 @@ -142,7 +142,7 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | sudo apt-get update - sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev libxkbcommon-x11-dev + sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev libudev-dev libxkbcommon-x11-dev - name: Run clippy (Windows stable) if: matrix.run_windows_native_clippy @@ -276,7 +276,7 @@ jobs: - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev + sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev libudev-dev - name: Set up Python uses: actions/setup-python@v6 @@ -340,7 +340,7 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | sudo apt-get update - sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev libxkbcommon-x11-dev + sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev libudev-dev libxkbcommon-x11-dev - name: Build native library run: cargo build --release @@ -389,7 +389,7 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | sudo apt-get update - sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev libxkbcommon-x11-dev + sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev libudev-dev libxkbcommon-x11-dev - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 @@ -487,7 +487,7 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | sudo apt-get update - sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev + sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev libudev-dev - name: Download native release artifact uses: actions/download-artifact@v4 @@ -602,7 +602,7 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | sudo apt-get update - sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev + sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev libudev-dev - name: Run full build script run: | @@ -677,7 +677,7 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | sudo apt-get update - sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev libxkbcommon-x11-dev + sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev libudev-dev libxkbcommon-x11-dev - name: Install npm dependencies working-directory: sdks/typescript @@ -950,7 +950,7 @@ jobs: - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y xvfb libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev libxkbcommon-x11-dev + sudo apt-get install -y xvfb libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev libudev-dev libxkbcommon-x11-dev - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -1087,6 +1087,7 @@ jobs: name: iOS Build Pipeline runs-on: macos-latest needs: [preflight] + timeout-minutes: 35 steps: - uses: actions/checkout@v6 @@ -1112,10 +1113,36 @@ jobs: CODE_SIGNING_ALLOWED=NO \ build + - name: Build iOS Flappy Bird example for simulator + env: + GOUD_ENGINE_IOS_LIB_DIR: ${{ github.workspace }}/platform/ios/build/simulator + working-directory: examples/ios/flappy_bird + run: | + # Resolve SPM dependencies and build for iOS Simulator + xcodebuild \ + -scheme FlappyBird \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath .build/derivedData \ + CODE_SIGNING_ALLOWED=NO \ + build 2>&1 || { + echo "xcodebuild failed, trying swift build as fallback..." + swift build \ + --sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \ + --triple arm64-apple-ios-simulator \ + -Xlinker -L"${GOUD_ENGINE_IOS_LIB_DIR}" + } + + - name: Shutdown iOS Simulator + if: always() + run: xcrun simctl shutdown booted || true + android-build-pipeline: name: Android Build Pipeline runs-on: ubuntu-latest needs: [preflight] + timeout-minutes: 15 steps: - uses: actions/checkout@v6 @@ -1149,6 +1176,49 @@ jobs: working-directory: platform/android/template run: ./gradlew assembleDebug --no-daemon + - name: Copy native libraries to Flappy Bird example + run: | + mkdir -p examples/android/flappy_bird/app/src/main/jniLibs + cp -r platform/android/template/app/src/main/jniLibs/* \ + examples/android/flappy_bird/app/src/main/jniLibs/ + + - name: Build Android Flappy Bird example + working-directory: examples/android/flappy_bird + env: + GOUD_REPO_ROOT: ${{ github.workspace }} + run: ./gradlew assembleDebug --no-daemon + + - name: Enable KVM group permissions + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Smoke test on Android emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 30 + arch: x86_64 + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio + disable-animations: true + script: | + adb install examples/android/flappy_bird/app/build/outputs/apk/debug/app-debug.apk + adb shell am start -n com.goudengine.flappybird/.MainActivity + sleep 5 + adb exec-out screencap -p > android-smoke-test.png + # Verify the app was installed and launched (may crash due to no GPU in CI) + adb shell pm list packages | grep -q "com.goudengine.flappybird" + + - name: Upload Android smoke test screenshot + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-smoke-screenshot + path: android-smoke-test.png + retention-days: 7 + # C SDK tests (CMake-based, needs native library) c-sdk-check: name: C SDK Tests on ${{ matrix.os }} @@ -1223,7 +1293,7 @@ jobs: - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev + sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxxf86vm-dev libasound2-dev libudev-dev - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f0b03a435..27fbf91a3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -102,7 +102,7 @@ The `RenderBackend` trait abstracts the GPU backend. Both 2D (SpriteBatch) and 3 | Feature | Dependencies | |---------|-------------| | `default` | `desktop-native` | -| `native` | `wgpu-backend`, `cc`, `bindgen`, `cbindgen`, `tiled`, `rayon`, `env_logger`, `toml`, `gltf`, `tobj`, `fbxcel`, `net-udp`, `net-tcp`, `net-ws`, `rapier2d`, `rapier3d` | +| `native` | `wgpu-backend`, `cc`, `bindgen`, `cbindgen`, `tiled`, `rayon`, `env_logger`, `toml`, `gltf`, `tobj`, `fbxcel`, `net-udp`, `net-tcp`, `net-ws`, `rapier2d`, `rapier3d`, `gilrs` | | `desktop-native` | `native`, `interprocess`, `notify`, `rodio` | | `jni-bridge` | `jni` | | `legacy-glfw-opengl` | `gl`, `glfw` | @@ -115,6 +115,7 @@ The `RenderBackend` trait abstracts the GPU backend. Both 2D (SpriteBatch) and 3 | `web` | `wgpu-backend`, `wasm-bindgen`, `wasm-bindgen-futures`, `web-sys`, `js-sys` | | `rapier2d` | `rapier2d` | | `rapier3d` | `rapier3d` | +| `gilrs` | `gilrs` | | `headless` | (empty) | | `xbox-gdk` | `wgpu-backend` | | `sdl-window` | `wgpu-backend`, `sdl2` | diff --git a/Cargo.lock b/Cargo.lock index 660204fe8..5dcd5d04f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1683,6 +1683,40 @@ dependencies = [ "weezl", ] +[[package]] +name = "gilrs" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fa85c2e35dc565c90511917897ea4eae16b77f2773d5223536f7b602536d462" +dependencies = [ + "fnv", + "gilrs-core", + "log", + "uuid", + "vec_map", +] + +[[package]] +name = "gilrs-core" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d23f2cc5144060a7f8d9e02d3fce5d06705376568256a509cdbc3c24d47e4f04" +dependencies = [ + "inotify", + "js-sys", + "libc", + "libudev-sys", + "log", + "nix", + "objc2-core-foundation", + "objc2-io-kit", + "uuid", + "vec_map", + "wasm-bindgen", + "web-sys", + "windows", +] + [[package]] name = "gl" version = "0.14.0" @@ -1814,6 +1848,7 @@ dependencies = [ "env_logger", "fbxcel", "fontdue", + "gilrs", "gl", "glfw", "gltf", @@ -2472,6 +2507,16 @@ dependencies = [ "redox_syscall 0.7.3", ] +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "lint-layers" version = "0.1.0" @@ -2803,6 +2848,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -3143,6 +3200,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "bitflags 2.11.0", + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-link-presentation" version = "0.2.2" @@ -5287,6 +5355,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "v_frame" version = "0.3.9" @@ -5298,6 +5376,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "version-compare" version = "0.1.1" diff --git a/codegen/generated/goud_engine.h b/codegen/generated/goud_engine.h index f73a12c14..03749202f 100644 --- a/codegen/generated/goud_engine.h +++ b/codegen/generated/goud_engine.h @@ -40,6 +40,11 @@ extern "C" { */ #define PROTOCOL_VERSION 1 +/** + * Maximum number of gamepad slots supported by the engine. + */ +#define MAX_GAMEPAD_SLOTS 4 + /** * Sentinel value for an invalid entity ID. */ @@ -1437,6 +1442,16 @@ typedef struct FfiUiStyle { float widget_spacing; } FfiUiStyle; +/** + * FFI-compatible gamepad button code. + */ +typedef uint32_t GoudGamepadButton; + +/** + * FFI-compatible gamepad axis code. + */ +typedef uint32_t GoudGamepadAxis; + /** * Opaque shader handle for FFI. */ @@ -1696,6 +1711,60 @@ typedef struct GoudResult { */ #define ERR_INVALID_STATE 902 +/** + * A / Cross + */ +#define GAMEPAD_BUTTON_SOUTH 0 + +/** + * B / Circle + */ +#define GAMEPAD_BUTTON_EAST 1 + +/** + * X / Square + */ +#define GAMEPAD_BUTTON_WEST 2 + +/** + * Y / Triangle + */ +#define GAMEPAD_BUTTON_NORTH 3 + +#define GAMEPAD_BUTTON_LEFT_BUMPER 4 + +#define GAMEPAD_BUTTON_RIGHT_BUMPER 5 + +#define GAMEPAD_BUTTON_BACK 6 + +#define GAMEPAD_BUTTON_START 7 + +#define GAMEPAD_BUTTON_GUIDE 8 + +#define GAMEPAD_BUTTON_LEFT_STICK 9 + +#define GAMEPAD_BUTTON_RIGHT_STICK 10 + +#define GAMEPAD_BUTTON_DPAD_UP 11 + +#define GAMEPAD_BUTTON_DPAD_RIGHT 12 + +#define GAMEPAD_BUTTON_DPAD_DOWN 13 + +#define GAMEPAD_BUTTON_DPAD_LEFT 14 + +#define GAMEPAD_AXIS_LEFT_X 0 + +#define GAMEPAD_AXIS_LEFT_Y 1 + +#define GAMEPAD_AXIS_RIGHT_X 2 + +#define GAMEPAD_AXIS_RIGHT_Y 3 + +#define GAMEPAD_AXIS_LEFT_TRIGGER 4 + +#define GAMEPAD_AXIS_RIGHT_TRIGGER 5 + /** * Invalid atlas handle constant. */ @@ -2869,6 +2938,61 @@ bool goud_input_action_just_pressed(struct GoudContextId context_id, const char */ bool goud_input_action_just_released(struct GoudContextId context_id, const char *action_name); +/** + * Returns `true` if the specified gamepad button is currently pressed. + */ +bool goud_input_gamepad_button_pressed(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t button); + +/** + * Returns `true` if the specified gamepad button was just pressed this frame. + */ +bool goud_input_gamepad_button_just_pressed(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t button); + +/** + * Returns `true` if the specified gamepad button was just released this frame. + */ +bool goud_input_gamepad_button_just_released(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t button); + +/** + * Returns the current value of a gamepad analog axis (-1.0 to 1.0). + */ +float goud_input_gamepad_axis(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t axis); + +/** + * Returns `true` if the specified gamepad is currently connected. + */ +bool goud_input_gamepad_connected(struct GoudContextId context_id, uint32_t gamepad_id); + +/** + * Returns the number of currently connected gamepads. + */ +uint32_t goud_input_gamepad_connected_count(struct GoudContextId context_id); + +/** + * Sets the vibration intensity for a gamepad (0.0-1.0). + */ +bool goud_input_gamepad_set_vibration(struct GoudContextId context_id, uint32_t gamepad_id, float intensity); + +/** + * Writes the left stick position to the output pointers. + */ +bool goud_input_gamepad_left_stick(struct GoudContextId context_id, uint32_t gamepad_id, float *out_x, float *out_y); + +/** + * Writes the right stick position to the output pointers. + */ +bool goud_input_gamepad_right_stick(struct GoudContextId context_id, uint32_t gamepad_id, float *out_x, float *out_y); + +/** + * Returns the left trigger value (0.0 to 1.0). + */ +float goud_input_gamepad_left_trigger(struct GoudContextId context_id, uint32_t gamepad_id); + +/** + * Returns the right trigger value (0.0 to 1.0). + */ +float goud_input_gamepad_right_trigger(struct GoudContextId context_id, uint32_t gamepad_id); + /** * Returns `true` if the specified key is currently pressed. */ @@ -5183,6 +5307,26 @@ bool goud_fixed_timestep_set(struct GoudContextId context_id, float step); */ bool goud_fixed_timestep_set_max_steps(struct GoudContextId context_id, uint32_t max); +/** + * Gets the display scale factor (DPI ratio) for the window. + */ +float goud_get_scale_factor(struct GoudContextId context_id); + +/** + * Gets the safe area insets for the current display. + */ +bool goud_get_safe_area_insets(struct GoudContextId context_id, float *top, float *bottom, float *left, float *right); + +/** + * Gets the logical window size. + */ +bool goud_get_logical_size(struct GoudContextId context_id, uint32_t *width, uint32_t *height); + +/** + * Gets the physical framebuffer size. + */ +bool goud_get_framebuffer_size(struct GoudContextId context_id, uint32_t *width, uint32_t *height); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/examples/android/flappy_bird/app/build.gradle.kts b/examples/android/flappy_bird/app/build.gradle.kts new file mode 100644 index 000000000..463a3832e --- /dev/null +++ b/examples/android/flappy_bird/app/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + id("com.android.application") + kotlin("android") +} + +val repoRoot = providers.environmentVariable("GOUD_REPO_ROOT") + .map { file(it).canonicalFile } + .orElse(rootProject.projectDir.resolve("../../../..").canonicalFile) + .get() + +android { + namespace = "com.goudengine.flappybird" + compileSdk = 34 + + defaultConfig { + applicationId = "com.goudengine.flappybird" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + sourceSets { + getByName("main") { + java.srcDirs( + "src/main/java", + repoRoot.resolve("sdks/kotlin/src/main/java"), + repoRoot.resolve("sdks/kotlin/src/main/kotlin"), + ) + jniLibs.srcDir("src/main/jniLibs") + assets.srcDir("src/main/assets") + } + } + + packaging { + jniLibs { + useLegacyPackaging = false + } + } +} + +dependencies { + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.core:core-ktx:1.13.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") +} diff --git a/examples/android/flappy_bird/app/src/main/AndroidManifest.xml b/examples/android/flappy_bird/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..66d6ecef8 --- /dev/null +++ b/examples/android/flappy_bird/app/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + diff --git a/examples/android/flappy_bird/app/src/main/assets/sprites b/examples/android/flappy_bird/app/src/main/assets/sprites new file mode 120000 index 000000000..18edcdc66 --- /dev/null +++ b/examples/android/flappy_bird/app/src/main/assets/sprites @@ -0,0 +1 @@ +../../../../../../../examples/csharp/flappy_goud/assets/sprites \ No newline at end of file diff --git a/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/Bird.kt b/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/Bird.kt new file mode 100644 index 000000000..9d81b38f4 --- /dev/null +++ b/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/Bird.kt @@ -0,0 +1,75 @@ +package com.goudengine.flappybird + +import com.goudengine.core.GoudGame +import com.goudengine.input.GamepadButton +import com.goudengine.input.Key +import com.goudengine.types.Color + +/** + * The player-controlled bird. + * + * Supports three input methods for flapping: + * - Touch: tap anywhere on the screen (primary mobile input) + * - Keyboard: press Space (useful in Android emulator) + * - Gamepad: A button / South face button + */ +class Bird(game: GoudGame) { + var x: Float = GameConstants.SCREEN_WIDTH / 3f + var y: Float = GameConstants.SCREEN_HEIGHT / 2f + private var velocity: Float = 0f + private var jumpCooldown: Float = 0f + private val animator = BirdAnimator(game) + + fun update(game: GoudGame, dt: Float) { + jumpCooldown -= dt + val scaledDt = dt * GameConstants.TARGET_FPS + + if (shouldFlap(game) && jumpCooldown <= 0f) { + velocity = GameConstants.JUMP_STRENGTH * GameConstants.TARGET_FPS + jumpCooldown = GameConstants.JUMP_COOLDOWN + } + + velocity += GameConstants.GRAVITY * scaledDt + y += velocity * dt + + animator.update(dt) + } + + fun render(game: GoudGame) { + val texture = animator.currentFrame() + game.drawSprite( + texture, x, y, + GameConstants.BIRD_WIDTH.toFloat(), + GameConstants.BIRD_HEIGHT.toFloat(), + 0f, Color.white() + ) + } + + fun reset() { + x = GameConstants.SCREEN_WIDTH / 3f + y = GameConstants.SCREEN_HEIGHT / 2f + velocity = 0f + jumpCooldown = 0f + } + + /** + * Returns true if the player wants to flap via any supported input method. + * + * Input methods: + * 1. Touch - tap anywhere (touchId 0 = primary finger) + * 2. Keyboard - Space key (works in Android emulator) + * 3. Gamepad - A/South button on gamepad 0 + */ + private fun shouldFlap(game: GoudGame): Boolean { + // Touch input: primary mobile input method + if (game.isTouchJustPressed(0)) return true + + // Keyboard input: useful for Android emulator testing + if (game.isKeyJustPressed(Key.Space)) return true + + // Gamepad input: A/South button on gamepad 0. + if (game.isGamepadButtonJustPressed(0, GamepadButton.South)) return true + + return false + } +} diff --git a/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/BirdAnimator.kt b/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/BirdAnimator.kt new file mode 100644 index 000000000..dd59418b3 --- /dev/null +++ b/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/BirdAnimator.kt @@ -0,0 +1,31 @@ +package com.goudengine.flappybird + +import com.goudengine.core.GoudGame + +/** + * Cycles through bird wing animation frames. + */ +class BirdAnimator(game: GoudGame) { + private val frames: LongArray + private var frameIndex = 0 + private var timer = 0f + private val frameDuration = 0.15f + + init { + frames = longArrayOf( + game.loadTexture("assets/sprites/yellowbird-downflap.png"), + game.loadTexture("assets/sprites/yellowbird-midflap.png"), + game.loadTexture("assets/sprites/yellowbird-upflap.png"), + ) + } + + fun update(dt: Float) { + timer += dt + if (timer >= frameDuration) { + timer = 0f + frameIndex = (frameIndex + 1) % frames.size + } + } + + fun currentFrame(): Long = frames[frameIndex] +} diff --git a/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/GameConstants.kt b/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/GameConstants.kt new file mode 100644 index 000000000..9bdcb60b4 --- /dev/null +++ b/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/GameConstants.kt @@ -0,0 +1,24 @@ +package com.goudengine.flappybird + +/** + * Game constants matching the canonical C# flappy_goud values. + * Source of truth: examples/rust/flappy_bird/src/constants.rs + */ +object GameConstants { + const val TARGET_FPS: Int = 120 + const val BASE_HEIGHT: Int = 112 + + const val SCREEN_WIDTH: Int = 288 + const val SCREEN_HEIGHT: Int = 512 + const val GRAVITY: Float = 9.8f + const val JUMP_STRENGTH: Float = -3.5f + const val JUMP_COOLDOWN: Float = 0.30f + + const val PIPE_SPEED: Float = 1.0f + const val PIPE_SPAWN_INTERVAL: Float = 1.5f + const val PIPE_WIDTH: Int = 60 + const val PIPE_GAP: Int = 100 + + const val BIRD_WIDTH: Int = 34 + const val BIRD_HEIGHT: Int = 24 +} diff --git a/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/GameManager.kt b/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/GameManager.kt new file mode 100644 index 000000000..7d5224e4c --- /dev/null +++ b/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/GameManager.kt @@ -0,0 +1,147 @@ +package com.goudengine.flappybird + +import com.goudengine.core.GoudGame +import com.goudengine.input.GamepadButton +import com.goudengine.input.Key +import com.goudengine.types.Color + +/** + * Manages overall game state: menu, playing, game-over transitions. + * + * State transitions accept input from touch, keyboard, and gamepad so the + * example works on physical devices, the Android emulator, and with a + * connected controller. + */ +class GameManager(game: GoudGame) { + private enum class State { MENU, PLAYING, GAME_OVER } + + private var state = State.MENU + private val bird = Bird(game) + private val pipes = mutableListOf() + private val score = ScoreCounter() + + private var pipeTimer = 0f + private var bgTexture = 0L + private var baseTexture = 0L + + init { + bgTexture = game.loadTexture("assets/sprites/background-day.png") + baseTexture = game.loadTexture("assets/sprites/base.png") + } + + fun update(game: GoudGame) { + val dt = game.deltaTime + + when (state) { + State.MENU -> { + if (anyActionPressed(game)) { + state = State.PLAYING + } + } + State.PLAYING -> { + bird.update(game, dt) + updatePipes(game, dt) + + if (checkCollisions()) { + state = State.GAME_OVER + } + } + State.GAME_OVER -> { + if (anyActionPressed(game)) { + reset(game) + } + } + } + } + + fun render(game: GoudGame) { + game.beginFrame() + + // Background + game.drawSprite( + bgTexture, 0f, 0f, + GameConstants.SCREEN_WIDTH.toFloat(), + GameConstants.SCREEN_HEIGHT.toFloat(), + 0f, Color.white() + ) + + // Pipes + for (pipe in pipes) { + pipe.render(game) + } + + // Base + game.drawSprite( + baseTexture, 0f, + (GameConstants.SCREEN_HEIGHT - GameConstants.BASE_HEIGHT).toFloat(), + GameConstants.SCREEN_WIDTH.toFloat(), + GameConstants.BASE_HEIGHT.toFloat(), + 0f, Color.white() + ) + + // Bird + bird.render(game) + + game.endFrame() + } + + // ------------------------------------------------------------------ + // Internal helpers + // ------------------------------------------------------------------ + + /** + * Returns true if any supported action input was just pressed. + * Used for menu/game-over transitions where all three input methods + * should work identically. + */ + private fun anyActionPressed(game: GoudGame): Boolean { + // Touch + if (game.isTouchJustPressed(0)) return true + // Keyboard (emulator) + if (game.isKeyJustPressed(Key.Space)) return true + // Gamepad A/South button + if (game.isGamepadButtonJustPressed(0, GamepadButton.South)) return true + return false + } + + private fun updatePipes(game: GoudGame, dt: Float) { + pipeTimer += dt + if (pipeTimer >= GameConstants.PIPE_SPAWN_INTERVAL) { + pipeTimer = 0f + pipes.add(Pipe.spawn(game)) + } + + val iter = pipes.iterator() + while (iter.hasNext()) { + val pipe = iter.next() + pipe.update(dt) + if (pipe.isOffScreen()) { + iter.remove() + } else if (!pipe.scored && pipe.x + GameConstants.PIPE_WIDTH < bird.x) { + pipe.scored = true + score.increment() + } + } + } + + private fun checkCollisions(): Boolean { + // Ground collision + val groundY = GameConstants.SCREEN_HEIGHT - GameConstants.BASE_HEIGHT + if (bird.y + GameConstants.BIRD_HEIGHT >= groundY) return true + + // Pipe collisions + for (pipe in pipes) { + if (pipe.collidesWith(bird.x, bird.y)) return true + } + + return false + } + + private fun reset(game: GoudGame) { + bird.reset() + pipes.clear() + score.reset() + pipeTimer = 0f + state = State.PLAYING + } +} diff --git a/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/MainActivity.kt b/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/MainActivity.kt new file mode 100644 index 000000000..f6bb7dd2f --- /dev/null +++ b/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/MainActivity.kt @@ -0,0 +1,51 @@ +package com.goudengine.flappybird + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.goudengine.core.EngineConfig +import com.goudengine.core.GoudEngine + +/** + * Android entry point for the Flappy Bird example. + * + * Demonstrates three input methods on Android: + * - Touch: tap the screen to flap (primary mobile input) + * - Keyboard: press Space to flap (useful in the Android emulator) + * - Gamepad: A button to flap + * + * The engine renders into a native surface provided by GoudEngine. + * Assets are loaded from the APK's assets/ directory. + */ +class MainActivity : AppCompatActivity() { + + private var gameManager: GameManager? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + GoudEngine.ensureLoaded() + + val game = EngineConfig.create() + .setTitle("Flappy Bird - Android") + .setSize(GameConstants.SCREEN_WIDTH, GameConstants.SCREEN_HEIGHT) + .build() + + val manager = GameManager(game) + gameManager = manager + + // Run the game loop on the render thread. + // On Android the engine creates its own surface; the Activity acts as + // the host lifecycle owner. + Thread { + try { + while (!game.shouldClose()) { + manager.update(game) + manager.render(game) + } + game.destroy() + } catch (t: Throwable) { + android.util.Log.e("GoudEngine", "Game loop crashed", t) + } + }.start() + } +} diff --git a/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/Movement.kt b/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/Movement.kt new file mode 100644 index 000000000..10b87e798 --- /dev/null +++ b/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/Movement.kt @@ -0,0 +1,13 @@ +package com.goudengine.flappybird + +/** + * Simple 2D movement helpers shared across game objects. + */ +object Movement { + /** + * Move an x position leftward at the given speed, scaled by dt and TARGET_FPS. + */ + fun scrollLeft(x: Float, speed: Float, dt: Float): Float { + return x - speed * GameConstants.TARGET_FPS * dt + } +} diff --git a/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/Pipe.kt b/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/Pipe.kt new file mode 100644 index 000000000..f08fe6a1e --- /dev/null +++ b/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/Pipe.kt @@ -0,0 +1,58 @@ +package com.goudengine.flappybird + +import com.goudengine.core.GoudGame +import com.goudengine.types.Color +import kotlin.random.Random + +/** + * A pair of top/bottom pipes scrolling leftward. + */ +class Pipe private constructor( + private val game: GoudGame, + var x: Float, + private val gapY: Float, + private val pipeTexture: Long, +) { + var scored = false + + fun update(dt: Float) { + x = Movement.scrollLeft(x, GameConstants.PIPE_SPEED, dt) + } + + fun render(game: GoudGame) { + val pw = GameConstants.PIPE_WIDTH.toFloat() + val gap = GameConstants.PIPE_GAP.toFloat() + val topH = gapY + val bottomY = gapY + gap + val bottomH = GameConstants.SCREEN_HEIGHT.toFloat() - bottomY + + // Top pipe + game.drawSprite(pipeTexture, x, 0f, pw, topH, 0f, Color.white()) + // Bottom pipe + game.drawSprite(pipeTexture, x, bottomY, pw, bottomH, 0f, Color.white()) + } + + fun isOffScreen(): Boolean = x + GameConstants.PIPE_WIDTH < 0 + + fun collidesWith(birdX: Float, birdY: Float): Boolean { + val bw = GameConstants.BIRD_WIDTH.toFloat() + val bh = GameConstants.BIRD_HEIGHT.toFloat() + val pw = GameConstants.PIPE_WIDTH.toFloat() + val gap = GameConstants.PIPE_GAP.toFloat() + + if (birdX + bw < x || birdX > x + pw) return false + if (birdY < gapY || birdY + bh > gapY + gap) return true + return false + } + + companion object { + fun spawn(game: GoudGame): Pipe { + val minY = 50f + val maxY = (GameConstants.SCREEN_HEIGHT - GameConstants.BASE_HEIGHT + - GameConstants.PIPE_GAP - 50).toFloat() + val gapY = Random.nextFloat() * (maxY - minY) + minY + val texture = game.loadTexture("assets/sprites/pipe-green.png") + return Pipe(game, GameConstants.SCREEN_WIDTH.toFloat(), gapY, texture) + } + } +} diff --git a/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/ScoreCounter.kt b/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/ScoreCounter.kt new file mode 100644 index 000000000..94fa03e97 --- /dev/null +++ b/examples/android/flappy_bird/app/src/main/java/com/goudengine/flappybird/ScoreCounter.kt @@ -0,0 +1,17 @@ +package com.goudengine.flappybird + +/** + * Tracks the player score. + */ +class ScoreCounter { + var value: Int = 0 + private set + + fun increment() { + value++ + } + + fun reset() { + value = 0 + } +} diff --git a/examples/android/flappy_bird/app/src/main/jniLibs/.gitkeep b/examples/android/flappy_bird/app/src/main/jniLibs/.gitkeep new file mode 100644 index 000000000..0c956928c --- /dev/null +++ b/examples/android/flappy_bird/app/src/main/jniLibs/.gitkeep @@ -0,0 +1,6 @@ +# This directory is populated by platform/android/build-android.sh +# which builds the native .so files via cargo-ndk for: +# - arm64-v8a +# - x86_64 +# +# Do not commit .so files to version control. diff --git a/examples/android/flappy_bird/app/src/main/res/values/strings.xml b/examples/android/flappy_bird/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..b35e36164 --- /dev/null +++ b/examples/android/flappy_bird/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Flappy Bird + diff --git a/examples/android/flappy_bird/app/src/main/res/values/themes.xml b/examples/android/flappy_bird/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..e09ca3552 --- /dev/null +++ b/examples/android/flappy_bird/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/examples/android/flappy_bird/build.gradle.kts b/examples/android/flappy_bird/build.gradle.kts new file mode 100644 index 000000000..85eff98f4 --- /dev/null +++ b/examples/android/flappy_bird/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.5.2" apply false + kotlin("android") version "1.9.22" apply false +} diff --git a/examples/android/flappy_bird/gradle.properties b/examples/android/flappy_bird/gradle.properties new file mode 100644 index 000000000..459e0db2c --- /dev/null +++ b/examples/android/flappy_bird/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official diff --git a/examples/android/flappy_bird/gradle/wrapper/gradle-wrapper.jar b/examples/android/flappy_bird/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..d997cfc60 Binary files /dev/null and b/examples/android/flappy_bird/gradle/wrapper/gradle-wrapper.jar differ diff --git a/examples/android/flappy_bird/gradle/wrapper/gradle-wrapper.properties b/examples/android/flappy_bird/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..b82aa23a4 --- /dev/null +++ b/examples/android/flappy_bird/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/android/flappy_bird/gradlew b/examples/android/flappy_bird/gradlew new file mode 100755 index 000000000..0262dcbd5 --- /dev/null +++ b/examples/android/flappy_bird/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/android/flappy_bird/settings.gradle.kts b/examples/android/flappy_bird/settings.gradle.kts new file mode 100644 index 000000000..93a62d60d --- /dev/null +++ b/examples/android/flappy_bird/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "FlappyBirdAndroid" +include(":app") diff --git a/examples/ios/flappy_bird/Package.swift b/examples/ios/flappy_bird/Package.swift new file mode 100644 index 000000000..bc020162e --- /dev/null +++ b/examples/ios/flappy_bird/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 5.9 + +import Foundation +import PackageDescription + +let libSearchPath: String = ProcessInfo.processInfo.environment["GOUD_ENGINE_IOS_LIB_DIR"] + ?? "../../../platform/ios/build/simulator" + +let package = Package( + name: "FlappyBirdiOS", + platforms: [ + .iOS(.v16), + ], + dependencies: [ + .package(path: "../../../sdks/swift"), + ], + targets: [ + .executableTarget( + name: "FlappyBird", + dependencies: [ + .product(name: "GoudEngine", package: "swift"), + ], + path: "Sources/FlappyBird", + linkerSettings: [ + .unsafeFlags(["-L", libSearchPath]), + .linkedFramework("Metal"), + .linkedFramework("QuartzCore"), + .linkedFramework("UIKit"), + .linkedLibrary("z"), + ] + ), + ] +) diff --git a/examples/ios/flappy_bird/Sources/FlappyBird/Bird.swift b/examples/ios/flappy_bird/Sources/FlappyBird/Bird.swift new file mode 100644 index 000000000..21c44631f --- /dev/null +++ b/examples/ios/flappy_bird/Sources/FlappyBird/Bird.swift @@ -0,0 +1,66 @@ +// Bird.swift -- iOS Flappy Bird +// Demonstrates all three input methods: +// 1. Touch -- primary mobile input (tap anywhere to flap) +// 2. Mouse -- iOS Simulator trackpad click +// 3. Keyboard -- iOS Simulator hardware keyboard (Space bar) + +import GoudEngine + +final class Bird { + private let game: GoudGame + private let movement: Movement + private let animator: BirdAnimator + + private(set) var x: Float + private(set) var y: Float + + static let width: Float = 34 + static let height: Float = 24 + + init(game: GoudGame) { + self.game = game + movement = Movement(gravity: GameConstants.gravity, jumpStrength: GameConstants.jumpStrength) + x = Float(GameConstants.screenWidth) / 4 + y = Float(GameConstants.screenHeight) / 2 + animator = BirdAnimator(game: game, x: x, y: y) + } + + func initialize() { + animator.initialize() + } + + func reset() { + x = Float(GameConstants.screenWidth) / 4 + y = Float(GameConstants.screenHeight) / 2 + movement.velocity = 0 + animator.reset() + } + + func update(deltaTime: Float) -> Bool { + var didFlap = false + + // Input method 1: Touch -- primary input on iOS devices + let wantsTouch = game.isTouchJustPressed(touchId: 0) + // Input method 2: Keyboard -- Space bar via Simulator hardware keyboard + let wantsKey = game.isKeyPressed(key: .SPACE) + // Input method 3: Mouse -- Simulator trackpad / pointer click + let wantsMouse = game.isMouseButtonPressed(button: .LEFT) + + if wantsTouch || wantsKey || wantsMouse { + didFlap = movement.tryJump(deltaTime: deltaTime) + } + + movement.applyGravity(deltaTime: deltaTime) + var yPosition = y + movement.updatePosition(positionY: &yPosition, deltaTime: deltaTime) + y = yPosition + + animator.update(deltaTime: deltaTime, x: x, y: y, rotation: movement.rotation) + + return didFlap + } + + func draw() { + animator.draw() + } +} diff --git a/examples/ios/flappy_bird/Sources/FlappyBird/BirdAnimator.swift b/examples/ios/flappy_bird/Sources/FlappyBird/BirdAnimator.swift new file mode 100644 index 000000000..ba8448b72 --- /dev/null +++ b/examples/ios/flappy_bird/Sources/FlappyBird/BirdAnimator.swift @@ -0,0 +1,70 @@ +// BirdAnimator.swift -- iOS Flappy Bird + +import Foundation +import GoudEngine + +final class BirdAnimator { + private let game: GoudGame + private var frameTextures: [UInt64] = [] + + private var currentFrame: Int = 0 + private var animationTime: Float = 0 + private let frameDuration: Float + + private var initialX: Float + private var initialY: Float + private var currentX: Float + private var currentY: Float + private var currentRotation: Float = 0 + + private let birdWidth: Float = 34 + private let birdHeight: Float = 24 + + init(game: GoudGame, x: Float, y: Float, frameDuration: Float = 0.1) { + self.game = game + self.frameDuration = frameDuration + self.initialX = x + self.initialY = y + self.currentX = x + self.currentY = y + } + + func initialize() { + frameTextures = [ + game.loadTexture(path: "assets/sprites/bluebird-downflap.png"), + game.loadTexture(path: "assets/sprites/bluebird-midflap.png"), + game.loadTexture(path: "assets/sprites/bluebird-upflap.png"), + ] + } + + func update(deltaTime: Float, x: Float, y: Float, rotation: Float) { + animationTime += deltaTime + if animationTime >= frameDuration { + currentFrame = (currentFrame + 1) % frameTextures.count + animationTime = 0 + } + currentX = x + currentY = y + currentRotation = rotation + } + + func draw() { + guard !frameTextures.isEmpty else { return } + game.drawSprite( + texture: frameTextures[currentFrame], + x: currentX + birdWidth / 2, + y: currentY + birdHeight / 2, + width: birdWidth, + height: birdHeight, + rotation: currentRotation * Float.pi / 180 + ) + } + + func reset() { + currentFrame = 0 + animationTime = 0 + currentX = initialX + currentY = initialY + currentRotation = 0 + } +} diff --git a/examples/ios/flappy_bird/Sources/FlappyBird/EmbeddedAudioClips.swift b/examples/ios/flappy_bird/Sources/FlappyBird/EmbeddedAudioClips.swift new file mode 100644 index 000000000..a15e73ad0 --- /dev/null +++ b/examples/ios/flappy_bird/Sources/FlappyBird/EmbeddedAudioClips.swift @@ -0,0 +1,25 @@ +// EmbeddedAudioClips.swift -- iOS Flappy Bird +// Canonical decimal fixture bytes documented in examples/shared/flappy_audio_fixture.txt + +import Foundation + +enum EmbeddedAudioClips { + static let flapWav = Data([ + 82, 73, 70, 70, 116, 0, 0, 0, 87, 65, 86, 69, 102, 109, 116, 32, 16, 0, 0, 0, 1, 0, 1, 0, 64, 31, + 0, 0, 64, 31, 0, 0, 1, 0, 8, 0, 100, 97, 116, 97, 80, 0, 0, 0, 127, 182, 191, 147, 87, 61, 88, 146, + 186, 177, 127, 78, 70, 108, 160, 183, 159, 110, 75, 83, 126, 168, 175, 142, 98, 79, 99, 141, 169, 162, + 127, 92, 87, 114, 150, 165, 149, 115, 92, 98, 126, 154, 158, 136, 108, 96, 109, 135, 153, 148, 126, 106, + 104, 119, 140, 148, 138, 120, 109, 112, 126, 139, 141, 131, 119, 114, 120, 130, 136, 134, 126, 121, 121, + 125, 129, 130, 128, 126, 126, 127, + ]) + + static let resetWav = Data([ + 82, 73, 70, 70, 156, 0, 0, 0, 87, 65, 86, 69, 102, 109, 116, 32, 16, 0, 0, 0, 1, 0, 1, 0, 64, 31, + 0, 0, 64, 31, 0, 0, 1, 0, 8, 0, 100, 97, 116, 97, 120, 0, 0, 0, 127, 143, 158, 171, 181, 188, 192, 192, + 189, 182, 172, 160, 146, 131, 117, 103, 91, 81, 74, 69, 68, 70, 76, 84, 94, 105, 118, 131, 143, 154, 164, + 171, 175, 177, 176, 172, 166, 158, 148, 137, 127, 116, 106, 97, 91, 86, 84, 84, 87, 91, 98, 106, 114, 123, + 132, 141, 148, 154, 158, 161, 161, 160, 156, 152, 146, 139, 131, 124, 117, 111, 106, 102, 100, 100, 100, 103, + 106, 110, 116, 121, 126, 132, 136, 140, 143, 145, 146, 145, 144, 142, 139, 135, 131, 128, 124, 121, 119, 117, + 115, 115, 115, 116, 118, 119, 121, 123, 125, 127, 128, 130, 130, 131, 130, 130, 129, 129, 128, 127, 127, 127, + ]) +} diff --git a/examples/ios/flappy_bird/Sources/FlappyBird/GameConstants.swift b/examples/ios/flappy_bird/Sources/FlappyBird/GameConstants.swift new file mode 100644 index 000000000..ea2aca03b --- /dev/null +++ b/examples/ios/flappy_bird/Sources/FlappyBird/GameConstants.swift @@ -0,0 +1,21 @@ +// GameConstants.swift -- iOS Flappy Bird +// Portrait orientation: 288x512 game area + 112 base + +import Foundation + +enum GameConstants { + static let targetFPS: UInt32 = 120 + static let baseHeight: Float = 112 + + // Portrait dimensions matching the original Flappy Bird aspect ratio + static let screenWidth: UInt32 = 288 + static let screenHeight: UInt32 = 512 + static let gravity: Float = 9.8 + static let jumpStrength: Float = -3.5 + static let jumpCooldown: Float = 0.30 + + static let pipeSpeed: Float = 1.0 + static let pipeSpawnInterval: Float = 1.5 + static let pipeWidth: Float = 60 + static let pipeGap: Float = 100 +} diff --git a/examples/ios/flappy_bird/Sources/FlappyBird/GameManager.swift b/examples/ios/flappy_bird/Sources/FlappyBird/GameManager.swift new file mode 100644 index 000000000..63eda11dd --- /dev/null +++ b/examples/ios/flappy_bird/Sources/FlappyBird/GameManager.swift @@ -0,0 +1,142 @@ +// GameManager.swift -- iOS Flappy Bird + +import Foundation +import GoudEngine + +final class GameManager { + private let game: GoudGame + + private let scoreCounter = ScoreCounter() + private let bird: Bird + private var pipes: [Pipe] = [] + private var pipeSpawnTimer: Float = 0 + + private var backgroundTextureId: UInt64 = 0 + private var baseTextureId: UInt64 = 0 + private var pipeTextureId: UInt64 = 0 + + private let backgroundWidth: Float = 288 + private let backgroundHeight: Float = 512 + private let baseWidth: Float = 336 + private let baseHeight: Float = 112 + + init(game: GoudGame) { + self.game = game + bird = Bird(game: game) + } + + func initialize() { + backgroundTextureId = game.loadTexture(path: "assets/sprites/background-day.png") + baseTextureId = game.loadTexture(path: "assets/sprites/base.png") + pipeTextureId = game.loadTexture(path: "assets/sprites/pipe-green.png") + + bird.initialize() + scoreCounter.initialize(game: game) + } + + func start() { + bird.reset() + pipes.removeAll() + scoreCounter.resetScore() + pipeSpawnTimer = 0 + } + + func update(deltaTime: Float) { + // Escape key still works in Simulator via hardware keyboard + if game.isKeyPressed(key: .ESCAPE) { + game.requestClose() + return + } + + // R key to restart (Simulator hardware keyboard) + if game.isKeyPressed(key: .R) { + resetGame() + return + } + + let didFlap = bird.update(deltaTime: deltaTime) + if didFlap { + let _ = game.audioPlay(data: EmbeddedAudioClips.flapWav) + } + + // Ground collision + if bird.y + Bird.height > Float(GameConstants.screenHeight) { + resetGame() + return + } + + // Top of screen + if bird.y < 0 { + resetGame() + return + } + + // Update pipes and check collisions + for pipe in pipes { + pipe.update(deltaTime: deltaTime) + + if pipe.collidesWithBird( + birdX: bird.x, birdY: bird.y, + birdWidth: Bird.width, birdHeight: Bird.height + ) { + resetGame() + return + } + } + + // Spawn new pipes + pipeSpawnTimer += deltaTime + if pipeSpawnTimer > GameConstants.pipeSpawnInterval { + pipeSpawnTimer = 0 + pipes.append(Pipe(game: game)) + } + + // Remove off-screen pipes and increment score + pipes.removeAll { pipe in + if pipe.isOffScreen() { + scoreCounter.incrementScore() + return true + } + return false + } + + draw() + } + + private func draw() { + // Layer 0: Background + game.drawSprite( + texture: backgroundTextureId, + x: backgroundWidth / 2, + y: backgroundHeight / 2, + width: backgroundWidth, + height: backgroundHeight + ) + + // Layer 1: Score + scoreCounter.draw(game: game) + + // Layer 2: Pipes + for pipe in pipes { + pipe.draw(pipeTextureId: pipeTextureId) + } + + // Layer 3: Bird + bird.draw() + + // Layer 4: Base/ground + game.drawSprite( + texture: baseTextureId, + x: baseWidth / 2, + y: Float(GameConstants.screenHeight) + baseHeight / 2, + width: baseWidth, + height: baseHeight + ) + } + + private func resetGame() { + let _ = game.audioPlay(data: EmbeddedAudioClips.resetWav) + scoreCounter.resetScore() + start() + } +} diff --git a/examples/ios/flappy_bird/Sources/FlappyBird/Movement.swift b/examples/ios/flappy_bird/Sources/FlappyBird/Movement.swift new file mode 100644 index 000000000..12e694eb4 --- /dev/null +++ b/examples/ios/flappy_bird/Sources/FlappyBird/Movement.swift @@ -0,0 +1,45 @@ +// Movement.swift -- iOS Flappy Bird +// Gravity and jump physics (identical to desktop version) + +import Foundation + +final class Movement { + var velocity: Float = 0 + private(set) var rotation: Float = 0 + + private let gravity: Float + private let jumpStrength: Float + private let rotationSmoothing: Float = 0.03 + private var jumpCooldownTimer: Float = 0 + + init(gravity: Float, jumpStrength: Float) { + self.gravity = gravity + self.jumpStrength = jumpStrength + } + + func applyGravity(deltaTime: Float) { + velocity += gravity * deltaTime * Float(GameConstants.targetFPS) + jumpCooldownTimer -= max(0, deltaTime) + } + + func tryJump(deltaTime: Float) -> Bool { + if jumpCooldownTimer <= 0 { + jump() + jumpCooldownTimer = GameConstants.jumpCooldown + return true + } + return false + } + + private func jump() { + velocity = 0 + velocity = jumpStrength * Float(GameConstants.targetFPS) + } + + func updatePosition(positionY: inout Float, deltaTime: Float) { + positionY += velocity * deltaTime + + let targetRotation = min(max(velocity * 3, -45), 45) + rotation += (targetRotation - rotation) * rotationSmoothing + } +} diff --git a/examples/ios/flappy_bird/Sources/FlappyBird/Pipe.swift b/examples/ios/flappy_bird/Sources/FlappyBird/Pipe.swift new file mode 100644 index 000000000..0228cf1f1 --- /dev/null +++ b/examples/ios/flappy_bird/Sources/FlappyBird/Pipe.swift @@ -0,0 +1,84 @@ +// Pipe.swift -- iOS Flappy Bird + +import Foundation +import GoudEngine + +final class Pipe { + private let game: GoudGame + private(set) var x: Float + let gapY: Float + + static let pipeWidth: Float = 52 + static let pipeHeight: Float = 320 + + var topPipeY: Float { gapY - GameConstants.pipeGap - Pipe.pipeHeight } + var bottomPipeY: Float { gapY + GameConstants.pipeGap } + + init(game: GoudGame) { + self.game = game + x = Float(GameConstants.screenWidth) + gapY = Float(Int.random( + in: Int(GameConstants.pipeGap)..<(Int(GameConstants.screenHeight) - Int(GameConstants.pipeGap)) + )) + } + + func update(deltaTime: Float) { + x -= GameConstants.pipeSpeed * deltaTime * Float(GameConstants.targetFPS) + } + + func draw(pipeTextureId: UInt64) { + // Top pipe (rotated 180 degrees) + game.drawSprite( + texture: pipeTextureId, + x: x + Pipe.pipeWidth / 2, + y: topPipeY + Pipe.pipeHeight / 2, + width: Pipe.pipeWidth, + height: Pipe.pipeHeight, + rotation: Float.pi + ) + + // Bottom pipe (no rotation) + game.drawSprite( + texture: pipeTextureId, + x: x + Pipe.pipeWidth / 2, + y: bottomPipeY + Pipe.pipeHeight / 2, + width: Pipe.pipeWidth, + height: Pipe.pipeHeight, + rotation: 0 + ) + } + + func isOffScreen() -> Bool { + return x + GameConstants.pipeWidth < 0 + } + + func collidesWithBird(birdX: Float, birdY: Float, birdWidth: Float, birdHeight: Float) -> Bool { + // Top pipe collision + if checkAABB( + x1: birdX, y1: birdY, w1: birdWidth, h1: birdHeight, + x2: x, y2: topPipeY, w2: GameConstants.pipeWidth, h2: Pipe.pipeHeight + ) { + return true + } + + // Bottom pipe collision + if checkAABB( + x1: birdX, y1: birdY, w1: birdWidth, h1: birdHeight, + x2: x, y2: bottomPipeY, w2: GameConstants.pipeWidth, h2: Pipe.pipeHeight + ) { + return true + } + + return false + } + + private func checkAABB( + x1: Float, y1: Float, w1: Float, h1: Float, + x2: Float, y2: Float, w2: Float, h2: Float + ) -> Bool { + return x1 < x2 + w2 && + x1 + w1 > x2 && + y1 < y2 + h2 && + y1 + h1 > y2 + } +} diff --git a/examples/ios/flappy_bird/Sources/FlappyBird/ScoreCounter.swift b/examples/ios/flappy_bird/Sources/FlappyBird/ScoreCounter.swift new file mode 100644 index 000000000..d039b8f22 --- /dev/null +++ b/examples/ios/flappy_bird/Sources/FlappyBird/ScoreCounter.swift @@ -0,0 +1,44 @@ +// ScoreCounter.swift -- iOS Flappy Bird + +import GoudEngine + +final class ScoreCounter { + private(set) var score: Int = 0 + private var digitTextures: [UInt64] = Array(repeating: 0, count: 10) + + private var xOffset: Float = 0 + private var yOffset: Float = 50 + + private let digitWidth: Float = 24 + private let digitHeight: Float = 36 + private let digitSpacing: Float = 30 + + func initialize(game: GoudGame) { + for i in 0..<10 { + digitTextures[i] = game.loadTexture(path: "assets/sprites/\(i).png") + } + xOffset = Float(GameConstants.screenWidth) / 2 - 30 + } + + func incrementScore() { + score += 1 + } + + func resetScore() { + score = 0 + } + + func draw(game: GoudGame) { + let scoreString = String(score) + for (i, char) in scoreString.enumerated() { + guard let digit = Int(String(char)) else { continue } + game.drawSprite( + texture: digitTextures[digit], + x: xOffset + Float(i) * digitSpacing + digitWidth / 2, + y: yOffset + digitHeight / 2, + width: digitWidth, + height: digitHeight + ) + } + } +} diff --git a/examples/ios/flappy_bird/Sources/FlappyBird/main.swift b/examples/ios/flappy_bird/Sources/FlappyBird/main.swift new file mode 100644 index 000000000..8dced8a3d --- /dev/null +++ b/examples/ios/flappy_bird/Sources/FlappyBird/main.swift @@ -0,0 +1,26 @@ +// main.swift -- iOS Flappy Bird +// Standalone iOS example demonstrating touch, keyboard, and mouse input. +// Runs in portrait orientation (288x624 including base). + +import GoudEngine + +let config = EngineConfig() +config + .setSize( + width: GameConstants.screenWidth, + height: GameConstants.screenHeight + UInt32(GameConstants.baseHeight) + ) + .setTitle(title: "Flappy Bird - iOS") + .setPhysicsBackend2D(backend: .SIMPLE) + +let game = config.build() +let gameManager = GameManager(game: game) + +gameManager.initialize() +gameManager.start() + +while !game.shouldClose() { + game.beginFrame(r: 0.4, g: 0.7, b: 0.9, a: 1.0) + gameManager.update(deltaTime: game.deltaTime) + game.endFrame() +} diff --git a/examples/ios/flappy_bird/assets b/examples/ios/flappy_bird/assets new file mode 120000 index 000000000..efa340b28 --- /dev/null +++ b/examples/ios/flappy_bird/assets @@ -0,0 +1 @@ +../../csharp/flappy_goud/assets \ No newline at end of file diff --git a/goud_engine/Cargo.toml b/goud_engine/Cargo.toml index 91b526071..dcf0083b6 100644 --- a/goud_engine/Cargo.toml +++ b/goud_engine/Cargo.toml @@ -12,7 +12,7 @@ categories = ["game-engines", "graphics", "rendering"] [features] default = ["desktop-native"] -native = ["wgpu-backend", "dep:cc", "dep:bindgen", "dep:cbindgen", "dep:tiled", "dep:rayon", "dep:env_logger", "dep:toml", "dep:gltf", "dep:tobj", "dep:fbxcel", "net-udp", "net-tcp", "net-ws", "rapier2d", "rapier3d"] +native = ["wgpu-backend", "dep:cc", "dep:bindgen", "dep:cbindgen", "dep:tiled", "dep:rayon", "dep:env_logger", "dep:toml", "dep:gltf", "dep:tobj", "dep:fbxcel", "net-udp", "net-tcp", "net-ws", "rapier2d", "rapier3d", "gilrs"] desktop-native = ["native", "dep:interprocess", "dep:notify", "dep:rodio"] jni-bridge = ["dep:jni"] legacy-glfw-opengl = ["dep:gl", "dep:glfw"] @@ -25,6 +25,7 @@ wgpu-backend = ["dep:wgpu", "dep:winit", "dep:naga", "dep:pollster"] web = ["wgpu-backend", "dep:wasm-bindgen", "dep:wasm-bindgen-futures", "dep:web-sys", "dep:js-sys"] rapier2d = ["dep:rapier2d"] rapier3d = ["dep:rapier3d"] +gilrs = ["dep:gilrs"] headless = [] xbox-gdk = ["wgpu-backend"] sdl-window = ["wgpu-backend", "dep:sdl2"] @@ -37,6 +38,7 @@ bindgen = { version = "0.64.0", optional = true } cgmath = "0.18.0" crossbeam-channel = "0.5" env_logger = { version = "0.11.10", optional = true } +gilrs = { version = "0.11", optional = true } gl = { version = "0.14.0", optional = true } glfw = { version = "0.59.0", optional = true } log = "0.4.17" diff --git a/goud_engine/src/core/input_manager/manager.rs b/goud_engine/src/core/input_manager/manager.rs index 72d8e6683..8b53d6f3c 100644 --- a/goud_engine/src/core/input_manager/manager.rs +++ b/goud_engine/src/core/input_manager/manager.rs @@ -9,6 +9,12 @@ use crate::core::providers::input_types::{KeyCode as Key, MouseButton, TouchPhas use super::types::{BufferedInput, GamepadState, InputBinding, TouchState}; +/// Maximum number of gamepad slots supported by the engine. +/// +/// This is the single source of truth for the gamepad slot limit used by +/// both the platform layer (gilrs bridge) and the FFI gamepad module. +pub const MAX_GAMEPAD_SLOTS: usize = 4; + /// Input management resource for ECS integration. /// /// Tracks keyboard, mouse, and gamepad input state across frames, diff --git a/goud_engine/src/core/input_manager/mod.rs b/goud_engine/src/core/input_manager/mod.rs index 7daf8c5bf..519fbccc5 100644 --- a/goud_engine/src/core/input_manager/mod.rs +++ b/goud_engine/src/core/input_manager/mod.rs @@ -83,5 +83,5 @@ mod types; #[cfg(test)] mod tests; -pub use manager::InputManager; +pub use manager::{InputManager, MAX_GAMEPAD_SLOTS}; pub use types::InputBinding; diff --git a/goud_engine/src/ffi/input/codes.rs b/goud_engine/src/ffi/input/codes.rs index f8c654e7d..06176ed21 100644 --- a/goud_engine/src/ffi/input/codes.rs +++ b/goud_engine/src/ffi/input/codes.rs @@ -109,3 +109,48 @@ mod mouse_buttons { pub const MOUSE_BUTTON_8: GoudMouseButton = 7; } pub use mouse_buttons::*; + +/// FFI-compatible gamepad button code. +pub type GoudGamepadButton = u32; + +/// FFI-compatible gamepad axis code. +pub type GoudGamepadAxis = u32; + +// Gamepad button constants (matching GamepadButton repr(u32) discriminants). +#[allow(missing_docs)] +mod gamepad_buttons { + use super::GoudGamepadButton; + /// A / Cross + pub const GAMEPAD_BUTTON_SOUTH: GoudGamepadButton = 0; + /// B / Circle + pub const GAMEPAD_BUTTON_EAST: GoudGamepadButton = 1; + /// X / Square + pub const GAMEPAD_BUTTON_WEST: GoudGamepadButton = 2; + /// Y / Triangle + pub const GAMEPAD_BUTTON_NORTH: GoudGamepadButton = 3; + pub const GAMEPAD_BUTTON_LEFT_BUMPER: GoudGamepadButton = 4; + pub const GAMEPAD_BUTTON_RIGHT_BUMPER: GoudGamepadButton = 5; + pub const GAMEPAD_BUTTON_BACK: GoudGamepadButton = 6; + pub const GAMEPAD_BUTTON_START: GoudGamepadButton = 7; + pub const GAMEPAD_BUTTON_GUIDE: GoudGamepadButton = 8; + pub const GAMEPAD_BUTTON_LEFT_STICK: GoudGamepadButton = 9; + pub const GAMEPAD_BUTTON_RIGHT_STICK: GoudGamepadButton = 10; + pub const GAMEPAD_BUTTON_DPAD_UP: GoudGamepadButton = 11; + pub const GAMEPAD_BUTTON_DPAD_RIGHT: GoudGamepadButton = 12; + pub const GAMEPAD_BUTTON_DPAD_DOWN: GoudGamepadButton = 13; + pub const GAMEPAD_BUTTON_DPAD_LEFT: GoudGamepadButton = 14; +} +pub use gamepad_buttons::*; + +// Gamepad axis constants (matching GamepadAxis repr(u32) discriminants). +#[allow(missing_docs)] +mod gamepad_axes { + use super::GoudGamepadAxis; + pub const GAMEPAD_AXIS_LEFT_X: GoudGamepadAxis = 0; + pub const GAMEPAD_AXIS_LEFT_Y: GoudGamepadAxis = 1; + pub const GAMEPAD_AXIS_RIGHT_X: GoudGamepadAxis = 2; + pub const GAMEPAD_AXIS_RIGHT_Y: GoudGamepadAxis = 3; + pub const GAMEPAD_AXIS_LEFT_TRIGGER: GoudGamepadAxis = 4; + pub const GAMEPAD_AXIS_RIGHT_TRIGGER: GoudGamepadAxis = 5; +} +pub use gamepad_axes::*; diff --git a/goud_engine/src/ffi/input/gamepad.rs b/goud_engine/src/ffi/input/gamepad.rs new file mode 100644 index 000000000..3420c9936 --- /dev/null +++ b/goud_engine/src/ffi/input/gamepad.rs @@ -0,0 +1,409 @@ +//! Gamepad input FFI functions: buttons, axes, sticks, triggers, and connection status. + +use crate::core::error::{set_last_error, GoudError}; +use crate::core::providers::input_types::GamepadAxis; +use crate::ffi::context::{GoudContextId, GOUD_INVALID_CONTEXT_ID}; + +use super::helpers::with_input; + +use crate::core::input_manager::MAX_GAMEPAD_SLOTS; + +/// Maximum supported gamepad index (exclusive). IDs >= this value are rejected. +const MAX_GAMEPAD_ID: u32 = MAX_GAMEPAD_SLOTS as u32; + +/// Converts a raw FFI axis code to a [`GamepadAxis`]. +/// +/// Returns `None` for unknown axis values. +fn axis_from_code(code: u32) -> Option { + // SAFETY: GamepadAxis is #[repr(u32)] with discriminants 0..=5 matching the + // FFI constants defined in codes.rs. We validate the range explicitly. + match code { + 0 => Some(GamepadAxis::LeftStickX), + 1 => Some(GamepadAxis::LeftStickY), + 2 => Some(GamepadAxis::RightStickX), + 3 => Some(GamepadAxis::RightStickY), + 4 => Some(GamepadAxis::LeftTrigger), + 5 => Some(GamepadAxis::RightTrigger), + _ => None, + } +} + +/// Returns `true` if the specified gamepad button is currently pressed. +/// +/// # Arguments +/// +/// * `context_id` - The context with InputManager +/// * `gamepad_id` - Gamepad index (0-3) +/// * `button` - Button code (see `GAMEPAD_BUTTON_*` constants) +/// +/// # Returns +/// +/// `true` if the button is pressed, `false` otherwise or on error. +#[no_mangle] +pub extern "C" fn goud_input_gamepad_button_pressed( + context_id: GoudContextId, + gamepad_id: u32, + button: u32, +) -> bool { + if context_id == GOUD_INVALID_CONTEXT_ID { + set_last_error(GoudError::InvalidContext); + return false; + } + if gamepad_id >= MAX_GAMEPAD_ID { + return false; + } + + with_input(context_id, |input| { + input.gamepad_button_pressed(gamepad_id as usize, button) + }) + .unwrap_or(false) +} + +/// Returns `true` if the specified gamepad button was just pressed this frame. +/// +/// # Arguments +/// +/// * `context_id` - The context with InputManager +/// * `gamepad_id` - Gamepad index (0-3) +/// * `button` - Button code (see `GAMEPAD_BUTTON_*` constants) +/// +/// # Returns +/// +/// `true` if the button was just pressed, `false` otherwise or on error. +#[no_mangle] +pub extern "C" fn goud_input_gamepad_button_just_pressed( + context_id: GoudContextId, + gamepad_id: u32, + button: u32, +) -> bool { + if context_id == GOUD_INVALID_CONTEXT_ID { + set_last_error(GoudError::InvalidContext); + return false; + } + if gamepad_id >= MAX_GAMEPAD_ID { + return false; + } + + with_input(context_id, |input| { + input.gamepad_button_just_pressed(gamepad_id as usize, button) + }) + .unwrap_or(false) +} + +/// Returns `true` if the specified gamepad button was just released this frame. +/// +/// # Arguments +/// +/// * `context_id` - The context with InputManager +/// * `gamepad_id` - Gamepad index (0-3) +/// * `button` - Button code (see `GAMEPAD_BUTTON_*` constants) +/// +/// # Returns +/// +/// `true` if the button was just released, `false` otherwise or on error. +#[no_mangle] +pub extern "C" fn goud_input_gamepad_button_just_released( + context_id: GoudContextId, + gamepad_id: u32, + button: u32, +) -> bool { + if context_id == GOUD_INVALID_CONTEXT_ID { + set_last_error(GoudError::InvalidContext); + return false; + } + if gamepad_id >= MAX_GAMEPAD_ID { + return false; + } + + with_input(context_id, |input| { + input.gamepad_button_just_released(gamepad_id as usize, button) + }) + .unwrap_or(false) +} + +/// Returns the current value of a gamepad analog axis (-1.0 to 1.0). +/// +/// # Arguments +/// +/// * `context_id` - The context with InputManager +/// * `gamepad_id` - Gamepad index (0-3) +/// * `axis` - Axis code (see `GAMEPAD_AXIS_*` constants) +/// +/// # Returns +/// +/// The axis value, or `0.0` on error. +#[no_mangle] +pub extern "C" fn goud_input_gamepad_axis( + context_id: GoudContextId, + gamepad_id: u32, + axis: u32, +) -> f32 { + if context_id == GOUD_INVALID_CONTEXT_ID { + set_last_error(GoudError::InvalidContext); + return 0.0; + } + if gamepad_id >= MAX_GAMEPAD_ID { + return 0.0; + } + let Some(engine_axis) = axis_from_code(axis) else { + return 0.0; + }; + + with_input(context_id, |input| { + input.gamepad_axis(gamepad_id as usize, engine_axis) + }) + .unwrap_or(0.0) +} + +/// Returns `true` if the specified gamepad is currently connected. +/// +/// # Arguments +/// +/// * `context_id` - The context with InputManager +/// * `gamepad_id` - Gamepad index (0-3) +/// +/// # Returns +/// +/// `true` if the gamepad is connected, `false` otherwise. +#[no_mangle] +pub extern "C" fn goud_input_gamepad_connected(context_id: GoudContextId, gamepad_id: u32) -> bool { + if context_id == GOUD_INVALID_CONTEXT_ID { + set_last_error(GoudError::InvalidContext); + return false; + } + if gamepad_id >= MAX_GAMEPAD_ID { + return false; + } + + with_input(context_id, |input| { + input.is_gamepad_connected(gamepad_id as usize) + }) + .unwrap_or(false) +} + +/// Returns the number of currently connected gamepads. +/// +/// # Arguments +/// +/// * `context_id` - The context with InputManager +/// +/// # Returns +/// +/// The count of connected gamepads (0-4), or `0` on error. +#[no_mangle] +pub extern "C" fn goud_input_gamepad_connected_count(context_id: GoudContextId) -> u32 { + if context_id == GOUD_INVALID_CONTEXT_ID { + set_last_error(GoudError::InvalidContext); + return 0; + } + + with_input(context_id, |input| input.connected_gamepad_count() as u32).unwrap_or(0) +} + +/// Sets the vibration intensity for a gamepad (0.0-1.0). +/// +/// Note: Actual vibration requires platform-layer support. This stores the +/// requested intensity in the InputManager for the platform to read. +/// +/// # Arguments +/// +/// * `context_id` - The context with InputManager +/// * `gamepad_id` - Gamepad index (0-3) +/// * `intensity` - Vibration intensity (0.0 = off, 1.0 = max) +/// +/// # Returns +/// +/// `true` on success, `false` on error. +#[no_mangle] +pub extern "C" fn goud_input_gamepad_set_vibration( + context_id: GoudContextId, + gamepad_id: u32, + intensity: f32, +) -> bool { + if context_id == GOUD_INVALID_CONTEXT_ID { + set_last_error(GoudError::InvalidContext); + return false; + } + if gamepad_id >= MAX_GAMEPAD_ID { + return false; + } + + // Vibration requires mutable access to the InputManager. + let mut registry = match crate::ffi::context::get_context_registry().lock() { + Ok(r) => r, + Err(_) => return false, + }; + let Some(context) = registry.get_mut(context_id) else { + return false; + }; + let Some(input) = context + .world_mut() + .resource_mut::() + else { + return false; + }; + input + .into_inner() + .set_gamepad_vibration(gamepad_id as usize, intensity); + true +} + +/// Writes the left stick position to the output pointers. +/// +/// # Arguments +/// +/// * `context_id` - The context with InputManager +/// * `gamepad_id` - Gamepad index (0-3) +/// * `out_x` - Pointer to store X axis value (-1.0 to 1.0) +/// * `out_y` - Pointer to store Y axis value (-1.0 to 1.0) +/// +/// # Returns +/// +/// `true` on success, `false` on error or null pointers. +/// +/// # Safety +/// +/// `out_x` and `out_y` must be valid, aligned, non-null pointers. +#[no_mangle] +pub unsafe extern "C" fn goud_input_gamepad_left_stick( + context_id: GoudContextId, + gamepad_id: u32, + out_x: *mut f32, + out_y: *mut f32, +) -> bool { + if context_id == GOUD_INVALID_CONTEXT_ID { + set_last_error(GoudError::InvalidContext); + return false; + } + if out_x.is_null() || out_y.is_null() { + set_last_error(GoudError::InvalidState( + "output pointer is null".to_string(), + )); + return false; + } + if gamepad_id >= MAX_GAMEPAD_ID { + return false; + } + + with_input(context_id, |input| { + let stick = input.gamepad_left_stick(gamepad_id as usize); + // SAFETY: out_x and out_y are non-null and valid, checked above. + unsafe { + *out_x = stick.x; + *out_y = stick.y; + } + true + }) + .unwrap_or(false) +} + +/// Writes the right stick position to the output pointers. +/// +/// # Arguments +/// +/// * `context_id` - The context with InputManager +/// * `gamepad_id` - Gamepad index (0-3) +/// * `out_x` - Pointer to store X axis value (-1.0 to 1.0) +/// * `out_y` - Pointer to store Y axis value (-1.0 to 1.0) +/// +/// # Returns +/// +/// `true` on success, `false` on error or null pointers. +/// +/// # Safety +/// +/// `out_x` and `out_y` must be valid, aligned, non-null pointers. +#[no_mangle] +pub unsafe extern "C" fn goud_input_gamepad_right_stick( + context_id: GoudContextId, + gamepad_id: u32, + out_x: *mut f32, + out_y: *mut f32, +) -> bool { + if context_id == GOUD_INVALID_CONTEXT_ID { + set_last_error(GoudError::InvalidContext); + return false; + } + if out_x.is_null() || out_y.is_null() { + set_last_error(GoudError::InvalidState( + "output pointer is null".to_string(), + )); + return false; + } + if gamepad_id >= MAX_GAMEPAD_ID { + return false; + } + + with_input(context_id, |input| { + let stick = input.gamepad_right_stick(gamepad_id as usize); + // SAFETY: out_x and out_y are non-null and valid, checked above. + unsafe { + *out_x = stick.x; + *out_y = stick.y; + } + true + }) + .unwrap_or(false) +} + +/// Returns the left trigger value (0.0 to 1.0). +/// +/// # Arguments +/// +/// * `context_id` - The context with InputManager +/// * `gamepad_id` - Gamepad index (0-3) +/// +/// # Returns +/// +/// The trigger value (0.0 = released, 1.0 = fully pressed), or `0.0` on error. +#[no_mangle] +pub extern "C" fn goud_input_gamepad_left_trigger( + context_id: GoudContextId, + gamepad_id: u32, +) -> f32 { + if context_id == GOUD_INVALID_CONTEXT_ID { + set_last_error(GoudError::InvalidContext); + return 0.0; + } + if gamepad_id >= MAX_GAMEPAD_ID { + return 0.0; + } + + with_input(context_id, |input| { + input.gamepad_left_trigger(gamepad_id as usize) + }) + .unwrap_or(0.0) +} + +/// Returns the right trigger value (0.0 to 1.0). +/// +/// # Arguments +/// +/// * `context_id` - The context with InputManager +/// * `gamepad_id` - Gamepad index (0-3) +/// +/// # Returns +/// +/// The trigger value (0.0 = released, 1.0 = fully pressed), or `0.0` on error. +#[no_mangle] +pub extern "C" fn goud_input_gamepad_right_trigger( + context_id: GoudContextId, + gamepad_id: u32, +) -> f32 { + if context_id == GOUD_INVALID_CONTEXT_ID { + set_last_error(GoudError::InvalidContext); + return 0.0; + } + if gamepad_id >= MAX_GAMEPAD_ID { + return 0.0; + } + + with_input(context_id, |input| { + input.gamepad_right_trigger(gamepad_id as usize) + }) + .unwrap_or(0.0) +} + +#[cfg(test)] +#[path = "gamepad_tests.rs"] +mod tests; diff --git a/goud_engine/src/ffi/input/gamepad_tests.rs b/goud_engine/src/ffi/input/gamepad_tests.rs new file mode 100644 index 000000000..3b427e398 --- /dev/null +++ b/goud_engine/src/ffi/input/gamepad_tests.rs @@ -0,0 +1,149 @@ +//! Tests for gamepad FFI functions: invalid context, null pointer, and boundary checks. + +use super::*; + +// ----------------------------------------------------------------------- +// Invalid context tests — all gamepad functions must reject INVALID_CONTEXT_ID +// ----------------------------------------------------------------------- + +#[test] +fn button_pressed_invalid_context_returns_false() { + assert!(!goud_input_gamepad_button_pressed( + GOUD_INVALID_CONTEXT_ID, + 0, + 0 + )); +} + +#[test] +fn button_just_pressed_invalid_context_returns_false() { + assert!(!goud_input_gamepad_button_just_pressed( + GOUD_INVALID_CONTEXT_ID, + 0, + 0 + )); +} + +#[test] +fn button_just_released_invalid_context_returns_false() { + assert!(!goud_input_gamepad_button_just_released( + GOUD_INVALID_CONTEXT_ID, + 0, + 0 + )); +} + +#[test] +fn axis_invalid_context_returns_zero() { + assert_eq!(goud_input_gamepad_axis(GOUD_INVALID_CONTEXT_ID, 0, 0), 0.0); +} + +#[test] +fn connected_invalid_context_returns_false() { + assert!(!goud_input_gamepad_connected(GOUD_INVALID_CONTEXT_ID, 0)); +} + +#[test] +fn connected_count_invalid_context_returns_zero() { + assert_eq!( + goud_input_gamepad_connected_count(GOUD_INVALID_CONTEXT_ID), + 0 + ); +} + +#[test] +fn set_vibration_invalid_context_returns_false() { + assert!(!goud_input_gamepad_set_vibration( + GOUD_INVALID_CONTEXT_ID, + 0, + 0.5 + )); +} + +#[test] +fn left_trigger_invalid_context_returns_zero() { + assert_eq!( + goud_input_gamepad_left_trigger(GOUD_INVALID_CONTEXT_ID, 0), + 0.0 + ); +} + +#[test] +fn right_trigger_invalid_context_returns_zero() { + assert_eq!( + goud_input_gamepad_right_trigger(GOUD_INVALID_CONTEXT_ID, 0), + 0.0 + ); +} + +// ----------------------------------------------------------------------- +// Null-pointer tests for stick output functions +// ----------------------------------------------------------------------- + +#[test] +fn left_stick_null_out_x_returns_false() { + let mut y: f32 = 0.0; + // SAFETY: Passing null for out_x deliberately to test the null guard. + let result = unsafe { + goud_input_gamepad_left_stick(GOUD_INVALID_CONTEXT_ID, 0, std::ptr::null_mut(), &mut y) + }; + assert!(!result); +} + +#[test] +fn left_stick_null_out_y_returns_false() { + let mut x: f32 = 0.0; + // SAFETY: Passing null for out_y deliberately to test the null guard. + let result = unsafe { + goud_input_gamepad_left_stick(GOUD_INVALID_CONTEXT_ID, 0, &mut x, std::ptr::null_mut()) + }; + assert!(!result); +} + +#[test] +fn left_stick_both_null_returns_false() { + // SAFETY: Passing null for both pointers to test the null guard. + let result = unsafe { + goud_input_gamepad_left_stick( + GOUD_INVALID_CONTEXT_ID, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + assert!(!result); +} + +#[test] +fn right_stick_null_out_x_returns_false() { + let mut y: f32 = 0.0; + // SAFETY: Passing null for out_x deliberately to test the null guard. + let result = unsafe { + goud_input_gamepad_right_stick(GOUD_INVALID_CONTEXT_ID, 0, std::ptr::null_mut(), &mut y) + }; + assert!(!result); +} + +#[test] +fn right_stick_null_out_y_returns_false() { + let mut x: f32 = 0.0; + // SAFETY: Passing null for out_y deliberately to test the null guard. + let result = unsafe { + goud_input_gamepad_right_stick(GOUD_INVALID_CONTEXT_ID, 0, &mut x, std::ptr::null_mut()) + }; + assert!(!result); +} + +#[test] +fn right_stick_both_null_returns_false() { + // SAFETY: Passing null for both pointers to test the null guard. + let result = unsafe { + goud_input_gamepad_right_stick( + GOUD_INVALID_CONTEXT_ID, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + assert!(!result); +} diff --git a/goud_engine/src/ffi/input/mod.rs b/goud_engine/src/ffi/input/mod.rs index 36df85a0f..ec538221c 100644 --- a/goud_engine/src/ffi/input/mod.rs +++ b/goud_engine/src/ffi/input/mod.rs @@ -27,6 +27,7 @@ mod actions; mod codes; +pub(crate) mod gamepad; mod helpers; mod keyboard; mod mouse; @@ -34,16 +35,23 @@ mod touch; // Re-export type aliases and constants so callers see the same public API. pub use codes::{ - GoudKeyCode, GoudMouseButton, KEY_0, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, - KEY_9, KEY_A, KEY_APOSTROPHE, KEY_B, KEY_BACKSPACE, KEY_C, KEY_COMMA, KEY_D, KEY_DELETE, - KEY_DOWN, KEY_E, KEY_END, KEY_ENTER, KEY_ESCAPE, KEY_F, KEY_F1, KEY_F10, KEY_F11, KEY_F12, - KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6, KEY_F7, KEY_F8, KEY_F9, KEY_G, KEY_H, KEY_HOME, KEY_I, - KEY_INSERT, KEY_J, KEY_K, KEY_L, KEY_LEFT, KEY_LEFT_ALT, KEY_LEFT_CONTROL, KEY_LEFT_SHIFT, - KEY_LEFT_SUPER, KEY_M, KEY_MINUS, KEY_N, KEY_O, KEY_P, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_PERIOD, - KEY_Q, KEY_R, KEY_RIGHT, KEY_RIGHT_ALT, KEY_RIGHT_CONTROL, KEY_RIGHT_SHIFT, KEY_RIGHT_SUPER, - KEY_S, KEY_SLASH, KEY_SPACE, KEY_T, KEY_TAB, KEY_U, KEY_UNKNOWN, KEY_UP, KEY_V, KEY_W, KEY_X, - KEY_Y, KEY_Z, MOUSE_BUTTON_4, MOUSE_BUTTON_5, MOUSE_BUTTON_6, MOUSE_BUTTON_7, MOUSE_BUTTON_8, - MOUSE_BUTTON_LEFT, MOUSE_BUTTON_MIDDLE, MOUSE_BUTTON_RIGHT, + GoudGamepadAxis, GoudGamepadButton, GoudKeyCode, GoudMouseButton, GAMEPAD_AXIS_LEFT_TRIGGER, + GAMEPAD_AXIS_LEFT_X, GAMEPAD_AXIS_LEFT_Y, GAMEPAD_AXIS_RIGHT_TRIGGER, GAMEPAD_AXIS_RIGHT_X, + GAMEPAD_AXIS_RIGHT_Y, GAMEPAD_BUTTON_BACK, GAMEPAD_BUTTON_DPAD_DOWN, GAMEPAD_BUTTON_DPAD_LEFT, + GAMEPAD_BUTTON_DPAD_RIGHT, GAMEPAD_BUTTON_DPAD_UP, GAMEPAD_BUTTON_EAST, GAMEPAD_BUTTON_GUIDE, + GAMEPAD_BUTTON_LEFT_BUMPER, GAMEPAD_BUTTON_LEFT_STICK, GAMEPAD_BUTTON_NORTH, + GAMEPAD_BUTTON_RIGHT_BUMPER, GAMEPAD_BUTTON_RIGHT_STICK, GAMEPAD_BUTTON_SOUTH, + GAMEPAD_BUTTON_START, GAMEPAD_BUTTON_WEST, KEY_0, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, + KEY_7, KEY_8, KEY_9, KEY_A, KEY_APOSTROPHE, KEY_B, KEY_BACKSPACE, KEY_C, KEY_COMMA, KEY_D, + KEY_DELETE, KEY_DOWN, KEY_E, KEY_END, KEY_ENTER, KEY_ESCAPE, KEY_F, KEY_F1, KEY_F10, KEY_F11, + KEY_F12, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6, KEY_F7, KEY_F8, KEY_F9, KEY_G, KEY_H, + KEY_HOME, KEY_I, KEY_INSERT, KEY_J, KEY_K, KEY_L, KEY_LEFT, KEY_LEFT_ALT, KEY_LEFT_CONTROL, + KEY_LEFT_SHIFT, KEY_LEFT_SUPER, KEY_M, KEY_MINUS, KEY_N, KEY_O, KEY_P, KEY_PAGE_DOWN, + KEY_PAGE_UP, KEY_PERIOD, KEY_Q, KEY_R, KEY_RIGHT, KEY_RIGHT_ALT, KEY_RIGHT_CONTROL, + KEY_RIGHT_SHIFT, KEY_RIGHT_SUPER, KEY_S, KEY_SLASH, KEY_SPACE, KEY_T, KEY_TAB, KEY_U, + KEY_UNKNOWN, KEY_UP, KEY_V, KEY_W, KEY_X, KEY_Y, KEY_Z, MOUSE_BUTTON_4, MOUSE_BUTTON_5, + MOUSE_BUTTON_6, MOUSE_BUTTON_7, MOUSE_BUTTON_8, MOUSE_BUTTON_LEFT, MOUSE_BUTTON_MIDDLE, + MOUSE_BUTTON_RIGHT, }; // Re-export all FFI functions. @@ -51,6 +59,13 @@ pub use actions::{ goud_input_action_just_pressed, goud_input_action_just_released, goud_input_action_pressed, goud_input_map_action_key, }; +pub use gamepad::{ + goud_input_gamepad_axis, goud_input_gamepad_button_just_pressed, + goud_input_gamepad_button_just_released, goud_input_gamepad_button_pressed, + goud_input_gamepad_connected, goud_input_gamepad_connected_count, + goud_input_gamepad_left_stick, goud_input_gamepad_left_trigger, goud_input_gamepad_right_stick, + goud_input_gamepad_right_trigger, goud_input_gamepad_set_vibration, +}; pub use keyboard::{ goud_input_key_just_pressed, goud_input_key_just_released, goud_input_key_pressed, }; diff --git a/goud_engine/src/ffi/window/mobile.rs b/goud_engine/src/ffi/window/mobile.rs new file mode 100644 index 000000000..8f427471b --- /dev/null +++ b/goud_engine/src/ffi/window/mobile.rs @@ -0,0 +1,250 @@ +//! # Mobile Responsive Scaling FFI +//! +//! FFI functions for querying DPI scale factor, safe area insets, and +//! logical/physical window sizes. These complement the existing window +//! property functions with a mobile-focused naming convention. + +use crate::core::error::{set_last_error, GoudError}; +use crate::ffi::context::{GoudContextId, GOUD_INVALID_CONTEXT_ID}; + +use super::state::WINDOW_STATES; + +/// Gets the display scale factor (DPI ratio) for the window. +/// +/// Returns 1.0 for standard density, 2.0 for Retina/xxhdpi, etc. +/// Returns 1.0 on error or invalid context. +#[no_mangle] +pub extern "C" fn goud_get_scale_factor(context_id: GoudContextId) -> f32 { + if context_id == GOUD_INVALID_CONTEXT_ID { + set_last_error(GoudError::InvalidContext); + return 1.0; + } + + WINDOW_STATES.with(|cell| { + let states = cell.borrow(); + let index = context_id.index() as usize; + states + .get(index) + .and_then(|opt| opt.as_ref()) + .map(|state| state.platform.get_scale_factor()) + .unwrap_or(1.0) + }) +} + +/// Gets the safe area insets for the current display. +/// +/// Safe area insets describe regions obscured by hardware features (notch, +/// rounded corners) or system UI (status bar, home indicator). Values are +/// in logical points. +/// +/// # Arguments +/// +/// * `context_id` - The windowed context +/// * `top` - Pointer to store the top inset +/// * `bottom` - Pointer to store the bottom inset +/// * `left` - Pointer to store the left inset +/// * `right` - Pointer to store the right inset +/// +/// # Returns +/// +/// `true` on success, `false` on error. +/// +/// # Safety +/// +/// All output pointers must be valid, non-null, and writable. +#[no_mangle] +pub unsafe extern "C" fn goud_get_safe_area_insets( + context_id: GoudContextId, + top: *mut f32, + bottom: *mut f32, + left: *mut f32, + right: *mut f32, +) -> bool { + if context_id == GOUD_INVALID_CONTEXT_ID { + set_last_error(GoudError::InvalidContext); + return false; + } + if top.is_null() || bottom.is_null() || left.is_null() || right.is_null() { + set_last_error(GoudError::InvalidState( + "output pointer is null".to_string(), + )); + return false; + } + + WINDOW_STATES.with(|cell| { + let states = cell.borrow(); + let index = context_id.index() as usize; + if let Some(Some(state)) = states.get(index) { + let insets = state.platform.get_safe_area_insets(); + // SAFETY: Caller guarantees all pointers are valid and writable. + *top = insets.top; + *bottom = insets.bottom; + *left = insets.left; + *right = insets.right; + true + } else { + set_last_error(GoudError::InvalidContext); + false + } + }) +} + +/// Gets the logical window size. +/// +/// This is an alias matching the mobile-focused naming convention. It +/// returns the same values as [`goud_window_get_size`](super::goud_window_get_size). +/// +/// # Safety +/// +/// `width` and `height` must be valid, non-null pointers. +#[no_mangle] +pub unsafe extern "C" fn goud_get_logical_size( + context_id: GoudContextId, + width: *mut u32, + height: *mut u32, +) -> bool { + // SAFETY: The caller's contract (non-null, valid, aligned pointers) is + // identical to `goud_window_get_size`'s own safety requirements, so + // forwarding the raw pointers unchanged is sound. The delegated function + // also performs its own null-pointer checks before any dereference. + super::properties::goud_window_get_size(context_id, width, height) +} + +/// Gets the physical framebuffer size. +/// +/// This is an alias matching the mobile-focused naming convention. It +/// returns the same values as +/// [`goud_window_get_framebuffer_size`](super::goud_window_get_framebuffer_size). +/// +/// # Safety +/// +/// `width` and `height` must be valid, non-null pointers. +#[no_mangle] +pub unsafe extern "C" fn goud_get_framebuffer_size( + context_id: GoudContextId, + width: *mut u32, + height: *mut u32, +) -> bool { + // SAFETY: The caller's contract (non-null, valid, aligned pointers) is + // identical to `goud_window_get_framebuffer_size`'s own safety + // requirements, so forwarding the raw pointers unchanged is sound. The + // delegated function also performs its own null-pointer checks before any + // dereference. + super::properties::goud_window_get_framebuffer_size(context_id, width, height) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn safe_area_insets_invalid_context_returns_false() { + let mut top: f32 = 0.0; + let mut bottom: f32 = 0.0; + let mut left: f32 = 0.0; + let mut right: f32 = 0.0; + // SAFETY: All pointers are valid stack references. + let result = unsafe { + goud_get_safe_area_insets( + GOUD_INVALID_CONTEXT_ID, + &mut top, + &mut bottom, + &mut left, + &mut right, + ) + }; + assert!(!result); + } + + #[test] + fn safe_area_insets_null_top_returns_false() { + let mut bottom: f32 = 0.0; + let mut left: f32 = 0.0; + let mut right: f32 = 0.0; + // SAFETY: Passing null for top deliberately to test the null guard. + let result = unsafe { + goud_get_safe_area_insets( + GOUD_INVALID_CONTEXT_ID, + std::ptr::null_mut(), + &mut bottom, + &mut left, + &mut right, + ) + }; + assert!(!result); + } + + #[test] + fn safe_area_insets_null_bottom_returns_false() { + let mut top: f32 = 0.0; + let mut left: f32 = 0.0; + let mut right: f32 = 0.0; + // SAFETY: Passing null for bottom deliberately to test the null guard. + let result = unsafe { + goud_get_safe_area_insets( + GOUD_INVALID_CONTEXT_ID, + &mut top, + std::ptr::null_mut(), + &mut left, + &mut right, + ) + }; + assert!(!result); + } + + #[test] + fn safe_area_insets_null_left_returns_false() { + let mut top: f32 = 0.0; + let mut bottom: f32 = 0.0; + let mut right: f32 = 0.0; + // SAFETY: Passing null for left deliberately to test the null guard. + let result = unsafe { + goud_get_safe_area_insets( + GOUD_INVALID_CONTEXT_ID, + &mut top, + &mut bottom, + std::ptr::null_mut(), + &mut right, + ) + }; + assert!(!result); + } + + #[test] + fn safe_area_insets_null_right_returns_false() { + let mut top: f32 = 0.0; + let mut bottom: f32 = 0.0; + let mut left: f32 = 0.0; + // SAFETY: Passing null for right deliberately to test the null guard. + let result = unsafe { + goud_get_safe_area_insets( + GOUD_INVALID_CONTEXT_ID, + &mut top, + &mut bottom, + &mut left, + std::ptr::null_mut(), + ) + }; + assert!(!result); + } + + #[test] + fn safe_area_insets_all_null_returns_false() { + // SAFETY: Passing null for all pointers to test the null guard. + let result = unsafe { + goud_get_safe_area_insets( + GOUD_INVALID_CONTEXT_ID, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + assert!(!result); + } + + #[test] + fn scale_factor_invalid_context_returns_one() { + assert_eq!(goud_get_scale_factor(GOUD_INVALID_CONTEXT_ID), 1.0); + } +} diff --git a/goud_engine/src/ffi/window/mod.rs b/goud_engine/src/ffi/window/mod.rs index 97fd914e5..1cee10055 100644 --- a/goud_engine/src/ffi/window/mod.rs +++ b/goud_engine/src/ffi/window/mod.rs @@ -47,6 +47,7 @@ mod fixed_timestep; mod lifecycle; +mod mobile; mod properties; mod state; @@ -64,6 +65,10 @@ pub use fixed_timestep::{ goud_fixed_timestep_set, goud_fixed_timestep_set_max_steps, goud_fixed_timestep_step, }; pub use lifecycle::{goud_window_create, goud_window_destroy}; +pub use mobile::{ + goud_get_framebuffer_size, goud_get_logical_size, goud_get_safe_area_insets, + goud_get_scale_factor, +}; pub use properties::{ goud_window_clear, goud_window_get_delta_time, goud_window_get_framebuffer_size, goud_window_get_fullscreen, goud_window_get_size, goud_window_poll_events, diff --git a/goud_engine/src/jni/generated.rs b/goud_engine/src/jni/generated.rs index 6dc22f2bb..7fdf42cb9 100644 --- a/goud_engine/src/jni/generated.rs +++ b/goud_engine/src/jni/generated.rs @@ -2326,6 +2326,129 @@ pub extern "system" fn Java_com_goudengine_internal_GoudGameNative_getTouchDelta }) } +#[allow(non_snake_case)] +#[no_mangle] +pub extern "system" fn Java_com_goudengine_internal_GoudGameNative_isGamepadButtonPressed<'local>( + mut env: jni::JNIEnv<'local>, + _class: jni::objects::JClass<'local>, + contextId: jni::sys::jlong, + gamepadId: jni::sys::jint, + button: jni::sys::jint, +) -> jni::sys::jboolean { + crate::jni::helpers::catch_jni_panic(&mut env, "Java_com_goudengine_internal_GoudGameNative_isGamepadButtonPressed", jni::sys::JNI_FALSE, |env| -> crate::jni::helpers::JniCallResult { + crate::jni::helpers::prepare_call(env)?; + crate::jni::helpers::clear_last_error(); + let result = crate::ffi::input::gamepad::goud_input_gamepad_button_pressed(goud_context_id_from_jlong(contextId), gamepadId as u32, button as u32); + if !result && crate::jni::helpers::last_error_code() != 0 { + let _ = crate::jni::helpers::throw_engine_error(env, "goud_input_gamepad_button_pressed", None); + return Err(()); + } + Ok(crate::jni::helpers::to_jboolean(result)) + }) +} + +#[allow(non_snake_case)] +#[no_mangle] +pub extern "system" fn Java_com_goudengine_internal_GoudGameNative_isGamepadButtonJustPressed<'local>( + mut env: jni::JNIEnv<'local>, + _class: jni::objects::JClass<'local>, + contextId: jni::sys::jlong, + gamepadId: jni::sys::jint, + button: jni::sys::jint, +) -> jni::sys::jboolean { + crate::jni::helpers::catch_jni_panic(&mut env, "Java_com_goudengine_internal_GoudGameNative_isGamepadButtonJustPressed", jni::sys::JNI_FALSE, |env| -> crate::jni::helpers::JniCallResult { + crate::jni::helpers::prepare_call(env)?; + crate::jni::helpers::clear_last_error(); + let result = crate::ffi::input::gamepad::goud_input_gamepad_button_just_pressed(goud_context_id_from_jlong(contextId), gamepadId as u32, button as u32); + if !result && crate::jni::helpers::last_error_code() != 0 { + let _ = crate::jni::helpers::throw_engine_error(env, "goud_input_gamepad_button_just_pressed", None); + return Err(()); + } + Ok(crate::jni::helpers::to_jboolean(result)) + }) +} + +#[allow(non_snake_case)] +#[no_mangle] +pub extern "system" fn Java_com_goudengine_internal_GoudGameNative_isGamepadButtonJustReleased<'local>( + mut env: jni::JNIEnv<'local>, + _class: jni::objects::JClass<'local>, + contextId: jni::sys::jlong, + gamepadId: jni::sys::jint, + button: jni::sys::jint, +) -> jni::sys::jboolean { + crate::jni::helpers::catch_jni_panic(&mut env, "Java_com_goudengine_internal_GoudGameNative_isGamepadButtonJustReleased", jni::sys::JNI_FALSE, |env| -> crate::jni::helpers::JniCallResult { + crate::jni::helpers::prepare_call(env)?; + crate::jni::helpers::clear_last_error(); + let result = crate::ffi::input::gamepad::goud_input_gamepad_button_just_released(goud_context_id_from_jlong(contextId), gamepadId as u32, button as u32); + if !result && crate::jni::helpers::last_error_code() != 0 { + let _ = crate::jni::helpers::throw_engine_error(env, "goud_input_gamepad_button_just_released", None); + return Err(()); + } + Ok(crate::jni::helpers::to_jboolean(result)) + }) +} + +#[allow(non_snake_case)] +#[no_mangle] +pub extern "system" fn Java_com_goudengine_internal_GoudGameNative_getGamepadAxis<'local>( + mut env: jni::JNIEnv<'local>, + _class: jni::objects::JClass<'local>, + contextId: jni::sys::jlong, + gamepadId: jni::sys::jint, + axis: jni::sys::jint, +) -> jni::sys::jfloat { + crate::jni::helpers::catch_jni_panic(&mut env, "Java_com_goudengine_internal_GoudGameNative_getGamepadAxis", 0.0, |env| -> crate::jni::helpers::JniCallResult { + crate::jni::helpers::prepare_call(env)?; + crate::jni::helpers::clear_last_error(); + let result = crate::ffi::input::gamepad::goud_input_gamepad_axis(goud_context_id_from_jlong(contextId), gamepadId as u32, axis as u32); + if crate::jni::helpers::last_error_code() != 0 { + let _ = crate::jni::helpers::throw_engine_error(env, "goud_input_gamepad_axis", None); + return Err(()); + } + Ok(result) + }) +} + +#[allow(non_snake_case)] +#[no_mangle] +pub extern "system" fn Java_com_goudengine_internal_GoudGameNative_isGamepadConnected<'local>( + mut env: jni::JNIEnv<'local>, + _class: jni::objects::JClass<'local>, + contextId: jni::sys::jlong, + gamepadId: jni::sys::jint, +) -> jni::sys::jboolean { + crate::jni::helpers::catch_jni_panic(&mut env, "Java_com_goudengine_internal_GoudGameNative_isGamepadConnected", jni::sys::JNI_FALSE, |env| -> crate::jni::helpers::JniCallResult { + crate::jni::helpers::prepare_call(env)?; + crate::jni::helpers::clear_last_error(); + let result = crate::ffi::input::gamepad::goud_input_gamepad_connected(goud_context_id_from_jlong(contextId), gamepadId as u32); + if !result && crate::jni::helpers::last_error_code() != 0 { + let _ = crate::jni::helpers::throw_engine_error(env, "goud_input_gamepad_connected", None); + return Err(()); + } + Ok(crate::jni::helpers::to_jboolean(result)) + }) +} + +#[allow(non_snake_case)] +#[no_mangle] +pub extern "system" fn Java_com_goudengine_internal_GoudGameNative_getGamepadConnectedCount<'local>( + mut env: jni::JNIEnv<'local>, + _class: jni::objects::JClass<'local>, + contextId: jni::sys::jlong, +) -> jni::sys::jint { + crate::jni::helpers::catch_jni_panic(&mut env, "Java_com_goudengine_internal_GoudGameNative_getGamepadConnectedCount", 0, |env| -> crate::jni::helpers::JniCallResult { + crate::jni::helpers::prepare_call(env)?; + crate::jni::helpers::clear_last_error(); + let result = crate::ffi::input::gamepad::goud_input_gamepad_connected_count(goud_context_id_from_jlong(contextId)); + if crate::jni::helpers::last_error_code() != 0 { + let _ = crate::jni::helpers::throw_engine_error(env, "goud_input_gamepad_connected_count", None); + return Err(()); + } + Ok(result as jni::sys::jint) + }) +} + #[allow(non_snake_case)] #[no_mangle] pub extern "system" fn Java_com_goudengine_internal_GoudGameNative_spawnEmpty<'local>( diff --git a/goud_engine/src/libs/platform/gilrs_bridge.rs b/goud_engine/src/libs/platform/gilrs_bridge.rs new file mode 100644 index 000000000..e797d921c --- /dev/null +++ b/goud_engine/src/libs/platform/gilrs_bridge.rs @@ -0,0 +1,106 @@ +//! gilrs gamepad integration bridge. +//! +//! Extracts gamepad polling and mapping from `winit_platform.rs` into a +//! dedicated module to keep file sizes manageable. All functions are +//! `pub(super)` so only sibling platform modules can call them. + +#![cfg(feature = "gilrs")] + +use crate::core::input_manager::InputManager; + +/// Maximum number of gamepad slots supported by the engine. +/// +/// Re-exported from [`InputManager`]'s canonical constant for use in +/// slot-bound comparisons. +use crate::core::input_manager::MAX_GAMEPAD_SLOTS; + +/// Polls all pending gilrs events and forwards them to the [`InputManager`]. +/// +/// Each physical gamepad is mapped to a slot index (0..MAX_GAMEPAD_SLOTS-1) +/// based on insertion order. Gamepads beyond the last slot are silently ignored. +pub(super) fn poll_gilrs_events(gilrs_instance: &mut gilrs::Gilrs, input: &mut InputManager) { + while let Some(gilrs::Event { id, event, .. }) = gilrs_instance.next_event() { + let Some(slot) = map_gilrs_gamepad_id(gilrs_instance, id) else { + continue; + }; + + match event { + gilrs::EventType::ButtonPressed(button, _) => { + if let Some(btn) = map_gilrs_button(button) { + input.press_gamepad_button(slot, btn); + } + } + gilrs::EventType::ButtonReleased(button, _) => { + if let Some(btn) = map_gilrs_button(button) { + input.release_gamepad_button(slot, btn); + } + } + gilrs::EventType::AxisChanged(axis, value, _) => { + if let Some(engine_axis) = map_gilrs_axis(axis) { + input.set_gamepad_axis(slot, engine_axis, value); + } + } + gilrs::EventType::Connected => { + input.set_gamepad_connected(slot, true); + log::info!("Gamepad connected: slot {slot}"); + } + gilrs::EventType::Disconnected => { + input.set_gamepad_connected(slot, false); + log::info!("Gamepad disconnected: slot {slot}"); + } + _ => {} + } + } +} + +/// Maps a gilrs `GamepadId` to a local slot index (0..MAX_GAMEPAD_SLOTS-1). +/// +/// Uses the order of connected gamepads as reported by gilrs. Returns `None` +/// if the gamepad would exceed the maximum slot count. +fn map_gilrs_gamepad_id(gilrs_instance: &gilrs::Gilrs, id: gilrs::GamepadId) -> Option { + let slot = gilrs_instance + .gamepads() + .enumerate() + .find_map(|(idx, (gid, _))| if gid == id { Some(idx) } else { None })?; + if slot < MAX_GAMEPAD_SLOTS { + Some(slot) + } else { + None + } +} + +/// Maps a gilrs button to the engine's FFI button constant (u32). +fn map_gilrs_button(button: gilrs::Button) -> Option { + Some(match button { + gilrs::Button::South => 0, + gilrs::Button::East => 1, + gilrs::Button::West => 2, + gilrs::Button::North => 3, + gilrs::Button::LeftTrigger => 4, + gilrs::Button::RightTrigger => 5, + gilrs::Button::Select => 6, + gilrs::Button::Start => 7, + gilrs::Button::Mode => 8, + gilrs::Button::LeftThumb => 9, + gilrs::Button::RightThumb => 10, + gilrs::Button::DPadUp => 11, + gilrs::Button::DPadRight => 12, + gilrs::Button::DPadDown => 13, + gilrs::Button::DPadLeft => 14, + _ => return None, + }) +} + +/// Maps a gilrs axis to the engine's [`GamepadAxis`]. +fn map_gilrs_axis(axis: gilrs::Axis) -> Option { + use crate::core::providers::input_types::GamepadAxis as GA; + Some(match axis { + gilrs::Axis::LeftStickX => GA::LeftStickX, + gilrs::Axis::LeftStickY => GA::LeftStickY, + gilrs::Axis::RightStickX => GA::RightStickX, + gilrs::Axis::RightStickY => GA::RightStickY, + gilrs::Axis::LeftZ => GA::LeftTrigger, + gilrs::Axis::RightZ => GA::RightTrigger, + _ => return None, + }) +} diff --git a/goud_engine/src/libs/platform/glfw_platform.rs b/goud_engine/src/libs/platform/glfw_platform.rs index f4cbfbccb..a7d674163 100644 --- a/goud_engine/src/libs/platform/glfw_platform.rs +++ b/goud_engine/src/libs/platform/glfw_platform.rs @@ -270,4 +270,17 @@ impl PlatformBackend for GlfwPlatform { fn get_fullscreen(&self) -> super::FullscreenMode { self.fullscreen_mode } + + fn get_scale_factor(&self) -> f32 { + let (fb_w, fb_h) = self.window.get_framebuffer_size(); + let (win_w, win_h) = self.window.get_size(); + if win_w > 0 && win_h > 0 { + // Average the X and Y scale factors for robustness. + let sx = fb_w as f32 / win_w as f32; + let sy = fb_h as f32 / win_h as f32; + ((sx + sy) * 0.5).max(1.0) + } else { + 1.0 + } + } } diff --git a/goud_engine/src/libs/platform/mod.rs b/goud_engine/src/libs/platform/mod.rs index d775ee04e..c4f499799 100644 --- a/goud_engine/src/libs/platform/mod.rs +++ b/goud_engine/src/libs/platform/mod.rs @@ -30,6 +30,8 @@ //! let mut platform = GlfwPlatform::new(&config)?; //! ``` +#[cfg(feature = "gilrs")] +mod gilrs_bridge; #[cfg(feature = "legacy-glfw-opengl")] pub mod glfw_platform; #[cfg(any( @@ -66,6 +68,25 @@ pub mod xbox_gdk_platform; ))] use crate::core::input_manager::InputManager; +/// Describes the non-interactive safe area on devices with notches, rounded +/// corners, or system bars (e.g. iOS/Android). Values are in logical points. +/// +/// This struct lives at the platform (Layer 2) level so that both the +/// [`PlatformBackend`] trait and higher layers (rendering) can reference it +/// without violating the dependency hierarchy. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +#[repr(C)] +pub struct SafeAreaInsets { + /// Inset from the top edge in logical points. + pub top: f32, + /// Inset from the bottom edge in logical points. + pub bottom: f32, + /// Inset from the left edge in logical points. + pub left: f32, + /// Inset from the right edge in logical points. + pub right: f32, +} + /// Fullscreen mode for the native window. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] #[repr(u32)] @@ -272,6 +293,23 @@ pub trait PlatformBackend { fn is_suspended(&self) -> bool { false } + + /// Returns the display scale factor (DPI ratio). + /// + /// A value of `1.0` means standard density; `2.0` corresponds to Apple + /// Retina or Android xxhdpi. The default implementation returns `1.0`. + fn get_scale_factor(&self) -> f32 { + 1.0 + } + + /// Returns the safe area insets for the current display. + /// + /// Safe area insets describe regions of the screen obscured by hardware + /// features (notch, rounded corners) or system UI (status bar, home + /// indicator). The default implementation returns zero insets. + fn get_safe_area_insets(&self) -> SafeAreaInsets { + SafeAreaInsets::default() + } } #[cfg(test)] diff --git a/goud_engine/src/libs/platform/winit_platform.rs b/goud_engine/src/libs/platform/winit_platform.rs index 2b5dd1b4f..1c2fdcd33 100644 --- a/goud_engine/src/libs/platform/winit_platform.rs +++ b/goud_engine/src/libs/platform/winit_platform.rs @@ -62,6 +62,10 @@ struct WinitState { pub struct WinitPlatform { event_loop: EventLoop<()>, state: WinitState, + /// gilrs gamepad backend for platform-independent controller support. + /// Wrapped in `Option` for graceful fallback if initialization fails. + #[cfg(feature = "gilrs")] + gilrs: Option, } impl WinitPlatform { @@ -103,7 +107,24 @@ impl WinitPlatform { )); } - let mut platform = Self { event_loop, state }; + #[cfg(feature = "gilrs")] + let gilrs = match gilrs::Gilrs::new() { + Ok(g) => { + log::info!("gilrs gamepad backend initialized"); + Some(g) + } + Err(e) => { + log::warn!("Failed to initialize gilrs gamepad backend: {e}"); + None + } + }; + + let mut platform = Self { + event_loop, + state, + #[cfg(feature = "gilrs")] + gilrs, + }; // Apply initial fullscreen mode from config. if config.fullscreen_mode != super::FullscreenMode::Windowed { @@ -160,6 +181,12 @@ impl PlatformBackend for WinitPlatform { .pump_app_events(Some(Duration::ZERO), &mut handler); } + // Poll gilrs gamepad events and forward to InputManager. + #[cfg(feature = "gilrs")] + if let Some(ref mut gilrs_instance) = self.gilrs { + super::gilrs_bridge::poll_gilrs_events(gilrs_instance, input); + } + let now = Instant::now(); let delta = (now - self.state.last_frame_time).as_secs_f32(); self.state.last_frame_time = now; @@ -233,6 +260,14 @@ impl PlatformBackend for WinitPlatform { fn is_suspended(&self) -> bool { self.state.is_suspended } + + fn get_scale_factor(&self) -> f32 { + self.state + .window + .as_ref() + .map(|w| w.scale_factor() as f32) + .unwrap_or(1.0) + } } // ============================================================================= diff --git a/goud_engine/src/rendering/mod.rs b/goud_engine/src/rendering/mod.rs index 77587d950..dcfafed62 100644 --- a/goud_engine/src/rendering/mod.rs +++ b/goud_engine/src/rendering/mod.rs @@ -17,5 +17,5 @@ pub(crate) use ui_render_system::ensure_ui_asset_loaders; pub use ui_render_system::{UiRenderStats, UiRenderSystem}; pub use viewport::{ compute_render_viewport, compute_render_viewport_with_aspect_lock, AspectRatioLock, - RenderViewport, ViewportScaleMode, + RenderViewport, SafeAreaInsets, ViewportScaleMode, }; diff --git a/goud_engine/src/rendering/sprite_batch/tests.rs b/goud_engine/src/rendering/sprite_batch/tests.rs index f47949e65..0506eeeed 100644 --- a/goud_engine/src/rendering/sprite_batch/tests.rs +++ b/goud_engine/src/rendering/sprite_batch/tests.rs @@ -183,6 +183,7 @@ fn test_gather_sprites_culls_outside_viewport() { height: 128, logical_width: 128, logical_height: 128, + scale_factor: 1.0, }); batch.gather_sprites(&world, &mut asset_server).unwrap(); diff --git a/goud_engine/src/rendering/viewport.rs b/goud_engine/src/rendering/viewport.rs index 1b027b565..d0e6b8e9e 100644 --- a/goud_engine/src/rendering/viewport.rs +++ b/goud_engine/src/rendering/viewport.rs @@ -52,8 +52,12 @@ pub enum ViewportScaleMode { Letterbox, } +// Re-export `SafeAreaInsets` from the platform layer so that higher-level +// consumers can access it without reaching into `libs::platform` directly. +pub use crate::libs::platform::SafeAreaInsets; + /// Resolved viewport rectangle plus its logical render size. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct RenderViewport { /// Viewport X origin in framebuffer pixels. pub x: i32, @@ -67,6 +71,8 @@ pub struct RenderViewport { pub logical_width: u32, /// Logical content height used for projection/layout. pub logical_height: u32, + /// Display scale factor (DPI ratio). 1.0 = standard, 2.0 = Retina/xxhdpi. + pub scale_factor: f32, } impl RenderViewport { @@ -80,6 +86,7 @@ impl RenderViewport { height: framebuffer_size.1.max(1), logical_width: framebuffer_size.0.max(1), logical_height: framebuffer_size.1.max(1), + scale_factor: 1.0, } } @@ -116,6 +123,7 @@ pub fn compute_render_viewport( height: framebuffer_height, logical_width, logical_height, + scale_factor: 1.0, }, ViewportScaleMode::Letterbox => { let framebuffer_aspect = framebuffer_width as f32 / framebuffer_height as f32; @@ -138,6 +146,7 @@ pub fn compute_render_viewport( height, logical_width, logical_height, + scale_factor: 1.0, } } } @@ -174,7 +183,7 @@ pub fn compute_render_viewport_with_aspect_lock( mod tests { use super::{ compute_render_viewport, compute_render_viewport_with_aspect_lock, AspectRatioLock, - RenderViewport, ViewportScaleMode, + RenderViewport, SafeAreaInsets, ViewportScaleMode, }; #[test] @@ -188,6 +197,7 @@ mod tests { height: 180, logical_width: 320, logical_height: 180, + scale_factor: 1.0, } ); } @@ -290,4 +300,138 @@ mod tests { assert!(viewport.y > 0, "letterbox should offset Y"); assert_eq!(viewport.width, 1024); } + + // ========================================================================= + // Scale factor and SafeAreaInsets tests + // ========================================================================= + + #[test] + fn render_viewport_default_scale_factor() { + let vp = RenderViewport::default(); + assert!((vp.scale_factor - 1.0).abs() < f32::EPSILON); + } + + #[test] + fn safe_area_insets_default_is_zero() { + let insets = SafeAreaInsets::default(); + assert!((insets.top).abs() < f32::EPSILON); + assert!((insets.bottom).abs() < f32::EPSILON); + assert!((insets.left).abs() < f32::EPSILON); + assert!((insets.right).abs() < f32::EPSILON); + } + + #[test] + fn safe_area_insets_repr_c_fields() { + let insets = SafeAreaInsets { + top: 47.0, + bottom: 34.0, + left: 0.0, + right: 0.0, + }; + assert!((insets.top - 47.0).abs() < f32::EPSILON); + assert!((insets.bottom - 34.0).abs() < f32::EPSILON); + } + + // ========================================================================= + // Mobile screen size viewport tests + // ========================================================================= + + /// iPhone 15: 393x852 logical, 1179x2556 physical, @3x scale + #[test] + fn mobile_iphone15_stretch_viewport() { + let logical = (393u32, 852u32); + let physical = (1179u32, 2556u32); + let vp = compute_render_viewport(physical, logical, ViewportScaleMode::Stretch); + assert_eq!(vp.width, physical.0); + assert_eq!(vp.height, physical.1); + assert_eq!(vp.logical_width, logical.0); + assert_eq!(vp.logical_height, logical.1); + assert_eq!(vp.x, 0); + assert_eq!(vp.y, 0); + } + + /// iPhone 15 in letterbox with a 16:9 design resolution (portrait). + /// The device aspect (~0.461) is narrower than 9:16 (~0.5625), so the + /// viewport should be pillarboxed (bars left/right) or letterboxed + /// depending on orientation. + #[test] + fn mobile_iphone15_letterbox_16x9_portrait() { + let physical = (1179u32, 2556u32); + // 9:16 portrait design + let logical = (540u32, 960u32); + let vp = compute_render_viewport(physical, logical, ViewportScaleMode::Letterbox); + let design_aspect = 540.0_f32 / 960.0; + let actual_aspect = vp.width as f32 / vp.height as f32; + assert!( + (actual_aspect - design_aspect).abs() < 0.02, + "aspect ratio should be preserved" + ); + // Either x or y should be non-zero for letterbox/pillarbox. + assert!( + vp.x > 0 || vp.y > 0, + "letterbox should offset on at least one axis" + ); + } + + /// Pixel 7: 412x915 logical, 1080x2400 physical, @2.625x scale + #[test] + fn mobile_pixel7_stretch_viewport() { + let logical = (412u32, 915u32); + let physical = (1080u32, 2400u32); + let vp = compute_render_viewport(physical, logical, ViewportScaleMode::Stretch); + assert_eq!(vp.width, physical.0); + assert_eq!(vp.height, physical.1); + assert_eq!(vp.logical_size(), logical); + } + + /// Pixel 7 letterbox with a 16:9 portrait design. + #[test] + fn mobile_pixel7_letterbox_portrait() { + let physical = (1080u32, 2400u32); + let logical = (540u32, 960u32); + let vp = compute_render_viewport(physical, logical, ViewportScaleMode::Letterbox); + let design_aspect = 540.0_f32 / 960.0; + let actual_aspect = vp.width as f32 / vp.height as f32; + assert!( + (actual_aspect - design_aspect).abs() < 0.02, + "aspect ratio should be preserved for Pixel 7" + ); + } + + /// iPad Pro 12.9": 1024x1366 logical, 2048x2732 physical, @2x scale + #[test] + fn mobile_ipad_pro_stretch_viewport() { + let logical = (1024u32, 1366u32); + let physical = (2048u32, 2732u32); + let vp = compute_render_viewport(physical, logical, ViewportScaleMode::Stretch); + assert_eq!(vp.width, physical.0); + assert_eq!(vp.height, physical.1); + assert_eq!(vp.logical_size(), logical); + } + + /// iPad Pro 12.9" letterbox with a 16:9 landscape design on a 3:4 screen. + #[test] + fn mobile_ipad_pro_letterbox_landscape() { + let physical = (2732u32, 2048u32); + let logical = (1920u32, 1080u32); + let vp = compute_render_viewport(physical, logical, ViewportScaleMode::Letterbox); + let design_aspect = 1920.0_f32 / 1080.0; + let actual_aspect = vp.width as f32 / vp.height as f32; + assert!( + (actual_aspect - design_aspect).abs() < 0.02, + "aspect ratio should be preserved for iPad Pro landscape" + ); + // 16:9 is wider than 4:3, so we expect letterbox bars top/bottom. + assert!(vp.y > 0, "should have letterbox bars on Y axis"); + } + + /// Verify that the scale factor propagates through the struct correctly. + #[test] + fn scale_factor_propagation() { + let mut vp = compute_render_viewport((1179, 2556), (393, 852), ViewportScaleMode::Stretch); + // compute_render_viewport returns 1.0 by default; the caller sets it. + assert!((vp.scale_factor - 1.0).abs() < f32::EPSILON); + vp.scale_factor = 3.0; + assert!((vp.scale_factor - 3.0).abs() < f32::EPSILON); + } } diff --git a/goud_engine/src/sdk/game/instance/mod.rs b/goud_engine/src/sdk/game/instance/mod.rs index 8e2e80abe..0f2b6523d 100644 --- a/goud_engine/src/sdk/game/instance/mod.rs +++ b/goud_engine/src/sdk/game/instance/mod.rs @@ -346,8 +346,10 @@ impl GoudGame { }, )?; let framebuffer_size = native_runtime.platform.get_framebuffer_size(); - let render_viewport = + let platform_scale = native_runtime.platform.get_scale_factor(); + let mut render_viewport = compute_render_viewport(framebuffer_size, window_size, ViewportScaleMode::Stretch); + render_viewport.scale_factor = platform_scale; let audio_manager = crate::assets::AudioManager::new().ok(); let debugger_route = @@ -442,6 +444,9 @@ impl GoudGame { self.viewport_scale_mode, self.config.aspect_ratio_lock, ); + if let Some(ref platform) = self.platform { + self.render_viewport.scale_factor = platform.get_scale_factor(); + } self.context.set_window_size(logical_size); self.apply_render_viewport(); } diff --git a/sdks/csharp/include/goud_engine.h b/sdks/csharp/include/goud_engine.h index f73a12c14..6ca2f3c0c 100644 --- a/sdks/csharp/include/goud_engine.h +++ b/sdks/csharp/include/goud_engine.h @@ -1437,6 +1437,16 @@ typedef struct FfiUiStyle { float widget_spacing; } FfiUiStyle; +/** + * FFI-compatible gamepad button code. + */ +typedef uint32_t GoudGamepadButton; + +/** + * FFI-compatible gamepad axis code. + */ +typedef uint32_t GoudGamepadAxis; + /** * Opaque shader handle for FFI. */ @@ -1696,6 +1706,60 @@ typedef struct GoudResult { */ #define ERR_INVALID_STATE 902 +/** + * A / Cross + */ +#define GAMEPAD_BUTTON_SOUTH 0 + +/** + * B / Circle + */ +#define GAMEPAD_BUTTON_EAST 1 + +/** + * X / Square + */ +#define GAMEPAD_BUTTON_WEST 2 + +/** + * Y / Triangle + */ +#define GAMEPAD_BUTTON_NORTH 3 + +#define GAMEPAD_BUTTON_LEFT_BUMPER 4 + +#define GAMEPAD_BUTTON_RIGHT_BUMPER 5 + +#define GAMEPAD_BUTTON_BACK 6 + +#define GAMEPAD_BUTTON_START 7 + +#define GAMEPAD_BUTTON_GUIDE 8 + +#define GAMEPAD_BUTTON_LEFT_STICK 9 + +#define GAMEPAD_BUTTON_RIGHT_STICK 10 + +#define GAMEPAD_BUTTON_DPAD_UP 11 + +#define GAMEPAD_BUTTON_DPAD_RIGHT 12 + +#define GAMEPAD_BUTTON_DPAD_DOWN 13 + +#define GAMEPAD_BUTTON_DPAD_LEFT 14 + +#define GAMEPAD_AXIS_LEFT_X 0 + +#define GAMEPAD_AXIS_LEFT_Y 1 + +#define GAMEPAD_AXIS_RIGHT_X 2 + +#define GAMEPAD_AXIS_RIGHT_Y 3 + +#define GAMEPAD_AXIS_LEFT_TRIGGER 4 + +#define GAMEPAD_AXIS_RIGHT_TRIGGER 5 + /** * Invalid atlas handle constant. */ @@ -2869,6 +2933,61 @@ bool goud_input_action_just_pressed(struct GoudContextId context_id, const char */ bool goud_input_action_just_released(struct GoudContextId context_id, const char *action_name); +/** + * Returns `true` if the specified gamepad button is currently pressed. + */ +bool goud_input_gamepad_button_pressed(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t button); + +/** + * Returns `true` if the specified gamepad button was just pressed this frame. + */ +bool goud_input_gamepad_button_just_pressed(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t button); + +/** + * Returns `true` if the specified gamepad button was just released this frame. + */ +bool goud_input_gamepad_button_just_released(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t button); + +/** + * Returns the current value of a gamepad analog axis (-1.0 to 1.0). + */ +float goud_input_gamepad_axis(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t axis); + +/** + * Returns `true` if the specified gamepad is currently connected. + */ +bool goud_input_gamepad_connected(struct GoudContextId context_id, uint32_t gamepad_id); + +/** + * Returns the number of currently connected gamepads. + */ +uint32_t goud_input_gamepad_connected_count(struct GoudContextId context_id); + +/** + * Sets the vibration intensity for a gamepad (0.0-1.0). + */ +bool goud_input_gamepad_set_vibration(struct GoudContextId context_id, uint32_t gamepad_id, float intensity); + +/** + * Writes the left stick position to the output pointers. + */ +bool goud_input_gamepad_left_stick(struct GoudContextId context_id, uint32_t gamepad_id, float *out_x, float *out_y); + +/** + * Writes the right stick position to the output pointers. + */ +bool goud_input_gamepad_right_stick(struct GoudContextId context_id, uint32_t gamepad_id, float *out_x, float *out_y); + +/** + * Returns the left trigger value (0.0 to 1.0). + */ +float goud_input_gamepad_left_trigger(struct GoudContextId context_id, uint32_t gamepad_id); + +/** + * Returns the right trigger value (0.0 to 1.0). + */ +float goud_input_gamepad_right_trigger(struct GoudContextId context_id, uint32_t gamepad_id); + /** * Returns `true` if the specified key is currently pressed. */ @@ -5183,6 +5302,26 @@ bool goud_fixed_timestep_set(struct GoudContextId context_id, float step); */ bool goud_fixed_timestep_set_max_steps(struct GoudContextId context_id, uint32_t max); +/** + * Gets the display scale factor (DPI ratio) for the window. + */ +float goud_get_scale_factor(struct GoudContextId context_id); + +/** + * Gets the safe area insets for the current display. + */ +bool goud_get_safe_area_insets(struct GoudContextId context_id, float *top, float *bottom, float *left, float *right); + +/** + * Gets the logical window size. + */ +bool goud_get_logical_size(struct GoudContextId context_id, uint32_t *width, uint32_t *height); + +/** + * Gets the physical framebuffer size. + */ +bool goud_get_framebuffer_size(struct GoudContextId context_id, uint32_t *width, uint32_t *height); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/sdks/go/include/goud_engine.h b/sdks/go/include/goud_engine.h index f73a12c14..03749202f 100644 --- a/sdks/go/include/goud_engine.h +++ b/sdks/go/include/goud_engine.h @@ -40,6 +40,11 @@ extern "C" { */ #define PROTOCOL_VERSION 1 +/** + * Maximum number of gamepad slots supported by the engine. + */ +#define MAX_GAMEPAD_SLOTS 4 + /** * Sentinel value for an invalid entity ID. */ @@ -1437,6 +1442,16 @@ typedef struct FfiUiStyle { float widget_spacing; } FfiUiStyle; +/** + * FFI-compatible gamepad button code. + */ +typedef uint32_t GoudGamepadButton; + +/** + * FFI-compatible gamepad axis code. + */ +typedef uint32_t GoudGamepadAxis; + /** * Opaque shader handle for FFI. */ @@ -1696,6 +1711,60 @@ typedef struct GoudResult { */ #define ERR_INVALID_STATE 902 +/** + * A / Cross + */ +#define GAMEPAD_BUTTON_SOUTH 0 + +/** + * B / Circle + */ +#define GAMEPAD_BUTTON_EAST 1 + +/** + * X / Square + */ +#define GAMEPAD_BUTTON_WEST 2 + +/** + * Y / Triangle + */ +#define GAMEPAD_BUTTON_NORTH 3 + +#define GAMEPAD_BUTTON_LEFT_BUMPER 4 + +#define GAMEPAD_BUTTON_RIGHT_BUMPER 5 + +#define GAMEPAD_BUTTON_BACK 6 + +#define GAMEPAD_BUTTON_START 7 + +#define GAMEPAD_BUTTON_GUIDE 8 + +#define GAMEPAD_BUTTON_LEFT_STICK 9 + +#define GAMEPAD_BUTTON_RIGHT_STICK 10 + +#define GAMEPAD_BUTTON_DPAD_UP 11 + +#define GAMEPAD_BUTTON_DPAD_RIGHT 12 + +#define GAMEPAD_BUTTON_DPAD_DOWN 13 + +#define GAMEPAD_BUTTON_DPAD_LEFT 14 + +#define GAMEPAD_AXIS_LEFT_X 0 + +#define GAMEPAD_AXIS_LEFT_Y 1 + +#define GAMEPAD_AXIS_RIGHT_X 2 + +#define GAMEPAD_AXIS_RIGHT_Y 3 + +#define GAMEPAD_AXIS_LEFT_TRIGGER 4 + +#define GAMEPAD_AXIS_RIGHT_TRIGGER 5 + /** * Invalid atlas handle constant. */ @@ -2869,6 +2938,61 @@ bool goud_input_action_just_pressed(struct GoudContextId context_id, const char */ bool goud_input_action_just_released(struct GoudContextId context_id, const char *action_name); +/** + * Returns `true` if the specified gamepad button is currently pressed. + */ +bool goud_input_gamepad_button_pressed(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t button); + +/** + * Returns `true` if the specified gamepad button was just pressed this frame. + */ +bool goud_input_gamepad_button_just_pressed(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t button); + +/** + * Returns `true` if the specified gamepad button was just released this frame. + */ +bool goud_input_gamepad_button_just_released(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t button); + +/** + * Returns the current value of a gamepad analog axis (-1.0 to 1.0). + */ +float goud_input_gamepad_axis(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t axis); + +/** + * Returns `true` if the specified gamepad is currently connected. + */ +bool goud_input_gamepad_connected(struct GoudContextId context_id, uint32_t gamepad_id); + +/** + * Returns the number of currently connected gamepads. + */ +uint32_t goud_input_gamepad_connected_count(struct GoudContextId context_id); + +/** + * Sets the vibration intensity for a gamepad (0.0-1.0). + */ +bool goud_input_gamepad_set_vibration(struct GoudContextId context_id, uint32_t gamepad_id, float intensity); + +/** + * Writes the left stick position to the output pointers. + */ +bool goud_input_gamepad_left_stick(struct GoudContextId context_id, uint32_t gamepad_id, float *out_x, float *out_y); + +/** + * Writes the right stick position to the output pointers. + */ +bool goud_input_gamepad_right_stick(struct GoudContextId context_id, uint32_t gamepad_id, float *out_x, float *out_y); + +/** + * Returns the left trigger value (0.0 to 1.0). + */ +float goud_input_gamepad_left_trigger(struct GoudContextId context_id, uint32_t gamepad_id); + +/** + * Returns the right trigger value (0.0 to 1.0). + */ +float goud_input_gamepad_right_trigger(struct GoudContextId context_id, uint32_t gamepad_id); + /** * Returns `true` if the specified key is currently pressed. */ @@ -5183,6 +5307,26 @@ bool goud_fixed_timestep_set(struct GoudContextId context_id, float step); */ bool goud_fixed_timestep_set_max_steps(struct GoudContextId context_id, uint32_t max); +/** + * Gets the display scale factor (DPI ratio) for the window. + */ +float goud_get_scale_factor(struct GoudContextId context_id); + +/** + * Gets the safe area insets for the current display. + */ +bool goud_get_safe_area_insets(struct GoudContextId context_id, float *top, float *bottom, float *left, float *right); + +/** + * Gets the logical window size. + */ +bool goud_get_logical_size(struct GoudContextId context_id, uint32_t *width, uint32_t *height); + +/** + * Gets the physical framebuffer size. + */ +bool goud_get_framebuffer_size(struct GoudContextId context_id, uint32_t *width, uint32_t *height); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/sdks/kotlin/src/main/java/com/goudengine/internal/GoudGameNative.java b/sdks/kotlin/src/main/java/com/goudengine/internal/GoudGameNative.java index 97b73fc78..4250809ac 100644 --- a/sdks/kotlin/src/main/java/com/goudengine/internal/GoudGameNative.java +++ b/sdks/kotlin/src/main/java/com/goudengine/internal/GoudGameNative.java @@ -274,6 +274,12 @@ private GoudGameNative() {} public static native int audioUpdateCrossfades(long contextId, float deltaSec); public static native int audioActiveCrossfadeCount(long contextId); public static native int audioActivate(long contextId); + public static native boolean isGamepadButtonPressed(long contextId, int gamepadId, int button); + public static native boolean isGamepadButtonJustPressed(long contextId, int gamepadId, int button); + public static native boolean isGamepadButtonJustReleased(long contextId, int gamepadId, int button); + public static native float getGamepadAxis(long contextId, int gamepadId, int axis); + public static native boolean isGamepadConnected(long contextId, int gamepadId); + public static native int getGamepadConnectedCount(long contextId); public static native boolean checkHotSwapShortcut(long contextId); public static native long p2pCreateMesh(long contextId, int protocol, int port, P2pMeshConfig config); public static native long p2pJoinMesh(long contextId, int protocol, String address, int port, P2pMeshConfig config); diff --git a/sdks/kotlin/src/main/kotlin/com/goudengine/core/GoudGame.kt b/sdks/kotlin/src/main/kotlin/com/goudengine/core/GoudGame.kt index c5a927e6c..05e9fcf36 100644 --- a/sdks/kotlin/src/main/kotlin/com/goudengine/core/GoudGame.kt +++ b/sdks/kotlin/src/main/kotlin/com/goudengine/core/GoudGame.kt @@ -137,6 +137,26 @@ class GoudGame internal constructor(internal val contextId: Long) : AutoCloseabl return com.goudengine.types.Vec2.fromNative(r) } + // -- Gamepad input -------------------------------------------------------- + + fun isGamepadButtonPressed(gamepadId: Int, button: com.goudengine.input.GamepadButton): Boolean = + GoudGameNative.isGamepadButtonPressed(contextId, gamepadId, button.value) + + fun isGamepadButtonJustPressed(gamepadId: Int, button: com.goudengine.input.GamepadButton): Boolean = + GoudGameNative.isGamepadButtonJustPressed(contextId, gamepadId, button.value) + + fun isGamepadButtonJustReleased(gamepadId: Int, button: com.goudengine.input.GamepadButton): Boolean = + GoudGameNative.isGamepadButtonJustReleased(contextId, gamepadId, button.value) + + fun getGamepadAxis(gamepadId: Int, axis: com.goudengine.input.GamepadAxis): Float = + GoudGameNative.getGamepadAxis(contextId, gamepadId, axis.value) + + fun isGamepadConnected(gamepadId: Int): Boolean = + GoudGameNative.isGamepadConnected(contextId, gamepadId) + + fun getGamepadConnectedCount(): Int = + GoudGameNative.getGamepadConnectedCount(contextId) + fun spawnEmpty(): com.goudengine.core.EntityHandle { val r = GoudGameNative.spawnEmpty(contextId) return com.goudengine.core.EntityHandle(r) diff --git a/sdks/kotlin/src/main/kotlin/com/goudengine/input/GamepadAxis.kt b/sdks/kotlin/src/main/kotlin/com/goudengine/input/GamepadAxis.kt new file mode 100644 index 000000000..2ef1e908b --- /dev/null +++ b/sdks/kotlin/src/main/kotlin/com/goudengine/input/GamepadAxis.kt @@ -0,0 +1,17 @@ +// This file is AUTO-GENERATED by GoudEngine codegen. DO NOT EDIT. +package com.goudengine.input + +/** Gamepad axis identifiers matching the FFI GAMEPAD_AXIS_* constants. */ +enum class GamepadAxis(val value: Int) { + LeftX(0), + LeftY(1), + RightX(2), + RightY(3), + LeftTrigger(4), + RightTrigger(5); + + companion object { + fun fromValue(value: Int): GamepadAxis? = + entries.firstOrNull { it.value == value } + } +} diff --git a/sdks/kotlin/src/main/kotlin/com/goudengine/input/GamepadButton.kt b/sdks/kotlin/src/main/kotlin/com/goudengine/input/GamepadButton.kt new file mode 100644 index 000000000..7887f097f --- /dev/null +++ b/sdks/kotlin/src/main/kotlin/com/goudengine/input/GamepadButton.kt @@ -0,0 +1,26 @@ +// This file is AUTO-GENERATED by GoudEngine codegen. DO NOT EDIT. +package com.goudengine.input + +/** Gamepad button identifiers matching the FFI GAMEPAD_BUTTON_* constants. */ +enum class GamepadButton(val value: Int) { + South(0), + East(1), + West(2), + North(3), + LeftBumper(4), + RightBumper(5), + Back(6), + Start(7), + Guide(8), + LeftStick(9), + RightStick(10), + DpadUp(11), + DpadRight(12), + DpadDown(13), + DpadLeft(14); + + companion object { + fun fromValue(value: Int): GamepadButton? = + entries.firstOrNull { it.value == value } + } +} diff --git a/sdks/python/goudengine/include/goud_engine.h b/sdks/python/goudengine/include/goud_engine.h index f73a12c14..6ca2f3c0c 100644 --- a/sdks/python/goudengine/include/goud_engine.h +++ b/sdks/python/goudengine/include/goud_engine.h @@ -1437,6 +1437,16 @@ typedef struct FfiUiStyle { float widget_spacing; } FfiUiStyle; +/** + * FFI-compatible gamepad button code. + */ +typedef uint32_t GoudGamepadButton; + +/** + * FFI-compatible gamepad axis code. + */ +typedef uint32_t GoudGamepadAxis; + /** * Opaque shader handle for FFI. */ @@ -1696,6 +1706,60 @@ typedef struct GoudResult { */ #define ERR_INVALID_STATE 902 +/** + * A / Cross + */ +#define GAMEPAD_BUTTON_SOUTH 0 + +/** + * B / Circle + */ +#define GAMEPAD_BUTTON_EAST 1 + +/** + * X / Square + */ +#define GAMEPAD_BUTTON_WEST 2 + +/** + * Y / Triangle + */ +#define GAMEPAD_BUTTON_NORTH 3 + +#define GAMEPAD_BUTTON_LEFT_BUMPER 4 + +#define GAMEPAD_BUTTON_RIGHT_BUMPER 5 + +#define GAMEPAD_BUTTON_BACK 6 + +#define GAMEPAD_BUTTON_START 7 + +#define GAMEPAD_BUTTON_GUIDE 8 + +#define GAMEPAD_BUTTON_LEFT_STICK 9 + +#define GAMEPAD_BUTTON_RIGHT_STICK 10 + +#define GAMEPAD_BUTTON_DPAD_UP 11 + +#define GAMEPAD_BUTTON_DPAD_RIGHT 12 + +#define GAMEPAD_BUTTON_DPAD_DOWN 13 + +#define GAMEPAD_BUTTON_DPAD_LEFT 14 + +#define GAMEPAD_AXIS_LEFT_X 0 + +#define GAMEPAD_AXIS_LEFT_Y 1 + +#define GAMEPAD_AXIS_RIGHT_X 2 + +#define GAMEPAD_AXIS_RIGHT_Y 3 + +#define GAMEPAD_AXIS_LEFT_TRIGGER 4 + +#define GAMEPAD_AXIS_RIGHT_TRIGGER 5 + /** * Invalid atlas handle constant. */ @@ -2869,6 +2933,61 @@ bool goud_input_action_just_pressed(struct GoudContextId context_id, const char */ bool goud_input_action_just_released(struct GoudContextId context_id, const char *action_name); +/** + * Returns `true` if the specified gamepad button is currently pressed. + */ +bool goud_input_gamepad_button_pressed(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t button); + +/** + * Returns `true` if the specified gamepad button was just pressed this frame. + */ +bool goud_input_gamepad_button_just_pressed(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t button); + +/** + * Returns `true` if the specified gamepad button was just released this frame. + */ +bool goud_input_gamepad_button_just_released(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t button); + +/** + * Returns the current value of a gamepad analog axis (-1.0 to 1.0). + */ +float goud_input_gamepad_axis(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t axis); + +/** + * Returns `true` if the specified gamepad is currently connected. + */ +bool goud_input_gamepad_connected(struct GoudContextId context_id, uint32_t gamepad_id); + +/** + * Returns the number of currently connected gamepads. + */ +uint32_t goud_input_gamepad_connected_count(struct GoudContextId context_id); + +/** + * Sets the vibration intensity for a gamepad (0.0-1.0). + */ +bool goud_input_gamepad_set_vibration(struct GoudContextId context_id, uint32_t gamepad_id, float intensity); + +/** + * Writes the left stick position to the output pointers. + */ +bool goud_input_gamepad_left_stick(struct GoudContextId context_id, uint32_t gamepad_id, float *out_x, float *out_y); + +/** + * Writes the right stick position to the output pointers. + */ +bool goud_input_gamepad_right_stick(struct GoudContextId context_id, uint32_t gamepad_id, float *out_x, float *out_y); + +/** + * Returns the left trigger value (0.0 to 1.0). + */ +float goud_input_gamepad_left_trigger(struct GoudContextId context_id, uint32_t gamepad_id); + +/** + * Returns the right trigger value (0.0 to 1.0). + */ +float goud_input_gamepad_right_trigger(struct GoudContextId context_id, uint32_t gamepad_id); + /** * Returns `true` if the specified key is currently pressed. */ @@ -5183,6 +5302,26 @@ bool goud_fixed_timestep_set(struct GoudContextId context_id, float step); */ bool goud_fixed_timestep_set_max_steps(struct GoudContextId context_id, uint32_t max); +/** + * Gets the display scale factor (DPI ratio) for the window. + */ +float goud_get_scale_factor(struct GoudContextId context_id); + +/** + * Gets the safe area insets for the current display. + */ +bool goud_get_safe_area_insets(struct GoudContextId context_id, float *top, float *bottom, float *left, float *right); + +/** + * Gets the logical window size. + */ +bool goud_get_logical_size(struct GoudContextId context_id, uint32_t *width, uint32_t *height); + +/** + * Gets the physical framebuffer size. + */ +bool goud_get_framebuffer_size(struct GoudContextId context_id, uint32_t *width, uint32_t *height); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/sdks/swift/Sources/CGoudEngine/include/goud_engine.h b/sdks/swift/Sources/CGoudEngine/include/goud_engine.h index f73a12c14..03749202f 100644 --- a/sdks/swift/Sources/CGoudEngine/include/goud_engine.h +++ b/sdks/swift/Sources/CGoudEngine/include/goud_engine.h @@ -40,6 +40,11 @@ extern "C" { */ #define PROTOCOL_VERSION 1 +/** + * Maximum number of gamepad slots supported by the engine. + */ +#define MAX_GAMEPAD_SLOTS 4 + /** * Sentinel value for an invalid entity ID. */ @@ -1437,6 +1442,16 @@ typedef struct FfiUiStyle { float widget_spacing; } FfiUiStyle; +/** + * FFI-compatible gamepad button code. + */ +typedef uint32_t GoudGamepadButton; + +/** + * FFI-compatible gamepad axis code. + */ +typedef uint32_t GoudGamepadAxis; + /** * Opaque shader handle for FFI. */ @@ -1696,6 +1711,60 @@ typedef struct GoudResult { */ #define ERR_INVALID_STATE 902 +/** + * A / Cross + */ +#define GAMEPAD_BUTTON_SOUTH 0 + +/** + * B / Circle + */ +#define GAMEPAD_BUTTON_EAST 1 + +/** + * X / Square + */ +#define GAMEPAD_BUTTON_WEST 2 + +/** + * Y / Triangle + */ +#define GAMEPAD_BUTTON_NORTH 3 + +#define GAMEPAD_BUTTON_LEFT_BUMPER 4 + +#define GAMEPAD_BUTTON_RIGHT_BUMPER 5 + +#define GAMEPAD_BUTTON_BACK 6 + +#define GAMEPAD_BUTTON_START 7 + +#define GAMEPAD_BUTTON_GUIDE 8 + +#define GAMEPAD_BUTTON_LEFT_STICK 9 + +#define GAMEPAD_BUTTON_RIGHT_STICK 10 + +#define GAMEPAD_BUTTON_DPAD_UP 11 + +#define GAMEPAD_BUTTON_DPAD_RIGHT 12 + +#define GAMEPAD_BUTTON_DPAD_DOWN 13 + +#define GAMEPAD_BUTTON_DPAD_LEFT 14 + +#define GAMEPAD_AXIS_LEFT_X 0 + +#define GAMEPAD_AXIS_LEFT_Y 1 + +#define GAMEPAD_AXIS_RIGHT_X 2 + +#define GAMEPAD_AXIS_RIGHT_Y 3 + +#define GAMEPAD_AXIS_LEFT_TRIGGER 4 + +#define GAMEPAD_AXIS_RIGHT_TRIGGER 5 + /** * Invalid atlas handle constant. */ @@ -2869,6 +2938,61 @@ bool goud_input_action_just_pressed(struct GoudContextId context_id, const char */ bool goud_input_action_just_released(struct GoudContextId context_id, const char *action_name); +/** + * Returns `true` if the specified gamepad button is currently pressed. + */ +bool goud_input_gamepad_button_pressed(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t button); + +/** + * Returns `true` if the specified gamepad button was just pressed this frame. + */ +bool goud_input_gamepad_button_just_pressed(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t button); + +/** + * Returns `true` if the specified gamepad button was just released this frame. + */ +bool goud_input_gamepad_button_just_released(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t button); + +/** + * Returns the current value of a gamepad analog axis (-1.0 to 1.0). + */ +float goud_input_gamepad_axis(struct GoudContextId context_id, uint32_t gamepad_id, uint32_t axis); + +/** + * Returns `true` if the specified gamepad is currently connected. + */ +bool goud_input_gamepad_connected(struct GoudContextId context_id, uint32_t gamepad_id); + +/** + * Returns the number of currently connected gamepads. + */ +uint32_t goud_input_gamepad_connected_count(struct GoudContextId context_id); + +/** + * Sets the vibration intensity for a gamepad (0.0-1.0). + */ +bool goud_input_gamepad_set_vibration(struct GoudContextId context_id, uint32_t gamepad_id, float intensity); + +/** + * Writes the left stick position to the output pointers. + */ +bool goud_input_gamepad_left_stick(struct GoudContextId context_id, uint32_t gamepad_id, float *out_x, float *out_y); + +/** + * Writes the right stick position to the output pointers. + */ +bool goud_input_gamepad_right_stick(struct GoudContextId context_id, uint32_t gamepad_id, float *out_x, float *out_y); + +/** + * Returns the left trigger value (0.0 to 1.0). + */ +float goud_input_gamepad_left_trigger(struct GoudContextId context_id, uint32_t gamepad_id); + +/** + * Returns the right trigger value (0.0 to 1.0). + */ +float goud_input_gamepad_right_trigger(struct GoudContextId context_id, uint32_t gamepad_id); + /** * Returns `true` if the specified key is currently pressed. */ @@ -5183,6 +5307,26 @@ bool goud_fixed_timestep_set(struct GoudContextId context_id, float step); */ bool goud_fixed_timestep_set_max_steps(struct GoudContextId context_id, uint32_t max); +/** + * Gets the display scale factor (DPI ratio) for the window. + */ +float goud_get_scale_factor(struct GoudContextId context_id); + +/** + * Gets the safe area insets for the current display. + */ +bool goud_get_safe_area_insets(struct GoudContextId context_id, float *top, float *bottom, float *left, float *right); + +/** + * Gets the logical window size. + */ +bool goud_get_logical_size(struct GoudContextId context_id, uint32_t *width, uint32_t *height); + +/** + * Gets the physical framebuffer size. + */ +bool goud_get_framebuffer_size(struct GoudContextId context_id, uint32_t *width, uint32_t *height); + #ifdef __cplusplus } /* extern "C" */ #endif