c24b7f15cb
build / build (push) Successful in 2m14s
Add a player_controls config group that maps game actions to controller inputs, and a Controller Options menu tab to rebind them in-headset. - 45 actions: movement, click/double-click, jump, fire, and the engine's own keyboard hotkeys (posture, save/load, weapon/MFD select, etc.), sent to the engine as synthetic key events. Cheat and dead hotkeys are not exposed. - Press-to-capture rebinding with per-input exclusivity, Reset to Defaults (built-in defaults, then questshock.config.default.json), and new defaults: left stick moves/strafes, right stick turns. - Controller profiles for Touch, Vive and generic OpenXR (res/controller_profiles/), matched to the active interaction profile at runtime, with a fatal dialog if a profile's bindings are rejected at startup. - The menu's thumbstick locomotion is disabled while the Controller Options tab is open or a capture is active. A scrollable list with a tap-to-jump scroll bar handles the long action list. - Opening the menu pauses a running game and resumes it on close. - Reset Position now keeps the player's facing instead of turning the screen to the current gaze. - Play the intro video in the game's language (engine patch 23), falling back to English. - Watchdog and shared-key handling for stuck movement keys, plus key-edge logging.
379 lines
17 KiB
Java
379 lines
17 KiB
Java
package de.ladkau.questshock;
|
|
|
|
import android.app.Activity;
|
|
import android.content.res.ColorStateList;
|
|
import android.graphics.Bitmap;
|
|
import android.graphics.Canvas;
|
|
import android.graphics.Paint;
|
|
import android.graphics.Rect;
|
|
import android.graphics.drawable.ColorDrawable;
|
|
import android.graphics.drawable.Drawable;
|
|
import android.graphics.drawable.StateListDrawable;
|
|
import android.os.SystemClock;
|
|
import android.view.MotionEvent;
|
|
import android.view.View;
|
|
import java.nio.ByteBuffer;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
|
|
/**
|
|
* Shared machinery for an off-screen, never-attached-to-a-window View tree
|
|
* rendered into its own OpenXR quad (see android/app/src/main/cpp/
|
|
* xr_overlay.c, which owns the quad's swapchain and drives whichever
|
|
* concrete subclass via JNI - construction, touch input, and pixel
|
|
* readback). Rendering to a Bitmap and dispatching synthetic MotionEvents
|
|
* into an unattached hierarchy both work the same way they would for an
|
|
* attached View - draw(Canvas)/dispatchTouchEvent() don't require a
|
|
* ViewRootImpl/window, just a measured+laid-out tree.
|
|
*
|
|
* Subclasses (MenuOverlay, KeyboardOverlay) provide their own content via
|
|
* buildContent() and, if they want translucency, contentAlpha() - and, since
|
|
* Java has no static virtual dispatch and each overlay needs its own
|
|
* singleton, each subclass still declares its own thin nativeInit()/
|
|
* nativeTakePixelsIfDirty()/nativeDispatchTouch()/nativeUpdateCursor()
|
|
* static methods (matching what xr_overlay.c's JNI glue resolves by class
|
|
* name) that just delegate into the instance methods here
|
|
* (takePixelsIfDirty()/dispatchTouch()/dispatchUpdateCursor()).
|
|
*/
|
|
abstract class OverlayPanel {
|
|
private static final float CURSOR_RADIUS = 10f;
|
|
|
|
// Button's own click handling (View.onTouchEvent()'s ACTION_UP case)
|
|
// calls View.post(mPerformClick) rather than invoking performClick()
|
|
// directly; post() queues the runnable to run once the view is attached
|
|
// to a window and returns true immediately even when unattached, so on
|
|
// this permanently-unattached tree that queued click silently never
|
|
// fires - dispatchTouchEvent() still delivers the down/up events
|
|
// correctly, only the click callback is swallowed. Registering this as
|
|
// each button's OnTouchListener bypasses that path entirely: returning
|
|
// true here skips View's internal onTouchEvent() (see
|
|
// ViewGroup/View#dispatchTouchEvent), so performClick() (which still
|
|
// runs any OnClickListener set via setOnClickListener()) is called
|
|
// directly instead.
|
|
protected static final View.OnTouchListener CLICK_ON_TOUCH_UP = (v, event) -> {
|
|
switch (event.getAction()) {
|
|
case MotionEvent.ACTION_DOWN:
|
|
v.setPressed(true);
|
|
break;
|
|
case MotionEvent.ACTION_UP:
|
|
v.setPressed(false);
|
|
v.performClick();
|
|
break;
|
|
case MotionEvent.ACTION_CANCEL:
|
|
v.setPressed(false);
|
|
break;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
// A plain, instantly-drawn StateListDrawable rather than the platform
|
|
// default Button background (a Material ripple): ripples animate via
|
|
// continuous invalidation callbacks tied to a real attached window's
|
|
// vsync, which never fires here (this tree is never attached to a
|
|
// window) - so setPressed()/setHovered() state changes would otherwise
|
|
// produce no visible change at all, independent of this project's own
|
|
// touch handling (which already calls setPressed() correctly - see
|
|
// CLICK_ON_TOUCH_UP above). Every button this project creates
|
|
// (MenuOverlay, KeyboardOverlay) should use this (or
|
|
// MenuOverlay.selectableButtonBackground(), which adds a persistent
|
|
// "selected" state on top of the same normal/hovered/pressed colors) -
|
|
// paired with buttonTextColors() below, since the normal/hovered state
|
|
// is light enough to need dark text while pressed/selected need white.
|
|
protected static Drawable buttonBackground() {
|
|
StateListDrawable drawable = new StateListDrawable();
|
|
drawable.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(PRESSED_COLOR));
|
|
drawable.addState(new int[]{android.R.attr.state_hovered}, new ColorDrawable(HOVERED_COLOR));
|
|
drawable.addState(new int[]{}, new ColorDrawable(NORMAL_COLOR));
|
|
return drawable;
|
|
}
|
|
|
|
// See buttonBackground() above - light gray (normal/hovered) needs dark
|
|
// text; the accent colors (pressed, and MenuOverlay's own "selected"
|
|
// state) need white. Every button should get both this and
|
|
// buttonBackground()/selectableButtonBackground() together.
|
|
protected static ColorStateList buttonTextColors() {
|
|
return new ColorStateList(
|
|
new int[][]{
|
|
new int[]{android.R.attr.state_pressed},
|
|
new int[]{android.R.attr.state_selected},
|
|
new int[]{},
|
|
},
|
|
new int[]{0xFFFFFFFF, 0xFFFFFFFF, 0xFF202020});
|
|
}
|
|
|
|
protected static final int NORMAL_COLOR = 0xFFC8C8C8;
|
|
protected static final int HOVERED_COLOR = 0xFFAEC2E8;
|
|
protected static final int PRESSED_COLOR = 0xFF3355AA;
|
|
|
|
protected final Activity activity;
|
|
private final int width;
|
|
private final int height;
|
|
private final View root;
|
|
|
|
// Views registered via registerHoverable() (every button, from both
|
|
// buildContent() and any later-added content) - hit-tested against the
|
|
// cursor position on every updateCursor() call so aiming at (not
|
|
// clicking) one visibly highlights it via android.R.attr.state_hovered
|
|
// (a real View state since API 14, paired with buttonBackground()
|
|
// above).
|
|
private final List<View> hoverableViews = new ArrayList<>();
|
|
private View hoveredView = null;
|
|
|
|
// contentBitmap holds just the panel's own rendered content (no cursor),
|
|
// redrawn only when that content actually changes (redrawContent(), via
|
|
// handleTouch() or a subclass's own key handling) via root.draw() - a
|
|
// full off-screen View-tree traversal that gets noticeably more
|
|
// expensive on a busier content tree. bitmap is what's actually
|
|
// published to native (see takePixelsIfDirty()): compositeAndPublish()
|
|
// cheaply blits contentBitmap plus the cursor circle into it. Splitting
|
|
// these two apart matters because updateCursor() below fires every
|
|
// single frame (~90Hz) while aiming at the panel - if it called the
|
|
// expensive root.draw() path every time (as an earlier version did), a
|
|
// busy content tree made each redraw slow enough that runOnUiThread()
|
|
// posts piled up faster than the UI thread could drain them, so the
|
|
// cursor visibly lagged behind the controller's actual aim instead of
|
|
// tracking it.
|
|
private final Bitmap contentBitmap;
|
|
private final Canvas contentCanvas;
|
|
private final Bitmap bitmap;
|
|
private final Canvas canvas;
|
|
private final Paint cursorPaint;
|
|
// Draws contentBitmap at contentAlpha() - see compositeAndPublish().
|
|
private final Paint contentAlphaPaint;
|
|
|
|
private final Object pixelLock = new Object();
|
|
private byte[] pendingPixels;
|
|
|
|
// Only ever touched on the UI thread (both dispatchUpdateCursor() and
|
|
// compositeAndPublish() run/are posted there) - no lock needed, unlike
|
|
// pendingPixels above.
|
|
private boolean cursorVisible = false;
|
|
private float cursorX = 0f;
|
|
private float cursorY = 0f;
|
|
|
|
protected OverlayPanel(Activity activity, int width, int height) {
|
|
this.activity = activity;
|
|
this.width = width;
|
|
this.height = height;
|
|
|
|
root = buildContent();
|
|
int widthSpec = View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY);
|
|
int heightSpec = View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY);
|
|
root.measure(widthSpec, heightSpec);
|
|
root.layout(0, 0, width, height);
|
|
|
|
contentBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
|
|
contentCanvas = new Canvas(contentBitmap);
|
|
bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
|
|
canvas = new Canvas(bitmap);
|
|
|
|
cursorPaint = new Paint();
|
|
cursorPaint.setColor(0xFFFFFFFF);
|
|
cursorPaint.setAntiAlias(true);
|
|
|
|
// contentAlpha() must not depend on any subclass instance state -
|
|
// it's called here, from the superclass constructor, before the
|
|
// subclass's own field initializers/constructor body have run (the
|
|
// usual Java construction order: this class's fields/constructor
|
|
// first, then the subclass's). A static constant is the only thing
|
|
// that's safe to return.
|
|
contentAlphaPaint = new Paint();
|
|
contentAlphaPaint.setAlpha(contentAlpha());
|
|
|
|
redrawContent();
|
|
}
|
|
|
|
// Builds this overlay's content View tree - called once, from this
|
|
// class's own constructor (see the note on contentAlpha() above: don't
|
|
// rely on subclass instance fields being initialized yet here; if a
|
|
// subclass needs its own mutable state available while building its
|
|
// content, e.g. a list of buttons to later relabel, initialize that
|
|
// field's value at the top of this method instead of via a field
|
|
// initializer).
|
|
protected abstract View buildContent();
|
|
|
|
// 0-255; defaults to fully opaque. Override for translucency (see the
|
|
// constructor's note on what's safe to depend on here).
|
|
protected int contentAlpha() {
|
|
return 255;
|
|
}
|
|
|
|
// Polled once per frame while this overlay's quad is visible (see
|
|
// xr_overlay_render_and_build_layer()) - returns the current pixels
|
|
// only once per redraw (null otherwise), so the native side knows when
|
|
// it can skip re-uploading a texture that hasn't actually changed.
|
|
protected final byte[] takePixelsIfDirty() {
|
|
synchronized (pixelLock) {
|
|
byte[] pixels = pendingPixels;
|
|
pendingPixels = null;
|
|
return pixels;
|
|
}
|
|
}
|
|
|
|
// u/v are this quad's own hit-test coordinates (0..1, top-left origin)
|
|
// - computed by xr_input.c's ray/quad intersection against xr_overlay.c's
|
|
// reported quad geometry, forwarded here as a synthetic tap. Runs on the
|
|
// UI thread since the View/Bitmap/Canvas objects here are otherwise only
|
|
// ever touched from there.
|
|
protected final void dispatchTouch(final float u, final float v, final boolean down) {
|
|
activity.runOnUiThread(() -> handleTouch(u, v, down));
|
|
}
|
|
|
|
// u/v are the same hit-test coordinates dispatchTouch() uses, but
|
|
// polled once per frame regardless of click state rather than only on
|
|
// click edges - lets the cursor track the aim ray continuously instead
|
|
// of only jumping when a trigger is pressed. visible=false (no hand's
|
|
// ray currently on the quad) hides it.
|
|
protected final void dispatchUpdateCursor(final float u, final float v,
|
|
final boolean visible) {
|
|
activity.runOnUiThread(() -> updateCursor(u, v, visible));
|
|
}
|
|
|
|
private void updateCursor(float u, float v, boolean visible) {
|
|
float x = u * width;
|
|
float y = v * height;
|
|
if (visible == cursorVisible && x == cursorX && y == cursorY) {
|
|
return;
|
|
}
|
|
cursorVisible = visible;
|
|
cursorX = x;
|
|
cursorY = y;
|
|
if (updateHoverState(x, y, visible)) {
|
|
// redrawContent() (called inside updateHoverState()) already
|
|
// composited and published this frame's pixels - nothing more
|
|
// to do.
|
|
return;
|
|
}
|
|
compositeAndPublish();
|
|
}
|
|
|
|
// Registers a button (or any View) to be hit-tested against the cursor
|
|
// position - called once per button, alongside buttonBackground()/
|
|
// setOnTouchListener(), from buildContent() (or any later content
|
|
// rebuild).
|
|
protected final void registerHoverable(View view) {
|
|
hoverableViews.add(view);
|
|
}
|
|
|
|
// Clears whatever's currently hovered without waiting for the cursor to
|
|
// actually move off of it - call after hiding/showing content (e.g. a
|
|
// tab switch), since a view that was hovered right before becoming
|
|
// GONE would otherwise still be internally tracked as hovered (and
|
|
// wrongly show that way again) if it's later made VISIBLE again before
|
|
// the cursor position next changes.
|
|
protected final void resetHover() {
|
|
if (hoveredView != null) {
|
|
hoveredView.setHovered(false);
|
|
hoveredView = null;
|
|
}
|
|
}
|
|
|
|
// Absolute bounds of view within this panel's own coordinate space
|
|
// (i.e. relative to root, which is always laid out at (0,0,width,
|
|
// height) - see the constructor) - View's own getLeft()/getTop() are
|
|
// parent-relative, so this walks up the parent chain summing them,
|
|
// additionally subtracting each ancestor's own getScrollX()/getScrollY()
|
|
// (0 for a plain ViewGroup, non-zero for e.g. a ScrollView - see
|
|
// MenuOverlay's Controller Options tab) so a view inside a scrolled
|
|
// container reports its actual on-screen position, not its laid-out one.
|
|
// Assumes view is actually part of this panel's tree (every registered
|
|
// view always is, since registerHoverable() is only ever called from
|
|
// buildContent()).
|
|
private Rect absoluteBounds(View view) {
|
|
int left = 0;
|
|
int top = 0;
|
|
View current = view;
|
|
while (true) {
|
|
left += current.getLeft();
|
|
top += current.getTop();
|
|
if (current == root || !(current.getParent() instanceof View)) {
|
|
break;
|
|
}
|
|
View parent = (View) current.getParent();
|
|
left -= parent.getScrollX();
|
|
top -= parent.getScrollY();
|
|
current = parent;
|
|
}
|
|
return new Rect(left, top, left + view.getWidth(), top + view.getHeight());
|
|
}
|
|
|
|
// Returns true if the hovered view changed (and so redrawContent() -
|
|
// which always publishes too - already ran); false if there's nothing
|
|
// more for updateCursor() to do beyond its own compositeAndPublish().
|
|
private boolean updateHoverState(float x, float y, boolean visible) {
|
|
View newHovered = null;
|
|
if (visible) {
|
|
for (View candidate : hoverableViews) {
|
|
if (candidate.getVisibility() != View.VISIBLE) {
|
|
continue;
|
|
}
|
|
if (absoluteBounds(candidate).contains((int) x, (int) y)) {
|
|
newHovered = candidate;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (newHovered == hoveredView) {
|
|
return false;
|
|
}
|
|
if (hoveredView != null) {
|
|
hoveredView.setHovered(false);
|
|
}
|
|
if (newHovered != null) {
|
|
newHovered.setHovered(true);
|
|
}
|
|
hoveredView = newHovered;
|
|
redrawContent();
|
|
return true;
|
|
}
|
|
|
|
private void handleTouch(float u, float v, boolean down) {
|
|
float x = u * width;
|
|
float y = v * height;
|
|
long time = SystemClock.uptimeMillis();
|
|
MotionEvent event = MotionEvent.obtain(
|
|
time, time, down ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP, x, y, 0);
|
|
try {
|
|
root.dispatchTouchEvent(event);
|
|
} finally {
|
|
event.recycle();
|
|
}
|
|
redrawContent();
|
|
}
|
|
|
|
// Re-runs the full off-screen View-tree draw (expensive - see the
|
|
// contentBitmap field comment above) - call only when the panel's
|
|
// actual content changed, not for the cursor-only updates
|
|
// compositeAndPublish() below handles on its own.
|
|
protected final void redrawContent() {
|
|
contentCanvas.drawColor(0xFF202020);
|
|
root.draw(contentCanvas);
|
|
compositeAndPublish();
|
|
}
|
|
|
|
// Cheap per-frame path: blits the last-rendered contentBitmap (no View
|
|
// traversal) plus the cursor circle into bitmap and publishes it.
|
|
// contentBitmap itself is fully opaque (redrawContent() draws an opaque
|
|
// background first); contentAlpha() translucency is applied right here
|
|
// instead, via contentAlphaPaint - which needs bitmap cleared to fully
|
|
// transparent first, since otherwise each frame's alpha-blended draw
|
|
// would blend against whatever was left over in bitmap from the
|
|
// previous frame rather than against nothing, compounding into a
|
|
// ghosting trail across frames.
|
|
private void compositeAndPublish() {
|
|
bitmap.eraseColor(0);
|
|
canvas.drawBitmap(contentBitmap, 0, 0, contentAlphaPaint);
|
|
if (cursorVisible) {
|
|
canvas.drawCircle(cursorX, cursorY, CURSOR_RADIUS, cursorPaint);
|
|
}
|
|
|
|
byte[] pixels = new byte[width * height * 4];
|
|
// ARGB_8888's actual in-memory byte order is R,G,B,A - matches
|
|
// GL_RGBA/GL_UNSIGNED_BYTE on the native side with no swizzling.
|
|
bitmap.copyPixelsToBuffer(ByteBuffer.wrap(pixels));
|
|
synchronized (pixelLock) {
|
|
pendingPixels = pixels;
|
|
}
|
|
}
|
|
}
|