Add VR locomotion: move/turn with thumbsticks while the menu is open
build / build (push) Successful in 2m25s

- Left thumbstick snap-turns, right thumbstick smooth-moves, letting the
  player line themselves up with the fixed game quad ("virtual screen")
  without needing physical room-scale space to do it in. Implemented via
  a second OpenXR reference space (a "play space") derived from the real
  LOCAL space by the inverse of the player's transform, so every existing
  quad/hit-testing/rendering call site just needed g_local_space swapped
  for it - no manual coordinate-transform math required elsewhere
- Generalized the ray/quad hit-testing in xr_input.c to a proper
  oriented-plane intersection (needed once quads can rotate); verified it
  degenerates to the old axis-aligned formula when unrotated, so the
  keyboard and non-recentered menu are unaffected
- The menu launcher now re-centers in front of the player, correctly
  oriented to face them, each time it's freshly reopened
- "Reset Position" (Main Menu tab) recenters using the player's current
  real head pose - not a plain reset-to-origin, which only undid
  thumbstick movement and ignored physical drift since the session
  started. The same recenter runs once automatically at startup too, as a
  fallback for when there's no complete saved position to use instead
- Position/orientation persists in questshock.config.json's new
  player_position group (independent of savegames), including new
  numeric-value support in the config file's hand-rolled JSON parser
  (previously string-enums only)
