Add VR locomotion: move/turn with thumbsticks while the menu is open
build / build (push) Successful in 2m25s
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user