Add remappable controller controls with a Controller Options tab
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:
ml
2026-09-20 06:08:50 +02:00
parent e1e6a006d4
commit c24b7f15cb
21 changed files with 2489 additions and 392 deletions
+1
View File
@@ -106,6 +106,7 @@ def stageEngineTask = tasks.register("stageEngine", Exec) {
inputs.file("${repoRoot}/build-image/Dockerfile")
// Staged into assets/ by prepare-android-project.sh.
inputs.file("${repoRoot}/res/questshock.config.default.json")
inputs.dir("${repoRoot}/res/controller_profiles")
outputs.dir("${repoRoot}/build/android-engine")
outputs.dir("${repoRoot}/build/android-gl4es")
outputs.dir("${repoRoot}/build/android-prebuilt")
+217 -15
View File
@@ -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;
}
+43 -16
View File
@@ -56,14 +56,10 @@ void qs_config_request_set_resolution(int resolution_index);
// menu applies a language change (see qs_config.c's qs_apply_language()).
void qs_config_request_set_language(int language_index);
// Same cross-thread-dispatch reasoning as the three above - called from the
// Main Menu tab's "Reset Position" button. A pure, live "return to a sane
// view of the game quad" action (see xr_input.h's own
// xr_input_recenter_player_to_screen(), which this dispatches to) -
// unrelated to questshock.config.json despite living in this file for the
// cross-thread dispatch machinery: it never reads or writes the config
// file, and isn't a "reset to (0,0,0)" - it recenters based on the
// player's current real head pose instead.
// Same cross-thread dispatch as the three above - called from the Main Menu
// tab's "Reset Position" button. Re-anchors the player's position in front of
// the game quad (see xr_input_reset_player_position()); never reads or writes
// the config file.
void qs_config_request_reset_position(void);
// Same cross-thread-dispatch reasoning as the four above - called from the
@@ -81,14 +77,45 @@ void qs_config_request_set_initial_screen_distance(int preset_index);
void qs_config_request_set_game_screen_width(int preset_index);
void qs_config_request_set_game_screen_distance(int preset_index);
// Returns true and handles the event if it's one of the ten custom events
// qs_config_request_save()/_load()/_set_renderer()/_set_resolution()/
// _set_language()/_reset_position()/the four _set_*_screen_*() setters
// enqueue - called from pump_events() (sdl_events.c, see
// android/engine-patches/21-android-config.patch) on the engine's own
// thread, once per polled event, before its normal switch on well-known
// SDL_* event types. Returns false (no-op) for any other event type,
// including before the ten custom types have even been registered.
// Cross-thread requests from the Controller Options tab (MenuOverlay.java),
// enqueued like every request_*() above. action_index indexes
// xr_input_action_name()'s list. Begin/cancel start and stop rebind capture;
// reset restores all bindings (the built-in defaults, then
// questshock.config.default.json's player_controls group if it has one).
void qs_config_request_begin_control_capture(int action_index);
void qs_config_request_cancel_control_capture(void);
void qs_config_request_reset_player_controls(void);
// Called from xr_input.c's xr_input_process_capture() on the engine's own
// thread, so no cross-thread dispatch is needed (as with
// qs_screen_profile_notify_loop_mode() below). Resolves
// MenuOverlay.onControlCaptureResult(int, String) for action_index (raw_name
// NULL on timeout or cancel), and again for displaced_action_index if the new
// binding took the input from another action (-1/NULL when none), so both
// rows can be updated together.
void qs_config_notify_control_capture_result(int action_index, const char *raw_name,
int displaced_action_index,
const char *displaced_raw_name);
// Called from xr_input.c whenever the VR menu's visibility changes. Showing
// the menu pauses a running game (not the main menu, intro or cutscenes) that
// is not already paused; hiding it unpauses only if the menu did the pausing.
// Enqueued like the request_*() functions above; the pause itself runs on the
// engine's thread at pump_events() time.
void qs_config_notify_menu_visibility(bool visible);
// Called from xr_input.c's xr_input_suggest_bindings() when the OpenXR runtime
// rejects a supported interaction profile's suggested bindings at startup.
// That is an app bug, not a config problem, so it shows
// MenuOverlay.showFatalControllerBindingError()'s non-cancelable "Exit" dialog;
// xr_input_init() aborts right after.
void qs_config_report_fatal_input_error(const char *profile_display_name);
// Returns true and handles the event if it is one of the custom events the
// request_*()/notify functions above enqueue. Called from pump_events()
// (sdl_events.c, see android/engine-patches/21-android-config.patch) on the
// engine's thread, once per polled event. Returns false for any other event
// type, including before the custom types are registered.
bool qs_config_handle_event(const SDL_Event *event);
// Called directly (no event-queue dispatch - see
@@ -11,6 +11,7 @@
#include <SDL.h>
#include "qs_config.h"
#include "xr_input.h"
#include "xr_overlay.h"
#include "xr_session.h"
@@ -233,3 +234,39 @@ JNIEXPORT jfloat JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetGpuUtilization(JNIEnv *env, jclass clazz) {
return xr_session_get_gpu_utilization();
}
// Controller Options tab: actionIndex follows xr_input.c's action order
// (MenuOverlay.java and Strings.java hold the matching labels).
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestBeginControlCapture(JNIEnv *env, jclass clazz,
jint actionIndex) {
qs_config_request_begin_control_capture(actionIndex);
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestCancelControlCapture(JNIEnv *env, jclass clazz) {
qs_config_request_cancel_control_capture();
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestResetPlayerControls(JNIEnv *env, jclass clazz) {
qs_config_request_reset_player_controls();
}
JNIEXPORT jstring JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetControlBindingKey(JNIEnv *env, jclass clazz,
jint actionIndex) {
return (*env)->NewStringUTF(env, xr_input_get_action_binding_name(actionIndex));
}
JNIEXPORT jstring JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetActiveInteractionProfilePath(JNIEnv *env,
jclass clazz) {
return (*env)->NewStringUTF(env, xr_input_get_active_interaction_profile_path());
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeSetControllerOptionsTabOpen(JNIEnv *env, jclass clazz,
jboolean open) {
xr_input_set_controller_options_tab_open(open == JNI_TRUE);
}
File diff suppressed because it is too large Load Diff
+68 -19
View File
@@ -67,27 +67,22 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw);
bool xr_input_get_beam_layer(int hand, XrCompositionLayerQuad *out_quad);
// Recenters the player so the game quad ("virtual screen") appears directly
// ahead of wherever they're currently really standing/facing - a pure,
// live "return to a sane view of the screen" action, unrelated to any
// stored/config value (unlike xr_session_set_player_transform()'s other
// callers, this never reads or writes questshock.config.json - it's driven
// entirely by the player's current real head pose, the same way
// xr_input_sync_and_draw()'s own menu re-center is). Called from the Main
// Menu tab's "Reset Position" button (via qs_config.c's cross-thread
// dispatch - see qs_config_request_reset_position()), but doesn't touch
// anything else qs_config.c owns; also called once automatically, from
// inside xr_input_sync_and_draw() itself, on whichever frame first has a
// valid head pose after startup (see g_pending_startup_recenter's own
// comment) - but only as a *fallback* default for when there's no
// complete saved player_position to fall back on instead (see
// xr_input_skip_startup_recenter() below), since a config-loaded value
// describes a transform relative to a *previous* session's real-space
// origin, which is only meaningful if that previous position was actually
// captured (LOCAL space's own origin isn't a stable point across
// launches). Returns false (no-op otherwise) if no valid head pose was
// available to recenter onto yet.
// ahead of wherever they are standing and facing, from their current real head
// pose. Never reads or writes questshock.config.json. Called automatically
// once, from xr_input_sync_and_draw(), on the first frame with a valid head
// pose after startup, unless a complete saved player_position was loaded (see
// xr_input_skip_startup_recenter()): a saved position is relative to a
// previous session's real-space origin, which is not stable across launches.
// Returns false (no-op) if no valid head pose is available yet.
bool xr_input_recenter_player_to_screen(void);
// The Main Menu tab's "Reset Position" button (via qs_config.c). Like
// xr_input_recenter_player_to_screen(), but re-anchors only the player's
// position to the centre in front of the game quad; the current facing (yaw)
// is kept, so the screen does not rotate to follow the gaze. Returns false
// (no-op) if no valid head pose is available yet.
bool xr_input_reset_player_position(void);
// Called once, from qs_config_load_startup() (qs_config.c), when a
// complete saved player_position was actually loaded from
// questshock.config.json - cancels the pending automatic startup recenter
@@ -96,6 +91,60 @@ bool xr_input_recenter_player_to_screen(void);
// yet (this only touches the plain file-local flag, no XR calls).
void xr_input_skip_startup_recenter(void);
// ===========================================================================
// Remappable controls ("player_controls" config group; see qs_config.c and
// CONFIG.md). menu_toggle is not among them and has no getter/setter: it is
// fixed (see xr_input_sync_and_draw()).
// ===========================================================================
// Number of remappable actions and each one's index<->name mapping, which
// qs_config.c uses to parse/write the player_controls group without its own
// copy of the list. The names are the keys used in questshock.config.json;
// index_from_name() returns false for an unrecognized name.
int xr_input_action_count(void);
const char *xr_input_action_name(int action_index);
bool xr_input_action_index_from_name(const char *name, int *out_index);
// Current binding for one action, as an opaque int and as its string form
// (e.g. "right_b", "trigger", "none" for unbound), the same strings used in
// questshock.config.json and reported by capture.
int xr_input_get_action_binding(int action_index);
const char *xr_input_get_action_binding_name(int action_index);
// Sets one action's binding from a config-file string. Returns false (binding
// unchanged) for an unrecognized name, or for the symmetric "trigger"/
// "squeeze" on an action other than mouse_click/mouse_double_click. Does not
// enforce one-action-per-input the way capture does: a hand-edited file that
// binds two actions to one input is accepted as-is.
bool xr_input_set_action_binding(int action_index, const char *raw_name);
// Restores every action to its built-in default (the table xr_input_init()
// starts from). qs_config.c calls this first for "Reset to Defaults", then
// applies questshock.config.default.json's player_controls group on top if it
// has one.
void xr_input_reset_player_controls(void);
// Begins/cancels rebind capture for one action (an index into
// xr_input_action_name()'s list): the next fresh button or thumbstick-direction
// edge on any hand becomes that action's binding and is reported via
// qs_config_notify_control_capture_result(); with no press, capture cancels
// after 8 seconds. Called via qs_config.c's cross-thread request functions.
// The menu button is never captured.
void xr_input_capture_begin(int action_index);
void xr_input_capture_cancel(void);
// The OpenXR interaction profile currently bound (e.g.
// "/interaction_profiles/oculus/touch_controller"), refreshed once per frame.
// MenuOverlay.java's Controller Options tab uses it to pick a file from
// controller_profiles/ (see CONFIG.md). Empty until the first successful
// query.
const char *xr_input_get_active_interaction_profile_path(void);
// Set by MenuOverlay.java when the Controller Options tab becomes or stops
// being the menu's current tab. While it is open, the menu's thumbstick
// locomotion (snap turn/smooth move) is disabled.
void xr_input_set_controller_options_tab_open(bool open);
void xr_input_shutdown(void);
#ifdef __cplusplus
@@ -2,22 +2,43 @@ package de.ladkau.questshock;
import android.app.Activity;
import android.app.ActivityManager;
import android.app.AlertDialog;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.StateListDrawable;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
/**
* The main menu launcher quad (see OverlayPanel for the shared off-screen-
@@ -73,6 +94,33 @@ public class MenuOverlay extends OverlayPanel {
// this same language's own Strings.java translation is filed under.
private static final String[] LANGUAGE_KEYS = {"english", "french", "german"};
// Controller Options tab actions. Order must match xr_input.c's
// QsControlAction enum/kActionNames[]: the indices go straight to
// nativeRequestBeginControlCapture()/nativeGetControlBindingKey(). Row
// labels are Strings.get("action_" + ACTION_KEYS[i], ...). The hotkey
// actions are unbound by default; xr_input.c lists the HotKeyLookup[]
// entries that are not exposed.
private static final String[] ACTION_KEYS = {
"move_forward", "move_back", "strafe_left", "strafe_right",
"turn_left", "turn_right", "jump", "mouse_click", "mouse_double_click",
"stand", "crouch", "prone", "toggle_freelook", "pause",
"full_view", "normal_view", "map_view", "save_game", "load_game",
"select_grenade", "arm_grenade", "select_drug", "use_drug",
"toggle_online_help", "show_help", "toggle_music", "quit",
"cycle_weapon_next", "cycle_weapon_prev", "cycle_detail",
"reload_weapon_primary", "reload_weapon_secondary", "hud_color",
"clear_fullscreen",
"mfd_left_1", "mfd_left_2", "mfd_left_3", "mfd_left_4", "mfd_left_5",
"mfd_right_1", "mfd_right_2", "mfd_right_3", "mfd_right_4", "mfd_right_5",
"fire_weapon",
};
private static final String CONTROLLER_PROFILES_DIR = QuestShockActivity.GAME_DIR + "/controller_profiles";
// Shown (with its own explanatory note - see Strings.java's
// "generic_profile_note") whenever no bundled profile's
// interaction_profile matches the runtime's active one, same fallback
// reasoning as qs_config.c's own generic khr/simple_controller binding.
private static final String GENERIC_PROFILE_FILENAME = "generic.json";
private static MenuOverlay instance;
// Kept so relayoutAndRedraw() (see its own comment) can re-measure/
@@ -87,13 +135,15 @@ public class MenuOverlay extends OverlayPanel {
private TextView titleView;
private TextView statsView;
// Content area (tabs) - only one of mainMenuTab/videoOptionsTab is
// VISIBLE at a time; currentTab tracks which.
// Content area (tabs) - only one of mainMenuTab/videoOptionsTab/
// controllerOptionsTab is VISIBLE at a time; currentTab tracks which.
private View mainMenuTab;
private View videoOptionsTab;
private View controllerOptionsTab;
private View currentTab;
private Button mainMenuTabButton;
private Button videoOptionsTabButton;
private Button controllerOptionsTabButton;
// Main Menu tab's own two states (only one VISIBLE at a time, within
// mainMenuTab) - mainMenuContent is the normal state (button row +
@@ -134,6 +184,32 @@ public class MenuOverlay extends OverlayPanel {
private Button[] gameScreenWidthButtons;
private Button[] gameScreenDistanceButtons;
// Controller Options tab controls - one row per ACTION_KEYS entry
// (controlRows[i] toggles VISIBLE/GONE per refreshControllerOptionsSelection()'s
// active-profile filtering; controlActionLabels/controlBindingButtons
// stay 1:1 with ACTION_KEYS regardless of visibility).
private ImageView controllerImageView;
private TextView controllerProfileLabel;
private TextView controllerGenericNoteView;
private ScrollView controllerScrollView;
private ScrollBarView controllerScrollBar;
private LinearLayout[] controlRows;
private TextView[] controlActionLabels;
private Button[] controlBindingButtons;
private Button controlResetButton;
// -1 = no row currently listening for a press; otherwise an index into
// ACTION_KEYS - the one row whose text refreshControllerOptionsSelection()
// leaves alone (showing the "Press a button…" placeholder) rather than
// overwriting with its actual current binding.
private int capturingActionIndex = -1;
// Cheap memoization keyed on the active interaction profile path string
// (xr_input.c only recomputes that once per frame anyway) - avoids
// re-reading/re-parsing every *.json file in CONTROLLER_PROFILES_DIR on
// every refreshControllerOptionsSelection() call (periodic, every
// STATS_REFRESH_MS while this tab is visible).
private String cachedProfilePath;
private ControllerProfile cachedProfile;
private int currentLanguage = Strings.ENGLISH;
private final Handler handler = new Handler(Looper.getMainLooper());
@@ -167,11 +243,15 @@ public class MenuOverlay extends OverlayPanel {
FrameLayout content = new FrameLayout(activity);
mainMenuTab = buildMainMenuTab();
videoOptionsTab = buildVideoOptionsTab();
controllerOptionsTab = buildControllerOptionsTab();
content.addView(mainMenuTab, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
content.addView(videoOptionsTab, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
content.addView(controllerOptionsTab, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
videoOptionsTab.setVisibility(View.GONE);
controllerOptionsTab.setVisibility(View.GONE);
currentTab = mainMenuTab;
panel.addView(content, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
@@ -435,6 +515,398 @@ public class MenuOverlay extends OverlayPanel {
return tab;
}
// Horizontal scroll bar for the Controller Options tab - a track with a
// round thumb at `fraction` (0..1) along it. See
// buildControllerOptionsTab() for why this isn't a SeekBar.
private static final class ScrollBarView extends View {
private static final float PAD = 20f;
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private float fraction;
ScrollBarView(Context context) {
super(context);
paint.setStrokeCap(Paint.Cap.ROUND);
}
void setFraction(float value) {
fraction = Math.max(0f, Math.min(1f, value));
invalidate();
}
float fractionForX(float x) {
float usable = getWidth() - 2 * PAD;
return usable > 0 ? Math.max(0f, Math.min(1f, (x - PAD) / usable)) : 0f;
}
@Override
protected void onDraw(Canvas canvas) {
float cy = getHeight() / 2f;
float left = PAD;
float right = getWidth() - PAD;
paint.setColor(0xFF606060);
paint.setStrokeWidth(8f);
canvas.drawLine(left, cy, right, cy, paint);
paint.setColor(0xFFE0E0E0);
canvas.drawCircle(left + fraction * (right - left), cy, 16f, paint);
}
}
// Controller Options tab (see CONFIG.md's "Player Controls"): an optional
// controller illustration and profile name at the top (from a matching
// res/controller_profiles/*.json and image, see
// refreshControllerOptionsSelection()), one row per ACTION_KEYS entry
// (label plus a button showing/capturing its binding), and a Reset to
// Defaults button. All rows are built once;
// refreshControllerOptionsSelection() shows or hides each to match the
// active controller profile.
//
// The rows don't fit the panel's fixed height, so they sit in a
// ScrollView. OverlayPanel.handleTouch() only delivers ACTION_DOWN and
// ACTION_UP (never ACTION_MOVE), so drag-to-scroll cannot work; a scroll
// bar below the list is tapped to jump to a position instead. A stock
// SeekBar cannot serve as that bar: it defers a press until an
// ACTION_MOVE past the touch slop (View.shouldDelayChildPressedState()
// defaults to true for every ViewGroup), so a tap does nothing.
// ScrollBarView computes the tapped position from its own touch
// listener, like each button's CLICK_ON_TOUCH_UP.
private View buildControllerOptionsTab() {
LinearLayout tab = new LinearLayout(activity);
tab.setOrientation(LinearLayout.VERTICAL);
LinearLayout scrollContent = new LinearLayout(activity);
scrollContent.setOrientation(LinearLayout.VERTICAL);
scrollContent.setPadding(24, 16, 24, 8);
controllerImageView = new ImageView(activity);
controllerImageView.setVisibility(View.GONE);
scrollContent.addView(controllerImageView,
new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 140));
controllerProfileLabel = new TextView(activity);
controllerProfileLabel.setTextColor(0xFFFFFFFF);
controllerProfileLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
controllerProfileLabel.setGravity(Gravity.CENTER);
scrollContent.addView(controllerProfileLabel);
controllerGenericNoteView = new TextView(activity);
controllerGenericNoteView.setTextColor(0xFFFFD060);
controllerGenericNoteView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13);
controllerGenericNoteView.setGravity(Gravity.CENTER);
controllerGenericNoteView.setPadding(0, 4, 0, 4);
controllerGenericNoteView.setVisibility(View.GONE);
scrollContent.addView(controllerGenericNoteView);
controlRows = new LinearLayout[ACTION_KEYS.length];
controlActionLabels = new TextView[ACTION_KEYS.length];
controlBindingButtons = new Button[ACTION_KEYS.length];
// Rows are created in display order (Fire Weapon first, then the
// rest in ACTION_KEYS order); every array below stays indexed by
// the real action index, which must keep matching xr_input.c.
int fireIndex = Arrays.asList(ACTION_KEYS).indexOf("fire_weapon");
for (int d = 0; d < ACTION_KEYS.length; d++) {
final int i = d == 0 ? fireIndex : (d - 1 < fireIndex ? d - 1 : d);
final int actionIndex = i;
LinearLayout row = new LinearLayout(activity);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
TextView label = new TextView(activity);
label.setTextColor(0xFFFFFFFF);
label.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15);
label.setText(Strings.get("action_" + ACTION_KEYS[i], currentLanguage));
row.addView(label,
new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
Button bindingButton = new Button(activity);
bindingButton.setOnClickListener(v -> onControlBindingButtonTapped(actionIndex));
bindingButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
bindingButton.setBackground(selectableButtonBackground());
bindingButton.setTextColor(buttonTextColors());
registerHoverable(bindingButton);
LinearLayout.LayoutParams bindingParams = new LinearLayout.LayoutParams(0, 64, 1f);
bindingParams.setMargins(4, 4, 4, 4);
row.addView(bindingButton, bindingParams);
scrollContent.addView(row, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
controlRows[i] = row;
controlActionLabels[i] = label;
controlBindingButtons[i] = bindingButton;
}
controllerScrollView = new ScrollView(activity);
controllerScrollView.addView(scrollContent, new ScrollView.LayoutParams(
ScrollView.LayoutParams.MATCH_PARENT, ScrollView.LayoutParams.WRAP_CONTENT));
tab.addView(controllerScrollView, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
// The scroll bar (see this method's comment above). Not a SeekBar:
// ProgressBar records its constructing thread (the native render
// thread here) and, when set from the UI thread, defers the visual
// update through View.post(), which never runs on this unattached
// tree. ScrollBarView draws from a plain field instead. It is a
// sibling of controllerScrollView, so it stays fixed below the
// scrolling rows, and it is always shown (a tap is a no-op when
// nothing overflows).
controllerScrollBar = new ScrollBarView(activity);
controllerScrollBar.setOnTouchListener((v, event) -> {
if (event.getAction() != MotionEvent.ACTION_DOWN) {
return true;
}
float fraction = controllerScrollBar.fractionForX(event.getX());
int range = scrollContent.getHeight() - controllerScrollView.getHeight();
if (range > 0) {
controllerScrollView.scrollTo(0, Math.round(range * fraction));
}
controllerScrollBar.setFraction(fraction);
return true;
});
// Keeps the thumb tracking the actual scroll position however it
// changed, not just from a tap on the bar itself.
controllerScrollView.setOnScrollChangeListener((v, scrollX, scrollY, oldX, oldY) -> {
int range = scrollContent.getHeight() - controllerScrollView.getHeight();
controllerScrollBar.setFraction(range > 0 ? (float) scrollY / range : 0f);
});
LinearLayout.LayoutParams scrollBarParams =
new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 48);
scrollBarParams.setMargins(24, 0, 24, 0);
tab.addView(controllerScrollBar, scrollBarParams);
LinearLayout resetRow = new LinearLayout(activity);
resetRow.setOrientation(LinearLayout.HORIZONTAL);
resetRow.setGravity(Gravity.CENTER);
resetRow.setPadding(0, 4, 0, 8);
controlResetButton = new Button(activity);
controlResetButton.setText(Strings.get("reset_to_defaults", currentLanguage));
controlResetButton.setOnClickListener(v -> onResetControlsClicked());
controlResetButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
controlResetButton.setBackground(buttonBackground());
controlResetButton.setTextColor(buttonTextColors());
registerHoverable(controlResetButton);
LinearLayout.LayoutParams resetParams =
new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, 56);
resetParams.setMargins(8, 8, 8, 8);
resetRow.addView(controlResetButton, resetParams);
tab.addView(resetRow);
return tab;
}
// Tapping a binding button starts capture for that row (or cancels it,
// if that row is the one already listening) - see xr_input.h's own
// capture_begin()/capture_cancel() comment. The row's own text is set
// to the localized placeholder immediately for instant feedback, rather
// than waiting for onControlCaptureResult() - which never arrives at
// all on cancel.
private void onControlBindingButtonTapped(int actionIndex) {
if (capturingActionIndex == actionIndex) {
nativeRequestCancelControlCapture();
capturingActionIndex = -1;
refreshControllerOptionsSelection();
return;
}
capturingActionIndex = actionIndex;
nativeRequestBeginControlCapture(actionIndex);
controlBindingButtons[actionIndex].setText(Strings.get("capture_prompt", currentLanguage));
controlBindingButtons[actionIndex].setSelected(true);
relayoutAndRedraw();
}
private void onResetControlsClicked() {
if (capturingActionIndex != -1) {
nativeRequestCancelControlCapture();
capturingActionIndex = -1;
}
nativeRequestResetPlayerControls();
refreshControllerOptionsSelection();
}
// Reflects the currently-active controller profile + bindings onto the
// tab - called when the tab is shown (switchTab()) and, while it's the
// visible tab, on the same periodic timer as the header stats
// (refreshStats()). Mirrors refreshVideoOptionsSelection()'s "query
// fresh, stays correct even if changed some other way" reasoning,
// except for whichever single row is mid-capture (see
// capturingActionIndex's own comment) - that one is left untouched so
// its placeholder isn't clobbered by this same periodic refresh.
private void refreshControllerOptionsSelection() {
String activeProfilePath = nativeGetActiveInteractionProfilePath();
ControllerProfile profile = loadControllerProfile(activeProfilePath);
boolean isGeneric = profile == null || GENERIC_PROFILE_FILENAME.equals(profile.sourceFileName);
controllerGenericNoteView.setVisibility(isGeneric ? View.VISIBLE : View.GONE);
if (isGeneric) {
controllerGenericNoteView.setText(Strings.get("generic_profile_note", currentLanguage));
}
Bitmap image = profile != null ? loadProfileImage(profile) : null;
if (image != null) {
controllerImageView.setImageBitmap(image);
controllerImageView.setVisibility(View.VISIBLE);
} else {
controllerImageView.setVisibility(View.GONE);
}
controllerProfileLabel.setText(profile != null ? profile.name : "");
for (int i = 0; i < ACTION_KEYS.length; i++) {
boolean relevant = profile == null || isActionRelevantForProfile(profile, nativeGetControlBindingKey(i));
controlRows[i].setVisibility(relevant ? View.VISIBLE : View.GONE);
if (i == capturingActionIndex) {
continue;
}
controlBindingButtons[i].setText(rawInputDisplayName(nativeGetControlBindingKey(i)));
controlBindingButtons[i].setSelected(false);
}
relayoutAndRedraw();
}
private String rawInputDisplayName(String rawKey) {
return Strings.get("raw_" + rawKey, currentLanguage);
}
// "trigger"/"squeeze" are the two symmetric default bindings (see
// xr_input.h's own comment) - relevant to a profile if it has *either*
// hand's concrete equivalent; "none" (unbound) always shows, so a
// player can see - and rebind - an action this profile happens not to
// default anything useful to. Every other key is a concrete raw input,
// checked against the profile's own controls list at that list's own
// granularity - see baseControlId()'s own comment.
private static boolean isActionRelevantForProfile(ControllerProfile profile, String rawKey) {
if ("none".equals(rawKey)) {
return true;
}
if ("trigger".equals(rawKey)) {
return profile.controlIds.contains("left_trigger") || profile.controlIds.contains("right_trigger");
}
if ("squeeze".equals(rawKey)) {
return profile.controlIds.contains("left_squeeze") || profile.controlIds.contains("right_squeeze");
}
return profile.controlIds.contains(baseControlId(rawKey));
}
// The 8 digitized thumbstick-direction raw inputs (see xr_input.h's own
// comment on QsRawInput) all point at the same physical stick, so
// res/controller_profiles/*.json lists just one "left_thumbstick"/
// "right_thumbstick" control id per hand (one diagram anchor point,
// one row-visibility check) rather than 4 - this maps any of the 4
// direction-specific names back to that shared id. Every other raw
// input (including the *_thumbstick_click ones, which are listed as
// their own separate control id - see CONFIG.md) already matches a
// profile's controls list directly, unchanged.
private static String baseControlId(String rawKey) {
switch (rawKey) {
case "left_thumbstick_up":
case "left_thumbstick_down":
case "left_thumbstick_left":
case "left_thumbstick_right":
return "left_thumbstick";
case "right_thumbstick_up":
case "right_thumbstick_down":
case "right_thumbstick_left":
case "right_thumbstick_right":
return "right_thumbstick";
default:
return rawKey;
}
}
// Parsed res/controller_profiles/*.json content (see CONFIG.md) - name/
// interaction_profile/image/controls straight from the file, plus the
// lazily-decoded image Bitmap itself (decoded at most once per profile
// object, not on every refresh - see loadProfileImage()).
private static final class ControllerProfile {
String sourceFileName;
String name;
String interactionProfile;
String imageFileName;
final Set<String> controlIds = new HashSet<>();
Bitmap image;
boolean imageLoadFailed;
}
// Scans CONTROLLER_PROFILES_DIR for the *.json file whose
// interaction_profile matches activeProfilePath, falling back to
// GENERIC_PROFILE_FILENAME if none match (e.g. the folder is missing,
// or a future runtime reports a profile this build doesn't ship a file
// for) - same fallback reasoning as xr_input.c's own
// khr/simple_controller binding. Result is memoized per active-profile-
// path string (see cachedProfilePath's own comment); returns null only
// if even the generic fallback file can't be found/parsed.
private ControllerProfile loadControllerProfile(String activeProfilePath) {
if (cachedProfile != null && Objects.equals(cachedProfilePath, activeProfilePath)) {
return cachedProfile;
}
File dir = new File(CONTROLLER_PROFILES_DIR);
File[] files = dir.listFiles((d, name) -> name.endsWith(".json"));
ControllerProfile matched = null;
ControllerProfile generic = null;
if (files != null) {
for (File file : files) {
ControllerProfile parsed = parseControllerProfileFile(file);
if (parsed == null) {
continue;
}
if (activeProfilePath != null && activeProfilePath.equals(parsed.interactionProfile)) {
matched = parsed;
}
if (GENERIC_PROFILE_FILENAME.equals(file.getName())) {
generic = parsed;
}
}
}
ControllerProfile result = matched != null ? matched : generic;
cachedProfile = result;
cachedProfilePath = activeProfilePath;
return result;
}
private static ControllerProfile parseControllerProfileFile(File file) {
try (InputStream in = new FileInputStream(file)) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) > 0) {
out.write(buf, 0, n);
}
JSONObject json = new JSONObject(out.toString("UTF-8"));
ControllerProfile profile = new ControllerProfile();
profile.sourceFileName = file.getName();
profile.name = json.optString("name", file.getName());
profile.interactionProfile = json.isNull("interaction_profile") ? null
: json.optString("interaction_profile", null);
profile.imageFileName = json.isNull("image") ? null : json.optString("image", null);
JSONArray controls = json.optJSONArray("controls");
if (controls != null) {
for (int i = 0; i < controls.length(); i++) {
String id = controls.getJSONObject(i).optString("id", null);
if (id != null) {
profile.controlIds.add(id);
}
}
}
return profile;
} catch (IOException | JSONException e) {
Log.e("QuestShock", "Could not parse controller profile " + file, e);
return null;
}
}
// Decoded at most once per ControllerProfile instance (cached alongside
// it, see loadControllerProfile()'s own memoization) - a missing/
// corrupt image file (e.g. the illustration hasn't been added to
// res/controller_profiles/ yet - see CONFIG.md) just means the tab
// shows no image, not a crash; imageLoadFailed avoids retrying the
// decode on every subsequent refresh.
private static Bitmap loadProfileImage(ControllerProfile profile) {
if (profile.imageFileName == null || profile.imageLoadFailed) {
return profile.image;
}
File file = new File(CONTROLLER_PROFILES_DIR, profile.imageFileName);
profile.image = BitmapFactory.decodeFile(file.getAbsolutePath());
profile.imageLoadFailed = profile.image == null;
return profile.image;
}
private View buildTabBar() {
LinearLayout tabBar = new LinearLayout(activity);
tabBar.setOrientation(LinearLayout.HORIZONTAL);
@@ -469,6 +941,20 @@ public class MenuOverlay extends OverlayPanel {
tabBar.addView(videoOptionsTabButton,
new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
View separator2 = new View(activity);
separator2.setBackgroundColor(0xFFFFFFFF);
tabBar.addView(separator2, new LinearLayout.LayoutParams(2, ViewGroup.LayoutParams.MATCH_PARENT));
controllerOptionsTabButton = new Button(activity);
controllerOptionsTabButton.setText(Strings.get("controller_options", currentLanguage));
controllerOptionsTabButton.setBackground(selectableButtonBackground());
controllerOptionsTabButton.setTextColor(buttonTextColors());
controllerOptionsTabButton.setOnClickListener(v -> switchTab(controllerOptionsTab));
controllerOptionsTabButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
registerHoverable(controllerOptionsTabButton);
tabBar.addView(controllerOptionsTabButton,
new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
return tabBar;
}
@@ -476,16 +962,35 @@ public class MenuOverlay extends OverlayPanel {
if (tab == currentTab) {
return;
}
// Defensive, per the Controller Options tab's own design - leaving
// this tab (however it happens) always cancels a pending capture,
// so it can never be silently left listening for a press that will
// now land on a row the player can't even see.
if (currentTab == controllerOptionsTab && capturingActionIndex != -1) {
nativeRequestCancelControlCapture();
capturingActionIndex = -1;
}
currentTab.setVisibility(View.GONE);
tab.setVisibility(View.VISIBLE);
currentTab = tab;
mainMenuTabButton.setSelected(tab == mainMenuTab);
videoOptionsTabButton.setSelected(tab == videoOptionsTab);
controllerOptionsTabButton.setSelected(tab == controllerOptionsTab);
nativeSetControllerOptionsTabOpen(tab == controllerOptionsTab);
resetHover();
if (tab == videoOptionsTab) {
refreshVideoOptionsSelection();
} else if (tab == mainMenuTab) {
refreshLanguageSelection();
} else if (tab == controllerOptionsTab) {
// Reset to the top only on actually switching into this tab -
// refreshControllerOptionsSelection() also runs on every
// periodic stats tick while it's the visible tab (see
// refreshStats()), which must never yank a mid-scroll view back
// to the top.
controllerScrollView.scrollTo(0, 0);
controllerScrollBar.setFraction(0f);
refreshControllerOptionsSelection();
}
relayoutAndRedraw();
}
@@ -649,6 +1154,8 @@ public class MenuOverlay extends OverlayPanel {
refreshVideoOptionsSelection();
} else if (currentTab == mainMenuTab) {
refreshLanguageSelection();
} else if (currentTab == controllerOptionsTab) {
refreshControllerOptionsSelection();
}
// The stats text's width changes every tick (digit count, "ms"
@@ -702,6 +1209,20 @@ public class MenuOverlay extends OverlayPanel {
gameScreenTitleView.setText(Strings.get("game_screen", currentLanguage));
gameScreenWidthLabel.setText(Strings.get("width", currentLanguage));
gameScreenDistanceLabel.setText(Strings.get("distance", currentLanguage));
controllerOptionsTabButton.setText(Strings.get("controller_options", currentLanguage));
controllerGenericNoteView.setText(Strings.get("generic_profile_note", currentLanguage));
controlResetButton.setText(Strings.get("reset_to_defaults", currentLanguage));
for (int i = 0; i < ACTION_KEYS.length; i++) {
controlActionLabels[i].setText(Strings.get("action_" + ACTION_KEYS[i], currentLanguage));
// The listening row's placeholder also needs re-translating on
// a language change mid-capture; every other row's actual
// binding text is refreshed by the very next periodic
// refreshControllerOptionsSelection() call regardless.
if (i == capturingActionIndex) {
controlBindingButtons[i].setText(Strings.get("capture_prompt", currentLanguage));
}
}
}
// "Dismiss" on the restart dialog - the live-appliable subset was
@@ -793,6 +1314,27 @@ public class MenuOverlay extends OverlayPanel {
private static native float nativeGetCpuUtilization();
private static native float nativeGetGpuUtilization();
// Controller Options tab - same enqueue-only reasoning as every other
// request_*() above. actionIndex indexes ACTION_KEYS (must match
// xr_input.c's own action ordering - see that array's own comment).
private static native void nativeRequestBeginControlCapture(int actionIndex);
private static native void nativeRequestCancelControlCapture();
private static native void nativeRequestResetPlayerControls();
// Read-only queries, safe to call directly - same reasoning as
// nativeGetRendererIsOpenGl() etc. above. nativeGetControlBindingKey()
// returns one of xr_input.c's kRawInputNames strings (e.g. "right_b",
// "trigger", "none") - rawInputDisplayName() maps it to a localized
// label. nativeGetActiveInteractionProfilePath() returns the OpenXR
// interaction profile path string (e.g.
// "/interaction_profiles/oculus/touch_controller"), or "" before the
// first successful in-engine query.
private static native String nativeGetControlBindingKey(int actionIndex);
private static native String nativeGetActiveInteractionProfilePath();
// Tells xr_input.c when Controller Options is the visible tab, so the
// menu's thumbstick locomotion is off while bindings are being tested
// or reassigned - see switchTab().
private static native void nativeSetControllerOptionsTabOpen(boolean open);
// Called from xr_overlay.c's xr_overlay_init_jni(), on the render
// thread, right after it resolves this class via the activity's own
// ClassLoader (plain FindClass() can't see app classes from a thread
@@ -841,6 +1383,56 @@ public class MenuOverlay extends OverlayPanel {
});
}
// Called from xr_input.c's xr_input_process_capture() (via
// qs_config_notify_control_capture_result(), qs_config.c) on the
// engine's own SDL thread, once a capture resolves - either a
// successful press (actionIndex is the row that was capturing) or a
// second call reporting a *different* row that got displaced by
// exclusivity (see xr_input.h's own comment), or a timeout/cancel
// (rawName null). Rather than applying rawName itself, this just clears
// capturingActionIndex (if it matches) and re-reads every row fresh
// from native truth - correct for either call shape with no need to
// special-case which one this is.
public static void onControlCaptureResult(final int actionIndex, final String rawName) {
if (instance == null) {
return;
}
instance.activity.runOnUiThread(() -> {
if (actionIndex == instance.capturingActionIndex) {
instance.capturingActionIndex = -1;
}
instance.refreshControllerOptionsSelection();
});
}
// Called from qs_config.c's qs_config_report_fatal_input_error(), from
// inside xr_input_init() - which runs before this class's own
// nativeInit() (see xr_session.c: xr_input_init() happens before the
// menu/keyboard overlays' xr_overlay_create() calls), so `instance` is
// guaranteed still null here and can't be used to reach the Activity
// the way showRestartDialog()/showMessageDialog() do. SDLActivity's own
// getContext() is set from onCreate(), long before any of this native
// XR startup runs, so it's used instead - the one deliberate exception
// to this class's usual "everything hangs off `instance`" convention.
// Non-cancelable, single "Exit" button, same hard-kill pattern as
// restartApp() (Runtime.getRuntime().exit(0), not finish() - by the
// time this fires, native startup is already partway through, so only
// killing the whole process is guaranteed to actually stop it).
public static void showFatalControllerBindingError(final String message) {
Context context = org.libsdl.app.SDLActivity.getContext();
if (!(context instanceof Activity)) {
return;
}
final Activity fatalActivity = (Activity) context;
fatalActivity.runOnUiThread(() -> new AlertDialog.Builder(fatalActivity)
.setTitle("Controller setup failed")
.setMessage(message)
.setCancelable(false)
.setPositiveButton("Exit", (dialog, which) -> Runtime.getRuntime().exit(0))
.create()
.show());
}
public static byte[] nativeTakePixelsIfDirty() {
return instance == null ? null : instance.takePixelsIfDirty();
}
@@ -271,7 +271,11 @@ abstract class OverlayPanel {
// 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.
// 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()).
@@ -285,7 +289,10 @@ abstract class OverlayPanel {
if (current == root || !(current.getParent() instanceof View)) {
break;
}
current = (View) current.getParent();
View parent = (View) current.getParent();
left -= parent.getScrollX();
top -= parent.getScrollY();
current = parent;
}
return new Rect(left, top, left + view.getWidth(), top + view.getHeight());
}
@@ -23,7 +23,7 @@ import org.libsdl.app.SDLSurface;
* SDL_RWops, so Android's asset-manager fallback for SDL_RWFromFile
* doesn't apply here) expects to find it - see android/engine-patches/ for
* why this is done from Java instead of patching engine/ itself, which is
* never modified.q
* never modified.
*
* super.onCreate() (SDLActivity's) must always run first and unconditionally
* - Android throws SuperNotCalledException otherwise, checked right after
@@ -39,7 +39,8 @@ import org.libsdl.app.SDLSurface;
public class QuestShockActivity extends SDLActivity {
private static final String TAG = "QuestShock";
private static final int PERMISSION_REQUEST_STORAGE = 1;
private static final String GAME_DIR = "/sdcard/questshock";
// Package-visible: MenuOverlay reads controller_profiles/ from here.
static final String GAME_DIR = "/sdcard/questshock";
// Loaded here (rather than waiting for SDLActivity's own
// getLibraries()/loadLibraries(), which only runs from super.onCreate())
@@ -143,7 +144,7 @@ public class QuestShockActivity extends SDLActivity {
// is already there, assume a previous run already extracted everything.
File marker = new File(gameDir, "GET_ASSETS_QUEST.txt");
if (!marker.exists()) {
copyAssetDir("shaders", new File(gameDir, "shaders"));
copyAssetDir("shaders", new File(gameDir, "shaders"), false);
copyAssetFile("res/soundfont.sf2", new File(gameDir, "res/soundfont.sf2"), false);
copyAssetFile("GET_ASSETS_QUEST.txt", marker, false);
}
@@ -155,6 +156,13 @@ public class QuestShockActivity extends SDLActivity {
copyAssetFile("questshock.config.default.json",
new File(gameDir, "questshock.config.default.json"), true);
// Refreshed on every launch. MenuOverlay's Controller Options tab
// matches the active OpenXR interaction profile against these files'
// "interaction_profile" and shows the paired image (see CONFIG.md).
// Only the bundled filenames are overwritten, so profiles a user adds
// to this folder are kept.
copyAssetDir("controller_profiles", new File(gameDir, "controller_profiles"), true);
// Shockolate's engine/ is never patched to check this itself (see the
// class doc above) - it just does plain fopen("res/data/...", ...)
// and, on a fresh install with no game data copied in yet, that
@@ -239,7 +247,7 @@ public class QuestShockActivity extends SDLActivity {
}
}
private void copyAssetDir(String assetDir, File destDir) {
private void copyAssetDir(String assetDir, File destDir, boolean overwrite) {
AssetManager assets = getAssets();
String[] entries;
try {
@@ -253,7 +261,7 @@ public class QuestShockActivity extends SDLActivity {
}
destDir.mkdirs();
for (String entry : entries) {
copyAssetFile(assetDir + "/" + entry, new File(destDir, entry), false);
copyAssetFile(assetDir + "/" + entry, new File(destDir, entry), overwrite);
}
}
}
@@ -61,6 +61,106 @@ final class Strings {
"Clavier - faites glisser ici pour déplacer",
"Tastatur - hier ziehen zum Verschieben");
put("close", "Close", "Fermer", "Schließen");
// Controller Options tab: action row labels, keyed by xr_input.c's
// action names (see MenuOverlay.ACTION_KEYS).
put("controller_options", "Controller Options", "Options de manette", "Controller-Optionen");
put("action_move_forward", "Move Forward", "Avancer", "Vorwärts gehen");
put("action_move_back", "Move Back", "Reculer", "Rückwärts gehen");
put("action_strafe_left", "Strafe Left", "Pas latéral gauche", "Seitwärts links");
put("action_strafe_right", "Strafe Right", "Pas latéral droit", "Seitwärts rechts");
put("action_turn_left", "Turn Left", "Tourner à gauche", "Nach links drehen");
put("action_turn_right", "Turn Right", "Tourner à droite", "Nach rechts drehen");
put("action_jump", "Jump", "Sauter", "Springen");
put("action_mouse_click", "Click", "Cliquer", "Klicken");
put("action_mouse_double_click", "Double-Click", "Double-clic", "Doppelklick");
// Hotkey actions (see xr_input.c); all unbound by default.
put("action_stand", "Stand", "Debout", "Stehen");
put("action_crouch", "Crouch", "Accroupi", "Ducken");
put("action_prone", "Prone", "Couché", "Liegen");
put("action_toggle_freelook", "Toggle Free Look", "Basculer la vue libre",
"Freies Umsehen umschalten");
put("action_pause", "Pause Game", "Mettre en pause", "Spiel pausieren");
put("action_full_view", "Fullscreen View", "Vue plein écran", "Vollbildansicht");
put("action_normal_view", "Normal View", "Vue normale", "Normale Ansicht");
put("action_map_view", "Automap View", "Vue de la carte", "Automap-Ansicht");
put("action_save_game", "Save Game", "Sauvegarder", "Spiel speichern");
put("action_load_game", "Load Game", "Charger", "Spiel laden");
put("action_select_grenade", "Select Grenade", "Sélectionner une grenade",
"Granate auswählen");
put("action_arm_grenade", "Arm Grenade", "Armer la grenade", "Granate scharfmachen");
put("action_select_drug", "Select Drug", "Sélectionner une drogue", "Droge auswählen");
put("action_use_drug", "Use Drug", "Utiliser la drogue", "Droge benutzen");
put("action_toggle_online_help", "Toggle Online Help", "Basculer l'aide en ligne",
"Online-Hilfe umschalten");
put("action_show_help", "Show Help", "Afficher l'aide", "Hilfe anzeigen");
put("action_toggle_music", "Toggle Music", "Basculer la musique", "Musik umschalten");
put("action_quit", "Quit Game", "Quitter le jeu", "Spiel beenden");
put("action_cycle_weapon_next", "Next Weapon", "Arme suivante", "Nächste Waffe");
put("action_cycle_weapon_prev", "Previous Weapon", "Arme précédente", "Vorherige Waffe");
put("action_cycle_detail", "Cycle Detail Level", "Changer le niveau de détail",
"Detailgrad wechseln");
put("action_reload_weapon_primary", "Reload Weapon (Primary)",
"Recharger l'arme (principal)", "Waffe nachladen (primär)");
put("action_reload_weapon_secondary", "Reload Weapon (Secondary)",
"Recharger l'arme (secondaire)", "Waffe nachladen (sekundär)");
put("action_hud_color", "Cycle HUD Color", "Changer la couleur de l'interface",
"HUD-Farbe wechseln");
put("action_clear_fullscreen", "Clear Fullscreen View", "Quitter la vue plein écran",
"Vollbildansicht schließen");
put("action_mfd_left_1", "Left MFD: Weapon", "MFD gauche : arme", "Linkes MFD: Waffe");
put("action_mfd_left_2", "Left MFD: Item", "MFD gauche : objet", "Linkes MFD: Gegenstand");
put("action_mfd_left_3", "Left MFD: Map", "MFD gauche : carte", "Linkes MFD: Karte");
put("action_mfd_left_4", "Left MFD: Target", "MFD gauche : cible", "Linkes MFD: Ziel");
put("action_mfd_left_5", "Left MFD: Info", "MFD gauche : info", "Linkes MFD: Info");
put("action_mfd_right_1", "Right MFD: Weapon", "MFD droit : arme", "Rechtes MFD: Waffe");
put("action_mfd_right_2", "Right MFD: Item", "MFD droit : objet",
"Rechtes MFD: Gegenstand");
put("action_mfd_right_3", "Right MFD: Map", "MFD droit : carte", "Rechtes MFD: Karte");
put("action_mfd_right_4", "Right MFD: Target", "MFD droit : cible", "Rechtes MFD: Ziel");
put("action_mfd_right_5", "Right MFD: Info", "MFD droit : info", "Rechtes MFD: Info");
put("action_fire_weapon", "Fire Weapon", "Tirer", "Waffe abfeuern");
put("capture_prompt", "Press a button…", "Appuyez sur un bouton…", "Taste drücken…");
put("reset_to_defaults", "Reset to Defaults", "Réinitialiser", "Auf Standard zurücksetzen");
put("generic_profile_note",
"This controller only exposes a trigger and a menu button - most actions above have "
+ "nothing to bind to.",
"Cette manette n'expose qu'une gâchette et un bouton menu - la plupart des actions "
+ "ci-dessus n'ont rien à quoi être assignées.",
"Dieser Controller bietet nur einen Trigger und eine Menütaste - die meisten "
+ "Aktionen oben haben nichts, woran sie gebunden werden können.");
// Raw physical-input display names (xr_input.c's kRawInputNames);
// "none" is the unbound sentinel.
put("raw_left_trigger", "Left Trigger", "Gâchette gauche", "Linker Trigger");
put("raw_right_trigger", "Right Trigger", "Gâchette droite", "Rechter Trigger");
put("raw_left_squeeze", "Left Grip", "Préhension gauche", "Linker Griff");
put("raw_right_squeeze", "Right Grip", "Préhension droite", "Rechter Griff");
put("raw_left_x", "X Button", "Bouton X", "X-Taste");
put("raw_left_y", "Y Button", "Bouton Y", "Y-Taste");
put("raw_right_a", "A Button", "Bouton A", "A-Taste");
put("raw_right_b", "B Button", "Bouton B", "B-Taste");
put("raw_left_thumbstick_click", "Left Stick Click", "Clic du stick gauche",
"Linker Stick-Klick");
put("raw_right_thumbstick_click", "Right Stick Click", "Clic du stick droit",
"Rechter Stick-Klick");
put("raw_left_thumbstick_up", "Left Stick Up", "Stick gauche haut", "Linker Stick hoch");
put("raw_left_thumbstick_down", "Left Stick Down", "Stick gauche bas", "Linker Stick runter");
put("raw_left_thumbstick_left", "Left Stick Left", "Stick gauche à gauche",
"Linker Stick links");
put("raw_left_thumbstick_right", "Left Stick Right", "Stick gauche à droite",
"Linker Stick rechts");
put("raw_right_thumbstick_up", "Right Stick Up", "Stick droit haut", "Rechter Stick hoch");
put("raw_right_thumbstick_down", "Right Stick Down", "Stick droit bas",
"Rechter Stick runter");
put("raw_right_thumbstick_left", "Right Stick Left", "Stick droit à gauche",
"Rechter Stick links");
put("raw_right_thumbstick_right", "Right Stick Right", "Stick droit à droite",
"Rechter Stick rechts");
put("raw_trigger", "Either Trigger", "L'une ou l'autre gâchette", "Beliebiger Trigger");
put("raw_squeeze", "Either Grip", "L'une ou l'autre préhension", "Beliebiger Griff");
put("raw_none", "(unbound)", "(non assigné)", "(nicht zugewiesen)");
}
private Strings() {}
@@ -0,0 +1,42 @@
--- a/src/GameSrc/cutsloop.c
+++ b/src/GameSrc/cutsloop.c
@@ -292,11 +292,38 @@
+#ifdef __ANDROID__
+// The intro (cutscene 0) ships in one file per language - localized
+// voice-over - each holding the same resource id (cutscene_anims[0]), so
+// opening the current language's file instead is all it takes. The death
+// and ending cutscenes only have one version.
+static char *intro_files_by_language[3] =
+{
+ "res/data/svgaintr.res", // English
+ "res/data/svfrintr.res", // French
+ "res/data/svgeintr.res" // German
+};
+
+static int open_cutscene_file(int id)
+{
+ if (id == 0 && which_lang > 0 && which_lang < 3)
+ {
+ int handle = ResOpenFile(intro_files_by_language[(int)which_lang]);
+ if (handle > 0)
+ return handle;
+ // Not installed for this language - fall back to English below.
+ }
+ return ResOpenFile(cutscene_files[id]);
+}
+#else
+#define open_cutscene_file(id) ResOpenFile(cutscene_files[id])
+#endif
+
short play_cutscene(int id, bool show_credits)
{
MacTuneKillCurrentTheme();
- cutscene_filehandle = ResOpenFile(cutscene_files[id]);
+ cutscene_filehandle = open_cutscene_file(id);
if (cutscene_filehandle <= 0) {
// If we failed to play the cutscene, go to setup / credits.
_new_mode = SETUP_LOOP;