diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000000..18ff10703d --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,47 @@ +name: Android APK + +on: + push: + branches: [port, touch-controls] + pull_request: + branches: [port] + workflow_dispatch: + +jobs: + build-android: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Install NDK and CMake + run: | + yes | sdkmanager --install "ndk;26.1.10909125" "cmake;3.22.1" + echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/26.1.10909125" >> $GITHUB_ENV + + - name: Install python deps for the asset preprocessor + run: sudo apt-get update && sudo apt-get install -y python3 + + - name: Build debug APK + working-directory: android + run: | + chmod +x ./gradlew + ./gradlew :app:assembleDebug --no-daemon --stacktrace + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: pd-android-debug + path: android/app/build/outputs/apk/debug/*.apk + if-no-files-found: error diff --git a/README.md b/README.md index dbc7cd2582..26c61afd2f 100644 --- a/README.md +++ b/README.md @@ -1,215 +1,107 @@ -# Perfect Dark port +# Perfect Dark — Android port with touch controls -This repository contains a work-in-progress port of the [Perfect Dark decompilation](https://github.com/n64decomp/perfect_dark) to modern platforms. +Port no oficial de **Perfect Dark** (Nintendo 64, 2000) para Android con **controles táctiles nativos**. Basado en el port de PC del equipo de [`fgsfdsfgs/perfect_dark`](https://github.com/fgsfdsfgs/perfect_dark) y en el trabajo inicial de Android de [`izzy2lost/perfect_dark`](https://github.com/izzy2lost/perfect_dark). -To run the port, you must already have a Perfect Dark ROM, specifically one of the following: -* `ntsc-final`/`US V1.1`/`US Rev 1` (md5 `e03b088b6ac9e0080440efed07c1e40f`). - **This is the recommended version to use**. - Called `NTSC version 8.7 final` on the boot screen. -* `ntsc-1.0`/`US V1.0` (md5 `7f4171b0c8d17815be37913f535e4e93`). - Technically supported, but not recommended. - Called `NTSC version 8.7 final` on the boot screen as well. -* `jpn-final` (md5 `538d2b75945eae069b29c46193e74790`). - Technically supported, but requires a separate custom-built executable. - Called `JPN version 8.9 final` on the boot screen. -* `pal-final` (md5 `d9b5cd305d228424891ce38e71bc9213`). - Technically supported, but requires a separate custom-built executable. - Called `PAL 8.7 final` on the boot screen. +> Necesitas aportar tu propia copia legal del ROM — aquí no se distribuye. -## Status +--- -The game is in a mostly functional state, with both singleplayer and split-screen multiplayer modes fully working. -There are minor graphics- and gameplay-related issues, and possibly occasional crashes. +## Características -**The following extra features are implemented:** -* mouselook; -* dual analog controller support; -* widescreen resolution support; -* configurable field of view; -* 60 FPS support, including fixes for some framerate-related issues; -* fixes for a couple original bugs and crashes; -* basic mod support, currently enough to load a few custom levels; -* slightly expanded memory heap size; -* experimental high framerate support (up to 240 FPS): - * enable `Uncap Tickrate` in `Extended Video Options` to activate; - * in practice the game will have issues running faster than ~165 FPS, so use VSync or `Video.FramerateLimit` to cap it. -* emulate the Transfer Pak functionality the game has on the Nintendo 64 to unlock some cheats automatically. - -**The following platforms are officially supported and tested:** -* Windows 7+: i686, x86_64 -* Linux: i686, x86_64 -* MacOS: x86_64 (OS 10.9+), arm64 (OS 11.0+) -* Nintendo Switch: arm64 -* Android: arm64-v8a, armeabi-v7a, x86_64, x86 - -## Download - -Latest [automatic builds](https://github.com/fgsfdsfgs/perfect_dark/releases/tag/ci-dev-build) for supported platforms: -* [x86_64-windows](https://github.com/fgsfdsfgs/perfect_dark/releases/download/ci-dev-build/pd-x86_64-windows.zip) -* [i686-windows](https://github.com/fgsfdsfgs/perfect_dark/releases/download/ci-dev-build/pd-i686-windows.zip) -* [x86_64-linux](https://github.com/fgsfdsfgs/perfect_dark/releases/download/ci-dev-build/pd-x86_64-linux.tar.gz) -* [i686-linux](https://github.com/fgsfdsfgs/perfect_dark/releases/download/ci-dev-build/pd-i686-linux.tar.gz) -* [arm64-nswitch](https://github.com/fgsfdsfgs/perfect_dark/releases/download/ci-dev-build/pd-arm64-nswitch.zip) - -If you are looking for netplay builds (the `port-net` branch), see [this link](https://github.com/fgsfdsfgs/perfect_dark/blob/port-net/README.md#download). - -## Running - -You must already have a Perfect Dark ROM to run the game, as specified above. - -This assumes that you're using an x86_64 build. If you aren't, replace `x86_64` below with your arch (e.g. `i686`). - -1. Create a directory named `data` next to `pd.x86_64` if it's not there. -2. Put your Perfect Dark NTSC ROM named `pd.ntsc-final.z64` into it. -3. Run the `pd.x86_64` executable. - -If you want to use a PAL or JPN ROM instead, put them into the `data` directory and run the appropriate executable: -* PAL: ROM name `pd.pal-final.z64`, executable name `pd.pal.x86_64`. -* JPN: ROM name `pd.jpn-final.z64`, executable name `pd.jpn.x86_64`. - -Optionally, you can also put your Perfect Dark for GameBoy Color ROM named `pd.gbc` in the `data` directory if you want to emulate having the Nintendo 64's Transfer Pak and unlock some cheats automatically. - -Optionally, you can move the data folder to `~/.local/share/perfectdark` on Linux or `~/Library/Application Support/perfectdark` on MacOS. - -Additional information can be found in the [wiki](https://github.com/fgsfdsfgs/perfect_dark/wiki). - -A GPU supporting OpenGL 3.0/ES3.0 or above is required to run the port. - -### Installing the Nintendo Switch version - -The Nintendo Switch build ZIP comes with all 3 regions in different folders: `perfectdark`, `perfectdark_pal` and `perfectdark_jpn`. - -Take the folder for the region you want and put it into the `/switch` folder on your SD card, then put your ROM into the `data` folder inside of the folder you extracted as described above. - -## Controls - -1964GEPD-style and Xbox-style bindings are implemented. - -N64 pad buttons X and Y (or `X_BUTTON`, `Y_BUTTON` in the code) refer to the reserved buttons `0x40` and `0x80`, which are also leveraged by 1964GEPD. - -Support for one controller, two-stick configurations are enabled for 1.2. - -Note that the mouse only controls player 1. - -Controls can be rebound in `pd.ini`. Default control scheme is as follows: - -| Action | Keyboard and mouse | Xbox pad | N64 pad | -| - | - | - | - | -| Fire / Accept | LMB/Space | RT | Z Trigger | -| Aim mode | RMB/Z | LT | R Trigger | -| Use / Cancel | E | N/A | B | -| Use / Accept | N/A | A | A | -| Crouch cycle | N/A | L3 | `0x80000000` (Extra) | -| Half-Crouch | Shift | N/A | `0x40000000` (Extra) | -| Full-Crouch | Control | N/A | `0x20000000` (Extra) | -| Reload | R | X | X `(0x40)` | -| Previous weapon | Mousewheel forward | B | D-Left | -| Next weapon | Mousewheel back | Y | Y `(0x80)` | -| Radial menu | Q | LB | D-Down | -| Alt fire mode | F | RB | L Trigger | -| Alt-fire oneshot | `F + LMB` or `E + LMB` | `A + RT` or `RB + RT` | `A + Z` or `L + Z` | -| Quick-detonate | `E + Q` or `E + R` | `A + B` or `A + X` | `A + D-Left`or `A + X` | - -## Building - -### Windows - -1. Install [MSYS2](https://www.msys2.org). -2. Open the `MINGW64` prompt if building for x86_64, or the `MINGW32` prompt if building for i686. (**NOTE:** _do not_ use the `MSYS` prompt) -3. Install dependencies: - `pacman -S mingw-w64-x86_64-toolchain mingw-w64-x86_64-SDL2 mingw-w64-x86_64-zlib mingw-w64-x86_64-cmake mingw-w64-x86_64-python3 mingw-w64-i686-toolchain mingw-w64-i686-SDL2 mingw-w64-i686-zlib mingw-w64-i686-cmake mingw-w64-i686-python3 make git` -4. Get the source code: - `git clone --recursive https://github.com/fgsfdsfgs/perfect_dark.git && cd perfect_dark` -5. Run `cmake -G"Unix Makefiles" -Bbuild .`. - * Add ` -DROMID=pal-final` or ` -DROMID=jpn-final` at the end of the command if you want to build a PAL or JPN executable respectively.\ -6. Run `cmake --build build -j4 -- -O`. -7. The resulting executable will be at `build/pd.x86_64.exe` (or at `build/pd.i686.exe` if building for i686). -8. If you don't know where you downloaded the source to, you can run `explorer .` to open the current directory. - -### Linux - -1. Ensure you have gcc, g++ (version 10.0+), make, cmake, git, python3 and SDL2 (version 2.0.12+), libGL and ZLib installed on your system. - * If you wish to crosscompile, you will also need to have libraries and compilers for the target platform installed, e.g. `gcc-multilib` and `g++-multilib` for x86_64 -> i686 crosscompilation. -2. Get the source code: - `git clone --recursive https://github.com/fgsfdsfgs/perfect_dark.git && cd perfect_dark` -3. Run the following command: - * ```cmake -G"Unix Makefiles" -Bbuild .``` - * Add ` -DROMID=pal-final` or ` -DROMID=jpn-final` at the end of the command if you want to build a PAL or JPN executable respectively. - * Add ` -DCMAKE_C_FLAGS=-m32 -DCMAKE_CXX_FLAGS=-m32` at the end of the command if you want to crosscompile from x86_64 to x86. -4. Run `cmake --build build -j4`. -5. The resulting executable will be at `build/pd.` (for example `build/pd.x86_64`). - -### MacOS - -1. Set up Homebrew. -2. Install dependencies: - * Execute command: `brew install cmake gcc python3 zlib git` -3. Install SDL2: - * Execute commands: - ``` - wget http://libsdl.org/release/SDL2-2.30.9.dmg -O SDL2.dmg - hdiutil mount SDL2.dmg - sudo cp -vr /Volumes/SDL2/SDL2.framework /Library/Frameworks - hdiutil detach /Volumes/SDL2 - ``` - * This installs SDL2 system-wide and this is how the automatic builds are done. The game will also look for it in the executable path, so you could - download it locally instead. -4. Get the source code: - `git clone --recursive https://github.com/fgsfdsfgs/perfect_dark.git && cd perfect_dark` -5. Configure: - * Execute command: `cmake -G"Unix Makefiles" -Bbuild -DCMAKE_OSX_ARCHITECTURES=x86_64 .` - * Replace `x86_64` with `arm64` if building for an ARM64 Mac. - * Add ` -DROMID=pal-final` or ` -DROMID=jpn-final` at the end of the command if you want to build a PAL or JPN executable respectively. -6. Build: - * Execute command: `cmake --build build --target pd -j4 --clean-first` -7. The resulting executable will be at `build/pd.` (for example `build/pd.x86_64`). - * You might need to execute `chmod +x build/pd.x86-64` before you can run it. - -### Nintendo Switch - -1. Set up the [devkitA64 environment](https://devkitpro.org/wiki/Getting_Started). - * On Windows you can do it under MSYS2 or WSL, usually MSYS2 is recommended. - * If using MSYS2, make sure to use the **MSYS2** shell, **not** MINGW32 or MINGW64. -2. Install host dependencies: - * On MSYS2: execute command `pacman -Syuu && pacman -S git make cmake python3` - * On Linux: use your package manager as normal to install the above dependencies. -3. Install Switch toolchain and dependencies: - * Execute commands: - ``` - dkp-pacman -Syuu - dkp-pacman -S devkitA64 libnx switch-zlib switch-sdl2 switch-cmake dkp-toolchain-vars - ``` - * If in MSYS2 or `dkp-pacman` doesn't work, replace it with just `pacman`. -4. Get the source code: - `git clone --recursive https://github.com/fgsfdsfgs/perfect_dark.git && cd perfect_dark` -5. Ensure devkitA64 environment variables are set: - * Execute command: `source /opt/devkitpro/switchvars.sh` - * If your `$DEVKITPRO` path is different, substitute that instead or set the variables manually. -6. Configure: - * Execute command: `aarch64-none-elf-cmake -G"Unix Makefiles" -Bbuild .` - * Add ` -DROMID=pal-final` or ` -DROMID=jpn-final` at the end of the command if you want to build a PAL or JPN executable respectively. -7. Build: - * Execute command: `make -C build -j4` -8. The resulting executable will be at `build/pd.arm64.nro`. - -### Notes - -Alternate compilers or toolchains can be specified by passing `-DCMAKE_TOOLCHAIN_FILE=whatever` as normal. The port does not build with Visual Studio. - -You will need to provide a `jpn-final` or `pal-final` ROM to run executables built for those regions, named `pd.jpn-final.z64` or `pd.pal-final.z64`. - -It might be possible to build and run the game on platforms that are not specified in the supported platforms list (e.g. Linux on armv7), but this has not been tested. - -## Credits - -* the original [decompilation project](https://github.com/n64decomp/perfect_dark) authors; -* Ryan Dwyer for the above, additional help, and `pd-extract`; -* doomhack for the only other publicly available [PD porting effort](https://github.com/doomhack/perfect_dark) I could find; -* [sm64-port](https://github.com/sm64-port/sm64-port) authors for the audio mixer and some other changes; -* [Ship of Harkinian team](https://github.com/Kenix3/libultraship/tree/main/src/graphic/Fast3D), Emill and MaikelChan for the libultraship version of fast3d that this port uses; -* lieff for [minimp3](https://github.com/lieff/minimp3); -* Mouse Injector and 1964GEPD authors for some of the 60FPS- and mouselook-related fixes; -* Raf for the 64-bit port; -* NicNamSam for the icon; -* everyone who has submitted pull requests and issues to this repository and tested the port; -* probably more I'm forgetting. +- Port del decomp de Perfect Dark corriendo nativo en ARM (arm64‑v8a / armeabi‑v7a / x86_64 / x86). +- **Overlay táctil al estilo CoD Mobile / Fortnite**: + - Mitad izquierda: zona de movimiento con stick flotante (se ancla donde pones el pulgar). + - Mitad derecha: zona de cámara tipo drag-to-look (arrastras, gira; sueltas, se detiene). + - Botones flotantes para FIRE, AIM, USE, RELOAD, ALT FIRE, cambio de arma, menú radial, crouch, pausa y back. + - Iconos vectoriales que escalan con el tamaño de cada botón. +- **Editor de layout en vivo**: entras desde un pill flotante en la esquina superior derecha, sin salir del juego. Puedes arrastrar botones, cambiarles el tamaño individual, ajustar sensibilidad X/Y independiente del look-pad, y persistir los cambios. +- **Auto-fade configurable**: el overlay desaparece tras 7 s sin tocarlo y reaparece al instante con cualquier toque. +- **Auto-launch al juego** cuando el ROM ya está en la carpeta de la app. +- Soporte completo para controladores Bluetooth/USB (funcionan a la par del touch). +- Incluye mejoras recientes del upstream: fix de SIGBUS en ARM 32-bit, fix de wrap vertical de cámara, separación de sensibilidad crosshair/cámara, acción Recenter Camera, UI Accept/Cancel swap. + +## Requisitos + +- **Android 5.0 (Lollipop, API 21)** o superior. +- GPU con **OpenGL ES 3.0**. +- ~20 MB libres para el APK + espacio para el ROM (~32 MB). +- Una copia legal del ROM de Perfect Dark en formato `.z64`: + - `pd.ntsc-final.z64` — **versión recomendada** (NTSC-U v1.1, MD5 `e03b088b6ac9e0080440efed07c1e40f`). + - NTSC-U v1.0 también se acepta con advertencia. + +## Cómo instalar y jugar + +1. Descarga el APK más reciente de [Releases](https://github.com/mgrz18/perfect_dark/releases). +2. Instálalo en tu teléfono Android (habilita "Instalar apps desconocidas" para tu gestor de archivos o navegador si Android te lo pide). +3. Abre la app. La primera vez te mostrará el launcher pidiendo el ROM. +4. Toca **Select ROM** y elige tu archivo `.z64`. Se copia a `Android/data/com.perfectdark.port/files/data/pd.ntsc-final.z64` y se verifica el hash MD5. +5. Si el hash coincide, la app arranca directo al juego. En las siguientes veces saltará el launcher automáticamente. + +## Controles táctiles + +### Disposición por defecto + +| Zona / botón | Acción | +|---|---| +| Mitad izquierda de la pantalla | **Movimiento** (stick flotante, se ancla donde pones el dedo) | +| Mitad derecha de la pantalla | **Cámara** (drag-to-look, tipo CoD Mobile / Quake Mobile) | +| FIRE | Disparo principal (N64: Z) | +| AIM | Apuntar (N64: R) | +| USE | Usar / interactuar | +| RELOAD | Recargar | +| ALT | Disparo alternativo | +| ← / → | Arma anterior / siguiente | +| WPN | Menú radial de armas | +| CRC | Cambiar crouch | +| ≡ | START / pausa | +| ✕ | Cancelar / back | + +### Editor de layout en vivo + +Pulsa el pill **`EDIT`** en la esquina superior derecha para entrar al modo edición: + +- Toca cualquier botón para seleccionarlo → aparece una barrita `− r=0.055 +` para ajustar su tamaño individual. +- Arrastra cualquier botón para moverlo. Los pads de movimiento y cámara son invisibles y no se editan. +- Barra superior: + - **SAVE** / **RESET** / **CANCEL** en la primera fila. + - **FADE: ON/OFF** para activar o desactivar el auto-fade del overlay. + - **X ±** / **Y ±** para ajustar sensibilidad de cámara por eje (rango 0.05 – 5.0). + - Pill **`▲`** para colapsar la barra y liberar la parte superior si estás alineando botones ahí. + +Los cambios quedan guardados al dar SAVE. + +## Créditos + +Este port se sostiene sobre el trabajo de muchas personas. Los créditos correctos son: + +- **Equipo original del decomp** — [`n64decomp/perfect_dark`](https://github.com/n64decomp/perfect_dark): la ingeniería inversa completa del juego desde la ROM de N64. Sin ese decomp ninguno de los ports existe. +- **[`fgsfdsfgs`](https://github.com/fgsfdsfgs)** y colaboradores — [`fgsfdsfgs/perfect_dark`](https://github.com/fgsfdsfgs/perfect_dark): el port a plataformas modernas (Windows, Linux, macOS, Switch). El motor que corre en Android es esencialmente su trabajo. +- **[`izzy2lost`](https://github.com/izzy2lost)** — [`izzy2lost/perfect_dark`](https://github.com/izzy2lost/perfect_dark): andamiaje inicial de Android (Gradle project, SDL2 Java wrappers, LauncherActivity con picker de ROM vía SAF). Esta rama se basa en su fork. +- **[AL2009man](https://github.com/AL2009man)**, **[joshuarwood](https://github.com/joshuarwood)**, **[rafccq](https://github.com/rafccq)**, **[TartanSpartan](https://github.com/TartanSpartan)**, **[emileb](https://github.com/emileb)** — PRs cherry-pickeados para fixes y mejoras relevantes (ver el historial de commits). +- **Rare / Nintendo** — creadores originales de Perfect Dark (2000). + +Los controles táctiles nativos, el editor de layout en vivo, el look-pad con inyección de mouse delta, el auto-fade configurable, y la integración general de este branch fueron construidos con la asistencia de **[Claude](https://claude.com/)** de Anthropic. No habría sido posible hacer todo esto tan rápido sin ese apoyo. + +## Soporte / problemas + +Si encuentras un bug, tienes una petición, o simplemente quieres comentar algo: + +- Abre un **[Issue](https://github.com/mgrz18/perfect_dark/issues)** explicando: + - Qué hiciste exactamente. + - Qué esperabas vs. qué pasó. + - Modelo de teléfono + versión de Android. + - Región de tu ROM (NTSC v1.1 recomendada). + - Logs de `adb logcat` si puedes capturarlos. + +También se aceptan Pull Requests a la rama `touch-controls`. + +## Licencia + +Mismo esquema que [`fgsfdsfgs/perfect_dark`](https://github.com/fgsfdsfgs/perfect_dark). El código decompilado y el port son reingeniería a partir de una copia ejecutable; **ni este repo ni sus binarios distribuyen assets, ROMs, audio ni texturas del juego original**, y el usuario debe aportar su propia copia legal del ROM. + +## Estado conocido + +- Juego funciona completo en single-player y multiplayer en split-screen local. +- Netplay multijugador por red **no** está incluido (existe en la rama `port-net` del upstream pero aún es experimental y no se ha integrado aquí). +- En teléfonos low-end con armeabi-v7a puede haber problemas de rendimiento puntuales. +- Si el overlay táctil se ve mal alineado o prefieres los defaults, resetéalo desde el editor (`EDIT` → `RESET` → `SAVE`). diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000000..f5dd81216c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,11 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/app/build +/app/.cxx +/captures +.externalNativeBuild +.cxx diff --git a/android/.gradle/8.0/checksums/checksums.lock b/android/.gradle/8.0/checksums/checksums.lock new file mode 100644 index 0000000000..47c262d8fa Binary files /dev/null and b/android/.gradle/8.0/checksums/checksums.lock differ diff --git a/android/.gradle/8.0/checksums/md5-checksums.bin b/android/.gradle/8.0/checksums/md5-checksums.bin new file mode 100644 index 0000000000..3c6ffbbb1c Binary files /dev/null and b/android/.gradle/8.0/checksums/md5-checksums.bin differ diff --git a/android/.gradle/8.0/checksums/sha1-checksums.bin b/android/.gradle/8.0/checksums/sha1-checksums.bin new file mode 100644 index 0000000000..da59bee03c Binary files /dev/null and b/android/.gradle/8.0/checksums/sha1-checksums.bin differ diff --git a/android/.gradle/8.0/dependencies-accessors/dependencies-accessors.lock b/android/.gradle/8.0/dependencies-accessors/dependencies-accessors.lock new file mode 100644 index 0000000000..caf5436f5a Binary files /dev/null and b/android/.gradle/8.0/dependencies-accessors/dependencies-accessors.lock differ diff --git a/android/.gradle/8.0/dependencies-accessors/gc.properties b/android/.gradle/8.0/dependencies-accessors/gc.properties new file mode 100644 index 0000000000..e69de29bb2 diff --git a/android/.gradle/8.0/executionHistory/executionHistory.bin b/android/.gradle/8.0/executionHistory/executionHistory.bin new file mode 100644 index 0000000000..cebbc19343 Binary files /dev/null and b/android/.gradle/8.0/executionHistory/executionHistory.bin differ diff --git a/android/.gradle/8.0/executionHistory/executionHistory.lock b/android/.gradle/8.0/executionHistory/executionHistory.lock new file mode 100644 index 0000000000..2e22335547 Binary files /dev/null and b/android/.gradle/8.0/executionHistory/executionHistory.lock differ diff --git a/android/.gradle/8.0/fileChanges/last-build.bin b/android/.gradle/8.0/fileChanges/last-build.bin new file mode 100644 index 0000000000..f76dd238ad Binary files /dev/null and b/android/.gradle/8.0/fileChanges/last-build.bin differ diff --git a/android/.gradle/8.0/fileHashes/fileHashes.bin b/android/.gradle/8.0/fileHashes/fileHashes.bin new file mode 100644 index 0000000000..8aed2dd8b7 Binary files /dev/null and b/android/.gradle/8.0/fileHashes/fileHashes.bin differ diff --git a/android/.gradle/8.0/fileHashes/fileHashes.lock b/android/.gradle/8.0/fileHashes/fileHashes.lock new file mode 100644 index 0000000000..d5a20883e0 Binary files /dev/null and b/android/.gradle/8.0/fileHashes/fileHashes.lock differ diff --git a/android/.gradle/8.0/fileHashes/resourceHashesCache.bin b/android/.gradle/8.0/fileHashes/resourceHashesCache.bin new file mode 100644 index 0000000000..031e0a8f31 Binary files /dev/null and b/android/.gradle/8.0/fileHashes/resourceHashesCache.bin differ diff --git a/android/.gradle/8.0/gc.properties b/android/.gradle/8.0/gc.properties new file mode 100644 index 0000000000..e69de29bb2 diff --git a/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000000..2ca5d3423c Binary files /dev/null and b/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/android/.gradle/buildOutputCleanup/cache.properties b/android/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 0000000000..79acebbe58 --- /dev/null +++ b/android/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Wed Apr 22 14:01:37 CST 2026 +gradle.version=8.0 diff --git a/android/.gradle/buildOutputCleanup/outputFiles.bin b/android/.gradle/buildOutputCleanup/outputFiles.bin new file mode 100644 index 0000000000..f4ae72a4b1 Binary files /dev/null and b/android/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/android/.gradle/file-system.probe b/android/.gradle/file-system.probe new file mode 100644 index 0000000000..9c2859de5a Binary files /dev/null and b/android/.gradle/file-system.probe differ diff --git a/android/.gradle/vcs-1/gc.properties b/android/.gradle/vcs-1/gc.properties new file mode 100644 index 0000000000..e69de29bb2 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3fbf02d2bc..ec8ecdda78 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -42,6 +42,14 @@ android:screenOrientation="landscape" android:configChanges="layoutDirection|locale|orientation|uiMode|screenLayout|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation" android:theme="@style/Theme.PerfectDark.Fullscreen"/> + + + diff --git a/android/app/src/main/java/com/perfectdark/port/GameView.java b/android/app/src/main/java/com/perfectdark/port/GameView.java deleted file mode 100644 index 9a71484e45..0000000000 --- a/android/app/src/main/java/com/perfectdark/port/GameView.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.perfectdark.port; - -import android.content.Context; -import android.opengl.GLSurfaceView; -import android.view.MotionEvent; -import android.view.KeyEvent; -import javax.microedition.khronos.egl.EGLConfig; -import javax.microedition.khronos.opengles.GL10; - -public class GameView extends GLSurfaceView { - private GameRenderer renderer; - private TouchControls touchControls; - - public GameView(Context context) { - super(context); - - // Create OpenGL ES 3.0 context - setEGLContextClientVersion(3); - - renderer = new GameRenderer(); - setRenderer(renderer); - - touchControls = new TouchControls(context); - - // Render continuously - setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY); - } - - @Override - public boolean onTouchEvent(MotionEvent event) { - return touchControls.onTouchEvent(event); - } - - @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { - nativeKeyDown(keyCode); - return true; - } - - @Override - public boolean onKeyUp(int keyCode, KeyEvent event) { - nativeKeyUp(keyCode); - return true; - } - - private class GameRenderer implements GLSurfaceView.Renderer { - @Override - public void onSurfaceCreated(GL10 gl, EGLConfig config) { - nativeSurfaceCreated(); - } - - @Override - public void onSurfaceChanged(GL10 gl, int width, int height) { - nativeSurfaceChanged(width, height); - } - - @Override - public void onDrawFrame(GL10 gl) { - nativeDrawFrame(); - } - } - - // Native methods - public native void nativeSurfaceCreated(); - public native void nativeSurfaceChanged(int width, int height); - public native void nativeDrawFrame(); - public native void nativeKeyDown(int keyCode); - public native void nativeKeyUp(int keyCode); - public native void nativeTouchEvent(int action, float x, float y, int pointerId); -} \ No newline at end of file diff --git a/android/app/src/main/java/com/perfectdark/port/LauncherActivity.java b/android/app/src/main/java/com/perfectdark/port/LauncherActivity.java index 89d2f01ade..48d4f08aec 100644 --- a/android/app/src/main/java/com/perfectdark/port/LauncherActivity.java +++ b/android/app/src/main/java/com/perfectdark/port/LauncherActivity.java @@ -36,7 +36,9 @@ public class LauncherActivity extends AppCompatActivity { private View missingRomView; private TextView infoText; + private Button playButton; private Button pickRomButton; + private Button editTouchButton; private final ActivityResultLauncher romPicker = registerForActivityResult(new ActivityResultContracts.OpenDocument(), this::onRomPicked); @@ -48,24 +50,54 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { missingRomView = findViewById(R.id.missingRomContainer); infoText = findViewById(R.id.infoText); + playButton = findViewById(R.id.playButton); pickRomButton = findViewById(R.id.pickRomButton); + editTouchButton = findViewById(R.id.editTouchButton); pickRomButton.setOnClickListener(v -> openRomPicker()); + editTouchButton.setOnClickListener(v -> + startActivity(new Intent(this, LayoutEditorActivity.class))); + playButton.setOnClickListener(v -> attemptPlay()); ensureDataDir(); + updatePlayEnabled(); + // If the ROM is already in place and matches a supported hash, skip + // the menu entirely and drop straight into the game. The user can + // still access the touch layout editor from the in-game EDIT pill. if (romExists()) { File target = new File(new File(getExternalFilesDir(null), "data"), ROM_FILE_NAME); - int hashStatus = checkRomHash(target); - if (hashStatus == 0) { + if (checkRomHash(target) == 0) { startGame(); - } else if (hashStatus == 1) { - showV10WarningDialog(target); - } else { - showHashMismatchDialog(target); } + } + } + + @Override + protected void onResume() { + super.onResume(); + updatePlayEnabled(); + } + + private void updatePlayEnabled() { + boolean rom = romExists(); + playButton.setEnabled(rom); + infoText.setText(rom ? "Perfect Dark" : + "ROM not found. Select your Perfect Dark NTSC (z64) ROM to proceed.\n" + + "It will be copied to Android/data/com.perfectdark.port/files/data as " + + ROM_FILE_NAME + "."); + } + + private void attemptPlay() { + if (!romExists()) return; + File target = new File(new File(getExternalFilesDir(null), "data"), ROM_FILE_NAME); + int hashStatus = checkRomHash(target); + if (hashStatus == 0) { + startGame(); + } else if (hashStatus == 1) { + showV10WarningDialog(target); } else { - showMissingRomUi(); + showHashMismatchDialog(target); } } @@ -83,8 +115,7 @@ private boolean romExists() { } private void showMissingRomUi() { - missingRomView.setVisibility(View.VISIBLE); - infoText.setText("ROM not found. Select your Perfect Dark NTSC (z64) ROM to proceed.\nIt will be copied to Android/data/com.perfectdark.port/files/data as " + ROM_FILE_NAME + "."); + updatePlayEnabled(); } private void openRomPicker() { diff --git a/android/app/src/main/java/com/perfectdark/port/LayoutEditorActivity.java b/android/app/src/main/java/com/perfectdark/port/LayoutEditorActivity.java new file mode 100644 index 0000000000..6b49f2eacb --- /dev/null +++ b/android/app/src/main/java/com/perfectdark/port/LayoutEditorActivity.java @@ -0,0 +1,118 @@ +package com.perfectdark.port; + +import android.app.AlertDialog; +import android.content.Context; +import android.os.Bundle; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; + +/** + * Fullscreen editor for the touch layout. Shows the same overlay the game + * uses, but in edit mode: tap + drag moves an element, toggling "resize" + * instead stretches its radius. "Save" writes to SharedPreferences. + */ +public class LayoutEditorActivity extends AppCompatActivity { + + private TouchOverlayView overlay; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { + getWindow().getAttributes().layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + } + hideSystemUI(); + + FrameLayout root = new FrameLayout(this); + root.setBackgroundColor(0xFF101014); + setContentView(root, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + + overlay = new TouchOverlayView(this); + overlay.setInternalHudVisible(false); + overlay.setEditMode(true); + root.addView(overlay, new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT)); + + root.addView(buildHud(this), new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.TOP)); + + Toast.makeText(this, + "Toca un control para arrastrarlo. Activa RESIZE para cambiar su tamaño.", + Toast.LENGTH_LONG).show(); + } + + private View buildHud(Context ctx) { + LinearLayout bar = new LinearLayout(ctx); + bar.setOrientation(LinearLayout.HORIZONTAL); + bar.setGravity(Gravity.CENTER_VERTICAL); + bar.setPadding(16, 16, 16, 16); + bar.setBackgroundColor(0x88000000); + + TextView title = new TextView(ctx); + title.setText("Touch Layout"); + title.setTextColor(0xFFFFFFFF); + title.setTextSize(18); + LinearLayout.LayoutParams titleLp = new LinearLayout.LayoutParams(0, + ViewGroup.LayoutParams.WRAP_CONTENT, 1f); + bar.addView(title, titleLp); + + // Per-button resize is done by tapping a button inside the overlay, + // so no global RESIZE toggle is needed here. + + Button reset = new Button(ctx); + reset.setText("RESET"); + reset.setOnClickListener(v -> new AlertDialog.Builder(this) + .setMessage("Restablecer el layout predeterminado?") + .setPositiveButton("Sí", (d, w) -> { + TouchLayout.resetDefaults(this); + overlay.reloadLayout(); + }) + .setNegativeButton("No", null) + .show()); + bar.addView(reset); + + Button save = new Button(ctx); + save.setText("GUARDAR"); + save.setOnClickListener(v -> { + overlay.getLayout().save(this); + Toast.makeText(this, "Layout guardado", Toast.LENGTH_SHORT).show(); + finish(); + }); + bar.addView(save); + + Button back = new Button(ctx); + back.setText("CANCELAR"); + back.setOnClickListener(v -> finish()); + bar.addView(back); + + return bar; + } + + private void hideSystemUI() { + View decorView = getWindow().getDecorView(); + int uiOptions = View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE; + decorView.setSystemUiVisibility(uiOptions); + } +} diff --git a/android/app/src/main/java/com/perfectdark/port/MainActivity.java b/android/app/src/main/java/com/perfectdark/port/MainActivity.java index 706e679f6b..2125660e3b 100644 --- a/android/app/src/main/java/com/perfectdark/port/MainActivity.java +++ b/android/app/src/main/java/com/perfectdark/port/MainActivity.java @@ -1,49 +1,52 @@ package com.perfectdark.port; import org.libsdl.app.SDLActivity; -import android.app.Activity; -import android.content.Context; -import android.content.pm.PackageManager; + import android.os.Bundle; -import android.os.Environment; import android.view.View; +import android.view.ViewGroup; import android.view.WindowManager; -import android.widget.Toast; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; + import java.io.File; public class MainActivity extends SDLActivity { - private static final int PERMISSION_REQUEST_CODE = 1; - static { System.loadLibrary("SDL2"); System.loadLibrary("pd"); } + private TouchOverlayView overlay; + @Override protected void onCreate(Bundle savedInstanceState) { - android.util.Log.i("PerfectDark", "MainActivity onCreate start"); - super.onCreate(savedInstanceState); - android.util.Log.i("PerfectDark", "MainActivity super.onCreate complete"); - - // Keep screen on and hide system UI + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - - // Handle display cutouts (remove white bars) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { - getWindow().getAttributes().layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + getWindow().getAttributes().layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; } - + hideSystemUI(); - - // No external storage permissions needed with SAF + app-scoped storage initializeGame(); - android.util.Log.i("PerfectDark", "MainActivity onCreate complete"); + attachTouchOverlay(); + } + + /** + * SDLActivity builds its own view hierarchy inside {@code mLayout}. We add + * our overlay as the last child so it sits on top and captures touches + * before they reach the SDL surface. + */ + private void attachTouchOverlay() { + overlay = new TouchOverlayView(this); + // mLayout is an SDL-owned RelativeLayout; a plain ViewGroup.LayoutParams + // with MATCH_PARENT on both axes is enough since we want it to fill. + mLayout.addView(overlay, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); } - + private void hideSystemUI() { View decorView = getWindow().getDecorView(); int uiOptions = View.SYSTEM_UI_FLAG_FULLSCREEN @@ -54,52 +57,28 @@ private void hideSystemUI() { | View.SYSTEM_UI_FLAG_LAYOUT_STABLE; decorView.setSystemUiVisibility(uiOptions); } - - private boolean checkPermissions() { return true; } - - private void requestPermissions() { /* no-op */ } - - @Override - public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - // No storage permissions requested; proceed regardless - } - + private void initializeGame() { - android.util.Log.i("PerfectDark", "initializeGame start"); - - // Create data directory in external storage File dataDir = new File(getExternalFilesDir(null), "data"); - android.util.Log.i("PerfectDark", "Data dir: " + dataDir.getAbsolutePath()); if (!dataDir.exists()) { dataDir.mkdirs(); } - - // Initialize native game - android.util.Log.i("PerfectDark", "Calling nativeInit"); nativeInit(dataDir.getAbsolutePath()); - - android.util.Log.i("PerfectDark", "initializeGame complete"); } - + @Override protected void onResume() { super.onResume(); hideSystemUI(); + if (overlay != null) overlay.reloadLayout(); } - - @Override - protected void onPause() { - super.onPause(); - } - + @Override protected void onDestroy() { super.onDestroy(); nativeDestroy(); } - // Native methods public native void nativeInit(String dataPath); public native void nativeStartGame(); public native void nativeDestroy(); diff --git a/android/app/src/main/java/com/perfectdark/port/TouchControls.java b/android/app/src/main/java/com/perfectdark/port/TouchControls.java deleted file mode 100644 index 8c07a85f9e..0000000000 --- a/android/app/src/main/java/com/perfectdark/port/TouchControls.java +++ /dev/null @@ -1,161 +0,0 @@ -package com.perfectdark.port; - -import android.content.Context; -import android.view.MotionEvent; - -public class TouchControls { - private Context context; - private boolean leftStickActive = false; - private boolean rightStickActive = false; - private float leftStickCenterX, leftStickCenterY; - private float rightStickCenterX, rightStickCenterY; - private int leftStickPointerId = -1; - private int rightStickPointerId = -1; - - // Touch zones (normalized coordinates 0-1) - private static final float LEFT_STICK_X = 0.15f; - private static final float LEFT_STICK_Y = 0.7f; - private static final float RIGHT_STICK_X = 0.85f; - private static final float RIGHT_STICK_Y = 0.7f; - private static final float STICK_RADIUS = 0.1f; - - // Button zones - private static final float FIRE_BUTTON_X = 0.9f; - private static final float FIRE_BUTTON_Y = 0.3f; - private static final float AIM_BUTTON_X = 0.1f; - private static final float AIM_BUTTON_Y = 0.3f; - private static final float BUTTON_RADIUS = 0.08f; - - public TouchControls(Context context) { - this.context = context; - } - - public boolean onTouchEvent(MotionEvent event) { - int action = event.getActionMasked(); - int pointerIndex = event.getActionIndex(); - int pointerId = event.getPointerId(pointerIndex); - - float x = event.getX(pointerIndex) / context.getResources().getDisplayMetrics().widthPixels; - float y = event.getY(pointerIndex) / context.getResources().getDisplayMetrics().heightPixels; - - switch (action) { - case MotionEvent.ACTION_DOWN: - case MotionEvent.ACTION_POINTER_DOWN: - handleTouchDown(x, y, pointerId); - break; - - case MotionEvent.ACTION_MOVE: - handleTouchMove(event); - break; - - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_POINTER_UP: - handleTouchUp(pointerId); - break; - } - - return true; - } - - private void handleTouchDown(float x, float y, int pointerId) { - // Check for left stick - if (isInCircle(x, y, LEFT_STICK_X, LEFT_STICK_Y, STICK_RADIUS) && leftStickPointerId == -1) { - leftStickActive = true; - leftStickPointerId = pointerId; - leftStickCenterX = LEFT_STICK_X; - leftStickCenterY = LEFT_STICK_Y; - return; - } - - // Check for right stick - if (isInCircle(x, y, RIGHT_STICK_X, RIGHT_STICK_Y, STICK_RADIUS) && rightStickPointerId == -1) { - rightStickActive = true; - rightStickPointerId = pointerId; - rightStickCenterX = RIGHT_STICK_X; - rightStickCenterY = RIGHT_STICK_Y; - return; - } - - // Check for fire button - if (isInCircle(x, y, FIRE_BUTTON_X, FIRE_BUTTON_Y, BUTTON_RADIUS)) { - nativeButtonDown(0); // Fire button - return; - } - - // Check for aim button - if (isInCircle(x, y, AIM_BUTTON_X, AIM_BUTTON_Y, BUTTON_RADIUS)) { - nativeButtonDown(1); // Aim button - return; - } - } - - private void handleTouchMove(MotionEvent event) { - for (int i = 0; i < event.getPointerCount(); i++) { - int pointerId = event.getPointerId(i); - float x = event.getX(i) / context.getResources().getDisplayMetrics().widthPixels; - float y = event.getY(i) / context.getResources().getDisplayMetrics().heightPixels; - - if (pointerId == leftStickPointerId && leftStickActive) { - float deltaX = x - leftStickCenterX; - float deltaY = y - leftStickCenterY; - float distance = (float) Math.sqrt(deltaX * deltaX + deltaY * deltaY); - - if (distance > STICK_RADIUS) { - deltaX = deltaX / distance * STICK_RADIUS; - deltaY = deltaY / distance * STICK_RADIUS; - } - - // Normalize to -1 to 1 range - float normalizedX = deltaX / STICK_RADIUS; - float normalizedY = deltaY / STICK_RADIUS; - - nativeStickInput(0, normalizedX, normalizedY); // Left stick - } - - if (pointerId == rightStickPointerId && rightStickActive) { - float deltaX = x - rightStickCenterX; - float deltaY = y - rightStickCenterY; - float distance = (float) Math.sqrt(deltaX * deltaX + deltaY * deltaY); - - if (distance > STICK_RADIUS) { - deltaX = deltaX / distance * STICK_RADIUS; - deltaY = deltaY / distance * STICK_RADIUS; - } - - float normalizedX = deltaX / STICK_RADIUS; - float normalizedY = deltaY / STICK_RADIUS; - - nativeStickInput(1, normalizedX, normalizedY); // Right stick - } - } - } - - private void handleTouchUp(int pointerId) { - if (pointerId == leftStickPointerId) { - leftStickActive = false; - leftStickPointerId = -1; - nativeStickInput(0, 0, 0); - } - - if (pointerId == rightStickPointerId) { - rightStickActive = false; - rightStickPointerId = -1; - nativeStickInput(1, 0, 0); - } - - // Handle button releases - nativeButtonUp(0); // Fire button - nativeButtonUp(1); // Aim button - } - - private boolean isInCircle(float x, float y, float centerX, float centerY, float radius) { - float dx = x - centerX; - float dy = y - centerY; - return (dx * dx + dy * dy) <= (radius * radius); - } - - // Native methods - public native void nativeStickInput(int stick, float x, float y); - public native void nativeButtonDown(int button); - public native void nativeButtonUp(int button); -} \ No newline at end of file diff --git a/android/app/src/main/java/com/perfectdark/port/TouchLayout.java b/android/app/src/main/java/com/perfectdark/port/TouchLayout.java new file mode 100644 index 0000000000..80e43bc3dc --- /dev/null +++ b/android/app/src/main/java/com/perfectdark/port/TouchLayout.java @@ -0,0 +1,181 @@ +package com.perfectdark.port; + +import android.content.Context; +import android.content.SharedPreferences; + +import java.util.ArrayList; +import java.util.List; + +/** + * Describes the on-screen touch controls: analog sticks, a drag-to-look pad + * (CoD/Fortnite style) and buttons. Positions/sizes are stored in normalized + * screen coordinates (0..1) so layouts survive rotation and different screens. + * + * Persisted in SharedPreferences under "touch_layout". + */ +public class TouchLayout { + public static final String PREFS = "touch_layout"; + + // N64 controller button bits (mirror CONT_* / os_cont.h). + public static final int BTN_A = 0x00008000; + public static final int BTN_B = 0x00004000; + public static final int BTN_Z = 0x00002000; + public static final int BTN_START = 0x00001000; + public static final int BTN_DU = 0x00000800; + public static final int BTN_DD = 0x00000400; + public static final int BTN_DL = 0x00000200; + public static final int BTN_DR = 0x00000100; + public static final int BTN_X = 0x00000040; + public static final int BTN_Y = 0x00000080; + public static final int BTN_L = 0x00000020; + public static final int BTN_R = 0x00000010; + public static final int BTN_CROUCH = 0x80000000; + + public enum Kind { LEFT_STICK, RIGHT_STICK, BUTTON, LOOK_PAD, MOVE_PAD } + + public static class Element { + public final String id; + public final Kind kind; + public final int mask; + public final String label; + public float cx, cy; // normalized center + public float radius; // for BUTTON / sticks (circular) + public float hw, hh; // half-width / half-height for LOOK_PAD + + public Element(String id, Kind kind, int mask, String label, + float cx, float cy, float radius) { + this.id = id; + this.kind = kind; + this.mask = mask; + this.label = label; + this.cx = cx; + this.cy = cy; + this.radius = radius; + this.hw = radius; + this.hh = radius; + } + + public static Element rect(String id, Kind kind, String label, + float cx, float cy, float hw, float hh) { + Element e = new Element(id, kind, 0, label, cx, cy, 0f); + e.hw = hw; + e.hh = hh; + return e; + } + } + + public final List elements = new ArrayList<>(); + + /** Pixel-to-mouse-delta multipliers for the look pad. Persistent, per-axis. */ + public float lookSensX = 0.4f; + public float lookSensY = 0.4f; + + /** If true, the whole overlay fades out after a few seconds of inactivity. */ + public boolean idleFade = true; + + public static TouchLayout defaults() { + TouchLayout l = new TouchLayout(); + + // Left half is the floating-stick move pad (CoD Mobile style) and + // covers the full left half of the screen. Buttons placed on top of + // it win the hit test, so this is safe. + Element movepad = Element.rect("movepad", Kind.MOVE_PAD, "MOVE", + 0.25f, 0.50f, 0.25f, 0.50f); + movepad.radius = 0.09f; + l.elements.add(movepad); + + // Right half is a drag-to-look pad, full right half of the screen. + l.elements.add(Element.rect("lookpad", Kind.LOOK_PAD, "LOOK", + 0.75f, 0.50f, 0.25f, 0.50f)); + + // Shooting cluster over the right-side look pad (right thumb / index). + l.elements.add(new Element("fire", Kind.BUTTON, BTN_Z, "FIRE", 0.92f, 0.78f, 0.070f)); + l.elements.add(new Element("aim", Kind.BUTTON, BTN_R, "AIM", 0.92f, 0.45f, 0.055f)); + l.elements.add(new Element("use", Kind.BUTTON, BTN_A, "USE", 0.82f, 0.55f, 0.050f)); + l.elements.add(new Element("reload", Kind.BUTTON, BTN_X, "RLD", 0.82f, 0.22f, 0.050f)); + l.elements.add(new Element("altfire",Kind.BUTTON, BTN_L, "ALT", 0.93f, 0.22f, 0.045f)); + + // Weapon + movement helpers over the left-side move pad. + l.elements.add(new Element("wprev", Kind.BUTTON, BTN_DL, "<", 0.06f, 0.30f, 0.045f)); + l.elements.add(new Element("wnext", Kind.BUTTON, BTN_Y, ">", 0.17f, 0.30f, 0.045f)); + l.elements.add(new Element("radial", Kind.BUTTON, BTN_DD, "WPN", 0.08f, 0.18f, 0.045f)); + l.elements.add(new Element("crouch", Kind.BUTTON, BTN_CROUCH, "CRC", 0.07f, 0.85f, 0.045f)); + + // Menu + cancel at the top. + l.elements.add(new Element("start", Kind.BUTTON, BTN_START, "STRT", 0.48f, 0.06f, 0.045f)); + l.elements.add(new Element("cancel", Kind.BUTTON, BTN_B, "BACK", 0.55f, 0.06f, 0.045f)); + return l; + } + + public void save(Context ctx) { + SharedPreferences.Editor e = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit(); + for (Element el : elements) { + e.putFloat(el.id + ".cx", el.cx); + e.putFloat(el.id + ".cy", el.cy); + e.putFloat(el.id + ".r", el.radius); + e.putFloat(el.id + ".hw", el.hw); + e.putFloat(el.id + ".hh", el.hh); + } + e.putFloat("_lookSensX", lookSensX); + e.putFloat("_lookSensY", lookSensY); + e.putBoolean("_idleFade", idleFade); + e.apply(); + } + + public static TouchLayout load(Context ctx) { + TouchLayout l = defaults(); + SharedPreferences p = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + for (Element el : l.elements) { + el.cx = p.getFloat(el.id + ".cx", el.cx); + el.cy = p.getFloat(el.id + ".cy", el.cy); + el.radius = p.getFloat(el.id + ".r", el.radius); + el.hw = p.getFloat(el.id + ".hw", el.hw); + el.hh = p.getFloat(el.id + ".hh", el.hh); + } + // Legacy single-axis key is used as the fallback for both axes if + // present, so existing installs keep their previous sensitivity. + float legacy = p.getFloat("_lookSens", l.lookSensX); + l.lookSensX = p.getFloat("_lookSensX", legacy); + l.lookSensY = p.getFloat("_lookSensY", legacy); + l.idleFade = p.getBoolean("_idleFade", l.idleFade); + return l; + } + + public static void resetDefaults(Context ctx) { + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply(); + } + + /** Deep copy, used to back up before entering live-edit mode. */ + public TouchLayout copy() { + TouchLayout out = new TouchLayout(); + out.lookSensX = lookSensX; + out.lookSensY = lookSensY; + out.idleFade = idleFade; + for (Element e : elements) { + Element c = (e.kind == Kind.LOOK_PAD || e.kind == Kind.MOVE_PAD) + ? Element.rect(e.id, e.kind, e.label, e.cx, e.cy, e.hw, e.hh) + : new Element(e.id, e.kind, e.mask, e.label, e.cx, e.cy, e.radius); + c.hw = e.hw; + c.hh = e.hh; + c.radius = e.radius; + out.elements.add(c); + } + return out; + } + + /** Copies positions/sizes (and sensitivity) from `src` into this layout in place. */ + public void assignFrom(TouchLayout src) { + lookSensX = src.lookSensX; + lookSensY = src.lookSensY; + idleFade = src.idleFade; + for (int i = 0; i < elements.size() && i < src.elements.size(); ++i) { + Element dst = elements.get(i); + Element s = src.elements.get(i); + dst.cx = s.cx; + dst.cy = s.cy; + dst.radius = s.radius; + dst.hw = s.hw; + dst.hh = s.hh; + } + } +} diff --git a/android/app/src/main/java/com/perfectdark/port/TouchOverlayView.java b/android/app/src/main/java/com/perfectdark/port/TouchOverlayView.java new file mode 100644 index 0000000000..e7a3bd5619 --- /dev/null +++ b/android/app/src/main/java/com/perfectdark/port/TouchOverlayView.java @@ -0,0 +1,1010 @@ +package com.perfectdark.port; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.os.SystemClock; +import android.util.AttributeSet; +import android.util.SparseArray; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.Nullable; + +/** + * Draws the virtual sticks / buttons / look-pad on top of the SDL surface and + * pumps their state into native code (touch.c) via nativeSetState(). + * + * Features: + * - LEFT_STICK / RIGHT_STICK: classic virtual analog sticks. + * - LOOK_PAD: rectangular zone where any drag maps to right-stick velocity + * (CoD Mobile / Fortnite-style camera control). + * - BUTTON: circular on/off buttons that OR into the controller button mask. + * - Edit mode: tap-drag an element to move, toggle RESIZE to stretch it + * (LOOK_PAD stretches width & height independently, others change radius). + * - Live-edit HUD: a small floating EDIT pill enters edit mode without + * leaving the game; SAVE persists, CANCEL restores the previous layout. + */ +public class TouchOverlayView extends View { + + private final SparseArray pointerToElement = new SparseArray<>(); + private TouchLayout layout; + private boolean editMode = false; + private boolean internalHudVisible = true; + // When true, the edit HUD shrinks to a single pill in the top-right so + // the user can place / drag buttons that live under the full bar. + private boolean hudCollapsed = false; + // Currently selected button in edit mode. Drives the per-button resize + // bar. Null means no button is selected. + private @Nullable String selectedButtonId = null; + private static final float BUTTON_SIZE_MIN = 0.03f; + private static final float BUTTON_SIZE_MAX = 0.15f; + private static final float BUTTON_SIZE_STEP = 0.005f; + + // Idle fade: after a period of no touches the whole overlay (buttons, + // floating EDIT pill, stick feedback) smoothly dims to a low alpha so + // it doesn't obscure gameplay. Any touch snaps it back to full alpha. + // Bypassed while edit mode is active. + private static final long IDLE_BEFORE_FADE_MS = 7000L; + private static final long FADE_DURATION_MS = 700L; + private static final float IDLE_ALPHA = 0.0f; + private long lastInteractionTimeMs = SystemClock.uptimeMillis(); + private final Runnable fadeKicker = this::invalidate; + + // Snapshot taken when entering edit mode; restored if the user cancels. + private @Nullable TouchLayout editSnapshot; + + private int leftPointer = -1, rightPointer = -1; + private float leftStickX, leftStickY; + private float rightStickX, rightStickY; + private int pressedButtons = 0; + + // ---- Floating-stick move pad (CoD-style: stick center = touch point). + private int movePointer = -1; + private float moveAnchorX, moveAnchorY; + private float moveMaxRadiusPx; + + // ---- Look pad (CoD / Quake-style: finger delta == mouse delta). + private int lookPointer = -1; + private float lookLastX, lookLastY; + // How many "mouse pixels" each phone pixel produces when dragging the + // look pad, per axis. Lives on TouchLayout so it is persisted with the + // rest of the config and tunable via the sens pills in the edit HUD. + private static final float LOOK_SENS_MIN = 0.05f; + private static final float LOOK_SENS_MAX = 5.0f; + private static final float LOOK_SENS_STEP = 0.1f; + + // ---- Edit drag state. + private String draggedId = null; + private float dragOffsetX, dragOffsetY; + + // ---- HUD action rectangles (recomputed each draw). + private final RectF editPillRect = new RectF(); + private final RectF btnSaveRect = new RectF(); + private final RectF btnResetRect = new RectF(); + private final RectF btnResizeRect = new RectF(); + private final RectF btnCancelRect = new RectF(); + private final RectF btnSensXDownRect = new RectF(); + private final RectF btnSensXUpRect = new RectF(); + private final RectF sensXLabelRect = new RectF(); + private final RectF btnSensYDownRect = new RectF(); + private final RectF btnSensYUpRect = new RectF(); + private final RectF sensYLabelRect = new RectF(); + private final RectF btnCollapseRect = new RectF(); + private final RectF btnFadeToggleRect = new RectF(); + // Per-selected-button resize bar rects. + private final RectF btnSizeDownRect = new RectF(); + private final RectF btnSizeLabelRect = new RectF(); + private final RectF btnSizeUpRect = new RectF(); + + // ---- Paints. + private final Paint paintBase = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint paintKnob = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint paintBtnIdle = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint paintBtnActive = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint paintLabel = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint paintEditBorder = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint paintLookBorder = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint paintLookFill = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint paintEditPillFill = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint paintEditPillText = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint paintHudBar = new Paint(); + private final Paint paintIconStroke = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint paintIconFill = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path tmpPath = new Path(); + private final RectF tmpRect = new RectF(); + + public TouchOverlayView(Context c) { + super(c); + init(c); + } + + public TouchOverlayView(Context c, @Nullable AttributeSet a) { + super(c, a); + init(c); + } + + private void init(Context c) { + layout = TouchLayout.load(c); + setWillNotDraw(false); + + paintBase.setColor(Color.argb(70, 255, 255, 255)); + paintBase.setStyle(Paint.Style.STROKE); + paintBase.setStrokeWidth(4); + + paintKnob.setColor(Color.argb(120, 255, 255, 255)); + paintKnob.setStyle(Paint.Style.FILL); + + paintBtnIdle.setColor(Color.argb(90, 200, 220, 255)); + paintBtnIdle.setStyle(Paint.Style.FILL); + + paintBtnActive.setColor(Color.argb(180, 255, 240, 150)); + paintBtnActive.setStyle(Paint.Style.FILL); + + paintLabel.setColor(Color.argb(220, 255, 255, 255)); + paintLabel.setTextAlign(Paint.Align.CENTER); + + paintEditBorder.setColor(Color.argb(220, 255, 160, 0)); + paintEditBorder.setStyle(Paint.Style.STROKE); + paintEditBorder.setStrokeWidth(6); + + paintLookBorder.setColor(Color.argb(110, 120, 200, 255)); + paintLookBorder.setStyle(Paint.Style.STROKE); + paintLookBorder.setStrokeWidth(3); + paintLookFill.setColor(Color.argb(20, 120, 200, 255)); + paintLookFill.setStyle(Paint.Style.FILL); + + paintEditPillFill.setColor(Color.argb(180, 40, 40, 48)); + paintEditPillFill.setStyle(Paint.Style.FILL); + paintEditPillText.setColor(Color.argb(255, 255, 200, 100)); + paintEditPillText.setTextAlign(Paint.Align.CENTER); + + paintHudBar.setColor(Color.argb(200, 20, 20, 28)); + + paintIconStroke.setColor(0xFFFFFFFF); + paintIconStroke.setStyle(Paint.Style.STROKE); + paintIconStroke.setStrokeCap(Paint.Cap.ROUND); + paintIconStroke.setStrokeJoin(Paint.Join.ROUND); + + paintIconFill.setColor(0xFFFFFFFF); + paintIconFill.setStyle(Paint.Style.FILL); + } + + public TouchLayout getLayout() { return layout; } + + public void reloadLayout() { + layout = TouchLayout.load(getContext()); + resetInputState(); + invalidate(); + } + + /** Call to force edit mode externally (used by LayoutEditorActivity). */ + public void setEditMode(boolean enabled) { + if (enabled == editMode) return; + editMode = enabled; + if (enabled) { + editSnapshot = layout.copy(); + hudCollapsed = false; + } else { + editSnapshot = null; + } + selectedButtonId = null; + resetInputState(); + invalidate(); + } + + public boolean isEditMode() { return editMode; } + + /** + * Disable the overlay's own HUD (EDIT pill + SAVE/RESET/RESIZE/CANCEL bar). + * The standalone LayoutEditorActivity supplies its own buttons as + * regular Android widgets, so we don't want double UI. + */ + public void setInternalHudVisible(boolean visible) { + internalHudVisible = visible; + invalidate(); + } + + private void resetInputState() { + pointerToElement.clear(); + leftPointer = rightPointer = lookPointer = movePointer = -1; + leftStickX = leftStickY = rightStickX = rightStickY = 0f; + pressedButtons = 0; + publish(false); + } + + // ---------------- Layout helpers ---------------- + + private float px(float norm, int size) { return norm * size; } + private int minExtent() { return Math.min(getWidth(), getHeight()); } + + @Nullable + private TouchLayout.Element hitTest(float x, float y) { + int w = getWidth(), h = getHeight(), m = minExtent(); + // Reverse iterate so visually top-most (buttons) wins over the lookpad. + for (int i = layout.elements.size() - 1; i >= 0; --i) { + TouchLayout.Element el = layout.elements.get(i); + float cx = px(el.cx, w), cy = px(el.cy, h); + if (el.kind == TouchLayout.Kind.LOOK_PAD || el.kind == TouchLayout.Kind.MOVE_PAD) { + float rx = el.hw * w, ry = el.hh * h; + if (x >= cx - rx && x <= cx + rx && y >= cy - ry && y <= cy + ry) { + return el; + } + } else { + float dx = x - cx, dy = y - cy; + float r = el.radius * m; + if (el.kind != TouchLayout.Kind.BUTTON) r *= 1.4f; + if (dx * dx + dy * dy <= r * r) return el; + } + } + return null; + } + + @Nullable + private TouchLayout.Element findById(String id) { + for (TouchLayout.Element el : layout.elements) if (el.id.equals(id)) return el; + return null; + } + + // ---------------- Touch handling ---------------- + + @Override + public boolean onTouchEvent(MotionEvent ev) { + int action = ev.getActionMasked(); + int idx = ev.getActionIndex(); + float x = ev.getX(idx), y = ev.getY(idx); + + // Any interaction resets the idle fade timer. + lastInteractionTimeMs = SystemClock.uptimeMillis(); + removeCallbacks(fadeKicker); + postDelayed(fadeKicker, IDLE_BEFORE_FADE_MS); + + // --- HUD (always tested first, in both gameplay and edit mode) --- + if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN) { + if (handleHudDown(x, y)) return true; + } + + if (editMode) return onEditTouch(ev); + + int pid = ev.getPointerId(idx); + + switch (action) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + handleDown(pid, x, y); + break; + case MotionEvent.ACTION_MOVE: + for (int i = 0; i < ev.getPointerCount(); ++i) { + handleMove(ev.getPointerId(i), ev.getX(i), ev.getY(i)); + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + case MotionEvent.ACTION_CANCEL: + handleUp(pid); + break; + } + + publish(pointerToElement.size() > 0 || lookPointer != -1 || movePointer != -1); + invalidate(); + return true; + } + + private void handleDown(int pid, float x, float y) { + TouchLayout.Element el = hitTest(x, y); + if (el == null) return; + + switch (el.kind) { + case LEFT_STICK: + if (leftPointer == -1) { + leftPointer = pid; + pointerToElement.put(pid, el.id); + updateStick(el, x, y, true); + } + break; + case RIGHT_STICK: + if (rightPointer == -1) { + rightPointer = pid; + pointerToElement.put(pid, el.id); + updateStick(el, x, y, false); + } + break; + case LOOK_PAD: + if (lookPointer == -1) { + lookPointer = pid; + lookLastX = x; lookLastY = y; + pointerToElement.put(pid, el.id); + } + break; + case MOVE_PAD: + if (movePointer == -1) { + movePointer = pid; + moveAnchorX = x; + moveAnchorY = y; + float r = el.radius > 0f ? el.radius : 0.09f; + moveMaxRadiusPx = r * minExtent(); + pointerToElement.put(pid, el.id); + } + break; + case BUTTON: + pointerToElement.put(pid, el.id); + pressedButtons |= el.mask; + break; + } + } + + private void handleMove(int pid, float x, float y) { + if (pid == lookPointer) { + float dx = (x - lookLastX) * layout.lookSensX; + float dy = (y - lookLastY) * layout.lookSensY; + lookLastX = x; lookLastY = y; + int idx = Math.round(dx), idy = Math.round(dy); + if (idx != 0 || idy != 0) { + nativeAddLookDelta(idx, idy); + } + return; + } + if (pid == movePointer) { + float dx = (x - moveAnchorX) / moveMaxRadiusPx; + float dy = (y - moveAnchorY) / moveMaxRadiusPx; + float len = (float) Math.sqrt(dx * dx + dy * dy); + if (len > 1f) { dx /= len; dy /= len; } + // In PD's CONTROLMODE_PC (the port default), stick1 is camera and + // stick2 is walk/strafe, so the move pad must feed the right stick. + rightStickX = dx; + rightStickY = dy; + return; + } + String id = pointerToElement.get(pid); + if (id == null) return; + TouchLayout.Element el = findById(id); + if (el == null) return; + if (el.kind == TouchLayout.Kind.LEFT_STICK) updateStick(el, x, y, true); + else if (el.kind == TouchLayout.Kind.RIGHT_STICK) updateStick(el, x, y, false); + } + + private void handleUp(int pid) { + if (pid == lookPointer) { + lookPointer = -1; + pointerToElement.remove(pid); + return; + } + if (pid == movePointer) { + movePointer = -1; + rightStickX = 0; + rightStickY = 0; + pointerToElement.remove(pid); + return; + } + + String id = pointerToElement.get(pid); + if (id == null) return; + pointerToElement.remove(pid); + + TouchLayout.Element el = findById(id); + if (el == null) return; + + switch (el.kind) { + case LEFT_STICK: + if (leftPointer == pid) { + leftPointer = -1; + leftStickX = leftStickY = 0f; + } + break; + case RIGHT_STICK: + if (rightPointer == pid) { + rightPointer = -1; + rightStickX = rightStickY = 0f; + } + break; + case BUTTON: + boolean stillHeld = false; + for (int i = 0; i < pointerToElement.size(); ++i) { + if (id.equals(pointerToElement.valueAt(i))) { stillHeld = true; break; } + } + if (!stillHeld) pressedButtons &= ~el.mask; + break; + default: + break; + } + } + + private void updateStick(TouchLayout.Element el, float x, float y, boolean left) { + int w = getWidth(), h = getHeight(), m = minExtent(); + float cx = px(el.cx, w), cy = px(el.cy, h); + float r = el.radius * m; + float dx = (x - cx) / r, dy = (y - cy) / r; + float len = (float) Math.sqrt(dx * dx + dy * dy); + if (len > 1f) { dx /= len; dy /= len; } + if (left) { leftStickX = dx; leftStickY = dy; } + else { rightStickX = dx; rightStickY = dy; } + } + + private static float clampStick(float v) { + return v < -1f ? -1f : (v > 1f ? 1f : v); + } + + private void publish(boolean anyDown) { + nativeSetState(leftStickX, leftStickY, rightStickX, rightStickY, + pressedButtons, anyDown); + } + + // ---------------- HUD (edit toggle + edit-mode buttons) ---------------- + + /** Returns true if the down-event was consumed by a HUD control. */ + private boolean handleHudDown(float x, float y) { + if (!internalHudVisible) return false; + if (editMode) { + // Per-button resize bar: takes priority over the main HUD so the + // user can press - / + even if it sits under a pill. It is only + // drawn when a button is selected. + if (selectedButtonId != null) { + TouchLayout.Element sel = findById(selectedButtonId); + if (sel != null) { + if (btnSizeDownRect.contains(x, y)) { + sel.radius = Math.max(BUTTON_SIZE_MIN, + sel.radius - BUTTON_SIZE_STEP); + invalidate(); + return true; + } + if (btnSizeUpRect.contains(x, y)) { + sel.radius = Math.min(BUTTON_SIZE_MAX, + sel.radius + BUTTON_SIZE_STEP); + invalidate(); + return true; + } + if (btnSizeLabelRect.contains(x, y)) { + // Consume taps on the label so they don't fall through + // to the button underneath. + return true; + } + } + } + // Collapse/expand toggle always takes priority so the user can + // reveal the rest of the screen underneath. + if (btnCollapseRect.contains(x, y)) { + hudCollapsed = !hudCollapsed; + invalidate(); + return true; + } + if (hudCollapsed) { + // Only the collapse pill is interactive while minimized. + return false; + } + if (btnSaveRect.contains(x, y)) { + layout.save(getContext()); + editSnapshot = null; + setEditMode(false); + return true; + } + if (btnCancelRect.contains(x, y)) { + if (editSnapshot != null) layout.assignFrom(editSnapshot); + setEditMode(false); + return true; + } + if (btnResetRect.contains(x, y)) { + TouchLayout defaults = TouchLayout.defaults(); + // Overwrite only geometry; keep same element ids/order. + for (int i = 0; i < layout.elements.size() && i < defaults.elements.size(); ++i) { + TouchLayout.Element dst = layout.elements.get(i); + TouchLayout.Element s = defaults.elements.get(i); + dst.cx = s.cx; dst.cy = s.cy; + dst.radius = s.radius; + dst.hw = s.hw; dst.hh = s.hh; + } + invalidate(); + return true; + } + if (btnFadeToggleRect.contains(x, y)) { + layout.idleFade = !layout.idleFade; + invalidate(); + return true; + } + if (btnSensXDownRect.contains(x, y)) { + layout.lookSensX = Math.max(LOOK_SENS_MIN, layout.lookSensX - LOOK_SENS_STEP); + invalidate(); + return true; + } + if (btnSensXUpRect.contains(x, y)) { + layout.lookSensX = Math.min(LOOK_SENS_MAX, layout.lookSensX + LOOK_SENS_STEP); + invalidate(); + return true; + } + if (btnSensYDownRect.contains(x, y)) { + layout.lookSensY = Math.max(LOOK_SENS_MIN, layout.lookSensY - LOOK_SENS_STEP); + invalidate(); + return true; + } + if (btnSensYUpRect.contains(x, y)) { + layout.lookSensY = Math.min(LOOK_SENS_MAX, layout.lookSensY + LOOK_SENS_STEP); + invalidate(); + return true; + } + return false; + } + // Gameplay: only the floating EDIT pill is interactive in the HUD. + if (editPillRect.contains(x, y)) { + setEditMode(true); + return true; + } + return false; + } + + // ---------------- Edit mode ---------------- + + private boolean onEditTouch(MotionEvent ev) { + int action = ev.getActionMasked(); + int idx = ev.getActionIndex(); + int w = getWidth(), h = getHeight(); + float x = ev.getX(idx), y = ev.getY(idx); + + switch (action) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: { + TouchLayout.Element el = hitTestEditable(x, y); + if (el == null) { + // Tap outside any editable element clears selection. + if (selectedButtonId != null) { + selectedButtonId = null; + invalidate(); + } + return true; + } + draggedId = el.id; + selectedButtonId = el.id; + dragOffsetX = x - px(el.cx, w); + dragOffsetY = y - px(el.cy, h); + break; + } + case MotionEvent.ACTION_MOVE: { + if (draggedId == null) return true; + TouchLayout.Element el = findById(draggedId); + if (el == null) return true; + el.cx = Math.max(0.02f, Math.min(0.98f, (x - dragOffsetX) / w)); + el.cy = Math.max(0.02f, Math.min(0.98f, (y - dragOffsetY) / h)); + break; + } + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + case MotionEvent.ACTION_CANCEL: + draggedId = null; + break; + } + invalidate(); + return true; + } + + /** Same as hitTest but skips the pads so they cannot be dragged. */ + @Nullable + private TouchLayout.Element hitTestEditable(float x, float y) { + int w = getWidth(), h = getHeight(), m = minExtent(); + for (int i = layout.elements.size() - 1; i >= 0; --i) { + TouchLayout.Element el = layout.elements.get(i); + if (el.kind == TouchLayout.Kind.LOOK_PAD + || el.kind == TouchLayout.Kind.MOVE_PAD) continue; + float cx = px(el.cx, w), cy = px(el.cy, h); + float dx = x - cx, dy = y - cy; + float r = el.radius * m; + if (dx * dx + dy * dy <= r * r) return el; + } + return null; + } + + // ---------------- Rendering ---------------- + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + int w = getWidth(), h = getHeight(), m = minExtent(); + paintLabel.setTextSize(Math.max(16f, m * 0.03f)); + + // Apply an optional idle-fade layer alpha to the game controls. Edit + // mode and the HUD always draw at full opacity (see below). + float fadeFactor = computeOverlayAlpha(); + int overlayLayer = -1; + if (fadeFactor < 0.999f) { + overlayLayer = canvas.saveLayerAlpha(0, 0, w, h, + (int) (fadeFactor * 255f)); + } + + for (TouchLayout.Element el : layout.elements) { + float cx = px(el.cx, w), cy = px(el.cy, h); + switch (el.kind) { + case LEFT_STICK: + case RIGHT_STICK: { + float r = el.radius * m; + canvas.drawCircle(cx, cy, r, paintBase); + float sx = (el.kind == TouchLayout.Kind.LEFT_STICK) ? leftStickX : rightStickX; + float sy = (el.kind == TouchLayout.Kind.LEFT_STICK) ? leftStickY : rightStickY; + canvas.drawCircle(cx + sx * r * 0.5f, cy + sy * r * 0.5f, r * 0.45f, paintKnob); + break; + } + case LOOK_PAD: + // Invisible by design — it is simply the right half of + // the screen. Nothing to draw. + break; + case MOVE_PAD: + // Same deal, but show the floating stick at the anchor + // while the user is actually touching the pad so there + // is clear feedback for direction + deflection. + if (movePointer != -1) { + canvas.drawCircle(moveAnchorX, moveAnchorY, moveMaxRadiusPx, paintBase); + canvas.drawCircle( + moveAnchorX + rightStickX * moveMaxRadiusPx * 0.5f, + moveAnchorY + rightStickY * moveMaxRadiusPx * 0.5f, + moveMaxRadiusPx * 0.45f, paintKnob); + } + break; + case BUTTON: { + float r = el.radius * m; + boolean pressed = (pressedButtons & el.mask) != 0; + canvas.drawCircle(cx, cy, r, pressed ? paintBtnActive : paintBtnIdle); + drawButtonIcon(canvas, el.id, cx, cy, r, pressed); + break; + } + } + // Pads are not editable, so only show the orange edit outline + // on buttons. + if (editMode && el.kind == TouchLayout.Kind.BUTTON) { + float r = el.radius * m; + canvas.drawCircle(cx, cy, r, paintEditBorder); + } + } + + if (overlayLayer >= 0) { + canvas.restoreToCount(overlayLayer); + } + + if (internalHudVisible) { + // The EDIT pill (when not in edit mode) fades with the rest of + // the overlay — wrap it in its own alpha layer. + if (!editMode) { + int pillLayer = -1; + if (fadeFactor < 0.999f) { + pillLayer = canvas.saveLayerAlpha(0, 0, w, h, + (int) (fadeFactor * 255f)); + } + drawHud(canvas, w, h, m); + if (pillLayer >= 0) canvas.restoreToCount(pillLayer); + } else { + drawHud(canvas, w, h, m); + } + } + + // If we are currently inside the fade-in/out animation window, + // schedule another frame so the animation progresses. Once the + // alpha has settled (either full or IDLE_ALPHA), we stop repainting. + long since = SystemClock.uptimeMillis() - lastInteractionTimeMs; + if (layout.idleFade && !editMode + && since >= IDLE_BEFORE_FADE_MS + && since < IDLE_BEFORE_FADE_MS + FADE_DURATION_MS) { + postInvalidateOnAnimation(); + } + } + + /** 1.0 = fully visible, IDLE_ALPHA = fully faded. */ + private float computeOverlayAlpha() { + if (!layout.idleFade || editMode) return 1f; + long since = SystemClock.uptimeMillis() - lastInteractionTimeMs; + if (since < IDLE_BEFORE_FADE_MS) return 1f; + if (since >= IDLE_BEFORE_FADE_MS + FADE_DURATION_MS) return IDLE_ALPHA; + float t = (since - IDLE_BEFORE_FADE_MS) / (float) FADE_DURATION_MS; + return 1f + (IDLE_ALPHA - 1f) * t; + } + + private void drawHud(Canvas canvas, int w, int h, int m) { + float dp = m / 400f; + float pillH = 48 * dp, pillW = 96 * dp; + float margin = 12 * dp; + + if (editMode) { + // The collapse pill lives in the top-right corner and is the + // only HUD control visible when collapsed. + float collapseW = pillH * 1.1f; + float collapseX0 = w - margin - collapseW; + float collapseY0 = margin * 0.3f; + btnCollapseRect.set(collapseX0, collapseY0, + collapseX0 + collapseW, collapseY0 + pillH); + + if (hudCollapsed) { + drawPill(canvas, btnCollapseRect, 0xAA202028, "EDIT ▼"); + // Clear other rects so stale positions don't swallow taps. + btnSaveRect.setEmpty(); btnResizeRect.setEmpty(); + btnResetRect.setEmpty(); btnCancelRect.setEmpty(); + btnFadeToggleRect.setEmpty(); + btnSensXDownRect.setEmpty(); btnSensXUpRect.setEmpty(); + sensXLabelRect.setEmpty(); + btnSensYDownRect.setEmpty(); btnSensYUpRect.setEmpty(); + sensYLabelRect.setEmpty(); + editPillRect.setEmpty(); + drawButtonResizeBar(canvas, w, h, m); + return; + } + + // Two-row HUD. Row 1 = actions. Row 2 = per-axis sensitivity. + float rowGapY = 6 * dp; + float barH = pillH * 2 + rowGapY + margin * 1.6f; + canvas.drawRect(0, 0, w, barH, paintHudBar); + + float gap = 8 * dp; + float squarePillW = pillH; // small square pill for +/- + + // --- Row 1 --- + float r1y0 = margin * 0.3f; + float r1y1 = r1y0 + pillH; + float x = margin; + + btnSaveRect.set(x, r1y0, x + pillW, r1y1); + x += pillW + gap; + btnResetRect.set(x, r1y0, x + pillW, r1y1); + x += pillW + gap; + btnFadeToggleRect.set(x, r1y0, x + pillW * 1.3f, r1y1); + x += pillW * 1.3f + gap; + btnCancelRect.set(x, r1y0, x + pillW, r1y1); + // RESIZE pill removed — per-button resize handles replaced it. + btnResizeRect.setEmpty(); + + // --- Row 2 --- + float r2y0 = r1y1 + rowGapY; + float r2y1 = r2y0 + pillH; + x = margin; + + btnSensXDownRect.set(x, r2y0, x + squarePillW, r2y1); + x += squarePillW + gap * 0.3f; + sensXLabelRect.set(x, r2y0, x + pillW * 1.3f, r2y1); + x += pillW * 1.3f + gap * 0.3f; + btnSensXUpRect.set(x, r2y0, x + squarePillW, r2y1); + x += squarePillW + gap * 2f; + + btnSensYDownRect.set(x, r2y0, x + squarePillW, r2y1); + x += squarePillW + gap * 0.3f; + sensYLabelRect.set(x, r2y0, x + pillW * 1.3f, r2y1); + x += pillW * 1.3f + gap * 0.3f; + btnSensYUpRect.set(x, r2y0, x + squarePillW, r2y1); + + drawPill(canvas, btnSaveRect, 0xFF3a8a3a, "SAVE"); + drawPill(canvas, btnResetRect, 0xFF444444, "RESET"); + drawPill(canvas, btnFadeToggleRect, + layout.idleFade ? 0xFF33688a : 0xFF444444, + layout.idleFade ? "FADE: ON" : "FADE: OFF"); + drawPill(canvas, btnCancelRect, 0xFF8a3a3a, "CANCEL"); + + drawPill(canvas, btnSensXDownRect, 0xFF444444, "-"); + drawPill(canvas, sensXLabelRect, 0xFF222228, + String.format("X %.2f", layout.lookSensX)); + drawPill(canvas, btnSensXUpRect, 0xFF444444, "+"); + + drawPill(canvas, btnSensYDownRect, 0xFF444444, "-"); + drawPill(canvas, sensYLabelRect, 0xFF222228, + String.format("Y %.2f", layout.lookSensY)); + drawPill(canvas, btnSensYUpRect, 0xFF444444, "+"); + + // Collapse button last so it sits on top of the bar row. + drawPill(canvas, btnCollapseRect, 0xAA202028, "▲"); + + // Disable the live-edit pill while editing to avoid visual clutter. + editPillRect.setEmpty(); + + drawButtonResizeBar(canvas, w, h, m); + } else { + // Floating "EDIT" pill in the top-right corner. + float x0 = w - margin - pillW; + float y0 = margin; + editPillRect.set(x0, y0, x0 + pillW, y0 + pillH); + drawPill(canvas, editPillRect, 0xAA202028, "EDIT"); + } + } + + /** + * Draws a vector icon that represents what the button does, centered on + * (cx, cy) with a drawing radius of ~0.55 * button radius. + */ + private void drawButtonIcon(Canvas canvas, String id, float cx, float cy, float r, boolean pressed) { + int alpha = pressed ? 0xFF : 0xE0; + paintIconStroke.setColor(0x00FFFFFF | (alpha << 24)); + paintIconFill.setColor(0x00FFFFFF | (alpha << 24)); + float ir = r * 0.55f; + + switch (id) { + case "fire": { + // Trigger bullseye: outer orange ring + solid red dot. + paintIconStroke.setColor(0x00FF6040 | (alpha << 24)); + paintIconStroke.setStrokeWidth(r * 0.10f); + canvas.drawCircle(cx, cy, ir, paintIconStroke); + paintIconFill.setColor(0x00FF4030 | (alpha << 24)); + canvas.drawCircle(cx, cy, ir * 0.55f, paintIconFill); + paintIconStroke.setColor(0x00FFFFFF | (alpha << 24)); + paintIconFill.setColor(0x00FFFFFF | (alpha << 24)); + break; + } + case "aim": { + // Scope reticle: ring + cross lines with central dot. + paintIconStroke.setStrokeWidth(r * 0.08f); + canvas.drawCircle(cx, cy, ir, paintIconStroke); + canvas.drawLine(cx - ir * 1.15f, cy, cx - ir * 0.45f, cy, paintIconStroke); + canvas.drawLine(cx + ir * 0.45f, cy, cx + ir * 1.15f, cy, paintIconStroke); + canvas.drawLine(cx, cy - ir * 1.15f, cx, cy - ir * 0.45f, paintIconStroke); + canvas.drawLine(cx, cy + ir * 0.45f, cx, cy + ir * 1.15f, paintIconStroke); + canvas.drawCircle(cx, cy, ir * 0.12f, paintIconFill); + break; + } + case "use": { + // Upward-pointing hand / open palm, approximated as an arrow. + paintIconStroke.setStrokeWidth(r * 0.13f); + tmpPath.reset(); + tmpPath.moveTo(cx - ir * 0.55f, cy + ir * 0.6f); + tmpPath.lineTo(cx + ir * 0.55f, cy + ir * 0.6f); + tmpPath.lineTo(cx + ir * 0.55f, cy - ir * 0.3f); + tmpPath.moveTo(cx, cy - ir * 0.8f); + tmpPath.lineTo(cx + ir * 0.55f, cy - ir * 0.3f); + tmpPath.lineTo(cx, cy + ir * 0.15f); + canvas.drawPath(tmpPath, paintIconStroke); + break; + } + case "reload": { + // 3/4 circular arrow with a triangular arrowhead. + paintIconStroke.setStrokeWidth(r * 0.13f); + paintIconStroke.setStyle(Paint.Style.STROKE); + tmpRect.set(cx - ir, cy - ir, cx + ir, cy + ir); + canvas.drawArc(tmpRect, -40f, 260f, false, paintIconStroke); + // Arrow tip at sweep end (-40 + 260 = 220 degrees). + double endAng = Math.toRadians(220); + float ex = cx + ir * (float) Math.cos(endAng); + float ey = cy + ir * (float) Math.sin(endAng); + double perp = endAng + Math.PI / 2; + float nx = (float) Math.cos(perp), ny = (float) Math.sin(perp); + float tipLen = ir * 0.45f; + float tipBase = ir * 0.30f; + float bx = ex - tipLen * (float) Math.cos(endAng); + float by = ey - tipLen * (float) Math.sin(endAng); + tmpPath.reset(); + tmpPath.moveTo(ex, ey); + tmpPath.lineTo(bx + nx * tipBase, by + ny * tipBase); + tmpPath.lineTo(bx - nx * tipBase, by - ny * tipBase); + tmpPath.close(); + canvas.drawPath(tmpPath, paintIconFill); + break; + } + case "altfire": { + // Hollow ring indicating "alternate mode". + paintIconStroke.setStrokeWidth(r * 0.10f); + canvas.drawCircle(cx, cy, ir * 0.75f, paintIconStroke); + canvas.drawCircle(cx, cy, ir * 0.15f, paintIconFill); + break; + } + case "wprev": + case "wnext": { + boolean left = id.equals("wprev"); + float sign = left ? -1f : 1f; + tmpPath.reset(); + tmpPath.moveTo(cx + sign * ir * 0.55f, cy); + tmpPath.lineTo(cx - sign * ir * 0.35f, cy - ir * 0.6f); + tmpPath.lineTo(cx - sign * ir * 0.35f, cy + ir * 0.6f); + tmpPath.close(); + canvas.drawPath(tmpPath, paintIconFill); + break; + } + case "radial": { + // 3×3 dot grid, evocative of the weapon radial menu. + float sp = ir * 0.55f; + float dr = ir * 0.14f; + for (int gx = -1; gx <= 1; ++gx) { + for (int gy = -1; gy <= 1; ++gy) { + canvas.drawCircle(cx + gx * sp, cy + gy * sp, dr, paintIconFill); + } + } + break; + } + case "crouch": { + // Down chevron on top of a floor bar. + paintIconStroke.setStrokeWidth(r * 0.14f); + tmpPath.reset(); + tmpPath.moveTo(cx - ir * 0.55f, cy - ir * 0.25f); + tmpPath.lineTo(cx, cy + ir * 0.25f); + tmpPath.lineTo(cx + ir * 0.55f, cy - ir * 0.25f); + canvas.drawPath(tmpPath, paintIconStroke); + canvas.drawLine(cx - ir * 0.7f, cy + ir * 0.55f, + cx + ir * 0.7f, cy + ir * 0.55f, paintIconStroke); + break; + } + case "start": { + // Pause bars (==) for START / pause. + paintIconFill.setColor(0x00FFFFFF | (alpha << 24)); + float bw = ir * 0.25f, bh = ir * 0.9f; + canvas.drawRoundRect(cx - ir * 0.45f, cy - bh * 0.5f, + cx - ir * 0.45f + bw, cy + bh * 0.5f, + bw * 0.4f, bw * 0.4f, paintIconFill); + canvas.drawRoundRect(cx + ir * 0.20f, cy - bh * 0.5f, + cx + ir * 0.20f + bw, cy + bh * 0.5f, + bw * 0.4f, bw * 0.4f, paintIconFill); + break; + } + case "cancel": { + // X mark for back / cancel. + paintIconStroke.setStrokeWidth(r * 0.15f); + canvas.drawLine(cx - ir * 0.55f, cy - ir * 0.55f, + cx + ir * 0.55f, cy + ir * 0.55f, paintIconStroke); + canvas.drawLine(cx + ir * 0.55f, cy - ir * 0.55f, + cx - ir * 0.55f, cy + ir * 0.55f, paintIconStroke); + break; + } + default: { + // Unknown id — fall back to a centered text label so future + // buttons still show something until an icon is authored. + TouchLayout.Element el = findById(id); + if (el != null && el.label != null && !el.label.isEmpty()) { + paintLabel.setTextSize(r * 0.85f); + canvas.drawText(el.label, cx, + cy + paintLabel.getTextSize() * 0.35f, paintLabel); + } + } + } + } + + /** + * Resize bar for the currently selected button: "[ - ] [ r=0.055 ] [ + ]". + * Positioned just below the button, or just above if that would run + * off-screen. + */ + private void drawButtonResizeBar(Canvas canvas, int w, int h, int m) { + if (selectedButtonId == null) { + btnSizeDownRect.setEmpty(); + btnSizeLabelRect.setEmpty(); + btnSizeUpRect.setEmpty(); + return; + } + TouchLayout.Element el = findById(selectedButtonId); + if (el == null || el.kind != TouchLayout.Kind.BUTTON) { + btnSizeDownRect.setEmpty(); + btnSizeLabelRect.setEmpty(); + btnSizeUpRect.setEmpty(); + return; + } + float dp = m / 400f; + float pillH = 44 * dp; + float squareW = pillH; + float labelW = 110 * dp; + float gap = 6 * dp; + float barW = squareW * 2 + labelW + gap * 2; + + float cx = el.cx * w, cy = el.cy * h, r = el.radius * m; + + float x0 = cx - barW / 2f; + if (x0 < 4 * dp) x0 = 4 * dp; + if (x0 + barW > w - 4 * dp) x0 = w - 4 * dp - barW; + + float y0 = cy + r + gap * 1.4f; + if (y0 + pillH > h - 4 * dp) { + y0 = cy - r - gap * 1.4f - pillH; + } + + float x = x0; + btnSizeDownRect.set(x, y0, x + squareW, y0 + pillH); + x += squareW + gap; + btnSizeLabelRect.set(x, y0, x + labelW, y0 + pillH); + x += labelW + gap; + btnSizeUpRect.set(x, y0, x + squareW, y0 + pillH); + + drawPill(canvas, btnSizeDownRect, 0xDD2a2a32, "−"); + drawPill(canvas, btnSizeLabelRect, 0xDD111116, + String.format("r %.3f", el.radius)); + drawPill(canvas, btnSizeUpRect, 0xDD2a2a32, "+"); + } + + private void drawPill(Canvas canvas, RectF rect, int argb, String label) { + paintEditPillFill.setColor(argb); + float r = rect.height() * 0.5f; + canvas.drawRoundRect(rect, r, r, paintEditPillFill); + paintEditPillText.setTextSize(rect.height() * 0.45f); + canvas.drawText(label, rect.centerX(), + rect.centerY() + paintEditPillText.getTextSize() * 0.35f, + paintEditPillText); + } + + private native void nativeSetState(float lx, float ly, float rx, float ry, + int buttons, boolean anyDown); + private native void nativeAddLookDelta(int dx, int dy); +} diff --git a/android/app/src/main/res/layout/activity_launcher.xml b/android/app/src/main/res/layout/activity_launcher.xml index a1a3d3ebef..022b4b68c2 100644 --- a/android/app/src/main/res/layout/activity_launcher.xml +++ b/android/app/src/main/res/layout/activity_launcher.xml @@ -14,14 +14,28 @@ android:layout_height="wrap_content" android:textColor="#FFFFFF" android:textSize="16sp" - android:text="ROM not found. Select your ROM to continue."/> + android:text="Perfect Dark"/> + +