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