diff --git a/CMakeLists.txt b/CMakeLists.txt
index 533ba58..d7423d9 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -320,6 +320,7 @@ endif()
if (ANDROID)
list(APPEND SOURCES ${CMAKE_SOURCE_DIR}/src/main/android_glue.cpp)
list(APPEND SOURCES ${CMAKE_SOURCE_DIR}/src/main/android_diag.cpp)
+ list(APPEND SOURCES ${CMAKE_SOURCE_DIR}/src/main/android_touch.cpp)
endif()
target_include_directories(Goemon64Recompiled PRIVATE
diff --git a/README.md b/README.md
index 884021d..490aa6e 100644
--- a/README.md
+++ b/README.md
@@ -16,10 +16,10 @@ The APK does not include the game. You'll need your own legally obtained ROM.
1. Install the APK and open the app.
2. On first launch, you'll be asked to pick your ROM file — use the file picker, it gets copied into the app's own storage.
-3. Make sure you have a gamepad. **The game is controller-only** — there is no touchscreen control scheme. A handheld's built-in controls work as-is; on a phone, pair a physical or Bluetooth pad first.
+3. Play with whatever you have. A handheld's built-in controls work as-is, and a physical or Bluetooth pad works on a phone. If there is no gamepad, **on-screen controls** appear automatically — see [On-Screen Controls](#on-screen-controls) below.
4. Press Start.
-**Requirements:** Android 9.0+, a 64-bit (`arm64-v8a`) device, and a Vulkan-capable GPU. This covers effectively any phone or handheld from the last several years. Tested primarily on Snapdragon/Adreno handhelds (Retroid Pocket 5, AYN Thor).
+**Requirements:** Android 9.0+, a 64-bit (`arm64-v8a`) device, and a Vulkan-capable GPU. A gamepad is recommended but no longer required. This covers effectively any phone or handheld from the last several years. Tested primarily on Snapdragon/Adreno handhelds (Retroid Pocket 5, AYN Thor).
## Something's Wrong — Quick Fixes
@@ -36,6 +36,29 @@ The APK does not include the game. You'll need your own legally obtained ROM.
More detail on each of these is in [Troubleshooting Details](#troubleshooting-details) below.
+## On-Screen Controls
+
+On a device with no gamepad, a full N64 pad is drawn over the game: analog stick under
+the left thumb, A and B under the right with the C-buttons above them, L/Z/R along the
+top edge, and Start in the middle.
+
+By default it **hides as soon as a gamepad is used** and comes back the next time you
+touch the screen, so a handheld with real sticks never sees it and a phone never has to
+go looking for a setting.
+
+Whether it appears at all is under **Settings → Touch → On-Screen Controls**
+(Auto / On / Off), along with **Edit Layout**, which lets you drag the controls
+wherever your hands actually want them, over the running game.
+
+**Long-press the ☰ handle** for size, opacity and vibration. A short tap on ☰ opens
+the game's settings menu (☰ is the on-screen stand-in for Select).
+
+The on-screen buttons go through the same bindings as a physical controller, so
+anything you remap in **Settings → Controls** moves them too, and they work alongside a
+real pad rather than instead of it.
+
+Full detail, and how to work on the layout: [docs/touch-controls.md](docs/touch-controls.md).
+
## Default Controls
The default gamepad layout (Xbox-style face buttons). Everything is remappable in **Settings → Controls**.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 30988ac..bb670ce 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,6 +5,12 @@
+
+
Both dispatch hooks only look, and always delegate: SDL owns the real + * handling of these events, and the overlay's interest in them is limited to + * noticing that a physical pad exists so it can get off the screen. Doing this + * here rather than in the view is deliberate -- the view sits above SDL's surface + * and must not compete with it for key or motion events. + */ + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + if (touchOverlay != null) { + touchOverlay.noteInputEvent(event); + } + return super.dispatchKeyEvent(event); + } + + @Override + public boolean dispatchGenericMotionEvent(MotionEvent event) { + if (touchOverlay != null) { + touchOverlay.noteInputEvent(event); + } + return super.dispatchGenericMotionEvent(event); } @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); + if (!hasFocus && touchOverlay != null) { + // Losing focus without pausing (a notification shade, a permission dialog) + // still means fingers have left the glass. + touchOverlay.view().release(); + } // Immersive flags set in onCreate()/onResume() run before the window // first gains focus, so Android drops them and the status/navigation // bars stay visible on launch. Re-apply once we actually have focus @@ -488,6 +553,20 @@ private void restartInto(boolean autostart) { startActivity(intent); } + /** + * Put the on-screen controls into layout-edit mode, from the game's settings menu. + * + *
Called from native code (android_glue.cpp) on the render thread, so the work + * is posted to the UI thread by the controller. Named to match the + * {@code GetMethodID} lookup in nativeInit -- renaming this silently breaks the + * menu button, since a missing method id is tolerated rather than fatal. + */ + void requestTouchLayoutEditor() { + if (touchOverlay != null) { + touchOverlay.requestEditorFromNative(); + } + } + // Implemented in android_glue.cpp. public native void nativeInit(String dataPath, boolean autostart); /** diff --git a/android/app/src/main/java/com/goemon64/recomp/touch/NativeTouch.java b/android/app/src/main/java/com/goemon64/recomp/touch/NativeTouch.java new file mode 100644 index 0000000..4620a04 --- /dev/null +++ b/android/app/src/main/java/com/goemon64/recomp/touch/NativeTouch.java @@ -0,0 +1,162 @@ +package com.goemon64.recomp.touch; + +/** + * The one seam between the on-screen controls and the game. + * + *
A few calls, none of them blocking: the overlay pushes + * the whole virtual pad every time it changes, and native code reads it from the + * game thread. See {@code include/goemon_touch.h} for what happens on the far side. + * + *
Not routed through the virtual pad: the menu toggle is event-driven on the + * native side, so a bit in the polled button mask is never seen by it. See + * {@code goemon64::touch::request_menu_toggle}. + */ + public static void requestMenuToggle() { + if (!available()) { + return; + } + try { + nativeRequestMenuToggle(); + } catch (UnsatisfiedLinkError ignored) { + available = Boolean.FALSE; + } + } + + /** + * Visibility mode chosen in the game's own settings menu, or {@code null} when the + * native library is absent, in which case the locally stored layout value stands in. + * + *
The game config owns this rather than SharedPreferences so there is exactly + * one source of truth: the player changes it in the menu they already have open, + * and it is saved with the rest of their settings. + */ + public static TouchLayout.Visibility mode() { + if (!available()) { + return null; + } + try { + switch (nativeGetMode()) { + case 1: return TouchLayout.Visibility.ALWAYS; + case 2: return TouchLayout.Visibility.NEVER; + default: return TouchLayout.Visibility.AUTO; + } + } catch (UnsatisfiedLinkError ignored) { + available = Boolean.FALSE; + return null; + } + } + + /** + * Stick response from the game config, 0..100, or -1 when the native library is + * absent, in which case the pad keeps its current setting. + */ + public static int stickSensitivity() { + if (!available()) { + return -1; + } + try { + return nativeGetStickSensitivity(); + } catch (UnsatisfiedLinkError ignored) { + available = Boolean.FALSE; + return -1; + } + } + + private static native void nativeSetState(int buttonMask, float[] axes); + + private static native int nativeGetStickSensitivity(); + + private static native int nativeGetMode(); + + private static native void nativeRequestMenuToggle(); + + private static native boolean nativeIsMenuOpen(); + + private static native void nativeSetActive(boolean active); + + private static native void nativeClearState(); +} diff --git a/android/app/src/main/java/com/goemon64/recomp/touch/TouchControl.java b/android/app/src/main/java/com/goemon64/recomp/touch/TouchControl.java new file mode 100644 index 0000000..a73a961 --- /dev/null +++ b/android/app/src/main/java/com/goemon64/recomp/touch/TouchControl.java @@ -0,0 +1,128 @@ +package com.goemon64.recomp.touch; + +/** + * The on-screen controls and what each one presses. + * + *
Every control emits an SDL game controller input, not an N64 button. + * That indirection is the whole design: the overlay is merged into + * {@code controller_button_state()} / {@code controller_axis_state()} alongside + * physical pads (see {@code include/goemon_touch.h}), so whatever the player has + * bound in Settings → Controls is what the on-screen button does. Rebinding B + * moves the on-screen B with it, and a mod that reads L gets the on-screen L for + * free. Emitting N64 buttons directly would instead have needed a second, parallel + * binding system that could silently drift out of step with the real one. + * + *
Why the C-buttons emit D-pad. The stock mapping gives each C direction + * three bindings: a face/shoulder button, the right stick, and the D-pad. The + * D-pad is the only one of the three that covers all four C directions (C-Right has + * no face button at all) and survives analog-camera mode, which suppresses + * the right stick. So D-pad is the binding that makes the on-screen C cluster + * behave the same whether or not the analog camera is on. + */ +public enum TouchControl { + + /** Analog stick. Drives an axis pair rather than a button. */ + STICK(Kind.STICK, "", Dir.NONE, Sdl.NONE, Sdl.NONE), + + /** N64 A — jump. The big one on a real pad, so it gets the largest default radius. */ + A(Kind.BUTTON, "A", Dir.NONE, Sdl.BUTTON_A, Sdl.NONE), + /** N64 B — attack. */ + B(Kind.BUTTON, "B", Dir.NONE, Sdl.BUTTON_B, Sdl.NONE), + + C_UP(Kind.BUTTON, "C", Dir.UP, Sdl.BUTTON_DPAD_UP, Sdl.NONE), + C_RIGHT(Kind.BUTTON, "C", Dir.RIGHT, Sdl.BUTTON_DPAD_RIGHT, Sdl.NONE), + C_DOWN(Kind.BUTTON, "C", Dir.DOWN, Sdl.BUTTON_DPAD_DOWN, Sdl.NONE), + C_LEFT(Kind.BUTTON, "C", Dir.LEFT, Sdl.BUTTON_DPAD_LEFT, Sdl.NONE), + + /** N64 Z — crouch. An analog trigger in the stock mapping, so it drives an axis. */ + Z(Kind.SHOULDER, "Z", Dir.NONE, Sdl.NONE, Sdl.AXIS_TRIGGERLEFT), + /** N64 L — unbound by this game; kept because mods use it. */ + L(Kind.SHOULDER, "L", Dir.NONE, Sdl.BUTTON_LEFTSHOULDER, Sdl.NONE), + /** N64 R — camera / Hook Chain. Also an analog trigger in the stock mapping. */ + R(Kind.SHOULDER, "R", Dir.NONE, Sdl.NONE, Sdl.AXIS_TRIGGERRIGHT), + + START(Kind.BUTTON, "START", Dir.NONE, Sdl.BUTTON_START, Sdl.NONE), + + /** + * Opens the recomp's own settings menu — the on-screen stand-in for Select. + * Without it, a device with no gamepad could never reach Settings, because + * Select appears nowhere else on the overlay. + */ + MENU(Kind.MENU, "", Dir.NONE, Sdl.BUTTON_BACK, Sdl.NONE); + + /** + * SDL input ids, mirrored from {@code SDL_gamecontroller.h}. + * + *
These live in a nested class for two reasons. Java forbids an enum + * constant's arguments from referring to a static field of the same enum by + * simple name (the fields initialise after the constants), so they could not + * be plain fields here. And {@code src/main/android_touch.cpp} static-asserts + * every value below against the real SDL enum, so an SDL renumbering breaks + * the native build and names this file — instead of shipping an overlay where + * every button silently presses the wrong thing. + */ + public static final class Sdl { + private Sdl() {} + + public static final int NONE = -1; + + // SDL_GameControllerButton + public static final int BUTTON_A = 0; + public static final int BUTTON_B = 1; + public static final int BUTTON_BACK = 4; + public static final int BUTTON_START = 6; + public static final int BUTTON_LEFTSHOULDER = 9; + public static final int BUTTON_DPAD_UP = 11; + public static final int BUTTON_DPAD_DOWN = 12; + public static final int BUTTON_DPAD_LEFT = 13; + public static final int BUTTON_DPAD_RIGHT = 14; + + // SDL_GameControllerAxis + public static final int AXIS_LEFTX = 0; + public static final int AXIS_LEFTY = 1; + public static final int AXIS_TRIGGERLEFT = 4; + public static final int AXIS_TRIGGERRIGHT = 5; + + /** Length of the axis array the native side expects. */ + public static final int AXIS_COUNT = 6; + } + + /** How a control is drawn and hit-tested. */ + public enum Kind { + /** Round button, one circular hit target. */ + BUTTON, + /** Rounded rectangle along a screen edge — L, Z and R. */ + SHOULDER, + /** Analog stick: a base ring with a knob that follows the finger. */ + STICK, + /** The settings handle: drawn as a hamburger rather than a glyph. */ + MENU + } + + /** Which way a control's arrow points, for the C cluster. */ + public enum Dir { NONE, UP, RIGHT, DOWN, LEFT } + + public final Kind kind; + public final String label; + public final Dir dir; + /** SDL button this control presses, or {@link Sdl#NONE} if it drives an axis. */ + public final int sdlButton; + /** SDL axis this control drives to 1.0, or {@link Sdl#NONE} if it is a button. */ + public final int sdlAxis; + + TouchControl(Kind kind, String label, Dir dir, int sdlButton, int sdlAxis) { + this.kind = kind; + this.label = label; + this.dir = dir; + this.sdlButton = sdlButton; + this.sdlAxis = sdlAxis; + } + + /** True if this control is one of the four C-cluster members. */ + public boolean isCButton() { + return this == C_UP || this == C_RIGHT || this == C_DOWN || this == C_LEFT; + } + + /** The C cluster in diamond order: up, right, down, left. */ + public static final TouchControl[] C_CLUSTER = { C_UP, C_RIGHT, C_DOWN, C_LEFT }; +} diff --git a/android/app/src/main/java/com/goemon64/recomp/touch/TouchLayout.java b/android/app/src/main/java/com/goemon64/recomp/touch/TouchLayout.java new file mode 100644 index 0000000..7463e2a --- /dev/null +++ b/android/app/src/main/java/com/goemon64/recomp/touch/TouchLayout.java @@ -0,0 +1,356 @@ +package com.goemon64.recomp.touch; + +import java.util.EnumMap; +import java.util.Locale; +import java.util.Map; + +/** + * Where every on-screen control sits, how big it is, and whether it is shown. + * + *
Deliberately free of any {@code android.view} / {@code android.graphics} type. + * The layout is the part of the overlay with real arithmetic in it — normalisation, + * scaling, clamping, the C-diamond derivation — so it is kept as plain Java, + * separate from drawing (see docs/touch-controls.md). + * + *
The C cluster is stored as a single anchor plus a spread, not four
+ * independent positions. The four C buttons are small and always move together on a
+ * real pad; giving the editor one handle for the diamond keeps them in formation and
+ * makes the cluster possible to place with a thumb.
+ */
+public final class TouchLayout {
+
+ /** Serialisation version, so a future layout change can migrate rather than reset. */
+ public static final int VERSION = 1;
+
+ /** Bounds for the global size multiplier the settings screen offers. */
+ public static final float SCALE_MIN = 0.65f;
+ public static final float SCALE_MAX = 1.60f;
+
+ /** Bounds for overlay opacity. Never fully opaque: it sits over the game. */
+ public static final float OPACITY_MIN = 0.15f;
+ public static final float OPACITY_MAX = 0.95f;
+
+ /** When the overlay is shown at all. */
+ public enum Visibility {
+ /** Show until a gamepad is used, then hide until the screen is touched. */
+ AUTO,
+ /** Always draw the overlay, even with a controller attached. */
+ ALWAYS,
+ /** Never draw it — the pre-existing controller-only behaviour. */
+ NEVER
+ }
+
+ /** A control's placement: normalised centre plus a size multiplier of its own. */
+ public static final class Placement {
+ public float x;
+ public float y;
+ public float scale;
+ public boolean visible;
+
+ Placement(float x, float y, float scale, boolean visible) {
+ this.x = x;
+ this.y = y;
+ this.scale = scale;
+ this.visible = visible;
+ }
+
+ Placement copy() {
+ return new Placement(x, y, scale, visible);
+ }
+ }
+
+ private final Map The vertical placement is biased low on purpose. Thumbs rest at the bottom
+ * corners, and the top third of a Goemon screen is where the HUD and the horizon
+ * live — the two things a hand should never be covering.
+ */
+ public static TouchLayout defaults() {
+ TouchLayout l = new TouchLayout();
+
+ // Left thumb.
+ l.placements.put(TouchControl.STICK, new Placement(0.145f, 0.660f, 1.0f, true));
+
+ // Right thumb. B sits up and to the left of A, as on a real N64 pad.
+ l.placements.put(TouchControl.A, new Placement(0.900f, 0.730f, 1.0f, true));
+ l.placements.put(TouchControl.B, new Placement(0.788f, 0.620f, 1.0f, true));
+
+ // C diamond anchor — one handle, four buttons derived from it.
+ l.placements.put(TouchControl.C_UP, new Placement(0.885f, 0.330f, 1.0f, true));
+ // The other three C entries carry only scale/visibility; their positions are
+ // derived from C_UP's anchor in geometry(). Kept in the map so each can still
+ // be hidden individually.
+ l.placements.put(TouchControl.C_RIGHT, new Placement(0f, 0f, 1.0f, true));
+ l.placements.put(TouchControl.C_DOWN, new Placement(0f, 0f, 1.0f, true));
+ l.placements.put(TouchControl.C_LEFT, new Placement(0f, 0f, 1.0f, true));
+
+ // Index fingers, along the top edge.
+ l.placements.put(TouchControl.L, new Placement(0.075f, 0.075f, 1.0f, true));
+ l.placements.put(TouchControl.Z, new Placement(0.205f, 0.075f, 1.0f, true));
+ l.placements.put(TouchControl.R, new Placement(0.925f, 0.075f, 1.0f, true));
+
+ // Centre, out of the way of both thumbs.
+ l.placements.put(TouchControl.START, new Placement(0.560f, 0.905f, 1.0f, true));
+ l.placements.put(TouchControl.MENU, new Placement(0.440f, 0.905f, 1.0f, true));
+
+ return l;
+ }
+
+ public Placement placement(TouchControl control) {
+ Placement p = placements.get(control);
+ if (p == null) {
+ // A control absent from a persisted layout (an older save, a hand-edited
+ // file) falls back to its stock placement rather than vanishing.
+ p = defaults().placements.get(control).copy();
+ placements.put(control, p);
+ }
+ return p;
+ }
+
+ public boolean isVisible(TouchControl control) {
+ return placement(control).visible;
+ }
+
+ // ----------------------------------------------------------------- geometry
+
+ /** A control resolved to pixels for one particular surface size. */
+ public static final class Geometry {
+ public final TouchControl control;
+ /** Centre, in pixels. */
+ public final float cx;
+ public final float cy;
+ /** Half-width. For round controls this is the radius. */
+ public final float rx;
+ /** Half-height. Equal to {@link #rx} for everything but SHOULDER. */
+ public final float ry;
+
+ Geometry(TouchControl control, float cx, float cy, float rx, float ry) {
+ this.control = control;
+ this.cx = cx;
+ this.cy = cy;
+ this.rx = rx;
+ this.ry = ry;
+ }
+
+ /**
+ * Whether a point presses this control.
+ *
+ * {@code slop} widens the target past what is drawn. Touch targets need to
+ * be bigger than they look — a thumb's contact patch is wide and its reported
+ * centre sits below where the player thinks they are pressing — but drawing
+ * them that big would bury the game. Every emulator front-end that feels good
+ * to play does this; the artwork is the label, not the hitbox.
+ */
+ public boolean hit(float x, float y, float slop) {
+ float dx = (x - cx) / (rx * slop);
+ float dy = (y - cy) / (ry * slop);
+ return dx * dx + dy * dy <= 1.0f;
+ }
+ }
+
+ /**
+ * Base sizes, as multiples of the surface's short edge, before any scaling.
+ * A is largest because it is largest on the hardware and is pressed most.
+ */
+ private static float baseRadius(TouchControl control) {
+ switch (control) {
+ case STICK: return 0.150f;
+ case A: return 0.088f;
+ case B: return 0.074f;
+ case C_UP: case C_RIGHT: case C_DOWN: case C_LEFT:
+ return 0.052f;
+ case L: case Z: case R:
+ return 0.062f;
+ case START: case MENU:
+ return 0.042f;
+ default: return 0.060f;
+ }
+ }
+
+ /**
+ * Resolve one control to pixels within a surface of {@code w} x {@code h},
+ * inset by the safe-area insets so nothing lands under a display cutout or a
+ * gesture bar.
+ */
+ public Geometry geometry(TouchControl control, float w, float h,
+ float insetLeft, float insetTop,
+ float insetRight, float insetBottom) {
+ float availW = Math.max(1f, w - insetLeft - insetRight);
+ float availH = Math.max(1f, h - insetTop - insetBottom);
+ float unit = sizingUnit(availW, availH);
+
+ Placement p = placement(control);
+ float scale = clamp(globalScale, SCALE_MIN, SCALE_MAX) * p.scale;
+ float r = baseRadius(control) * unit * scale;
+
+ float cx;
+ float cy;
+ if (control.isCButton() && control != TouchControl.C_UP) {
+ // Derived from the C_UP anchor so the diamond moves as one piece.
+ Placement anchor = placement(TouchControl.C_UP);
+ float anchorR = baseRadius(TouchControl.C_UP) * unit
+ * clamp(globalScale, SCALE_MIN, SCALE_MAX) * anchor.scale;
+ float spread = anchorR * cSpread;
+ float ax = insetLeft + anchor.x * availW;
+ float ay = insetTop + anchor.y * availH + spread; // diamond centre
+ switch (control) {
+ case C_RIGHT: cx = ax + spread; cy = ay; break;
+ case C_DOWN: cx = ax; cy = ay + spread; break;
+ default: cx = ax - spread; cy = ay; break; // C_LEFT
+ }
+ } else {
+ cx = insetLeft + p.x * availW;
+ cy = insetTop + p.y * availH;
+ }
+
+ float rx = r;
+ float ry = r;
+ if (control.kind == TouchControl.Kind.SHOULDER) {
+ // Wider than tall: a trigger is reached with the side of an index finger,
+ // which is a long contact patch across the top edge, not a round one.
+ rx = r * 1.55f;
+ ry = r * 0.72f;
+ }
+
+ // Last line of defence: nothing may hang off the usable rect, whatever the
+ // aspect ratio and wherever the player dragged it. A control half off the
+ // screen is half unpressable, and on the C diamond that would silently cost a
+ // whole direction. Clamping can nudge one C member out of formation on an
+ // extreme aspect, which is a far smaller problem than losing it off the edge.
+ cx = clamp(cx, insetLeft + rx, insetLeft + availW - rx);
+ cy = clamp(cy, insetTop + ry, insetTop + availH - ry);
+
+ return new Geometry(control, cx, cy, rx, ry);
+ }
+
+ /**
+ * The length every control size is a multiple of.
+ *
+ * The height of the widest 16:9 box that fits: {@code availH} on a 16:9-or-wider
+ * screen, and {@code availW * 9/16} on anything squatter. See the class comment for
+ * why neither dimension alone works.
+ */
+ public static float sizingUnit(float availW, float availH) {
+ return Math.min(availH, availW * (9f / 16f));
+ }
+
+ /** Keep a normalised position inside the surface. */
+ public static float clamp01(float v) {
+ return clamp(v, 0f, 1f);
+ }
+
+ public static float clamp(float v, float lo, float hi) {
+ if (Float.isNaN(v)) {
+ return lo;
+ }
+ return v < lo ? lo : (v > hi ? hi : v);
+ }
+
+ // -------------------------------------------------------------- persistence
+
+ /**
+ * Serialise to a compact single-line form.
+ *
+ * Hand-rolled rather than JSON because it is stored in SharedPreferences and
+ * read back by this class alone; adding org.json here would buy nothing. The
+ * parser below ignores anything it does not recognise, so a layout written by a
+ * newer build degrades to defaults for the unknown parts instead of throwing.
+ */
+ public String serialize() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("v=").append(VERSION);
+ sb.append(";vis=").append(visibility.name());
+ sb.append(String.format(Locale.US, ";scale=%.4f;op=%.4f;spread=%.4f;hap=%d",
+ globalScale, opacity, cSpread, haptics ? 1 : 0));
+ for (TouchControl c : TouchControl.values()) {
+ Placement p = placement(c);
+ sb.append(String.format(Locale.US, ";%s=%.5f,%.5f,%.4f,%d",
+ c.name(), p.x, p.y, p.scale, p.visible ? 1 : 0));
+ }
+ return sb.toString();
+ }
+
+ /** Parse {@link #serialize} output, falling back to defaults for anything bad. */
+ public static TouchLayout deserialize(String s) {
+ TouchLayout l = defaults();
+ if (s == null || s.isEmpty()) {
+ return l;
+ }
+ for (String part : s.split(";")) {
+ int eq = part.indexOf('=');
+ if (eq <= 0) {
+ continue;
+ }
+ String key = part.substring(0, eq);
+ String value = part.substring(eq + 1);
+ try {
+ switch (key) {
+ case "v":
+ continue;
+ case "vis":
+ l.visibility = Visibility.valueOf(value);
+ continue;
+ case "scale":
+ l.globalScale = clamp(Float.parseFloat(value), SCALE_MIN, SCALE_MAX);
+ continue;
+ case "op":
+ l.opacity = clamp(Float.parseFloat(value), OPACITY_MIN, OPACITY_MAX);
+ continue;
+ case "spread":
+ l.cSpread = clamp(Float.parseFloat(value), 1.6f, 3.5f);
+ continue;
+ case "hap":
+ l.haptics = "1".equals(value);
+ continue;
+ default:
+ break;
+ }
+ TouchControl control = TouchControl.valueOf(key);
+ String[] f = value.split(",");
+ if (f.length < 4) {
+ continue;
+ }
+ Placement p = l.placement(control);
+ p.x = clamp01(Float.parseFloat(f[0]));
+ p.y = clamp01(Float.parseFloat(f[1]));
+ p.scale = clamp(Float.parseFloat(f[2]), 0.5f, 2.0f);
+ p.visible = "1".equals(f[3]);
+ } catch (IllegalArgumentException ignored) {
+ // Unknown enum name or unparseable number: keep the default for that
+ // key. A corrupt preference must never stop the overlay from drawing.
+ }
+ }
+ return l;
+ }
+}
diff --git a/android/app/src/main/java/com/goemon64/recomp/touch/TouchOverlayController.java b/android/app/src/main/java/com/goemon64/recomp/touch/TouchOverlayController.java
new file mode 100644
index 0000000..1eead64
--- /dev/null
+++ b/android/app/src/main/java/com/goemon64/recomp/touch/TouchOverlayController.java
@@ -0,0 +1,332 @@
+package com.goemon64.recomp.touch;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Gravity;
+import android.view.InputEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.SeekBar;
+import android.widget.TextView;
+
+/**
+ * Owns the on-screen controls for one activity: installs the view over SDL's
+ * surface, keeps it in step with the game's menu state, and puts up the settings
+ * and layout editor.
+ *
+ * Kept separate from {@link TouchOverlayView} so the view stays a view — drawing
+ * and touch only — with the activity, dialogs and the polling loop kept out of it.
+ */
+public final class TouchOverlayController {
+
+ /**
+ * How often to ask native code whether a menu is up. Fast enough that the pad is
+ * gone before a thumb arrives at the menu it just opened, slow enough to be
+ * invisible on a battery graph — this is one atomic read per tick.
+ */
+ private static final long MENU_POLL_MS = 120L;
+
+ private final Activity activity;
+ private final TouchOverlayView view;
+ private final Handler handler = new Handler(Looper.getMainLooper());
+
+ private boolean polling;
+
+ /** SDL's layout, kept so the editor toolbar can be parented over the pad. */
+ private ViewGroup host;
+
+ private final Runnable menuPoll = new Runnable() {
+ @Override
+ public void run() {
+ if (!polling) {
+ return;
+ }
+ view.setMenuOpen(NativeTouch.isMenuOpen());
+ // The visibility mode is owned by the game's settings menu, so it is read
+ // on the same tick rather than pushed: the player can change it while the
+ // overlay is on screen, and this way the pad follows within a frame or two
+ // with no callback to wire up and nothing to keep in sync.
+ TouchLayout.Visibility mode = NativeTouch.mode();
+ if (mode != null && mode != view.layout().visibility) {
+ TouchLayout layout = view.layout();
+ layout.visibility = mode;
+ view.setLayout(layout);
+ }
+ int sensitivity = NativeTouch.stickSensitivity();
+ if (sensitivity >= 0 && sensitivity != view.pad().stickSensitivity()) {
+ view.pad().setStickSensitivity(sensitivity);
+ }
+ handler.postDelayed(this, MENU_POLL_MS);
+ }
+ };
+
+ /** Enter layout-edit mode. Called from the game's settings menu, off the UI thread. */
+ public void requestEditorFromNative() {
+ handler.post(() -> {
+ if (host != null) {
+ startEditing(host);
+ }
+ });
+ }
+
+ public TouchOverlayController(Activity activity) {
+ this.activity = activity;
+ this.view = new TouchOverlayView(activity);
+ this.view.setLayout(TouchPrefs.load(activity));
+ this.view.setOnLayoutChanged(this::persist);
+ }
+
+ public TouchOverlayView view() {
+ return view;
+ }
+
+ /**
+ * Add the overlay on top of an existing view hierarchy.
+ *
+ * {@code parent} is SDL's own layout, which already holds its
+ * {@code SurfaceView}. Adding afterwards puts this on top: the surface has no
+ * {@code setZOrderOnTop}, so it composites below the window's ordinary views.
+ */
+ public void attachTo(ViewGroup parent) {
+ host = parent;
+ view.setOnSettingsRequested(() -> showSettings(parent));
+ ViewGroup.LayoutParams lp = new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT);
+ parent.addView(view, lp);
+ // The surface below must keep receiving key events and SDL's own touches; the
+ // overlay only ever consumes touches that land on a control.
+ view.setFocusable(false);
+ }
+
+ public void onResume() {
+ polling = true;
+ handler.removeCallbacks(menuPoll);
+ handler.post(menuPoll);
+ NativeTouch.setActive(view.isPadShown());
+ }
+
+ public void onPause() {
+ polling = false;
+ handler.removeCallbacks(menuPoll);
+ // Anything held when the app went away would otherwise still be held when it
+ // comes back — or worse, for the rest of the session.
+ view.release();
+ NativeTouch.setActive(false);
+ }
+
+ /** Feed a raw input event in so a physical pad can auto-hide the overlay. */
+ public void noteInputEvent(InputEvent event) {
+ if (TouchOverlayView.isGamepadEvent(event)) {
+ view.noteGamepadUsed();
+ }
+ }
+
+ private void persist() {
+ TouchPrefs.save(activity, view.layout());
+ }
+
+ // ------------------------------------------------------------- editor / UI
+
+ /**
+ * Put the pad into edit mode with a small toolbar, so controls can be dragged
+ * where a given pair of hands actually wants them.
+ *
+ * The editor is the overlay itself rather than a separate mock screen. The
+ * only question it has to answer is "can my thumb reach that", and a mock at a
+ * different size, on a different aspect ratio, without the game behind it,
+ * cannot answer that question.
+ */
+ public void startEditing(ViewGroup parent) {
+ if (view.isEditing()) {
+ return;
+ }
+ view.setEditing(true);
+
+ LinearLayout bar = new LinearLayout(activity);
+ bar.setOrientation(LinearLayout.HORIZONTAL);
+ bar.setGravity(Gravity.CENTER);
+ bar.setBackgroundColor(0xCC000000);
+ int pad = dp(10);
+ bar.setPadding(pad, pad, pad, pad);
+
+ final TextView hint = new TextView(activity);
+ hint.setTextColor(0xFFFFFFFF);
+ hint.setPadding(0, 0, dp(16), 0);
+ bar.addView(hint);
+
+ // Relabels as the selection changes, so the size buttons always say what they
+ // are about to resize. Without it "-" and "+" are a guess.
+ final Runnable relabel = () -> {
+ TouchControl sel = view.selected();
+ hint.setText(sel == null
+ ? "Drag to move \u00b7 tap a control to size it"
+ : "Drag to move \u00b7 sizing: " + label(sel));
+ };
+ relabel.run();
+
+ Button smaller = new Button(activity);
+ smaller.setText("\u2212");
+ smaller.setOnClickListener(v -> {
+ view.nudgeSelectedScale(-0.1f);
+ relabel.run();
+ });
+ bar.addView(smaller);
+
+ Button bigger = new Button(activity);
+ bigger.setText("+");
+ bigger.setOnClickListener(v -> {
+ view.nudgeSelectedScale(0.1f);
+ relabel.run();
+ });
+ bar.addView(bigger);
+
+ // The view reports selection changes through the same callback it uses for
+ // moves, so touching a control updates the label too.
+ view.setOnLayoutChanged(() -> {
+ persist();
+ relabel.run();
+ });
+
+ Button reset = new Button(activity);
+ reset.setText("Reset");
+ reset.setOnClickListener(v -> {
+ view.setLayout(TouchLayout.defaults());
+ persist();
+ });
+ bar.addView(reset);
+
+ Button done = new Button(activity);
+ done.setText("Done");
+ bar.addView(done);
+
+ FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+ lp.gravity = Gravity.TOP | Gravity.CENTER_HORIZONTAL;
+
+ FrameLayout host = new FrameLayout(activity);
+ host.addView(bar, lp);
+ parent.addView(host, new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT));
+
+ done.setOnClickListener(v -> {
+ view.setEditing(false);
+ persist();
+ view.setOnLayoutChanged(this::persist);
+ parent.removeView(host);
+ });
+ }
+
+ /** The settings sheet: how big, how visible, and whether it buzzes. */
+ public void showSettings(ViewGroup editorParent) {
+ final ViewGroup parent = editorParent != null ? editorParent : host;
+ TouchLayout layout = view.layout();
+
+ LinearLayout root = new LinearLayout(activity);
+ root.setOrientation(LinearLayout.VERTICAL);
+ int pad = dp(20);
+ root.setPadding(pad, pad, pad, pad);
+
+ // No show/hide control here on purpose. That setting lives in the game's own
+ // menu (Settings -> Touch -> On-Screen Controls) and is stored in the game
+ // config, which this sheet cannot write. The mode poll rewrites
+ // layout.visibility from that config every 120 ms, so a duplicate control here
+ // would be undone within a frame or two while still being persisted to
+ // SharedPreferences -- two stores disagreeing behind a control that looks like
+ // it works.
+ TextView where = label("Show/hide is in the game menu: Settings \u2192 Touch");
+ where.setTextColor(0xFF93A1B0);
+ root.addView(where);
+
+ root.addView(label("Size"));
+ root.addView(slider(layout.globalScale, TouchLayout.SCALE_MIN, TouchLayout.SCALE_MAX,
+ value -> {
+ layout.globalScale = value;
+ view.setLayout(layout);
+ persist();
+ }));
+
+ root.addView(label("Opacity"));
+ root.addView(slider(layout.opacity, TouchLayout.OPACITY_MIN, TouchLayout.OPACITY_MAX,
+ value -> {
+ layout.opacity = value;
+ view.setLayout(layout);
+ persist();
+ }));
+
+ android.widget.CheckBox haptics = new android.widget.CheckBox(activity);
+ haptics.setText("Vibrate on press");
+ haptics.setChecked(layout.haptics);
+ haptics.setOnCheckedChangeListener((b, checked) -> {
+ layout.haptics = checked;
+ persist();
+ });
+ root.addView(haptics);
+
+ new AlertDialog.Builder(activity)
+ .setTitle("On-screen controls")
+ .setView(root)
+ .setPositiveButton("Close", null)
+ .setNeutralButton("Edit layout…", (d, which) -> {
+ if (parent != null) {
+ startEditing(parent);
+ }
+ })
+ .show();
+ }
+
+ /** Human-readable name for the toolbar, since enum names are not player-facing. */
+ private static String label(TouchControl control) {
+ switch (control) {
+ case STICK: return "Stick";
+ case START: return "Start";
+ case MENU: return "Menu";
+ case C_UP: case C_RIGHT: case C_DOWN: case C_LEFT:
+ return "C buttons";
+ default: return control.name();
+ }
+ }
+
+ private TextView label(String s) {
+ TextView t = new TextView(activity);
+ t.setText(s);
+ t.setPadding(0, dp(12), 0, 0);
+ return t;
+ }
+
+ private interface OnValue {
+ void accept(float value);
+ }
+
+ private SeekBar slider(float current, float min, float max, OnValue sink) {
+ SeekBar bar = new SeekBar(activity);
+ bar.setMax(100);
+ bar.setProgress((int) ((current - min) / (max - min) * 100f));
+ bar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ @Override
+ public void onProgressChanged(SeekBar b, int progress, boolean fromUser) {
+ if (fromUser) {
+ sink.accept(min + (max - min) * (progress / 100f));
+ }
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar b) {}
+
+ @Override
+ public void onStopTrackingTouch(SeekBar b) {}
+ });
+ return bar;
+ }
+
+ private int dp(int value) {
+ return (int) (value * activity.getResources().getDisplayMetrics().density);
+ }
+}
diff --git a/android/app/src/main/java/com/goemon64/recomp/touch/TouchOverlayView.java b/android/app/src/main/java/com/goemon64/recomp/touch/TouchOverlayView.java
new file mode 100644
index 0000000..a66b7aa
--- /dev/null
+++ b/android/app/src/main/java/com/goemon64/recomp/touch/TouchOverlayView.java
@@ -0,0 +1,796 @@
+package com.goemon64.recomp.touch;
+
+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.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.VibrationEffect;
+import android.os.Vibrator;
+import android.util.AttributeSet;
+import android.view.HapticFeedbackConstants;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.WindowInsets;
+
+import java.util.List;
+
+/**
+ * The on-screen N64 controls: a transparent {@link View} composited over SDL's
+ * surface.
+ *
+ * Everything is drawn as vectors — no bitmaps. That keeps the APK unchanged in
+ * size, stays sharp at any density from a phone to a handheld's 1080p panel, and
+ * means restyling the pad is an edit to this file rather than a round trip through
+ * an image pipeline.
+ *
+ * The handle is the one control with click-on-release semantics rather than
+ * press-on-touch. It has to be: a tap opens the game's menu and a long press
+ * opens the overlay's own settings, and those are only distinguishable once the
+ * finger lifts. Sending the press on touch-down and retracting it later does not
+ * work — the game would already have seen a complete press and toggled its menu
+ * open behind the settings dialog. Every other control still fires on contact,
+ * because for a gameplay button that latency would be the whole ballgame.
+ */
+ private int menuPointerId = -1;
+ private float menuDownX;
+ private float menuDownY;
+ /** Drawn as held while the finger is down, even though nothing has been sent. */
+ private boolean menuVisualHeld;
+
+ private float insetLeft;
+ private float insetTop;
+ private float insetRight;
+ private float insetBottom;
+
+ private final Handler handler = new Handler(Looper.getMainLooper());
+
+ public TouchOverlayView(Context context) {
+ super(context);
+ init();
+ }
+
+ public TouchOverlayView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ private void init() {
+ setFocusable(false);
+ setFocusableInTouchMode(false);
+ // The game's surface is below; this view must never paint a background over it.
+ setBackgroundColor(Color.TRANSPARENT);
+ stroke.setStyle(Paint.Style.STROKE);
+ text.setTextAlign(Paint.Align.CENTER);
+ text.setFakeBoldText(true);
+ vibrator = (Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE);
+ // Start hidden if a pad is already attached; see gamepadConnected().
+ gamepadUsed = gamepadConnected();
+ setLayout(layout);
+ }
+
+ // ------------------------------------------------------------------ wiring
+
+ public void setLayout(TouchLayout layout) {
+ this.layout = layout;
+ pad.setLayout(layout);
+ applyInsets();
+ syncActive();
+ invalidate();
+ }
+
+ public TouchLayout layout() {
+ return layout;
+ }
+
+ public TouchPad pad() {
+ return pad;
+ }
+
+ /** Called after the editor moves something, so the caller can persist it. */
+ public void setOnLayoutChanged(Runnable listener) {
+ this.onLayoutChanged = listener;
+ }
+
+ /** Called when the settings handle is long-pressed. */
+ public void setOnSettingsRequested(Runnable listener) {
+ this.onSettingsRequested = listener;
+ }
+
+ public void setEditing(boolean editing) {
+ if (this.editing == editing) {
+ return;
+ }
+ this.editing = editing;
+ // Leaving any held button behind would be sent to the game for as long as the
+ // editor is open.
+ pad.clear();
+ pushState();
+ dragging = null;
+ selected = null;
+ invalidate();
+ }
+
+ public boolean isEditing() {
+ return editing;
+ }
+
+ /**
+ * Report that a physical gamepad was used. In AUTO visibility the overlay gets
+ * out of the way until the screen is touched again.
+ */
+ public void noteGamepadUsed() {
+ if (gamepadUsed) {
+ return;
+ }
+ gamepadUsed = true;
+ if (layout.visibility == TouchLayout.Visibility.AUTO) {
+ pad.clear();
+ pushState();
+ invalidate();
+ syncActive();
+ }
+ }
+
+ /** Report that the native UI is (or is no longer) capturing input. */
+ public void setMenuOpen(boolean menuOpen) {
+ if (this.menuOpen == menuOpen) {
+ return;
+ }
+ this.menuOpen = menuOpen;
+ if (menuOpen) {
+ pad.clear();
+ pushState();
+ }
+ invalidate();
+ syncActive();
+ }
+
+ /** Whether the pad should be drawn and should take touches right now. */
+ public boolean isPadShown() {
+ // Editing is checked BEFORE Off: the settings menu offers "Off" and "Edit
+ // Layout" side by side, so a player can reach the editor with the overlay
+ // turned off, and the editor has to show the controls it is editing.
+ if (editing) {
+ return true;
+ }
+ if (layout.visibility == TouchLayout.Visibility.NEVER) {
+ return false;
+ }
+ // A menu hides the pad and, more importantly, stops it consuming touches, so
+ // SDL's touch-to-mouse emulation can drive the RmlUi menu underneath.
+ if (menuOpen) {
+ return false;
+ }
+ if (layout.visibility == TouchLayout.Visibility.ALWAYS) {
+ return true;
+ }
+ return !gamepadUsed;
+ }
+
+ private void syncActive() {
+ NativeTouch.setActive(isPadShown());
+ }
+
+ private void pushState() {
+ NativeTouch.setState(pad.buttonMask(), pad.axes());
+ }
+
+ /** Drop everything held. Called when the activity pauses or loses focus. */
+ public void release() {
+ cancelMenuLongPress();
+ pad.clear();
+ pushState();
+ invalidate();
+ }
+
+ // ------------------------------------------------------------------ layout
+
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ super.onSizeChanged(w, h, oldw, oldh);
+ applyInsets();
+ }
+
+ @Override
+ public WindowInsets onApplyWindowInsets(WindowInsets insets) {
+ // Display cutouts and gesture bars: a button under either is a button that
+ // cannot be pressed, so the whole layout is resolved inside the safe area.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && insets.getDisplayCutout() != null) {
+ android.view.DisplayCutout cutout = insets.getDisplayCutout();
+ insetLeft = cutout.getSafeInsetLeft();
+ insetTop = cutout.getSafeInsetTop();
+ insetRight = cutout.getSafeInsetRight();
+ insetBottom = cutout.getSafeInsetBottom();
+ } else {
+ insetLeft = insetTop = insetRight = insetBottom = 0f;
+ }
+ applyInsets();
+ // Returned unchanged, deliberately. This view is a sibling of SDL's surface in
+ // the same layout, and a ViewGroup stops dispatching insets to later children
+ // once one is consumed -- swallowing them here could leave the surface with no
+ // cutout information at all. The overlay only wants to read them.
+ return insets;
+ }
+
+ private void applyInsets() {
+ pad.setSurface(getWidth(), getHeight(),
+ insetLeft, insetTop, insetRight, insetBottom);
+ invalidate();
+ }
+
+ // ------------------------------------------------------------------- input
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ // Same ordering as isPadShown(): with Off selected the editor still has to
+ // take touches, or its controls draw but cannot be dragged.
+ if (!editing && layout.visibility == TouchLayout.Visibility.NEVER) {
+ return false;
+ }
+
+ // A screen touch means the player is back on the glass; undo the gamepad
+ // auto-hide. Done before the shown check so the touch that brings the pad
+ // back is not itself swallowed.
+ if (gamepadUsed && !editing
+ && event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ gamepadUsed = false;
+ syncActive();
+ invalidate();
+ }
+
+ if (!isPadShown()) {
+ // Not ours: returning false lets the event fall through to SDL's surface,
+ // which is what keeps the RmlUi menu touch-navigable.
+ return false;
+ }
+
+ if (editing) {
+ return handleEditTouch(event);
+ }
+ return handlePlayTouch(event);
+ }
+
+ /**
+ * Drive the pad from a touch gesture.
+ *
+ * Always returns true while the pad is shown, including for a touch that lands
+ * on no control at all. That is not laziness — Android delivers every pointer of a
+ * gesture to whichever view claimed its ACTION_DOWN. Letting an empty-space touch
+ * fall through to SDL would hand SDL the whole gesture, and every later finger in
+ * it, so a thumb resting on the picture would silently kill the buttons under the
+ * other hand until it lifted. Owning the gesture costs nothing: while the pad is
+ * shown there is no menu on screen for SDL's touch-to-mouse emulation to drive,
+ * and the moment a menu opens the pad hides and stops consuming entirely.
+ */
+ private boolean handlePlayTouch(MotionEvent event) {
+ int action = event.getActionMasked();
+
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN: {
+ int index = event.getActionIndex();
+ int id = event.getPointerId(index);
+ float x = event.getX(index);
+ float y = event.getY(index);
+ if (pad.controlAt(x, y) == TouchControl.MENU) {
+ // The handle NEVER reaches the pad -- it is routed through
+ // request_menu_toggle instead. Tested before the menuPointerId
+ // guard so that a second finger landing on the handle is dropped
+ // too, rather than falling through to pointerDown and latching
+ // TouchControl.MENU's SDL button (BACK) into the polled mask.
+ if (menuPointerId == -1) {
+ armLongPress(id, x, y);
+ }
+ } else {
+ pad.pointerDown(id, x, y);
+ }
+ break;
+ }
+ case MotionEvent.ACTION_MOVE: {
+ for (int i = 0; i < event.getPointerCount(); i++) {
+ int id = event.getPointerId(i);
+ pad.pointerMove(id, event.getX(i), event.getY(i));
+ if (id == menuPointerId
+ && (Math.abs(event.getX(i) - menuDownX) > LONG_PRESS_SLOP_PX
+ || Math.abs(event.getY(i) - menuDownY) > LONG_PRESS_SLOP_PX)) {
+ cancelMenuLongPress();
+ }
+ }
+ break;
+ }
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP: {
+ int id = event.getPointerId(event.getActionIndex());
+ if (id == menuPointerId) {
+ // Lifted before the long press completed, so it was a tap.
+ float x = menuDownX;
+ float y = menuDownY;
+ cancelMenuLongPress();
+ pulseMenu(x, y);
+ }
+ pad.pointerUp(id);
+ break;
+ }
+ case MotionEvent.ACTION_CANCEL: {
+ cancelMenuLongPress();
+ pad.clear();
+ break;
+ }
+ default:
+ break;
+ }
+
+ if (pad.consumePressed()) {
+ buzz();
+ }
+ pushState();
+ invalidate();
+ return true;
+ }
+
+ /**
+ * Start counting a long press on the settings handle.
+ *
+ * The press is delivered to the game as normal while the timer runs; it is
+ * only if the timer completes that the press is taken back. That ordering is
+ * what lets a tap stay instant — the common action is not made to wait for the
+ * rare one.
+ */
+ private void armLongPress(int pointerId, float x, float y) {
+ menuPointerId = pointerId;
+ menuDownX = x;
+ menuDownY = y;
+ menuVisualHeld = true;
+ handler.postDelayed(longPress, LONG_PRESS_MS);
+ }
+
+ private void cancelMenuLongPress() {
+ if (menuPointerId != -1) {
+ handler.removeCallbacks(longPress);
+ menuPointerId = -1;
+ }
+ menuVisualHeld = false;
+ }
+
+ private final Runnable longPress = new Runnable() {
+ @Override
+ public void run() {
+ if (menuPointerId == -1) {
+ return;
+ }
+ menuPointerId = -1;
+ menuVisualHeld = false;
+ invalidate();
+ if (onSettingsRequested == null) {
+ return;
+ }
+ buzz();
+ onSettingsRequested.run();
+ }
+ };
+
+ /**
+ * Send a tapped menu press as a short pulse, since the finger has already left
+ * and there is no release left to drive it.
+ */
+ private void pulseMenu(float x, float y) {
+ // Goes straight to the UI rather than through the virtual pad. The pad can only
+ // set a bit in the polled button mask, and the native menu toggle is
+ // event-driven, so a bit there would never be seen. requestMenuToggle queues a
+ // real controller event on the same queue a physical pad feeds, which both
+ // opens and closes the menu and follows a rebind of Toggle Menu.
+ NativeTouch.requestMenuToggle();
+ }
+
+ private boolean handleEditTouch(MotionEvent event) {
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN: {
+ float x = event.getX();
+ float y = event.getY();
+ TouchLayout.Geometry hit = null;
+ for (TouchLayout.Geometry g : pad.geometry()) {
+ if (g.hit(x, y, 1.15f)) {
+ hit = g;
+ break;
+ }
+ }
+ if (hit == null) {
+ // Still consumed: the editor is a modal state, and a stray tap must
+ // not reach the game underneath it.
+ return true;
+ }
+ // The C diamond moves as one piece, so a grab on any member drags the
+ // anchor the other three are derived from.
+ dragging = hit.control.isCButton() ? TouchControl.C_UP : hit.control;
+ selected = dragging;
+ if (onLayoutChanged != null) {
+ // Lets the toolbar relabel itself for the new selection.
+ onLayoutChanged.run();
+ }
+ TouchLayout.Geometry anchor = pad.geometryFor(dragging);
+ dragDx = (anchor != null ? anchor.cx : x) - x;
+ dragDy = (anchor != null ? anchor.cy : y) - y;
+ invalidate();
+ return true;
+ }
+ case MotionEvent.ACTION_MOVE: {
+ if (dragging == null) {
+ return true;
+ }
+ moveTo(dragging, event.getX() + dragDx, event.getY() + dragDy);
+ return true;
+ }
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL: {
+ if (dragging != null) {
+ dragging = null;
+ if (onLayoutChanged != null) {
+ onLayoutChanged.run();
+ }
+ invalidate();
+ }
+ return true;
+ }
+ default:
+ return true;
+ }
+ }
+
+ /** The control the resize buttons act on, or null if nothing has been touched yet. */
+ public TouchControl selected() {
+ return selected;
+ }
+
+ /**
+ * Grow or shrink the selected control.
+ *
+ * Sizing the C cluster sizes all four of it. They move as one piece already
+ * (their positions derive from C_UP's anchor), and resizing only the anchor would
+ * change the diamond's spread as a side effect while leaving three buttons the old
+ * size — visibly wrong, and not what "make this bigger" means.
+ */
+ public void nudgeSelectedScale(float delta) {
+ if (selected == null) {
+ return;
+ }
+ if (selected.isCButton()) {
+ for (TouchControl c : TouchControl.C_CLUSTER) {
+ applyScale(c, delta);
+ }
+ } else {
+ applyScale(selected, delta);
+ }
+ pad.setLayout(layout);
+ applyInsets();
+ if (onLayoutChanged != null) {
+ onLayoutChanged.run();
+ }
+ }
+
+ private void applyScale(TouchControl control, float delta) {
+ TouchLayout.Placement p = layout.placement(control);
+ // Same bounds deserialize() enforces, so a value set here always round-trips.
+ p.scale = TouchLayout.clamp(p.scale + delta, 0.5f, 2.0f);
+ }
+
+ /** Move a control to a pixel position, converting back to normalised storage. */
+ private void moveTo(TouchControl control, float px, float py) {
+ float availW = Math.max(1f, getWidth() - insetLeft - insetRight);
+ float availH = Math.max(1f, getHeight() - insetTop - insetBottom);
+
+ float nx = (px - insetLeft) / availW;
+ float ny = (py - insetTop) / availH;
+
+ TouchLayout.Placement p = layout.placement(control);
+ p.x = TouchLayout.clamp01(nx);
+ p.y = TouchLayout.clamp01(ny);
+ pad.setLayout(layout);
+ applyInsets();
+ }
+
+ /**
+ * Whether a physical gamepad is attached right now.
+ *
+ * Used once, to seed {@link #gamepadUsed} at startup. On a handheld with
+ * built-in controls the overlay would otherwise be drawn over the game until the
+ * first button press — a visible flash on a device that never wanted it.
+ *
+ * This is deliberately NOT a device-type check. Android has no reliable "is
+ * this a handheld" signal: {@code isExternal()} is hidden API, FEATURE_GAMEPAD is
+ * reported inconsistently, and a model allowlist rots. Connected-at-startup is a
+ * weaker signal, which is exactly why it only sets the initial value
+ * rather than gating {@link #isPadShown()}: a pad paired but sitting in a drawer
+ * costs the player one tap to get the overlay back, instead of leaving a phone
+ * with no usable controls at all.
+ */
+ private static boolean gamepadConnected() {
+ for (int id : InputDevice.getDeviceIds()) {
+ InputDevice device = InputDevice.getDevice(id);
+ // Device id 0 is the virtual keyboard, and some systems expose other
+ // virtual sources; neither is a pad anyone is holding.
+ if (device == null || device.isVirtual()) {
+ continue;
+ }
+ int sources = device.getSources();
+ if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD
+ || (sources & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Sniff a raw input event for gamepad use.
+ *
+ * Called from the activity's dispatch hooks rather than being wired here,
+ * because SDL owns the surface's own key and motion handling and this view must
+ * not compete for it.
+ */
+ public static boolean isGamepadEvent(android.view.InputEvent event) {
+ int source = event.getSource();
+ boolean fromPad = (source & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD
+ || (source & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
+ || (source & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD;
+ if (!fromPad) {
+ return false;
+ }
+ if (event instanceof KeyEvent) {
+ // Volume and other system keys can arrive tagged with a pad source on some
+ // devices; only a real button counts as "the player picked up a pad".
+ int code = ((KeyEvent) event).getKeyCode();
+ return KeyEvent.isGamepadButton(code)
+ || code == KeyEvent.KEYCODE_DPAD_UP
+ || code == KeyEvent.KEYCODE_DPAD_DOWN
+ || code == KeyEvent.KEYCODE_DPAD_LEFT
+ || code == KeyEvent.KEYCODE_DPAD_RIGHT;
+ }
+ return true;
+ }
+
+ private void buzz() {
+ if (!layout.haptics || hapticsUnavailable) {
+ return;
+ }
+ // A thumb on glass has no detent, so the buzz is the only confirmation a
+ // press landed. Short and weak on purpose: this fires on every button.
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && vibrator != null
+ && vibrator.hasVibrator()) {
+ vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK));
+ } else {
+ performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY,
+ HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING);
+ }
+ } catch (RuntimeException e) {
+ // Latched off rather than retried: whatever the cause (a missing
+ // permission, an OEM vibrator service that rejects the effect), it will
+ // not fix itself, and retrying would throw on every press for the rest
+ // of the session.
+ hapticsUnavailable = true;
+ }
+ }
+
+ // ----------------------------------------------------------------- drawing
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ if (!isPadShown()) {
+ return;
+ }
+ List The whole class is plain Java — no {@code MotionEvent}, no {@code Canvas} — so
+ * the input logic stays separate from Android's event and drawing types.
+ * {@link TouchOverlayView} does nothing but unpack Android events into the four
+ * methods below and hand the result to JNI.
+ *
+ * Turned into a gamma exponent by {@link #stickGamma()}: the thumb's distance
+ * from the centre is raised to that power before being sent. 100 is linear.
+ */
+ private int stickSensitivity = 50;
+
+ private TouchLayout layout;
+ private float width;
+ private float height;
+ private float insetLeft;
+ private float insetTop;
+ private float insetRight;
+ private float insetBottom;
+
+ /** Cached resolved geometry, smallest control first — see {@link #controlAt}. */
+ private List 1.0 (sensitivity 100) sends the thumb's distance from centre unchanged. That
+ * is linear, and linear is the problem: the stick is roughly 9 mm across on a
+ * 450 dpi phone, so the whole walking band sits inside about 4.5 mm of travel and
+ * a slow walk cannot be held. Raising the magnitude to a power above 1 stretches
+ * the low end — at 1.75, a thumb 30% out sends 13% rather than 30% — while 1.0 at
+ * the rim is unchanged, so nothing is lost at the top.
+ */
+ private float stickGamma() {
+ return 1.0f + (1.0f - stickSensitivity / 100.0f) * 1.5f;
+ }
+
+ public void setSurface(float width, float height,
+ float insetLeft, float insetTop,
+ float insetRight, float insetBottom) {
+ this.width = width;
+ this.height = height;
+ this.insetLeft = insetLeft;
+ this.insetTop = insetTop;
+ this.insetRight = insetRight;
+ this.insetBottom = insetBottom;
+ resolve();
+ }
+
+ /**
+ * Recompute pixel geometry, ordered smallest-first.
+ *
+ * The order is the hit-test priority. Slop regions overlap — the stick's is
+ * large and sits near B, the C diamond's members nearly touch — and without an
+ * order the winner would depend on enum declaration order. Smallest-first means
+ * the most precise target always wins, so a deliberate press on a small C button
+ * is never swallowed by the stick's generous margin.
+ */
+ private void resolve() {
+ List SharedPreferences rather than the game's own config: this is Android-side
+ * presentation state that the native config system has no other reason to know
+ * about, and keeping it here means the layout survives independently of the
+ * recomp's config file (which is rewritten wholesale on a settings change) and can
+ * be read by an activity that has not loaded the native library at all.
+ */
+public final class TouchPrefs {
+
+ private static final String FILE = "touch_controls";
+ private static final String KEY_LAYOUT = "layout";
+
+ private TouchPrefs() {}
+
+ private static SharedPreferences prefs(Context context) {
+ return context.getApplicationContext()
+ .getSharedPreferences(FILE, Context.MODE_PRIVATE);
+ }
+
+ public static TouchLayout load(Context context) {
+ return TouchLayout.deserialize(prefs(context).getString(KEY_LAYOUT, null));
+ }
+
+ public static void save(Context context, TouchLayout layout) {
+ prefs(context).edit().putString(KEY_LAYOUT, layout.serialize()).apply();
+ }
+}
diff --git a/assets/config_menu.rml b/assets/config_menu.rml
index 64ff7b1..dc0a01b 100644
--- a/assets/config_menu.rml
+++ b/assets/config_menu.rml
@@ -27,6 +27,7 @@
+
@@ -107,6 +108,16 @@
Why a View and not the game's own renderer
+ * The recomp already has an RmlUi layer, so drawing the pad there was the obvious
+ * alternative. A plain Android View wins on every axis that matters here. It needs
+ * no knowledge of RT64 or Vulkan, so the native build is untouched by changes to it,
+ * and it gets Android's own multi-touch, haptics and safe-area insets for free. SDL's surface is
+ * a plain {@code SurfaceView} with no {@code setZOrderOnTop}, so a sibling view added
+ * after it composites cleanly on top.
+ *
+ * Redraw policy
+ * The view invalidates on state change only, never per frame. An idle overlay costs
+ * nothing; a pad being played costs one damage rect per touch event, on a surface
+ * the compositor is already updating for the game.
+ */
+public class TouchOverlayView extends View {
+
+ /** Idle and pressed alpha, as multipliers on the configured opacity. */
+ private static final float ALPHA_IDLE = 1.0f;
+ private static final float ALPHA_PRESSED = 1.55f;
+
+ /**
+ * How long the settings handle must be held to open the overlay's own settings.
+ * A tap on it opens the game's menu, which is the common case; the long press is
+ * the escape hatch to size, opacity and the layout editor.
+ */
+ private static final long LONG_PRESS_MS = 550L;
+
+ /** How far a finger may drift during a long press before it stops counting. */
+ private static final float LONG_PRESS_SLOP_PX = 24f;
+
+ private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint stroke = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Path path = new Path();
+ private final RectF rect = new RectF();
+
+ private TouchLayout layout = TouchLayout.defaults();
+ private TouchPad pad = new TouchPad(layout);
+
+ private Vibrator vibrator;
+
+ /**
+ * Set if the vibrator ever refuses. Haptics fire on every single button press, so
+ * this path must be incapable of taking the app down: a device that throws once
+ * will throw every time, and the correct outcome is silently no haptics, not a
+ * crash mid-game. Belt and braces alongside the VIBRATE permission in the
+ * manifest -- OEM vibrator implementations are a well-known source of surprises.
+ */
+ private boolean hapticsUnavailable;
+
+ /**
+ * True once a gamepad has been used, and seeded at startup from whether one is
+ * already attached. In AUTO visibility this hides the overlay, and the next
+ * screen touch brings it back — the same behaviour a player expects
+ * from any mobile front-end, and the reason a handheld with real sticks never
+ * has to visit a settings screen to get its screen back.
+ */
+ private boolean gamepadUsed;
+
+ /** Set while the native UI is capturing input, so the pad hides for menus. */
+ private boolean menuOpen;
+
+ /** Editor mode: controls are dragged rather than pressed. */
+ private boolean editing;
+ private TouchControl dragging;
+ /**
+ * Last control touched in the editor. Kept after the drag ends so the resize
+ * buttons have something to act on — you pick a control by touching it, then
+ * size it, rather than having to hold it while reaching for a button.
+ */
+ private TouchControl selected;
+ private float dragDx;
+ private float dragDy;
+ private Runnable onLayoutChanged;
+ private Runnable onSettingsRequested;
+
+ /**
+ * Long-press bookkeeping for the settings handle.
+ *
+ * State, not edges
+ * Each event recomputes the entire button mask from the set of live pointers,
+ * rather than incrementing and decrementing a per-button hold count. Both approaches
+ * handle two fingers on one button, but only this one is impossible to leave stuck:
+ * there is no counter that can drift, so a pointer lost to a cancelled gesture or a
+ * dropped UP event cannot strand a button held forever. Recomputing costs a loop
+ * over at most a handful of pointers.
+ */
+public final class TouchPad {
+
+ /**
+ * How far past its drawn edge a control can be pressed. Thumbs are wide and
+ * land low of where the player is aiming; see {@link TouchLayout.Geometry#hit}.
+ */
+ private static final float PRESS_SLOP = 1.25f;
+
+ /**
+ * How far a finger already holding a button may drift before it releases.
+ * Larger than {@link #PRESS_SLOP} so that a thumb rolling on a button it is
+ * deliberately holding does not chatter, while a deliberate slide off it still
+ * lets go.
+ */
+ private static final float RELEASE_SLOP = 1.85f;
+
+ /**
+ * Dead centre of the analog stick. Deliberately tiny: the N64-accurate deadzone
+ * that actually matters is applied natively in {@code controls.cpp}
+ * ({@code inner_deadzone}), and stacking a second large one here would eat the
+ * slow-walk range the game has. This exists only to keep a still thumb from
+ * reporting a few thousandths of drift.
+ */
+ private static final float STICK_DEADZONE = 0.06f;
+
+ /** What a single live pointer is currently doing. */
+ private static final class Pointer {
+ final TouchControl control;
+ /** Stick only: current normalised offset from the base, in [-1, 1]. */
+ float stickX;
+ float stickY;
+ /** Buttons only: cleared when the finger drifts past RELEASE_SLOP. */
+ boolean holding = true;
+
+ Pointer(TouchControl control) {
+ this.control = control;
+ }
+ }
+
+ private final Map