Add remappable controller controls with a Controller Options tab
build / build (push) Successful in 2m14s
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.
This commit is contained in:
@@ -369,6 +369,14 @@ typedef struct {
|
||||
bool has_x, has_z, has_yaw_degrees;
|
||||
} QsParsedPlayerPosition;
|
||||
|
||||
// One string slot per remappable action, indexed by
|
||||
// xr_input_action_index_from_name(). This file does not list the actions
|
||||
// itself; xr_input.c owns them. Sized above the current action count.
|
||||
#define QS_MAX_PLAYER_CONTROL_ACTIONS 96
|
||||
typedef struct {
|
||||
char bindings[QS_MAX_PLAYER_CONTROL_ACTIONS][24]; // "" = not present in the file
|
||||
} QsParsedPlayerControls;
|
||||
|
||||
typedef struct {
|
||||
QsParsedVideoOptions video_options;
|
||||
bool has_video_options;
|
||||
@@ -376,6 +384,8 @@ typedef struct {
|
||||
bool has_general;
|
||||
QsParsedPlayerPosition player_position;
|
||||
bool has_player_position;
|
||||
QsParsedPlayerControls player_controls;
|
||||
bool has_player_controls;
|
||||
} QsParsedConfig;
|
||||
|
||||
static void qs_parse_video_options_field(const char *key, QsJsonParser *p, void *ctx) {
|
||||
@@ -428,6 +438,17 @@ static void qs_parse_player_position_field(const char *key, QsJsonParser *p, voi
|
||||
qs_json_skip_value(p);
|
||||
}
|
||||
|
||||
// Looks up `key` in xr_input.c's action-name list instead of a fixed strcmp
|
||||
// chain.
|
||||
static void qs_parse_player_controls_field(const char *key, QsJsonParser *p, void *ctx) {
|
||||
QsParsedPlayerControls *out = (QsParsedPlayerControls *)ctx;
|
||||
int index;
|
||||
if (xr_input_action_index_from_name(key, &index) && index < QS_MAX_PLAYER_CONTROL_ACTIONS)
|
||||
qs_json_take_string_field(p, out->bindings[index], sizeof(out->bindings[index]));
|
||||
else
|
||||
qs_json_skip_value(p);
|
||||
}
|
||||
|
||||
static void qs_parse_top_level_field(const char *key, QsJsonParser *p, void *ctx) {
|
||||
QsParsedConfig *out = (QsParsedConfig *)ctx;
|
||||
if (strcmp(key, "video_options") == 0 && !p->error && p->p < p->end && *p->p == '{')
|
||||
@@ -437,6 +458,9 @@ static void qs_parse_top_level_field(const char *key, QsJsonParser *p, void *ctx
|
||||
else if (strcmp(key, "player_position") == 0 && !p->error && p->p < p->end && *p->p == '{')
|
||||
out->has_player_position =
|
||||
qs_json_parse_object(p, qs_parse_player_position_field, &out->player_position);
|
||||
else if (strcmp(key, "player_controls") == 0 && !p->error && p->p < p->end && *p->p == '{')
|
||||
out->has_player_controls =
|
||||
qs_json_parse_object(p, qs_parse_player_controls_field, &out->player_controls);
|
||||
else
|
||||
qs_json_skip_value(p);
|
||||
}
|
||||
@@ -874,6 +898,38 @@ static bool qs_apply_player_position(const QsParsedPlayerPosition *parsed) {
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// General: player controls
|
||||
// ===========================================================================
|
||||
|
||||
// Live-appliable: xr_input_set_action_binding() (xr_input.c) just writes
|
||||
// into the per-frame lookup table xr_input_sync_and_draw() already reads
|
||||
// every frame - no restart distinction needed, same as player_position.
|
||||
#define QS_PLAYER_CONTROLS_REQUIRES_RESTART false
|
||||
|
||||
// Applies only the actions present in *parsed and leaves the rest untouched.
|
||||
// Invalid raw-input names (a typo, or a symmetric form on the wrong action -
|
||||
// see xr_input_set_action_binding()) are WARN+skipped per action.
|
||||
static bool qs_apply_player_controls(const QsParsedPlayerControls *parsed) {
|
||||
bool changed = false;
|
||||
int count = xr_input_action_count();
|
||||
if (count > QS_MAX_PLAYER_CONTROL_ACTIONS)
|
||||
count = QS_MAX_PLAYER_CONTROL_ACTIONS;
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (parsed->bindings[i][0] == '\0')
|
||||
continue;
|
||||
int before = xr_input_get_action_binding(i);
|
||||
if (!xr_input_set_action_binding(i, parsed->bindings[i])) {
|
||||
WARN("%s: player_controls.%s=\"%s\" is invalid - ignoring", QS_CONFIG_FILENAME,
|
||||
xr_input_action_name(i), parsed->bindings[i]);
|
||||
continue;
|
||||
}
|
||||
if (xr_input_get_action_binding(i) != before)
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Read-only queries for the Video Options tab (see qs_config.h)
|
||||
// ===========================================================================
|
||||
@@ -1013,6 +1069,11 @@ static void qs_config_apply_parsed(const QsParsedConfig *parsed, bool live, char
|
||||
if (changed && QS_PLAYER_POSITION_REQUIRES_RESTART)
|
||||
qs_append_restart_reason(restart_needed, restart_needed_size, "player_position");
|
||||
}
|
||||
if (parsed->has_player_controls) {
|
||||
bool changed = qs_apply_player_controls(&parsed->player_controls);
|
||||
if (changed && QS_PLAYER_CONTROLS_REQUIRES_RESTART)
|
||||
qs_append_restart_reason(restart_needed, restart_needed_size, "player_controls");
|
||||
}
|
||||
}
|
||||
|
||||
static bool qs_config_write_file(void) {
|
||||
@@ -1037,6 +1098,14 @@ static bool qs_config_write_file(void) {
|
||||
fprintf(f, " \"general\": {\n");
|
||||
fprintf(f, " \"language\": \"%s\"\n", qs_current_language_name());
|
||||
fprintf(f, " },\n");
|
||||
{
|
||||
int count = xr_input_action_count();
|
||||
fprintf(f, " \"player_controls\": {\n");
|
||||
for (int i = 0; i < count; i++)
|
||||
fprintf(f, " \"%s\": \"%s\"%s\n", xr_input_action_name(i),
|
||||
xr_input_get_action_binding_name(i), i + 1 < count ? "," : "");
|
||||
fprintf(f, " },\n");
|
||||
}
|
||||
{
|
||||
float x, z, yaw_degrees;
|
||||
xr_session_get_player_transform(&x, &z, &yaw_degrees);
|
||||
@@ -1117,6 +1186,52 @@ static void qs_show_restart_dialog(const char *restart_needed) {
|
||||
// only qs_apply_language()'s missing-resource-files warning above.
|
||||
static void qs_show_message_dialog(const char *message) { qs_call_dialog_method("showMessageDialog", message); }
|
||||
|
||||
// Non-cancelable, single-"Exit"-button dialog (MenuOverlay.java's
|
||||
// showFatalControllerBindingError()) - see qs_config_report_fatal_input_error()'s
|
||||
// own comment (qs_config.h) on when/why this fires.
|
||||
void qs_config_report_fatal_input_error(const char *profile_display_name) {
|
||||
char message[256];
|
||||
snprintf(message, sizeof(message),
|
||||
"Controller support failed to initialize: the OpenXR runtime rejected the \"%s\" "
|
||||
"interaction profile's bindings. This is an app bug, not something you can fix.",
|
||||
profile_display_name);
|
||||
qs_call_dialog_method("showFatalControllerBindingError", message);
|
||||
}
|
||||
|
||||
// Shared by qs_config_notify_control_capture_result() below - resolves
|
||||
// MenuOverlay.onControlCaptureResult(int, String), called once per changed
|
||||
// row (up to twice per capture - see this function's own header comment).
|
||||
static void qs_call_control_capture_result(int action_index, const char *raw_name) {
|
||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||
jobject activity = (jobject)SDL_AndroidGetActivity();
|
||||
if (!env || !activity)
|
||||
return;
|
||||
|
||||
jclass menuOverlayClass = qs_find_class(env, activity, "de.ladkau.questshock.MenuOverlay");
|
||||
if (!menuOverlayClass)
|
||||
return;
|
||||
|
||||
jmethodID method = (*env)->GetStaticMethodID(env, menuOverlayClass, "onControlCaptureResult",
|
||||
"(ILjava/lang/String;)V");
|
||||
if (!method) {
|
||||
(*env)->ExceptionClear(env);
|
||||
return;
|
||||
}
|
||||
|
||||
jstring rawNameStr = raw_name ? (*env)->NewStringUTF(env, raw_name) : NULL;
|
||||
(*env)->CallStaticVoidMethod(env, menuOverlayClass, method, (jint)action_index, rawNameStr);
|
||||
if ((*env)->ExceptionCheck(env))
|
||||
(*env)->ExceptionClear(env);
|
||||
}
|
||||
|
||||
void qs_config_notify_control_capture_result(int action_index, const char *raw_name,
|
||||
int displaced_action_index,
|
||||
const char *displaced_raw_name) {
|
||||
qs_call_control_capture_result(action_index, raw_name);
|
||||
if (displaced_action_index >= 0)
|
||||
qs_call_control_capture_result(displaced_action_index, displaced_raw_name);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Public API (see qs_config.h)
|
||||
// ===========================================================================
|
||||
@@ -1131,16 +1246,21 @@ static Uint32 s_set_initial_screen_width_event_type;
|
||||
static Uint32 s_set_initial_screen_distance_event_type;
|
||||
static Uint32 s_set_game_screen_width_event_type;
|
||||
static Uint32 s_set_game_screen_distance_event_type;
|
||||
static Uint32 s_begin_control_capture_event_type;
|
||||
static Uint32 s_cancel_control_capture_event_type;
|
||||
static Uint32 s_reset_player_controls_event_type;
|
||||
static Uint32 s_menu_visibility_event_type;
|
||||
|
||||
void qs_config_load_startup(void) {
|
||||
// Registers the custom SDL event types qs_config_request_save()/_load()/
|
||||
// _set_renderer()/_set_resolution()/_set_language()/_reset_position()/
|
||||
// the four _set_*_screen_*() screen-profile setters enqueue - done
|
||||
// here, once, on the engine's own thread and before the menu/its
|
||||
// buttons could possibly exist yet, rather than lazily from those
|
||||
// functions themselves (which run on Android's UI thread and could
|
||||
// otherwise race each other doing this lazily).
|
||||
Uint32 base = SDL_RegisterEvents(10);
|
||||
// the four _set_*_screen_*() screen-profile setters/the three
|
||||
// control-capture/reset requests enqueue - done here, once, on the
|
||||
// engine's own thread and before the menu/its buttons could possibly
|
||||
// exist yet, rather than lazily from those functions themselves (which
|
||||
// run on Android's UI thread and could otherwise race each other doing
|
||||
// this lazily).
|
||||
Uint32 base = SDL_RegisterEvents(14);
|
||||
s_save_event_type = base;
|
||||
s_load_event_type = base + 1;
|
||||
s_set_renderer_event_type = base + 2;
|
||||
@@ -1151,6 +1271,10 @@ void qs_config_load_startup(void) {
|
||||
s_set_game_screen_width_event_type = base + 7;
|
||||
s_set_initial_screen_distance_event_type = base + 8;
|
||||
s_set_game_screen_distance_event_type = base + 9;
|
||||
s_begin_control_capture_event_type = base + 10;
|
||||
s_cancel_control_capture_event_type = base + 11;
|
||||
s_reset_player_controls_event_type = base + 12;
|
||||
s_menu_visibility_event_type = base + 13;
|
||||
|
||||
QsParsedConfig parsed;
|
||||
bool haveConfig = qs_config_read_file(&parsed);
|
||||
@@ -1266,6 +1390,65 @@ void qs_config_request_set_game_screen_distance(int preset_index) {
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
// Controller Options tab (see qs_config.h) - same enqueue-only reasoning as
|
||||
// every button above.
|
||||
void qs_config_request_begin_control_capture(int action_index) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_begin_control_capture_event_type;
|
||||
event.user.code = action_index;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_request_cancel_control_capture(void) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_cancel_control_capture_event_type;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_notify_menu_visibility(bool visible) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_menu_visibility_event_type;
|
||||
event.user.code = visible ? 1 : 0;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_request_reset_player_controls(void) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_reset_player_controls_event_type;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
// hkeyfunc.c - the same toggle the game's own "pause" hotkey ('p') calls,
|
||||
// which also handles the mouse capture/cursor/sound side of pausing.
|
||||
extern uchar game_paused;
|
||||
extern uchar pause_game_func(ushort keycode, uint32_t context, intptr_t data);
|
||||
|
||||
// True only while a pause this file caused is (as far as it knows) still in
|
||||
// effect - see qs_config_notify_menu_visibility().
|
||||
static bool s_menu_paused_game = false;
|
||||
|
||||
// s_screen_profile_is_game is "a game is running" (anything but the setup/
|
||||
// main-menu and cutscene loops, see qs_screen_profile_notify_loop_mode()).
|
||||
static void qs_apply_menu_pause(bool menu_visible) {
|
||||
if (menu_visible) {
|
||||
if (s_screen_profile_is_game && !game_paused) {
|
||||
pause_game_func(0, 0, 0);
|
||||
s_menu_paused_game = true;
|
||||
}
|
||||
} else if (s_menu_paused_game) {
|
||||
s_menu_paused_game = false;
|
||||
// Only toggle back if it's still paused - the game may have
|
||||
// unpaused itself (or the player did) while the menu was open, and
|
||||
// toggling then would pause it instead.
|
||||
if (game_paused)
|
||||
pause_game_func(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
bool qs_config_handle_event(const SDL_Event *event) {
|
||||
if (event->type == s_save_event_type) {
|
||||
if (qs_config_write_file())
|
||||
@@ -1305,15 +1488,10 @@ bool qs_config_handle_event(const SDL_Event *event) {
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_reset_position_event_type) {
|
||||
// Deliberately not xr_session_set_player_transform(0,0,0) - see
|
||||
// xr_input_recenter_player_to_screen()'s own comment: a "return to
|
||||
// (0,0,0)" reset only undoes thumbstick locomotion, not any real,
|
||||
// physical drift/rotation since the session started, which is what
|
||||
// actually made this feel "slightly off" - recentering onto the
|
||||
// player's current real head pose fixes both at once, and is
|
||||
// unrelated to questshock.config.json (this event doesn't read or
|
||||
// write the config file at all, just like this one never has).
|
||||
xr_input_recenter_player_to_screen();
|
||||
// Re-anchors the position onto the player's current real head position
|
||||
// and keeps their facing (see xr_input_reset_player_position()). Does
|
||||
// not read or write questshock.config.json.
|
||||
xr_input_reset_player_position();
|
||||
message_info("Recentered");
|
||||
return true;
|
||||
}
|
||||
@@ -1345,5 +1523,29 @@ bool qs_config_handle_event(const SDL_Event *event) {
|
||||
qs_preset_value(s_screen_distance_offset_m, s_screen_distance_step_m, index));
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_begin_control_capture_event_type) {
|
||||
xr_input_capture_begin(event->user.code);
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_cancel_control_capture_event_type) {
|
||||
xr_input_capture_cancel();
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_menu_visibility_event_type) {
|
||||
qs_apply_menu_pause(event->user.code != 0);
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_reset_player_controls_event_type) {
|
||||
// Built-in defaults first (they cover every action), then
|
||||
// questshock.config.default.json's player_controls group, if it has
|
||||
// one, overrides the actions it specifies.
|
||||
xr_input_reset_player_controls();
|
||||
QsParsedConfig defaultParsed;
|
||||
if (qs_config_read_file_named(QS_CONFIG_DEFAULT_FILENAME, &defaultParsed) &&
|
||||
defaultParsed.has_player_controls)
|
||||
qs_apply_player_controls(&defaultParsed.player_controls);
|
||||
message_info("Controller bindings reset to defaults");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user