diff --git a/docs/modding.md b/docs/modding.md index 6c45ef92..0b9422d3 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -274,10 +274,11 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`, `worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`, `vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a palette-correct blit of either canvas into an arbitrary screen rect, and the -`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`) -for driving a second physical display. This is what lets a mod lay the two -passes out as two stacked Game Boy screens, or push one onto a second screen, -without the engine knowing the layout. +`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` / +`setEnabled`) for driving a second physical display. `pollTouch()` returns the +oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`. +This is what lets a mod lay the two passes out as two stacked Game Boy screens, +or push one onto a second screen, without the engine knowing the layout. `screen.render_visible` receives `(next, state)` while the main screen is being composed. Return `false` to omit that state from drawing, opacity selection and diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 6cba2fb5..2204e45d 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -1003,4 +1003,33 @@ void love_android_secondary_enable(int on) env->DeleteLocalRef(activity); } +extern "C" __attribute__((visibility("default"))) +const char *love_android_poll_secondary_touch() +{ + static thread_local std::string event; + event.clear(); + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + jmethodID method = env->GetStaticMethodID(activity, "pollSecondaryDisplayTouch", + "()Ljava/lang/String;"); + if (!method) + env->ExceptionClear(); + else + { + jstring value = (jstring) env->CallStaticObjectMethod(activity, method); + if (value) + { + const char *utf = env->GetStringUTFChars(value, nullptr); + if (utf) + { + event = utf; + env->ReleaseStringUTFChars(value, utf); + } + env->DeleteLocalRef(value); + } + } + env->DeleteLocalRef(activity); + return event.empty() ? nullptr : event.c_str(); +} + #endif // LOVE_ANDROID diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 1d70c7fa..09fa2148 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -1325,6 +1325,9 @@ public class GameActivity extends SDLActivity { // in src/jni/love/src/common/android.cpp. private static volatile SecondaryPresentation secondaryPresentation; private static volatile boolean secondaryEnabled = false; + private static final int MAX_SECONDARY_TOUCHES = 32; + private static final java.util.ArrayDeque secondaryTouches = + new java.util.ArrayDeque<>(); @Keep public static void setSecondaryEnabled(final boolean on) { @@ -1377,6 +1380,7 @@ public class GameActivity extends SDLActivity { private static void teardownSecondaryDisplay() { SecondaryPresentation p = secondaryPresentation; secondaryPresentation = null; + synchronized (secondaryTouches) { secondaryTouches.clear(); } if (p != null) { try { p.dismiss(); } catch (Throwable t) {} } @@ -1395,6 +1399,13 @@ public class GameActivity extends SDLActivity { } } + @Keep + public static String pollSecondaryDisplayTouch() { + synchronized (secondaryTouches) { + return secondaryTouches.pollFirst(); + } + } + private static class SecondaryPresentation extends android.app.Presentation { private final FrameView frameView; @@ -1461,6 +1472,7 @@ public class GameActivity extends SDLActivity { private final android.graphics.Paint paint = new android.graphics.Paint(); private final Object lock = new Object(); private int fw, fh; + private int activePointer = -1; FrameView(Context context) { super(context); @@ -1482,6 +1494,52 @@ public class GameActivity extends SDLActivity { postInvalidate(); } + private void enqueueTouch(String event) { + synchronized (secondaryTouches) { + if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) { + secondaryTouches.clear(); + secondaryTouches.addLast("cancel,0,0"); + } else { + secondaryTouches.addLast(event); + } + } + } + + private int logicalX(float x) { + return Math.min(fw - 1, Math.max(0, + (int) ((x - dst.left) * fw / dst.width()))); + } + + private int logicalY(float y) { + return Math.min(fh - 1, Math.max(0, + (int) ((y - dst.top) * fh / dst.height()))); + } + + @Override + public boolean onTouchEvent(android.view.MotionEvent event) { + synchronized (lock) { + int action = event.getActionMasked(); + if (action == android.view.MotionEvent.ACTION_DOWN && fw > 0 + && dst.contains((int) event.getX(), (int) event.getY())) { + activePointer = event.getPointerId(0); + enqueueTouch("down," + logicalX(event.getX()) + "," + + logicalY(event.getY())); + } else if (action == android.view.MotionEvent.ACTION_UP + && activePointer >= 0) { + int index = event.findPointerIndex(activePointer); + if (index >= 0 && fw > 0) { + enqueueTouch("up," + logicalX(event.getX(index)) + "," + + logicalY(event.getY(index))); + } + activePointer = -1; + } else if (action == android.view.MotionEvent.ACTION_CANCEL) { + activePointer = -1; + enqueueTouch("cancel,0,0"); + } + } + return true; + } + @Override protected void onDraw(android.graphics.Canvas canvas) { synchronized (lock) { diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua index c8a6efc3..2e5fb3b5 100644 --- a/src/render/SecondScreen.lua +++ b/src/render/SecondScreen.lua @@ -5,13 +5,15 @@ local SecondScreen = {} local C = nil +local ffi = nil local function log(msg) pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end) end do - local ok, ffi = pcall(require, "ffi") + local ok + ok, ffi = pcall(require, "ffi") if not (ok and ffi) then log("ffi unavailable (not LuaJIT); second display disabled") else @@ -19,6 +21,7 @@ do int love_android_secondary_ready(); void love_android_push_secondary(const void *rgba, int w, int h); void love_android_secondary_enable(int on); + const char *love_android_poll_secondary_touch(); ]]) local okLib, lib = pcall(ffi.load, "love") if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then @@ -51,6 +54,17 @@ function SecondScreen.push(imageData, w, h) end) end +-- Returns the oldest queued secondary-display event as "action,x,y", where +-- coordinates are in the submitted frame's pixel space. +function SecondScreen.pollTouch() + if not C then return nil end + local ok, event = pcall(function() + return C.love_android_poll_secondary_touch() + end) + if not ok or event == nil or event == ffi.NULL then return nil end + return ffi.string(event) +end + function SecondScreen.setEnabled(on) if not C then return end pcall(function() C.love_android_secondary_enable(on and 1 or 0) end) diff --git a/tests/engine/second_screen_touch_test.lua b/tests/engine/second_screen_touch_test.lua new file mode 100644 index 00000000..309ba054 --- /dev/null +++ b/tests/engine/second_screen_touch_test.lua @@ -0,0 +1,42 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local name = "src.render.SecondScreen" +local oldModule = package.loaded[name] +local oldFfi = package.loaded.ffi +local oldPreload = package.preload.ffi +local null = {} +local calls = 0 +local C = { + love_android_secondary_ready = function() return 1 end, + love_android_push_secondary = function() end, + love_android_secondary_enable = function() end, + love_android_poll_secondary_touch = function() + calls = calls + 1 + return calls == 1 and "down,12,34" or null + end, +} +local fakeFfi = { + C = C, + NULL = null, + cdef = function() end, + load = function() return C end, + string = function(value) return value end, +} + +package.loaded[name] = nil +package.loaded.ffi = nil +package.preload.ffi = function() return fakeFfi end + +local SecondScreen = require(name) +T.eq(SecondScreen.pollTouch(), "down,12,34", + "secondary touch reaches the Lua facade") +T.eq(SecondScreen.pollTouch(), nil, "an empty native touch queue returns nil") +C.love_android_poll_secondary_touch = nil +T.eq(SecondScreen.pollTouch(), nil, "an older native bridge remains safe") + +package.loaded[name] = oldModule +package.loaded.ffi = oldFfi +package.preload.ffi = oldPreload + +T.finish("second-screen touch facade")