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..01334be 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -29,12 +29,23 @@ 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 targetSdk = 35 - versionCode = 1 - versionName = "1.0.1" + versionCode = 2 + versionName = "1.0.2" ndk { abiFilters += listOf("arm64-v8a") @@ -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 0000000..59b5dce Binary files /dev/null and b/android/app/rechan.keystore differ 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/android/app/src/main/cpp/AndroidInput.cpp b/android/app/src/main/cpp/AndroidInput.cpp index cb9d06c..55e9fbd 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 = @@ -70,6 +74,111 @@ int HatToButtons(float hatX, float hatY) { return buttons; } +// 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; + +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); + } + else { + s_touchPointer = -1; + 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); + 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: { + // 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); + androidbridge::QueueTouchTap(); + } + } + break; + } + case AMOTION_EVENT_ACTION_MOVE: { + const int32_t pointerCount = AMotionEvent_getPointerCount(event); + for (int32_t i = 0; i < pointerCount; ++i) { + const int32_t pid = AMotionEvent_getPointerId(event, i); + 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(); + } + } + break; + } + case AMOTION_EVENT_ACTION_UP: + case AMOTION_EVENT_ACTION_POINTER_UP: { + const int32_t slot = androidbridge::FindTouchSlotById(pointerId); + if (slot >= 0) { + androidbridge::QueueTouchTapPos(x, y); + androidbridge::ClearTouchPoint(slot); + if (pointerId == s_touchPointer) { + 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; + } +} + 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. @@ -117,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); @@ -125,6 +242,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); } @@ -143,12 +267,42 @@ bool HandleKeyEvent(AInputEvent* event) { bool HandleMotionEvent(AInputEvent* event) { const int32_t source = AInputEvent_getSource(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). Multi-touch slots drive the on-screen controls. + // 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; + } + + // 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; } 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)); @@ -179,6 +333,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: 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/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java b/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java index e61e148..52eccfc 100644 --- a/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java +++ b/android/app/src/main/java/com/deivid22srk/rechan/GameActivity.java @@ -1,17 +1,40 @@ 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.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; + /** * NativeActivity hosting the ReChan GLES3 engine. Immersive fullscreen with * cutout support; all input flows through the native input queue. + * + * 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 InputManager.InputDeviceListener { + + private static final String TAG = "ReChan"; + + private InputManager inputManager; + private Boolean lastPushedPhysicalPad = null; @Override protected void onCreate(Bundle savedInstanceState) { @@ -20,8 +43,130 @@ protected void onCreate(Bundle savedInstanceState) { getWindow().getAttributes().layoutInDisplayCutoutMode = android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; } + + // Watch for physical gamepads connecting/disconnecting while the + // activity lives. Null handler -> callbacks on the main thread + // (this thread, which has a Looper). + inputManager = (InputManager) getSystemService(Context.INPUT_SERVICE); + if (inputManager != null) { + inputManager.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() { + if (inputManager != null) { + inputManager.unregisterInputDeviceListener(this); + } + super.onDestroy(); + } + + // --- InputManager.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() { + 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 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 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; + } + } + return false; + } + return true; + } + + // --- 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 new file mode 100644 index 0000000..8d06998 --- /dev/null +++ b/android/app/src/main/java/com/deivid22srk/rechan/hud/HudBridge.java @@ -0,0 +1,65 @@ +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; + public static final int CONTEXT_MENU = 3; + + // 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); + + /** + * 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); + + /** + * 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/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..2485980 --- /dev/null +++ b/android/app/src/main/java/com/deivid22srk/rechan/hud/HudController.java @@ -0,0 +1,297 @@ +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.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 panelAttached; + private boolean touchPassThrough; + private boolean running; + private int lastContext = -1; + + public HudController(Activity activity) { + this.activity = activity; + } + + /** 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); + + // 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) { + activity.getWindow().getDecorView().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(); + + 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() { + running = false; + handler.removeCallbacks(contextPoller); + if (inputManager != null) { + inputManager.unregisterInputDeviceListener(this); + } + 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; + } + } + + // --- 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) { + // 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 { + hudView.animateVisibility(true, 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) { + if (hudView == null) return; + if (gamepadConnected) { + 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")); + } + } + + /** 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..b2a312a --- /dev/null +++ b/android/app/src/main/java/com/deivid22srk/rechan/hud/TouchHudView.java @@ -0,0 +1,617 @@ +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) { + // INVISIBLE keeps the panel window sized; hidden + // views receive no touch dispatch anyway. + setVisibility(INVISIBLE); + 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. 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 = gameplay && (!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/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/extra/fecustommenumgr.cpp b/src/extra/fecustommenumgr.cpp index 62fc360..883b26c 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" @@ -18,6 +19,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" @@ -554,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); @@ -1124,6 +1166,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); @@ -1158,6 +1218,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) { @@ -1166,6 +1237,121 @@ 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 (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; + 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) { + // 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; + } + } + } + } +} +#endif + static constexpr f32 kPopupFadeSec = 0.15f; static constexpr f32 kPopupCloseGapSec = 0.2f; @@ -1204,6 +1390,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. @@ -2105,9 +2296,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); } @@ -2116,6 +2304,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(); @@ -2189,10 +2394,12 @@ 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; + // 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()) { @@ -2374,9 +2581,6 @@ void feCustomMenuMgr::MoveCursor(s32 dir) { if (prev.binding == EntryBinding_DisplayScreenMode) { m_pendingScreenModeActive = false; } - if (prev.binding == EntryBinding_DisplayMsaa) { - m_pendingMsaaActive = false; - } } } @@ -2425,10 +2629,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; } @@ -2783,10 +2983,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; } @@ -4206,6 +4411,137 @@ 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; +} + +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) @@ -5112,6 +5448,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); @@ -5189,11 +5529,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); @@ -5208,17 +5545,11 @@ 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); - if (selected && m_pendingMsaaActive) { - msaaIndex = m_pendingMsaaIndex; - } + const s32 msaaIndex = GetBoundValue(item); const s32 msaaSamples = MsaaOptionIndexToSamples(msaaIndex); const char* msaaToken = (msaaSamples == 0) ? "FE_OFF" @@ -5229,11 +5560,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) { @@ -5249,11 +5577,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) { @@ -5264,11 +5589,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)); @@ -5278,11 +5600,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); @@ -5295,11 +5614,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)); @@ -5309,11 +5625,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) { @@ -5323,12 +5636,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); @@ -5345,11 +5660,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 6b9cac2..700dc77 100644 --- a/src/extra/fecustommenumgr.h +++ b/src/extra/fecustommenumgr.h @@ -404,8 +404,16 @@ 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; } + // 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, @@ -445,6 +453,41 @@ 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(); + + // 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); @@ -601,6 +644,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; @@ -609,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; diff --git a/src/extra/touchcontrols.cpp b/src/extra/touchcontrols.cpp new file mode 100644 index 0000000..5f34357 --- /dev/null +++ b/src/extra/touchcontrols.cpp @@ -0,0 +1,568 @@ +// 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 +}; + +// 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.950f, 0.690f, 0.062f}, + {"RB", "R1", GamepadButton::RightBumper, 0.950f, 0.520f, 0.050f}, + {"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 (PPSSPP / AetherSX2 style). +// --------------------------------------------------------------------------- + +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. +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 +// --------------------------------------------------------------------------- + +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 +// +// 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_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; + +// --------------------------------------------------------------------------- +// 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 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; +} + +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_btnPointer[i] = -1; + s_btnSeq[i] = 0; + } + } + if (s_joyPointer >= 0) { + ReleaseJoystick(); + } +} + +// 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; + + 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; + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +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) + && !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; + + // 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(); } + } + + // 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); + + // 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 && pts[i].seq == seq) 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; + 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 && !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 && 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; + 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; + } + + // 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 && s_btnSeq[ci] == pts[pi].seq) { + 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, ctx)) continue; + + 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; + 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 f32 w = SCREEN_WIDTH; + const f32 h = SCREEN_HEIGHT; + const f32 base = std::min(w, h); + + const HudContext ctx = touchhud::GetContext(); + + TextManager* tm = g_textManager; + + // Pass 1: draw every visible control's background (filled circle + ring + // + 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; + + 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]; + 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); + + 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 + 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; + FillCircle(x + s_knobX * r, y + s_knobY * r, knobR, knobR, knob); + } + } + + // 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; + 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); + } + } +} + +void Reset() { + ResetAll(); + s_visible = false; + s_alpha = 0.0f; +} + +// --------------------------------------------------------------------------- +// Movie / cutscene skip +// --------------------------------------------------------------------------- + +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, 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); +} + +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; + s_skipSeq = 0; +} + +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]; + const int32_t ptCount = androidbridge::LoadTouchPoints(pts, androidbridge::kMaxTouchPoints); + + const float skipX = SCREEN_WIDTH * 0.90f; + 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. The seq + // makes "lifts" reliable even if Android recycles the pointer id. + 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; + const bool inSkip = (dx * dx + dy * dy <= skipR * skipR * 2.0f); + + 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 && pts[i].seq == s_skipSeq) { + skipPointerDown = true; + } + } + + if (!skipPointerDown) { + s_skipPointer = -1; + s_skipSeq = 0; + } +} + +void RenderMovieSkip() { + if (!s_movieSkippable) return; + if (androidbridge::IsPhysicalPadConnected()) return; + ScreenDraw::Batch batch; + + const float skipX = SCREEN_WIDTH * 0.90f; + const float skipY = SCREEN_HEIGHT * 0.075f; + const float r = std::min(SCREEN_WIDTH, SCREEN_HEIGHT) * 0.055f; + + // 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); + + // 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 * 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); + if (b.width > 0.0f && b.height > 0.0f) { + g_textManager->PrintString(skipLabel, skipX, skipY + r * 1.15f); + } + } +} + +} // namespace touchcontrols + +#endif // RC_PLATFORM_ANDROID diff --git a/src/extra/touchcontrols.h b/src/extra/touchcontrols.h new file mode 100644 index 0000000..becaa68 --- /dev/null +++ b/src/extra/touchcontrols.h @@ -0,0 +1,45 @@ +// 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 ------------------------------------------------- + +// 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(); + +// 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 new file mode 100644 index 0000000..6fdc88d --- /dev/null +++ b/src/extra/touchhud.cpp @@ -0,0 +1,296 @@ +// 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 "extra/touchcontrols.h" +#include "pddi/gles/AndroidPlatform.h" + +namespace touchhud { + +namespace { +HudContext s_context = HudContext::Hidden; +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) { + 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() { + // 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()) { + 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) { + return HudContext::Hidden; + } + + 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: + case GameState::FE: + case GameState::Menu: + case GameState::DbgMenu: + case GameState::LocationMenu: + case GameState::OpenLocationMenu: + case GameState::EndGameLoop: + case GameState::EndLevelLoop: + return HudContext::Hidden; + + 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. + 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; + } +} + +} // 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)); + + // 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 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), + androidbridge::IsPhysicalPadConnected() ? 1 : 0); + } + + // "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) + // - 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. + if (androidbridge::ConsumeTouchTap()) { + const bool menuActive = g_feCustomMenuMgr && g_feCustomMenuMgr->IsActive(); + const GameState st = g_game ? g_game->GetState() : GameState::Null; + if (!menuActive && !androidbridge::IsPhysicalPadConnected()) { + 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; + } + else if (st == GameState::EndLevelLoop && s_confirmPulseFrames == 0) { + s_confirmPulseFrames = 2; + } + } + } + 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; + } + 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. + // + // 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; + ConsumeMenuTap(drainX, drainY); + } +} + +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) --- +// 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()); +} + +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); +} + +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/src/extra/touchhud.h b/src/extra/touchhud.h new file mode 100644 index 0000000..5002b2a --- /dev/null +++ b/src/extra/touchhud.h @@ -0,0 +1,41 @@ +// 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, // 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). +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(); + +// 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/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/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/director.cpp b/src/gen/director.cpp index 6781184..1c5b298 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) { @@ -1880,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; @@ -2237,6 +2283,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 +3058,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; } } 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"; diff --git a/src/gen/game.cpp b/src/gen/game.cpp index 5dc5e37..f4f20ce 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" @@ -1184,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) { @@ -1352,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; @@ -2183,6 +2198,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(); @@ -2295,6 +2318,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); @@ -2324,10 +2353,23 @@ 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(); } + +#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/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); diff --git a/src/gen/main.cpp b/src/gen/main.cpp index b26c7c3..d8101f4 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 @@ -323,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(); } @@ -377,6 +386,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/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/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/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); 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/AndroidPlatform.cpp b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp index 9d24332..29411e7 100644 --- a/vendor/libp3d/pddi/gles/AndroidPlatform.cpp +++ b/vendor/libp3d/pddi/gles/AndroidPlatform.cpp @@ -13,7 +13,41 @@ 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}; +// 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}; +// 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}; +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}; + +// 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}; + std::atomic seq{0}; // bumped on every (re)acquire (DOWN) +}; +TouchSlot g_touchSlots[kMaxTouchPoints]; } // namespace void SetNativeWindow(NativeWindowHandle window) { @@ -66,6 +100,38 @@ 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); +} + +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); @@ -76,6 +142,118 @@ 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); +} + +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); +} + +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; +} + +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) { + // 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 c3aeaed..8865bac 100644 --- a/vendor/libp3d/pddi/gles/AndroidPlatform.h +++ b/vendor/libp3d/pddi/gles/AndroidPlatform.h @@ -52,4 +52,91 @@ 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(); + +// --- 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. +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); + +// --- 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; + // 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(); +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 diff --git a/vendor/libp3d/pddi/gles/glesrender.cpp b/vendor/libp3d/pddi/gles/glesrender.cpp index fc97c3b..069a1bf 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; - } } )"; @@ -1057,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_)); @@ -1188,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_)); @@ -1314,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 8b7f696..8d4bb8a 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 {} @@ -178,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 {} @@ -204,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;