ebiten/cmd/ebitenmobile/_files/EbitenView.java
divVerent 10c1b56e62
cmd/ebitenmobile: fix Xbox-ish gamepad axis assignment (#2596)
This commit mirrors https://github.com/libsdl-org/SDL/pull/7405
(6f1f586086).

Note that for SDL, this code is used a lot less than for Ebitengine, as SDL
mostly migrated to HIDAPI and direct USB device access rather than using
Android's APIs. For Bluetooth devices, however, the Java APIs are used the
same way.

This was the remaining problem to be solved to automatically support standard
layout on most standard gamepads (this should cover most Xbox-ish and PS-ish
gamepads on the market).

In particular this covers gamepads with the following assignment:

- Left stick = X/Y, right stick = Z/RZ, triggers = LEFTTRIGGER/RIGHTTRIGGER
  (which basically is what Android docs say and some PS gamepads do)

- Left stick = X/Y, right stick = RX/RY, triggers = Z/RZ
  (Xbox gamepad style, apparently)

- Left stick = X/Y, right stick = RX/RY, triggers = LEFTTRIGGER/RIGHTTRIGGER
  (Not sure if this exists, but it's conceivable)

As we found on the SDL pull request discussion, gamepads that offer flight
controls (e.g. THROTTLE and RUDDER) will likely not work well, before and
after this change.

Closes #2557
2023-03-12 22:32:30 +09:00

449 lines
18 KiB
Java

// Copyright 2022 The Ebitengine Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package {{.JavaPkg}}.{{.PrefixLower}};
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import android.content.Context;
import android.hardware.input.InputManager;
import android.os.Handler;
import android.os.Looper;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.Display;
import android.view.KeyEvent;
import android.view.InputDevice;
import android.view.MotionEvent;
import android.view.ViewGroup;
import android.view.WindowManager;
import {{.JavaPkg}}.ebitenmobileview.Ebitenmobileview;
public class EbitenView extends ViewGroup implements InputManager.InputDeviceListener {
static class Gamepad {
public int deviceId;
public ArrayList<InputDevice.MotionRange> axes;
public ArrayList<InputDevice.MotionRange> hats;
}
// See https://github.com/libsdl-org/SDL/blob/2df2da11f627299c6e05b7e0aff407c915043372/android-project/app/src/main/java/org/libsdl/app/SDLControllerManager.java#L154-L173
static class RangeComparator implements Comparator<InputDevice.MotionRange> {
@Override
public int compare(InputDevice.MotionRange arg0, InputDevice.MotionRange arg1) {
int arg0Axis = arg0.getAxis();
int arg1Axis = arg1.getAxis();
if (arg0Axis == MotionEvent.AXIS_GAS) {
arg0Axis = MotionEvent.AXIS_BRAKE;
} else if (arg0Axis == MotionEvent.AXIS_BRAKE) {
arg0Axis = MotionEvent.AXIS_GAS;
}
if (arg1Axis == MotionEvent.AXIS_GAS) {
arg1Axis = MotionEvent.AXIS_BRAKE;
} else if (arg1Axis == MotionEvent.AXIS_BRAKE) {
arg1Axis = MotionEvent.AXIS_GAS;
}
// Make sure the AXIS_Z is sorted between AXIS_RY and AXIS_RZ.
//
// The value ordering on Android otherwise is AXIS_X, AXIS_Y,
// all kinds of axes used by touchscreens or touchpads only,
// AXIS_Z, AXIS_RX, AXIS_RY, AXIS_RZ, hats, triggers,
// flight controls, car controls, misc stuff.
//
// This is because the usual pairing are:
// - AXIS_X, AXIS_Y (left stick).
// - AXIS_RX, AXIS_RY (sometimes the right stick, sometimes triggers).
// - AXIS_Z, AXIS_RZ (sometimes the right stick, sometimes triggers).
//
// This sorts the axes in the above order, which tends to be correct
// for Xbox-ish game pads that have the right stick on RX/RY and the
// triggers on Z/RZ.
//
// Gamepads that don't have AXIS_Z/AXIS_RZ but use
// AXIS_LTRIGGER/AXIS_RTRIGGER are unaffected by this.
//
// References:
// - https://developer.android.com/develop/ui/views/touch-and-input/game-controllers/controller-input
// - https://www.kernel.org/doc/html/latest/input/gamepad.html
if (arg0Axis == MotionEvent.AXIS_Z) {
arg0Axis = MotionEvent.AXIS_RZ - 1;
} else if (arg0Axis > MotionEvent.AXIS_Z && arg0Axis < MotionEvent.AXIS_RZ) {
arg0Axis--;
}
if (arg1Axis == MotionEvent.AXIS_Z) {
arg1Axis = MotionEvent.AXIS_RZ - 1;
} else if (arg1Axis > MotionEvent.AXIS_Z && arg1Axis < MotionEvent.AXIS_RZ) {
arg1Axis--;
}
return arg0Axis - arg1Axis;
}
}
private static double pxToDp(double x) {
return x / Ebitenmobileview.deviceScale();
}
public EbitenView(Context context) {
super(context);
initialize(context);
}
public EbitenView(Context context, AttributeSet attrs) {
super(context, attrs);
initialize(context);
}
private void initialize(Context context) {
this.gamepads = new ArrayList<Gamepad>();
this.ebitenSurfaceView = new EbitenSurfaceView(getContext());
LayoutParams params = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
addView(this.ebitenSurfaceView, params);
this.inputManager = (InputManager)context.getSystemService(Context.INPUT_SERVICE);
this.inputManager.registerInputDeviceListener(this, null);
for (int id : this.inputManager.getInputDeviceIds()) {
this.onInputDeviceAdded(id);
}
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
this.ebitenSurfaceView.layout(0, 0, right - left, bottom - top);
double widthInDp = pxToDp(right - left);
double heightInDp = pxToDp(bottom - top);
Ebitenmobileview.layout(widthInDp, heightInDp);
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
Ebitenmobileview.onKeyDownOnAndroid(keyCode, event.getUnicodeChar(), event.getSource(), event.getDeviceId());
return true;
}
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
Ebitenmobileview.onKeyUpOnAndroid(keyCode, event.getSource(), event.getDeviceId());
return true;
}
@Override
public boolean onTouchEvent(MotionEvent e) {
// getActionIndex returns a valid value only for the action whose index is the returned value of getActionIndex (#2220).
// See https://developer.android.com/reference/android/view/MotionEvent#getActionMasked().
// For other pointers, treat their actions as MotionEvent.ACTION_MOVE.
int touchIndex = e.getActionIndex();
for (int i = 0; i < e.getPointerCount(); i++) {
int id = e.getPointerId(i);
int x = (int)e.getX(i);
int y = (int)e.getY(i);
int action = (i == touchIndex) ? e.getActionMasked() : MotionEvent.ACTION_MOVE;
Ebitenmobileview.updateTouchesOnAndroid(action, id, (int)pxToDp(x), (int)pxToDp(y));
}
return true;
}
private Gamepad getGamepad(int deviceId) {
for (Gamepad gamepad : this.gamepads) {
if (gamepad.deviceId == deviceId) {
return gamepad;
}
}
return null;
}
@Override
public boolean onGenericMotionEvent(MotionEvent event) {
if ((event.getSource() & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) {
return super.onGenericMotionEvent(event);
}
if (event.getAction() != MotionEvent.ACTION_MOVE) {
return super.onGenericMotionEvent(event);
}
// See https://github.com/libsdl-org/SDL/blob/2df2da11f627299c6e05b7e0aff407c915043372/android-project/app/src/main/java/org/libsdl/app/SDLControllerManager.java#L256-L277
Gamepad gamepad = this.getGamepad(event.getDeviceId());
if (gamepad == null) {
return true;
}
int actionPointerIndex = event.getActionIndex();
for (int i = 0; i < gamepad.axes.size(); i++) {
InputDevice.MotionRange range = gamepad.axes.get(i);
float axisValue = event.getAxisValue(range.getAxis(), actionPointerIndex);
float value = (axisValue - range.getMin()) / range.getRange() * 2.0f - 1.0f;
Ebitenmobileview.onGamepadAxisChanged(gamepad.deviceId, i, value);
}
for (int i = 0; i < gamepad.hats.size() / 2; i++) {
int hatX = Math.round(event.getAxisValue(gamepad.hats.get(2*i).getAxis(), actionPointerIndex));
int hatY = Math.round(event.getAxisValue(gamepad.hats.get(2*i+1).getAxis(), actionPointerIndex));
Ebitenmobileview.onGamepadHatChanged(gamepad.deviceId, i, hatX, hatY);
}
return true;
}
@Override
public void onInputDeviceAdded(int deviceId) {
InputDevice inputDevice = this.inputManager.getInputDevice(deviceId);
// The InputDevice can be null on some deivces (#1342).
if (inputDevice == null) {
return;
}
// A fingerprint reader is unexpectedly recognized as a joystick. Skip this (#1542).
if (inputDevice.getName().equals("uinput-fpc")) {
return;
}
int sources = inputDevice.getSources();
if ((sources & InputDevice.SOURCE_GAMEPAD) != InputDevice.SOURCE_GAMEPAD &&
(sources & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) {
return;
}
// See https://github.com/libsdl-org/SDL/blob/2df2da11f627299c6e05b7e0aff407c915043372/android-project/app/src/main/java/org/libsdl/app/SDLControllerManager.java#L182-L216
List<InputDevice.MotionRange> ranges = inputDevice.getMotionRanges();
Collections.sort(ranges, new RangeComparator());
Gamepad gamepad = new Gamepad();
gamepad.deviceId = deviceId;
gamepad.axes = new ArrayList<InputDevice.MotionRange>();
gamepad.hats = new ArrayList<InputDevice.MotionRange>();
for (InputDevice.MotionRange range : ranges) {
if (range.getAxis() == MotionEvent.AXIS_HAT_X || range.getAxis() == MotionEvent.AXIS_HAT_Y) {
gamepad.hats.add(range);
} else {
gamepad.axes.add(range);
}
}
this.gamepads.add(gamepad);
String descriptor = inputDevice.getDescriptor();
int vendorId = inputDevice.getVendorId();
int productId = inputDevice.getProductId();
// These values are required to calculate SDL's GUID.
int buttonMask = getButtonMask(inputDevice, gamepad.hats.size()/2);
int axisMask = getAxisMask(inputDevice);
Ebitenmobileview.onGamepadAdded(deviceId, inputDevice.getName(), gamepad.axes.size(), gamepad.hats.size()/2, descriptor, vendorId, productId, buttonMask, axisMask);
// Initialize the trigger axes values explicitly, or the initial button values would be 0.5 instead of 0.
if (gamepad.axes.size() >= 6) {
Ebitenmobileview.onGamepadAxisChanged(deviceId, 4, -1);
Ebitenmobileview.onGamepadAxisChanged(deviceId, 5, -1);
}
}
// The implementation is copied from SDL:
// https://github.com/libsdl-org/SDL/blob/0e9560aea22818884921e5e5064953257bfe7fa7/android-project/app/src/main/java/org/libsdl/app/SDLControllerManager.java#L308
private static int getButtonMask(InputDevice joystickDevice, int nhats) {
int buttonMask = 0;
int[] keys = new int[] {
KeyEvent.KEYCODE_BUTTON_A,
KeyEvent.KEYCODE_BUTTON_B,
KeyEvent.KEYCODE_BUTTON_X,
KeyEvent.KEYCODE_BUTTON_Y,
KeyEvent.KEYCODE_BACK,
KeyEvent.KEYCODE_BUTTON_MODE,
KeyEvent.KEYCODE_BUTTON_START,
KeyEvent.KEYCODE_BUTTON_THUMBL,
KeyEvent.KEYCODE_BUTTON_THUMBR,
KeyEvent.KEYCODE_BUTTON_L1,
KeyEvent.KEYCODE_BUTTON_R1,
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_BUTTON_SELECT,
KeyEvent.KEYCODE_DPAD_CENTER,
// These don't map into any SDL controller buttons directly
KeyEvent.KEYCODE_BUTTON_L2,
KeyEvent.KEYCODE_BUTTON_R2,
KeyEvent.KEYCODE_BUTTON_C,
KeyEvent.KEYCODE_BUTTON_Z,
KeyEvent.KEYCODE_BUTTON_1,
KeyEvent.KEYCODE_BUTTON_2,
KeyEvent.KEYCODE_BUTTON_3,
KeyEvent.KEYCODE_BUTTON_4,
KeyEvent.KEYCODE_BUTTON_5,
KeyEvent.KEYCODE_BUTTON_6,
KeyEvent.KEYCODE_BUTTON_7,
KeyEvent.KEYCODE_BUTTON_8,
KeyEvent.KEYCODE_BUTTON_9,
KeyEvent.KEYCODE_BUTTON_10,
KeyEvent.KEYCODE_BUTTON_11,
KeyEvent.KEYCODE_BUTTON_12,
KeyEvent.KEYCODE_BUTTON_13,
KeyEvent.KEYCODE_BUTTON_14,
KeyEvent.KEYCODE_BUTTON_15,
KeyEvent.KEYCODE_BUTTON_16,
};
int[] masks = new int[] {
(1 << 0), // A -> A
(1 << 1), // B -> B
(1 << 2), // X -> X
(1 << 3), // Y -> Y
(1 << 4), // BACK -> BACK
(1 << 5), // MODE -> GUIDE
(1 << 6), // START -> START
(1 << 7), // THUMBL -> LEFTSTICK
(1 << 8), // THUMBR -> RIGHTSTICK
(1 << 9), // L1 -> LEFTSHOULDER
(1 << 10), // R1 -> RIGHTSHOULDER
(1 << 11), // DPAD_UP -> DPAD_UP
(1 << 12), // DPAD_DOWN -> DPAD_DOWN
(1 << 13), // DPAD_LEFT -> DPAD_LEFT
(1 << 14), // DPAD_RIGHT -> DPAD_RIGHT
(1 << 4), // SELECT -> BACK
(1 << 0), // DPAD_CENTER -> A
(1 << 15), // L2 -> ??
(1 << 16), // R2 -> ??
(1 << 17), // C -> ??
(1 << 18), // Z -> ??
(1 << 20), // 1 -> ??
(1 << 21), // 2 -> ??
(1 << 22), // 3 -> ??
(1 << 23), // 4 -> ??
(1 << 24), // 5 -> ??
(1 << 25), // 6 -> ??
(1 << 26), // 7 -> ??
(1 << 27), // 8 -> ??
(1 << 28), // 9 -> ??
(1 << 29), // 10 -> ??
(1 << 30), // 11 -> ??
(1 << 31), // 12 -> ??
// We're out of room...
0xFFFFFFFF, // 13 -> ??
0xFFFFFFFF, // 14 -> ??
0xFFFFFFFF, // 15 -> ??
0xFFFFFFFF, // 16 -> ??
};
boolean[] hasKeys = joystickDevice.hasKeys(keys);
for (int i = 0; i < keys.length; ++i) {
if (hasKeys[i]) {
buttonMask |= masks[i];
}
}
// https://github.com/libsdl-org/SDL/blob/47f2373dc13b66c48bf4024fcdab53cd0bdd59bb/src/joystick/android/SDL_sysjoystick.c#L360-L367
if (nhats > 0) {
// Add Dpad buttons.
buttonMask |= 1 << 11;
buttonMask |= 1 << 12;
buttonMask |= 1 << 13;
buttonMask |= 1 << 14;
}
return buttonMask;
}
private static int getAxisMask(InputDevice joystickDevice) {
final int SDL_CONTROLLER_AXIS_LEFTX = 0;
final int SDL_CONTROLLER_AXIS_LEFTY = 1;
final int SDL_CONTROLLER_AXIS_RIGHTX = 2;
final int SDL_CONTROLLER_AXIS_RIGHTY = 3;
final int SDL_CONTROLLER_AXIS_TRIGGERLEFT = 4;
final int SDL_CONTROLLER_AXIS_TRIGGERRIGHT = 5;
int naxes = 0;
boolean haveZ = false;
boolean havePastZBeforeRZ = false;
for (InputDevice.MotionRange range : joystickDevice.getMotionRanges()) {
if ((range.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) {
int axis = range.getAxis();
if (axis != MotionEvent.AXIS_HAT_X && axis != MotionEvent.AXIS_HAT_Y) {
naxes++;
}
if (axis == MotionEvent.AXIS_Z) {
haveZ = true;
} else if (axis > MotionEvent.AXIS_Z && axis < MotionEvent.AXIS_RZ) {
havePastZBeforeRZ = true;
}
}
}
// The variable is_accelerometer seems always false, then skip the checking:
// https://github.com/libsdl-org/SDL/blob/0e9560aea22818884921e5e5064953257bfe7fa7/android-project/app/src/main/java/org/libsdl/app/SDLControllerManager.java#L207
int axisMask = 0;
if (naxes >= 2) {
axisMask |= ((1 << SDL_CONTROLLER_AXIS_LEFTX) | (1 << SDL_CONTROLLER_AXIS_LEFTY));
}
if (naxes >= 4) {
axisMask |= ((1 << SDL_CONTROLLER_AXIS_RIGHTX) | (1 << SDL_CONTROLLER_AXIS_RIGHTY));
}
if (naxes >= 6) {
axisMask |= ((1 << SDL_CONTROLLER_AXIS_TRIGGERLEFT) | (1 << SDL_CONTROLLER_AXIS_TRIGGERRIGHT));
}
// Also add an indicator bit for whether the sorting order has changed.
// This serves to disable outdated gamecontrollerdb.txt mappings.
if (haveZ && havePastZBeforeRZ) {
axisMask |= 0x8000;
}
return axisMask;
}
@Override
public void onInputDeviceChanged(int deviceId) {
// Do nothing.
}
@Override
public void onInputDeviceRemoved(int deviceId) {
// Do not call inputManager.getInputDevice(), which returns null (#1185).
Ebitenmobileview.onInputDeviceRemoved(deviceId);
this.gamepads.remove(this.getGamepad(deviceId));
}
// suspendGame suspends the game.
// It is recommended to call this when the application is being suspended e.g.,
// Activity's onPause is called.
public void suspendGame() {
this.inputManager.unregisterInputDeviceListener(this);
this.ebitenSurfaceView.onPause();
try {
Ebitenmobileview.suspend();
} catch (final Exception e) {
onErrorOnGameUpdate(e);
}
}
// resumeGame resumes the game.
// It is recommended to call this when the application is being resumed e.g.,
// Activity's onResume is called.
public void resumeGame() {
this.inputManager.registerInputDeviceListener(this, null);
this.ebitenSurfaceView.onResume();
try {
Ebitenmobileview.resume();
} catch (final Exception e) {
onErrorOnGameUpdate(e);
}
}
// onErrorOnGameUpdate is called on the main thread when an error happens when updating a game.
// You can define your own error handler, e.g., using Crashlytics, by overriding this method.
protected void onErrorOnGameUpdate(Exception e) {
Log.e("Go", e.toString());
}
private EbitenSurfaceView ebitenSurfaceView;
private InputManager inputManager;
private ArrayList<Gamepad> gamepads;
}