Files
healer-man/android/app/src/main/java/com/phenomrom/healerman/ControllerBridgeActivity.java
T
2026-09-11 14:26:15 -04:00

268 lines
12 KiB
Java

package com.phenomrom.healerman;
import android.os.Bundle;
import android.os.SystemClock;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowManager;
import androidx.activity.OnBackPressedCallback;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.getcapacitor.BridgeActivity;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/** Routes every Thor controller event into one JavaScript input service without WebView focus. */
public abstract class ControllerBridgeActivity extends BridgeActivity {
private static final float AXIS_DEAD_ZONE = 0.45f;
private static final float TRIGGER_PRESS_THRESHOLD = 0.55f;
private static final float TRIGGER_RELEASE_THRESHOLD = 0.35f;
private static final long REPEAT_THROTTLE_MS = 55L;
private final long[] lastDpadDispatchAt = new long[16];
private final Set<String> activeMotionTokens = new HashSet<>();
private final Map<String, Long> lastMotionDispatchAt = new HashMap<>();
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
// Android system Back is not the controller's Circle button.
// JavaScript pauses live gameplay or backs out of the active
// menu without consuming the player's Circle ability binding.
dispatchNativeControllerToken("SystemBack", false, true);
dispatchNativeControllerToken("SystemBack", false, false);
}
});
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
WindowManager.LayoutParams attributes = getWindow().getAttributes();
attributes.preferredRefreshRate = 60.0f;
getWindow().setAttributes(attributes);
if (bridge != null && bridge.getWebView() != null) {
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
}
enterImmersiveMode();
}
@Override
public void onPause() {
clearHeldControllerState();
super.onPause();
}
@Override
public void onResume() {
super.onResume();
enterImmersiveMode();
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
String token = controllerToken(event.getKeyCode());
if (token == null || bridge == null) return super.dispatchKeyEvent(event);
if (event.getAction() == KeyEvent.ACTION_DOWN) {
boolean repeat = event.getRepeatCount() > 0;
if (isDpadToken(token) && shouldThrottleDpad(token)) return true;
dispatchNativeControllerToken(token, repeat, true);
} else if (event.getAction() == KeyEvent.ACTION_UP) {
dispatchNativeControllerToken(token, false, false);
}
return true;
}
@Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
if (
bridge == null
|| event.getActionMasked() != MotionEvent.ACTION_MOVE
|| !isControllerMotionEvent(event)
) {
return super.dispatchGenericMotionEvent(event);
}
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
float rightStickX = controllerAxisValue(event, MotionEvent.AXIS_Z, MotionEvent.AXIS_RX);
float rightStickY = controllerAxisValue(event, MotionEvent.AXIS_RZ, MotionEvent.AXIS_RY);
dispatchNativeControllerMotion(leftStickX, leftStickY, rightStickX, rightStickY);
Set<String> currentTokens = new HashSet<>();
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_X), "Button14", "Button15");
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_Y), "Button12", "Button13");
addAxisTokens(currentTokens, leftStickX, "Axis0-", "Axis0+");
addAxisTokens(currentTokens, leftStickY, "Axis1-", "Axis1+");
addAxisTokens(currentTokens, rightStickX, "Axis2-", "Axis2+");
addAxisTokens(currentTokens, rightStickY, "Axis3-", "Axis3+");
addPositiveAxisToken(currentTokens, positiveControllerAxisValue(event, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE), "Button6");
addPositiveAxisToken(currentTokens, positiveControllerAxisValue(event, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS), "Button7");
long now = SystemClock.uptimeMillis();
// Publish removed modifiers before any new ability-button down. This
// makes a quick L2-up/R2-down transition resolve against Base instead
// of the stale tertiary layer. New modifiers likewise precede actions.
for (String token : activeMotionTokens) {
if (isLayerModifierToken(token) && !currentTokens.contains(token)) {
dispatchNativeControllerToken(token, false, false);
}
}
for (String token : currentTokens) {
if (isLayerModifierToken(token)) dispatchMotionToken(token, now);
}
for (String token : currentTokens) {
if (!isLayerModifierToken(token)) dispatchMotionToken(token, now);
}
for (String token : activeMotionTokens) {
if (!isLayerModifierToken(token) && !currentTokens.contains(token)) {
dispatchNativeControllerToken(token, false, false);
}
}
activeMotionTokens.clear();
activeMotionTokens.addAll(currentTokens);
return true;
}
private void enterImmersiveMode() {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
WindowInsetsControllerCompat controller = WindowCompat.getInsetsController(
getWindow(),
getWindow().getDecorView()
);
controller.hide(WindowInsetsCompat.Type.systemBars());
controller.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
);
}
private boolean isControllerMotionEvent(MotionEvent event) {
int source = event.getSource();
return (source & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|| (source & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD
|| (source & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD;
}
private void addAxisTokens(Set<String> tokens, float value, String negative, String positive) {
if (value <= -AXIS_DEAD_ZONE) tokens.add(negative);
if (value >= AXIS_DEAD_ZONE) tokens.add(positive);
}
private void addPositiveAxisToken(Set<String> tokens, float value, String token) {
float threshold = activeMotionTokens.contains(token)
? TRIGGER_RELEASE_THRESHOLD
: TRIGGER_PRESS_THRESHOLD;
if (value >= threshold) tokens.add(token);
}
private boolean isLayerModifierToken(String token) {
return token.equals("Button4") || token.equals("Button6");
}
private void dispatchMotionToken(String token, long now) {
boolean repeat = activeMotionTokens.contains(token);
long lastDispatch = lastMotionDispatchAt.containsKey(token)
? lastMotionDispatchAt.get(token)
: 0L;
if (!repeat || now - lastDispatch >= REPEAT_THROTTLE_MS) {
dispatchNativeControllerToken(token, repeat, true);
lastMotionDispatchAt.put(token, now);
}
}
private float controllerAxisValue(MotionEvent event, int primaryAxis, int fallbackAxis) {
InputDevice device = event.getDevice();
if (device != null && device.getMotionRange(primaryAxis) != null) {
return event.getAxisValue(primaryAxis);
}
return event.getAxisValue(fallbackAxis);
}
private float positiveControllerAxisValue(MotionEvent event, int primaryAxis, int fallbackAxis) {
// Several Android controller drivers declare both trigger axes but
// report movement on only one of them, so range presence alone cannot
// safely choose the source.
return Math.max(0.0f, Math.max(
event.getAxisValue(primaryAxis),
event.getAxisValue(fallbackAxis)
));
}
private void dispatchNativeControllerToken(String token, boolean repeat, boolean pressed) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('hm-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + ",pressed:" + pressed + "}}));";
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
}
private void dispatchNativeControllerMotion(float moveX, float moveY, float lookX, float lookY) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('hm-native-controller-motion',"
+ "{detail:{moveX:" + moveX + ",moveY:" + moveY
+ ",lookX:" + lookX + ",lookY:" + lookY + "}}));";
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
}
private void clearHeldControllerState() {
activeMotionTokens.clear();
lastMotionDispatchAt.clear();
dispatchNativeControllerMotion(0.0f, 0.0f, 0.0f, 0.0f);
if (bridge == null || bridge.getWebView() == null) return;
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(
"window.dispatchEvent(new Event('hm-native-controller-reset'));",
null
));
}
private boolean shouldThrottleDpad(String token) {
int index = Integer.parseInt(token.substring("Button".length()));
long now = SystemClock.uptimeMillis();
if (now - lastDpadDispatchAt[index] < REPEAT_THROTTLE_MS) return true;
lastDpadDispatchAt[index] = now;
return false;
}
private boolean isDpadToken(String token) {
return token.equals("Button12")
|| token.equals("Button13")
|| token.equals("Button14")
|| token.equals("Button15");
}
private String controllerToken(int keyCode) {
switch (keyCode) {
case KeyEvent.KEYCODE_BUTTON_A:
case KeyEvent.KEYCODE_DPAD_CENTER:
case KeyEvent.KEYCODE_ENTER:
case KeyEvent.KEYCODE_NUMPAD_ENTER:
return "Button0";
case KeyEvent.KEYCODE_BUTTON_B:
case KeyEvent.KEYCODE_ESCAPE:
return "Button1";
case KeyEvent.KEYCODE_BUTTON_X: return "Button2";
case KeyEvent.KEYCODE_BUTTON_Y: return "Button3";
case KeyEvent.KEYCODE_BUTTON_L1: return "Button4";
case KeyEvent.KEYCODE_BUTTON_R1: return "Button5";
case KeyEvent.KEYCODE_BUTTON_L2: return "Button6";
case KeyEvent.KEYCODE_BUTTON_R2: return "Button7";
case KeyEvent.KEYCODE_BUTTON_SELECT: return "Button8";
case KeyEvent.KEYCODE_BUTTON_START: return "Button9";
case KeyEvent.KEYCODE_BUTTON_THUMBL: return "Button10";
case KeyEvent.KEYCODE_BUTTON_THUMBR: return "Button11";
case KeyEvent.KEYCODE_DPAD_UP: return "Button12";
case KeyEvent.KEYCODE_DPAD_DOWN: return "Button13";
case KeyEvent.KEYCODE_DPAD_LEFT: return "Button14";
case KeyEvent.KEYCODE_DPAD_RIGHT: return "Button15";
default: return null;
}
}
}