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
+216 -7
View File
@@ -66,6 +66,21 @@ startup, in addition to (and independent of) the engine's own `prefs.txt`.
"x": 0.0, "x": 0.0,
"z": 0.0, "z": 0.0,
"yaw_degrees": 0.0 "yaw_degrees": 0.0
},
"player_controls": {
"move_forward": "left_thumbstick_up",
"move_back": "left_thumbstick_down",
"strafe_left": "left_thumbstick_left",
"strafe_right": "left_thumbstick_right",
"turn_left": "right_thumbstick_left",
"turn_right": "right_thumbstick_right",
"jump": "left_thumbstick_click",
"mouse_click": "right_a",
"mouse_double_click": "right_b",
"stand": "right_thumbstick_up",
"prone": "right_thumbstick_down",
"toggle_freelook": "right_thumbstick_click",
"fire_weapon": "right_trigger"
} }
} }
``` ```
@@ -177,13 +192,14 @@ to an automatic recenter instead (see **Reset Position** below), using
your real head pose at that moment. A complete saved position is applied your real head pose at that moment. A complete saved position is applied
as-is, with no such recenter. as-is, with no such recenter.
**Reset Position** (Main Menu tab) is a separate, live-only action, not a **Reset Position** (Main Menu tab) is a live-only action, not a config
config value: it recenters using your *current* real head pose, so the value: it moves you to the centre in front of the game quad, based on where
game quad ends up directly ahead of wherever you're actually standing/ your head actually is right now. It resets only your *position*, not which
facing right now - not a plain "go back to `(0, 0, 0)`" (which would only way you're facing, so the screen does not rotate to follow your gaze - press
undo thumbstick movement, not any physical drift/rotation since the it while looking off to one side and the screen stays off to the other side.
session started). It's unrelated to `questshock.config.json` - it never The automatic recenter at startup (when there's no saved position) does
reads or writes this file. face the screen towards where you're looking. Reset Position never reads or
writes `questshock.config.json`.
### `x`, `z` ### `x`, `z`
@@ -198,3 +214,196 @@ Which way the player is virtually facing, in degrees.
- **Type**: number - **Type**: number
- **Restart required?**: No (live-appliable) - **Restart required?**: No (live-appliable)
## Player Controls (`player_controls`)
Maps each of 45 in-game actions to a physical controller input: 9
movement/click actions plus 36 for the engine's own keyboard hotkeys and
firing (posture, save/load, weapon select, MFD panels, etc. - see the hotkey
table below). Some actions are bound by default (see the default-input
columns); the rest are unbound and can be bound on demand. Assign them in the
**Controller Options** menu tab: tap an action's binding button, then press
the button or stick direction to use ("Press a button…"); tap it again, or
wait 8 seconds, to cancel without changing it.
- **Type**: one enum-string field per action (below)
- **Restart required?**: No (live-appliable) - a rebind takes effect
immediately, and persists across restarts once saved (**Save
Configuration**, same as every other group here).
- **Not included here**: opening/closing the VR menu itself is a fixed,
non-remappable button (see "Menu button" below) - there's no
`menu_toggle` field, and the Controller Options tab never lets you
reassign it. This guarantees you can always reach this tab and press
**Reset to Defaults** to recover from any bad rebind, no matter what else
you've reassigned.
- **Reset to Defaults**: restores the "Default input" column below for
every action not overridden by a `player_controls` group in
`questshock.config.default.json` - if that file defines one (it doesn't,
out of the box), the values it specifies are applied on top of the table
below instead, so you can customize what "reset" means without a rebuild.
This is independent of your own `questshock.config.json` - resetting
doesn't read or write that file, just the live bindings (**Save
Configuration** afterward to persist them).
### Actions
| Action key | Default input | Effect |
|---|---|---|
| `move_forward` | `left_thumbstick_up` | Walk forward |
| `move_back` | `left_thumbstick_down` | Walk backward |
| `strafe_left` | `left_thumbstick_left` | Step sideways, left |
| `strafe_right` | `left_thumbstick_right` | Step sideways, right |
| `turn_left` | `right_thumbstick_left` | Turn left |
| `turn_right` | `right_thumbstick_right` | Turn right |
| `jump` | `left_thumbstick_click` | Jump |
| `mouse_click` | `right_a` | Click - operates the keyboard/game quad's cursor, whichever the pointing hand's ray is over |
| `mouse_double_click` | `right_b` | Double-click - game quad only (e.g. using an inventory item); has no effect over the menu or keyboard |
Movement actions bound to a thumbstick direction are suppressed while the
VR menu is open, since the same physical stick already snap-turns/smooth-
moves the player in that state (see `player_position` above) - this is the
one case where a binding's effect depends on whether the menu is open, and
it only ever affects the 8 thumbstick-direction inputs below.
`mouse_click` additionally behaves differently depending on the menu:
while the VR menu is open, its "click" is always whichever hand's trigger
is used to interact with the menu (so you can never lock yourself out of
this settings screen by rebinding `mouse_click` elsewhere); once the menu
is closed, both the on-screen keyboard (if it's still open) and the game
quad use your actual `mouse_click` binding. The default (`right_a`) is a
specific button on the right controller, so only the right hand's ray clicks;
bind the `trigger` identifier (see below) for either hand to click.
### Hotkey actions
The engine's own keyboard hotkeys (`HotKeyLookup[]` in `Prefs.c`) plus
firing, as 36 bindable actions. `stand` (`right_thumbstick_up`), `prone`
(`right_thumbstick_down`), `toggle_freelook` (`right_thumbstick_click`) and
`fire_weapon` (`right_trigger`) are bound by default; the rest are unbound
(`none`). Each fires once per press, like pressing the key on a real
keyboard (there is no hold behavior).
| Action key | Keyboard equivalent | Effect |
|---|---|---|
| `stand` | `t` | Stand up |
| `crouch` | `g` | Crouch |
| `prone` | `b` | Go prone |
| `toggle_freelook` | `f` | Toggle mouse-look |
| `pause` | `p` | Pause the game |
| `full_view` | Ctrl+F | Switch to the fullscreen view loop |
| `normal_view` | Ctrl+D | Switch to the normal gameplay view |
| `map_view` | Ctrl+A | Switch to the automap view |
| `save_game` | Ctrl+S | Save the game |
| `load_game` | Ctrl+L | Load the game |
| `select_grenade` | Ctrl+' | Select the next grenade type |
| `arm_grenade` | Alt+' | Arm the selected grenade |
| `select_drug` | Ctrl+; | Select the next drug/patch |
| `use_drug` | Alt+; | Use the selected drug/patch |
| `toggle_online_help` | Ctrl+H | Toggle the online help overlay |
| `show_help` | Alt+O | Show the help screen |
| `toggle_music` | Ctrl+M | Toggle music |
| `quit` | Ctrl+Q | Quit the game - opt-in only; nothing binds this by default |
| `cycle_weapon_next` | Tab | Ready the next weapon |
| `cycle_weapon_prev` | Shift+Tab | Ready the previous weapon |
| `cycle_detail` | Ctrl+1 | Cycle the graphics detail level |
| `reload_weapon_primary` | Ctrl+Backspace | Reload the current weapon's primary ammo |
| `reload_weapon_secondary` | Alt+Backspace | Reload the current weapon's secondary ammo |
| `hud_color` | Alt+H | Cycle the HUD color scheme |
| `clear_fullscreen` | Backspace | Close a fullscreen view (e.g. a datapad or email) |
| `mfd_left_1`-`mfd_left_5` | F1-F5 | Set the left MFD panel to Weapon/Item/Map/Target/Info |
| `mfd_right_1`-`mfd_right_5` | F6-F10 | Set the right MFD panel to Weapon/Item/Map/Target/Info |
| `fire_weapon` | Enter | Fire the readied weapon at the cursor (the engine's default fire key). Clicking in the 3D view with `mouse_click` also fires; this is a separate button for it |
**Not exposed** (other `HotKeyLookup[]` entries):
- `keypad 0`-`keypad 9`: a controller button per digit is not useful; use the
on-screen keyboard.
- The 4 `cheat_*` entries: debug cheats.
- `mac_help` (disabled) and `audiolog_cancel` (its build flag is not
defined): they are not real hotkeys in this build.
- `toggle_opengl` (Ctrl+G): the Video Options tab's Renderer buttons already
toggle the same setting.
- The 10 numeric hardware-item hotkeys (bio scan, fullscreen, 360 view,
lantern, shield, infrared, nav unit, data reader, booster, jumpjets): their
registered keys never match a real keypress in the engine.
### Raw input identifiers
Every raw physical input available to bind an action to, with its generic
OpenXR interaction-profile component name and its Meta Quest Touch control
name. Two symmetric identifiers (`trigger`, `squeeze`) are also accepted, but
only for `mouse_click`/`mouse_double_click`: they mean "either hand's own
trigger/grip, whichever hand is pointing". Every other identifier below
refers to one specific control.
| Identifier | OpenXR component (Touch) | Meta Quest control |
|---|---|---|
| `left_trigger` | `/user/hand/left/input/trigger/value` | Left Trigger |
| `right_trigger` | `/user/hand/right/input/trigger/value` | Right Trigger |
| `left_squeeze` | `/user/hand/left/input/squeeze/value` | Left Grip |
| `right_squeeze` | `/user/hand/right/input/squeeze/value` | Right Grip |
| `left_x` | `/user/hand/left/input/x/click` | X Button |
| `left_y` | `/user/hand/left/input/y/click` | Y Button |
| `right_a` | `/user/hand/right/input/a/click` | A Button |
| `right_b` | `/user/hand/right/input/b/click` | B Button |
| `left_thumbstick_click` | `/user/hand/left/input/thumbstick/click` | Left Thumbstick Click |
| `right_thumbstick_click` | `/user/hand/right/input/thumbstick/click` | Right Thumbstick Click |
| `left_thumbstick_up/down/left/right` | `/user/hand/left/input/thumbstick` (digitized) | Left Thumbstick, tilted |
| `right_thumbstick_up/down/left/right` | `/user/hand/right/input/thumbstick` (digitized) | Right Thumbstick, tilted |
| `trigger` | either hand's trigger | Either Trigger (`mouse_click`/`mouse_double_click` only) |
| `squeeze` | either hand's grip | Either Grip (`mouse_click`/`mouse_double_click` only) |
| `none` | - | Unbound - the action never fires |
**Menu button** (left controller, Touch/Vive/generic alike) is not in this
list - it always opens/closes the VR menu and can never be reassigned.
**Other supported controllers**: HTC Vive wands have no A/B/X/Y and no
analog grip - `left_x`/`left_y`/`right_a`/`right_b` simply never fire on
that profile, and `left/right_squeeze` are a plain click rather than
analog. Their trackpad stands in for the thumbstick identifiers above
(same digitization, just a trackpad instead of a stick). A bare-minimum
OpenXR controller (`khr/simple_controller`, used as a fallback when
neither Touch nor Vive bindings are suggested) only has `left_trigger`/
`right_trigger` (via its `select/click` component) and the menu button -
every other identifier above simply never fires on it, which is why the
Controller Options tab shows a note and hides irrelevant rows when this
profile is active.
### Controller profile files (`controller_profiles/`)
The Controller Options tab shows a labeled illustration of whichever
controller is actually active, driven by `res/controller_profiles/*.json`
(bundled with the app, copied to `/sdcard/questshock/controller_profiles/`
on every launch, refreshing the known bundled files without touching any
extra ones you add there yourself). Each file:
```json
{
"name": "Meta Quest Touch",
"interaction_profile": "/interaction_profiles/oculus/touch_controller",
"image": "touch.png",
"controls": [
{"id": "right_b", "label": "B", "anchor_x": 0.76, "anchor_y": 0.22}
]
}
```
- `interaction_profile` - the OpenXR interaction profile path this file
describes; matched against whichever one is actually bound at runtime.
Falls back to `generic.json` if nothing matches.
- `image` - filename (same folder) of the controller illustration.
- `controls` - one entry per labeled point on that illustration: `id` is a
raw input identifier from the table above (thumbstick directions share
one `left_thumbstick`/`right_thumbstick` entry, since they're the same
physical stick); `anchor_x`/`anchor_y` are 0-1 fractions of the image for
positioning that label's pointer line.
This is presentation data only - adding real support for a new controller
still needs matching bindings in the app itself; a profile file with no
corresponding code-level support just never gets matched.
### Fatal startup error
If the OpenXR runtime rejects any of the three supported controller
profiles' bindings at launch (an app bug, not something you did), a
non-cancelable dialog explains this and exits - this should never happen
in a normal build.
+1
View File
@@ -106,6 +106,7 @@ def stageEngineTask = tasks.register("stageEngine", Exec) {
inputs.file("${repoRoot}/build-image/Dockerfile") inputs.file("${repoRoot}/build-image/Dockerfile")
// Staged into assets/ by prepare-android-project.sh. // Staged into assets/ by prepare-android-project.sh.
inputs.file("${repoRoot}/res/questshock.config.default.json") 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-engine")
outputs.dir("${repoRoot}/build/android-gl4es") outputs.dir("${repoRoot}/build/android-gl4es")
outputs.dir("${repoRoot}/build/android-prebuilt") outputs.dir("${repoRoot}/build/android-prebuilt")
+217 -15
View File
@@ -369,6 +369,14 @@ typedef struct {
bool has_x, has_z, has_yaw_degrees; bool has_x, has_z, has_yaw_degrees;
} QsParsedPlayerPosition; } 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 { typedef struct {
QsParsedVideoOptions video_options; QsParsedVideoOptions video_options;
bool has_video_options; bool has_video_options;
@@ -376,6 +384,8 @@ typedef struct {
bool has_general; bool has_general;
QsParsedPlayerPosition player_position; QsParsedPlayerPosition player_position;
bool has_player_position; bool has_player_position;
QsParsedPlayerControls player_controls;
bool has_player_controls;
} QsParsedConfig; } QsParsedConfig;
static void qs_parse_video_options_field(const char *key, QsJsonParser *p, void *ctx) { 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); 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) { static void qs_parse_top_level_field(const char *key, QsJsonParser *p, void *ctx) {
QsParsedConfig *out = (QsParsedConfig *)ctx; QsParsedConfig *out = (QsParsedConfig *)ctx;
if (strcmp(key, "video_options") == 0 && !p->error && p->p < p->end && *p->p == '{') 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 == '{') else if (strcmp(key, "player_position") == 0 && !p->error && p->p < p->end && *p->p == '{')
out->has_player_position = out->has_player_position =
qs_json_parse_object(p, qs_parse_player_position_field, &out->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 else
qs_json_skip_value(p); qs_json_skip_value(p);
} }
@@ -874,6 +898,38 @@ static bool qs_apply_player_position(const QsParsedPlayerPosition *parsed) {
return changed; 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) // 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) if (changed && QS_PLAYER_POSITION_REQUIRES_RESTART)
qs_append_restart_reason(restart_needed, restart_needed_size, "player_position"); 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) { static bool qs_config_write_file(void) {
@@ -1037,6 +1098,14 @@ static bool qs_config_write_file(void) {
fprintf(f, " \"general\": {\n"); fprintf(f, " \"general\": {\n");
fprintf(f, " \"language\": \"%s\"\n", qs_current_language_name()); fprintf(f, " \"language\": \"%s\"\n", qs_current_language_name());
fprintf(f, " },\n"); 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; float x, z, yaw_degrees;
xr_session_get_player_transform(&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. // 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); } 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) // 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_initial_screen_distance_event_type;
static Uint32 s_set_game_screen_width_event_type; static Uint32 s_set_game_screen_width_event_type;
static Uint32 s_set_game_screen_distance_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) { void qs_config_load_startup(void) {
// Registers the custom SDL event types qs_config_request_save()/_load()/ // Registers the custom SDL event types qs_config_request_save()/_load()/
// _set_renderer()/_set_resolution()/_set_language()/_reset_position()/ // _set_renderer()/_set_resolution()/_set_language()/_reset_position()/
// the four _set_*_screen_*() screen-profile setters enqueue - done // the four _set_*_screen_*() screen-profile setters/the three
// here, once, on the engine's own thread and before the menu/its // control-capture/reset requests enqueue - done here, once, on the
// buttons could possibly exist yet, rather than lazily from those // engine's own thread and before the menu/its buttons could possibly
// functions themselves (which run on Android's UI thread and could // exist yet, rather than lazily from those functions themselves (which
// otherwise race each other doing this lazily). // run on Android's UI thread and could otherwise race each other doing
Uint32 base = SDL_RegisterEvents(10); // this lazily).
Uint32 base = SDL_RegisterEvents(14);
s_save_event_type = base; s_save_event_type = base;
s_load_event_type = base + 1; s_load_event_type = base + 1;
s_set_renderer_event_type = base + 2; 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_game_screen_width_event_type = base + 7;
s_set_initial_screen_distance_event_type = base + 8; s_set_initial_screen_distance_event_type = base + 8;
s_set_game_screen_distance_event_type = base + 9; 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; QsParsedConfig parsed;
bool haveConfig = qs_config_read_file(&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); 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) { bool qs_config_handle_event(const SDL_Event *event) {
if (event->type == s_save_event_type) { if (event->type == s_save_event_type) {
if (qs_config_write_file()) if (qs_config_write_file())
@@ -1305,15 +1488,10 @@ bool qs_config_handle_event(const SDL_Event *event) {
return true; return true;
} }
if (event->type == s_reset_position_event_type) { if (event->type == s_reset_position_event_type) {
// Deliberately not xr_session_set_player_transform(0,0,0) - see // Re-anchors the position onto the player's current real head position
// xr_input_recenter_player_to_screen()'s own comment: a "return to // and keeps their facing (see xr_input_reset_player_position()). Does
// (0,0,0)" reset only undoes thumbstick locomotion, not any real, // not read or write questshock.config.json.
// physical drift/rotation since the session started, which is what xr_input_reset_player_position();
// 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();
message_info("Recentered"); message_info("Recentered");
return true; 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)); qs_preset_value(s_screen_distance_offset_m, s_screen_distance_step_m, index));
return true; 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; 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()). // menu applies a language change (see qs_config.c's qs_apply_language()).
void qs_config_request_set_language(int language_index); void qs_config_request_set_language(int language_index);
// Same cross-thread-dispatch reasoning as the three above - called from the // Same cross-thread dispatch as the three above - called from the Main Menu
// Main Menu tab's "Reset Position" button. A pure, live "return to a sane // tab's "Reset Position" button. Re-anchors the player's position in front of
// view of the game quad" action (see xr_input.h's own // the game quad (see xr_input_reset_player_position()); never reads or writes
// xr_input_recenter_player_to_screen(), which this dispatches to) - // the config file.
// 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.
void qs_config_request_reset_position(void); void qs_config_request_reset_position(void);
// Same cross-thread-dispatch reasoning as the four above - called from the // 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_width(int preset_index);
void qs_config_request_set_game_screen_distance(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 // Cross-thread requests from the Controller Options tab (MenuOverlay.java),
// qs_config_request_save()/_load()/_set_renderer()/_set_resolution()/ // enqueued like every request_*() above. action_index indexes
// _set_language()/_reset_position()/the four _set_*_screen_*() setters // xr_input_action_name()'s list. Begin/cancel start and stop rebind capture;
// enqueue - called from pump_events() (sdl_events.c, see // reset restores all bindings (the built-in defaults, then
// android/engine-patches/21-android-config.patch) on the engine's own // questshock.config.default.json's player_controls group if it has one).
// thread, once per polled event, before its normal switch on well-known void qs_config_request_begin_control_capture(int action_index);
// SDL_* event types. Returns false (no-op) for any other event type, void qs_config_request_cancel_control_capture(void);
// including before the ten custom types have even been registered. 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); bool qs_config_handle_event(const SDL_Event *event);
// Called directly (no event-queue dispatch - see // Called directly (no event-queue dispatch - see
@@ -11,6 +11,7 @@
#include <SDL.h> #include <SDL.h>
#include "qs_config.h" #include "qs_config.h"
#include "xr_input.h"
#include "xr_overlay.h" #include "xr_overlay.h"
#include "xr_session.h" #include "xr_session.h"
@@ -233,3 +234,39 @@ JNIEXPORT jfloat JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetGpuUtilization(JNIEnv *env, jclass clazz) { Java_de_ladkau_questshock_MenuOverlay_nativeGetGpuUtilization(JNIEnv *env, jclass clazz) {
return xr_session_get_gpu_utilization(); 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); bool xr_input_get_beam_layer(int hand, XrCompositionLayerQuad *out_quad);
// Recenters the player so the game quad ("virtual screen") appears directly // Recenters the player so the game quad ("virtual screen") appears directly
// ahead of wherever they're currently really standing/facing - a pure, // ahead of wherever they are standing and facing, from their current real head
// live "return to a sane view of the screen" action, unrelated to any // pose. Never reads or writes questshock.config.json. Called automatically
// stored/config value (unlike xr_session_set_player_transform()'s other // once, from xr_input_sync_and_draw(), on the first frame with a valid head
// callers, this never reads or writes questshock.config.json - it's driven // pose after startup, unless a complete saved player_position was loaded (see
// entirely by the player's current real head pose, the same way // xr_input_skip_startup_recenter()): a saved position is relative to a
// xr_input_sync_and_draw()'s own menu re-center is). Called from the Main // previous session's real-space origin, which is not stable across launches.
// Menu tab's "Reset Position" button (via qs_config.c's cross-thread // Returns false (no-op) if no valid head pose is available yet.
// 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.
bool xr_input_recenter_player_to_screen(void); 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 // Called once, from qs_config_load_startup() (qs_config.c), when a
// complete saved player_position was actually loaded from // complete saved player_position was actually loaded from
// questshock.config.json - cancels the pending automatic startup recenter // 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). // yet (this only touches the plain file-local flag, no XR calls).
void xr_input_skip_startup_recenter(void); 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); void xr_input_shutdown(void);
#ifdef __cplusplus #ifdef __cplusplus
@@ -2,22 +2,43 @@ package de.ladkau.questshock;
import android.app.Activity; import android.app.Activity;
import android.app.ActivityManager; import android.app.ActivityManager;
import android.app.AlertDialog;
import android.content.Context; import android.content.Context;
import android.content.Intent; 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.ColorDrawable;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.graphics.drawable.StateListDrawable; import android.graphics.drawable.StateListDrawable;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log;
import android.util.TypedValue; import android.util.TypedValue;
import android.view.Gravity; import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.Button; import android.widget.Button;
import android.widget.FrameLayout; import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView; 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.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- * 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. // this same language's own Strings.java translation is filed under.
private static final String[] LANGUAGE_KEYS = {"english", "french", "german"}; 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; private static MenuOverlay instance;
// Kept so relayoutAndRedraw() (see its own comment) can re-measure/ // Kept so relayoutAndRedraw() (see its own comment) can re-measure/
@@ -87,13 +135,15 @@ public class MenuOverlay extends OverlayPanel {
private TextView titleView; private TextView titleView;
private TextView statsView; private TextView statsView;
// Content area (tabs) - only one of mainMenuTab/videoOptionsTab is // Content area (tabs) - only one of mainMenuTab/videoOptionsTab/
// VISIBLE at a time; currentTab tracks which. // controllerOptionsTab is VISIBLE at a time; currentTab tracks which.
private View mainMenuTab; private View mainMenuTab;
private View videoOptionsTab; private View videoOptionsTab;
private View controllerOptionsTab;
private View currentTab; private View currentTab;
private Button mainMenuTabButton; private Button mainMenuTabButton;
private Button videoOptionsTabButton; private Button videoOptionsTabButton;
private Button controllerOptionsTabButton;
// Main Menu tab's own two states (only one VISIBLE at a time, within // Main Menu tab's own two states (only one VISIBLE at a time, within
// mainMenuTab) - mainMenuContent is the normal state (button row + // mainMenuTab) - mainMenuContent is the normal state (button row +
@@ -134,6 +184,32 @@ public class MenuOverlay extends OverlayPanel {
private Button[] gameScreenWidthButtons; private Button[] gameScreenWidthButtons;
private Button[] gameScreenDistanceButtons; 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 int currentLanguage = Strings.ENGLISH;
private final Handler handler = new Handler(Looper.getMainLooper()); private final Handler handler = new Handler(Looper.getMainLooper());
@@ -167,11 +243,15 @@ public class MenuOverlay extends OverlayPanel {
FrameLayout content = new FrameLayout(activity); FrameLayout content = new FrameLayout(activity);
mainMenuTab = buildMainMenuTab(); mainMenuTab = buildMainMenuTab();
videoOptionsTab = buildVideoOptionsTab(); videoOptionsTab = buildVideoOptionsTab();
controllerOptionsTab = buildControllerOptionsTab();
content.addView(mainMenuTab, new FrameLayout.LayoutParams( content.addView(mainMenuTab, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
content.addView(videoOptionsTab, new FrameLayout.LayoutParams( content.addView(videoOptionsTab, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); 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); videoOptionsTab.setVisibility(View.GONE);
controllerOptionsTab.setVisibility(View.GONE);
currentTab = mainMenuTab; currentTab = mainMenuTab;
panel.addView(content, new LinearLayout.LayoutParams( panel.addView(content, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)); LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
@@ -435,6 +515,398 @@ public class MenuOverlay extends OverlayPanel {
return tab; 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() { private View buildTabBar() {
LinearLayout tabBar = new LinearLayout(activity); LinearLayout tabBar = new LinearLayout(activity);
tabBar.setOrientation(LinearLayout.HORIZONTAL); tabBar.setOrientation(LinearLayout.HORIZONTAL);
@@ -469,6 +941,20 @@ public class MenuOverlay extends OverlayPanel {
tabBar.addView(videoOptionsTabButton, tabBar.addView(videoOptionsTabButton,
new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)); 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; return tabBar;
} }
@@ -476,16 +962,35 @@ public class MenuOverlay extends OverlayPanel {
if (tab == currentTab) { if (tab == currentTab) {
return; 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); currentTab.setVisibility(View.GONE);
tab.setVisibility(View.VISIBLE); tab.setVisibility(View.VISIBLE);
currentTab = tab; currentTab = tab;
mainMenuTabButton.setSelected(tab == mainMenuTab); mainMenuTabButton.setSelected(tab == mainMenuTab);
videoOptionsTabButton.setSelected(tab == videoOptionsTab); videoOptionsTabButton.setSelected(tab == videoOptionsTab);
controllerOptionsTabButton.setSelected(tab == controllerOptionsTab);
nativeSetControllerOptionsTabOpen(tab == controllerOptionsTab);
resetHover(); resetHover();
if (tab == videoOptionsTab) { if (tab == videoOptionsTab) {
refreshVideoOptionsSelection(); refreshVideoOptionsSelection();
} else if (tab == mainMenuTab) { } else if (tab == mainMenuTab) {
refreshLanguageSelection(); 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(); relayoutAndRedraw();
} }
@@ -649,6 +1154,8 @@ public class MenuOverlay extends OverlayPanel {
refreshVideoOptionsSelection(); refreshVideoOptionsSelection();
} else if (currentTab == mainMenuTab) { } else if (currentTab == mainMenuTab) {
refreshLanguageSelection(); refreshLanguageSelection();
} else if (currentTab == controllerOptionsTab) {
refreshControllerOptionsSelection();
} }
// The stats text's width changes every tick (digit count, "ms" // 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)); gameScreenTitleView.setText(Strings.get("game_screen", currentLanguage));
gameScreenWidthLabel.setText(Strings.get("width", currentLanguage)); gameScreenWidthLabel.setText(Strings.get("width", currentLanguage));
gameScreenDistanceLabel.setText(Strings.get("distance", 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 // "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 nativeGetCpuUtilization();
private static native float nativeGetGpuUtilization(); 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 // 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 // thread, right after it resolves this class via the activity's own
// ClassLoader (plain FindClass() can't see app classes from a thread // 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() { public static byte[] nativeTakePixelsIfDirty() {
return instance == null ? null : instance.takePixelsIfDirty(); return instance == null ? null : instance.takePixelsIfDirty();
} }
@@ -271,7 +271,11 @@ abstract class OverlayPanel {
// Absolute bounds of view within this panel's own coordinate space // 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, // (i.e. relative to root, which is always laid out at (0,0,width,
// height) - see the constructor) - View's own getLeft()/getTop() are // 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 // Assumes view is actually part of this panel's tree (every registered
// view always is, since registerHoverable() is only ever called from // view always is, since registerHoverable() is only ever called from
// buildContent()). // buildContent()).
@@ -285,7 +289,10 @@ abstract class OverlayPanel {
if (current == root || !(current.getParent() instanceof View)) { if (current == root || !(current.getParent() instanceof View)) {
break; 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()); 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 * SDL_RWops, so Android's asset-manager fallback for SDL_RWFromFile
* doesn't apply here) expects to find it - see android/engine-patches/ for * 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 * 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 * super.onCreate() (SDLActivity's) must always run first and unconditionally
* - Android throws SuperNotCalledException otherwise, checked right after * - Android throws SuperNotCalledException otherwise, checked right after
@@ -39,7 +39,8 @@ import org.libsdl.app.SDLSurface;
public class QuestShockActivity extends SDLActivity { public class QuestShockActivity extends SDLActivity {
private static final String TAG = "QuestShock"; private static final String TAG = "QuestShock";
private static final int PERMISSION_REQUEST_STORAGE = 1; 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 // Loaded here (rather than waiting for SDLActivity's own
// getLibraries()/loadLibraries(), which only runs from super.onCreate()) // 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. // is already there, assume a previous run already extracted everything.
File marker = new File(gameDir, "GET_ASSETS_QUEST.txt"); File marker = new File(gameDir, "GET_ASSETS_QUEST.txt");
if (!marker.exists()) { 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("res/soundfont.sf2", new File(gameDir, "res/soundfont.sf2"), false);
copyAssetFile("GET_ASSETS_QUEST.txt", marker, false); copyAssetFile("GET_ASSETS_QUEST.txt", marker, false);
} }
@@ -155,6 +156,13 @@ public class QuestShockActivity extends SDLActivity {
copyAssetFile("questshock.config.default.json", copyAssetFile("questshock.config.default.json",
new File(gameDir, "questshock.config.default.json"), true); 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 // Shockolate's engine/ is never patched to check this itself (see the
// class doc above) - it just does plain fopen("res/data/...", ...) // class doc above) - it just does plain fopen("res/data/...", ...)
// and, on a fresh install with no game data copied in yet, that // 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(); AssetManager assets = getAssets();
String[] entries; String[] entries;
try { try {
@@ -253,7 +261,7 @@ public class QuestShockActivity extends SDLActivity {
} }
destDir.mkdirs(); destDir.mkdirs();
for (String entry : entries) { 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", "Clavier - faites glisser ici pour déplacer",
"Tastatur - hier ziehen zum Verschieben"); "Tastatur - hier ziehen zum Verschieben");
put("close", "Close", "Fermer", "Schließen"); 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() {} 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;
+1
View File
@@ -92,6 +92,7 @@ cp -a "$ANDROID_DIR/gles-shaders/." "$ASSETS_DIR/shaders/"
cp "/opt/prebuilt/soundfont/default.sf2" "$ASSETS_DIR/res/soundfont.sf2" cp "/opt/prebuilt/soundfont/default.sf2" "$ASSETS_DIR/res/soundfont.sf2"
cp "$REPO_ROOT/res/assets/GET_ASSETS_QUEST.txt" "$ASSETS_DIR/GET_ASSETS_QUEST.txt" cp "$REPO_ROOT/res/assets/GET_ASSETS_QUEST.txt" "$ASSETS_DIR/GET_ASSETS_QUEST.txt"
cp "$REPO_ROOT/res/questshock.config.default.json" "$ASSETS_DIR/questshock.config.default.json" cp "$REPO_ROOT/res/questshock.config.default.json" "$ASSETS_DIR/questshock.config.default.json"
cp -a "$REPO_ROOT/res/controller_profiles" "$ASSETS_DIR/controller_profiles"
echo "== Staging prebuilt Android SDL2/SDL2_mixer/fluidsynth-lite/openxr .so's into jniLibs ==" echo "== Staging prebuilt Android SDL2/SDL2_mixer/fluidsynth-lite/openxr .so's into jniLibs =="
# gl4es is deliberately absent here - it's no longer prebuilt, it's compiled # gl4es is deliberately absent here - it's no longer prebuilt, it's compiled
+9
View File
@@ -0,0 +1,9 @@
{
"name": "Generic OpenXR Controller",
"interaction_profile": "/interaction_profiles/khr/simple_controller",
"image": "generic.png",
"controls": [
{"id": "left_trigger", "label": "Select (Left)", "anchor_x": 0.30, "anchor_y": 0.60},
{"id": "right_trigger", "label": "Select (Right)", "anchor_x": 0.30, "anchor_y": 0.60}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+19
View File
@@ -0,0 +1,19 @@
{
"name": "Meta Quest Touch",
"interaction_profile": "/interaction_profiles/oculus/touch_controller",
"image": "touch.png",
"controls": [
{"id": "left_trigger", "label": "Trigger", "anchor_x": 0.30, "anchor_y": 0.45},
{"id": "right_trigger", "label": "Trigger", "anchor_x": 0.72, "anchor_y": 0.45},
{"id": "left_squeeze", "label": "Grip", "anchor_x": 0.12, "anchor_y": 0.55},
{"id": "right_squeeze", "label": "Grip", "anchor_x": 0.58, "anchor_y": 0.55},
{"id": "left_x", "label": "X", "anchor_x": 0.14, "anchor_y": 0.33},
{"id": "left_y", "label": "Y", "anchor_x": 0.24, "anchor_y": 0.35},
{"id": "right_a", "label": "A", "anchor_x": 0.68, "anchor_y": 0.35},
{"id": "right_b", "label": "B", "anchor_x": 0.75, "anchor_y": 0.33},
{"id": "left_thumbstick", "label": "Thumbstick", "anchor_x": 0.20, "anchor_y": 0.28},
{"id": "right_thumbstick", "label": "Thumbstick", "anchor_x": 0.62, "anchor_y": 0.30},
{"id": "left_thumbstick_click", "label": "Thumbstick Click", "anchor_x": 0.20, "anchor_y": 0.28},
{"id": "right_thumbstick_click", "label": "Thumbstick Click", "anchor_x": 0.62, "anchor_y": 0.30}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"name": "HTC Vive Wand",
"interaction_profile": "/interaction_profiles/htc/vive_controller",
"image": "vive.png",
"controls": [
{"id": "left_trigger", "label": "Trigger", "anchor_x": 0.13, "anchor_y": 0.62},
{"id": "right_trigger", "label": "Trigger", "anchor_x": 0.78, "anchor_y": 0.62},
{"id": "left_squeeze", "label": "Grip", "anchor_x": 0.08, "anchor_y": 0.68},
{"id": "right_squeeze", "label": "Grip", "anchor_x": 0.85, "anchor_y": 0.68},
{"id": "left_thumbstick", "label": "Trackpad", "anchor_x": 0.20, "anchor_y": 0.42},
{"id": "right_thumbstick", "label": "Trackpad", "anchor_x": 0.72, "anchor_y": 0.42},
{"id": "left_thumbstick_click", "label": "Trackpad Click", "anchor_x": 0.20, "anchor_y": 0.42},
{"id": "right_thumbstick_click", "label": "Trackpad Click", "anchor_x": 0.72, "anchor_y": 0.42}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

-132
View File
@@ -1,132 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="210mm"
height="297mm"
viewBox="0 0 210 297"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
sodipodi:docname="sketch_main_menu.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="2.5287798"
inkscape:cx="167.47208"
inkscape:cy="123.18194"
inkscape:window-width="1920"
inkscape:window-height="962"
inkscape:window-x="0"
inkscape:window-y="33"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#000080;stroke-width:0.193167"
id="rect1"
width="63.682735"
height="4.0229344"
x="4.4946499"
y="3.4182527" />
<rect
style="fill:#b3b3b3;stroke-width:0.543629"
id="rect1-5"
width="63.682735"
height="31.862694"
x="4.5049582"
y="7.3757277" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;stroke-width:0.264583"
x="25.181696"
y="20.839491"
id="text2"><tspan
sodipodi:role="line"
id="tspan2"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;stroke-width:0.264583"
x="25.181696"
y="20.839491">Menu content here</tspan></text>
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
x="6.1205692"
y="5.8075914"
id="text3"><tspan
sodipodi:role="line"
id="tspan3"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
x="6.1205692"
y="5.8075914">Questshock (v3.0.2)</tspan></text>
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
x="60.855247"
y="5.7559261"
id="text4"><tspan
sodipodi:role="line"
id="tspan4"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
x="60.855247"
y="5.7559261">[FPS: 70]</tspan></text>
<rect
style="fill:#000080;stroke-width:0.156477"
id="rect1-2"
width="63.682735"
height="2.6398373"
x="4.471632"
y="39.1059" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
x="5.4945946"
y="40.859875"
id="text3-7"><tspan
sodipodi:role="line"
id="tspan3-5"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
x="5.4945946"
y="40.859875">Main Menu</tspan></text>
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
x="13.718193"
y="40.915668"
id="text3-7-8"><tspan
sodipodi:role="line"
id="tspan3-5-9"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
x="13.718193"
y="40.915668">Video Options</tspan></text>
<rect
style="fill:#1a1a1a;stroke:#ffffff;stroke-width:0.256016"
id="rect4"
width="0.055807162"
height="2.4157476"
x="12.918808"
y="39.218266" />
<rect
style="fill:#1a1a1a;stroke:#ffffff;stroke-width:0.256016"
id="rect4-1"
width="0.055807162"
height="2.4157476"
x="22.843752"
y="39.203552" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.2 KiB