This commit is contained in:
ml
2026-09-05 15:47:54 +02:00
parent c0619a55ab
commit 360a1f7e3d
12 changed files with 812 additions and 46 deletions
+168 -7
View File
@@ -19,6 +19,9 @@
#include "qs_config.h"
#include "xr_input.h"
#include "xr_session.h"
#include "Prefs.h"
#include "fullscrn.h"
#include "lg.h"
@@ -143,6 +146,38 @@ static bool qs_json_skip_number(QsJsonParser *p) {
return true;
}
// Parses a JSON number at the cursor into *out - unlike qs_json_skip_number()
// above (which every other option so far has only ever needed, since every
// existing option is a string enum), this one actually extracts the value.
// Same scan-then-copy-then-parse approach as qs_json_parse_string(), needed
// because the source buffer isn't NUL-terminated at an arbitrary offset, so
// strtof() can't just be pointed at the cursor directly.
static bool qs_json_parse_number(QsJsonParser *p, float *out) {
if (p->error)
return false;
const char *start = p->p;
if (p->p < p->end && *p->p == '-')
p->p++;
while (p->p < p->end && (isdigit((unsigned char)*p->p) || *p->p == '.' || *p->p == 'e' ||
*p->p == 'E' || *p->p == '+' || *p->p == '-'))
p->p++;
size_t len = (size_t)(p->p - start);
if (len == 0 || len >= 64) {
qs_json_fail(p);
return false;
}
char buf[64];
memcpy(buf, start, len);
buf[len] = '\0';
char *endptr = NULL;
*out = strtof(buf, &endptr);
if (endptr == buf) {
qs_json_fail(p);
return false;
}
return true;
}
static bool qs_json_skip_literal(QsJsonParser *p, const char *literal) {
size_t len = strlen(literal);
if ((size_t)(p->end - p->p) < len || strncmp(p->p, literal, len) != 0) {
@@ -275,6 +310,19 @@ static void qs_json_take_string_field(QsJsonParser *p, char *out, size_t out_siz
qs_json_skip_value(p);
}
// Same "skip if wrong type" behavior as qs_json_take_string_field() above,
// for a numeric field - sets *has to true only on success, since (unlike
// the string groups, where an empty string means "not present") 0.0f is a
// perfectly valid value and can't double as an absent-sentinel.
static void qs_json_take_number_field(QsJsonParser *p, float *out, bool *has) {
if (!p->error && p->p < p->end && (isdigit((unsigned char)*p->p) || *p->p == '-')) {
if (qs_json_parse_number(p, out))
*has = true;
} else {
qs_json_skip_value(p);
}
}
// ===========================================================================
// Schema: "video_options" group
// ===========================================================================
@@ -288,11 +336,21 @@ typedef struct {
char language[16]; // "" = not present in the file
} QsParsedGeneral;
typedef struct {
float x, z, yaw_degrees;
// Unlike every string-valued option above, 0.0f is a perfectly valid
// value here and can't double as "not present in the file" - see
// qs_json_take_number_field()'s own comment.
bool has_x, has_z, has_yaw_degrees;
} QsParsedPlayerPosition;
typedef struct {
QsParsedVideoOptions video_options;
bool has_video_options;
QsParsedGeneral general;
bool has_general;
QsParsedPlayerPosition player_position;
bool has_player_position;
} QsParsedConfig;
static void qs_parse_video_options_field(const char *key, QsJsonParser *p, void *ctx) {
@@ -313,12 +371,27 @@ static void qs_parse_general_field(const char *key, QsJsonParser *p, void *ctx)
qs_json_skip_value(p);
}
static void qs_parse_player_position_field(const char *key, QsJsonParser *p, void *ctx) {
QsParsedPlayerPosition *out = (QsParsedPlayerPosition *)ctx;
if (strcmp(key, "x") == 0)
qs_json_take_number_field(p, &out->x, &out->has_x);
else if (strcmp(key, "z") == 0)
qs_json_take_number_field(p, &out->z, &out->has_z);
else if (strcmp(key, "yaw_degrees") == 0)
qs_json_take_number_field(p, &out->yaw_degrees, &out->has_yaw_degrees);
else
qs_json_skip_value(p);
}
static void qs_parse_top_level_field(const char *key, QsJsonParser *p, void *ctx) {
QsParsedConfig *out = (QsParsedConfig *)ctx;
if (strcmp(key, "video_options") == 0 && !p->error && p->p < p->end && *p->p == '{')
out->has_video_options = qs_json_parse_object(p, qs_parse_video_options_field, &out->video_options);
else if (strcmp(key, "general") == 0 && !p->error && p->p < p->end && *p->p == '{')
out->has_general = qs_json_parse_object(p, qs_parse_general_field, &out->general);
else if (strcmp(key, "player_position") == 0 && !p->error && p->p < p->end && *p->p == '{')
out->has_player_position =
qs_json_parse_object(p, qs_parse_player_position_field, &out->player_position);
else
qs_json_skip_value(p);
}
@@ -569,6 +642,46 @@ static const char *qs_current_language_name(void) {
return "english"; // shouldn't happen - safe fallback for Save
}
// ===========================================================================
// General: player position
// ===========================================================================
// Live-appliable: xr_session_set_player_transform() (xr_session.c) is a
// plain state setter with no XR API calls of its own - safe to call before
// xr_init() has even run (which is how startup seeding works here, unlike
// every other option: nothing in xr_session.c exists yet to "apply" this
// to at startup, so the value is simply stored and picked up once
// xr_init() creates its own play space - see that function's own comment)
// as well as live, mid-session.
#define QS_PLAYER_POSITION_REQUIRES_RESTART false
// Applies only the fields actually present in *parsed, leaving the other
// axis/yaw untouched - e.g. a hand-edited file with just `"yaw_degrees"`
// shouldn't reset x/z to 0. Always live-appliable (see above) - the `live`
// parameter argument that qs_apply_renderer()/qs_apply_resolution()/
// qs_apply_language() take doesn't apply here, so unlike those this takes
// no such argument.
static bool qs_apply_player_position(const QsParsedPlayerPosition *parsed) {
float x, z, yaw_degrees;
xr_session_get_player_transform(&x, &z, &yaw_degrees);
bool changed = false;
if (parsed->has_x && parsed->x != x) {
x = parsed->x;
changed = true;
}
if (parsed->has_z && parsed->z != z) {
z = parsed->z;
changed = true;
}
if (parsed->has_yaw_degrees && parsed->yaw_degrees != yaw_degrees) {
yaw_degrees = parsed->yaw_degrees;
changed = true;
}
if (changed)
xr_session_set_player_transform(x, z, yaw_degrees);
return changed;
}
// ===========================================================================
// Read-only queries for the Video Options tab (see qs_config.h)
// ===========================================================================
@@ -629,6 +742,11 @@ static void qs_config_apply_parsed(const QsParsedConfig *parsed, bool live, char
qs_append_restart_reason(restart_needed, restart_needed_size, "language");
}
}
if (parsed->has_player_position) {
bool changed = qs_apply_player_position(&parsed->player_position);
if (changed && QS_PLAYER_POSITION_REQUIRES_RESTART)
qs_append_restart_reason(restart_needed, restart_needed_size, "player_position");
}
}
static bool qs_config_write_file(void) {
@@ -644,7 +762,16 @@ static bool qs_config_write_file(void) {
fprintf(f, " },\n");
fprintf(f, " \"general\": {\n");
fprintf(f, " \"language\": \"%s\"\n", qs_current_language_name());
fprintf(f, " }\n");
fprintf(f, " },\n");
{
float x, z, yaw_degrees;
xr_session_get_player_transform(&x, &z, &yaw_degrees);
fprintf(f, " \"player_position\": {\n");
fprintf(f, " \"x\": %f,\n", x);
fprintf(f, " \"z\": %f,\n", z);
fprintf(f, " \"yaw_degrees\": %f\n", yaw_degrees);
fprintf(f, " }\n");
}
fprintf(f, "}\n");
fclose(f);
return true;
@@ -725,24 +852,38 @@ static Uint32 s_load_event_type;
static Uint32 s_set_renderer_event_type;
static Uint32 s_set_resolution_event_type;
static Uint32 s_set_language_event_type;
static Uint32 s_reset_position_event_type;
void qs_config_load_startup(void) {
// Registers the custom SDL event types qs_config_request_save()/_load()/
// _set_renderer()/_set_resolution()/_set_language() enqueue - done here,
// once, on the engine's own thread and before the menu/its buttons could
// possibly exist yet, rather than lazily from those functions themselves
// (which run on Android's UI thread and could otherwise race each other
// doing this lazily).
Uint32 base = SDL_RegisterEvents(5);
// _set_renderer()/_set_resolution()/_set_language()/_reset_position()
// enqueue - done here, once, on the engine's own thread and before the
// menu/its buttons could possibly exist yet, rather than lazily from
// those functions themselves (which run on Android's UI thread and
// could otherwise race each other doing this lazily).
Uint32 base = SDL_RegisterEvents(6);
s_save_event_type = base;
s_load_event_type = base + 1;
s_set_renderer_event_type = base + 2;
s_set_resolution_event_type = base + 3;
s_set_language_event_type = base + 4;
s_reset_position_event_type = base + 5;
QsParsedConfig parsed;
if (qs_config_read_file(&parsed))
qs_config_apply_parsed(&parsed, /*live=*/false, NULL, 0);
// A saved player_position only means anything if all three fields are
// present (a partial one - e.g. just "yaw_degrees" from a hand-edited
// file - isn't a complete, sane transform) - xr_input.c's own
// automatic startup recenter (xr_input_recenter_player_to_screen(),
// via g_pending_startup_recenter) is the fallback for a missing file,
// a missing player_position group, or an incomplete one; skip it here
// once a complete saved position was actually loaded, so it doesn't
// immediately override what was just applied above.
if (parsed.has_player_position && parsed.player_position.has_x &&
parsed.player_position.has_z && parsed.player_position.has_yaw_degrees)
xr_input_skip_startup_recenter();
}
void qs_config_request_save(void) {
@@ -783,6 +924,13 @@ void qs_config_request_set_language(int language_index) {
SDL_PushEvent(&event);
}
void qs_config_request_reset_position(void) {
SDL_Event event;
SDL_zero(event);
event.type = s_reset_position_event_type;
SDL_PushEvent(&event);
}
bool qs_config_handle_event(const SDL_Event *event) {
if (event->type == s_save_event_type) {
if (qs_config_write_file())
@@ -821,5 +969,18 @@ bool qs_config_handle_event(const SDL_Event *event) {
qs_apply_language(kLanguages[index].name, /*live=*/true);
return true;
}
if (event->type == s_reset_position_event_type) {
// Deliberately not xr_session_set_player_transform(0,0,0) - see
// xr_input_recenter_player_to_screen()'s own comment: a "return to
// (0,0,0)" reset only undoes thumbstick locomotion, not any real,
// physical drift/rotation since the session started, which is what
// actually made this feel "slightly off" - recentering onto the
// player's current real head pose fixes both at once, and is
// unrelated to questshock.config.json (this event doesn't read or
// write the config file at all, just like this one never has).
xr_input_recenter_player_to_screen();
message_info("Recentered");
return true;
}
return false;
}