From 215c314b0a4e0c13820bba19b940d3cb236fea6e Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Tue, 25 Aug 2026 11:40:58 -0300 Subject: [PATCH 01/31] Contextual HUD: touch overlay with game-context adaptation, gamepad hot-plug auto-hide, layout editor - touchhud.{h,cpp}: per-frame context publishing (Hidden/OnFoot/Climbing) + JNI input bridge - HudBridge/HudController/TouchHudView: overlay with fades, multi-touch, safe areas, layout editor (long-press), opacity/low-contrast, haptics, JSON persistence - Physical gamepad hot-plug hides/shows HUD automatically (real-gamepad source check) - Stable committed keystore for debug+release (no more signature churn between CI runs) --- .github/workflows/build.yml | 2 +- android/app/build.gradle.kts | 17 +- android/app/rechan.keystore | Bin 0 -> 2696 bytes .../com/deivid22srk/rechan/GameActivity.java | 20 +- .../com/deivid22srk/rechan/hud/HudBridge.java | 45 ++ .../deivid22srk/rechan/hud/HudController.java | 220 +++++++ .../deivid22srk/rechan/hud/TouchHudView.java | 611 ++++++++++++++++++ docs/CHANGELOG_CONTEXTUAL_HUD.md | 67 ++ docs/TOUCH_HUD_TESTS.md | 81 +++ src/extra/touchhud.cpp | 106 +++ src/extra/touchhud.h | 30 + src/gen/main.cpp | 6 + vendor/libp3d/pddi/gles/AndroidPlatform.cpp | 9 + vendor/libp3d/pddi/gles/AndroidPlatform.h | 6 + 14 files changed, 1215 insertions(+), 5 deletions(-) create mode 100644 android/app/rechan.keystore create mode 100644 android/app/src/main/java/com/deivid22srk/rechan/hud/HudBridge.java create mode 100644 android/app/src/main/java/com/deivid22srk/rechan/hud/HudController.java create mode 100644 android/app/src/main/java/com/deivid22srk/rechan/hud/TouchHudView.java create mode 100644 docs/CHANGELOG_CONTEXTUAL_HUD.md create mode 100644 docs/TOUCH_HUD_TESTS.md create mode 100644 src/extra/touchhud.cpp create mode 100644 src/extra/touchhud.h diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7956133..42a4891 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: build on: push: - branches: [main] + branches: [main, 'feature/**'] pull_request: workflow_dispatch: diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 662e8a4..aeaab67 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -29,6 +29,17 @@ android { // Matches AGP 8.9's default and the preinstalled default on GitHub runners. ndkVersion = "27.0.12077973" + // Stable sideload signing: the keystore is committed so APKs from any + // CI run (and local builds) share one signature and update in place. + signingConfigs { + create("sideload") { + storeFile = file("rechan.keystore") + storePassword = "rechan" + keyAlias = "rechan" + keyPassword = "rechan" + } + } + defaultConfig { applicationId = "com.deivid22srk.rechan" minSdk = 24 @@ -57,12 +68,12 @@ android { buildTypes { debug { isMinifyEnabled = false + signingConfig = signingConfigs.getByName("sideload") } release { isMinifyEnabled = false - // Sideload distribution: sign releases with the debug key so the - // APK installs without extra setup. - signingConfig = signingConfigs.getByName("debug") + // Sideload distribution: same stable key for both build types. + signingConfig = signingConfigs.getByName("sideload") } } diff --git a/android/app/rechan.keystore b/android/app/rechan.keystore new file mode 100644 index 0000000000000000000000000000000000000000..59b5dce47a724c97c448d78644c85ce10739efbb GIT binary patch literal 2696 zcma);S5y-W5`_~85HR#$C?V2|v_K#vAV?2gqI6L}>Ai?qlp3T2klub!g7l&yNC`+$ zx>BS`6$GS!;nO}=l>SN6dPavBeV{Wf z2zU|1hT3GcD_+#q4+v_Ixq-K$1rb5RXaFJA*_mjJU9Ur*xivKdrYiDSaTosq^ zJMH~E+c$$Z#3d4(7sM|q+o6#7@@G$3lcH4w3IL6q+XJF;4DYfHBH(l8W~M3Z;eksi z2=k0fv&bH2EnjG%XixzrIu#Ei2UT~C3laML9xHeaTJ#I_Vtz(Xj-o$l<_red-9$n$ z;D9Rf7C=gjX%?lv$=Hb1cQLKh&4a#%FynPBRuXuV{M&YC?0yJy?cW|D&coc7wI9?K zllu75<|oae4Nc(_{1hC!V1D$;!oWz{zE?fP$)2;%`^@@u(-C^_WOgfhOjtCYf1$g? zx z9{(!bnSboOF}Xg($jZ_$qAkhh`2${1)})V=^RK448)yBXQ%|3RxtU+)B3qOra@i>P ziD~z#4K-8It~Nab`_b5`iw6_8TcnPH7=DKDm)q_(o;3dmc*Ifb8fzxoql(F}ao(KY zAwLBL)sU>!~9!FxG zd5zNX-LXqb^f>>VBtxjq3r}I~sSJzjY6lIkU^3_5e(KofPkMa_6_0VHwGcG$0wS#% zg(qfS@ryvG&`jfE$yJ!FMc410@xh_PTeHP*8`Ej4H^4K(-w14Y;n^q4pD9cOR6pKgji;(pN!0Dv}k2e$z-eELW6*jrI&bF#BlPgRblxO zAs!km6vZ$la4X2Im-SGO2mvP7O$7SfUg%_l^YA?g4|%BZkzouC3fYNQEc?8`oi#b) z5~X}BCrjdZ-d+aK-7}Bwor1AXwxMLsm#hO~3HsC6 zs`XlGj+R}DQuXRr8%}co@Nz-2t&^VF_nL2w>4QFaquPtBM?e?87LVk|h_#;L@STj( z&ZCxV{sRfFBS&A$r*xT%7ef!R=qnF;!x$$0jc(!~?0d`lF^*sdEqU)DCY9l5C2z>me(THb_J|+O^#yFXJ^B`0jkwz@ zd!DCSUIWSZCoUPG@+=4lAOPSDxD9XvI0Af-f`5x#@=y?mxx2TsfV?tB8I4xPU=>kl zWh4>g_-BcMzJLh2`Wuvj3#KU{a_|Tu?QmMTe^&XRNk#lah?Lxh%#)(Mk*&@Iq+{z)tufdDJ$H}g zZSJDgQmlly*OFW`MYeQTOJnW*e$2Efr&Skd;l_Xh`;qtL!rEJ_P!d$K{*qK~c!klg z#y3Mjf&JEPr5$@4aE2WYD|XI?WpimI(5ZLPUB@S^5=!UjHoFTqDzRfk_k*9K)#U5= zA_a%>I+ZJ}8^PlTmZ*#KMX)t>vYg=8Zf@*K^u+nmmZ9lhmZHiEhht!H3KcKftoydL zY$yY)tZ(s=^uAtj=C85BuptXIdzK&jU>4!KpPt4)W#I7-v8C-!>sl10lGViJqn_6T zf9m=myo8Te*$#BmIS-DvB3Oe%OK)dAPEj1=VQ*^*O966r3GS8EQ~Natr@CzOZ(>!% zq8RrsF4o(8duHFE=27mdNeMe6Ock>-qz{wTQ~1_ zw|QliPAO90s-@B8(~C8^>W|Rd1vZ;yJkMS%Gvt|QkBG4Lk-)P1`N84sQ&ICGG`al; zAQD~N4fAa%yV}vNJc>B6!vfS|Dz1iNy_g=H`2XL9O9^k#2P~y_f!QeqQ4F2`xqR> z_7=@b?^Bld)y^aknMQF7fvBc;$Ns+A0ZKjt$*+8{fG+X|pA@*9;G2ABS0Y_?9nYJG zPJBmjtZCCu>?YlZA2{WVMHAkUgaZaks@*X6)K-|<>{fcususGF8Lf5JHDcu2H{bPH zZmU=oA`*5kPbA!UU1@aXy-EhYW!pEGWXQDvel#8tjIvWSQ`V9R`hcDE$VsA7!RHk9 zt)3mEDUU*%pWEocX89uvfiQqp7QP%8HB3s=o`b~7778?h1-c>n6P}3;w2Jx1=J?bk z9*p!Wu5a4=+sV0K0&@?6ny$YlH$p(~J-ssamtRRMHlzPUII)f(zEv%;YJo%woDCrl|!s+}`HdX!!B@L$E}R z{&*M~X@ZnTGXMF`Xn=G8kbr_AB>66jg!&_Do$j)pVIl45pbT@R&0f?^Twl=Ynm!1c bsEP}hp7$C}+H2#q0F4Pw?I3*q6)FD&Y2MBq literal 0 HcmV?d00001 diff --git a/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java b/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java index e61e148..17f2a7b 100644 --- a/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java +++ b/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java @@ -7,12 +7,19 @@ import android.view.WindowInsets; import android.view.WindowInsetsController; +import com.deivid22srk.rechan.hud.HudController; + /** * NativeActivity hosting the ReChan GLES3 engine. Immersive fullscreen with - * cutout support; all input flows through the native input queue. + * cutout support; all input flows through the native input queue. A + * contextual touch overlay (HudController) is attached on top: it hides + * itself while a physical gamepad is connected and adapts its controls to + * the gameplay context published by the engine. */ public class GameActivity extends NativeActivity { + private HudController hudController; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -20,6 +27,17 @@ protected void onCreate(Bundle savedInstanceState) { getWindow().getAttributes().layoutInDisplayCutoutMode = android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; } + hudController = new HudController(this); + hudController.attach(); + } + + @Override + protected void onDestroy() { + if (hudController != null) { + hudController.detach(); + hudController = null; + } + super.onDestroy(); } @Override diff --git a/android/app/src/main/java/com/deivid22srk/rechan/hud/HudBridge.java b/android/app/src/main/java/com/deivid22srk/rechan/hud/HudBridge.java new file mode 100644 index 0000000..e5af3ec --- /dev/null +++ b/android/app/src/main/java/com/deivid22srk/rechan/hud/HudBridge.java @@ -0,0 +1,45 @@ +package com.deivid22srk.rechan.hud; + +/** + * JNI bridge between the touch overlay (Java) and the engine (librechan.so). + * + * Touch input feeds the exact same androidbridge gamepad state a physical + * gamepad uses, so the engine sees one merged virtual pad and there is no + * duplicate input path. Context values must match touchhud::HudContext + * (src/extra/touchhud.h). + */ +public final class HudBridge { + // touchhud::HudContext + public static final int CONTEXT_HIDDEN = 0; + public static final int CONTEXT_ON_FOOT = 1; + public static final int CONTEXT_CLIMBING = 2; + + // pddi GamepadButton (GLFW-style layout) + public static final int BTN_A = 0; + public static final int BTN_B = 1; + public static final int BTN_X = 2; + public static final int BTN_Y = 3; + public static final int BTN_LB = 4; + public static final int BTN_RB = 5; + public static final int BTN_START = 7; + + // pddi GamepadAxis + public static final int AXIS_LEFT_X = 0; + public static final int AXIS_LEFT_Y = 1; + + static { + // Already loaded by NativeActivity; loadLibrary is idempotent. + System.loadLibrary("rechan"); + } + + private HudBridge() {} + + public static native void nativePostButton(int button, boolean down); + + public static native void nativePostAxis(int axis, float value); + + public static native void nativePostConnected(boolean connected); + + /** Current gameplay context (touchhud::HudContext). */ + public static native int nativePollContext(); +} diff --git a/android/app/src/main/java/com/deivid22srk/rechan/hud/HudController.java b/android/app/src/main/java/com/deivid22srk/rechan/hud/HudController.java new file mode 100644 index 0000000..6fd43a7 --- /dev/null +++ b/android/app/src/main/java/com/deivid22srk/rechan/hud/HudController.java @@ -0,0 +1,220 @@ +package com.deivid22srk.rechan.hud; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.hardware.input.InputManager; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.InputDevice; +import android.view.MotionEvent; +import android.view.View; +import android.view.WindowInsets; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Owns the touch overlay lifecycle: + * - physical gamepad hot-plug detection (auto hide/show, no user action); + * - gameplay context polling (engine -> HUD) with smooth transitions; + * - persistence of the user layout (positions/scale/opacity/contrast). + * + * Decoupled from the engine: all communication goes through HudBridge + * (JNI) — the view never touches engine state directly. + */ +public class HudController implements InputManager.InputDeviceListener { + + private static final String TAG = "rechan-hud"; + private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); + private static final String PREFS = "rechan_hud"; + private static final String KEY_LAYOUT = "layout_json"; + private static final long CONTEXT_POLL_MS = 100; + + private final Activity activity; + private final Handler handler = new Handler(Looper.getMainLooper()); + private TouchHudView hudView; + private InputManager inputManager; + private boolean gamepadConnected; + private boolean running; + private int lastContext = -1; + + public HudController(Activity activity) { + this.activity = activity; + } + + /** Attach the overlay to the activity and start listening. */ + public void attach() { + hudView = new TouchHudView(activity, this); + hudView.setVisibility(View.GONE); // shown once state settles + activity.addContentView( + hudView, + new android.view.ViewGroup.LayoutParams( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + android.view.ViewGroup.LayoutParams.MATCH_PARENT)); + + // Safe areas (cutout + system bars): recompute default layout bounds. + // WindowInsets.Type / getInsets() require API 30; older devices run + // without safe-area adjustment. + if (android.os.Build.VERSION.SDK_INT >= 30) { + hudView.setOnApplyWindowInsetsListener((v, insets) -> { + android.graphics.Insets bars = insets.getInsets( + WindowInsets.Type.systemBars() + | WindowInsets.Type.displayCutout()); + hudView.setSafeInsets(bars.left, bars.top, bars.right, bars.bottom); + return insets; + }); + } + + inputManager = (InputManager) activity.getSystemService(Context.INPUT_SERVICE); + inputManager.registerInputDeviceListener(this, handler); + gamepadConnected = anyRealGamepadConnected(); + applyGamepadVisibility(false); + + running = true; + handler.postDelayed(contextPoller, CONTEXT_POLL_MS); + log("attached; gamepadConnected=" + gamepadConnected); + } + + public void detach() { + running = false; + handler.removeCallbacks(contextPoller); + if (inputManager != null) { + inputManager.unregisterInputDeviceListener(this); + } + if (hudView != null) { + hudView.releaseAll(); + } + } + + // --- Context polling (engine -> HUD) ------------------------------------ + + private final Runnable contextPoller = new Runnable() { + @Override + public void run() { + if (!running) return; + int context = HudBridge.nativePollContext(); + if (context != lastContext) { + log("context " + lastContext + " -> " + context); + lastContext = context; + applyContext(context); + } + handler.postDelayed(this, CONTEXT_POLL_MS); + } + }; + + private void applyContext(int context) { + if (gamepadConnected || context == HudBridge.CONTEXT_HIDDEN) { + hudView.animateVisibility(false, context); + } + else { + hudView.animateVisibility(true, context); + } + } + + // --- Physical gamepad hot-plug ------------------------------------------ + + private void applyGamepadVisibility(boolean animate) { + if (hudView == null) return; + if (gamepadConnected) { + hudView.releaseAll(); // no stuck virtual buttons while hidden + } + hudView.animateVisibility(!gamepadConnected, lastContext); + if (animate) { + log("gamepad " + (gamepadConnected ? "connected -> HUD hidden" + : "disconnected -> HUD shown")); + } + } + + /** A "real" gamepad reports GAMEPAD or JOYSTICK sources with joystick + * axes; DPAD-only TV remotes and mice don't qualify. */ + private static boolean isRealGamepad(InputDevice device) { + if (device == null || device.isVirtual()) return false; + final int sources = device.getSources(); + final boolean gamepadSrc = + (sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD; + final boolean joystickSrc = + (sources & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK; + if (!gamepadSrc && !joystickSrc) return false; + if (joystickSrc) { + boolean hasStickAxes = false; + for (InputDevice.MotionRange range : device.getMotionRanges()) { + if ((range.getSource() & InputDevice.SOURCE_JOYSTICK) + == InputDevice.SOURCE_JOYSTICK) { + hasStickAxes = true; + break; + } + } + if (!hasStickAxes) return false; + } + return true; + } + + private boolean anyRealGamepadConnected() { + for (int id : inputManager.getInputDeviceIds()) { + if (isRealGamepad(inputManager.getInputDevice(id))) { + return true; + } + } + return false; + } + + @Override + public void onInputDeviceAdded(int deviceId) { + InputDevice device = inputManager.getInputDevice(deviceId); + if (!isRealGamepad(device)) { + log("ignored input device " + deviceId + + (device != null ? " sources=" + Integer.toHexString(device.getSources()) : "")); + return; + } + gamepadConnected = true; + applyGamepadVisibility(true); + } + + @Override + public void onInputDeviceRemoved(int deviceId) { + // Re-scan: another pad may still be connected (multi-device case). + boolean any = anyRealGamepadConnected(); + if (any != gamepadConnected) { + gamepadConnected = any; + applyGamepadVisibility(true); + } + } + + @Override + public void onInputDeviceChanged(int deviceId) { + boolean any = anyRealGamepadConnected(); + if (any != gamepadConnected) { + gamepadConnected = any; + applyGamepadVisibility(true); + } + } + + // --- Layout persistence -------------------------------------------------- + + void saveLayout(JSONObject layout) { + activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY_LAYOUT, layout.toString()).apply(); + log("layout saved"); + } + + JSONObject loadLayout() { + try { + String raw = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_LAYOUT, null); + if (raw != null) { + return new JSONObject(raw); + } + } catch (JSONException e) { + log("layout parse failed: " + e.getMessage()); + } + return null; + } + + private static void log(String message) { + if (DEBUG) { + Log.d(TAG, message); + } + } +} diff --git a/android/app/src/main/java/com/deivid22srk/rechan/hud/TouchHudView.java b/android/app/src/main/java/com/deivid22srk/rechan/hud/TouchHudView.java new file mode 100644 index 0000000..6a53fef --- /dev/null +++ b/android/app/src/main/java/com/deivid22srk/rechan/hud/TouchHudView.java @@ -0,0 +1,611 @@ +package com.deivid22srk.rechan.hud; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RectF; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.HapticFeedbackConstants; +import android.view.MotionEvent; +import android.view.View; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Fullscreen transparent overlay that draws and handles the contextual touch + * controls (virtual joystick + action buttons). Input is forwarded to the + * engine through HudBridge (JNI) into the shared gamepad state — the engine + * treats it exactly like a physical gamepad. + * + * Features: contextual control sets (per gameplay context), fade/scale + * transitions, safe-area aware default layout, user layout editing + * (long-press a control), per-control size, global opacity and low-contrast + * mode, light haptic feedback, all persisted locally as JSON. + */ +public class TouchHudView extends View { + + private static final String TAG = "rechan-hud"; + private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); + private static final long EDIT_LONGPRESS_MS = 600; + + /** One on-screen control (button or the joystick). */ + private static class Control { + final String id; + final String label; + final int buttonId; // -1 for the joystick + float nx, ny; // normalized center within the safe rect + float nr; // normalized radius (relative to min(safeW, safeH)) + boolean interactive = true; + + Control(String id, String label, int buttonId, float nx, float ny, float nr) { + this.id = id; + this.label = label; + this.buttonId = buttonId; + this.nx = nx; + this.ny = ny; + this.nr = nr; + } + } + + private final HudController controller; + private final Handler handler = new Handler(Looper.getMainLooper()); + + private final List controls = new ArrayList<>(); + private Control joystick; + + private int insetL, insetT, insetR, insetB; + private float opacity = 0.45f; + private boolean lowContrast = false; + private boolean editMode = false; + private Control selectedControl; + private boolean visible = false; + private int context = HudBridge.CONTEXT_HIDDEN; + + // Pointer tracking: pointerId -> control id ("" for unassigned) + private final android.util.SparseArray pointerControls = new android.util.SparseArray<>(); + private final Set pressedButtons = new HashSet<>(); + private int joyPointerId = -1; + private float joyKnobDx, joyKnobDy; + + // Long-press -> edit mode + private final Runnable longPressRunnable = this::enterEditMode; + private float longPressStartX, longPressStartY; + private int longPressPointer = -1; + private boolean longPressPending; + + // Edit-mode chips + private final List chipBounds = new ArrayList<>(); + private static final String[] CHIPS = {"Menor", "Maior", "Opac-", "Opac+", "Contorno", "Concluir"}; + + // Paints + private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint strokePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint knobPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + public TouchHudView(Context context, HudController controller) { + super(context); + this.controller = controller; + setFocusable(false); + setClickable(true); // receive touch events + setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + + joystick = new Control("joy", "", -1, 0.12f, 0.70f, 0.105f); + controls.add(joystick); + controls.add(new Control("A", "A", HudBridge.BTN_A, 0.865f, 0.775f, 0.072f)); + controls.add(new Control("B", "B", HudBridge.BTN_B, 0.775f, 0.685f, 0.062f)); + controls.add(new Control("X", "X", HudBridge.BTN_X, 0.875f, 0.600f, 0.062f)); + controls.add(new Control("Y", "Y", HudBridge.BTN_Y, 0.965f, 0.690f, 0.062f)); + controls.add(new Control("RB", "R1", HudBridge.BTN_RB, 0.950f, 0.520f, 0.050f)); + controls.add(new Control("LB", "L1", HudBridge.BTN_LB, 0.045f, 0.280f, 0.050f)); + controls.add(new Control("ST", "≡", HudBridge.BTN_START, 0.500f, 0.940f, 0.042f)); + + JSONObject saved = controller.loadLayout(); + if (saved != null) { + applyLayout(saved); + } + + fillPaint.setColor(Color.WHITE); + strokePaint.setColor(Color.WHITE); + strokePaint.setStyle(Paint.Style.STROKE); + knobPaint.setColor(Color.WHITE); + textPaint.setColor(Color.WHITE); + textPaint.setTextAlign(Paint.Align.CENTER); + textPaint.setFakeBoldText(true); + } + + // --- Public API (used by HudController) ---------------------------------- + + void setSafeInsets(int l, int t, int r, int b) { + insetL = l; + insetT = t; + insetR = r; + insetB = b; + invalidate(); + } + + /** Smooth show/hide with fade + slight scale; context picks active set. */ + void animateVisibility(boolean show, int hudContext) { + this.context = hudContext; + applyContextToControls(); + if (show == visible) { + invalidate(); + return; + } + visible = show; + if (show) { + setVisibility(VISIBLE); + setAlpha(0f); + setScaleX(0.96f); + setScaleY(0.96f); + animate().alpha(1f).scaleX(1f).scaleY(1f).setDuration(180).start(); + } + else { + releaseAll(); + animate().alpha(0f).setDuration(180) + .withEndAction(() -> { + if (!visible) { + setVisibility(GONE); + exitEditMode(); + } + }).start(); + } + log("visibility -> " + show + " context=" + hudContext); + } + + /** Release every virtual control (context change, gamepad connect, hide). */ + void releaseAll() { + for (Control c : controls) { + if (c.buttonId >= 0 && pressedButtons.contains(c.buttonId)) { + HudBridge.nativePostButton(c.buttonId, false); + } + } + pressedButtons.clear(); + if (joyPointerId != -1) { + HudBridge.nativePostAxis(HudBridge.AXIS_LEFT_X, 0f); + HudBridge.nativePostAxis(HudBridge.AXIS_LEFT_Y, 0f); + joyPointerId = -1; + } + joyKnobDx = joyKnobDy = 0f; + pointerControls.clear(); + handler.removeCallbacks(longPressRunnable); + longPressPending = false; + invalidate(); + } + + // --- Context handling ----------------------------------------------------- + + private void applyContextToControls() { + // Climbing: joystick + jump only. OnFoot: everything. + boolean climbing = context == HudBridge.CONTEXT_CLIMBING; + for (Control c : controls) { + c.interactive = !climbing || c == joystick || "A".equals(c.id); + } + } + + // --- Layout persistence ---------------------------------------------------- + + private JSONObject toJSON() { + try { + JSONObject root = new JSONObject(); + JSONObject ctrls = new JSONObject(); + for (Control c : controls) { + ctrls.put(c.id, new JSONObject() + .put("x", c.nx).put("y", c.ny).put("r", c.nr)); + } + root.put("controls", ctrls); + root.put("opacity", opacity); + root.put("lowContrast", lowContrast); + return root; + } catch (JSONException e) { + return null; + } + } + + private void applyLayout(JSONObject root) { + try { + JSONObject ctrls = root.optJSONObject("controls"); + if (ctrls != null) { + for (Control c : controls) { + JSONObject pos = ctrls.optJSONObject(c.id); + if (pos != null) { + c.nx = clamp01((float) pos.optDouble("x", c.nx)); + c.ny = clamp01((float) pos.optDouble("y", c.ny)); + c.nr = clamp01((float) pos.optDouble("r", c.nr)); + } + } + } + opacity = clamp01((float) root.optDouble("opacity", opacity)); + lowContrast = root.optBoolean("lowContrast", false); + } catch (Exception e) { + log("applyLayout failed: " + e.getMessage()); + } + } + + private static float clamp01(float v) { + return Math.max(0f, Math.min(1f, v)); + } + + // --- Geometry helpers ------------------------------------------------------- + + private float safeW() { + return Math.max(1, getWidth() - insetL - insetR); + } + + private float safeH() { + return Math.max(1, getHeight() - insetT - insetB); + } + + private float cx(Control c) { + return insetL + c.nx * safeW(); + } + + private float cy(Control c) { + return insetT + c.ny * safeH(); + } + + private float radius(Control c) { + return c.nr * Math.min(safeW(), safeH()); + } + + private Control controlAt(float x, float y) { + Control hit = null; + float bestDist = Float.MAX_VALUE; + for (Control c : controls) { + if (!c.interactive) continue; + float dx = x - cx(c); + float dy = y - cy(c); + float dist = (float) Math.hypot(dx, dy); + float reach = radius(c) * (c == joystick ? 1.6f : 1.35f); + if (dist <= reach && dist < bestDist) { + bestDist = dist; + hit = c; + } + } + return hit; + } + + // --- Touch handling ----------------------------------------------------------- + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (!visible || editMode) { + return editMode ? handleEditTouch(event) : false; + } + + final int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: { + int idx = event.getActionIndex(); + int pid = event.getPointerId(idx); + float x = event.getX(idx); + float y = event.getY(idx); + Control c = controlAt(x, y); + pointerControls.put(pid, c != null ? c.id : ""); + if (c != null) { + if (c == joystick) { + joyPointerId = pid; + updateJoystick(x, y); + } + else { + pressButton(c, true); + } + startLongPressDetection(pid, x, y); + } + return true; + } + case MotionEvent.ACTION_MOVE: { + for (int i = 0; i < event.getPointerCount(); i++) { + int pid = event.getPointerId(i); + String assigned = pointerControls.get(pid, ""); + if (assigned.isEmpty()) continue; + Control c = findControl(assigned); + if (c == null) continue; + float x = event.getX(i); + float y = event.getY(i); + if (c == joystick && pid == joyPointerId) { + updateJoystick(x, y); + } + if (longPressPending && pid == longPressPointer) { + if (Math.hypot(x - longPressStartX, y - longPressStartY) > touchSlopPx()) { + cancelLongPressDetection(); + } + } + } + return true; + } + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: { + int idx = event.getActionIndex(); + int pid = event.getPointerId(idx); + releasePointer(pid); + if (pid == joyPointerId) { + joyPointerId = -1; + joyKnobDx = joyKnobDy = 0f; + HudBridge.nativePostAxis(HudBridge.AXIS_LEFT_X, 0f); + HudBridge.nativePostAxis(HudBridge.AXIS_LEFT_Y, 0f); + invalidate(); + } + if (pointerControls.size() == 0) { + cancelLongPressDetection(); + } + return true; + } + case MotionEvent.ACTION_CANCEL: { + releaseAll(); + return true; + } + default: + return true; + } + } + + private void startLongPressDetection(int pid, float x, float y) { + longPressStartX = x; + longPressStartY = y; + longPressPointer = pid; + longPressPending = true; + handler.removeCallbacks(longPressRunnable); + handler.postDelayed(longPressRunnable, EDIT_LONGPRESS_MS); + } + + private void cancelLongPressDetection() { + handler.removeCallbacks(longPressRunnable); + longPressPending = false; + longPressPointer = -1; + } + + private float touchSlopPx() { + return android.view.ViewConfiguration.get(getContext()).getScaledTouchSlop() * 2f; + } + + private void updateJoystick(float x, float y) { + float dx = x - cx(joystick); + float dy = y - cy(joystick); + float r = radius(joystick); + float mag = (float) Math.hypot(dx, dy); + if (mag > r) { + dx = dx / mag * r; + dy = dy / mag * r; + } + joyKnobDx = dx; + joyKnobDy = dy; + // Screen Y is down-positive, matching the GLFW/gamepad axis convention. + HudBridge.nativePostAxis(HudBridge.AXIS_LEFT_X, clampAxis(dx / r)); + HudBridge.nativePostAxis(HudBridge.AXIS_LEFT_Y, clampAxis(dy / r)); + invalidate(); + } + + private static float clampAxis(float v) { + return Math.max(-1f, Math.min(1f, v)); + } + + private void pressButton(Control c, boolean down) { + if (down) { + pressedButtons.add(c.buttonId); + HudBridge.nativePostButton(c.buttonId, true); + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY); + } + else { + pressedButtons.remove(c.buttonId); + HudBridge.nativePostButton(c.buttonId, false); + } + invalidate(); + } + + private void releasePointer(int pid) { + String assigned = pointerControls.get(pid, ""); + pointerControls.delete(pid); + if (assigned.isEmpty()) return; + Control c = findControl(assigned); + if (c != null && c != joystick) { + pressButton(c, false); + } + } + + private Control findControl(String id) { + for (Control c : controls) { + if (c.id.equals(id)) return c; + } + return null; + } + + // --- Edit mode ----------------------------------------------------------------- + + private void enterEditMode() { + if (!visible || editMode) return; + Control hit = null; + // Select the control under the original long-press position. + for (int i = 0; i < pointerControls.size(); i++) { + String assigned = pointerControls.valueAt(i); + if (!assigned.isEmpty()) { + hit = findControl(assigned); + break; + } + } + releaseAll(); + editMode = true; + selectedControl = hit != null ? hit : controls.get(1); + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + log("edit mode entered (selected " + selectedControl.id + ")"); + invalidate(); + } + + private void exitEditMode() { + if (!editMode) return; + editMode = false; + JSONObject json = toJSON(); + if (json != null) { + controller.saveLayout(json); + } + invalidate(); + } + + private boolean handleEditTouch(MotionEvent event) { + final int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: { + float x = event.getX(); + float y = event.getY(); + for (int i = 0; i < chipBounds.size(); i++) { + if (chipBounds.get(i).contains(x, y)) { + onChip(i); + return true; + } + } + Control c = controlAt(x, y); + if (c != null) { + selectedControl = c; + pointerControls.put(event.getPointerId(0), c.id); + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY); + } + return true; + } + case MotionEvent.ACTION_MOVE: { + // Single-finger drag of the selected control in edit mode. + if (selectedControl != null) { + float x = event.getX(); + float y = event.getY(); + selectedControl.nx = clamp01((x - insetL) / safeW()); + selectedControl.ny = clamp01((y - insetT) / safeH()); + invalidate(); + } + return true; + } + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + pointerControls.clear(); + return true; + default: + return true; + } + } + + private void onChip(int index) { + switch (CHIPS[index]) { + case "Menor": + if (selectedControl != null) { + selectedControl.nr = Math.max(0.035f, selectedControl.nr - 0.008f); + } + break; + case "Maior": + if (selectedControl != null) { + selectedControl.nr = Math.min(0.18f, selectedControl.nr + 0.008f); + } + break; + case "Opac-": + opacity = clamp01(opacity - 0.05f); + break; + case "Opac+": + opacity = clamp01(opacity + 0.05f); + break; + case "Contorno": + lowContrast = !lowContrast; + break; + case "Concluir": + exitEditMode(); + return; + } + invalidate(); + } + + // --- Drawing --------------------------------------------------------------------- + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + if (!visible || getAlpha() <= 0.01f) return; + + int alpha = (int) (opacity * 255); + float base = Math.min(safeW(), safeH()); + + for (Control c : controls) { + if (!c.interactive) continue; + float x = cx(c); + float y = cy(c); + float r = radius(c); + boolean pressed = c.buttonId >= 0 && pressedButtons.contains(c.buttonId); + + if (lowContrast) { + strokePaint.setStrokeWidth(Math.max(2f, base * 0.006f)); + strokePaint.setAlpha(alpha); + canvas.drawCircle(x, y, r, strokePaint); + } + else { + fillPaint.setAlpha(pressed ? Math.min(255, alpha + 90) : alpha); + canvas.drawCircle(x, y, r, fillPaint); + strokePaint.setStrokeWidth(Math.max(2f, base * 0.004f)); + strokePaint.setAlpha(200); + canvas.drawCircle(x, y, r, strokePaint); + } + + if (c == joystick) { + float knobR = r * 0.45f; + knobPaint.setAlpha(Math.min(255, alpha + 60)); + canvas.drawCircle(x + joyKnobDx, y + joyKnobDy, knobR, knobPaint); + } + else if (!c.label.isEmpty()) { + textPaint.setTextSize(r * 0.85f); + textPaint.setAlpha(lowContrast ? alpha : 255); + Paint.FontMetrics fm = textPaint.getFontMetrics(); + float textY = y - (fm.ascent + fm.descent) / 2f; + canvas.drawText(c.label, x, textY, textPaint); + } + + if (editMode && c == selectedControl) { + strokePaint.setStrokeWidth(Math.max(3f, base * 0.008f)); + strokePaint.setAlpha(255); + strokePaint.setColor(0xFFFFC857); + canvas.drawCircle(x, y, r * 1.15f, strokePaint); + strokePaint.setColor(Color.WHITE); + } + } + + if (editMode) { + drawChips(canvas); + } + } + + private void drawChips(Canvas canvas) { + chipBounds.clear(); + float base = Math.min(safeW(), safeH()); + float chipH = base * 0.07f; + float textSize = chipH * 0.42f; + textPaint.setTextSize(textSize); + Paint.FontMetrics fm = textPaint.getFontMetrics(); + float x = insetL + safeW() * 0.5f; + float totalW = 0f; + float[] widths = new float[CHIPS.length]; + for (int i = 0; i < CHIPS.length; i++) { + widths[i] = textPaint.measureText(CHIPS[i]) + chipH; + totalW += widths[i]; + } + float cx = x - totalW / 2f; + float cy = insetT + base * 0.03f; + for (int i = 0; i < CHIPS.length; i++) { + RectF rect = new RectF(cx, cy, cx + widths[i], cy + chipH); + chipBounds.add(rect); + fillPaint.setAlpha(160); + canvas.drawRoundRect(rect, chipH / 2f, chipH / 2f, fillPaint); + textPaint.setAlpha(255); + canvas.drawText(CHIPS[i], rect.centerX(), + cy + chipH / 2f - (fm.ascent + fm.descent) / 2f, textPaint); + cx += widths[i] + chipH * 0.25f; + } + } + + private static void log(String message) { + if (DEBUG) { + Log.d(TAG, message); + } + } +} diff --git a/docs/CHANGELOG_CONTEXTUAL_HUD.md b/docs/CHANGELOG_CONTEXTUAL_HUD.md new file mode 100644 index 0000000..17654ed --- /dev/null +++ b/docs/CHANGELOG_CONTEXTUAL_HUD.md @@ -0,0 +1,67 @@ +# Changelog — Contextual HUD (feature/contextual-hud) + +## Resumo + +Overlay de controles touch **contextual** para Android, desacoplado da engine: +o jogo publica um "contexto de gameplay" por frame; a camada Java consome via +JNI e adapta os controles em tela. Gamepad físico conectado → HUD some; +desconectado → volta, sem pausar o jogo. + +## Novos arquivos + +| Arquivo | Papel | +|---|---| +| `src/extra/touchhud.{h,cpp}` | Publica o contexto (Hidden/OnFoot/Climbing) por frame + funções JNI (input touch → estado de gamepad compartilhado) | +| `android/.../hud/HudBridge.java` | Contrato JNI (botões/eixos pddi + contexto) | +| `android/.../hud/HudController.java` | Detecção de gamepad físico (hot-plug), polling de contexto (100 ms), persistência do layout | +| `android/.../hud/TouchHudView.java` | View do overlay: joystick virtual + botões, multi-touch, fades, modo edição, safe-areas, haptics | +| `docs/TOUCH_HUD_TESTS.md` | Matriz de testes manuais | + +## Alterações + +- `src/gen/main.cpp`: chama `touchhud::PublishFrame()` por frame (Android only). +- `vendor/libp3d/pddi/gles/AndroidPlatform.{h,cpp}`: atomics `SetHudContext/LoadHudContext`. +- `android/.../GameActivity.java`: anexa/desanexa o `HudController`. +- `android/app/build.gradle.kts`: keystore estável commitado (`rechan.keystore`) + para debug **e** release — APKs de qualquer run do CI agora se atualizam + in-place (fim dos conflitos `INSTALL_FAILED_UPDATE_INCOMPATIBLE`). +- `.github/workflows/build.yml`: roda também em `feature/**`. +- Fix incluído da main: analógico Y invertido (Android AXIS_Y já é convenção GLFW). + +## Decisões técnicas + +1. **Overlay Java (View) em vez de renderizar na engine**: desacopla HUD do + GLES state do jogo; input touch entra pelo mesmo caminho de um gamepad + físico (`androidbridge`), impossibilitando input duplicado por construção. +2. **Pull model para contexto** (Java polla `nativePollContext()` a cada + 100 ms): evita plumbing de JavaVM/AttachCurrentThread; latência mascarada + pelos fades de 180 ms. +3. **Contextos v1**: `Hidden` (menu/cutscene/NIS/estados não-Play), + `OnFoot`, `Climbing` (actionStates 18, 23–28: pole/ledge/ladder). + Sinais usados: `GameState`, `g_feCustomMenuMgr->IsActive()`, + `g_directorActive`/`g_director->scriptState`, `Player::s_player->actionState`. +4. **Gamepad real ≠ falso positivo**: exige `SOURCE_GAMEPAD` ou + (`SOURCE_JOYSTICK` + eixos joystick em `getMotionRanges()`); remotes DPAD-only, + mouse e teclado não ocultam o HUD. Multi-device: re-scan no remove + (esconde só quando o ÚLTIMO gamepad cai). +5. **Persistência JSON** em SharedPreferences (posições normalizadas → + independente de resolução), opacidade e modo contorno globais. +6. **Keystore commitado**: projeto de sideload; assinatura estável > segredo + (não é um limite de segurança real para distribuição por fora da Play). + +## Limitações conhecidas (v1) + +- Sem detecção de "perto de interativo": botão B (grab) é sempre exibido no + contexto OnFoot (não troca de ícone perto de veículo/objeto — o jogo não + expõe um sinal de proximidade pronto). +- Sem "veículo": JCS não tem direção livre de veículos; contextos de carrinho/ + skate são cutscenes/estados scriptados (cobertos por Hidden/OnFoot). +- Modo edição: arraste de um dedo; sem pinch-zoom (tamanho via chips). +- HUD não aparece em API < 30 com safe-areas (funciona, sem ajuste de notch). +- Transições de contexto têm latência de até 100 ms + 180 ms de fade. + +## Migração de instalação + +A troca de assinatura (keystore estável) exige **desinstalar o app antigo uma +última vez**. A ROM copiada (`discimage/game.bin`) será apagada junto — no +próximo boot, selecione a pasta da ROM novamente (a cópia é automática). diff --git a/docs/TOUCH_HUD_TESTS.md b/docs/TOUCH_HUD_TESTS.md new file mode 100644 index 0000000..5c08077 --- /dev/null +++ b/docs/TOUCH_HUD_TESTS.md @@ -0,0 +1,81 @@ +# Contextual HUD — Plano de Testes Manuais + +Feature: `feature/contextual-hud` — overlay touch contextual com auto-hide +por gamepad físico. + +## Setup comum + +1. Instalar APK `rechan-debug-arm64` (ou release) do run da branch. +2. Iniciar o jogo e aguardar a gameplay (pular intro, confirmar assets). + +## 1. Contextos de gameplay (troca em tempo real) + +| # | Cenário | Esperado | +|---|---------|----------| +| 1.1 | Jogo em gameplay normal (a pé) | HUD visível: joystick + A/B/X/Y/R1/L1/Start | +| 1.2 | Abrir o menu (Start ou BACK) | HUD some com fade (~180 ms); botões virtuais soltos (sem input preso) | +| 1.3 | Fechar o menu | HUD volta com fade+scale | +| 1.4 | Cutscene (NIS/director) iniciar | HUD some | +| 1.5 | Cutscene terminar | HUD volta | +| 1.6 | Subir escada/ledge/pole (actionState 18,23–28) | HUD reduzido: só joystick + A | +| 1.7 | Sair da escada | HUD completo volta | +| 1.8 | Tela de título/intro/load | HUD oculto | +| 1.9 | Combate (soco/chute) | HUD completo permanece (contexto OnFoot) | + +## 2. Gamepad físico — show/hide automático + +| # | Cenário | Esperado | +|---|---------|----------| +| 2.1 | Conectar gamepad Bluetooth DURANTE gameplay | HUD some automaticamente, sem pausar o jogo; controles físicos funcionam | +| 2.2 | Desconectar gamepad Bluetooth | HUD reaparece automaticamente | +| 2.3 | Conectar gamepad USB/OTG durante gameplay | Igual 2.1 | +| 2.4 | Desconectar USB | Igual 2.2 | +| 2.5 | App já aberto com gamepad conectado desde antes | HUD nunca aparece | +| 2.6 | Dois gamepads: desconectar UM | HUD permanece oculto (ainda há 1 conectado) | +| 2.7 | Dois gamepads: desconectar AMBOS | HUD reaparece | +| 2.8 | Mouse USB conectado | HUD NÃO some (mouse não é gamepad) | +| 2.9 | Controle remoto de TV (DPAD-only) | HUD NÃO some (sem eixo joystick / sem SOURCE_GAMEPAD) | +| 2.10 | Teclado Bluetooth | HUD NÃO some | + +Logs de depuração (debug build): `adb shell setprop log.tag.rechan-hud DEBUG` +antes de abrir o app; eventos aparecem em `adb logcat -s rechan-hud`. + +## 3. Touch input + +| # | Cenário | Esperado | +|---|---------|----------| +| 3.1 | Joystick: movimentos suaves em todas as direções | Personagem anda/corre proporcionalmente; cima = cima | +| 3.2 | Multi-touch: andar + socar simultâneos | Ambos funcionam | +| 3.3 | Botão A (pular) durante corrida | Salto responde | +| 3.4 | Menu via ≡ (Start) | Menu abre; navegar por D-pad do gamepad se conectado | +| 3.5 | Tocar fora dos controles | Nada acontece (sem input fantasma no jogo) | +| 3.6 | Segurar botão e abrir menu (contexto muda) | Botão é liberado (releaseAll) — sem input preso | + +## 4. Layout/edição/persistência + +| # | Cenário | Esperado | +|---|---------|----------| +| 4.1 | Long-press (600 ms) em um controle | Entra em modo edição; controle selecionado com anel âmbar; chips no topo | +| 4.2 | Arrastar controle selecionado | Move; não envia input ao jogo | +| 4.3 | Chips "Menor"/"Maior" | Redimensiona o controle selecionado | +| 4.4 | Chips "Opac-"/"Opac+" | Ajusta opacidade global (5% por toque) | +| 4.5 | Chip "Contorno" | Alterna modo baixo contraste (contorno vazado) | +| 4.6 | Chip "Concluir" | Sai e salva; fechar/reabrir o app restaura tudo | +| 4.7 | Matar o app em edição | Sem corrupção (última configuração salva prevalece) | + +## 5. Telas/orientação + +| # | Cenário | Esperado | +|---|---------|----------| +| 5.1 | Tela 16:9 (720x1600 landscape) | Controles dentro da área segura, sem sobrepôr notch | +| 5.2 | Tela 20:9/21:9 | Joystick e cluster de ações proporcionais, alcançáveis | +| 5.3 | Rotação (se permitida pelo sistema) | Layout reproporcional (posições normalizadas) | + +## 6. Regressão dos controles existentes + +| # | Cenário | Esperado | +|---|---------|----------| +| 6.1 | Gamepad físico: D-pad | Funciona como antes (sem interferência do HUD) | +| 6.2 | Gamepad físico: analógico | Direção correta (cima=cima) — inclui fix do eixo Y | +| 6.3 | Gamepad físico: botões A/B/X/Y/L/R/Start | Mapeamento inalterado | +| 6.4 | BACK/MENU físicos → Start do jogo | Inalterado | diff --git a/src/extra/touchhud.cpp b/src/extra/touchhud.cpp new file mode 100644 index 0000000..bfa9bcb --- /dev/null +++ b/src/extra/touchhud.cpp @@ -0,0 +1,106 @@ +// touchhud.cpp — publishes the gameplay context for the Android touch overlay. +// Signals used (all already exposed by the engine): +// - g_game->GetState() -> only GameState::Play is interactive +// - g_feCustomMenuMgr->IsActive() -> custom menu overlays gameplay +// - g_directorActive / g_director->scriptState -> cutscenes (NIS) +// - Player::s_player->actionState -> ladder/ledge/pole states +// Touch input returns via androidbridge (same path as a physical gamepad). +#include "extra/touchhud.h" + +#if defined(RC_PLATFORM_ANDROID) + +#include + +#include "gen/game.h" +#include "gen/common.h" +#include "gen/director.h" +#include "ai/player.h" +#include "ai/humanoid.h" +#include "extra/fecustommenumgr.h" +#include "pddi/gles/AndroidPlatform.h" + +namespace touchhud { + +namespace { +HudContext s_context = HudContext::Hidden; + +bool IsClimbState(s32 actionState) { + switch (actionState) { + case AS_POLE_IDLE: // 18 + case AS_LEDGE_LATCH: // 23 + case AS_LEDGE_PULLUP: // 24 + case AS_LADDER_CLIMB_DOWN: // 25 + case AS_LADDER_CLIMB_UP: // 26 + case AS_LADDER_CLIMBING: // 27 + case AS_LADDER_DISMOUNT: // 28 + return true; + default: + return false; + } +} + +HudContext ComputeContext() { + if (!g_game || g_game->GetState() != GameState::Play) { + return HudContext::Hidden; + } + if (g_feCustomMenuMgr && g_feCustomMenuMgr->IsActive()) { + return HudContext::Hidden; + } + if (g_directorActive != 0 || (g_director && g_director->scriptState != 0)) { + // Cutscene / NIS running. + return HudContext::Hidden; + } + if (const Player* player = Player::s_player) { + if (IsClimbState(player->actionState)) { + return HudContext::Climbing; + } + } + return HudContext::OnFoot; +} + +} // namespace + +void PublishFrame() { + s_context = ComputeContext(); + androidbridge::SetHudContext(static_cast(s_context)); +} + +HudContext GetContext() { + return s_context; +} + +} // namespace touchhud + +// --- JNI surface for the Java touch overlay (com.deivid22srk.rechan.hud) --- +// Touch controls feed the same androidbridge gamepad state a physical pad +// uses, so the engine cannot tell them apart and no duplicate input path +// exists. + +extern "C" { + +JNIEXPORT void JNICALL +Java_com_deivid22srk_rechan_hud_HudBridge_nativePostButton( + JNIEnv*, jclass, jint button, jboolean down) { + androidbridge::PostGamepadButton(button, down == JNI_TRUE); +} + +JNIEXPORT void JNICALL +Java_com_deivid22srk_rechan_hud_HudBridge_nativePostAxis( + JNIEnv*, jclass, jint axis, jfloat value) { + androidbridge::PostGamepadAxis(axis, value); +} + +JNIEXPORT void JNICALL +Java_com_deivid22srk_rechan_hud_HudBridge_nativePostConnected( + JNIEnv*, jclass, jboolean connected) { + androidbridge::PostGamepadConnected(connected == JNI_TRUE); +} + +JNIEXPORT jint JNICALL +Java_com_deivid22srk_rechan_hud_HudBridge_nativePollContext(JNIEnv*, jclass) { + return static_cast(touchhud::GetContext()); +} + +} // extern "C" + +#endif // RC_PLATFORM_ANDROID diff --git a/src/extra/touchhud.h b/src/extra/touchhud.h new file mode 100644 index 0000000..a4c10ca --- /dev/null +++ b/src/extra/touchhud.h @@ -0,0 +1,30 @@ +// touchhud.h — Contextual HUD contract (Android only). +// +// The game loop publishes a coarse "context" every frame; the Java overlay +// (com.deivid22srk.rechan.hud) polls it via JNI and adapts the on-screen +// touch controls. Touch input flows back through androidbridge's gamepad +// state, so the engine sees the virtual pad exactly like a physical one. +#pragma once + +#include "core.h" + +#if defined(RC_PLATFORM_ANDROID) + +namespace touchhud { + +// Keep in sync with HudBridge.CONTEXT_* in the Java layer. +enum class HudContext : u32 { + Hidden = 0, // menus, cutscenes/NIS, intros, loading, non-play states + OnFoot = 1, // normal gameplay (combat included) + Climbing = 2, // ladders / ledges / poles: reduced control set +}; + +// Called once per frame from the main loop (game thread). +void PublishFrame(); + +// Current context (also readable from any thread). +HudContext GetContext(); + +} // namespace touchhud + +#endif // RC_PLATFORM_ANDROID diff --git a/src/gen/main.cpp b/src/gen/main.cpp index b26c7c3..c3df2df 100644 --- a/src/gen/main.cpp +++ b/src/gen/main.cpp @@ -36,6 +36,7 @@ #endif #include "extra/version.h" +#include "extra/touchhud.h" #include #include @@ -377,6 +378,11 @@ static int RechanMain(int argc, char** argv) { jcsUpdateAmbience(); jcsUpdateDialogCD(); +#if defined(RC_PLATFORM_ANDROID) + // Publish gameplay context for the touch overlay (no-op elsewhere). + touchhud::PublishFrame(); +#endif + g_time->WaitForFrameEnd(frameStart); static f32 titleAccumSec = 0.0f; diff --git a/vendor/libp3d/pddi/gles/AndroidPlatform.cpp b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp index 9d24332..d7eb2d3 100644 --- a/vendor/libp3d/pddi/gles/AndroidPlatform.cpp +++ b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp @@ -14,6 +14,7 @@ std::atomic g_exitRequested{false}; std::atomic g_heldButtons{0}; std::atomic g_axes[GamepadAxis::COUNT] = {}; std::atomic g_padConnected{false}; +std::atomic g_hudContext{0}; } // namespace void SetNativeWindow(NativeWindowHandle window) { @@ -76,6 +77,14 @@ PadSnapshot LoadPadSnapshot() { return snap; } +void SetHudContext(uint32_t context) { + g_hudContext.store(context, std::memory_order_relaxed); +} + +uint32_t LoadHudContext() { + return g_hudContext.load(std::memory_order_relaxed); +} + static_assert(GamepadButton::COUNT <= 32, "buttons fit in u32 bitmask"); static_assert(GamepadAxis::COUNT == 6, "PadSnapshot axes size matches"); diff --git a/vendor/libp3d/pddi/gles/AndroidPlatform.h b/vendor/libp3d/pddi/gles/AndroidPlatform.h index c3aeaed..8f2273e 100644 --- a/vendor/libp3d/pddi/gles/AndroidPlatform.h +++ b/vendor/libp3d/pddi/gles/AndroidPlatform.h @@ -52,4 +52,10 @@ struct PadSnapshot { PadSnapshot LoadPadSnapshot(); +// --- Touch HUD context (game thread -> UI thread) --- +// Published by the game loop each frame; the Java overlay polls it to decide +// which on-screen controls to show. Values match touchhud::HudContext. +void SetHudContext(uint32_t context); +uint32_t LoadHudContext(); + } // namespace androidbridge From cbe3cef006a9f1c5244bb4f0906def58434bd96f Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Tue, 25 Aug 2026 11:51:16 -0300 Subject: [PATCH 02/31] HUD: use TYPE_APPLICATION_PANEL window (NativeActivity takeInputQueue bypasses view dispatch) --- .../deivid22srk/rechan/hud/HudController.java | 65 ++++++++++++++----- .../deivid22srk/rechan/hud/TouchHudView.java | 4 +- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/java/com/deivid22srk/rechan/hud/HudController.java b/android/app/src/main/java/com/deivid22srk/rechan/hud/HudController.java index 6fd43a7..60b74a8 100644 --- a/android/app/src/main/java/com/deivid22srk/rechan/hud/HudController.java +++ b/android/app/src/main/java/com/deivid22srk/rechan/hud/HudController.java @@ -8,8 +8,6 @@ import android.os.Looper; import android.util.Log; import android.view.InputDevice; -import android.view.MotionEvent; -import android.view.View; import android.view.WindowInsets; import org.json.JSONException; @@ -37,6 +35,7 @@ public class HudController implements InputManager.InputDeviceListener { private TouchHudView hudView; private InputManager inputManager; private boolean gamepadConnected; + private boolean panelAttached; private boolean running; private int lastContext = -1; @@ -44,21 +43,21 @@ public HudController(Activity activity) { this.activity = activity; } - /** Attach the overlay to the activity and start listening. */ + /** Attach the overlay to the activity and start listening. + * + * NativeActivity claims the window's input pipeline via + * takeInputQueue(), so views inside the activity NEVER receive touch + * events. The HUD therefore lives in its own panel window + * (TYPE_APPLICATION_PANEL, FLAG_NOT_FOCUSABLE): it gets an independent + * input channel for touches while gamepad keys keep flowing to the game + * window untouched. */ public void attach() { hudView = new TouchHudView(activity, this); - hudView.setVisibility(View.GONE); // shown once state settles - activity.addContentView( - hudView, - new android.view.ViewGroup.LayoutParams( - android.view.ViewGroup.LayoutParams.MATCH_PARENT, - android.view.ViewGroup.LayoutParams.MATCH_PARENT)); - - // Safe areas (cutout + system bars): recompute default layout bounds. - // WindowInsets.Type / getInsets() require API 30; older devices run - // without safe-area adjustment. + + // Safe areas (cutout + system bars) come from the activity's decor + // view — panel windows may not receive insets dispatches. if (android.os.Build.VERSION.SDK_INT >= 30) { - hudView.setOnApplyWindowInsetsListener((v, insets) -> { + activity.getWindow().getDecorView().setOnApplyWindowInsetsListener((v, insets) -> { android.graphics.Insets bars = insets.getInsets( WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout()); @@ -70,11 +69,37 @@ public void attach() { inputManager = (InputManager) activity.getSystemService(Context.INPUT_SERVICE); inputManager.registerInputDeviceListener(this, handler); gamepadConnected = anyRealGamepadConnected(); - applyGamepadVisibility(false); running = true; handler.postDelayed(contextPoller, CONTEXT_POLL_MS); log("attached; gamepadConnected=" + gamepadConnected); + + // The window token only exists once the decor view is attached. + activity.getWindow().getDecorView().post(this::attachPanelWindow); + } + + private void attachPanelWindow() { + if (hudView == null || !running) return; + try { + android.view.WindowManager wm = + (android.view.WindowManager) activity.getSystemService(Context.WINDOW_SERVICE); + android.view.WindowManager.LayoutParams params = + new android.view.WindowManager.LayoutParams( + android.view.WindowManager.LayoutParams.MATCH_PARENT, + android.view.WindowManager.LayoutParams.MATCH_PARENT, + android.view.WindowManager.LayoutParams.TYPE_APPLICATION_PANEL, + android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + | android.view.WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED + | android.view.WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN + | android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + android.graphics.PixelFormat.TRANSLUCENT); + params.token = activity.getWindow().getDecorView().getWindowToken(); + panelAttached = true; + wm.addView(hudView, params); + applyGamepadVisibility(false); + } catch (Exception e) { + log("panel attach failed: " + e); + } } public void detach() { @@ -85,6 +110,16 @@ public void detach() { } if (hudView != null) { hudView.releaseAll(); + if (panelAttached) { + try { + android.view.WindowManager wm = + (android.view.WindowManager) activity.getSystemService(Context.WINDOW_SERVICE); + wm.removeView(hudView); + } catch (Exception ignored) { + } + panelAttached = false; + } + hudView = null; } } diff --git a/android/app/src/main/java/com/deivid22srk/rechan/hud/TouchHudView.java b/android/app/src/main/java/com/deivid22srk/rechan/hud/TouchHudView.java index 6a53fef..872f123 100644 --- a/android/app/src/main/java/com/deivid22srk/rechan/hud/TouchHudView.java +++ b/android/app/src/main/java/com/deivid22srk/rechan/hud/TouchHudView.java @@ -153,7 +153,9 @@ void animateVisibility(boolean show, int hudContext) { animate().alpha(0f).setDuration(180) .withEndAction(() -> { if (!visible) { - setVisibility(GONE); + // INVISIBLE keeps the panel window sized; hidden + // views receive no touch dispatch anyway. + setVisibility(INVISIBLE); exitEditMode(); } }).start(); From 221b1e413c5289c05366808701b302ceabc4e7b6 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Tue, 25 Aug 2026 13:24:10 -0300 Subject: [PATCH 03/31] Diagnostics: dump stuck Director script stream, DoorFunc unknown token, door NIS trigger logging --- src/ai/door.cpp | 3 +++ src/gen/director.cpp | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/ai/door.cpp b/src/ai/door.cpp index 8c1d068..99fb656 100644 --- a/src/ai/door.cpp +++ b/src/ai/door.cpp @@ -367,6 +367,9 @@ void Door::HandleHumanoidCollision(Humanoid* hum) { if (isValidPlayer && doorState == 1) { // PSX: state 1 + valid player trigger door cutscene via Director + LOG("[Door::HandleHumanoidCollision] triggering door NIS (name=%s state=%d killTarget=%d script=%s)", + GetName() ? GetName() : "null", doorState, killTarget ? 1 : 0, + killTarget ? "WithDialog" : "Plain"); if (g_director) { // PSX: if killTarget exists, use NISdoor1WithDialog; else NISdoor1 s32* doorScript = killTarget diff --git a/src/gen/director.cpp b/src/gen/director.cpp index 6781184..6df588b 100644 --- a/src/gen/director.cpp +++ b/src/gen/director.cpp @@ -1266,6 +1266,9 @@ void Director::SetCodeSnip(s32* snip, Thing* thing) { codeSnipPtr = snip; g_codeSnipThing = thing; // PSX: gp[869] = thing + LOG("[Director] SetCodeSnip script=%p thing=%s", static_cast(snip), + (thing && thing->GetName()) ? thing->GetName() : "null"); + const s32* deathScript = ResolveDirectorScriptVA(kDirectorScriptDeath); const s32* deathVolScript = ResolveDirectorScriptVA(kDirectorScriptDeathVol); if (snip == deathScript || snip == deathVolScript || snip == death || snip == death_vol) { @@ -2237,6 +2240,23 @@ void Director::Process() { // advancing scriptPtr — the script blocks on the bad opcode each frame // rather than desyncing by consuming one word. LOG("[Director] unknown opcode 0x%02X at scriptPtr=%p", static_cast(opcode), static_cast(scriptPtr)); + // Android diagnostics: dump the surrounding stream once per stuck + // address so the offending script can be identified offline. + { + static const void* s_lastStuckPtr = nullptr; + if (s_lastStuckPtr != scriptPtr) { + s_lastStuckPtr = scriptPtr; + LOG("[Director] stuck-script dump: scriptBase=%p codeSnipPtr=%p scriptState=%d", + static_cast(scriptBase), + static_cast(codeSnipPtr), scriptState); + for (s32 i = -8; i <= 8; ++i) { + const s32 w = scriptPtr[i]; + LOG("[Director] scriptPtr[%d] @%p = 0x%08X (%d)", + i, static_cast(scriptPtr + i), + static_cast(w), w); + } + } + } field68 = 1; break; } @@ -2995,6 +3015,9 @@ void Director::ProcessDoorFunc() { default: // PSX: unknown door tokens are ignored. + LOG("[Director] ProcessDoorFunc unknown token 0x%02X at scriptPtr=%p (base=%p)", + static_cast(op), static_cast(scriptPtr), + static_cast(scriptBase)); break; } } From a899391d5c64070ee4f2da79e6d02bf2c3f2788d Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Tue, 25 Aug 2026 13:55:01 -0300 Subject: [PATCH 04/31] Fix stuck door NIS: implement top-level opcode 0x3E (AttachThingToDoor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real PSX WORLDPTS door scripts (shadowing the compiled reconstructions at runtime) use top-level opcode 0x3E with a humanoid-hash operand to walk the player into the doorway via NisControl. The recomp only knew 0x3E as a DoorFunc sub-command, so the interpreter hit the unknown-opcode path and blocked forever at the same scriptPtr — door never opened (stuck NIS, actor reload loop). Implement it as AttachToDoor with the door taken from g_codeSnipThing (set by SetCodeSnip when the door collision triggers the NIS). --- src/gen/director.cpp | 43 +++++++++++++++++++++++++++++++++++++++++++ src/gen/director.h | 2 ++ 2 files changed, 45 insertions(+) diff --git a/src/gen/director.cpp b/src/gen/director.cpp index 6df588b..1c5b298 100644 --- a/src/gen/director.cpp +++ b/src/gen/director.cpp @@ -1883,6 +1883,49 @@ void Director::Process() { ProcessDoorFunc(); break; + case DirectorOpcode::AttachThingToDoor: + { + // PSX top-level 0x3E (real WORLDPTS door NIS data): attach the + // humanoid named by the hash operand to the door that started + // this code snip (g_codeSnipThing), walking it into the doorway + // via Behaviour::NisControl. Same body as DoorFunc's + // AttachToDoor sub-command, but the door comes from the code + // snip thing instead of a previous SetDoor token — the real + // scripts never issue SetDoor. + const u32 thingRef = static_cast(scriptPtr[1]); + scriptPtr += 2; + + Humanoid* humanoid = FindHumanoidByScriptRef(thingRef); + Door* door = dynamic_cast(g_codeSnipThing); + if (humanoid && door && humanoid->behaviour) { + LOG("[Director] AttachThingToDoor humanoid=%s door=%s", + humanoid->GetName() ? humanoid->GetName() : "null", + door->GetName() ? door->GetName() : "null"); + LVector localCenter = {}; + localCenter.x = ((s32)door->collBox.minX + (s32)door->collBox.maxX) / 2; + localCenter.y = ((s32)door->collBox.minY + (s32)door->collBox.maxY) / 2; + localCenter.z = ((s32)door->collBox.minZ + (s32)door->collBox.maxZ) / 2; + + s32 sinY = rmSin16(door->orientation.y); + s32 cosY = rmSin16(door->orientation.y + 0x4000); + + LVector attachPos = {}; + attachPos.x = door->pos.x + (s32)(((s64)cosY * localCenter.x) >> 16) + (s32)(((s64)sinY * localCenter.z) >> 16); + attachPos.y = door->pos.y; + attachPos.z = door->pos.z + (s32)((-(s64)sinY * localCenter.x) >> 16) + (s32)(((s64)cosY * localCenter.z) >> 16); + + humanoid->behaviour->destPoint = attachPos; + humanoid->behaviour->handlerThisOffset = 0; + humanoid->behaviour->handlerDispatch = -1; + humanoid->behaviour->handler = Behaviour::NisControl; + } + else { + LOG("[Director] AttachThingToDoor skipped: humanoid=%p door=%p", + static_cast(humanoid), static_cast(door)); + } + break; + } + case DirectorOpcode::FacePointAndNisControl: // PSX: face player toward NIS point, write Behaviour::destPoint, set NisControl behaviour scriptPtr += 1; diff --git a/src/gen/director.h b/src/gen/director.h index 72ca19c..b41d531 100644 --- a/src/gen/director.h +++ b/src/gen/director.h @@ -52,6 +52,7 @@ enum class DirectorOpcode : s32 { DropPickup = 0x2A, CameraFunc = 0x2B, DoorFunc = 0x38, + AttachThingToDoor = 0x3E, FacePointAndNisControl = 0x40, LadderFunc = 0x41, ModelFunc = 0x47, @@ -130,6 +131,7 @@ static inline const char* CmdToString(DirectorOpcode op) { case DirectorOpcode::DropPickup: return "DropPickup"; case DirectorOpcode::CameraFunc: return "CameraFunc"; case DirectorOpcode::DoorFunc: return "DoorFunc"; + case DirectorOpcode::AttachThingToDoor: return "AttachThingToDoor"; case DirectorOpcode::FacePointAndNisControl: return "FacePointAndNisControl"; case DirectorOpcode::LadderFunc: return "LadderFunc"; case DirectorOpcode::ModelFunc: return "ModelFunc"; From 4b5c74844c2d796d4574781f5c8d3c1d8474ad21 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Tue, 25 Aug 2026 15:42:56 -0300 Subject: [PATCH 05/31] Engine-side touch for menus: touchscreen emulates mouse (hover+click), title tap = Start - AndroidInput: first-finger touchscreen tracking feeds androidbridge touch state - glesDisplay (Android): IsMouseButtonDown/GetMousePosition return touch state, so PlatformInput/ActionInput/the engine's own menu code handle touch natively (entry hover, click-to-activate, sliders, scrollbars - full PC mouse parity) - touchhud: tap on the 'press any button' title screen pulses Start - HudController: FLAG_NOT_TOUCHABLE pass-through in Menu/Hidden contexts (taps reach the engine); gameplay contexts keep the overlay controls --- android/app/src/main/cpp/AndroidInput.cpp | 51 ++++++++++++++++ .../com/deivid22srk/rechan/hud/HudBridge.java | 1 + .../deivid22srk/rechan/hud/HudController.java | 44 +++++++++++++- .../deivid22srk/rechan/hud/TouchHudView.java | 10 +++- src/extra/touchhud.cpp | 58 +++++++++++++++---- src/extra/touchhud.h | 3 +- vendor/libp3d/pddi/gles/AndroidPlatform.cpp | 24 ++++++++ vendor/libp3d/pddi/gles/AndroidPlatform.h | 12 ++++ vendor/libp3d/pddi/gles/glesrender.h | 16 +++++ 9 files changed, 204 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/cpp/AndroidInput.cpp b/android/app/src/main/cpp/AndroidInput.cpp index cb9d06c..caca6ba 100644 --- a/android/app/src/main/cpp/AndroidInput.cpp +++ b/android/app/src/main/cpp/AndroidInput.cpp @@ -70,6 +70,9 @@ int HatToButtons(float hatX, float hatY) { return buttons; } +// Pointer id of the first finger driving the emulated mouse (-1 = none). +int32_t s_touchPointer = -1; + void ApplyHatButtons(int32_t deviceId, int wanted) { // Per-device diff state: a pad without HAT axes keeps emitting 0 and must // not clear the D-pad held on a different controller. @@ -143,6 +146,54 @@ bool HandleKeyEvent(AInputEvent* event) { bool HandleMotionEvent(AInputEvent* event) { const int32_t source = AInputEvent_getSource(event); + + // Touchscreen: emulate a mouse for the engine's own menu code (hover + + // left-click) and queue taps for game-loop decisions (title screen). + if ((source & AINPUT_SOURCE_TOUCHSCREEN) != 0) { + const int32_t action = AMotionEvent_getAction(event); + switch (action & AMOTION_EVENT_ACTION_POINTER_INDEX_MASK) { + case AMOTION_EVENT_ACTION_DOWN: + case AMOTION_EVENT_ACTION_POINTER_DOWN: { + const int32_t idx = (action >> AMOTION_EVENT_ACTION_POINTER_INDEX_SHIFT) + & 0xff; + if (s_touchPointer == -1) { + s_touchPointer = AMotionEvent_getPointerId(event, idx); + androidbridge::SetTouchMouse( + AMotionEvent_getX(event, idx), AMotionEvent_getY(event, idx), true); + androidbridge::QueueTouchTap(); + } + return true; + } + case AMOTION_EVENT_ACTION_MOVE: { + for (int32_t i = 0; i < AMotionEvent_getPointerCount(event); ++i) { + if (AMotionEvent_getPointerId(event, i) == s_touchPointer) { + androidbridge::SetTouchMouse( + AMotionEvent_getX(event, i), AMotionEvent_getY(event, i), true); + break; + } + } + return true; + } + case AMOTION_EVENT_ACTION_UP: + case AMOTION_EVENT_ACTION_POINTER_UP: + case AMOTION_EVENT_ACTION_CANCEL: { + const int32_t idx = (action >> AMOTION_EVENT_ACTION_POINTER_INDEX_SHIFT) + & 0xff; + const bool primaryLifted = + (action == AMOTION_EVENT_ACTION_UP) || + (action == AMOTION_EVENT_ACTION_CANCEL) || + (AMotionEvent_getPointerId(event, idx) == s_touchPointer); + if (primaryLifted && s_touchPointer != -1) { + s_touchPointer = -1; + androidbridge::SetTouchMouse(0.0f, 0.0f, false); + } + return true; + } + default: + return true; + } + } + if ((source & kSourceGamepadMask) == 0) { return false; } diff --git a/android/app/src/main/java/com/deivid22srk/rechan/hud/HudBridge.java b/android/app/src/main/java/com/deivid22srk/rechan/hud/HudBridge.java index e5af3ec..6bf6343 100644 --- a/android/app/src/main/java/com/deivid22srk/rechan/hud/HudBridge.java +++ b/android/app/src/main/java/com/deivid22srk/rechan/hud/HudBridge.java @@ -13,6 +13,7 @@ public final class HudBridge { public static final int CONTEXT_HIDDEN = 0; public static final int CONTEXT_ON_FOOT = 1; public static final int CONTEXT_CLIMBING = 2; + public static final int CONTEXT_MENU = 3; // pddi GamepadButton (GLFW-style layout) public static final int BTN_A = 0; diff --git a/android/app/src/main/java/com/deivid22srk/rechan/hud/HudController.java b/android/app/src/main/java/com/deivid22srk/rechan/hud/HudController.java index 60b74a8..2485980 100644 --- a/android/app/src/main/java/com/deivid22srk/rechan/hud/HudController.java +++ b/android/app/src/main/java/com/deivid22srk/rechan/hud/HudController.java @@ -36,6 +36,7 @@ public class HudController implements InputManager.InputDeviceListener { private InputManager inputManager; private boolean gamepadConnected; private boolean panelAttached; + private boolean touchPassThrough; private boolean running; private int lastContext = -1; @@ -140,7 +141,13 @@ public void run() { }; private void applyContext(int context) { - if (gamepadConnected || context == HudBridge.CONTEXT_HIDDEN) { + // Menus and non-interactive states: hide the overlay AND let touches + // fall through to the engine (the game's own menu code handles them + // as mouse input). Gameplay contexts: overlay consumes touches. + final boolean gameplay = context == HudBridge.CONTEXT_ON_FOOT + || context == HudBridge.CONTEXT_CLIMBING; + setTouchPassThrough(!gameplay); + if (gamepadConnected || !gameplay) { hudView.animateVisibility(false, context); } else { @@ -148,6 +155,31 @@ private void applyContext(int context) { } } + /** Toggle FLAG_NOT_TOUCHABLE on the panel window so taps pass through to + * the game window (and from there into the native input queue). */ + private void setTouchPassThrough(boolean passThrough) { + if (hudView == null || !panelAttached || passThrough == touchPassThrough) { + return; + } + touchPassThrough = passThrough; + try { + android.view.WindowManager wm = + (android.view.WindowManager) activity.getSystemService(Context.WINDOW_SERVICE); + android.view.WindowManager.LayoutParams params = + (android.view.WindowManager.LayoutParams) hudView.getLayoutParams(); + if (passThrough) { + params.flags |= android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE; + } + else { + params.flags &= ~android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE; + } + wm.updateViewLayout(hudView, params); + log("touchPassThrough=" + passThrough); + } catch (Exception e) { + log("passThrough update failed: " + e); + } + } + // --- Physical gamepad hot-plug ------------------------------------------ private void applyGamepadVisibility(boolean animate) { @@ -156,6 +188,16 @@ private void applyGamepadVisibility(boolean animate) { hudView.releaseAll(); // no stuck virtual buttons while hidden } hudView.animateVisibility(!gamepadConnected, lastContext); + // With a gamepad in charge the overlay never needs touches; without + // one, pass-through is decided by the gameplay context (applyContext). + if (gamepadConnected) { + setTouchPassThrough(true); + } + else { + final boolean gameplay = lastContext == HudBridge.CONTEXT_ON_FOOT + || lastContext == HudBridge.CONTEXT_CLIMBING; + setTouchPassThrough(!gameplay); + } if (animate) { log("gamepad " + (gamepadConnected ? "connected -> HUD hidden" : "disconnected -> HUD shown")); diff --git a/android/app/src/main/java/com/deivid22srk/rechan/hud/TouchHudView.java b/android/app/src/main/java/com/deivid22srk/rechan/hud/TouchHudView.java index 872f123..b2a312a 100644 --- a/android/app/src/main/java/com/deivid22srk/rechan/hud/TouchHudView.java +++ b/android/app/src/main/java/com/deivid22srk/rechan/hud/TouchHudView.java @@ -186,10 +186,14 @@ void releaseAll() { // --- Context handling ----------------------------------------------------- private void applyContextToControls() { - // Climbing: joystick + jump only. OnFoot: everything. - boolean climbing = context == HudBridge.CONTEXT_CLIMBING; + // Climbing: joystick + jump only. OnFoot: everything. Menu/Hidden: + // nothing interactive (the engine handles touches natively there and + // the controller hides this view + passes touches through). + final boolean climbing = context == HudBridge.CONTEXT_CLIMBING; + final boolean gameplay = context == HudBridge.CONTEXT_CLIMBING + || context == HudBridge.CONTEXT_ON_FOOT; for (Control c : controls) { - c.interactive = !climbing || c == joystick || "A".equals(c.id); + c.interactive = gameplay && (!climbing || c == joystick || "A".equals(c.id)); } } diff --git a/src/extra/touchhud.cpp b/src/extra/touchhud.cpp index bfa9bcb..2469ae4 100644 --- a/src/extra/touchhud.cpp +++ b/src/extra/touchhud.cpp @@ -23,6 +23,7 @@ namespace touchhud { namespace { HudContext s_context = HudContext::Hidden; +s32 s_startPulseFrames = 0; bool IsClimbState(s32 actionState) { switch (actionState) { @@ -40,22 +41,44 @@ bool IsClimbState(s32 actionState) { } HudContext ComputeContext() { - if (!g_game || g_game->GetState() != GameState::Play) { - return HudContext::Hidden; - } + // Custom menu overlays (asset setup, pause, title/FE menus) always win: + // they need d-pad style navigation, available with or without a gamepad. if (g_feCustomMenuMgr && g_feCustomMenuMgr->IsActive()) { - return HudContext::Hidden; + return HudContext::Menu; } - if (g_directorActive != 0 || (g_director && g_director->scriptState != 0)) { - // Cutscene / NIS running. + + if (!g_game) { return HudContext::Hidden; } - if (const Player* player = Player::s_player) { - if (IsClimbState(player->actionState)) { - return HudContext::Climbing; + + switch (g_game->GetState()) { + case GameState::Title: + case GameState::TitleLoop: + case GameState::OpenFE: + case GameState::FE: + case GameState::Menu: + case GameState::DbgMenu: + case GameState::LocationMenu: + case GameState::OpenLocationMenu: + return HudContext::Menu; + + case GameState::Play: { + if (g_directorActive != 0 || (g_director && g_director->scriptState != 0)) { + // Cutscene / NIS running. + return HudContext::Hidden; + } + if (const Player* player = Player::s_player) { + if (IsClimbState(player->actionState)) { + return HudContext::Climbing; + } + } + return HudContext::OnFoot; } + + default: + // Intros, movies, loading, credits, error loops: touch is useless. + return HudContext::Hidden; } - return HudContext::OnFoot; } } // namespace @@ -63,6 +86,21 @@ HudContext ComputeContext() { void PublishFrame() { s_context = ComputeContext(); androidbridge::SetHudContext(static_cast(s_context)); + + // Title screen "press any button": a tap anywhere becomes a Start press + // (the custom menu is inactive during that phase, so taps aren't menu + // clicks yet). Two frames so the pad edge is sampled reliably. + if (androidbridge::ConsumeTouchTap()) { + const bool menuActive = g_feCustomMenuMgr && g_feCustomMenuMgr->IsActive(); + const bool onTitle = g_game && g_game->GetState() == GameState::TitleLoop; + if (!menuActive && onTitle && s_startPulseFrames == 0) { + s_startPulseFrames = 2; + } + } + if (s_startPulseFrames > 0) { + androidbridge::PostGamepadButton(GamepadButton::Start, s_startPulseFrames == 2); + --s_startPulseFrames; + } } HudContext GetContext() { diff --git a/src/extra/touchhud.h b/src/extra/touchhud.h index a4c10ca..5036d19 100644 --- a/src/extra/touchhud.h +++ b/src/extra/touchhud.h @@ -14,9 +14,10 @@ namespace touchhud { // Keep in sync with HudBridge.CONTEXT_* in the Java layer. enum class HudContext : u32 { - Hidden = 0, // menus, cutscenes/NIS, intros, loading, non-play states + Hidden = 0, // cutscenes/NIS, intros, loading, credits, non-interactive OnFoot = 1, // normal gameplay (combat included) Climbing = 2, // ladders / ledges / poles: reduced control set + Menu = 3, // title/FE menus, asset setup, pause: d-pad style navigation }; // Called once per frame from the main loop (game thread). diff --git a/vendor/libp3d/pddi/gles/AndroidPlatform.cpp b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp index d7eb2d3..e863249 100644 --- a/vendor/libp3d/pddi/gles/AndroidPlatform.cpp +++ b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp @@ -15,6 +15,10 @@ std::atomic g_heldButtons{0}; std::atomic g_axes[GamepadAxis::COUNT] = {}; std::atomic g_padConnected{false}; std::atomic g_hudContext{0}; +std::atomic g_touchX{0.0f}; +std::atomic g_touchY{0.0f}; +std::atomic g_touchDown{false}; +std::atomic g_tapQueued{false}; } // namespace void SetNativeWindow(NativeWindowHandle window) { @@ -85,6 +89,26 @@ uint32_t LoadHudContext() { return g_hudContext.load(std::memory_order_relaxed); } +void SetTouchMouse(float x, float y, bool down) { + g_touchX.store(x, std::memory_order_relaxed); + g_touchY.store(y, std::memory_order_relaxed); + g_touchDown.store(down, std::memory_order_relaxed); +} + +bool GetTouchMouse(float* x, float* y) { + if (x) x[0] = g_touchX.load(std::memory_order_relaxed); + if (y) y[0] = g_touchY.load(std::memory_order_relaxed); + return g_touchDown.load(std::memory_order_relaxed); +} + +void QueueTouchTap() { + g_tapQueued.store(true, std::memory_order_release); +} + +bool ConsumeTouchTap() { + return g_tapQueued.exchange(false, std::memory_order_acq_rel); +} + static_assert(GamepadButton::COUNT <= 32, "buttons fit in u32 bitmask"); static_assert(GamepadAxis::COUNT == 6, "PadSnapshot axes size matches"); diff --git a/vendor/libp3d/pddi/gles/AndroidPlatform.h b/vendor/libp3d/pddi/gles/AndroidPlatform.h index 8f2273e..c8f7ff4 100644 --- a/vendor/libp3d/pddi/gles/AndroidPlatform.h +++ b/vendor/libp3d/pddi/gles/AndroidPlatform.h @@ -58,4 +58,16 @@ PadSnapshot LoadPadSnapshot(); void SetHudContext(uint32_t context); uint32_t LoadHudContext(); +// --- Touch-as-mouse (input thread -> game thread) --- +// Feeds the GLES display's mouse stubs so the engine's own menu code +// (hover + left-click) handles touch directly, exactly like a PC mouse. +void SetTouchMouse(float x, float y, bool down); +// Returns true while a finger is down; always writes the latest position. +bool GetTouchMouse(float* x, float* y); + +// One-shot tap notification consumed by the game loop (used to turn a tap +// into a Start press on the "press any button" title screen). +void QueueTouchTap(); +bool ConsumeTouchTap(); + } // namespace androidbridge diff --git a/vendor/libp3d/pddi/gles/glesrender.h b/vendor/libp3d/pddi/gles/glesrender.h index 8b7f696..977e875 100644 --- a/vendor/libp3d/pddi/gles/glesrender.h +++ b/vendor/libp3d/pddi/gles/glesrender.h @@ -165,8 +165,24 @@ class glesDisplay : public pddiDisplay { void PollEvents() override; bool IsKeyDown(int) override { return false; } + +#if defined(RC_PLATFORM_ANDROID) + // Touch-as-mouse: fed by the NativeActivity input pump via androidbridge. + bool IsMouseButtonDown(int button) override { + if (button != 0) return false; + float x, y; + return androidbridge::GetTouchMouse(&x, &y); + } + void GetMousePosition(double& x, double& y) override { + float fx, fy; + androidbridge::GetTouchMouse(&fx, &fy); + x = fx; + y = fy; + } +#else bool IsMouseButtonDown(int) override { return false; } void GetMousePosition(double& x, double& y) override { x = 0.0; y = 0.0; } +#endif void SetIcon(int, int, const unsigned char*) override {} From 23b69e9ba0c2aaa75bef73c7551e217c78edc5af Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Tue, 25 Aug 2026 17:53:54 -0300 Subject: [PATCH 06/31] Disable Java HUD overlay; keep native touch->mouse for menus (AndroidInput touch handling, glesDisplay mouse stubs) --- .../com/deivid22srk/rechan/GameActivity.java | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java b/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java index 17f2a7b..dea0300 100644 --- a/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java +++ b/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java @@ -7,19 +7,17 @@ import android.view.WindowInsets; import android.view.WindowInsetsController; -import com.deivid22srk.rechan.hud.HudController; - /** * NativeActivity hosting the ReChan GLES3 engine. Immersive fullscreen with - * cutout support; all input flows through the native input queue. A - * contextual touch overlay (HudController) is attached on top: it hides - * itself while a physical gamepad is connected and adapts its controls to - * the gameplay context published by the engine. + * cutout support; all input flows through the native input queue. + * + * The Java HUD overlay (HudController) is disabled for now — the engine + * handles touch natively for menus (mouse emulation via touchscreen events + * in AndroidInput.cpp). A future re-enable can restore the gameplay touch + * controls. */ public class GameActivity extends NativeActivity { - private HudController hudController; - @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -27,17 +25,9 @@ protected void onCreate(Bundle savedInstanceState) { getWindow().getAttributes().layoutInDisplayCutoutMode = android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; } - hudController = new HudController(this); - hudController.attach(); - } - - @Override - protected void onDestroy() { - if (hudController != null) { - hudController.detach(); - hudController = null; - } - super.onDestroy(); + // HUD overlay disabled — see HudController / TouchHudView for the + // gameplay touch overlay (joystick + buttons). Native touch→mouse + // in AndroidInput.cpp handles menus. } @Override From eb2017aef57d8369424e452ebf09d5b7bc2d238f Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Tue, 25 Aug 2026 18:06:58 -0300 Subject: [PATCH 07/31] Touch diagnostics: log touch->mouse chain state in PublishFrame (throttled) --- src/extra/fecustommenumgr.h | 1 + src/extra/touchhud.cpp | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/extra/fecustommenumgr.h b/src/extra/fecustommenumgr.h index 6b9cac2..30c3a34 100644 --- a/src/extra/fecustommenumgr.h +++ b/src/extra/fecustommenumgr.h @@ -404,6 +404,7 @@ class feCustomMenuMgr { bool IsPopupActive() const { return m_activePopup != PopupKind_None; } PopupKind GetActivePopup() const { return m_activePopup; } bool IsActive() const { return m_active; } + bool IsMouseActive() const { return m_mouseInputActive; } MenuPage GetCurrentPage() const { return m_currPage; } void RenderAutosaveSpinner(s32 centerX, s32 centerY, f32 alpha01 = 1.0f) const; diff --git a/src/extra/touchhud.cpp b/src/extra/touchhud.cpp index 2469ae4..35ae8cf 100644 --- a/src/extra/touchhud.cpp +++ b/src/extra/touchhud.cpp @@ -87,6 +87,21 @@ void PublishFrame() { s_context = ComputeContext(); androidbridge::SetHudContext(static_cast(s_context)); + // Touch diagnostics (throttled): confirm the touch->mouse chain end to end. + static s32 s_diagCounter = 0; + if ((s_diagCounter++ & 0x3F) == 0) { + float tx = 0.0f, ty = 0.0f; + const bool touchDown = androidbridge::GetTouchMouse(&tx, &ty); + const bool menuActive = g_feCustomMenuMgr && g_feCustomMenuMgr->IsActive(); + const bool mouseActive = g_feCustomMenuMgr ? g_feCustomMenuMgr->IsMouseActive() : false; + LOG("[TouchDiag] down=%d pos=(%.0f,%.0f) menuActive=%d mouseActive=%d" + " hadGp=%d hadKb=%d ctx=%u", + touchDown ? 1 : 0, tx, ty, menuActive ? 1 : 0, mouseActive ? 1 : 0, + (g_actionInput && g_actionInput->HadGamepadInputThisFrame()) ? 1 : 0, + (g_actionInput && g_actionInput->HadKeyboardInputThisFrame()) ? 1 : 0, + static_cast(s_context)); + } + // Title screen "press any button": a tap anywhere becomes a Start press // (the custom menu is inactive during that phase, so taps aren't menu // clicks yet). Two frames so the pad edge is sampled reliably. From faea25741cb8421d96853da8a9dba94ddcf7c546 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Tue, 25 Aug 2026 18:14:08 -0300 Subject: [PATCH 08/31] Fix menu touch: activate mouse mode when a button/finger is held down (IsMouseButtonDown) --- src/extra/fecustommenumgr.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/extra/fecustommenumgr.cpp b/src/extra/fecustommenumgr.cpp index 62fc360..6653304 100644 --- a/src/extra/fecustommenumgr.cpp +++ b/src/extra/fecustommenumgr.cpp @@ -1158,6 +1158,17 @@ void feCustomMenuMgr::UpdateMouseCursorVisibility() { m_mouseInputActive = true; } + // A held mouse button / touchscreen finger must also wake the mouse path: + // Android feeds touch-as-mouse, and on a tap the position doesn't move and + // the click edge can be missed (e.g. finger down before the menu opens), + // which would otherwise leave m_mouseInputActive stuck false. + if (!m_mouseInputActive && g_actionInput->IsMouseButtonDown(MouseBtn::Left)) { + m_mouseInputActive = true; + if (g_display) { + g_display->SetCursorVisible(true); + } + } + if (nonMouseInput && m_mouseInputActive) { m_mouseInputActive = false; if (g_display) { From c11d01bf8d5d28ccb3b480e088db5c0c153842aa Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Tue, 25 Aug 2026 18:26:18 -0300 Subject: [PATCH 09/31] Add MenuMouse diagnostic: hit-test geometry and leftClick state in generic handler --- src/extra/fecustommenumgr.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/extra/fecustommenumgr.cpp b/src/extra/fecustommenumgr.cpp index 6653304..3c10e44 100644 --- a/src/extra/fecustommenumgr.cpp +++ b/src/extra/fecustommenumgr.cpp @@ -2127,6 +2127,23 @@ s32 feCustomMenuMgr::Invoke() { } } +#if defined(RC_PLATFORM_ANDROID) + // Touch->mouse diagnostics for the asset/menu screens: one line per + // click or per second so the hit-test geometry can be verified. + { + static s32 s_diagCounter = 0; + const bool clicked = leftClick || (++s_diagCounter % 60 == 0); + if (clicked) { + LOG("[MenuMouse] page=%d sx=%.0f sy=%.0f psx=(%.1f,%.1f) panel=(%d,%d)+%dx%d" + " frameH=%d cursor=%d leftClick=%d", + static_cast(m_currPage), sx, sy, psxX, psxY, + panelX, panelY, page->frameW, page->frameH, + m_currPage == MenuPage_AssetMissing ? (s32)page->frameH : 0, + m_cursor, leftClick ? 1 : 0); + } + } +#endif + if (leftClick) { PlaySound(FE_SND_MENU_5); Confirm(); From b3e917e32b62fb6c7db8c0374643d7e63be08d29 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Tue, 25 Aug 2026 18:38:37 -0300 Subject: [PATCH 10/31] =?UTF-8?q?Direct=20touch=20for=20menus:=20tap=20pos?= =?UTF-8?q?ition=20hit-tests=20entries=20and=20activates=20via=20Confirm()?= =?UTF-8?q?=20=E2=80=94=20no=20mouse=20emulation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/cpp/AndroidInput.cpp | 7 +++ src/extra/fecustommenumgr.cpp | 59 +++++++++++++++++++++ src/extra/fecustommenumgr.h | 4 ++ src/extra/touchhud.cpp | 4 ++ src/extra/touchhud.h | 4 ++ vendor/libp3d/pddi/gles/AndroidPlatform.cpp | 18 +++++++ vendor/libp3d/pddi/gles/AndroidPlatform.h | 5 ++ 7 files changed, 101 insertions(+) diff --git a/android/app/src/main/cpp/AndroidInput.cpp b/android/app/src/main/cpp/AndroidInput.cpp index caca6ba..b5726cb 100644 --- a/android/app/src/main/cpp/AndroidInput.cpp +++ b/android/app/src/main/cpp/AndroidInput.cpp @@ -184,6 +184,13 @@ bool HandleMotionEvent(AInputEvent* event) { (action == AMOTION_EVENT_ACTION_CANCEL) || (AMotionEvent_getPointerId(event, idx) == s_touchPointer); if (primaryLifted && s_touchPointer != -1) { + // A lift at the (possibly moved) position is a tap: publish + // it for direct menu hit-testing on the game thread. + const int32_t nidx = (action == AMOTION_EVENT_ACTION_UP) ? 0 : idx; + if (action != AMOTION_EVENT_ACTION_CANCEL) { + androidbridge::QueueTouchTapPos( + AMotionEvent_getX(event, nidx), AMotionEvent_getY(event, nidx)); + } s_touchPointer = -1; androidbridge::SetTouchMouse(0.0f, 0.0f, false); } diff --git a/src/extra/fecustommenumgr.cpp b/src/extra/fecustommenumgr.cpp index 3c10e44..10a8b52 100644 --- a/src/extra/fecustommenumgr.cpp +++ b/src/extra/fecustommenumgr.cpp @@ -18,6 +18,9 @@ #include "xclib/xclib.h" #include "gen/scoremgr.h" #include "gen/world.h" +#if defined(RC_PLATFORM_ANDROID) +#include "extra/touchhud.h" +#endif #include "p3d/texture.h" #include "pddi/pdditex.h" #include "pddi/pddishad.h" @@ -1177,6 +1180,57 @@ void feCustomMenuMgr::UpdateMouseCursorVisibility() { } } +#if defined(RC_PLATFORM_ANDROID) +void feCustomMenuMgr::ProcessTouchTaps() { + float tapX = 0.0f, tapY = 0.0f; + while (touchhud::ConsumeMenuTap(tapX, tapY)) { + const f32 screenW = g_display ? (f32)g_display->GetScreenWidth() : DEFAULT_SCREEN_WIDTH; + const f32 screenH = g_display ? (f32)g_display->GetScreenHeight() : DEFAULT_SCREEN_HEIGHT; + const f32 aspect = g_display ? g_display->GetAspectRatio() : (4.0f / 3.0f); +#if FIX_ASPECT_RATIO + const f32 effectiveW = screenW * DEFAULT_ASPECT_RATIO / aspect; + const f32 offsetX = (screenW - effectiveW) * 0.5f; + const f32 psxX = (tapX - offsetX) * DEFAULT_SCREEN_WIDTH / effectiveW; +#else + const f32 psxX = tapX * DEFAULT_SCREEN_WIDTH / screenW; +#endif + const f32 psxY = tapY * DEFAULT_SCREEN_HEIGHT / screenH; + + const PageDef& page = m_pages[m_currPage]; + const s32 panelX = DEF_WINDOW_CENTER_X - page.frameW / 2; + const s32 panelY = DEF_WINDOW_CENTER_Y - page.frameH / 2; + const s32 rowSpan = (page.numEntries > 0) ? ((page.numEntries - 1) * DEF_ROW_STEP) : 0; + const s32 extraH = CalcPageExtraHeight(page); + const s32 entryBlockH = DEF_CONTENT_PAD + rowSpan + DEF_ROW_TEXT_H + extraH; + const s32 bodyAvailH = page.frameH - DEF_TITLE_BAR_H - DEF_BOTTOM_BAR_H - DEF_CONTENT_TOP_PAD - DEF_CONTENT_BOTTOM_PAD; + const s32 bodyCenterPad = (bodyAvailH > entryBlockH) ? ((bodyAvailH - entryBlockH) / 2) : 0; + const s32 firstY = panelY + DEF_TITLE_BAR_H + DEF_CONTENT_TOP_PAD + bodyCenterPad + DEF_CONTENT_PAD; + const s32 baseLabelX = panelX + DEF_LABEL_X_PAD; + const s32 baseValueX = panelX + page.frameW - DEF_VALUE_X_PAD; + const s32 baseCenterX = DEF_WINDOW_CENTER_X; + + if (psxX >= (f32)panelX && psxX < (f32)(panelX + page.frameW)) { + for (s32 i = 0; i < page.numEntries; i++) { + s32 rowTop = 0; + ResolveEntryLayout(page, i, firstY, baseLabelX, baseValueX, baseCenterX, + &rowTop, nullptr, nullptr, nullptr, nullptr); + const s32 rowH = DEF_ROW_STEP + GetEntryExtraHeight(page, page.entries[i]); + if (psxY >= (f32)rowTop && psxY < (f32)(rowTop + rowH)) { + if (page.entries[i].type != EntryType_Info) { + LOG("[MenuTouch] tap page=%d entry=%d psx=(%.1f,%.1f) -> confirm", + (s32)m_currPage, i, psxX, psxY); + m_cursor = i; + PlaySound(FE_SND_MENU_5); + Confirm(); + } + break; + } + } + } + } +} +#endif + static constexpr f32 kPopupFadeSec = 0.15f; static constexpr f32 kPopupCloseGapSec = 0.2f; @@ -1215,6 +1269,11 @@ s32 feCustomMenuMgr::Invoke() { m_result = 1; UpdateMouseCursorVisibility(); +#if defined(RC_PLATFORM_ANDROID) + // Direct touchscreen taps against menu entries (no mouse emulation). + ProcessTouchTaps(); +#endif + // Popup overlay active: hold all normal page input/navigation until it // closes. Closing fade (+ post-fade gap) plays out first; GetPopupFadeAlpha() // clamps its ratio to kPopupFadeSec, so alpha stays at 0 for the gap portion. diff --git a/src/extra/fecustommenumgr.h b/src/extra/fecustommenumgr.h index 30c3a34..a909851 100644 --- a/src/extra/fecustommenumgr.h +++ b/src/extra/fecustommenumgr.h @@ -446,6 +446,10 @@ class feCustomMenuMgr { void GoBack(); void Adjust(s32 dir); void UpdateMouseCursorVisibility(); // mouse-move/click reactivation + keyboard/gamepad hide, shared by every page including non-interactive popups + + // Android: process queued touchscreen taps against the current page's + // entries (direct activation, no mouse emulation). + void ProcessTouchTaps(); s32 GetBoundValue(const Entry& e) const; void ApplyValue(const Entry& e, s32 v); void PlayValueChangeFeedback(const Entry& entry); diff --git a/src/extra/touchhud.cpp b/src/extra/touchhud.cpp index 35ae8cf..4955c69 100644 --- a/src/extra/touchhud.cpp +++ b/src/extra/touchhud.cpp @@ -122,6 +122,10 @@ HudContext GetContext() { return s_context; } +bool ConsumeMenuTap(float& x, float& y) { + return androidbridge::ConsumeTouchTapPos(&x, &y); +} + } // namespace touchhud // --- JNI surface for the Java touch overlay (com.deivid22srk.rechan.hud) --- diff --git a/src/extra/touchhud.h b/src/extra/touchhud.h index 5036d19..92a5344 100644 --- a/src/extra/touchhud.h +++ b/src/extra/touchhud.h @@ -26,6 +26,10 @@ void PublishFrame(); // Current context (also readable from any thread). HudContext GetContext(); +// Consume a pending menu tap (screen pixel position) published by the touch +// input pump. Used by feCustomMenuMgr for direct entry hit-testing. +bool ConsumeMenuTap(float& x, float& y); + } // namespace touchhud #endif // RC_PLATFORM_ANDROID diff --git a/vendor/libp3d/pddi/gles/AndroidPlatform.cpp b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp index e863249..f3dbc77 100644 --- a/vendor/libp3d/pddi/gles/AndroidPlatform.cpp +++ b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp @@ -19,6 +19,9 @@ std::atomic g_touchX{0.0f}; std::atomic g_touchY{0.0f}; std::atomic g_touchDown{false}; std::atomic g_tapQueued{false}; +std::atomic g_tapPosQueued{false}; +std::atomic g_tapPosX{0.0f}; +std::atomic g_tapPosY{0.0f}; } // namespace void SetNativeWindow(NativeWindowHandle window) { @@ -109,6 +112,21 @@ bool ConsumeTouchTap() { return g_tapQueued.exchange(false, std::memory_order_acq_rel); } +void QueueTouchTapPos(float x, float y) { + g_tapPosX.store(x, std::memory_order_relaxed); + g_tapPosY.store(y, std::memory_order_relaxed); + g_tapPosQueued.store(true, std::memory_order_release); +} + +bool ConsumeTouchTapPos(float* x, float* y) { + if (!g_tapPosQueued.exchange(false, std::memory_order_acq_rel)) { + return false; + } + if (x) x[0] = g_tapPosX.load(std::memory_order_relaxed); + if (y) y[0] = g_tapPosY.load(std::memory_order_relaxed); + return true; +} + static_assert(GamepadButton::COUNT <= 32, "buttons fit in u32 bitmask"); static_assert(GamepadAxis::COUNT == 6, "PadSnapshot axes size matches"); diff --git a/vendor/libp3d/pddi/gles/AndroidPlatform.h b/vendor/libp3d/pddi/gles/AndroidPlatform.h index c8f7ff4..0978fd2 100644 --- a/vendor/libp3d/pddi/gles/AndroidPlatform.h +++ b/vendor/libp3d/pddi/gles/AndroidPlatform.h @@ -70,4 +70,9 @@ bool GetTouchMouse(float* x, float* y); void QueueTouchTap(); bool ConsumeTouchTap(); +// Touch tap position for direct menu hit-testing (input thread → game thread). +// The game thread processes these each frame in the menu's Invoke(). +void QueueTouchTapPos(float x, float y); +bool ConsumeTouchTapPos(float* x, float* y); + } // namespace androidbridge From 3393b7b428c7aeab55019c88ddef073f00d80f6b Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Tue, 25 Aug 2026 18:47:49 -0300 Subject: [PATCH 11/31] Fix touch ACTION_UP not processed: switch was masking with POINTER_INDEX_MASK (0xff00) instead of ACTION_MASK (0xff) --- android/app/src/main/cpp/AndroidInput.cpp | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/cpp/AndroidInput.cpp b/android/app/src/main/cpp/AndroidInput.cpp index b5726cb..dbdb11a 100644 --- a/android/app/src/main/cpp/AndroidInput.cpp +++ b/android/app/src/main/cpp/AndroidInput.cpp @@ -151,11 +151,12 @@ bool HandleMotionEvent(AInputEvent* event) { // left-click) and queue taps for game-loop decisions (title screen). if ((source & AINPUT_SOURCE_TOUCHSCREEN) != 0) { const int32_t action = AMotionEvent_getAction(event); - switch (action & AMOTION_EVENT_ACTION_POINTER_INDEX_MASK) { + const int32_t actionCode = action & AMOTION_EVENT_ACTION_MASK; + const int32_t idx = (action & AMOTION_EVENT_ACTION_POINTER_INDEX_MASK) + >> AMOTION_EVENT_ACTION_POINTER_INDEX_SHIFT; + switch (actionCode) { case AMOTION_EVENT_ACTION_DOWN: case AMOTION_EVENT_ACTION_POINTER_DOWN: { - const int32_t idx = (action >> AMOTION_EVENT_ACTION_POINTER_INDEX_SHIFT) - & 0xff; if (s_touchPointer == -1) { s_touchPointer = AMotionEvent_getPointerId(event, idx); androidbridge::SetTouchMouse( @@ -177,19 +178,16 @@ bool HandleMotionEvent(AInputEvent* event) { case AMOTION_EVENT_ACTION_UP: case AMOTION_EVENT_ACTION_POINTER_UP: case AMOTION_EVENT_ACTION_CANCEL: { - const int32_t idx = (action >> AMOTION_EVENT_ACTION_POINTER_INDEX_SHIFT) - & 0xff; const bool primaryLifted = - (action == AMOTION_EVENT_ACTION_UP) || - (action == AMOTION_EVENT_ACTION_CANCEL) || + (actionCode == AMOTION_EVENT_ACTION_UP) || + (actionCode == AMOTION_EVENT_ACTION_CANCEL) || (AMotionEvent_getPointerId(event, idx) == s_touchPointer); if (primaryLifted && s_touchPointer != -1) { // A lift at the (possibly moved) position is a tap: publish // it for direct menu hit-testing on the game thread. - const int32_t nidx = (action == AMOTION_EVENT_ACTION_UP) ? 0 : idx; - if (action != AMOTION_EVENT_ACTION_CANCEL) { + if (actionCode != AMOTION_EVENT_ACTION_CANCEL) { androidbridge::QueueTouchTapPos( - AMotionEvent_getX(event, nidx), AMotionEvent_getY(event, nidx)); + AMotionEvent_getX(event, idx), AMotionEvent_getY(event, idx)); } s_touchPointer = -1; androidbridge::SetTouchMouse(0.0f, 0.0f, false); From 2d25c3a97e78abab2c55d7bd672e4d5ce982cc84 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Tue, 25 Aug 2026 18:58:39 -0300 Subject: [PATCH 12/31] =?UTF-8?q?Touch=20menus:=20don't=20emulate=20mouse?= =?UTF-8?q?=20button=20(position-only=20hover)=20=E2=80=94=20direct=20taps?= =?UTF-8?q?=20already=20activate,=20prevents=20double=20Confirm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/cpp/AndroidInput.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/cpp/AndroidInput.cpp b/android/app/src/main/cpp/AndroidInput.cpp index dbdb11a..53531ee 100644 --- a/android/app/src/main/cpp/AndroidInput.cpp +++ b/android/app/src/main/cpp/AndroidInput.cpp @@ -147,8 +147,10 @@ bool HandleKeyEvent(AInputEvent* event) { bool HandleMotionEvent(AInputEvent* event) { const int32_t source = AInputEvent_getSource(event); - // Touchscreen: emulate a mouse for the engine's own menu code (hover + - // left-click) and queue taps for game-loop decisions (title screen). + // Touchscreen: feed the mouse POSITION for hover only (button state is + // intentionally NOT emulated — direct taps handled by the menu's + // ProcessTouchTaps would otherwise double-activate alongside a mouse + // left-click Confirm). if ((source & AINPUT_SOURCE_TOUCHSCREEN) != 0) { const int32_t action = AMotionEvent_getAction(event); const int32_t actionCode = action & AMOTION_EVENT_ACTION_MASK; @@ -160,7 +162,7 @@ bool HandleMotionEvent(AInputEvent* event) { if (s_touchPointer == -1) { s_touchPointer = AMotionEvent_getPointerId(event, idx); androidbridge::SetTouchMouse( - AMotionEvent_getX(event, idx), AMotionEvent_getY(event, idx), true); + AMotionEvent_getX(event, idx), AMotionEvent_getY(event, idx), false); androidbridge::QueueTouchTap(); } return true; @@ -169,7 +171,7 @@ bool HandleMotionEvent(AInputEvent* event) { for (int32_t i = 0; i < AMotionEvent_getPointerCount(event); ++i) { if (AMotionEvent_getPointerId(event, i) == s_touchPointer) { androidbridge::SetTouchMouse( - AMotionEvent_getX(event, i), AMotionEvent_getY(event, i), true); + AMotionEvent_getX(event, i), AMotionEvent_getY(event, i), false); break; } } From 9349e75653090c311ec709e20138fe9023d3ffae Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Tue, 25 Aug 2026 19:08:46 -0300 Subject: [PATCH 13/31] Fix title Start pulse: also set PostGamepadConnected(true) so the pulse isn't ignored on touch-only devices --- src/extra/touchhud.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/extra/touchhud.cpp b/src/extra/touchhud.cpp index 4955c69..469270b 100644 --- a/src/extra/touchhud.cpp +++ b/src/extra/touchhud.cpp @@ -113,6 +113,10 @@ void PublishFrame() { } } if (s_startPulseFrames > 0) { + // The gamepad state is only polled by PlatformInput when connected is + // set; a touch-only device has no physical pad, so mark the virtual + // pad connected for the duration of the pulse. + androidbridge::PostGamepadConnected(true); androidbridge::PostGamepadButton(GamepadButton::Start, s_startPulseFrames == 2); --s_startPulseFrames; } From 03c1dfed434ab12b80c27ad17592b4876efa590d Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Tue, 25 Aug 2026 19:38:34 -0300 Subject: [PATCH 14/31] Engine touch controls: virtual joystick + action buttons rendered in-engine, movie skip button - Multi-touch slots in androidbridge (4 pointers) driven by AndroidInput - touchcontrols module: contextual controls (OnFoot full / Climbing reduced), fade transitions, virtual gamepad via androidbridge (same path as physical pad) - Render via ScreenDraw + TextManager labels in Display::EndFrame (on top) - Movie cutscene skip button (Start pulse) rendered+processed in PlayMovie loop --- android/app/src/main/cpp/AndroidInput.cpp | 148 ++++++--- src/extra/touchcontrols.cpp | 349 ++++++++++++++++++++ src/extra/touchcontrols.h | 36 ++ src/extra/touchhud.cpp | 4 + src/gen/display.cpp | 8 +- src/gen/game.cpp | 10 + vendor/libp3d/pddi/gles/AndroidPlatform.cpp | 36 ++ vendor/libp3d/pddi/gles/AndroidPlatform.h | 13 + 8 files changed, 555 insertions(+), 49 deletions(-) create mode 100644 src/extra/touchcontrols.cpp create mode 100644 src/extra/touchcontrols.h diff --git a/android/app/src/main/cpp/AndroidInput.cpp b/android/app/src/main/cpp/AndroidInput.cpp index 53531ee..30a4aad 100644 --- a/android/app/src/main/cpp/AndroidInput.cpp +++ b/android/app/src/main/cpp/AndroidInput.cpp @@ -71,8 +71,105 @@ int HatToButtons(float hatX, float hatY) { } // Pointer id of the first finger driving the emulated mouse (-1 = none). +// The multi-touch slots (androidbridge) track all fingers; the primary +// index is the first slot that was assigned (used for menu hover). int32_t s_touchPointer = -1; +// Find a free slot in the androidbridge multi-touch array, or return the +// slot that already holds the given pointer id. +static int32_t FindTouchSlot(int32_t pointerId, bool create) { + androidbridge::TouchPoint points[androidbridge::kMaxTouchPoints]; + const int32_t cnt = androidbridge::LoadTouchPoints(points, androidbridge::kMaxTouchPoints); + for (int32_t i = 0; i < cnt; ++i) { + if (points[i].id == pointerId) return i; + } + if (!create) return -1; + // First free slot + for (int32_t i = 0; i < androidbridge::kMaxTouchPoints; ++i) { + if (!points[i].active) return i; + } + return -1; // all slots full +} + +static void UpdateMouseFromPrimary() { + androidbridge::TouchPoint pts[androidbridge::kMaxTouchPoints]; + const int32_t cnt = androidbridge::LoadTouchPoints(pts, androidbridge::kMaxTouchPoints); + // The primary pointer is s_touchPointer; find its position. + for (int32_t i = 0; i < cnt; ++i) { + if (pts[i].id == s_touchPointer) { + androidbridge::SetTouchMouse(pts[i].x, pts[i].y, false); + return; + } + } + // Primary pointer went away — reset mouse. + s_touchPointer = -1; + androidbridge::SetTouchMouse(0.0f, 0.0f, false); +} + +static void HandleTouchEvent(AInputEvent* event) { + const int32_t action = AMotionEvent_getAction(event); + const int32_t actionCode = action & AMOTION_EVENT_ACTION_MASK; + const int32_t idx = (action & AMOTION_EVENT_ACTION_POINTER_INDEX_MASK) + >> AMOTION_EVENT_ACTION_POINTER_INDEX_SHIFT; + const int32_t pointerId = AMotionEvent_getPointerId(event, idx); + const float x = AMotionEvent_getX(event, idx); + const float y = AMotionEvent_getY(event, idx); + + switch (actionCode) { + case AMOTION_EVENT_ACTION_DOWN: + case AMOTION_EVENT_ACTION_POINTER_DOWN: { + int32_t slot = FindTouchSlot(-1, true); + if (slot >= 0) { + androidbridge::SetTouchPoint(slot, pointerId, x, y, true); + if (s_touchPointer == -1) { + s_touchPointer = pointerId; + androidbridge::SetTouchMouse(x, y, false); + androidbridge::QueueTouchTap(); + } + } + break; + } + case AMOTION_EVENT_ACTION_MOVE: { + for (int32_t i = 0; i < AMotionEvent_getPointerCount(event); ++i) { + const int32_t pid = AMotionEvent_getPointerId(event, i); + const int32_t slot = FindTouchSlot(pid, false); + if (slot >= 0) { + androidbridge::SetTouchPoint( + slot, pid, AMotionEvent_getX(event, i), AMotionEvent_getY(event, i), true); + } + } + UpdateMouseFromPrimary(); + break; + } + case AMOTION_EVENT_ACTION_UP: + case AMOTION_EVENT_ACTION_POINTER_UP: + case AMOTION_EVENT_ACTION_CANCEL: { + const int32_t slot = FindTouchSlot(pointerId, false); + if (slot >= 0) { + if (actionCode != AMOTION_EVENT_ACTION_CANCEL) { + androidbridge::QueueTouchTapPos(x, y); + } + androidbridge::ClearTouchPoint(slot); + if (pointerId == s_touchPointer) { + s_touchPointer = -1; + // If another finger is still down, adopt it as primary. + androidbridge::TouchPoint pts[androidbridge::kMaxTouchPoints]; + const int32_t cnt = androidbridge::LoadTouchPoints(pts, androidbridge::kMaxTouchPoints); + if (cnt > 0) { + s_touchPointer = pts[0].id; + androidbridge::SetTouchMouse(pts[0].x, pts[0].y, false); + } else { + androidbridge::SetTouchMouse(0.0f, 0.0f, false); + } + } + } + break; + } + default: + break; + } +} + void ApplyHatButtons(int32_t deviceId, int wanted) { // Per-device diff state: a pad without HAT axes keeps emitting 0 and must // not clear the D-pad held on a different controller. @@ -150,55 +247,10 @@ bool HandleMotionEvent(AInputEvent* event) { // Touchscreen: feed the mouse POSITION for hover only (button state is // intentionally NOT emulated — direct taps handled by the menu's // ProcessTouchTaps would otherwise double-activate alongside a mouse - // left-click Confirm). + // left-click Confirm). Multi-touch slots drive the on-screen controls. if ((source & AINPUT_SOURCE_TOUCHSCREEN) != 0) { - const int32_t action = AMotionEvent_getAction(event); - const int32_t actionCode = action & AMOTION_EVENT_ACTION_MASK; - const int32_t idx = (action & AMOTION_EVENT_ACTION_POINTER_INDEX_MASK) - >> AMOTION_EVENT_ACTION_POINTER_INDEX_SHIFT; - switch (actionCode) { - case AMOTION_EVENT_ACTION_DOWN: - case AMOTION_EVENT_ACTION_POINTER_DOWN: { - if (s_touchPointer == -1) { - s_touchPointer = AMotionEvent_getPointerId(event, idx); - androidbridge::SetTouchMouse( - AMotionEvent_getX(event, idx), AMotionEvent_getY(event, idx), false); - androidbridge::QueueTouchTap(); - } - return true; - } - case AMOTION_EVENT_ACTION_MOVE: { - for (int32_t i = 0; i < AMotionEvent_getPointerCount(event); ++i) { - if (AMotionEvent_getPointerId(event, i) == s_touchPointer) { - androidbridge::SetTouchMouse( - AMotionEvent_getX(event, i), AMotionEvent_getY(event, i), false); - break; - } - } - return true; - } - case AMOTION_EVENT_ACTION_UP: - case AMOTION_EVENT_ACTION_POINTER_UP: - case AMOTION_EVENT_ACTION_CANCEL: { - const bool primaryLifted = - (actionCode == AMOTION_EVENT_ACTION_UP) || - (actionCode == AMOTION_EVENT_ACTION_CANCEL) || - (AMotionEvent_getPointerId(event, idx) == s_touchPointer); - if (primaryLifted && s_touchPointer != -1) { - // A lift at the (possibly moved) position is a tap: publish - // it for direct menu hit-testing on the game thread. - if (actionCode != AMOTION_EVENT_ACTION_CANCEL) { - androidbridge::QueueTouchTapPos( - AMotionEvent_getX(event, idx), AMotionEvent_getY(event, idx)); - } - s_touchPointer = -1; - androidbridge::SetTouchMouse(0.0f, 0.0f, false); - } - return true; - } - default: - return true; - } + HandleTouchEvent(event); + return true; } if ((source & kSourceGamepadMask) == 0) { diff --git a/src/extra/touchcontrols.cpp b/src/extra/touchcontrols.cpp new file mode 100644 index 0000000..a1f45b6 --- /dev/null +++ b/src/extra/touchcontrols.cpp @@ -0,0 +1,349 @@ +// touchcontrols.cpp — on-screen touch controls rendered in-engine. +#include "extra/touchcontrols.h" + +#if defined(RC_PLATFORM_ANDROID) + +#include "extra/touchhud.h" +#include "gen/display.h" +#include "gen/common.h" +#include "gen/time.h" +#include "pc/tim.h" +#include "pc/textmgr.h" +#include "pddi/gles/AndroidPlatform.h" +#include "pddi/pddidev.h" + +#include +#include +#include + +namespace touchcontrols { + +using touchhud::HudContext; + +// --------------------------------------------------------------------------- +// Control definitions (normalized positions; radius relative to min(w,h)) +// --------------------------------------------------------------------------- + +struct Control { + const char* id; + const char* label; + int buttonId; // GamepadButton::* or -1 for joystick + float nx, ny; // normalized center (0..1) + float nr; // normalized radius +}; + +static const Control kControls[] = { + {"joy", "", -1, 0.120f, 0.700f, 0.105f}, + {"A", "A", GamepadButton::A, 0.865f, 0.775f, 0.072f}, + {"B", "B", GamepadButton::B, 0.775f, 0.685f, 0.062f}, + {"X", "X", GamepadButton::X, 0.875f, 0.600f, 0.062f}, + {"Y", "Y", GamepadButton::Y, 0.965f, 0.690f, 0.062f}, + {"RB", "R1", GamepadButton::RightBumper, 0.950f, 0.520f, 0.050f}, + {"LB", "L1", GamepadButton::LeftBumper, 0.045f, 0.280f, 0.050f}, + {"ST", "≡", GamepadButton::Start, 0.500f, 0.940f, 0.042f}, +}; +static constexpr int32_t kControlCount = sizeof(kControls) / sizeof(kControls[0]); +static constexpr int32_t kJoyIdx = 0; + +// --------------------------------------------------------------------------- +// Runtime layout +// --------------------------------------------------------------------------- + +static float s_screenW = 0.0f; +static float s_screenH = 0.0f; +static float s_cx[kControlCount]; +static float s_cy[kControlCount]; +static float s_cr[kControlCount]; // touch reach (pixels) +static float s_joyBaseR = 0.0f; // joystick base radius (pixels) + +static void RecomputeLayout() { + const f32 w = SCREEN_WIDTH; + const f32 h = SCREEN_HEIGHT; + if (w == s_screenW && h == s_screenH) return; + s_screenW = w; + s_screenH = h; + const f32 base = std::min(w, h); + for (int32_t i = 0; i < kControlCount; ++i) { + s_cx[i] = kControls[i].nx * w; + s_cy[i] = kControls[i].ny * h; + s_cr[i] = kControls[i].nr * base * 1.35f; + } + s_joyBaseR = kControls[kJoyIdx].nr * base; +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +static bool s_visible = false; +static float s_alpha = 0.0f; +static bool s_btnDown[kControlCount] = {}; +static int32_t s_btnSlot[kControlCount]; // touch slot per control, -1 = none +static int32_t s_joySlot = -1; // touch slot dragging joystick +static float s_knobX = 0.0f; +static float s_knobY = 0.0f; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static int32_t FindControlAt(float x, float y) { + for (int32_t i = 0; i < kControlCount; ++i) { + const float dx = x - s_cx[i]; + const float dy = y - s_cy[i]; + if (dx * dx + dy * dy <= s_cr[i] * s_cr[i]) return i; + } + return -1; +} + +static void ResetAll() { + for (int32_t i = 0; i < kControlCount; ++i) { + if (kControls[i].buttonId >= 0 && s_btnDown[i]) { + androidbridge::PostGamepadButton(kControls[i].buttonId, false); + s_btnDown[i] = false; + s_btnSlot[i] = -1; + } + } + if (s_joySlot >= 0) { + androidbridge::PostGamepadAxis(GamepadAxis::LeftX, 0.0f); + androidbridge::PostGamepadAxis(GamepadAxis::LeftY, 0.0f); + s_joySlot = -1; + s_knobX = s_knobY = 0.0f; + } +} + +static bool IsControlInteractive(int32_t ci, bool climbing) { + if (ci == kJoyIdx) return true; + if (!climbing) return true; + return std::strcmp(kControls[ci].id, "A") == 0; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +void Update() { + RecomputeLayout(); + + const HudContext ctx = touchhud::GetContext(); + const bool wantVisible = (ctx == HudContext::OnFoot || ctx == HudContext::Climbing); + if (wantVisible && !s_visible) { + ResetAll(); + } + s_visible = wantVisible; + + // Fade + const f32 dt = g_time ? g_time->GetDeltaTime() : (1.0f / 30.0f); + const f32 fRate = 4.0f * dt; + if (s_visible && s_alpha < 1.0f) { + s_alpha += fRate; + if (s_alpha > 1.0f) s_alpha = 1.0f; + } + else if (!s_visible && s_alpha > 0.0f) { + s_alpha -= fRate; + if (s_alpha < 0.0f) { s_alpha = 0.0f; ResetAll(); } + } + + if (!s_visible && s_alpha <= 0.0f) return; + + androidbridge::TouchPoint pts[androidbridge::kMaxTouchPoints]; + const int32_t ptCount = androidbridge::LoadTouchPoints(pts, androidbridge::kMaxTouchPoints); + + const bool climbing = (ctx == HudContext::Climbing); + + // 1) Release any button whose touch slot is gone. + for (int32_t ci = 0; ci < kControlCount; ++ci) { + if (kControls[ci].buttonId < 0 || !s_btnDown[ci]) continue; + const int32_t slot = s_btnSlot[ci]; + const bool stillHeld = (slot >= 0 && slot < ptCount && pts[slot].active); + if (!stillHeld) { + s_btnDown[ci] = false; + s_btnSlot[ci] = -1; + androidbridge::PostGamepadButton(kControls[ci].buttonId, false); + } + } + + // Release joystick if its slot is gone. + if (s_joySlot >= 0 && !(s_joySlot < ptCount && pts[s_joySlot].active)) { + androidbridge::PostGamepadAxis(GamepadAxis::LeftX, 0.0f); + androidbridge::PostGamepadAxis(GamepadAxis::LeftY, 0.0f); + s_joySlot = -1; + s_knobX = s_knobY = 0.0f; + } + + // 2) Assign/update active pointers. + bool anyActive = false; + for (int32_t pi = 0; pi < ptCount; ++pi) { + if (!pts[pi].active) continue; + + // Joystick follow + if (s_joySlot == pi) { + const float dx = pts[pi].x - s_cx[kJoyIdx]; + const float dy = pts[pi].y - s_cy[kJoyIdx]; + const float r = s_joyBaseR; + float mag = sqrtf(dx * dx + dy * dy); + float nx = dx, ny = dy; + if (mag > r) { nx = dx / mag * r; ny = dy / mag * r; } + s_knobX = nx / r; + s_knobY = ny / r; + androidbridge::PostGamepadAxis(GamepadAxis::LeftX, s_knobX); + androidbridge::PostGamepadAxis(GamepadAxis::LeftY, s_knobY); + anyActive = true; + continue; + } + + // Already holding a button with this pointer? + bool handled = false; + for (int32_t ci = 0; ci < kControlCount; ++ci) { + if (kControls[ci].buttonId >= 0 && s_btnDown[ci] && s_btnSlot[ci] == pi) { + handled = true; + anyActive = true; + break; + } + } + if (handled) continue; + + // Hit-test a new control. + const int32_t hit = FindControlAt(pts[pi].x, pts[pi].y); + if (hit < 0 || !IsControlInteractive(hit, climbing)) continue; + + if (kControls[hit].buttonId >= 0) { + s_btnDown[hit] = true; + s_btnSlot[hit] = pi; + androidbridge::PostGamepadButton(kControls[hit].buttonId, true); + anyActive = true; + } + else if (hit == kJoyIdx && s_joySlot == -1) { + s_joySlot = pi; + const float dx = pts[pi].x - s_cx[kJoyIdx]; + const float dy = pts[pi].y - s_cy[kJoyIdx]; + s_knobX = dx / s_joyBaseR; + s_knobY = dy / s_joyBaseR; + if (s_knobX > 1.0f) s_knobX = 1.0f; + if (s_knobX < -1.0f) s_knobX = -1.0f; + if (s_knobY > 1.0f) s_knobY = 1.0f; + if (s_knobY < -1.0f) s_knobY = -1.0f; + androidbridge::PostGamepadAxis(GamepadAxis::LeftX, s_knobX); + androidbridge::PostGamepadAxis(GamepadAxis::LeftY, s_knobY); + anyActive = true; + } + } + + if (anyActive) { + androidbridge::PostGamepadConnected(true); + } +} + +void Render() { + if (s_alpha <= 0.01f) return; + RecomputeLayout(); + + ScreenDraw::Batch batch; + const u8 baseA = static_cast(s_alpha * 170.0f); + const u8 fillA = static_cast(s_alpha * 110.0f); + const u8 knobA = static_cast(s_alpha * 230.0f); + const f32 w = SCREEN_WIDTH; + const f32 h = SCREEN_HEIGHT; + const f32 base = std::min(w, h); + + const bool climbing = (touchhud::GetContext() == HudContext::Climbing); + + TextManager* tm = g_textManager; + if (tm) { + tm->SetFontByName("Menu"); + tm->SetColor(255, 255, 255, 255); + } + + for (int32_t i = 0; i < kControlCount; ++i) { + if (!IsControlInteractive(i, climbing)) continue; + + const float x = s_cx[i]; + const float y = s_cy[i]; + const float r = kControls[i].nr * base; + + if (kControls[i].buttonId >= 0) { + const bool pressed = s_btnDown[i]; + ScreenDraw::DrawFilledCircle(x, y, r, r, 255, 255, 255, + pressed ? (u8)(fillA + 90) : fillA); + ScreenDraw::DrawCircle(x, y, r, r, std::max(2.0f, r * 0.06f), 255, 255, 255, baseA); + if (tm) { + const f32 textSize = r * 0.85f; + tm->SetScale(textSize / 48.0f, textSize / 48.0f); + const TextBounds b = tm->MeasureString(kControls[i].label); + tm->PrintString(kControls[i].label, + x - b.width / 2.0f, + y - b.height / 2.0f); + } + } + else { + // Joystick base + knob + ScreenDraw::DrawFilledCircle(x, y, r, r, 255, 255, 255, baseA); + ScreenDraw::DrawCircle(x, y, r, r, std::max(2.0f, r * 0.04f), 255, 255, 255, 200); + const float knobR = r * 0.45f; + ScreenDraw::DrawFilledCircle(x + s_knobX * r, y + s_knobY * r, + knobR, knobR, 255, 255, 255, knobA); + } + } +} + +void Reset() { + ResetAll(); + s_visible = false; + s_alpha = 0.0f; +} + +// --------------------------------------------------------------------------- +// Movie / cutscene skip +// --------------------------------------------------------------------------- + +static void DoSkip() { + androidbridge::PostGamepadConnected(true); + androidbridge::PostGamepadButton(GamepadButton::Start, true); +} + +void UpdateMovieSkip() { + RecomputeLayout(); + + androidbridge::TouchPoint pts[androidbridge::kMaxTouchPoints]; + const int32_t ptCount = androidbridge::LoadTouchPoints(pts, androidbridge::kMaxTouchPoints); + + const float skipX = SCREEN_WIDTH * 0.90f; + const float skipY = SCREEN_HEIGHT * 0.05f; + const float skipR = std::min(SCREEN_WIDTH, SCREEN_HEIGHT) * 0.055f; + + for (int32_t i = 0; i < ptCount; ++i) { + if (!pts[i].active) continue; + const float dx = pts[i].x - skipX; + const float dy = pts[i].y - skipY; + if (dx * dx + dy * dy <= skipR * skipR * 2.0f) { + DoSkip(); + return; + } + } +} + +void RenderMovieSkip() { + ScreenDraw::Batch batch; + + const float skipX = SCREEN_WIDTH * 0.90f; + const float skipY = SCREEN_HEIGHT * 0.05f; + const float r = std::min(SCREEN_WIDTH, SCREEN_HEIGHT) * 0.055f; + + ScreenDraw::DrawFilledCircle(skipX, skipY, r, r, 255, 255, 255, 170); + ScreenDraw::DrawCircle(skipX, skipY, r, r, std::max(2.0f, r * 0.06f), 255, 255, 255, 210); + + if (g_textManager) { + g_textManager->SetFontByName("Menu"); + g_textManager->SetScale(r / 20.0f, r / 20.0f); + g_textManager->SetColor(255, 255, 255, 255); + const char* skipLabel = "Pular"; + const TextBounds b = g_textManager->MeasureString(skipLabel); + g_textManager->PrintString(skipLabel, + skipX - b.width / 2.0f, + skipY - b.height / 2.0f); + } +} + +} // namespace touchcontrols + +#endif // RC_PLATFORM_ANDROID diff --git a/src/extra/touchcontrols.h b/src/extra/touchcontrols.h new file mode 100644 index 0000000..5da2854 --- /dev/null +++ b/src/extra/touchcontrols.h @@ -0,0 +1,36 @@ +// touchcontrols.h — on-screen touch controls rendered by the engine itself +// (Android only). Virtual joystick + action buttons feed the shared +// androidbridge gamepad state, exactly like a physical gamepad. No Java +// overlay involved: input is multi-touch from the NativeActivity input queue +// and rendering uses the engine's own 2D overlay (ScreenDraw). +#pragma once + +#include "core.h" + +#if defined(RC_PLATFORM_ANDROID) + +namespace touchcontrols { + +// Process the current multi-touch state against the visible controls and +// publish virtual gamepad state. Called once per game-loop frame. +void Update(); + +// Render the gameplay touch controls overlay. Called from Display::EndFrame +// just before SwapBuffers so it draws on top of everything. +void Render(); + +// Reset every held virtual button/axis (context switch, pause, gamepad plug). +void Reset(); + +// --- Cutscene / movie skip ------------------------------------------------- + +// Process touches against the "skip" button (used inside the blocking +// PlayMovie loop where the main loop doesn't run). +void UpdateMovieSkip(); + +// Render the skip button (same context as UpdateMovieSkip). +void RenderMovieSkip(); + +} // namespace touchcontrols + +#endif // RC_PLATFORM_ANDROID diff --git a/src/extra/touchhud.cpp b/src/extra/touchhud.cpp index 469270b..06af829 100644 --- a/src/extra/touchhud.cpp +++ b/src/extra/touchhud.cpp @@ -17,6 +17,7 @@ #include "ai/player.h" #include "ai/humanoid.h" #include "extra/fecustommenumgr.h" +#include "extra/touchcontrols.h" #include "pddi/gles/AndroidPlatform.h" namespace touchhud { @@ -87,6 +88,9 @@ void PublishFrame() { s_context = ComputeContext(); androidbridge::SetHudContext(static_cast(s_context)); + // On-screen touch controls (joystick + action buttons). + touchcontrols::Update(); + // Touch diagnostics (throttled): confirm the touch->mouse chain end to end. static s32 s_diagCounter = 0; if ((s_diagCounter++ & 0x3F) == 0) { diff --git a/src/gen/display.cpp b/src/gen/display.cpp index 501029e..5fd0e2f 100644 --- a/src/gen/display.cpp +++ b/src/gen/display.cpp @@ -9,6 +9,9 @@ #include "p3d/camera.h" #include "pddi/pddi.h" #include "pddi/pddidev.h" +#if defined(RC_PLATFORM_ANDROID) +#include "extra/touchcontrols.h" +#endif Display* g_display = nullptr; s32 Display::s_defaultScreenMode = ScreenMode_Windowed; @@ -327,7 +330,10 @@ void Display::EndFrame() { ++frameCounter; p3d::context->EndFrame(); p3d::display->RenderOverlay(); -#if defined(RC_PLATFORM_SWITCH) +#if defined(RC_PLATFORM_ANDROID) + // On-screen touch controls drawn on top of the game frame. + touchcontrols::Render(); +#elif defined(RC_PLATFORM_SWITCH) DrawDebugInfo(); #endif const f64 t0 = Time::GetTimeInSeconds(); diff --git a/src/gen/game.cpp b/src/gen/game.cpp index 5dc5e37..ac27436 100644 --- a/src/gen/game.cpp +++ b/src/gen/game.cpp @@ -1,6 +1,9 @@ // game.cpp #include "common.h" #include "gen/game.h" +#if defined(RC_PLATFORM_ANDROID) +#include "extra/touchcontrols.h" +#endif #include "gen/display.h" #include "gen/envmgr.h" #include "gen/camera.h" @@ -2324,8 +2327,15 @@ void Game::PlayMovie(const char* name, s32 skippable, s32 unloadLevel) { player->AdvanceFrame(); +#if defined(RC_PLATFORM_ANDROID) + touchcontrols::UpdateMovieSkip(); +#endif + g_display->BeginFrame(); player->Render(); +#if defined(RC_PLATFORM_ANDROID) + touchcontrols::RenderMovieSkip(); +#endif g_display->EndFrame(); } } diff --git a/vendor/libp3d/pddi/gles/AndroidPlatform.cpp b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp index f3dbc77..e28de73 100644 --- a/vendor/libp3d/pddi/gles/AndroidPlatform.cpp +++ b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp @@ -22,6 +22,15 @@ std::atomic g_tapQueued{false}; std::atomic g_tapPosQueued{false}; std::atomic g_tapPosX{0.0f}; std::atomic g_tapPosY{0.0f}; + +// Multi-touch slots for the on-screen controls. +struct TouchSlot { + std::atomic id{-1}; + std::atomic x{0.0f}; + std::atomic y{0.0f}; + std::atomic active{false}; +}; +TouchSlot g_touchSlots[kMaxTouchPoints]; } // namespace void SetNativeWindow(NativeWindowHandle window) { @@ -127,6 +136,33 @@ bool ConsumeTouchTapPos(float* x, float* y) { return true; } +void SetTouchPoint(int32_t slot, int32_t id, float x, float y, bool active) { + if (slot < 0 || slot >= kMaxTouchPoints) return; + g_touchSlots[slot].id.store(id, std::memory_order_relaxed); + g_touchSlots[slot].x.store(x, std::memory_order_relaxed); + g_touchSlots[slot].y.store(y, std::memory_order_relaxed); + g_touchSlots[slot].active.store(active, std::memory_order_relaxed); +} + +void ClearTouchPoint(int32_t slot) { + if (slot < 0 || slot >= kMaxTouchPoints) return; + g_touchSlots[slot].id.store(-1, std::memory_order_relaxed); + g_touchSlots[slot].active.store(false, std::memory_order_relaxed); +} + +int32_t LoadTouchPoints(TouchPoint* out, int32_t max) { + int32_t count = 0; + for (int32_t s = 0; s < kMaxTouchPoints && count < max; ++s) { + if (!g_touchSlots[s].active.load(std::memory_order_relaxed)) continue; + TouchPoint& p = out[count++]; + p.id = g_touchSlots[s].id.load(std::memory_order_relaxed); + p.x = g_touchSlots[s].x.load(std::memory_order_relaxed); + p.y = g_touchSlots[s].y.load(std::memory_order_relaxed); + p.active = true; + } + return count; +} + static_assert(GamepadButton::COUNT <= 32, "buttons fit in u32 bitmask"); static_assert(GamepadAxis::COUNT == 6, "PadSnapshot axes size matches"); diff --git a/vendor/libp3d/pddi/gles/AndroidPlatform.h b/vendor/libp3d/pddi/gles/AndroidPlatform.h index 0978fd2..58c6b7a 100644 --- a/vendor/libp3d/pddi/gles/AndroidPlatform.h +++ b/vendor/libp3d/pddi/gles/AndroidPlatform.h @@ -65,6 +65,19 @@ void SetTouchMouse(float x, float y, bool down); // Returns true while a finger is down; always writes the latest position. bool GetTouchMouse(float* x, float* y); +// --- Multi-touch for on-screen controls (input thread -> game thread) --- +static constexpr int32_t kMaxTouchPoints = 4; +struct TouchPoint { + int32_t id = -1; // Android pointer id, -1 = slot free + float x = 0.0f; // surface pixel coordinates + float y = 0.0f; + bool active = false; +}; + +void SetTouchPoint(int32_t slot, int32_t id, float x, float y, bool active); +void ClearTouchPoint(int32_t slot); +int32_t LoadTouchPoints(TouchPoint* out, int32_t max); + // One-shot tap notification consumed by the game loop (used to turn a tap // into a Start press on the "press any button" title screen). void QueueTouchTap(); From 0cd9d6b46b100947265029834deaba5b505d52a7 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Tue, 25 Aug 2026 19:54:57 -0300 Subject: [PATCH 15/31] Fix touch overlay visibility + skip button: render in HUD::Display (correct render-target state), center labels via TextAlign_Center/SCREEN_SCALE --- src/extra/touchcontrols.cpp | 20 +++++++++++--------- src/fe/hud.cpp | 8 ++++++++ src/gen/display.cpp | 8 +------- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/extra/touchcontrols.cpp b/src/extra/touchcontrols.cpp index a1f45b6..b1aa328 100644 --- a/src/extra/touchcontrols.cpp +++ b/src/extra/touchcontrols.cpp @@ -252,6 +252,7 @@ void Render() { if (tm) { tm->SetFontByName("Menu"); tm->SetColor(255, 255, 255, 255); + tm->SetPromptsEnabled(true); } for (int32_t i = 0; i < kControlCount; ++i) { @@ -267,12 +268,12 @@ void Render() { pressed ? (u8)(fillA + 90) : fillA); ScreenDraw::DrawCircle(x, y, r, r, std::max(2.0f, r * 0.06f), 255, 255, 255, baseA); if (tm) { - const f32 textSize = r * 0.85f; - tm->SetScale(textSize / 48.0f, textSize / 48.0f); + // Center the label on the button, matching the menu's text pattern. + tm->SetAlignment(TextAlign_Center); + const f32 textScale = (r * 1.4f) / 48.0f; + tm->SetScale(SCREEN_SCALE_Y(textScale), SCREEN_SCALE_Y(textScale)); const TextBounds b = tm->MeasureString(kControls[i].label); - tm->PrintString(kControls[i].label, - x - b.width / 2.0f, - y - b.height / 2.0f); + tm->PrintString(kControls[i].label, x, y - b.height / 2.0f); } } else { @@ -334,13 +335,14 @@ void RenderMovieSkip() { if (g_textManager) { g_textManager->SetFontByName("Menu"); - g_textManager->SetScale(r / 20.0f, r / 20.0f); g_textManager->SetColor(255, 255, 255, 255); + g_textManager->SetPromptsEnabled(true); + g_textManager->SetAlignment(TextAlign_Center); + const f32 textScale = (r * 1.6f) / 48.0f; + g_textManager->SetScale(SCREEN_SCALE_Y(textScale), SCREEN_SCALE_Y(textScale)); const char* skipLabel = "Pular"; const TextBounds b = g_textManager->MeasureString(skipLabel); - g_textManager->PrintString(skipLabel, - skipX - b.width / 2.0f, - skipY - b.height / 2.0f); + g_textManager->PrintString(skipLabel, skipX, skipY - b.height / 2.0f); } } diff --git a/src/fe/hud.cpp b/src/fe/hud.cpp index 32b6e41..c41d096 100644 --- a/src/fe/hud.cpp +++ b/src/fe/hud.cpp @@ -7,6 +7,9 @@ #if CUSTOM_MENU #include "extra/customtext.h" #include "extra/customhudmgr.h" +#if defined(RC_PLATFORM_ANDROID) +#include "extra/touchcontrols.h" +#endif #endif #include "xclib/xclib.h" #include "ai/humanoid.h" @@ -126,6 +129,11 @@ void HUD::Display() { // all original HUD state/update behavior. #if CUSTOM_MENU g_customHudMgr.Render(*this); +#if defined(RC_PLATFORM_ANDROID) + // On-screen touch controls drawn right after the game HUD (same overlay + // state, so they appear on top of the game frame). + touchcontrols::Render(); +#endif #else if (section) { #if FIX_ASPECT_RATIO diff --git a/src/gen/display.cpp b/src/gen/display.cpp index 5fd0e2f..501029e 100644 --- a/src/gen/display.cpp +++ b/src/gen/display.cpp @@ -9,9 +9,6 @@ #include "p3d/camera.h" #include "pddi/pddi.h" #include "pddi/pddidev.h" -#if defined(RC_PLATFORM_ANDROID) -#include "extra/touchcontrols.h" -#endif Display* g_display = nullptr; s32 Display::s_defaultScreenMode = ScreenMode_Windowed; @@ -330,10 +327,7 @@ void Display::EndFrame() { ++frameCounter; p3d::context->EndFrame(); p3d::display->RenderOverlay(); -#if defined(RC_PLATFORM_ANDROID) - // On-screen touch controls drawn on top of the game frame. - touchcontrols::Render(); -#elif defined(RC_PLATFORM_SWITCH) +#if defined(RC_PLATFORM_SWITCH) DrawDebugInfo(); #endif const f64 t0 = Time::GetTimeInSeconds(); From 3c4431e2cbbc71f7dd42d07ddfd7ea793ce71b9c Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Wed, 26 Aug 2026 00:18:59 +0000 Subject: [PATCH 16/31] Fix contextual HUD: visible labels + Menu context support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the invisible 'Pular' / A / B / X / Y labels: ScreenDraw::DrawFilledCircle takes (centerX, centerY, radiusX, radiusY, u0, v0, u1, v1, segments, r, g, b, a). The previous call passed (x, y, r, r, 255, 255, 255, alpha) — the trailing 4 args landed in the u0/v0/u1/v1 UV slots, leaving the color at its default (255,255,255,255) (opaque white). White text on an opaque white circle was camouflaged, so the labels looked like they were rendered behind the button. Fixes: - Add TouchColor struct + dark translucent palette (kFill, kRing, kKnob, kSkipFill, kSkipRing, kText) plus FadeAlpha() helper that scales the alpha channel by the current HUD fade. - Add FillCircle()/RingCircle() wrappers that pass all 13 positional args explicitly, so the color actually reaches the (r,g,b,a) tail. - Render() and RenderMovieSkip() now draw a dark filled circle + light ring + white label on top, matching the PPSSPP/AetherSX2 overlay look. - Render() reorganized into two passes: pass 1 draws every visible control's background (filled circle + ring + joystick knob); pass 2 draws the labels. Guarantees consistent ordering regardless of how the text backend's texture changes trigger pending-batch flushes. Missing HUD on Destination Select (and other menus): - touchcontrols::Update() only set s_visible for OnFoot / Climbing. GameState::LocationMenu -> HudContext::Menu -> HUD hidden. - IsControlInteractive(ci, climbing) refactored to take HudContext; Menu context now exposes joystick (d-pad nav) + A (confirm) + B (back) + Start (pause/title-confirm). X/Y/LB/RB are hidden. - Update() now includes HudContext::Menu in wantVisible, so the contextual HUD appears on the title screen and destination select. --- src/extra/touchcontrols.cpp | 157 +++++++++++++++++++++++++++++------- 1 file changed, 126 insertions(+), 31 deletions(-) diff --git a/src/extra/touchcontrols.cpp b/src/extra/touchcontrols.cpp index b1aa328..963b6ba 100644 --- a/src/extra/touchcontrols.cpp +++ b/src/extra/touchcontrols.cpp @@ -45,6 +45,54 @@ static const Control kControls[] = { static constexpr int32_t kControlCount = sizeof(kControls) / sizeof(kControls[0]); static constexpr int32_t kJoyIdx = 0; +// --------------------------------------------------------------------------- +// Visual style — dark translucent fills + light outlines so the white labels +// stay readable on top of the game frame. Matches the look of common mobile +// touch overlays (PPSSPP / AetherSX2 style) and avoids the previous issue +// where the white "Pular" / A / B / X / Y labels were camouflaged against a +// white circle background and looked like they were rendered behind. +// --------------------------------------------------------------------------- + +struct TouchColor { + u8 r, g, b, a; +}; + +static constexpr TouchColor kFill = { 30, 30, 46, 175 }; // dark blue-gray, semi-transparent +static constexpr TouchColor kFillPressed = { 60, 60, 90, 220 }; // brighter when held +static constexpr TouchColor kRing = { 220, 220, 230, 230 }; // light outline +static constexpr TouchColor kRingJoystick = { 200, 200, 215, 200 }; +static constexpr TouchColor kKnob = { 235, 235, 245, 230 }; +static constexpr TouchColor kText = { 255, 255, 255, 255 }; +static constexpr TouchColor kSkipFill = { 30, 30, 46, 200 }; +static constexpr TouchColor kSkipRing = { 220, 220, 230, 235 }; +static constexpr TouchColor kSkipText = { 255, 255, 255, 255 }; + +// Scale a color's alpha channel by the current HUD fade. +static inline u8 FadeAlpha(u8 a, float fade) { + const int v = static_cast(a) * static_cast(fade * 255.0f + 0.5f) / 255; + return static_cast(std::min(255, std::max(0, v))); +} + +// Thin wrappers that pass all positional args to ScreenDraw's circle helpers +// so the color lands in the (r,g,b,a) tail, not in the UV slots. The previous +// code called DrawFilledCircle(x, y, r, r, R, G, B, A) — those trailing 4 +// args matched u0/v0/u1/v1, leaving the color at its default (255,255,255,255), +// which is why the "Pular" / A / B / X / Y labels were camouflaged against an +// opaque white circle and looked like they were rendered behind the button. +static inline void FillCircle(float x, float y, float rx, float ry, + const TouchColor& c) { + ScreenDraw::DrawFilledCircle(x, y, rx, ry, + 0.0f, 0.0f, 1.0f, 1.0f, 32, + c.r, c.g, c.b, c.a); +} + +static inline void RingCircle(float x, float y, float rx, float ry, + float thickness, const TouchColor& c) { + ScreenDraw::DrawCircle(x, y, rx, ry, thickness, + 0.0f, 0.0f, 1.0f, 1.0f, 32, + c.r, c.g, c.b, c.a); +} + // --------------------------------------------------------------------------- // Runtime layout // --------------------------------------------------------------------------- @@ -112,10 +160,26 @@ static void ResetAll() { } } -static bool IsControlInteractive(int32_t ci, bool climbing) { +// Returns whether a control is part of the visible set for the given context. +// - OnFoot : full controller (joy + A/B/X/Y + LB/RB + Start) +// - Climbing : reduced set — joystick + A only +// - Menu : navigation set — joystick (d-pad style) + A (confirm) + +// B (back) + Start (pause/title-confirm) +static bool IsControlInteractive(int32_t ci, HudContext ctx) { if (ci == kJoyIdx) return true; - if (!climbing) return true; - return std::strcmp(kControls[ci].id, "A") == 0; + + switch (ctx) { + case HudContext::OnFoot: + return true; + case HudContext::Climbing: + return std::strcmp(kControls[ci].id, "A") == 0; + case HudContext::Menu: + return std::strcmp(kControls[ci].id, "A") == 0 + || std::strcmp(kControls[ci].id, "B") == 0 + || std::strcmp(kControls[ci].id, "ST") == 0; + default: + return false; + } } // --------------------------------------------------------------------------- @@ -126,7 +190,9 @@ void Update() { RecomputeLayout(); const HudContext ctx = touchhud::GetContext(); - const bool wantVisible = (ctx == HudContext::OnFoot || ctx == HudContext::Climbing); + const bool wantVisible = (ctx == HudContext::OnFoot + || ctx == HudContext::Climbing + || ctx == HudContext::Menu); if (wantVisible && !s_visible) { ResetAll(); } @@ -149,8 +215,6 @@ void Update() { androidbridge::TouchPoint pts[androidbridge::kMaxTouchPoints]; const int32_t ptCount = androidbridge::LoadTouchPoints(pts, androidbridge::kMaxTouchPoints); - const bool climbing = (ctx == HudContext::Climbing); - // 1) Release any button whose touch slot is gone. for (int32_t ci = 0; ci < kControlCount; ++ci) { if (kControls[ci].buttonId < 0 || !s_btnDown[ci]) continue; @@ -205,7 +269,7 @@ void Update() { // Hit-test a new control. const int32_t hit = FindControlAt(pts[pi].x, pts[pi].y); - if (hit < 0 || !IsControlInteractive(hit, climbing)) continue; + if (hit < 0 || !IsControlInteractive(hit, ctx)) continue; if (kControls[hit].buttonId >= 0) { s_btnDown[hit] = true; @@ -239,24 +303,25 @@ void Render() { RecomputeLayout(); ScreenDraw::Batch batch; - const u8 baseA = static_cast(s_alpha * 170.0f); - const u8 fillA = static_cast(s_alpha * 110.0f); - const u8 knobA = static_cast(s_alpha * 230.0f); const f32 w = SCREEN_WIDTH; const f32 h = SCREEN_HEIGHT; const f32 base = std::min(w, h); - const bool climbing = (touchhud::GetContext() == HudContext::Climbing); + const HudContext ctx = touchhud::GetContext(); TextManager* tm = g_textManager; if (tm) { tm->SetFontByName("Menu"); - tm->SetColor(255, 255, 255, 255); + tm->SetColor(kText.r, kText.g, kText.b, kText.a); tm->SetPromptsEnabled(true); } + // Pass 1: draw every visible control's background (filled circle + ring + // + joystick knob). Drawing all circles first avoids per-button texture + // flushes breaking the per-control draw order, and gives the text pass a + // clean slate to render on top of. for (int32_t i = 0; i < kControlCount; ++i) { - if (!IsControlInteractive(i, climbing)) continue; + if (!IsControlInteractive(i, ctx)) continue; const float x = s_cx[i]; const float y = s_cy[i]; @@ -264,25 +329,48 @@ void Render() { if (kControls[i].buttonId >= 0) { const bool pressed = s_btnDown[i]; - ScreenDraw::DrawFilledCircle(x, y, r, r, 255, 255, 255, - pressed ? (u8)(fillA + 90) : fillA); - ScreenDraw::DrawCircle(x, y, r, r, std::max(2.0f, r * 0.06f), 255, 255, 255, baseA); - if (tm) { - // Center the label on the button, matching the menu's text pattern. - tm->SetAlignment(TextAlign_Center); - const f32 textScale = (r * 1.4f) / 48.0f; - tm->SetScale(SCREEN_SCALE_Y(textScale), SCREEN_SCALE_Y(textScale)); - const TextBounds b = tm->MeasureString(kControls[i].label); - tm->PrintString(kControls[i].label, x, y - b.height / 2.0f); - } + TouchColor fill = pressed ? kFillPressed : kFill; + fill.a = FadeAlpha(fill.a, s_alpha); + TouchColor ring = kRing; + ring.a = FadeAlpha(ring.a, s_alpha); + FillCircle(x, y, r, r, fill); + RingCircle(x, y, r, r, std::max(2.0f, r * 0.06f), ring); } else { // Joystick base + knob - ScreenDraw::DrawFilledCircle(x, y, r, r, 255, 255, 255, baseA); - ScreenDraw::DrawCircle(x, y, r, r, std::max(2.0f, r * 0.04f), 255, 255, 255, 200); + TouchColor fill = kFill; + fill.a = FadeAlpha(fill.a, s_alpha); + TouchColor ring = kRingJoystick; + ring.a = FadeAlpha(ring.a, s_alpha); + TouchColor knob = kKnob; + knob.a = FadeAlpha(knob.a, s_alpha); + + FillCircle(x, y, r, r, fill); + RingCircle(x, y, r, r, std::max(2.0f, r * 0.04f), ring); const float knobR = r * 0.45f; - ScreenDraw::DrawFilledCircle(x + s_knobX * r, y + s_knobY * r, - knobR, knobR, 255, 255, 255, knobA); + FillCircle(x + s_knobX * r, y + s_knobY * r, knobR, knobR, knob); + } + } + + // Pass 2: render labels on top of the circles. The text backend uses a + // different texture (the font atlas), which causes the pending circle + // batch to flush before the glyphs queue — so labels always land on top + // of their corresponding button. + if (tm) { + for (int32_t i = 0; i < kControlCount; ++i) { + if (!IsControlInteractive(i, ctx)) continue; + if (kControls[i].buttonId < 0) continue; + if (!kControls[i].label || kControls[i].label[0] == '\0') continue; + + const float x = s_cx[i]; + const float y = s_cy[i]; + const float r = kControls[i].nr * base; + + tm->SetAlignment(TextAlign_Center); + const f32 textScale = (r * 1.4f) / 48.0f; + tm->SetScale(SCREEN_SCALE_Y(textScale), SCREEN_SCALE_Y(textScale)); + const TextBounds b = tm->MeasureString(kControls[i].label); + tm->PrintString(kControls[i].label, x, y - b.height / 2.0f); } } } @@ -330,12 +418,19 @@ void RenderMovieSkip() { const float skipY = SCREEN_HEIGHT * 0.05f; const float r = std::min(SCREEN_WIDTH, SCREEN_HEIGHT) * 0.055f; - ScreenDraw::DrawFilledCircle(skipX, skipY, r, r, 255, 255, 255, 170); - ScreenDraw::DrawCircle(skipX, skipY, r, r, std::max(2.0f, r * 0.06f), 255, 255, 255, 210); + // Pass 1: dark filled circle + light ring on top of the movie frame. + // Uses the corrected color path: alpha lands in the color channel, not + // in the UV slot, so the "Pular" label can be read on top of the circle. + FillCircle(skipX, skipY, r, r, kSkipFill); + RingCircle(skipX, skipY, r, r, std::max(2.0f, r * 0.06f), kSkipRing); + // Pass 2: "Pular" label on top of the circle. The text backend uses the + // font atlas texture, which forces the pending circle batch to flush + // before the glyphs are queued — so the label is guaranteed to render + // on top of the dark fill, not behind it. if (g_textManager) { g_textManager->SetFontByName("Menu"); - g_textManager->SetColor(255, 255, 255, 255); + g_textManager->SetColor(kSkipText.r, kSkipText.g, kSkipText.b, kSkipText.a); g_textManager->SetPromptsEnabled(true); g_textManager->SetAlignment(TextAlign_Center); const f32 textScale = (r * 1.6f) / 48.0f; From 6e6a10b5f16ef33acdfbc289e8475c5ae869a797 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Wed, 26 Aug 2026 10:34:16 +0000 Subject: [PATCH 17/31] Fix touch HUD: pointer-id tracking, movie skip glyph, Start bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes found from user screenshots + log: 1. Character moved by itself / buttons dropped while held: LoadTouchPoints() returns a COMPACTED array (only active points), so indices shift whenever another finger lifts. Tracking joystick/buttons by array index made the joystick grab the wrong finger after a lift (e.g. the A button finger -> character walks alone) and made held buttons release spontaneously. Track pointers by their stable Android pointer id (TouchPoint.id) instead. 2. Skip button looked like an empty 'ball' during cutscenes: during PlayMovie the FE fonts are usually not loaded, so the text-only 'Pular' label never rendered, leaving a bare circle. The button now draws a font-independent '>>' fast-forward glyph (two batched triangles) inside the circle, with 'Pular' as an optional caption under it when a font is available (MeasureString > 0 guard). 3. Skip button shown on non-skippable movies: PlayMovie(skippable=0) (logos, credits) rendered a fake skip button. Add SetMovieSkippable()/EndMovieSkip(); the button only shows and feeds input for skippable movies. 4. Virtual Start stuck down after a skip: DoSkip() posted Start down and never released it, leaking into the next state (instant pause / skipping the following movie). EndMovieSkip() releases it after the movie loop. A finger that already skipped is ignored until it lifts (no re-trigger while held). 5. Start button showed a broken '?' glyph: the '≡' label is not in the PSX bitmap font. Replace it with a 3-bar hamburger glyph drawn as batched rects (no font dependency). 6. Layout: Y button was clipped at the right edge (0.965 -> 0.950); L1 moved from mid-left screen to the action cluster (0.845, 0.455) above the diamond so combat inputs sit together; Start slightly larger (0.042 -> 0.048); skip button lowered (0.05 -> 0.075) so the circle is not cut off by the top edge. --- src/extra/touchcontrols.cpp | 206 +++++++++++++++++++++++++----------- src/extra/touchcontrols.h | 9 ++ src/gen/game.cpp | 12 +++ src/pc/tim.cpp | 15 +++ src/pc/tim.h | 6 ++ 5 files changed, 184 insertions(+), 64 deletions(-) diff --git a/src/extra/touchcontrols.cpp b/src/extra/touchcontrols.cpp index 963b6ba..c6c1cf6 100644 --- a/src/extra/touchcontrols.cpp +++ b/src/extra/touchcontrols.cpp @@ -32,25 +32,25 @@ struct Control { float nr; // normalized radius }; +// L1/R1 sit above the action diamond (LB=COUNTER, RB=DIVE_ROLL) so all combat +// inputs live on the right thumb; the left side is reserved for the joystick. +// Y is kept off the far right edge so the circle is never clipped. static const Control kControls[] = { {"joy", "", -1, 0.120f, 0.700f, 0.105f}, {"A", "A", GamepadButton::A, 0.865f, 0.775f, 0.072f}, {"B", "B", GamepadButton::B, 0.775f, 0.685f, 0.062f}, {"X", "X", GamepadButton::X, 0.875f, 0.600f, 0.062f}, - {"Y", "Y", GamepadButton::Y, 0.965f, 0.690f, 0.062f}, + {"Y", "Y", GamepadButton::Y, 0.950f, 0.690f, 0.062f}, {"RB", "R1", GamepadButton::RightBumper, 0.950f, 0.520f, 0.050f}, - {"LB", "L1", GamepadButton::LeftBumper, 0.045f, 0.280f, 0.050f}, - {"ST", "≡", GamepadButton::Start, 0.500f, 0.940f, 0.042f}, + {"LB", "L1", GamepadButton::LeftBumper, 0.845f, 0.455f, 0.050f}, + {"ST", "", GamepadButton::Start, 0.500f, 0.940f, 0.048f}, }; static constexpr int32_t kControlCount = sizeof(kControls) / sizeof(kControls[0]); static constexpr int32_t kJoyIdx = 0; // --------------------------------------------------------------------------- // Visual style — dark translucent fills + light outlines so the white labels -// stay readable on top of the game frame. Matches the look of common mobile -// touch overlays (PPSSPP / AetherSX2 style) and avoids the previous issue -// where the white "Pular" / A / B / X / Y labels were camouflaged against a -// white circle background and looked like they were rendered behind. +// stay readable on top of the game frame (PPSSPP / AetherSX2 style). // --------------------------------------------------------------------------- struct TouchColor { @@ -74,11 +74,7 @@ static inline u8 FadeAlpha(u8 a, float fade) { } // Thin wrappers that pass all positional args to ScreenDraw's circle helpers -// so the color lands in the (r,g,b,a) tail, not in the UV slots. The previous -// code called DrawFilledCircle(x, y, r, r, R, G, B, A) — those trailing 4 -// args matched u0/v0/u1/v1, leaving the color at its default (255,255,255,255), -// which is why the "Pular" / A / B / X / Y labels were camouflaged against an -// opaque white circle and looked like they were rendered behind the button. +// so the color lands in the (r,g,b,a) tail, not in the UV slots. static inline void FillCircle(float x, float y, float rx, float ry, const TouchColor& c) { ScreenDraw::DrawFilledCircle(x, y, rx, ry, @@ -121,13 +117,20 @@ static void RecomputeLayout() { // --------------------------------------------------------------------------- // State +// +// Pointers are tracked by their stable Android pointer ID (TouchPoint.id). +// They must NEVER be tracked by index into the LoadTouchPoints() result: +// that array is compacted (only active points are returned), so indices shift +// whenever another finger lifts. Index-based tracking made the joystick grab +// the wrong finger — the character walked by itself — and made held buttons +// drop spontaneously. // --------------------------------------------------------------------------- static bool s_visible = false; static float s_alpha = 0.0f; static bool s_btnDown[kControlCount] = {}; -static int32_t s_btnSlot[kControlCount]; // touch slot per control, -1 = none -static int32_t s_joySlot = -1; // touch slot dragging joystick +static int32_t s_btnPointer[kControlCount]; // pointer id per button, -1 = none +static int32_t s_joyPointer = -1; // pointer id dragging joystick static float s_knobX = 0.0f; static float s_knobY = 0.0f; @@ -144,19 +147,23 @@ static int32_t FindControlAt(float x, float y) { return -1; } +static void ReleaseJoystick() { + androidbridge::PostGamepadAxis(GamepadAxis::LeftX, 0.0f); + androidbridge::PostGamepadAxis(GamepadAxis::LeftY, 0.0f); + s_joyPointer = -1; + s_knobX = s_knobY = 0.0f; +} + static void ResetAll() { for (int32_t i = 0; i < kControlCount; ++i) { if (kControls[i].buttonId >= 0 && s_btnDown[i]) { androidbridge::PostGamepadButton(kControls[i].buttonId, false); s_btnDown[i] = false; - s_btnSlot[i] = -1; + s_btnPointer[i] = -1; } } - if (s_joySlot >= 0) { - androidbridge::PostGamepadAxis(GamepadAxis::LeftX, 0.0f); - androidbridge::PostGamepadAxis(GamepadAxis::LeftY, 0.0f); - s_joySlot = -1; - s_knobX = s_knobY = 0.0f; + if (s_joyPointer >= 0) { + ReleaseJoystick(); } } @@ -215,33 +222,38 @@ void Update() { androidbridge::TouchPoint pts[androidbridge::kMaxTouchPoints]; const int32_t ptCount = androidbridge::LoadTouchPoints(pts, androidbridge::kMaxTouchPoints); - // 1) Release any button whose touch slot is gone. + // Stable identity lookup: find a touch point by pointer id. + auto findById = [&pts, ptCount](int32_t id) -> const androidbridge::TouchPoint* { + if (id < 0) return nullptr; + for (int32_t i = 0; i < ptCount; ++i) { + if (pts[i].active && pts[i].id == id) return &pts[i]; + } + return nullptr; + }; + + // 1) Release any button whose pointer is gone. for (int32_t ci = 0; ci < kControlCount; ++ci) { if (kControls[ci].buttonId < 0 || !s_btnDown[ci]) continue; - const int32_t slot = s_btnSlot[ci]; - const bool stillHeld = (slot >= 0 && slot < ptCount && pts[slot].active); - if (!stillHeld) { + if (!findById(s_btnPointer[ci])) { s_btnDown[ci] = false; - s_btnSlot[ci] = -1; + s_btnPointer[ci] = -1; androidbridge::PostGamepadButton(kControls[ci].buttonId, false); } } - // Release joystick if its slot is gone. - if (s_joySlot >= 0 && !(s_joySlot < ptCount && pts[s_joySlot].active)) { - androidbridge::PostGamepadAxis(GamepadAxis::LeftX, 0.0f); - androidbridge::PostGamepadAxis(GamepadAxis::LeftY, 0.0f); - s_joySlot = -1; - s_knobX = s_knobY = 0.0f; + // Release the joystick if its pointer is gone. + if (s_joyPointer >= 0 && !findById(s_joyPointer)) { + ReleaseJoystick(); } - // 2) Assign/update active pointers. + // 2) Assign/update active pointers (matched by id, never by index). bool anyActive = false; for (int32_t pi = 0; pi < ptCount; ++pi) { if (!pts[pi].active) continue; + const int32_t pid = pts[pi].id; // Joystick follow - if (s_joySlot == pi) { + if (s_joyPointer == pid) { const float dx = pts[pi].x - s_cx[kJoyIdx]; const float dy = pts[pi].y - s_cy[kJoyIdx]; const float r = s_joyBaseR; @@ -256,10 +268,10 @@ void Update() { continue; } - // Already holding a button with this pointer? + // Pointer already holding a button? bool handled = false; for (int32_t ci = 0; ci < kControlCount; ++ci) { - if (kControls[ci].buttonId >= 0 && s_btnDown[ci] && s_btnSlot[ci] == pi) { + if (kControls[ci].buttonId >= 0 && s_btnDown[ci] && s_btnPointer[ci] == pid) { handled = true; anyActive = true; break; @@ -273,12 +285,12 @@ void Update() { if (kControls[hit].buttonId >= 0) { s_btnDown[hit] = true; - s_btnSlot[hit] = pi; + s_btnPointer[hit] = pid; androidbridge::PostGamepadButton(kControls[hit].buttonId, true); anyActive = true; } - else if (hit == kJoyIdx && s_joySlot == -1) { - s_joySlot = pi; + else if (hit == kJoyIdx && s_joyPointer == -1) { + s_joyPointer = pid; const float dx = pts[pi].x - s_cx[kJoyIdx]; const float dy = pts[pi].y - s_cy[kJoyIdx]; s_knobX = dx / s_joyBaseR; @@ -310,16 +322,11 @@ void Render() { const HudContext ctx = touchhud::GetContext(); TextManager* tm = g_textManager; - if (tm) { - tm->SetFontByName("Menu"); - tm->SetColor(kText.r, kText.g, kText.b, kText.a); - tm->SetPromptsEnabled(true); - } // Pass 1: draw every visible control's background (filled circle + ring - // + joystick knob). Drawing all circles first avoids per-button texture - // flushes breaking the per-control draw order, and gives the text pass a - // clean slate to render on top of. + // + joystick knob). The Start button gets a 3-bar "menu" glyph drawn as + // batched rects — the PSX bitmap font has no '≡' glyph (it rendered as a + // broken '?'-looking mark before). for (int32_t i = 0; i < kControlCount; ++i) { if (!IsControlInteractive(i, ctx)) continue; @@ -335,6 +342,21 @@ void Render() { ring.a = FadeAlpha(ring.a, s_alpha); FillCircle(x, y, r, r, fill); RingCircle(x, y, r, r, std::max(2.0f, r * 0.06f), ring); + + if (std::strcmp(kControls[i].id, "ST") == 0) { + // Hamburger/menu bars, queued after the circle so they land + // on top of it in the same batch. + TouchColor bar = kRing; + bar.a = FadeAlpha(bar.a, s_alpha); + const float barW = r * 1.15f; + const float barH = std::max(2.0f, r * 0.16f); + for (int b = 0; b < 3; ++b) { + const float by = (y - r * 0.34f) + b * (r * 0.34f); + ScreenDraw::DrawColoredRect(x - barW * 0.5f, by, + barW, barH, + bar.r, bar.g, bar.b, bar.a); + } + } } else { // Joystick base + knob @@ -352,11 +374,14 @@ void Render() { } } - // Pass 2: render labels on top of the circles. The text backend uses a - // different texture (the font atlas), which causes the pending circle - // batch to flush before the glyphs queue — so labels always land on top - // of their corresponding button. + // Pass 2: render text labels on top of the circles. The text backend + // queues glyph quads with the font atlas texture, which flushes the + // pending circle batch first — so labels always land on top. if (tm) { + tm->SetFontByName("Menu"); + tm->SetColor(kText.r, kText.g, kText.b, kText.a); + tm->SetPromptsEnabled(true); + for (int32_t i = 0; i < kControlCount; ++i) { if (!IsControlInteractive(i, ctx)) continue; if (kControls[i].buttonId < 0) continue; @@ -385,59 +410,112 @@ void Reset() { // Movie / cutscene skip // --------------------------------------------------------------------------- -static void DoSkip() { +static bool s_movieSkippable = false; // PlayMovie(skippable) gates everything +static bool s_skipHeld = false; // Start posted down, waiting for release +static int32_t s_skipPointer = -1; // pointer that already triggered a skip + +static void DoSkip(int32_t pointerId) { + if (s_skipHeld) return; // one press per touch + s_skipHeld = true; + s_skipPointer = pointerId; androidbridge::PostGamepadConnected(true); androidbridge::PostGamepadButton(GamepadButton::Start, true); } +void SetMovieSkippable(bool skippable) { + s_movieSkippable = skippable; +} + +void EndMovieSkip() { + // The PlayMovie loop breaks the frame Start goes down; without this the + // virtual Start would stay held and instantly open the pause menu (or + // skip the next movie) after playback. + if (s_skipHeld) { + androidbridge::PostGamepadButton(GamepadButton::Start, false); + s_skipHeld = false; + } + s_movieSkippable = false; + s_skipPointer = -1; +} + void UpdateMovieSkip() { + if (!s_movieSkippable) return; RecomputeLayout(); androidbridge::TouchPoint pts[androidbridge::kMaxTouchPoints]; const int32_t ptCount = androidbridge::LoadTouchPoints(pts, androidbridge::kMaxTouchPoints); const float skipX = SCREEN_WIDTH * 0.90f; - const float skipY = SCREEN_HEIGHT * 0.05f; + const float skipY = SCREEN_HEIGHT * 0.075f; const float skipR = std::min(SCREEN_WIDTH, SCREEN_HEIGHT) * 0.055f; + // A finger that already skipped stays ignored until it lifts, so holding + // the button through the rest of the movie doesn't re-trigger. + bool skipPointerDown = false; + for (int32_t i = 0; i < ptCount; ++i) { if (!pts[i].active) continue; + const float dx = pts[i].x - skipX; const float dy = pts[i].y - skipY; - if (dx * dx + dy * dy <= skipR * skipR * 2.0f) { - DoSkip(); + const bool inSkip = (dx * dx + dy * dy <= skipR * skipR * 2.0f); + + if (inSkip && pts[i].id != s_skipPointer) { + DoSkip(pts[i].id); return; } + if (pts[i].id == s_skipPointer) { + skipPointerDown = true; + } + } + + if (!skipPointerDown) { + s_skipPointer = -1; } } void RenderMovieSkip() { + if (!s_movieSkippable) return; ScreenDraw::Batch batch; const float skipX = SCREEN_WIDTH * 0.90f; - const float skipY = SCREEN_HEIGHT * 0.05f; + const float skipY = SCREEN_HEIGHT * 0.075f; const float r = std::min(SCREEN_WIDTH, SCREEN_HEIGHT) * 0.055f; - // Pass 1: dark filled circle + light ring on top of the movie frame. - // Uses the corrected color path: alpha lands in the color channel, not - // in the UV slot, so the "Pular" label can be read on top of the circle. + // Dark filled circle + light ring on top of the movie frame. FillCircle(skipX, skipY, r, r, kSkipFill); RingCircle(skipX, skipY, r, r, std::max(2.0f, r * 0.06f), kSkipRing); - // Pass 2: "Pular" label on top of the circle. The text backend uses the - // font atlas texture, which forces the pending circle batch to flush - // before the glyphs are queued — so the label is guaranteed to render - // on top of the dark fill, not behind it. + // Fast-forward ">>" glyph drawn as two triangles queued in the same + // batch. During PlayMovie the FE fonts are often not loaded yet (they + // are (re)loaded after the movie), so a text-only label would leave an + // empty circle — the "ball" artifact. The glyph needs no font and makes + // the button read as "skip" on its own. + const TouchColor& g = kSkipText; + const float tw = r * 0.52f; + const float th = r * 0.90f; + const float gap = r * 0.20f; + const float x0 = skipX - tw - gap * 0.5f; + const float x1 = skipX + gap * 0.5f; + ScreenDraw::DrawTriangle(x0, skipY - th * 0.5f, x0, skipY + th * 0.5f, + x0 + tw, skipY, g.r, g.g, g.b, g.a); + ScreenDraw::DrawTriangle(x1, skipY - th * 0.5f, x1, skipY + th * 0.5f, + x1 + tw, skipY, g.r, g.g, g.b, g.a); + + // Optional "Pular" caption under the button — only when a font is + // actually loaded (MeasureString returns zero bounds otherwise). if (g_textManager) { g_textManager->SetFontByName("Menu"); g_textManager->SetColor(kSkipText.r, kSkipText.g, kSkipText.b, kSkipText.a); g_textManager->SetPromptsEnabled(true); g_textManager->SetAlignment(TextAlign_Center); - const f32 textScale = (r * 1.6f) / 48.0f; + const f32 textScale = (r * 0.85f) / 48.0f; g_textManager->SetScale(SCREEN_SCALE_Y(textScale), SCREEN_SCALE_Y(textScale)); const char* skipLabel = "Pular"; const TextBounds b = g_textManager->MeasureString(skipLabel); - g_textManager->PrintString(skipLabel, skipX, skipY - b.height / 2.0f); + if (b.width > 0.0f && b.height > 0.0f) { + g_textManager->PrintString(skipLabel, skipX, skipY + r * 1.15f); + } } } diff --git a/src/extra/touchcontrols.h b/src/extra/touchcontrols.h index 5da2854..becaa68 100644 --- a/src/extra/touchcontrols.h +++ b/src/extra/touchcontrols.h @@ -24,6 +24,15 @@ void Reset(); // --- Cutscene / movie skip ------------------------------------------------- +// Gate the skip button with PlayMovie's skippable flag: non-skippable movies +// (logos, credits) must neither show the button nor post virtual input. +void SetMovieSkippable(bool skippable); + +// Release the virtual Start press and clear the skip state. MUST be called +// after the PlayMovie loop ends (skip or natural end), otherwise Start stays +// held down and leaks into the next game state. +void EndMovieSkip(); + // Process touches against the "skip" button (used inside the blocking // PlayMovie loop where the main loop doesn't run). void UpdateMovieSkip(); diff --git a/src/gen/game.cpp b/src/gen/game.cpp index ac27436..822bc93 100644 --- a/src/gen/game.cpp +++ b/src/gen/game.cpp @@ -2298,6 +2298,12 @@ void Game::PlayMovie(const char* name, s32 skippable, s32 unloadLevel) { f64 prevFrame = Time::GetTimeInSeconds(); f32 targetDt = 1.0f / player->GetFrameRate(); +#if defined(RC_PLATFORM_ANDROID) + // The skip button only shows (and only feeds input) for skippable + // movies; logo/credit movies must not display a fake skip button. + touchcontrols::SetMovieSkippable(skippable != 0); +#endif + while (!player->IsFinished() && !p3d::display->ShouldClose()) { f64 now = Time::GetTimeInSeconds(); f32 elapsed = (f32)(now - prevFrame); @@ -2338,6 +2344,12 @@ void Game::PlayMovie(const char* name, s32 skippable, s32 unloadLevel) { #endif g_display->EndFrame(); } + +#if defined(RC_PLATFORM_ANDROID) + // Release the virtual Start press (a skip leaves it held down) and + // clear the skip state so it never leaks into the next game state. + touchcontrols::EndMovieSkip(); +#endif } // PSX: destructor 0x8001434C with param 3 diff --git a/src/pc/tim.cpp b/src/pc/tim.cpp index fe5f055..6376a12 100644 --- a/src/pc/tim.cpp +++ b/src/pc/tim.cpp @@ -746,6 +746,21 @@ void ScreenDraw::DrawColoredRect(f32 x, f32 y, f32 w, f32 h, EndOverlay(prev); } +void ScreenDraw::DrawTriangle(f32 x0, f32 y0, f32 x1, f32 y1, f32 x2, f32 y2, + u8 r, u8 g, u8 b, u8 a) { + // Triangles only queue vertices: they must be drawn inside an active + // Batch so the overlay state and the final flush are handled there. This + // keeps them ordered with the other batched primitives (circles/rects). + if (s_overlayBatchDepth <= 0 || a == 0) { + return; + } + EnsureShader(); + EnsurePrimitiveBatchTexture(nullptr); + QueueBatchedVertex(x0, y0, 0.0f, 0.0f, r, g, b, a); + QueueBatchedVertex(x1, y1, 0.0f, 0.0f, r, g, b, a); + QueueBatchedVertex(x2, y2, 0.0f, 0.0f, r, g, b, a); +} + void ScreenDraw::DrawGouraudQuad(f32 x0, f32 y0, u8 r0, u8 g0, u8 b0, u8 a0, f32 x1, f32 y1, u8 r1, u8 g1, u8 b1, u8 a1, f32 x2, f32 y2, u8 r2, u8 g2, u8 b2, u8 a2, diff --git a/src/pc/tim.h b/src/pc/tim.h index a18ba47..c0593d5 100644 --- a/src/pc/tim.h +++ b/src/pc/tim.h @@ -78,6 +78,12 @@ namespace ScreenDraw { void DrawColoredRect(f32 x, f32 y, f32 w, f32 h, u8 r, u8 g, u8 b, u8 a); + // Draw a solid colored triangle. Only queues vertices, so it must be + // called inside an active ScreenDraw::Batch (state + flush handled there); + // this keeps ordering with the other batched primitives. + void DrawTriangle(f32 x0, f32 y0, f32 x1, f32 y1, f32 x2, f32 y2, + u8 r = 255, u8 g = 255, u8 b = 255, u8 a = 255); + // Draw a fullscreen colored quad, used for fade transitions. void DrawColoredQuad(u8 r, u8 g, u8 b, u8 a); From 19f5503b4fdbe1528074d25b715128fa094fdb17 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Wed, 26 Aug 2026 12:33:06 +0000 Subject: [PATCH 18/31] Fix input device fight: touch misdetected as keyboard/mouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine was treating the finger's touch-as-mouse position as 'desktop input', which cleared gamepadActive every frame the user dragged a finger — including on the virtual joystick itself. The virtual gamepad and the phantom 'keyboard/mouse' fought each other: analog controls froze, prompts flipped to keyboard glyphs, buttons got scrambled by the control-map remap, and menu drags switched to cursor mode and gated pad navigation off. - inputaction: on Android there is no desktop to switch to — the virtual gamepad is the primary device; touch-as-mouse no longer counts as keyboard/mouse input - fecustommenumgr: mouse hover/cursor mode permanently off on touch-first devices (taps via ProcessTouchTaps, nav via pad) - androidbridge: virtual pad connected from boot so analog mode and gamepad prompts are active from the first frame - main loop: touch controls feed the pad BEFORE ServiceInput so touch hits land in the same frame, not one frame later - AndroidInput: require full AINPUT_SOURCE_TOUCHSCREEN source so pointer-class devices (BT mouse/stylus) are not treated as fingers --- android/app/src/main/cpp/AndroidInput.cpp | 6 +++++- src/extra/fecustommenumgr.cpp | 18 ++++++++++++++++++ src/extra/touchhud.cpp | 11 ++++++++--- src/extra/touchhud.h | 6 ++++++ src/gen/main.cpp | 8 ++++++++ src/pc/inputaction.cpp | 21 +++++++++++++++++++++ vendor/libp3d/pddi/gles/AndroidPlatform.cpp | 7 ++++++- 7 files changed, 72 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/cpp/AndroidInput.cpp b/android/app/src/main/cpp/AndroidInput.cpp index 30a4aad..10375c1 100644 --- a/android/app/src/main/cpp/AndroidInput.cpp +++ b/android/app/src/main/cpp/AndroidInput.cpp @@ -248,7 +248,11 @@ bool HandleMotionEvent(AInputEvent* event) { // intentionally NOT emulated — direct taps handled by the menu's // ProcessTouchTaps would otherwise double-activate alongside a mouse // left-click Confirm). Multi-touch slots drive the on-screen controls. - if ((source & AINPUT_SOURCE_TOUCHSCREEN) != 0) { + // Require BOTH source bits (class-pointer + touchscreen): mice, styluses + // and touchpads share the pointer class bit, and treating their events + // as finger input made the engine "recognize a mouse" that doesn't + // exist while the virtual gamepad was also active. + if ((source & AINPUT_SOURCE_TOUCHSCREEN) == AINPUT_SOURCE_TOUCHSCREEN) { HandleTouchEvent(event); return true; } diff --git a/src/extra/fecustommenumgr.cpp b/src/extra/fecustommenumgr.cpp index 10a8b52..7c14dd3 100644 --- a/src/extra/fecustommenumgr.cpp +++ b/src/extra/fecustommenumgr.cpp @@ -1127,6 +1127,24 @@ void feCustomMenuMgr::UpdateMouseCursorVisibility() { if (!g_actionInput) return; +#if defined(RC_PLATFORM_ANDROID) + // Touch-first device: the "mouse position" is the user's finger (the + // touch-as-mouse bridge) and mouse buttons are never emulated, so + // hover/cursor mode is useless — direct taps go through + // ProcessTouchTaps() and navigation through the virtual gamepad. + // Previously a finger drag (>4px) woke cursor mode, which gated the + // pad navigation off and made menus look "stuck"/frozen. Keep the + // mouse path permanently OFF on Android. + if (m_mouseInputActive || m_mousePosInitialized) { + m_mouseInputActive = false; + m_mousePosInitialized = false; + if (g_display) { + g_display->SetCursorVisible(false); + } + } + return; +#endif + double sx = 0.0; double sy = 0.0; g_actionInput->GetMousePosition(sx, sy); diff --git a/src/extra/touchhud.cpp b/src/extra/touchhud.cpp index 06af829..8c5560a 100644 --- a/src/extra/touchhud.cpp +++ b/src/extra/touchhud.cpp @@ -84,13 +84,18 @@ HudContext ComputeContext() { } // namespace +void ProcessInput() { + // On-screen touch controls (joystick + action buttons): hit-test the + // current fingers and post virtual gamepad buttons/axes. Runs BEFORE + // PlatformInput::ServiceInput() in the main loop (see main.cpp) so the + // state lands in this frame's pad snapshot, not the next one. + touchcontrols::Update(); +} + void PublishFrame() { s_context = ComputeContext(); androidbridge::SetHudContext(static_cast(s_context)); - // On-screen touch controls (joystick + action buttons). - touchcontrols::Update(); - // Touch diagnostics (throttled): confirm the touch->mouse chain end to end. static s32 s_diagCounter = 0; if ((s_diagCounter++ & 0x3F) == 0) { diff --git a/src/extra/touchhud.h b/src/extra/touchhud.h index 92a5344..5002b2a 100644 --- a/src/extra/touchhud.h +++ b/src/extra/touchhud.h @@ -23,6 +23,12 @@ enum class HudContext : u32 { // Called once per frame from the main loop (game thread). void PublishFrame(); +// Feed the on-screen touch controls (virtual gamepad) once per frame. +// Must run BEFORE PlatformInput::ServiceInput() in the main loop so the +// virtual pad state posted here is polled in the SAME frame instead of +// one frame later. +void ProcessInput(); + // Current context (also readable from any thread). HudContext GetContext(); diff --git a/src/gen/main.cpp b/src/gen/main.cpp index c3df2df..d8101f4 100644 --- a/src/gen/main.cpp +++ b/src/gen/main.cpp @@ -324,6 +324,14 @@ static int RechanMain(int argc, char** argv) { p3d::display->PollEvents(); +#if defined(RC_PLATFORM_ANDROID) + // Feed the virtual gamepad BEFORE polling input so this frame's + // touch hits land in the same ServiceInput/ActionInput snapshot + // (they used to be posted at the end of the frame and only became + // visible one frame later). + touchhud::ProcessInput(); +#endif + if (p3d::input) { p3d::input->ServiceInput(); } diff --git a/src/pc/inputaction.cpp b/src/pc/inputaction.cpp index ea373f7..a2e7a5c 100644 --- a/src/pc/inputaction.cpp +++ b/src/pc/inputaction.cpp @@ -387,6 +387,18 @@ void ActionInput::Update(PlatformInput* platform) { #if defined(RC_PLATFORM_SWITCH) const bool hasKeyboard = false; const bool hasMouse = false; +#elif defined(RC_PLATFORM_ANDROID) + // Android is a touch-first console. The platform's "mouse position" is + // the user's FINGER, fed through the touch-as-mouse bridge in + // AndroidInput.cpp, and a physical keyboard never exists (IsKeyDown + // is permanently false). Counting the finger as desktop input made + // this device switch fight the virtual gamepad every frame: dragging + // the on-screen joystick also moved the touch-mouse, which cleared + // gamepadActive — freezing the analog controls, flipping prompts to + // keyboard glyphs and scrambling buttons through the control-map + // remap. Touch is the gamepad here; there is no desktop to switch to. + const bool hasKeyboard = false; + const bool hasMouse = false; #else const bool hasKeyboard = platform->HasAnyKeyboardInput(); const bool hasMouse = platform->HasAnyMouseInput(); @@ -404,12 +416,21 @@ void ActionInput::Update(PlatformInput* platform) { hadKeyboardInputThisFrame = hasKeyboard; hadMouseInputThisFrame = hasMouse; hadGamepadInputThisFrame = hasGp; +#if defined(RC_PLATFORM_ANDROID) + // Touch-only device: the virtual gamepad IS the primary (and only) + // input device. Staying in gamepad mode keeps the pad analog ('s'), + // keeps the analog override below active, keeps prompts on gamepad + // glyphs and skips the PSX control-map button remap, which is what + // made some on-screen buttons appear dead before. + gamepadActive = true; +#else if (hasGp) { gamepadActive = true; } if (hasDesktop) { gamepadActive = false; } +#endif } // Update all action states. diff --git a/vendor/libp3d/pddi/gles/AndroidPlatform.cpp b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp index e28de73..f4243d5 100644 --- a/vendor/libp3d/pddi/gles/AndroidPlatform.cpp +++ b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp @@ -13,7 +13,12 @@ std::atomic g_windowGeneration{0}; std::atomic g_exitRequested{false}; std::atomic g_heldButtons{0}; std::atomic g_axes[GamepadAxis::COUNT] = {}; -std::atomic g_padConnected{false}; +// Touch-first device: the on-screen HUD feeds a virtual gamepad that is +// always present, so the engine starts (and stays) in gamepad mode — +// analog pad type, gamepad prompt glyphs and pad menu navigation work +// from the first frame, before the first touch. Physical pad / on-screen +// presses merely keep the flag set. +std::atomic g_padConnected{true}; std::atomic g_hudContext{0}; std::atomic g_touchX{0.0f}; std::atomic g_touchY{0.0f}; From 0a0f100223a55e8e7352251d19b61fd5efe61453 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Wed, 26 Aug 2026 13:45:03 +0000 Subject: [PATCH 19/31] Fix touch slot corruption: zombie pointers freezing the HUD Root cause of the intermittent 'analog stuck + all buttons dead': FindTouchSlot() searched the COMPACTED LoadTouchPoints() array, so when slots had holes a MOVE wrote a finger into the WRONG bridge slot, duplicating its pointer id; the leftover copy stayed active forever (zombie). Zombies held s_joyPointer (joystick frozen at its last deflection = character moving alone) and filled all 4 slots until new touches were silently dropped (every button dead). - androidbridge: new real-slot helpers FindTouchSlotById / AcquireTouchSlot (self-heals stale same-id entries) / ClearAllTouchPoints; TouchPoint gains a per-acquire 'seq' so a recycled Android pointer id can never keep holding a control. - AndroidInput: DOWN/MOVE/UP now address real slots; MOVE self-heals missed DOWNs; ACTION_CANCEL clears the WHOLE gesture (it aborts all pointers, not one). - AndroidShell: reset touch state on PAUSE/STOP/LOST_FOCUS/TERM_WINDOW (Android never delivers the missing UPs when fingers are taken away). - touchcontrols: match controls by (pointer id, seq); dedup duplicated ids defensively; movie-skip pointer also seq-guarded. --- android/app/src/main/cpp/AndroidInput.cpp | 109 +++++++++++--------- android/app/src/main/cpp/AndroidShell.cpp | 15 ++- src/extra/touchcontrols.cpp | 52 +++++++--- vendor/libp3d/pddi/gles/AndroidPlatform.cpp | 45 +++++++- vendor/libp3d/pddi/gles/AndroidPlatform.h | 22 ++++ 5 files changed, 181 insertions(+), 62 deletions(-) diff --git a/android/app/src/main/cpp/AndroidInput.cpp b/android/app/src/main/cpp/AndroidInput.cpp index 10375c1..2b7b790 100644 --- a/android/app/src/main/cpp/AndroidInput.cpp +++ b/android/app/src/main/cpp/AndroidInput.cpp @@ -75,34 +75,29 @@ int HatToButtons(float hatX, float hatY) { // index is the first slot that was assigned (used for menu hover). int32_t s_touchPointer = -1; -// Find a free slot in the androidbridge multi-touch array, or return the -// slot that already holds the given pointer id. -static int32_t FindTouchSlot(int32_t pointerId, bool create) { - androidbridge::TouchPoint points[androidbridge::kMaxTouchPoints]; - const int32_t cnt = androidbridge::LoadTouchPoints(points, androidbridge::kMaxTouchPoints); - for (int32_t i = 0; i < cnt; ++i) { - if (points[i].id == pointerId) return i; +static void AdoptPrimaryFromSlots() { + // Primary finger went away — either adopt another live finger or reset + // the emulated mouse. Runs on the input thread only. + androidbridge::TouchPoint pts[androidbridge::kMaxTouchPoints]; + const int32_t cnt = androidbridge::LoadTouchPoints(pts, androidbridge::kMaxTouchPoints); + if (cnt > 0) { + s_touchPointer = pts[0].id; + androidbridge::SetTouchMouse(pts[0].x, pts[0].y, false); } - if (!create) return -1; - // First free slot - for (int32_t i = 0; i < androidbridge::kMaxTouchPoints; ++i) { - if (!points[i].active) return i; + else { + s_touchPointer = -1; + androidbridge::SetTouchMouse(0.0f, 0.0f, false); } - return -1; // all slots full } -static void UpdateMouseFromPrimary() { - androidbridge::TouchPoint pts[androidbridge::kMaxTouchPoints]; - const int32_t cnt = androidbridge::LoadTouchPoints(pts, androidbridge::kMaxTouchPoints); - // The primary pointer is s_touchPointer; find its position. - for (int32_t i = 0; i < cnt; ++i) { - if (pts[i].id == s_touchPointer) { - androidbridge::SetTouchMouse(pts[i].x, pts[i].y, false); - return; - } - } - // Primary pointer went away — reset mouse. +// Drops every finger at once. ACTION_CANCEL aborts the WHOLE gesture (not +// just one pointer), and lifecycle events (pause, lost focus, surface going +// away) mean Android will never deliver the missing UPs — leaving the slots +// set would freeze the joystick at its last deflection and keep "ghost" +// fingers hogging every slot, which is exactly how the HUD died before. +void RechanAndroidResetTouch() { s_touchPointer = -1; + androidbridge::ClearAllTouchPoints(); androidbridge::SetTouchMouse(0.0f, 0.0f, false); } @@ -118,9 +113,10 @@ static void HandleTouchEvent(AInputEvent* event) { switch (actionCode) { case AMOTION_EVENT_ACTION_DOWN: case AMOTION_EVENT_ACTION_POINTER_DOWN: { - int32_t slot = FindTouchSlot(-1, true); - if (slot >= 0) { - androidbridge::SetTouchPoint(slot, pointerId, x, y, true); + // AcquireTouchSlot indexes the real bridge slots (reusing any + // stale same-id entry) — never the compacted LoadTouchPoints() + // view, which used to send a finger's data into the WRONG slot. + if (androidbridge::AcquireTouchSlot(pointerId, x, y) >= 0) { if (s_touchPointer == -1) { s_touchPointer = pointerId; androidbridge::SetTouchMouse(x, y, false); @@ -130,41 +126,58 @@ static void HandleTouchEvent(AInputEvent* event) { break; } case AMOTION_EVENT_ACTION_MOVE: { - for (int32_t i = 0; i < AMotionEvent_getPointerCount(event); ++i) { + const int32_t pointerCount = AMotionEvent_getPointerCount(event); + for (int32_t i = 0; i < pointerCount; ++i) { const int32_t pid = AMotionEvent_getPointerId(event, i); - const int32_t slot = FindTouchSlot(pid, false); - if (slot >= 0) { - androidbridge::SetTouchPoint( - slot, pid, AMotionEvent_getX(event, i), AMotionEvent_getY(event, i), true); + const float px = AMotionEvent_getX(event, i); + const float py = AMotionEvent_getY(event, i); + int32_t slot = androidbridge::FindTouchSlotById(pid); + if (slot < 0) { + // Missed the DOWN (slot pressure at the time): self-heal + // by claiming a slot now instead of dropping the finger. + slot = androidbridge::AcquireTouchSlot(pid, px, py); + if (slot < 0) continue; + } + androidbridge::SetTouchPoint(slot, pid, px, py, true); + } + // Keep the emulated mouse on the primary finger. + if (s_touchPointer >= 0) { + androidbridge::TouchPoint pts[androidbridge::kMaxTouchPoints]; + const int32_t cnt = androidbridge::LoadTouchPoints(pts, androidbridge::kMaxTouchPoints); + bool found = false; + for (int32_t i = 0; i < cnt; ++i) { + if (pts[i].id == s_touchPointer) { + androidbridge::SetTouchMouse(pts[i].x, pts[i].y, false); + found = true; + break; + } + } + if (!found) { + AdoptPrimaryFromSlots(); } } - UpdateMouseFromPrimary(); break; } case AMOTION_EVENT_ACTION_UP: - case AMOTION_EVENT_ACTION_POINTER_UP: - case AMOTION_EVENT_ACTION_CANCEL: { - const int32_t slot = FindTouchSlot(pointerId, false); + case AMOTION_EVENT_ACTION_POINTER_UP: { + const int32_t slot = androidbridge::FindTouchSlotById(pointerId); if (slot >= 0) { - if (actionCode != AMOTION_EVENT_ACTION_CANCEL) { - androidbridge::QueueTouchTapPos(x, y); - } + androidbridge::QueueTouchTapPos(x, y); androidbridge::ClearTouchPoint(slot); if (pointerId == s_touchPointer) { - s_touchPointer = -1; - // If another finger is still down, adopt it as primary. - androidbridge::TouchPoint pts[androidbridge::kMaxTouchPoints]; - const int32_t cnt = androidbridge::LoadTouchPoints(pts, androidbridge::kMaxTouchPoints); - if (cnt > 0) { - s_touchPointer = pts[0].id; - androidbridge::SetTouchMouse(pts[0].x, pts[0].y, false); - } else { - androidbridge::SetTouchMouse(0.0f, 0.0f, false); - } + AdoptPrimaryFromSlots(); } } break; } + case AMOTION_EVENT_ACTION_CANCEL: { + // CANCEL aborts the entire gesture — every finger is up as far as + // the app is concerned. Clearing only the indexed pointer used to + // strand the others as "zombie" slots (joystick stuck dragging, + // buttons dead once the zombies filled all 4 slots). + RechanAndroidResetTouch(); + break; + } default: break; } diff --git a/android/app/src/main/cpp/AndroidShell.cpp b/android/app/src/main/cpp/AndroidShell.cpp index d848518..7257329 100644 --- a/android/app/src/main/cpp/AndroidShell.cpp +++ b/android/app/src/main/cpp/AndroidShell.cpp @@ -19,6 +19,7 @@ extern int GameMain(int argc, char** argv); bool RechanAndroidHandleInputEvent(AInputEvent* event); +void RechanAndroidResetTouch(); // Belt-and-suspenders anchor: referencing glue's entry symbol here guarantees // the linker pulls native_app_glue's object out of the static archive (the @@ -140,6 +141,10 @@ void HandleCommand(android_app* app, int32_t cmd) { } break; case APP_CMD_TERM_WINDOW: + // Fingers still down when the surface goes away will never get + // their UP events — drop them or the virtual joystick stays + // deflected and ghost fingers hog the touch slots forever. + RechanAndroidResetTouch(); androidbridge::SetNativeWindow(nullptr); // The EGL surface keeps its own strong reference to the window, so // dropping ours here is safe; it only guards the handoff window. @@ -149,9 +154,17 @@ void HandleCommand(android_app* app, int32_t cmd) { } break; case APP_CMD_PAUSE: + case APP_CMD_STOP: + case APP_CMD_LOST_FOCUS: + // Same story as TERM_WINDOW: the system takes the fingers away + // (shade pulled, home gesture, incoming call) without per-pointer + // UPs. Reset the touch state so the HUD starts clean on resume. + RechanAndroidResetTouch(); // Backgrounded: silence the output device (the game loop parks on // its own once the surface goes away). - AudioEngine::Suspend(); + if (cmd == APP_CMD_PAUSE) { + AudioEngine::Suspend(); + } break; case APP_CMD_RESUME: AudioEngine::Resume(); diff --git a/src/extra/touchcontrols.cpp b/src/extra/touchcontrols.cpp index c6c1cf6..74f3467 100644 --- a/src/extra/touchcontrols.cpp +++ b/src/extra/touchcontrols.cpp @@ -130,7 +130,9 @@ static bool s_visible = false; static float s_alpha = 0.0f; static bool s_btnDown[kControlCount] = {}; static int32_t s_btnPointer[kControlCount]; // pointer id per button, -1 = none +static uint32_t s_btnSeq[kControlCount]; // touch seq of the grabbing finger static int32_t s_joyPointer = -1; // pointer id dragging joystick +static uint32_t s_joySeq = 0; // touch seq of the dragging finger static float s_knobX = 0.0f; static float s_knobY = 0.0f; @@ -151,6 +153,7 @@ static void ReleaseJoystick() { androidbridge::PostGamepadAxis(GamepadAxis::LeftX, 0.0f); androidbridge::PostGamepadAxis(GamepadAxis::LeftY, 0.0f); s_joyPointer = -1; + s_joySeq = 0; s_knobX = s_knobY = 0.0f; } @@ -160,6 +163,7 @@ static void ResetAll() { androidbridge::PostGamepadButton(kControls[i].buttonId, false); s_btnDown[i] = false; s_btnPointer[i] = -1; + s_btnSeq[i] = 0; } } if (s_joyPointer >= 0) { @@ -222,11 +226,14 @@ void Update() { androidbridge::TouchPoint pts[androidbridge::kMaxTouchPoints]; const int32_t ptCount = androidbridge::LoadTouchPoints(pts, androidbridge::kMaxTouchPoints); - // Stable identity lookup: find a touch point by pointer id. - auto findById = [&pts, ptCount](int32_t id) -> const androidbridge::TouchPoint* { + // Stable identity lookup: find a touch point by (pointer id, seq). + // The seq changes every time a slot is re-acquired by a new finger, so a + // recycled pointer id can never keep holding a control it never grabbed. + auto findByTouch = [&pts, ptCount](int32_t id, uint32_t seq) + -> const androidbridge::TouchPoint* { if (id < 0) return nullptr; for (int32_t i = 0; i < ptCount; ++i) { - if (pts[i].active && pts[i].id == id) return &pts[i]; + if (pts[i].active && pts[i].id == id && pts[i].seq == seq) return &pts[i]; } return nullptr; }; @@ -234,26 +241,39 @@ void Update() { // 1) Release any button whose pointer is gone. for (int32_t ci = 0; ci < kControlCount; ++ci) { if (kControls[ci].buttonId < 0 || !s_btnDown[ci]) continue; - if (!findById(s_btnPointer[ci])) { + if (!findByTouch(s_btnPointer[ci], s_btnSeq[ci])) { s_btnDown[ci] = false; s_btnPointer[ci] = -1; + s_btnSeq[ci] = 0; androidbridge::PostGamepadButton(kControls[ci].buttonId, false); } } // Release the joystick if its pointer is gone. - if (s_joyPointer >= 0 && !findById(s_joyPointer)) { + if (s_joyPointer >= 0 && !findByTouch(s_joyPointer, s_joySeq)) { ReleaseJoystick(); } // 2) Assign/update active pointers (matched by id, never by index). + // Duplicated ids in the slot array can no longer occur (the bridge now + // indexes real slots), but a duplicate would still wedge two controls to + // one finger — skip any id already seen this frame. bool anyActive = false; for (int32_t pi = 0; pi < ptCount; ++pi) { if (!pts[pi].active) continue; const int32_t pid = pts[pi].id; + bool seenEarlier = false; + for (int32_t pj = 0; pj < pi; ++pj) { + if (pts[pj].active && pts[pj].id == pid) { + seenEarlier = true; + break; + } + } + if (seenEarlier) continue; + // Joystick follow - if (s_joyPointer == pid) { + if (s_joyPointer == pid && s_joySeq == pts[pi].seq) { const float dx = pts[pi].x - s_cx[kJoyIdx]; const float dy = pts[pi].y - s_cy[kJoyIdx]; const float r = s_joyBaseR; @@ -271,7 +291,8 @@ void Update() { // Pointer already holding a button? bool handled = false; for (int32_t ci = 0; ci < kControlCount; ++ci) { - if (kControls[ci].buttonId >= 0 && s_btnDown[ci] && s_btnPointer[ci] == pid) { + if (kControls[ci].buttonId >= 0 && s_btnDown[ci] + && s_btnPointer[ci] == pid && s_btnSeq[ci] == pts[pi].seq) { handled = true; anyActive = true; break; @@ -286,11 +307,13 @@ void Update() { if (kControls[hit].buttonId >= 0) { s_btnDown[hit] = true; s_btnPointer[hit] = pid; + s_btnSeq[hit] = pts[pi].seq; androidbridge::PostGamepadButton(kControls[hit].buttonId, true); anyActive = true; } else if (hit == kJoyIdx && s_joyPointer == -1) { s_joyPointer = pid; + s_joySeq = pts[pi].seq; const float dx = pts[pi].x - s_cx[kJoyIdx]; const float dy = pts[pi].y - s_cy[kJoyIdx]; s_knobX = dx / s_joyBaseR; @@ -413,11 +436,13 @@ void Reset() { static bool s_movieSkippable = false; // PlayMovie(skippable) gates everything static bool s_skipHeld = false; // Start posted down, waiting for release static int32_t s_skipPointer = -1; // pointer that already triggered a skip +static uint32_t s_skipSeq = 0; // seq of that touch (guards id recycling) -static void DoSkip(int32_t pointerId) { +static void DoSkip(int32_t pointerId, uint32_t seq) { if (s_skipHeld) return; // one press per touch s_skipHeld = true; s_skipPointer = pointerId; + s_skipSeq = seq; androidbridge::PostGamepadConnected(true); androidbridge::PostGamepadButton(GamepadButton::Start, true); } @@ -436,6 +461,7 @@ void EndMovieSkip() { } s_movieSkippable = false; s_skipPointer = -1; + s_skipSeq = 0; } void UpdateMovieSkip() { @@ -450,7 +476,8 @@ void UpdateMovieSkip() { const float skipR = std::min(SCREEN_WIDTH, SCREEN_HEIGHT) * 0.055f; // A finger that already skipped stays ignored until it lifts, so holding - // the button through the rest of the movie doesn't re-trigger. + // the button through the rest of the movie doesn't re-trigger. The seq + // makes "lifts" reliable even if Android recycles the pointer id. bool skipPointerDown = false; for (int32_t i = 0; i < ptCount; ++i) { @@ -460,17 +487,18 @@ void UpdateMovieSkip() { const float dy = pts[i].y - skipY; const bool inSkip = (dx * dx + dy * dy <= skipR * skipR * 2.0f); - if (inSkip && pts[i].id != s_skipPointer) { - DoSkip(pts[i].id); + if (inSkip && !(pts[i].id == s_skipPointer && pts[i].seq == s_skipSeq)) { + DoSkip(pts[i].id, pts[i].seq); return; } - if (pts[i].id == s_skipPointer) { + if (pts[i].id == s_skipPointer && pts[i].seq == s_skipSeq) { skipPointerDown = true; } } if (!skipPointerDown) { s_skipPointer = -1; + s_skipSeq = 0; } } diff --git a/vendor/libp3d/pddi/gles/AndroidPlatform.cpp b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp index f4243d5..33616b3 100644 --- a/vendor/libp3d/pddi/gles/AndroidPlatform.cpp +++ b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp @@ -34,6 +34,7 @@ struct TouchSlot { std::atomic x{0.0f}; std::atomic y{0.0f}; std::atomic active{false}; + std::atomic seq{0}; // bumped on every (re)acquire (DOWN) }; TouchSlot g_touchSlots[kMaxTouchPoints]; } // namespace @@ -158,16 +159,58 @@ void ClearTouchPoint(int32_t slot) { int32_t LoadTouchPoints(TouchPoint* out, int32_t max) { int32_t count = 0; for (int32_t s = 0; s < kMaxTouchPoints && count < max; ++s) { - if (!g_touchSlots[s].active.load(std::memory_order_relaxed)) continue; + // acquire pairs with AcquireTouchSlot's release store of active, + // so the id/x/y/seq written before it are visible once active. + if (!g_touchSlots[s].active.load(std::memory_order_acquire)) continue; TouchPoint& p = out[count++]; p.id = g_touchSlots[s].id.load(std::memory_order_relaxed); p.x = g_touchSlots[s].x.load(std::memory_order_relaxed); p.y = g_touchSlots[s].y.load(std::memory_order_relaxed); p.active = true; + p.seq = g_touchSlots[s].seq.load(std::memory_order_relaxed); } return count; } +int32_t FindTouchSlotById(int32_t pointerId) { + if (pointerId < 0) return -1; + for (int32_t s = 0; s < kMaxTouchPoints; ++s) { + if (!g_touchSlots[s].active.load(std::memory_order_acquire)) continue; + if (g_touchSlots[s].id.load(std::memory_order_relaxed) == pointerId) return s; + } + return -1; +} + +int32_t AcquireTouchSlot(int32_t pointerId, float x, float y) { + if (pointerId < 0) return -1; + // Reuse the entry already holding this id if any (id collision after a + // missed UP self-heals here instead of leaking a duplicate). + int32_t slot = FindTouchSlotById(pointerId); + if (slot < 0) { + for (int32_t s = 0; s < kMaxTouchPoints; ++s) { + if (!g_touchSlots[s].active.load(std::memory_order_relaxed)) { + slot = s; + break; + } + } + } + if (slot < 0) return -1; // all slots busy + // Write id/x/y + a FRESH seq, active last so the game thread never sees + // a new activation paired with the previous touch's seq. + g_touchSlots[slot].id.store(pointerId, std::memory_order_relaxed); + g_touchSlots[slot].x.store(x, std::memory_order_relaxed); + g_touchSlots[slot].y.store(y, std::memory_order_relaxed); + g_touchSlots[slot].seq.fetch_add(1, std::memory_order_relaxed); + g_touchSlots[slot].active.store(true, std::memory_order_release); + return slot; +} + +void ClearAllTouchPoints() { + for (int32_t s = 0; s < kMaxTouchPoints; ++s) { + ClearTouchPoint(s); + } +} + static_assert(GamepadButton::COUNT <= 32, "buttons fit in u32 bitmask"); static_assert(GamepadAxis::COUNT == 6, "PadSnapshot axes size matches"); diff --git a/vendor/libp3d/pddi/gles/AndroidPlatform.h b/vendor/libp3d/pddi/gles/AndroidPlatform.h index 58c6b7a..bbd2d07 100644 --- a/vendor/libp3d/pddi/gles/AndroidPlatform.h +++ b/vendor/libp3d/pddi/gles/AndroidPlatform.h @@ -72,12 +72,34 @@ struct TouchPoint { float x = 0.0f; // surface pixel coordinates float y = 0.0f; bool active = false; + // Bumped every time a slot is (re)acquired by a DOWN. Android recycles + // pointer ids across gestures, so id alone can't tell "same finger that + // grabbed the control" from "new finger that recycled the id within one + // frame" — the seq can. + uint32_t seq = 0; }; void SetTouchPoint(int32_t slot, int32_t id, float x, float y, bool active); void ClearTouchPoint(int32_t slot); int32_t LoadTouchPoints(TouchPoint* out, int32_t max); +// Real-slot helpers. CRITICAL: LoadTouchPoints() returns the active points +// COMPACTED (packed at the front), so its indices are NOT slot indices. +// Never use compacted indices with Set/ClearTouchPoint — that wrote a moving +// finger into the wrong slot, duplicated its pointer id, and stranded +// "zombie" slots that stayed active forever (stuck joystick + dead buttons). +// These helpers index g_touchSlots directly: +// FindTouchSlotById -> slot holding pointerId, or -1. +// AcquireTouchSlot -> reuses the slot already holding pointerId (self- +// heals a stale entry), else claims the first free +// slot; writes id/x/y and marks it active. -1 if full. +// ClearAllTouchPoints-> releases every slot (ACTION_CANCEL aborts the WHOLE +// gesture; lifecycle pause/resume must also drop all +// fingers, since Android will never send their UP). +int32_t FindTouchSlotById(int32_t pointerId); +int32_t AcquireTouchSlot(int32_t pointerId, float x, float y); +void ClearAllTouchPoints(); + // One-shot tap notification consumed by the game loop (used to turn a tap // into a Start press on the "press any button" title screen). void QueueTouchTap(); From 4409e9710d354f002d329eb8332491f994ff88e0 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Wed, 26 Aug 2026 13:51:42 +0000 Subject: [PATCH 20/31] Fix link error: move RechanAndroidResetTouch out of anonymous namespace It was defined inside 'namespace {' (internal linkage) so AndroidShell.cpp could not resolve it. Now declared up front and defined at file scope. --- android/app/src/main/cpp/AndroidInput.cpp | 29 +++++++++++++++-------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/cpp/AndroidInput.cpp b/android/app/src/main/cpp/AndroidInput.cpp index 2b7b790..e6a8800 100644 --- a/android/app/src/main/cpp/AndroidInput.cpp +++ b/android/app/src/main/cpp/AndroidInput.cpp @@ -7,6 +7,10 @@ #include "pddi/gles/AndroidPlatform.h" #include "pddi/pddidev.h" +// Defined at file scope below (after the anonymous namespace) so that +// AndroidShell.cpp can link against it. +void RechanAndroidResetTouch(); + namespace { constexpr int32_t kSourceGamepadMask = @@ -90,16 +94,8 @@ static void AdoptPrimaryFromSlots() { } } -// Drops every finger at once. ACTION_CANCEL aborts the WHOLE gesture (not -// just one pointer), and lifecycle events (pause, lost focus, surface going -// away) mean Android will never deliver the missing UPs — leaving the slots -// set would freeze the joystick at its last deflection and keep "ghost" -// fingers hogging every slot, which is exactly how the HUD died before. -void RechanAndroidResetTouch() { - s_touchPointer = -1; - androidbridge::ClearAllTouchPoints(); - androidbridge::SetTouchMouse(0.0f, 0.0f, false); -} +// (RechanAndroidResetTouch is defined at file scope below, after the +// anonymous namespace, so AndroidShell.cpp can link against it.) static void HandleTouchEvent(AInputEvent* event) { const int32_t action = AMotionEvent_getAction(event); @@ -306,6 +302,19 @@ bool HandleMotionEvent(AInputEvent* event) { } // namespace +// Drops every finger at once. ACTION_CANCEL aborts the WHOLE gesture (not +// just one pointer), and lifecycle events (pause, lost focus, surface going +// away) mean Android will never deliver the missing UPs — leaving the slots +// set would freeze the joystick at its last deflection and keep "ghost" +// fingers hogging every slot, which is exactly how the HUD died before. +// Defined at file scope (external linkage): AndroidShell.cpp calls this from +// its command handler. +void RechanAndroidResetTouch() { + s_touchPointer = -1; + androidbridge::ClearAllTouchPoints(); + androidbridge::SetTouchMouse(0.0f, 0.0f, false); +} + bool RechanAndroidHandleInputEvent(AInputEvent* event) { switch (AInputEvent_getType(event)) { case AINPUT_EVENT_TYPE_KEY: From 28a2bb17051f479a0873d6f938f06a2c1d618e72 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Wed, 26 Aug 2026 18:24:51 +0000 Subject: [PATCH 21/31] Fix magenta chroma-key leak: port PSX SetTransparentTim behaviour The PSX original ran every texture through SetTransparentTim (LOADERS.CPP:460) at load time, which rewrote the magenta key colour (0x7C1F) in the CLUTs to 0x0000 so the GPU skipped those texels in every draw mode. That step was never ported, so raw magenta leaked to the screen wherever textures use magenta as the transparency key: - semi-transparent world geometry (windows, banners, decals, pickup sprites): the 3D shader only discarded magenta on opaque draws (uUseZeroTexelKey==0); blended draws used zero-texel keying only - decoded VRAM pages used by BackG sprites (PsxToRGBA keyed only 0x0000) - CELL images / TIM images / bitmap font palettes (PsxAbgr1555ToRgba8888 set alpha=255 for every colour != 0) Fix: discard/key 0x7C1F (either STP bit) unconditionally in all four VRAM/CLUT shaders (GLES + desktop GL, colour + shadow depth passes), in PsxToRGBA (world.cpp) and in PsxAbgr1555ToRgba8888 (psxcolor_helpers.h), reproducing the hardware behaviour. --- src/gen/psxcolor_helpers.h | 9 +++++++++ src/gen/world.cpp | 6 +++++- vendor/libp3d/pddi/gl/glrender.cpp | 20 ++++++++++++-------- vendor/libp3d/pddi/gles/glesrender.cpp | 16 ++++++++++------ 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/gen/psxcolor_helpers.h b/src/gen/psxcolor_helpers.h index ce6f734..a397e5d 100644 --- a/src/gen/psxcolor_helpers.h +++ b/src/gen/psxcolor_helpers.h @@ -7,6 +7,15 @@ inline u32 PsxAbgr1555ToRgba8888(u16 color) { return 0; } + // PSX SetTransparentTim (LOADERS.CPP:460) rewrote the magenta key colour in + // texture CLUTs to 0x0000 at load time so the GPU skipped those texels in + // every draw mode. This port uploads raw CLUTs, so key 0x7C1F (magenta, + // either STP bit) here to reproduce the hardware behaviour. This covers + // CELL images (HUD/FE sprites), TIM images and bitmap font palettes. + if ((color & 0x7FFF) == 0x7C1F) { + return 0; + } + const u32 r5 = color & 0x1F; const u32 g5 = (color >> 5) & 0x1F; const u32 b5 = (color >> 10) & 0x1F; diff --git a/src/gen/world.cpp b/src/gen/world.cpp index b3c2df4..04a372f 100644 --- a/src/gen/world.cpp +++ b/src/gen/world.cpp @@ -111,7 +111,11 @@ static void PsxToRGBA(u16 c, u8& r, u8& g, u8& b, u8& a) { r = static_cast((r5 << 3) | (r5 >> 2)); g = static_cast((g5 << 3) | (g5 >> 2)); b = static_cast((b5 << 3) | (b5 >> 2)); - a = (c == 0) ? 0 : 255; + // PSX SetTransparentTim (LOADERS.CPP:460) rewrote the magenta key colour to + // 0x0000 at texture-load time, making the GPU skip those texels. Key 0x7C1F + // (magenta, either STP bit) here so decoded VRAM pages (BackG sprites) + // match the hardware behaviour. + a = (c == 0 || (c & 0x7FFF) == 0x7C1F) ? 0 : 255; } void PsxVRAM::DecodePage(u16 tpage, u16 cba, u8* out) const { diff --git a/vendor/libp3d/pddi/gl/glrender.cpp b/vendor/libp3d/pddi/gl/glrender.cpp index be0a82c..e0b81d3 100644 --- a/vendor/libp3d/pddi/gl/glrender.cpp +++ b/vendor/libp3d/pddi/gl/glrender.cpp @@ -182,12 +182,15 @@ void main() { zeroTexel = (clutWord == 0u); } + // PSX SetTransparentTim (LOADERS.CPP:460) rewrote the magenta key + // colour in the CLUT to 0x0000 at texture-load time, so the GPU + // skipped those texels in EVERY draw mode. This port uploads raw + // CLUTs to VRAM, so discard the key colour on both keying paths: + // opaque (magenta key) and blended (zero-texel key). + if ((clutWord & 0x7FFFu) == 0x7C1Fu) discard; if (uUseZeroTexelKey != 0) { if (zeroTexel) discard; } - else { - if ((clutWord & 0x7FFFu) == 0x7C1Fu) discard; - } } )"; @@ -495,14 +498,15 @@ void main() { zeroTexel = (clutWord == 0u); } - // World/block content in this renderer commonly uses magenta keying, - // while blended effect paths use zero texel keying. + // PSX SetTransparentTim (LOADERS.CPP:460) rewrote the magenta key + // colour in the CLUT to 0x0000 at texture-load time, so the GPU + // skipped those texels in EVERY draw mode. This port uploads raw + // CLUTs to VRAM, so discard the key colour on both keying paths: + // opaque (magenta key) and blended (zero-texel key). + if ((clutWord & 0x7FFFu) == 0x7C1Fu) discard; if (uUseZeroTexelKey != 0) { if (zeroTexel) discard; } - else { - if ((clutWord & 0x7FFFu) == 0x7C1Fu) discard; - } float r = float(clutWord & 0x1Fu) / 31.0; float g = float((clutWord >> 5u) & 0x1Fu) / 31.0; diff --git a/vendor/libp3d/pddi/gles/glesrender.cpp b/vendor/libp3d/pddi/gles/glesrender.cpp index fc97c3b..faf1a9a 100644 --- a/vendor/libp3d/pddi/gles/glesrender.cpp +++ b/vendor/libp3d/pddi/gles/glesrender.cpp @@ -427,12 +427,15 @@ void main() { zeroTexel = (clutWord == 0u); } + // PSX SetTransparentTim (LOADERS.CPP:460) rewrote the magenta key + // colour in the CLUT to 0x0000 at texture-load time, so the GPU + // skipped those texels in EVERY draw mode. This port uploads raw + // CLUTs to VRAM, so discard the key colour on both keying paths: + // opaque (magenta key) and blended (zero-texel key). + if ((clutWord & 0x7FFFu) == 0x7C1Fu) discard; if (uUseZeroTexelKey != 0) { if (zeroTexel) discard; } - else { - if ((clutWord & 0x7FFFu) == 0x7C1Fu) discard; - } float r = float(clutWord & 0x1Fu) / 31.0; float g = float((clutWord >> 5u) & 0x1Fu) / 31.0; @@ -545,12 +548,13 @@ void main() { zeroTexel = (clutWord == 0u); } + // Key colour discard must match the colour pass (PSX SetTransparentTim + // rewrote CLUT magenta to 0x0000, so hardware skipped it everywhere), + // otherwise keyed-out texels would still cast shadows. + if ((clutWord & 0x7FFFu) == 0x7C1Fu) discard; if (uUseZeroTexelKey != 0) { if (zeroTexel) discard; } - else { - if ((clutWord & 0x7FFFu) == 0x7C1Fu) discard; - } } )"; From 86f9d633ac5d3f4e5c828668cce8dab5032eb117 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Wed, 26 Aug 2026 20:20:01 +0000 Subject: [PATCH 22/31] Add touch support to press-button screens (Game Over, legal splash) The Game Over screen (GameState::EndGameLoop, 'Press A to continue') had no touch path: the HUD context was Hidden (no virtual buttons) and the touch-as-mouse bridge was disabled on Android, so touch-only players were stuck and needed a physical gamepad. - ComputeContext: EndGameLoop now maps to HudContext::Menu, so the virtual joystick/A/B/Start set is available on the Game Over screen. - PublishFrame: generalizes the title 'tap = Start' pulse into a 'tap = the button this screen waits for' pulse: * TitleLoop -> Start (unchanged) * EndGameLoop -> A (Game Over continue) * Intro legal splash -> A (AnyJustPressed skip; PC skips with any mouse button, touch had nothing) The confirm pulse is gated on the fade state so taps during the Game Over fade-out don't queue dead input. - PublishFrame drains the pending position-carrying tap whenever no custom menu is active: previously it stayed queued and resurfaced as a phantom click in the next menu to open (taps on the Game Over screen clicking a Location menu entry right after the fade, HUD taps during gameplay clicking the pause menu opened later). - game.h: public IsGameOverFading()/IsIntroSplashActive() queries. --- src/extra/touchhud.cpp | 59 +++++++++++++++++++++++++++++++++++++----- src/gen/game.h | 5 ++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/extra/touchhud.cpp b/src/extra/touchhud.cpp index 8c5560a..1bb185a 100644 --- a/src/extra/touchhud.cpp +++ b/src/extra/touchhud.cpp @@ -24,7 +24,8 @@ namespace touchhud { namespace { HudContext s_context = HudContext::Hidden; -s32 s_startPulseFrames = 0; +s32 s_startPulseFrames = 0; // tap->Start pulse (title "press start") +s32 s_confirmPulseFrames = 0; // tap->A pulse ("press A to continue" screens) bool IsClimbState(s32 actionState) { switch (actionState) { @@ -61,8 +62,16 @@ HudContext ComputeContext() { case GameState::DbgMenu: case GameState::LocationMenu: case GameState::OpenLocationMenu: + case GameState::EndGameLoop: + // EndGameLoop = Game Over screen ("Press A to continue"): needs the + // navigation set so touch-only players can confirm and leave it. return HudContext::Menu; + case GameState::Intro: + // Legal splash auto-expires, but a tap can skip it (see the + // confirm pulse in PublishFrame) - no HUD needed while it plays. + return HudContext::Hidden; + case GameState::Play: { if (g_directorActive != 0 || (g_director && g_director->scriptState != 0)) { // Cutscene / NIS running. @@ -111,14 +120,33 @@ void PublishFrame() { static_cast(s_context)); } - // Title screen "press any button": a tap anywhere becomes a Start press - // (the custom menu is inactive during that phase, so taps aren't menu - // clicks yet). Two frames so the pad edge is sampled reliably. + // "Press a button" screens (no menu active): a tap anywhere becomes the + // button that screen waits for, so touch-only players are never stuck: + // - TitleLoop : Start ("press start" screen) + // - EndGameLoop : A (Game Over "press A to continue"; the + // screen also accepts Start/Cross via the + // virtual pad, and the fade phase ignores + // input, hence the gameOverFadeType gate) + // - Intro legal splash : A (AnyJustPressed skips the splash; on PC + // any mouse button does the same) + // Two frames so the pad edge is sampled reliably. if (androidbridge::ConsumeTouchTap()) { const bool menuActive = g_feCustomMenuMgr && g_feCustomMenuMgr->IsActive(); - const bool onTitle = g_game && g_game->GetState() == GameState::TitleLoop; - if (!menuActive && onTitle && s_startPulseFrames == 0) { - s_startPulseFrames = 2; + const GameState st = g_game ? g_game->GetState() : GameState::Null; + if (!menuActive) { + if (st == GameState::TitleLoop && s_startPulseFrames == 0) { + s_startPulseFrames = 2; + } + else if (st == GameState::EndGameLoop + && g_game && !g_game->IsGameOverFading() + && s_confirmPulseFrames == 0) { + s_confirmPulseFrames = 2; + } + else if (st == GameState::Intro + && g_game && g_game->IsIntroSplashActive() + && s_confirmPulseFrames == 0) { + s_confirmPulseFrames = 2; + } } } if (s_startPulseFrames > 0) { @@ -129,6 +157,23 @@ void PublishFrame() { androidbridge::PostGamepadButton(GamepadButton::Start, s_startPulseFrames == 2); --s_startPulseFrames; } + if (s_confirmPulseFrames > 0) { + androidbridge::PostGamepadConnected(true); + androidbridge::PostGamepadButton(GamepadButton::A, s_confirmPulseFrames == 2); + --s_confirmPulseFrames; + } + + // With no menu active, nothing during game.Step() consumed the pending + // UP-side tap (the one carrying a position). Drain it so it can't + // resurface as a phantom click when the next menu opens - e.g. taps on + // the Game Over screen must not click an entry of the Location menu that + // appears right after the fade, and HUD button taps during gameplay must + // not click the pause menu opened a moment later. + if (!(g_feCustomMenuMgr && g_feCustomMenuMgr->IsActive())) { + float drainX = 0.0f; + float drainY = 0.0f; + ConsumeMenuTap(drainX, drainY); + } } HudContext GetContext() { diff --git a/src/gen/game.h b/src/gen/game.h index 8591480..c5923a8 100644 --- a/src/gen/game.h +++ b/src/gen/game.h @@ -98,6 +98,11 @@ class Game : public Manager { bool IsAutosaveSpinnerVisible() const { return autosavePhase == 1 || autosavePhase == 2; } bool IsAutosaveFailureVisible() const { return autosavePhase == 3; } + // Touch overlay queries: is the Game Over screen already fading out + // (input ignored), and is the intro legal splash still playing? + bool IsGameOverFading() const { return gameOverFadeType != 0; } + bool IsIntroSplashActive() const { return !assetCheckDone && introPhase != 4; } + // PSX: PlayMovie__4GamePcii (GAME.CPP:3309, 0x8002BBF0) void PlayMovie(const char* name, s32 skippable, s32 unloadLevel); From 47dede839cbc70fefd3db89c289456e5663118b1 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Wed, 26 Aug 2026 20:54:16 +0000 Subject: [PATCH 23/31] Hide contextual touch HUD while a physical gamepad is connected Connecting a Bluetooth/USB pad now takes over as THE input: the on-screen touch HUD (joystick, action buttons, movie-skip button) hides, and its virtual gamepad input path is suspended so palm brushes on the glass can't fight the real pad. When the last pad disconnects, the HUD comes right back. Detection (reliable connect AND disconnect): - GameActivity now implements InputDevice.InputDeviceListener, registered in onCreate (rescan in onResume, unregistered in onDestroy). Every add/remove/change rescans all devices for real gamepads (SOURCE_GAMEPAD / SOURCE_JOYSTICK with actual stick axes; virtual devices, keyboards and DPAD-only TV remotes are excluded) and pushes the result through HudBridge.nativeSetPhysicalGamepad -> JNI -> androidbridge::SetPhysicalPadConnected (new atomic flag). - Native self-confirm: gamepad-SOURCE key/motion events in AndroidInput.cpp also set the flag (DPAD-source remote navigation is excluded on purpose - remotes can't play analog, the HUD must stay). Engine consumption: - touchcontrols::Update(): wantVisible is gated on !IsPhysicalPadConnected(); ResetAll() now runs on EVERY visibility transition so fingers holding the joystick/buttons release immediately when the pad connects (instead of driving the game through the fade-out), and touch processing is fully skipped while hidden. - UpdateMovieSkip()/RenderMovieSkip(): the on-screen skip button is part of the HUD - hidden and inert while a pad is connected (Start on the pad skips the movie). - touchhud::PublishFrame(): the tap-to-continue pulses (title 'press start', Game Over 'press A', legal splash skip) are suspended while a pad is connected; the real pad's own Start/A drives those screens. Direct menu taps stay enabled with a pad connected (mixed input is convenient and harmless). [TouchDiag] log now includes physPad=. The new flag is completely independent from PostGamepadConnected: the engine's gamepad MODE still stays on (touch-first boot default), only the HUD visibility follows the physical pad. --- android/app/src/main/cpp/AndroidInput.cpp | 14 +++ .../com/deivid22srk/rechan/GameActivity.java | 104 ++++++++++++++++-- .../com/deivid22srk/rechan/hud/HudBridge.java | 8 ++ src/extra/touchcontrols.cpp | 22 +++- src/extra/touchhud.cpp | 25 ++++- vendor/libp3d/pddi/gles/AndroidPlatform.cpp | 13 +++ vendor/libp3d/pddi/gles/AndroidPlatform.h | 12 ++ 7 files changed, 183 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/cpp/AndroidInput.cpp b/android/app/src/main/cpp/AndroidInput.cpp index e6a8800..67470c5 100644 --- a/android/app/src/main/cpp/AndroidInput.cpp +++ b/android/app/src/main/cpp/AndroidInput.cpp @@ -234,6 +234,13 @@ bool HandleKeyEvent(AInputEvent* event) { if (const KeyButtonEntry* entry = FindKeyEntry(keyCode)) { androidbridge::PostGamepadConnected(true); + // Gamepad-sourced button keys confirm a physical pad is present. + // Source must be GAMEPAD (not DPAD/KEYBOARD): TV remotes and the + // device's own hardware keys also send DPAD_* codes, but they can't + // drive analog gameplay and must not hide the touch HUD. + if ((AInputEvent_getSource(event) & AINPUT_SOURCE_GAMEPAD) != 0) { + androidbridge::SetPhysicalPadConnected(true); + } if (entry->pddiButton >= 0) { androidbridge::PostGamepadButton(entry->pddiButton, down); } @@ -272,6 +279,13 @@ bool HandleMotionEvent(AInputEvent* event) { androidbridge::PostGamepadConnected(true); + // Stick/trigger motion confirms a physical pad is present. DPAD-source + // motion (TV remote navigation) is excluded for the same reason as + // above: it can't play, so the touch HUD must stay available. + if ((source & (AINPUT_SOURCE_GAMEPAD | AINPUT_SOURCE_JOYSTICK)) != 0) { + androidbridge::SetPhysicalPadConnected(true); + } + const float lx = ClampAxis(AMotionEvent_getAxisValue(event, AMOTION_EVENT_AXIS_X, 0)); const float ly = ClampAxis(AMotionEvent_getAxisValue(event, AMOTION_EVENT_AXIS_Y, 0)); const float rx = ClampAxis(AMotionEvent_getAxisValue(event, AMOTION_EVENT_AXIS_Z, 0)); diff --git a/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java b/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java index dea0300..03cb21c 100644 --- a/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java +++ b/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java @@ -3,20 +3,27 @@ import android.app.NativeActivity; import android.os.Build; import android.os.Bundle; +import android.view.InputDevice; import android.view.View; import android.view.WindowInsets; import android.view.WindowInsetsController; +import com.deivid22srk.rechan.hud.HudBridge; + /** * NativeActivity hosting the ReChan GLES3 engine. Immersive fullscreen with * cutout support; all input flows through the native input queue. * - * The Java HUD overlay (HudController) is disabled for now — the engine - * handles touch natively for menus (mouse emulation via touchscreen events - * in AndroidInput.cpp). A future re-enable can restore the gameplay touch - * controls. + * The engine handles touch natively (on-screen contextual HUD in + * touchcontrols.cpp; menu taps in fecustommenumgr.cpp). + * + * Physical gamepad hot-plug: an InputDevice listener watches for pads being + * added/removed and pushes the presence flag into the engine, so the + * on-screen touch HUD hides while a real (Bluetooth/USB) pad is connected and + * comes back when the last pad disconnects. */ -public class GameActivity extends NativeActivity { +public class GameActivity extends NativeActivity + implements InputDevice.InputDeviceListener { @Override protected void onCreate(Bundle savedInstanceState) { @@ -25,11 +32,92 @@ protected void onCreate(Bundle savedInstanceState) { getWindow().getAttributes().layoutInDisplayCutoutMode = android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; } - // HUD overlay disabled — see HudController / TouchHudView for the - // gameplay touch overlay (joystick + buttons). Native touch→mouse - // in AndroidInput.cpp handles menus. + + // Watch for physical gamepads connecting/disconnecting while the + // activity lives. Null handler -> callbacks on the main thread + // (this thread, which has a Looper). + InputDevice.registerInputDeviceListener(this, null); + updatePhysicalGamepadState(); + } + + @Override + protected void onResume() { + super.onResume(); + // Pads that connected while the activity was down (or that the listener + // missed) are caught by the rescan. + updatePhysicalGamepadState(); + } + + @Override + protected void onDestroy() { + InputDevice.unregisterInputDeviceListener(this); + super.onDestroy(); } + // --- InputDevice.InputDeviceListener --- + + @Override + public void onInputDeviceAdded(int deviceId) { + updatePhysicalGamepadState(); + } + + @Override + public void onInputDeviceRemoved(int deviceId) { + updatePhysicalGamepadState(); + } + + @Override + public void onInputDeviceChanged(int deviceId) { + updatePhysicalGamepadState(); + } + + private void updatePhysicalGamepadState() { + HudBridge.nativeSetPhysicalGamepad(hasPhysicalGamepad()); + } + + /** + * True while at least one non-virtual input device reports gamepad or + * joystick sources (joystick-source devices must really expose stick + * axes). Deliberately excludes: + * - keyboards (SOURCE_KEYBOARD) — they can't play; + * - DPAD-only TV remotes (SOURCE_DPAD) — no analog stick, no face + * buttons; the touch HUD stays useful with them. + * Same criteria as the (disabled) HudController.isRealGamepad. + */ + private static boolean hasPhysicalGamepad() { + final int[] ids = InputDevice.getDeviceIds(); + for (int id : ids) { + InputDevice device = InputDevice.getDevice(id); + if (device == null) continue; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && device.isVirtual()) { + continue; + } + final int sources = device.getSources(); + final boolean gamepadSrc = + (sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD; + final boolean joystickSrc = + (sources & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK; + if (!gamepadSrc && !joystickSrc) continue; + if (joystickSrc && !gamepadSrc) { + // Joystick source without stick axes: some odd devices report + // the source bit but expose no ranges — not a playable pad. + boolean hasStickAxes = false; + for (InputDevice.MotionRange range : device.getMotionRanges()) { + if ((range.getSource() & InputDevice.SOURCE_JOYSTICK) + == InputDevice.SOURCE_JOYSTICK) { + hasStickAxes = true; + break; + } + } + if (!hasStickAxes) continue; + } + return true; + } + return false; + } + + // --- Immersive fullscreen --- + @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); diff --git a/android/app/src/main/java/com/deivid22srk/rechan/hud/HudBridge.java b/android/app/src/main/java/com/deivid22srk/rechan/hud/HudBridge.java index 6bf6343..f2b2813 100644 --- a/android/app/src/main/java/com/deivid22srk/rechan/hud/HudBridge.java +++ b/android/app/src/main/java/com/deivid22srk/rechan/hud/HudBridge.java @@ -41,6 +41,14 @@ private HudBridge() {} public static native void nativePostConnected(boolean connected); + /** + * Physical (Bluetooth/USB) gamepad presence, pushed by GameActivity's + * InputDevice listener. While true the engine hides the on-screen touch + * HUD (the real pad is the input); when the last pad disconnects the HUD + * comes back. + */ + public static native void nativeSetPhysicalGamepad(boolean connected); + /** Current gameplay context (touchhud::HudContext). */ public static native int nativePollContext(); } diff --git a/src/extra/touchcontrols.cpp b/src/extra/touchcontrols.cpp index 74f3467..5f34357 100644 --- a/src/extra/touchcontrols.cpp +++ b/src/extra/touchcontrols.cpp @@ -201,10 +201,20 @@ void Update() { RecomputeLayout(); const HudContext ctx = touchhud::GetContext(); + // A physical (Bluetooth/USB) gamepad takes over: hide the on-screen + // controls AND suspend their input path, so palm brushes on the glass + // can't fight the real pad (GameActivity's InputDevice listener owns the + // flag; the last pad disconnecting brings the HUD right back). + const bool physicalPad = androidbridge::IsPhysicalPadConnected(); const bool wantVisible = (ctx == HudContext::OnFoot || ctx == HudContext::Climbing - || ctx == HudContext::Menu); - if (wantVisible && !s_visible) { + || ctx == HudContext::Menu) + && !physicalPad; + if (wantVisible != s_visible) { + // Clean slate on EVERY visibility transition: entering (no stale + // controls from the previous state) and leaving (fingers holding + // joystick/buttons must release immediately instead of driving the + // game through the fade-out - e.g. right after a pad connects). ResetAll(); } s_visible = wantVisible; @@ -221,7 +231,9 @@ void Update() { if (s_alpha < 0.0f) { s_alpha = 0.0f; ResetAll(); } } - if (!s_visible && s_alpha <= 0.0f) return; + // Hidden (or fading out): no touch processing at all - controls were + // already released above, and new fingers must not grab anything. + if (!s_visible) return; androidbridge::TouchPoint pts[androidbridge::kMaxTouchPoints]; const int32_t ptCount = androidbridge::LoadTouchPoints(pts, androidbridge::kMaxTouchPoints); @@ -466,6 +478,9 @@ void EndMovieSkip() { void UpdateMovieSkip() { if (!s_movieSkippable) return; + // Physical pad connected: Start on the real pad skips the movie, so the + // on-screen skip button (and its touch path) stays suspended. + if (androidbridge::IsPhysicalPadConnected()) return; RecomputeLayout(); androidbridge::TouchPoint pts[androidbridge::kMaxTouchPoints]; @@ -504,6 +519,7 @@ void UpdateMovieSkip() { void RenderMovieSkip() { if (!s_movieSkippable) return; + if (androidbridge::IsPhysicalPadConnected()) return; ScreenDraw::Batch batch; const float skipX = SCREEN_WIDTH * 0.90f; diff --git a/src/extra/touchhud.cpp b/src/extra/touchhud.cpp index 1bb185a..18cb0fa 100644 --- a/src/extra/touchhud.cpp +++ b/src/extra/touchhud.cpp @@ -113,11 +113,12 @@ void PublishFrame() { const bool menuActive = g_feCustomMenuMgr && g_feCustomMenuMgr->IsActive(); const bool mouseActive = g_feCustomMenuMgr ? g_feCustomMenuMgr->IsMouseActive() : false; LOG("[TouchDiag] down=%d pos=(%.0f,%.0f) menuActive=%d mouseActive=%d" - " hadGp=%d hadKb=%d ctx=%u", + " hadGp=%d hadKb=%d ctx=%u physPad=%d", touchDown ? 1 : 0, tx, ty, menuActive ? 1 : 0, mouseActive ? 1 : 0, (g_actionInput && g_actionInput->HadGamepadInputThisFrame()) ? 1 : 0, (g_actionInput && g_actionInput->HadKeyboardInputThisFrame()) ? 1 : 0, - static_cast(s_context)); + static_cast(s_context), + androidbridge::IsPhysicalPadConnected() ? 1 : 0); } // "Press a button" screens (no menu active): a tap anywhere becomes the @@ -129,11 +130,13 @@ void PublishFrame() { // input, hence the gameOverFadeType gate) // - Intro legal splash : A (AnyJustPressed skips the splash; on PC // any mouse button does the same) - // Two frames so the pad edge is sampled reliably. + // Two frames so the pad edge is sampled reliably. Suspended while a + // physical gamepad is connected: the real pad's own Start/A must drive + // these screens, and a stray screen tap must not. if (androidbridge::ConsumeTouchTap()) { const bool menuActive = g_feCustomMenuMgr && g_feCustomMenuMgr->IsActive(); const GameState st = g_game ? g_game->GetState() : GameState::Null; - if (!menuActive) { + if (!menuActive && !androidbridge::IsPhysicalPadConnected()) { if (st == GameState::TitleLoop && s_startPulseFrames == 0) { s_startPulseFrames = 2; } @@ -169,6 +172,11 @@ void PublishFrame() { // the Game Over screen must not click an entry of the Location menu that // appears right after the fade, and HUD button taps during gameplay must // not click the pause menu opened a moment later. + // + // NOTE: direct menu TAPS stay enabled even with a physical gamepad + // connected - mixed input is convenient and harmless - so this drain only + // runs while no menu is up; an active menu's Invoke() consumes its taps + // itself during game.Step(). if (!(g_feCustomMenuMgr && g_feCustomMenuMgr->IsActive())) { float drainX = 0.0f; float drainY = 0.0f; @@ -216,6 +224,15 @@ Java_com_deivid22srk_rechan_hud_HudBridge_nativePollContext(JNIEnv*, jclass) { return static_cast(touchhud::GetContext()); } +JNIEXPORT void JNICALL +Java_com_deivid22srk_rechan_hud_HudBridge_nativeSetPhysicalGamepad( + JNIEnv*, jclass, jboolean connected) { + // Pushed by GameActivity's InputDevice.InputDeviceListener: a physical + // (Bluetooth/USB) gamepad connecting hides the on-screen touch HUD, the + // last pad disconnecting brings it back. + androidbridge::SetPhysicalPadConnected(connected == JNI_TRUE); +} + } // extern "C" #endif // RC_PLATFORM_ANDROID diff --git a/vendor/libp3d/pddi/gles/AndroidPlatform.cpp b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp index 33616b3..cb89cfe 100644 --- a/vendor/libp3d/pddi/gles/AndroidPlatform.cpp +++ b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp @@ -19,6 +19,11 @@ std::atomic g_axes[GamepadAxis::COUNT] = {}; // from the first frame, before the first touch. Physical pad / on-screen // presses merely keep the flag set. std::atomic g_padConnected{true}; +// Physical (Bluetooth/USB) gamepad presence — only gates the touch HUD +// visibility, never the engine's gamepad mode. Written by the Java +// InputDevice listener and by native gamepad-sourced events; read once per +// frame by the game thread. +std::atomic g_physicalPadConnected{false}; std::atomic g_hudContext{0}; std::atomic g_touchX{0.0f}; std::atomic g_touchY{0.0f}; @@ -89,6 +94,14 @@ void PostGamepadConnected(bool connected) { g_padConnected.store(connected, std::memory_order_relaxed); } +void SetPhysicalPadConnected(bool connected) { + g_physicalPadConnected.store(connected, std::memory_order_relaxed); +} + +bool IsPhysicalPadConnected() { + return g_physicalPadConnected.load(std::memory_order_relaxed); +} + PadSnapshot LoadPadSnapshot() { PadSnapshot snap; snap.heldButtons = g_heldButtons.load(std::memory_order_relaxed); diff --git a/vendor/libp3d/pddi/gles/AndroidPlatform.h b/vendor/libp3d/pddi/gles/AndroidPlatform.h index bbd2d07..a23414e 100644 --- a/vendor/libp3d/pddi/gles/AndroidPlatform.h +++ b/vendor/libp3d/pddi/gles/AndroidPlatform.h @@ -52,6 +52,18 @@ struct PadSnapshot { PadSnapshot LoadPadSnapshot(); +// --- Physical gamepad presence (Java InputDevice listener -> game thread) --- +// True while a physical (Bluetooth/USB) gamepad is connected. Completely +// independent of the virtual touch pad above: PostGamepadConnected drives the +// ENGINE's "is a pad plugged in" mode (which stays true on this touch-first +// build), while this flag only decides whether the on-screen touch HUD +// should be visible. Producers: the Java InputDevice.InputDeviceListener +// (GameActivity) for connect AND disconnect, plus a native self-confirm when +// gamepad-sourced events arrive (AndroidInput.cpp). Consumer: the game thread +// (touchhud/touchcontrols) once per frame. +void SetPhysicalPadConnected(bool connected); +bool IsPhysicalPadConnected(); + // --- Touch HUD context (game thread -> UI thread) --- // Published by the game loop each frame; the Java overlay polls it to decide // which on-screen controls to show. Values match touchhud::HudContext. From 330f1e251d7fc94ed2da52e0448f3fb8baf8f7a2 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Wed, 26 Aug 2026 21:01:34 +0000 Subject: [PATCH 24/31] Fix Java build: InputManager.InputDeviceListener, not InputDevice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device listener API lives on android.hardware.input.InputManager (instance obtained via getSystemService(INPUT_SERVICE)), not on android.view.InputDevice — the first CI run failed to find InputDevice.InputDeviceListener / registerInputDeviceListener. Same registration pattern as the (disabled) HudController: register in onCreate with a null handler (callbacks on the main-thread Looper), rescan in onResume, unregister in onDestroy. Java now syntax/semantics-checked locally with ecj against minimal Android API stubs (scripts/javastub) before pushing. --- .../com/deivid22srk/rechan/GameActivity.java | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java b/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java index 03cb21c..7e270fe 100644 --- a/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java +++ b/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java @@ -1,6 +1,8 @@ package com.deivid22srk.rechan; import android.app.NativeActivity; +import android.content.Context; +import android.hardware.input.InputManager; import android.os.Build; import android.os.Bundle; import android.view.InputDevice; @@ -23,7 +25,9 @@ * comes back when the last pad disconnects. */ public class GameActivity extends NativeActivity - implements InputDevice.InputDeviceListener { + implements InputManager.InputDeviceListener { + + private InputManager inputManager; @Override protected void onCreate(Bundle savedInstanceState) { @@ -36,7 +40,10 @@ protected void onCreate(Bundle savedInstanceState) { // Watch for physical gamepads connecting/disconnecting while the // activity lives. Null handler -> callbacks on the main thread // (this thread, which has a Looper). - InputDevice.registerInputDeviceListener(this, null); + inputManager = (InputManager) getSystemService(Context.INPUT_SERVICE); + if (inputManager != null) { + inputManager.registerInputDeviceListener(this, null); + } updatePhysicalGamepadState(); } @@ -50,11 +57,13 @@ protected void onResume() { @Override protected void onDestroy() { - InputDevice.unregisterInputDeviceListener(this); + if (inputManager != null) { + inputManager.unregisterInputDeviceListener(this); + } super.onDestroy(); } - // --- InputDevice.InputDeviceListener --- + // --- InputManager.InputDeviceListener --- @Override public void onInputDeviceAdded(int deviceId) { From d32f6cb46bd5ab12530ec09f692fde171d110d29 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Thu, 27 Aug 2026 11:26:03 +0000 Subject: [PATCH 25/31] Filter Xiaomi ghost gamepads so the contextual HUD shows Xiaomi/MIUI exposes fingerprint readers (uinput-fpc, uinput-goodix, uinput-synaptics, uinput-elan, uinput-vfs, uinput-atrus) as input devices whose sources mask lies: SOURCE_GAMEPAD|SOURCE_JOYSTICK are set while isVirtual() returns false (documented in Godot #47656, libgdx #5596, Unity forums, GameMaker on Redmi). The pad-presence scan saw one of these at boot on every Xiaomi/Redmi/POCO phone, so IsPhysicalPadConnected() stuck true and the contextual touch HUD never appeared. Fix, engine + Java working together: - GameActivity: skip devices named uinput-* in the physical-pad scan (the filter the whole industry converged on); push the ghost device ids into the engine alongside the presence flag. - androidbridge: lock-free ghost-id blacklist (single Java writer, ids-then-count release ordering; input thread reads a consistent prefix). - AndroidInput: swallow events from ghost device ids entirely - they can no longer confirm pad presence nor inject phantom buttons/axes (fingerprint swipe gestures would otherwise drive the virtual stick). - HudBridge/touchhud: nativeSetGhostPadDeviceIds JNI bridge. Real Bluetooth/USB pads are unaffected; TVs remotes/keyboards still don't count; virtual pads (scrcpy-style streaming) still confirm presence via events, which is correct. --- android/app/src/main/cpp/AndroidInput.cpp | 17 +++ .../com/deivid22srk/rechan/GameActivity.java | 102 ++++++++++++------ .../com/deivid22srk/rechan/hud/HudBridge.java | 11 ++ src/extra/touchhud.cpp | 22 ++++ vendor/libp3d/pddi/gles/AndroidPlatform.cpp | 30 ++++++ vendor/libp3d/pddi/gles/AndroidPlatform.h | 17 +++ 6 files changed, 168 insertions(+), 31 deletions(-) diff --git a/android/app/src/main/cpp/AndroidInput.cpp b/android/app/src/main/cpp/AndroidInput.cpp index 67470c5..55e9fbd 100644 --- a/android/app/src/main/cpp/AndroidInput.cpp +++ b/android/app/src/main/cpp/AndroidInput.cpp @@ -226,6 +226,14 @@ bool HandleKeyEvent(AInputEvent* event) { const int32_t keyCode = AKeyEvent_getKeyCode(event); const bool down = AKeyEvent_getAction(event) == AKEY_EVENT_ACTION_DOWN; + // Ghost devices (Xiaomi uinput fingerprint readers posing as gamepads, + // see AndroidPlatform.h): swallow their events entirely — no phantom + // buttons, no pad-presence confirm. The id blacklist is pushed by + // GameActivity's InputDevice scan. + if (androidbridge::IsGhostPadDeviceId(AInputEvent_getDeviceId(event))) { + return true; + } + if (IsMenuKey(keyCode)) { androidbridge::PostGamepadConnected(true); androidbridge::PostGamepadButton(GamepadButton::Start, down); @@ -273,6 +281,15 @@ bool HandleMotionEvent(AInputEvent* event) { return true; } + // Ghost devices (Xiaomi uinput fingerprint readers posing as gamepads/ + // joysticks, see AndroidPlatform.h): swallow — their gesture events + // must neither confirm pad presence nor drive the virtual stick + // (swipes on the fingerprint sensor would walk the character by + // themselves). + if (androidbridge::IsGhostPadDeviceId(AInputEvent_getDeviceId(event))) { + return true; + } + if ((source & kSourceGamepadMask) == 0) { return false; } diff --git a/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java b/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java index 7e270fe..52eccfc 100644 --- a/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java +++ b/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java @@ -5,11 +5,15 @@ import android.hardware.input.InputManager; import android.os.Build; import android.os.Bundle; +import android.util.Log; import android.view.InputDevice; import android.view.View; import android.view.WindowInsets; import android.view.WindowInsetsController; +import java.util.ArrayList; +import java.util.List; + import com.deivid22srk.rechan.hud.HudBridge; /** @@ -27,7 +31,10 @@ public class GameActivity extends NativeActivity implements InputManager.InputDeviceListener { + private static final String TAG = "ReChan"; + private InputManager inputManager; + private Boolean lastPushedPhysicalPad = null; @Override protected void onCreate(Bundle savedInstanceState) { @@ -81,48 +88,81 @@ public void onInputDeviceChanged(int deviceId) { } private void updatePhysicalGamepadState() { - HudBridge.nativeSetPhysicalGamepad(hasPhysicalGamepad()); + boolean physicalPad = false; + final List ghostIds = new ArrayList<>(); + // No early return: even after a real pad is found the scan keeps + // going so EVERY ghost device id reaches the engine's blacklist. + for (int id : InputDevice.getDeviceIds()) { + InputDevice device = InputDevice.getDevice(id); + if (device == null) continue; + if (isGhostInputDevice(device)) { + ghostIds.add(id); + continue; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && device.isVirtual()) { + continue; + } + if (isPhysicalGamepadDevice(device)) { + physicalPad = true; + } + } + HudBridge.nativeSetPhysicalGamepad(physicalPad); + final int[] ghosts = new int[ghostIds.size()]; + for (int i = 0; i < ghosts.length; i++) ghosts[i] = ghostIds.get(i); + HudBridge.nativeSetGhostPadDeviceIds(ghosts); + if (lastPushedPhysicalPad == null || lastPushedPhysicalPad != physicalPad) { + Log.i(TAG, "physical gamepad " + + (physicalPad ? "connected" : "disconnected") + + " (ghost devices filtered: " + ghosts.length + ")"); + lastPushedPhysicalPad = physicalPad; + } + } + + /** + * Xiaomi/MIUI exposes fingerprint readers and other sensor hubs through + * uinput as input devices that LIE in their sources mask: SOURCE_GAMEPAD + * and SOURCE_JOYSTICK are set while isVirtual() reports false. Without + * filtering, every Xiaomi/Redmi/POCO phone sees a "physical gamepad" at + * boot and the touch HUD never appears. Known ghost names: uinput-fpc, + * uinput-goodix, uinput-synaptics, uinput-elan, uinput-vfs, + * uinput-atrus (documented across engines: Godot #47656, libgdx #5596, + * Unity forums, GameMaker on Redmi). None of them can play the game. + */ + private static boolean isGhostInputDevice(InputDevice device) { + final String name = device.getName(); + if (name == null) return false; + final String n = name.trim().toLowerCase(); + return n.startsWith("uinput-") || n.equals("uinput"); } /** - * True while at least one non-virtual input device reports gamepad or - * joystick sources (joystick-source devices must really expose stick - * axes). Deliberately excludes: + * True for real, playable pads (Bluetooth/USB). Ghost devices (uinput-*) + * and virtual devices must be filtered BEFORE calling this. Also + * deliberately excludes: * - keyboards (SOURCE_KEYBOARD) — they can't play; * - DPAD-only TV remotes (SOURCE_DPAD) — no analog stick, no face * buttons; the touch HUD stays useful with them. * Same criteria as the (disabled) HudController.isRealGamepad. */ - private static boolean hasPhysicalGamepad() { - final int[] ids = InputDevice.getDeviceIds(); - for (int id : ids) { - InputDevice device = InputDevice.getDevice(id); - if (device == null) continue; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && device.isVirtual()) { - continue; - } - final int sources = device.getSources(); - final boolean gamepadSrc = - (sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD; - final boolean joystickSrc = - (sources & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK; - if (!gamepadSrc && !joystickSrc) continue; - if (joystickSrc && !gamepadSrc) { - // Joystick source without stick axes: some odd devices report - // the source bit but expose no ranges — not a playable pad. - boolean hasStickAxes = false; - for (InputDevice.MotionRange range : device.getMotionRanges()) { - if ((range.getSource() & InputDevice.SOURCE_JOYSTICK) - == InputDevice.SOURCE_JOYSTICK) { - hasStickAxes = true; - break; - } + private static boolean isPhysicalGamepadDevice(InputDevice device) { + final int sources = device.getSources(); + final boolean gamepadSrc = + (sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD; + final boolean joystickSrc = + (sources & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK; + if (!gamepadSrc && !joystickSrc) return false; + if (joystickSrc && !gamepadSrc) { + // Joystick source without stick axes: some odd devices report + // the source bit but expose no ranges — not a playable pad. + for (InputDevice.MotionRange range : device.getMotionRanges()) { + if ((range.getSource() & InputDevice.SOURCE_JOYSTICK) + == InputDevice.SOURCE_JOYSTICK) { + return true; } - if (!hasStickAxes) continue; } - return true; + return false; } - return false; + return true; } // --- Immersive fullscreen --- diff --git a/android/app/src/main/java/com/deivid22srk/rechan/hud/HudBridge.java b/android/app/src/main/java/com/deivid22srk/rechan/hud/HudBridge.java index f2b2813..8d06998 100644 --- a/android/app/src/main/java/com/deivid22srk/rechan/hud/HudBridge.java +++ b/android/app/src/main/java/com/deivid22srk/rechan/hud/HudBridge.java @@ -49,6 +49,17 @@ private HudBridge() {} */ public static native void nativeSetPhysicalGamepad(boolean connected); + /** + * Device ids of "ghost" input devices that must never be treated as + * gamepads: Xiaomi/MIUI exposes its fingerprint reader (uinput-fpc, + * uinput-goodix, uinput-synaptics, ...) with SOURCE_GAMEPAD| + * SOURCE_JOYSTICK sources while isVirtual() reports false. The engine + * swallows events from these ids so they can neither confirm + * physical-pad presence nor inject phantom buttons/axes (fingerprint + * swipe gestures would otherwise drive the virtual stick). + */ + public static native void nativeSetGhostPadDeviceIds(int[] deviceIds); + /** Current gameplay context (touchhud::HudContext). */ public static native int nativePollContext(); } diff --git a/src/extra/touchhud.cpp b/src/extra/touchhud.cpp index 18cb0fa..a2a9795 100644 --- a/src/extra/touchhud.cpp +++ b/src/extra/touchhud.cpp @@ -233,6 +233,28 @@ Java_com_deivid22srk_rechan_hud_HudBridge_nativeSetPhysicalGamepad( androidbridge::SetPhysicalPadConnected(connected == JNI_TRUE); } +JNIEXPORT void JNICALL +Java_com_deivid22srk_rechan_hud_HudBridge_nativeSetGhostPadDeviceIds( + JNIEnv* env, jclass, jintArray ids) { + // Device ids of "ghost" pads (Xiaomi uinput fingerprint readers that + // report SOURCE_GAMEPAD|SOURCE_JOYSTICK while isVirtual() is false). + // AndroidInput.cpp swallows their events so they can neither confirm + // physical-pad presence nor inject phantom buttons/axes. + if (env == nullptr || ids == nullptr) { + androidbridge::SetGhostPadDeviceIds(nullptr, 0); + return; + } + const jsize n = env->GetArrayLength(ids); + if (n <= 0) { + androidbridge::SetGhostPadDeviceIds(nullptr, 0); + return; + } + jint buf[16]; + const jsize count = n < 16 ? n : 16; + env->GetIntArrayRegion(ids, 0, count, buf); + androidbridge::SetGhostPadDeviceIds(buf, count); +} + } // extern "C" #endif // RC_PLATFORM_ANDROID diff --git a/vendor/libp3d/pddi/gles/AndroidPlatform.cpp b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp index cb89cfe..29411e7 100644 --- a/vendor/libp3d/pddi/gles/AndroidPlatform.cpp +++ b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp @@ -24,6 +24,12 @@ std::atomic g_padConnected{true}; // InputDevice listener and by native gamepad-sourced events; read once per // frame by the game thread. std::atomic g_physicalPadConnected{false}; +// Ghost pad device ids (see AndroidPlatform.h): Xiaomi uinput fingerprint +// readers posing as gamepads. Written by the Java thread only (ids first, +// count last with release); read from the input thread. +constexpr int32_t kMaxGhostPadDevices = 16; +std::atomic g_ghostPadIds[kMaxGhostPadDevices] = {}; +std::atomic g_ghostPadCount{0}; std::atomic g_hudContext{0}; std::atomic g_touchX{0.0f}; std::atomic g_touchY{0.0f}; @@ -102,6 +108,30 @@ bool IsPhysicalPadConnected() { return g_physicalPadConnected.load(std::memory_order_relaxed); } +void SetGhostPadDeviceIds(const int32_t* ids, int32_t count) { + if (ids == nullptr) count = 0; + if (count > kMaxGhostPadDevices) count = kMaxGhostPadDevices; + if (count < 0) count = 0; + // Ids first, count last (release): a reader that observes the new count + // also observes every id under it; a reader still on the old count may + // see stale ids beyond it, which is harmless (those devices are gone + // and can no longer send events). + for (int32_t i = 0; i < count; ++i) { + g_ghostPadIds[i].store(ids[i], std::memory_order_relaxed); + } + g_ghostPadCount.store(count, std::memory_order_release); +} + +bool IsGhostPadDeviceId(int32_t deviceId) { + const int32_t n = g_ghostPadCount.load(std::memory_order_acquire); + for (int32_t i = 0; i < n; ++i) { + if (g_ghostPadIds[i].load(std::memory_order_relaxed) == deviceId) { + return true; + } + } + return false; +} + PadSnapshot LoadPadSnapshot() { PadSnapshot snap; snap.heldButtons = g_heldButtons.load(std::memory_order_relaxed); diff --git a/vendor/libp3d/pddi/gles/AndroidPlatform.h b/vendor/libp3d/pddi/gles/AndroidPlatform.h index a23414e..8865bac 100644 --- a/vendor/libp3d/pddi/gles/AndroidPlatform.h +++ b/vendor/libp3d/pddi/gles/AndroidPlatform.h @@ -64,6 +64,23 @@ PadSnapshot LoadPadSnapshot(); void SetPhysicalPadConnected(bool connected); bool IsPhysicalPadConnected(); +// --- Ghost pad devices (Java InputDevice scan -> input thread) --- +// Xiaomi/MIUI exposes fingerprint readers and other sensor hubs through +// uinput (uinput-fpc, uinput-goodix, uinput-synaptics, uinput-elan, +// uinput-vfs, uinput-atrus) as input devices whose sources mask LIES: +// SOURCE_GAMEPAD|SOURCE_JOYSTICK are set while isVirtual() reports false. +// Without filtering, every Xiaomi/Redmi/POCO phone sees a "physical +// gamepad" at boot and the touch HUD never appears (documented across +// engines: Godot #47656, libgdx #5596, Unity forums, GameMaker on Redmi). +// GameActivity pushes the ghost device ids it finds; AndroidInput.cpp then +// swallows their events entirely - no phantom pad-presence confirm, no +// phantom buttons/axes (fingerprint swipe gestures would otherwise drive +// the virtual stick). Single writer (Java thread): ids are stored first +// and the count last (release), so readers always observe a consistent +// prefix. +void SetGhostPadDeviceIds(const int32_t* ids, int32_t count); +bool IsGhostPadDeviceId(int32_t deviceId); + // --- Touch HUD context (game thread -> UI thread) --- // Published by the game loop each frame; the Java overlay polls it to decide // which on-screen controls to show. Values match touchhud::HudContext. From d83a24d687b981d0e210f8bb67143d2082157b1e Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Thu, 27 Aug 2026 11:57:12 +0000 Subject: [PATCH 26/31] Add touch support to Level Results screen (tally) + show buttons on Game Over/title The Level Results screen ("Press [A] to continue", GameState::EndLevelLoop) had no touch path: it fell into ComputeContext()'s default -> HudContext:: Hidden, so the virtual buttons never showed and touch-only players were stuck the same way they were on the Game Over screen before. - touchhud ComputeContext: EndLevelLoop -> HudContext::Menu. The tally already accepts exactly what the navigation set provides - A held/ pressed = fast-forward the count (ACTION_MENU_CONFIRM, hdTally::Update) and A/Start = confirm the "done" prompt (hdTally::DoDoneStuff) - and this state renders through MenuDraw -> MenuRender -> HUD::Display, so the on-screen controls draw automatically. - touchhud PublishFrame: tap-anywhere = A pulse on EndLevelLoop, matching the Game Over / legal-splash behaviour (tap during the count fast- forwards it, tap on the prompt leaves the screen). - game.cpp gsEndGameLoopState: call touchcontrols::Render() on Android. The Game Over screen renders on its own path (no HUD::Display), so the virtual A/Start buttons were ACTIVE but INVISIBLE; touch players only had the blind tap-anywhere pulse. - game.cpp gsTitleLoopState (title menu active): same - the title menu renders via its own path, draw the touch controls explicitly like the pause menu does via CustomMenuRender. Full state audit (all 27 GameStates + hdTally/hdDestSelect/movie loops): every other screen already has a touch path - custom menus take direct taps, PlayMovie has the skip button, press-start/Game Over/splash have tap pulses, loads and transitions take no input. ErrorLoop is a latent (non-touch) dead-end, unreachable through normal play. --- src/extra/touchhud.cpp | 11 +++++++++++ src/gen/game.cpp | 15 +++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/extra/touchhud.cpp b/src/extra/touchhud.cpp index a2a9795..620c97e 100644 --- a/src/extra/touchhud.cpp +++ b/src/extra/touchhud.cpp @@ -65,6 +65,10 @@ HudContext ComputeContext() { case GameState::EndGameLoop: // EndGameLoop = Game Over screen ("Press A to continue"): needs the // navigation set so touch-only players can confirm and leave it. + case GameState::EndLevelLoop: + // EndLevelLoop = Level Results / tally ("Press [A] to continue"): + // A fast-forwards the count and confirms, Start closes it too - + // the navigation set covers everything a gamepad player uses. return HudContext::Menu; case GameState::Intro: @@ -130,6 +134,10 @@ void PublishFrame() { // input, hence the gameOverFadeType gate) // - Intro legal splash : A (AnyJustPressed skips the splash; on PC // any mouse button does the same) + // - EndLevelLoop : A (Level Results tally: during the count + // A = fast-forward - same as holding A on + // a real pad; once "Press [A] to continue" + // shows, A confirms and leaves the screen) // Two frames so the pad edge is sampled reliably. Suspended while a // physical gamepad is connected: the real pad's own Start/A must drive // these screens, and a stray screen tap must not. @@ -150,6 +158,9 @@ void PublishFrame() { && s_confirmPulseFrames == 0) { s_confirmPulseFrames = 2; } + else if (st == GameState::EndLevelLoop && s_confirmPulseFrames == 0) { + s_confirmPulseFrames = 2; + } } } if (s_startPulseFrames > 0) { diff --git a/src/gen/game.cpp b/src/gen/game.cpp index 822bc93..99d21bf 100644 --- a/src/gen/game.cpp +++ b/src/gen/game.cpp @@ -1187,6 +1187,13 @@ bool Game::gsTitleLoopState(Game* game) { if (renderMgr) { renderMgr->Render(); } +#if defined(RC_PLATFORM_ANDROID) + // The title menu runs on its own render path (no HUD::Display), so + // draw the on-screen touch controls explicitly - otherwise the + // virtual joystick/A/B/Start stay active but invisible, unlike the + // pause menu which renders them via CustomMenuRender. + touchcontrols::Render(); +#endif g_display->EndFrame(); if (menuResult == 4) { @@ -2186,6 +2193,14 @@ bool Game::gsEndGameLoopState(Game* game) { game->gameOverScreen->Render(); #endif } +#if defined(RC_PLATFORM_ANDROID) + // This state renders through its own path (no HUD::Display), so without + // this call the virtual A/Start buttons stay ACTIVE yet INVISIBLE on the + // "Press A to continue" screen - touch-only players had only the + // tap-anywhere pulse. Draw the on-screen controls explicitly, same as + // the pause menu does via CustomMenuRender. + touchcontrols::Render(); +#endif if (game->gameOverFadeType != 0) { s32 stillFading = FadeUpdate(); From f500c11da607caa5e8564acc9f1bf0c8be892e53 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Thu, 27 Aug 2026 12:59:30 +0000 Subject: [PATCH 27/31] Add touch steppers (< value >) to settings menus Tapping an adjustable row (Frame Rate, Anti-Aliasing, Language, volume sliders, ...) ran Confirm(), which is a no-op for most List bindings - the value could only be changed with the virtual D-pad. Adjustable rows now render their value flanked by '<' and '>' arrows. The arrows' screen positions are measured during RenderCurrentPage() (TextManager::MeasureString) and recorded as tap zones that ProcessTouchTaps() consumes: tapping '<' steps the value down, '>' steps it up (same Adjust() path as MENU_LEFT/RIGHT, including the staged commit flow for Resolution/ScreenMode/MSAA). Tapping elsewhere on the row keeps the previous select+confirm behaviour. Applies to every adjustable row across Display, Sound, Controller and Cheats pages (List, Slider and Toggle entries). Desktop/other builds render and behave exactly as before. --- src/extra/fecustommenumgr.cpp | 262 ++++++++++++++++++++++++++-------- src/extra/fecustommenumgr.h | 35 +++++ 2 files changed, 241 insertions(+), 56 deletions(-) diff --git a/src/extra/fecustommenumgr.cpp b/src/extra/fecustommenumgr.cpp index 7c14dd3..ad5f1d9 100644 --- a/src/extra/fecustommenumgr.cpp +++ b/src/extra/fecustommenumgr.cpp @@ -1,5 +1,6 @@ #include "gen/common.h" #include "fecustommenumgr.h" +#include #include #include "gen/display.h" #include "pc/inputaction.h" @@ -1235,11 +1236,46 @@ void feCustomMenuMgr::ProcessTouchTaps() { const s32 rowH = DEF_ROW_STEP + GetEntryExtraHeight(page, page.entries[i]); if (psxY >= (f32)rowTop && psxY < (f32)(rowTop + rowH)) { if (page.entries[i].type != EntryType_Info) { - LOG("[MenuTouch] tap page=%d entry=%d psx=(%.1f,%.1f) -> confirm", - (s32)m_currPage, i, psxX, psxY); - m_cursor = i; - PlaySound(FE_SND_MENU_5); - Confirm(); + // Adjustable rows render "< value >" steppers: a tap on + // an arrow steps the value instead of confirming, so + // touch users can change List/Slider/Toggle settings + // without the virtual D-pad. + const TouchStepperZone* zone = FindTouchStepperZone(i); + s32 adjustDir = 0; + if (zone) { + if (tapX >= zone->leftMidX - zone->grabHalfW + && tapX <= zone->leftMidX + zone->grabHalfW) { + adjustDir = -1; + } + else if (tapX >= zone->rightMidX - zone->grabHalfW + && tapX <= zone->rightMidX + zone->grabHalfW) { + adjustDir = 1; + } + } + + if (i != m_cursor) { + // Leaving a (possibly staged) row: same cleanup + // MoveCursor() performs for pad/keyboard navigation. + ClearPendingDisplayStaging(); + } + + if (adjustDir != 0) { + m_cursor = i; + const s32 prevVal = GetBoundValue(page.entries[i]); + Adjust(adjustDir); + if (prevVal != GetBoundValue(page.entries[i])) { + PlayValueChangeFeedback(page.entries[i]); + } + LOG("[MenuTouch] tap page=%d entry=%d psx=(%.1f,%.1f) -> adjust %d", + (s32)m_currPage, i, psxX, psxY, adjustDir); + } + else { + LOG("[MenuTouch] tap page=%d entry=%d psx=(%.1f,%.1f) -> confirm", + (s32)m_currPage, i, psxX, psxY); + m_cursor = i; + PlaySound(FE_SND_MENU_5); + Confirm(); + } } break; } @@ -2298,6 +2334,9 @@ void feCustomMenuMgr::SetPage(MenuPage page) { m_scrollBarDragging = false; m_keyBindCaptureActive = false; m_keyBindCaptureBlockFrames = 0; + // Arrow tap zones belong to the previous page's layout; invalidate them + // until the next RenderCurrentPage() re-records the new page's rows. + ClearTouchStepperZones(); if (m_currPage == MenuPage_Title) { if (SaveGameHasAutosave()) { @@ -4311,6 +4350,138 @@ static void DrawSliderCircleMeterPSX(s32 rightX, s32 textY, s32 value, tTexture* DrawSliderCircleMeterPSX((f32)rightX, (f32)textY, (f32)value, sliderOTex, sliderFTex); } +// --- Android touch steppers ("< value >") --------------------------------- +// Adjustable menu rows (List/Slider/Toggle) are useless to a touch player +// without the virtual D-pad: tapping the row runs Confirm(), which is a no-op +// for most List bindings. These helpers flank the rendered value with "<" and +// ">" arrows and record each arrow's screen-pixel tap zone so +// ProcessTouchTaps() can step the value when the arrow itself is tapped. +// Note: '<' only starts a prompt token when followed by a known "" +// pattern, so "< 30 >" renders as literal glyphs (Basic Latin range). +void feCustomMenuMgr::RecordTouchStepperZone(s32 entryIndex, f32 leftMidX, f32 rightMidX, f32 grabHalfW) { + if (entryIndex < 0 || entryIndex >= MAX_ENTRIES_PER_MENU) { + return; + } + for (s32 i = 0; i < m_touchStepperZoneCount; i++) { + if (m_touchStepperZones[i].entryIndex == entryIndex) { + m_touchStepperZones[i].leftMidX = leftMidX; + m_touchStepperZones[i].rightMidX = rightMidX; + m_touchStepperZones[i].grabHalfW = grabHalfW; + return; + } + } + if (m_touchStepperZoneCount >= MAX_ENTRIES_PER_MENU) { + return; + } + m_touchStepperZones[m_touchStepperZoneCount].entryIndex = entryIndex; + m_touchStepperZones[m_touchStepperZoneCount].leftMidX = leftMidX; + m_touchStepperZones[m_touchStepperZoneCount].rightMidX = rightMidX; + m_touchStepperZones[m_touchStepperZoneCount].grabHalfW = grabHalfW; + m_touchStepperZoneCount++; +} + +const feCustomMenuMgr::TouchStepperZone* feCustomMenuMgr::FindTouchStepperZone(s32 entryIndex) const { + for (s32 i = 0; i < m_touchStepperZoneCount; i++) { + if (m_touchStepperZones[i].entryIndex == entryIndex) { + return &m_touchStepperZones[i]; + } + } + return nullptr; +} + +void feCustomMenuMgr::ClearPendingDisplayStaging() { + m_pendingResolutionActive = false; + m_pendingScreenModeActive = false; + m_pendingMsaaActive = false; +} + +void feCustomMenuMgr::PrintValueWithTouchSteppers(s32 entryIndex, const char* valueText, + f32 valueScreenX, f32 rowScreenY, + bool selected, + const xcColour1555& selectedColor, + const xcColour1555& normalColor) { + if (!g_textManager || !valueText) { + return; + } + + const xcColour1555& color = selected ? selectedColor : normalColor; + +#if defined(RC_PLATFORM_ANDROID) + char stepperBuf[96]; + std::snprintf(stepperBuf, sizeof(stepperBuf), "< %s >", valueText); + + g_textManager->SetAlignment(TextAlign_Right); + g_textManager->SetColor(color.GetRed8(), color.GetGreen8(), color.GetBlue8()); + g_textManager->PrintString(stepperBuf, valueScreenX, rowScreenY); + + // Record tap zones for the arrows. The string is right-aligned at + // valueScreenX, so the '<' sits at the far left of the measured block and + // the '>' ends exactly at valueScreenX. Widths come back in screen pixels + // (they include the text manager's current scale). + const TextBounds total = g_textManager->MeasureString(stepperBuf); + if (total.width > 0.0f) { + const TextBounds leftArrow = g_textManager->MeasureString("<"); + const TextBounds rightArrow = g_textManager->MeasureString(">"); + const f32 leftMidX = valueScreenX - total.width + leftArrow.width * 0.5f; + const f32 rightMidX = valueScreenX - rightArrow.width * 0.5f; + const f32 arrowW = std::max(leftArrow.width, rightArrow.width); + const f32 grabHalfW = std::max(arrowW * 0.75f, 20.0f); + RecordTouchStepperZone(entryIndex, leftMidX, rightMidX, grabHalfW); + } +#else + (void)entryIndex; + g_textManager->SetAlignment(TextAlign_Right); + g_textManager->SetColor(color.GetRed8(), color.GetGreen8(), color.GetBlue8()); + g_textManager->PrintString(valueText, valueScreenX, rowScreenY); +#endif +} + +void feCustomMenuMgr::DrawSliderTouchSteppers(s32 entryIndex, f32 rightX, f32 textY, f32 value, + bool selected, + const xcColour1555& selectedColor, + const xcColour1555& normalColor) { + // The meter itself is drawn on every platform with unchanged geometry + // (DrawSliderCircleMeterPSX is a file-static defined above). On Android + // the meter is additionally flanked by "<" ">" stepper arrows. + DrawSliderCircleMeterPSX(rightX, textY, value, m_sliderOTex, m_sliderFTex); + +#if defined(RC_PLATFORM_ANDROID) + if (!g_textManager) { + return; + } + + // Meter occupies [rightX - segments*step, rightX - 6] in PSX pixels; + // flank it with '<' and '>' arrows (same font/scale as row labels). + const f32 meterLeftX = rightX + - (f32)DEF_SLIDER_CIRCLE_SEGMENTS * (f32)DEF_SLIDER_CIRCLE_STEP; + const f32 leftEdgeX = SCALE_AND_CENTER_X(meterLeftX - 5.0f); + const f32 rightStartX = SCALE_AND_CENTER_X(rightX - 2.0f); + const f32 arrowScreenY = SCREEN_SCALE_Y(textY); + const xcColour1555& color = selected ? selectedColor : normalColor; + + g_textManager->SetAlignment(TextAlign_Right); + g_textManager->SetColor(color.GetRed8(), color.GetGreen8(), color.GetBlue8()); + g_textManager->PrintString("<", leftEdgeX, arrowScreenY); + + g_textManager->SetAlignment(TextAlign_Left); + g_textManager->PrintString(">", rightStartX, arrowScreenY); + + const TextBounds leftArrow = g_textManager->MeasureString("<"); + const TextBounds rightArrow = g_textManager->MeasureString(">"); + const f32 leftMidX = leftEdgeX - leftArrow.width * 0.5f; + const f32 rightMidX = rightStartX + rightArrow.width * 0.5f; + const f32 arrowW = std::max(leftArrow.width, rightArrow.width); + const f32 grabHalfW = std::max(arrowW * 0.75f, 20.0f); + RecordTouchStepperZone(entryIndex, leftMidX, rightMidX, grabHalfW); +#else + (void)entryIndex; + (void)selected; + (void)selectedColor; + (void)normalColor; +#endif +} +// --- end Android touch steppers ------------------------------------------- + static void DrawUniformBorderRectPSX(f32 x, f32 y, f32 w, f32 h, f32 borderPx, u8 r, u8 g, u8 b, u8 a) { if (w <= 0 || h <= 0 || borderPx <= 0.0f) @@ -5217,6 +5388,10 @@ void feCustomMenuMgr::RenderCurrentPage() { && m_currPage != MenuPage_Changelog #endif ) { + // Adjustable rows re-record their "< value >" arrow tap zones below; + // start every frame from a clean slate so entries that stopped + // rendering (or pages whose rows shifted) can't keep stale zones. + ClearTouchStepperZones(); for (s32 i = 0; i < page->numEntries; i++) { const Entry& item = page->entries[i]; const bool selected = (i == m_cursor); @@ -5294,11 +5469,8 @@ void feCustomMenuMgr::RenderCurrentPage() { continue; } - g_textManager->SetAlignment(TextAlign_Right); - g_textManager->SetColor(selected ? selectedColor.GetRed8() : normalColor.GetRed8(), - selected ? selectedColor.GetGreen8() : normalColor.GetGreen8(), - selected ? selectedColor.GetBlue8() : normalColor.GetBlue8()); - g_textManager->PrintString(resText, valueScreenX, rowScreenY); + PrintValueWithTouchSteppers(i, resText, valueScreenX, rowScreenY, + selected, selectedColor, normalColor); } else if (item.binding == EntryBinding_DisplayScreenMode) { s32 mode = GetBoundValue(item); @@ -5313,11 +5485,8 @@ void feCustomMenuMgr::RenderCurrentPage() { modeText = Localize("FE_SCW"); } - g_textManager->SetAlignment(TextAlign_Right); - g_textManager->SetColor(selected ? selectedColor.GetRed8() : normalColor.GetRed8(), - selected ? selectedColor.GetGreen8() : normalColor.GetGreen8(), - selected ? selectedColor.GetBlue8() : normalColor.GetBlue8()); - g_textManager->PrintString(modeText, valueScreenX, rowScreenY); + PrintValueWithTouchSteppers(i, modeText, valueScreenX, rowScreenY, + selected, selectedColor, normalColor); } else if (item.binding == EntryBinding_DisplayMsaa) { s32 msaaIndex = GetBoundValue(item); @@ -5334,11 +5503,8 @@ void feCustomMenuMgr::RenderCurrentPage() { continue; } - g_textManager->SetAlignment(TextAlign_Right); - g_textManager->SetColor(selected ? selectedColor.GetRed8() : normalColor.GetRed8(), - selected ? selectedColor.GetGreen8() : normalColor.GetGreen8(), - selected ? selectedColor.GetBlue8() : normalColor.GetBlue8()); - g_textManager->PrintString(msaaText, valueScreenX, rowScreenY); + PrintValueWithTouchSteppers(i, msaaText, valueScreenX, rowScreenY, + selected, selectedColor, normalColor); } #if MODERN_GRAPHICS else if (item.binding == EntryBinding_DisplayShadowQuality) { @@ -5354,11 +5520,8 @@ void feCustomMenuMgr::RenderCurrentPage() { continue; } - g_textManager->SetAlignment(TextAlign_Right); - g_textManager->SetColor(selected ? selectedColor.GetRed8() : normalColor.GetRed8(), - selected ? selectedColor.GetGreen8() : normalColor.GetGreen8(), - selected ? selectedColor.GetBlue8() : normalColor.GetBlue8()); - g_textManager->PrintString(qualityText, valueScreenX, rowScreenY); + PrintValueWithTouchSteppers(i, qualityText, valueScreenX, rowScreenY, + selected, selectedColor, normalColor); } #endif else if (item.binding == EntryBinding_DisplayFrameRate) { @@ -5369,11 +5532,8 @@ void feCustomMenuMgr::RenderCurrentPage() { continue; } - g_textManager->SetAlignment(TextAlign_Right); - g_textManager->SetColor(selected ? selectedColor.GetRed8() : normalColor.GetRed8(), - selected ? selectedColor.GetGreen8() : normalColor.GetGreen8(), - selected ? selectedColor.GetBlue8() : normalColor.GetBlue8()); - g_textManager->PrintString(frameRateText, valueScreenX, rowScreenY); + PrintValueWithTouchSteppers(i, frameRateText, valueScreenX, rowScreenY, + selected, selectedColor, normalColor); } else if (item.binding == EntryBinding_ControllerPromptStyle) { const s32 style = ClampControllerPromptStyle(GetBoundValue(item)); @@ -5383,11 +5543,8 @@ void feCustomMenuMgr::RenderCurrentPage() { continue; } - g_textManager->SetAlignment(TextAlign_Right); - g_textManager->SetColor(selected ? selectedColor.GetRed8() : normalColor.GetRed8(), - selected ? selectedColor.GetGreen8() : normalColor.GetGreen8(), - selected ? selectedColor.GetBlue8() : normalColor.GetBlue8()); - g_textManager->PrintString(styleText, valueScreenX, rowScreenY); + PrintValueWithTouchSteppers(i, styleText, valueScreenX, rowScreenY, + selected, selectedColor, normalColor); } else if (item.binding == EntryBinding_PlayerConfig) { const s32 cfg = GetBoundValue(item); @@ -5400,11 +5557,8 @@ void feCustomMenuMgr::RenderCurrentPage() { continue; } - g_textManager->SetAlignment(TextAlign_Right); - g_textManager->SetColor(selected ? selectedColor.GetRed8() : normalColor.GetRed8(), - selected ? selectedColor.GetGreen8() : normalColor.GetGreen8(), - selected ? selectedColor.GetBlue8() : normalColor.GetBlue8()); - g_textManager->PrintString(cfgText, valueScreenX, rowScreenY); + PrintValueWithTouchSteppers(i, cfgText, valueScreenX, rowScreenY, + selected, selectedColor, normalColor); } else if (item.binding == EntryBinding_Language) { const char* langToken = GetLanguageDisplayToken(GetBoundValue(item)); @@ -5414,11 +5568,8 @@ void feCustomMenuMgr::RenderCurrentPage() { continue; } - g_textManager->SetAlignment(TextAlign_Right); - g_textManager->SetColor(selected ? selectedColor.GetRed8() : normalColor.GetRed8(), - selected ? selectedColor.GetGreen8() : normalColor.GetGreen8(), - selected ? selectedColor.GetBlue8() : normalColor.GetBlue8()); - g_textManager->PrintString(langText, valueScreenX, rowScreenY); + PrintValueWithTouchSteppers(i, langText, valueScreenX, rowScreenY, + selected, selectedColor, normalColor); } } else if (item.type == EntryType_Slider && item.binding != EntryBinding_None) { @@ -5428,12 +5579,14 @@ void feCustomMenuMgr::RenderCurrentPage() { selected ? selectedColor.GetBlue8() : normalColor.GetBlue8()); g_textManager->PrintString(label, labelScreenX, rowScreenY); - DrawSliderCircleMeterPSX( - rowValueX, - rowY, - GetBoundValue(item), - m_sliderOTex, - m_sliderFTex); + DrawSliderTouchSteppers( + i, + (f32)rowValueX, + (f32)rowY, + (f32)GetBoundValue(item), + selected, + selectedColor, + normalColor); } else if (item.type == EntryType_Toggle && item.binding != EntryBinding_None) { const s32 toggle = GetBoundValue(item); @@ -5450,11 +5603,8 @@ void feCustomMenuMgr::RenderCurrentPage() { selected ? selectedColor.GetBlue8() : normalColor.GetBlue8()); g_textManager->PrintString(label, labelScreenX, rowScreenY); - g_textManager->SetAlignment(TextAlign_Right); - g_textManager->SetColor(selected ? selectedColor.GetRed8() : normalColor.GetRed8(), - selected ? selectedColor.GetGreen8() : normalColor.GetGreen8(), - selected ? selectedColor.GetBlue8() : normalColor.GetBlue8()); - g_textManager->PrintString(toggleText, valueScreenX, rowScreenY); + PrintValueWithTouchSteppers(i, toggleText, valueScreenX, rowScreenY, + selected, selectedColor, normalColor); } else { g_textManager->SetAlignment(TextAlign_Center); diff --git a/src/extra/fecustommenumgr.h b/src/extra/fecustommenumgr.h index a909851..c14659c 100644 --- a/src/extra/fecustommenumgr.h +++ b/src/extra/fecustommenumgr.h @@ -450,6 +450,37 @@ class feCustomMenuMgr { // Android: process queued touchscreen taps against the current page's // entries (direct activation, no mouse emulation). void ProcessTouchTaps(); + + // Android touch steppers: adjustable rows (List/Slider/Toggle) render their + // value flanked by "<" and ">" arrows; tapping an arrow steps the value. + // The arrow glyph positions are measured during RenderCurrentPage() and + // stored as screen-pixel tap zones (m_touchStepperZones) that + // ProcessTouchTaps() consumes on the following frames. + struct TouchStepperZone { + s32 entryIndex; + f32 leftMidX; // screen px, center of the '<' glyph + f32 rightMidX; // screen px, center of the '>' glyph + f32 grabHalfW; // generous tap radius around each arrow center + }; + void ClearTouchStepperZones() { m_touchStepperZoneCount = 0; } + void RecordTouchStepperZone(s32 entryIndex, f32 leftMidX, f32 rightMidX, f32 grabHalfW); + const TouchStepperZone* FindTouchStepperZone(s32 entryIndex) const; + // Discard staged Resolution/ScreenMode/MSAA values (same cleanup MoveCursor + // performs when the cursor leaves a staged row). + void ClearPendingDisplayStaging(); + // Prints a right-aligned value text; on Android it is flanked by "< >" + // stepper arrows and the arrow tap zones are recorded. + void PrintValueWithTouchSteppers(s32 entryIndex, const char* valueText, + f32 valueScreenX, f32 rowScreenY, + bool selected, + const xcColour1555& selectedColor, + const xcColour1555& normalColor); + // Draws a slider's circle meter; on Android it is flanked by "< >" + // stepper arrows and the arrow tap zones are recorded. + void DrawSliderTouchSteppers(s32 entryIndex, f32 rightX, f32 textY, f32 value, + bool selected, + const xcColour1555& selectedColor, + const xcColour1555& normalColor); s32 GetBoundValue(const Entry& e) const; void ApplyValue(const Entry& e, s32 v); void PlayValueChangeFeedback(const Entry& entry); @@ -606,6 +637,10 @@ class feCustomMenuMgr { s32 m_cursor = 0; s32 m_result = 1; ColorPulse m_pulse{ xcColour1555(132, 0, 0), xcColour1555(224, 146, 0), 0.5f }; + // Android touch: "< value >" arrow tap zones for the current page's + // adjustable rows (screen px, refreshed every RenderCurrentPage()). + TouchStepperZone m_touchStepperZones[MAX_ENTRIES_PER_MENU] = {}; + s32 m_touchStepperZoneCount = 0; // Resolution selection is staged while focused and committed on confirm. bool m_pendingResolutionActive = false; s32 m_pendingResolutionIndex = 0; From 45769562a8ffd7a412b95b58e48a7c43de8dab4b Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Thu, 27 Aug 2026 13:30:19 +0000 Subject: [PATCH 28/31] Hide redundant virtual gamepad on touch-operable menus Custom menu pages that direct touch already covers (title, options, settings, cheats, confirms, pause, location) no longer show the on-screen joystick/A/B/Start overlay - taps and < value > steppers drive everything, so the pad was pure visual clutter. The pad stays on pages that taps cannot fully operate: Key Bindings, Mods and the save-slot tables (scrolling + non-generic row layouts need virtual D-pad navigation), plus Changelog on updater builds. Non-interactive Menu-context states (press-start, Game Over, Level Results, transitions) also hide the pad: the existing tap-anywhere Start/A pulses cover them, and a physical pad is unaffected. --- src/extra/fecustommenumgr.cpp | 38 +++++++++++++++++++++++++++++++++++ src/extra/fecustommenumgr.h | 7 +++++++ src/extra/touchhud.cpp | 28 +++++++++++++++++--------- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/extra/fecustommenumgr.cpp b/src/extra/fecustommenumgr.cpp index ad5f1d9..5ef7562 100644 --- a/src/extra/fecustommenumgr.cpp +++ b/src/extra/fecustommenumgr.cpp @@ -558,6 +558,44 @@ static bool IsSaveSlotPage(MenuPage page) { || page == MenuPage_DeleteSlots; } +bool feCustomMenuMgr::NeedsVirtualPadNavigation() const { + switch (m_currPage) { + case MenuPage_KeyBindings: + // Own row/slot grid layout (RenderKeyBindingsPage) + scrolling: + // taps don't hit its rows, and the list scrolls with Up/Down. + return true; + +#ifdef MOD_LOADER + case MenuPage_Mods: + // Mod list is rendered by RenderModsPage and scrolls with + // Up/Down - the virtual stick is the only touch scroll path. + return true; +#endif + + case MenuPage_LoadSlots: + case MenuPage_SaveSlots: + case MenuPage_DeleteSlots: + // Slot table (RenderSaveSlotsPage) scrolls with Up/Down; its + // rows are NOT laid out by the generic entry layout that + // ProcessTouchTaps() hit-tests, so the stick + A are required. + return true; + +#if AUTO_UPDATER + case MenuPage_Changelog: + // Scrolling text: Up/Down (or mouse wheel, which Android has + // none of). Not compiled on Android, kept correct elsewhere. + return true; +#endif + + default: + // Generic list pages (Title/Frontend/Pause/StartGame/Options/ + // Controller/Display/Sound/Cheats/confirms/Location/AssetMissing): + // every row is directly tappable and adjustable rows carry + // "< value >" steppers - the on-screen pad would be redundant. + return false; + } +} + static s32 SaveSlotToDisplayIndex(s32 slotIndex); static s32 SaveDisplayIndexToSlot(s32 displayIndex); diff --git a/src/extra/fecustommenumgr.h b/src/extra/fecustommenumgr.h index c14659c..6883795 100644 --- a/src/extra/fecustommenumgr.h +++ b/src/extra/fecustommenumgr.h @@ -407,6 +407,13 @@ class feCustomMenuMgr { bool IsMouseActive() const { return m_mouseInputActive; } MenuPage GetCurrentPage() const { return m_currPage; } + // True when the current page cannot be fully operated by direct touch + // (ProcessTouchTaps + steppers): scrolling lists, table layouts and the + // key-binding grid still need virtual D-pad navigation. Generic list + // pages (title/options/settings/cheats/confirms/location) are fully + // tappable, so the on-screen gamepad there is redundant clutter. + bool NeedsVirtualPadNavigation() const; + void RenderAutosaveSpinner(s32 centerX, s32 centerY, f32 alpha01 = 1.0f) const; // Called by settings.cpp when the controller-prompt-style setting changes, diff --git a/src/extra/touchhud.cpp b/src/extra/touchhud.cpp index 620c97e..a1b59de 100644 --- a/src/extra/touchhud.cpp +++ b/src/extra/touchhud.cpp @@ -43,10 +43,15 @@ bool IsClimbState(s32 actionState) { } HudContext ComputeContext() { - // Custom menu overlays (asset setup, pause, title/FE menus) always win: - // they need d-pad style navigation, available with or without a gamepad. + // Custom menu overlays (asset setup, pause, title/FE menus): the generic + // list pages are fully touch-operable (direct entry taps + "< value >" + // steppers), so the on-screen pad there is redundant clutter - hide it. + // Only the pages that cannot be driven by taps (scrolling lists, slot + // tables, key-binding grid) keep the d-pad navigation set. if (g_feCustomMenuMgr && g_feCustomMenuMgr->IsActive()) { - return HudContext::Menu; + return g_feCustomMenuMgr->NeedsVirtualPadNavigation() + ? HudContext::Menu + : HudContext::Hidden; } if (!g_game) { @@ -54,6 +59,16 @@ HudContext ComputeContext() { } switch (g_game->GetState()) { + // These states are only reached WITHOUT an active custom menu in + // non-interactive moments (transitions or "press a button" screens): + // - Title/TitleLoop: press-start phase - a tap anywhere pulses + // Start (PublishFrame), so no visible pad is needed; + // - OpenFE/FE/Menu/LocationMenu/OpenLocationMenu: 1-frame + // transitions or frames where the menu is not up yet; + // - DbgMenu: empty stub, nothing interactive; + // - EndGameLoop: Game Over "press A to continue" - tap pulses A; + // - EndLevelLoop: Level Results tally - tap pulses A (fast-forward + // during the count, confirm after). case GameState::Title: case GameState::TitleLoop: case GameState::OpenFE: @@ -63,13 +78,8 @@ HudContext ComputeContext() { case GameState::LocationMenu: case GameState::OpenLocationMenu: case GameState::EndGameLoop: - // EndGameLoop = Game Over screen ("Press A to continue"): needs the - // navigation set so touch-only players can confirm and leave it. case GameState::EndLevelLoop: - // EndLevelLoop = Level Results / tally ("Press [A] to continue"): - // A fast-forwards the count and confirms, Start closes it too - - // the navigation set covers everything a gamepad player uses. - return HudContext::Menu; + return HudContext::Hidden; case GameState::Intro: // Legal splash auto-expires, but a tap can skip it (see the From bf7106899b989ae539dcb9cbf4e1ba5dc2915bfa Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Thu, 27 Aug 2026 14:07:29 +0000 Subject: [PATCH 29/31] Implement runtime MSAA on GLES + mark app as game Anti-Aliasing in Display Settings always reverted to Off on Android because glesDisplay::SetMSAA was a no-op and GetMSAA() always returned 0; Display::SetMSAA() reads the value back, so every commit reset the setting (and saved 0 to the config). The EGL config cannot change while the GL context lives (that would destroy every GL object), so AA is now done through an offscreen multisampled framebuffer: each frame renders into a window-sized multisampled FBO (RGBA8 + depth24/stencil8 renderbuffers) and is resolved onto the window framebuffer with a blit right before the swap. Sample count changes apply instantly from the settings menu; the request is clamped to GL_MAX_SAMPLES with graceful fallback (halve samples, then off). GetMSAA() reports the count actually applied so the menu and the saved config stay honest. Also tags the app as a game (android:appCategory="game" + android:isGame) so launchers, Game Mode and Play Games recognise it. --- android/app/src/main/AndroidManifest.xml | 2 + vendor/libp3d/pddi/gles/glesrender.cpp | 220 ++++++++++++++++++++++- vendor/libp3d/pddi/gles/glesrender.h | 26 ++- 3 files changed, 242 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 71ac199..f9acf49 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,8 @@ diff --git a/vendor/libp3d/pddi/gles/glesrender.cpp b/vendor/libp3d/pddi/gles/glesrender.cpp index faf1a9a..069a1bf 100644 --- a/vendor/libp3d/pddi/gles/glesrender.cpp +++ b/vendor/libp3d/pddi/gles/glesrender.cpp @@ -1061,6 +1061,9 @@ glesDisplay::~glesDisplay() { if (!dpy) { return; } + // Free the MSAA framebuffer while the context may still be current; + // with no current context these calls are harmless no-ops. + DestroyMsaaFramebuffer(); eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); if (eglContext_) { eglDestroyContext(dpy, static_cast(eglContext_)); @@ -1192,9 +1195,209 @@ bool glesDisplay::InitDisplay(const pddiDisplayInit& init) { eglDisplay_ = dpy; eglSurface_ = surface; eglContext_ = context; + + // Honour a startup sample count (tContextInitData::msaa). The offscreen + // MSAA framebuffer is created lazily on the first BeginMsaaFrame. + if (init.msaa > 0) { + SetMSAA(init.msaa); + } return true; } +// ---- glesDisplay MSAA ------------------------------------------------- +// The window surface's EGL config is fixed once the context exists (changing +// it would require destroying the context and every GL object with it), so +// anti-aliasing is done through an offscreen multisampled framebuffer: each +// frame is rendered into it and resolved onto the window framebuffer with a +// blit right before the swap. The sample count can therefore change at +// runtime, instantly, from the settings menu. + +static int ClampGlesMsaaSamples(int samples) { + if (samples <= 0) { + return 0; + } + + static const int kAllowed[] = { 2, 4, 8, 16 }; + int closest = kAllowed[0]; + int closestDist = (samples > closest) ? (samples - closest) : (closest - samples); + for (u32 i = 1; i < (u32)(sizeof(kAllowed) / sizeof(kAllowed[0])); i++) { + const int candidate = kAllowed[i]; + const int dist = (samples > candidate) ? (samples - candidate) : (candidate - samples); + if (dist < closestDist) { + closest = candidate; + closestDist = dist; + } + } + + GLint maxSamples = 0; + glGetIntegerv(GL_MAX_SAMPLES, &maxSamples); + if (maxSamples > 0 && closest > maxSamples) { + int fallback = 0; + for (u32 i = 0; i < (u32)(sizeof(kAllowed) / sizeof(kAllowed[0])); i++) { + if (kAllowed[i] <= maxSamples) { + fallback = kAllowed[i]; + } + } + closest = fallback; + } + + return closest; +} + +void glesDisplay::DestroyMsaaFramebuffer() { + msaaFrameActive = false; + msaaAppliedSamples = 0; + msaaWidth = 0; + msaaHeight = 0; + if (msaaColorRbo) { + glDeleteRenderbuffers(1, &msaaColorRbo); + msaaColorRbo = 0; + } + if (msaaDepthRbo) { + glDeleteRenderbuffers(1, &msaaDepthRbo); + msaaDepthRbo = 0; + } + if (msaaFramebuffer) { + glDeleteFramebuffers(1, &msaaFramebuffer); + msaaFramebuffer = 0; + } +} + +bool glesDisplay::EnsureMsaaFramebuffer(int w, int h) { + if (msaaFramebuffer && msaaAppliedSamples > 0 && + msaaWidth == w && msaaHeight == h) { + return true; + } + + GLint prevFbo = 0; + glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFbo); + + DestroyMsaaFramebuffer(); + int samples = ClampGlesMsaaSamples(msaaRequestedSamples); + if (w <= 0 || h <= 0 || samples <= 0) { + glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prevFbo); + return false; + } + + // Try the requested count, halving on failure until 2x; give up (0) if + // multisampled renderbuffers are not supported at all. + while (samples >= 2) { + while (glGetError() != GL_NO_ERROR) {} // drain stale errors before probing + + GLuint fbo = 0, color = 0, depth = 0; + glGenFramebuffers(1, &fbo); + glGenRenderbuffers(1, &color); + glGenRenderbuffers(1, &depth); + + glBindRenderbuffer(GL_RENDERBUFFER, color); + glRenderbufferStorageMultisample(GL_RENDERBUFFER, samples, GL_RGBA8, w, h); + glBindRenderbuffer(GL_RENDERBUFFER, depth); + glRenderbufferStorageMultisample(GL_RENDERBUFFER, samples, GL_DEPTH24_STENCIL8, w, h); + + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_RENDERBUFFER, color); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, + GL_RENDERBUFFER, depth); + + const bool complete = + glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE && + glGetError() == GL_NO_ERROR; + + if (complete) { + msaaFramebuffer = fbo; + msaaColorRbo = color; + msaaDepthRbo = depth; + msaaWidth = w; + msaaHeight = h; + msaaAppliedSamples = samples; + glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prevFbo); + LogGles("glesDisplay: MSAA %dx enabled (framebuffer %dx%d)", samples, w, h); + return true; + } + + glDeleteFramebuffers(1, &fbo); + glDeleteRenderbuffers(1, &color); + glDeleteRenderbuffers(1, &depth); + samples /= 2; + } + + while (glGetError() != GL_NO_ERROR) {} + glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prevFbo); + LogGles("glesDisplay: MSAA unavailable (requested %d), rendering without AA", + msaaRequestedSamples); + // Hard failure: drop the request so BeginMsaaFrame does not retry the + // allocation every frame; a new SetMSAA re-arms it. GetMSAA() reports 0 + // so callers see that AA is actually off. + msaaRequestedSamples = 0; + return false; +} + +void glesDisplay::SetMSAA(int samples) { + // Remember the request; without a current GL context (init not done, or + // the surface is parked while paused) the framebuffer is created lazily + // by the first BeginMsaaFrame after (re)activation. + msaaRequestedSamples = samples > 0 ? samples : 0; + + if (!eglContext_ || !eglSurface_) { + return; + } + + if (msaaRequestedSamples <= 0) { + DestroyMsaaFramebuffer(); + return; + } + + EnsureMsaaFramebuffer(width, height); +} + +bool glesDisplay::BeginMsaaFrame(int viewportW, int viewportH) { + if (msaaRequestedSamples <= 0) { + return false; + } + + if (!msaaFramebuffer || msaaAppliedSamples <= 0 || + msaaWidth != viewportW || msaaHeight != viewportH) { + if (!EnsureMsaaFramebuffer(viewportW, viewportH)) { + return false; + } + } + + glBindFramebuffer(GL_FRAMEBUFFER, msaaFramebuffer); + glViewport(0, 0, msaaWidth, msaaHeight); + glScissor(0, 0, msaaWidth, msaaHeight); + glEnable(GL_SCISSOR_TEST); + msaaFrameActive = true; + return true; +} + +void glesDisplay::ResolveMsaaFrame() { + if (!msaaFrameActive || !msaaFramebuffer) { + return; + } + msaaFrameActive = false; + + GLboolean prevColorMask[4] = { GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE }; + glGetBooleanv(GL_COLOR_WRITEMASK, prevColorMask); + const GLboolean prevScissor = glIsEnabled(GL_SCISSOR_TEST); + + glBindFramebuffer(GL_READ_FRAMEBUFFER, msaaFramebuffer); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); + // The blit is the multisample resolve; scissor and colour-mask state + // would clip it, so neutralize both for the duration of the copy. + glDisable(GL_SCISSOR_TEST); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + glBlitFramebuffer(0, 0, msaaWidth, msaaHeight, + 0, 0, msaaWidth, msaaHeight, + GL_COLOR_BUFFER_BIT, GL_NEAREST); + + glColorMask(prevColorMask[0], prevColorMask[1], prevColorMask[2], prevColorMask[3]); + if (prevScissor) { + glEnable(GL_SCISSOR_TEST); + } + glBindFramebuffer(GL_FRAMEBUFFER, 0); +} + void glesDisplay::SwapBuffers() { if (!eglDisplay_ || !eglSurface_) return; eglSwapBuffers(static_cast(eglDisplay_), static_cast(eglSurface_)); @@ -1318,16 +1521,25 @@ void glesContext::BeginFrame() { int vx, vy, vw, vh; display->GetViewport(vx, vy, vw, vh); - glBindFramebuffer(GL_FRAMEBUFFER, 0); - glViewport(vx, vy, vw, vh); - glScissor(vx, vy, vw, vh); - glEnable(GL_SCISSOR_TEST); + if (display->BeginMsaaFrame(vw, vh)) { + // Rendering into the offscreen multisampled framebuffer this frame; + // it already set the viewport/scissor to the window-sized target. + } + else { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glViewport(vx, vy, vw, vh); + glScissor(vx, vy, vw, vh); + glEnable(GL_SCISSOR_TEST); + } stateDirty = true; activeRenderTarget = nullptr; } void glesContext::EndFrame() { glDisable(GL_SCISSOR_TEST); + // Resolve the multisampled frame (if AA is on) onto the window + // framebuffer before the swap. + display->ResolveMsaaFrame(); glFlush(); } diff --git a/vendor/libp3d/pddi/gles/glesrender.h b/vendor/libp3d/pddi/gles/glesrender.h index 977e875..8d4bb8a 100644 --- a/vendor/libp3d/pddi/gles/glesrender.h +++ b/vendor/libp3d/pddi/gles/glesrender.h @@ -194,8 +194,16 @@ class glesDisplay : public pddiDisplay { bool IsBorderless() override { return false; } void SetResolution(int w, int h) override { width = w; height = h; } void SetVSync(bool enabled) override; - void SetMSAA(int) override {} - int GetMSAA() override { return 0; } + void SetMSAA(int samples) override; + int GetMSAA() override { return msaaAppliedSamples; } + + // Runtime-switchable AA: the EGL config stays single-sample (changing it + // would mean destroying the GL context), so frames render into an + // offscreen multisampled framebuffer that is resolved onto the window + // framebuffer right before the swap. glesContext calls BeginMsaaFrame at + // BeginFrame and ResolveMsaaFrame at EndFrame. + bool BeginMsaaFrame(int viewportW, int viewportH); + void ResolveMsaaFrame(); void SetWindowPos(int, int) override {} void SetTitle(const char*) override {} @@ -220,6 +228,20 @@ class glesDisplay : public pddiDisplay { void* eglSurfaceWindow_ = nullptr; // ANativeWindow the surface was created from unsigned long long surfaceGen_ = 0; // androidbridge generation at creation #endif + + // Offscreen multisampled framebuffer (runtime AA). All fields are only + // touched from the game thread, which owns the GL context. + void DestroyMsaaFramebuffer(); + bool EnsureMsaaFramebuffer(int w, int h); + int msaaRequestedSamples = 0; // last requested sample count (0 = off) + int msaaAppliedSamples = 0; // samples actually allocated (0 = off) + u32 msaaFramebuffer = 0; + u32 msaaColorRbo = 0; + u32 msaaDepthRbo = 0; + int msaaWidth = 0; + int msaaHeight = 0; + bool msaaFrameActive = false; // MSAA framebuffer bound for the current frame + int width = 1280; int height = 720; bool shouldClose = false; From 32e313bca1410e5da61a057ece75532d777149b8 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Thu, 27 Aug 2026 14:40:58 +0000 Subject: [PATCH 30/31] Apply Anti-Aliasing immediately instead of staging it in Display Settings Anti-Aliasing was one of the three staged bindings (Resolution, Screen Mode, MSAA): the arrows only staged a pending value and it took a separate confirm tap on the value text to commit. Touch users never discovered the confirm step - they tapped the arrow, saw the new value, and watched it snap back to Off as soon as they moved to another row or backed out of the page, because every staging cleanup path (MoveCursor/SetPage/row-tap/mouse-leave) discards pending values. The staging made sense when changing AA could not take effect at runtime, but the GLES backend now switches its multisample framebuffer on the fly (safely clamped to what the device supports), so MSAA is applied the moment the arrow is tapped - exactly like Frame Rate and V-Sync. The displayed value is the read-back effective sample count, so the row always shows what is really active, and ApplyValue persists it to the config immediately. Resolution and Screen Mode keep their staged flow (those changes can blank the display and still deserve a confirm step). Desktop is unaffected beyond sharing the menu code: glDisplay::SetMSAA only toggles GL_MULTISAMPLE. --- src/extra/fecustommenumgr.cpp | 28 +++++++++------------------- src/extra/fecustommenumgr.h | 4 ---- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/extra/fecustommenumgr.cpp b/src/extra/fecustommenumgr.cpp index 5ef7562..30cdbc1 100644 --- a/src/extra/fecustommenumgr.cpp +++ b/src/extra/fecustommenumgr.cpp @@ -2267,9 +2267,6 @@ s32 feCustomMenuMgr::Invoke() { if (prev.binding == EntryBinding_DisplayScreenMode) { m_pendingScreenModeActive = false; } - if (prev.binding == EntryBinding_DisplayMsaa) { - m_pendingMsaaActive = false; - } m_cursor = i; PlaySound(FE_SND_MENU_7); } @@ -2368,7 +2365,6 @@ void feCustomMenuMgr::SetPage(MenuPage page) { m_result = 1; m_pendingResolutionActive = false; m_pendingScreenModeActive = false; - m_pendingMsaaActive = false; m_scrollBarDragging = false; m_keyBindCaptureActive = false; m_keyBindCaptureBlockFrames = 0; @@ -2556,9 +2552,6 @@ void feCustomMenuMgr::MoveCursor(s32 dir) { if (prev.binding == EntryBinding_DisplayScreenMode) { m_pendingScreenModeActive = false; } - if (prev.binding == EntryBinding_DisplayMsaa) { - m_pendingMsaaActive = false; - } } } @@ -2607,10 +2600,6 @@ void feCustomMenuMgr::Confirm() { ApplyValue(*e, m_pendingScreenMode); m_pendingScreenModeActive = false; } - else if (e->binding == EntryBinding_DisplayMsaa && m_pendingMsaaActive) { - ApplyValue(*e, m_pendingMsaaIndex); - m_pendingMsaaActive = false; - } return; } @@ -2965,10 +2954,15 @@ void feCustomMenuMgr::Adjust(s32 dir) { } if (e->binding == EntryBinding_DisplayMsaa) { - const s32 current = m_pendingMsaaActive ? m_pendingMsaaIndex : GetBoundValue(*e); + // Applied immediately, like Frame Rate: the GLES backend swaps its + // multisample framebuffer on the fly (clamped to what the device + // supports, with graceful fallback), so there is nothing risky to + // stage. The old staged flow read as "reverting to Off" to touch + // users - the value only looked applied while the row stayed + // selected, and leaving the row (or backing out) discarded it. + const s32 current = GetBoundValue(*e); const s32 v = WrapStepValue(current, e->step, e->lo, e->hi, dir); - m_pendingMsaaIndex = v; - m_pendingMsaaActive = true; + ApplyValue(*e, v); return; } @@ -4430,7 +4424,6 @@ const feCustomMenuMgr::TouchStepperZone* feCustomMenuMgr::FindTouchStepperZone(s void feCustomMenuMgr::ClearPendingDisplayStaging() { m_pendingResolutionActive = false; m_pendingScreenModeActive = false; - m_pendingMsaaActive = false; } void feCustomMenuMgr::PrintValueWithTouchSteppers(s32 entryIndex, const char* valueText, @@ -5527,10 +5520,7 @@ void feCustomMenuMgr::RenderCurrentPage() { selected, selectedColor, normalColor); } else if (item.binding == EntryBinding_DisplayMsaa) { - s32 msaaIndex = GetBoundValue(item); - if (selected && m_pendingMsaaActive) { - msaaIndex = m_pendingMsaaIndex; - } + const s32 msaaIndex = GetBoundValue(item); const s32 msaaSamples = MsaaOptionIndexToSamples(msaaIndex); const char* msaaToken = (msaaSamples == 0) ? "FE_OFF" diff --git a/src/extra/fecustommenumgr.h b/src/extra/fecustommenumgr.h index 6883795..700dc77 100644 --- a/src/extra/fecustommenumgr.h +++ b/src/extra/fecustommenumgr.h @@ -656,10 +656,6 @@ class feCustomMenuMgr { bool m_pendingScreenModeActive = false; s32 m_pendingScreenMode = 0; - // MSAA selection is staged while focused and committed on confirm. - bool m_pendingMsaaActive = false; - s32 m_pendingMsaaIndex = 0; - // Input mode tracking: when false, mouse hover is ignored until mouse moves/clicks. bool m_mouseInputActive = true; bool m_mousePosInitialized = false; From 1017ef1d8270430bf9e77b405808380ed6e7be40 Mon Sep 17 00:00:00 2001 From: deivid22srk Date: Thu, 27 Aug 2026 16:00:27 +0000 Subject: [PATCH 31/31] Touch-select the destination screen, fix HUD on fresh save, bump 1.0.2 Three fixes for the fresh-install experience (data cleared, no save): 1. Destination select (Level N / grade / dragon grid) is now tappable: the page is fully custom-drawn and its single Confirm entry is invisible, so the generic hit-test left a thin uncued strip in the middle of the panel - touch players read the screen as dead and got stuck (no pad shown either). A tap anywhere on the panel now selects; the bottom prompt row works as drawn (right half, where the 'B Back' prompt sits, backs out to the hub). 2. The contextual HUD never appeared on a fresh save's first hub visit. gsOpenFEState called Activate(MenuPage_Frontend) and then handed off through FE -> OpenLocationMenu -> QueueLevelLoad -> PrePlay -> Play without ever deactivating the menu; nothing in that chain Invokes it (its only Invoke sites are Intro/TitleLoop/Menu/LocationMenu). The stale 'active' flag made ComputeContext report the menu overlay, and since the menu-hiding rework that means Hidden - no HUD, no virtual joystick, hub unplayable until a pause/resume cycle. (Continues never hit this: the pending-load branch skips the Activate.) The dead Activate is removed, and ComputeContext now ignores an active menu in states that never Invoke one, so no future leak can hide the pad during gameplay. 3. Version 1.0.2 (versionCode 2) in gradle + GAME_VERSION. --- android/app/build.gradle.kts | 4 ++-- src/extra/fecustommenumgr.cpp | 29 +++++++++++++++++++++++++++++ src/extra/touchhud.cpp | 31 +++++++++++++++++++++++-------- src/extra/version.h | 2 +- src/gen/game.cpp | 11 ++++++++--- 5 files changed, 63 insertions(+), 14 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index aeaab67..01334be 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -44,8 +44,8 @@ android { applicationId = "com.deivid22srk.rechan" minSdk = 24 targetSdk = 35 - versionCode = 1 - versionName = "1.0.1" + versionCode = 2 + versionName = "1.0.2" ndk { abiFilters += listOf("arm64-v8a") diff --git a/src/extra/fecustommenumgr.cpp b/src/extra/fecustommenumgr.cpp index 30cdbc1..883b26c 100644 --- a/src/extra/fecustommenumgr.cpp +++ b/src/extra/fecustommenumgr.cpp @@ -1266,6 +1266,35 @@ void feCustomMenuMgr::ProcessTouchTaps() { const s32 baseValueX = panelX + page.frameW - DEF_VALUE_X_PAD; const s32 baseCenterX = DEF_WINDOW_CENTER_X; + if (m_currPage == MenuPage_Location) { + // Destination select: a fully custom-drawn page (grade box, dragon + // grid, A/B prompt row) whose only entry is an invisible Confirm + // button. The generic auto-layout hit band for that entry is a + // thin strip in the middle of the panel with no visual cue, so + // touch players read the screen as dead (fresh-run players got + // stuck here: the virtual pad is hidden on this page and there is + // no apparent tap target). Treat the whole panel as one big + // Select button, and let the bottom prompt row work as drawn: + // tapping its right half (where the "B Back" prompt sits) backs + // out to the hub instead of selecting. + const bool inPanelX = psxX >= (f32)panelX && psxX < (f32)(panelX + page.frameW); + const bool inPanelY = psxY >= (f32)panelY && psxY < (f32)(panelY + page.frameH); + if (inPanelX && inPanelY) { + const bool backTap = psxY >= (f32)(panelY + page.frameH - DEF_BOTTOM_BAR_H) + && psxX >= (f32)(panelX + page.frameW / 2); + PlaySound(FE_SND_MENU_5); + if (backTap) { + GoBack(); + } + else { + Confirm(); + } + LOG("[MenuTouch] tap page=%d psx=(%.1f,%.1f) -> location %s", + (s32)m_currPage, psxX, psxY, backTap ? "back" : "select"); + } + continue; + } + if (psxX >= (f32)panelX && psxX < (f32)(panelX + page.frameW)) { for (s32 i = 0; i < page.numEntries; i++) { s32 rowTop = 0; diff --git a/src/extra/touchhud.cpp b/src/extra/touchhud.cpp index a1b59de..6fdc88d 100644 --- a/src/extra/touchhud.cpp +++ b/src/extra/touchhud.cpp @@ -43,15 +43,30 @@ bool IsClimbState(s32 actionState) { } HudContext ComputeContext() { - // Custom menu overlays (asset setup, pause, title/FE menus): the generic - // list pages are fully touch-operable (direct entry taps + "< value >" - // steppers), so the on-screen pad there is redundant clutter - hide it. - // Only the pages that cannot be driven by taps (scrolling lists, slot - // tables, key-binding grid) keep the d-pad navigation set. + // Custom menu overlays (asset setup, pause, title/FE menus, destination + // select): the generic list pages are fully touch-operable (direct entry + // taps + "< value >" steppers), so the on-screen pad there is redundant + // clutter - hide it. Only the pages that cannot be driven by taps + // (scrolling lists, slot tables, key-binding grid) keep the d-pad + // navigation set. + // + // The menu's active flag is only meaningful in the states that actually + // Invoke() it (Intro's gates, TitleLoop's FE menu, Menu pause, + // LocationMenu destination select). A menu that is "active" in any other + // state is stale bookkeeping riding along from a transition - treating it + // as a real overlay would pin the context to Hidden and leave a + // touch-only player without the virtual joystick for the whole level. if (g_feCustomMenuMgr && g_feCustomMenuMgr->IsActive()) { - return g_feCustomMenuMgr->NeedsVirtualPadNavigation() - ? HudContext::Menu - : HudContext::Hidden; + const GameState menuState = g_game ? g_game->GetState() : GameState::Null; + const bool menuInvokedHere = menuState == GameState::Intro + || menuState == GameState::TitleLoop + || menuState == GameState::Menu + || menuState == GameState::LocationMenu; + if (menuInvokedHere) { + return g_feCustomMenuMgr->NeedsVirtualPadNavigation() + ? HudContext::Menu + : HudContext::Hidden; + } } if (!g_game) { diff --git a/src/extra/version.h b/src/extra/version.h index 15e280c..810815f 100644 --- a/src/extra/version.h +++ b/src/extra/version.h @@ -1,7 +1,7 @@ #pragma once // Game title #define JCS_TITLE "ReChan" -#define GAME_VERSION "v1.0.1" +#define GAME_VERSION "v1.0.2" #define UPDATE_REPO_OWNER "SilverwireGames" #define UPDATE_REPO_NAME "ReChan" diff --git a/src/gen/game.cpp b/src/gen/game.cpp index 99d21bf..f4f20ce 100644 --- a/src/gen/game.cpp +++ b/src/gen/game.cpp @@ -1362,10 +1362,15 @@ bool Game::gsOpenFEState(Game* game) { game->SetState(GameState::QueueLevelLoad); } else { -#if CUSTOM_MENU - g_feCustomMenuMgr->Activate(); -#endif // No level selected - show FE menu + // NOTE: intentionally does NOT Activate() the custom menu here. This + // state immediately hands off to FE -> OpenLocationMenu -> + // QueueLevelLoad -> PrePlay -> Play, and none of those states Invoke() + // the custom menu (its only Invoke sites are Intro/TitleLoop/Menu/ + // LocationMenu). A menu left "active" from here would ride along into + // Play as stale bookkeeping and keep the contextual touch HUD (and the + // virtual joystick) hidden for the whole hub visit - exactly what + // happened to fresh-save players on touch-only devices. game->SetState(GameState::FE); } return true;