From e1e6a006d40852179fd693021879713ac76279c6 Mon Sep 17 00:00:00 2001 From: ml Date: Sun, 6 Sep 2026 09:00:45 +0200 Subject: [PATCH] Add configurable game/initial screen width and distance, with a bundled first-run config - Video Options tab offers width and distance presets independently for the Initial Screen (splash/intro/menu) and Game Screen (gameplay) profiles, with their preset step/offset configurable via questshock.config.json. - Ships res/questshock.config.default.json as the initial config on first run (or whenever questshock.config.json doesn't exist yet), refreshed from the APK on every launch without touching a user's own saved config. --- CONFIG.md | 68 ++- android/app/build.gradle | 3 + android/app/src/main/cpp/qs_config.c | 391 +++++++++++++++++- android/app/src/main/cpp/qs_config.h | 61 ++- android/app/src/main/cpp/questshock_native.c | 63 +++ android/app/src/main/cpp/xr_input.c | 90 +++- android/app/src/main/cpp/xr_session.c | 174 ++++++-- android/app/src/main/cpp/xr_session.h | 25 +- .../de/ladkau/questshock/MenuOverlay.java | 195 ++++++++- .../ladkau/questshock/QuestShockActivity.java | 19 +- .../java/de/ladkau/questshock/Strings.java | 4 + .../22-android-screen-profile.patch | 30 ++ build-image/prepare-android-project.sh | 3 +- res/questshock.config.default.json | 17 + 14 files changed, 1052 insertions(+), 91 deletions(-) create mode 100644 android/engine-patches/22-android-screen-profile.patch create mode 100644 res/questshock.config.default.json diff --git a/CONFIG.md b/CONFIG.md index 1561ee8..aa839ca 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -9,9 +9,11 @@ startup, in addition to (and independent of) the engine's own `prefs.txt`. - **Format**: plain JSON, grouped by concern. Each top-level key is a *group*; each key inside a group is one *option*. - **Loading**: read automatically at app startup, before the splash screen, - if the file exists. If it doesn't exist, nothing changes - the engine - behaves exactly as it did before this file existed (driven by `prefs.txt` - as usual). + if the file exists. If it doesn't, `questshock.config.default.json` (same + folder) is used instead - a copy bundled with the app, refreshed on every + launch, never overwriting your own saved `questshock.config.json`. If + neither file exists, the engine behaves as it did before this file + existed (driven by `prefs.txt` as usual). - **Saving**: never automatic. In-headset, open the menu (controller menu button) and press **Save Configuration** to write the file from your current live settings, overwriting it if present. Press **Load @@ -47,7 +49,15 @@ startup, in addition to (and independent of) the engine's own `prefs.txt`. { "video_options": { "renderer": "opengl", - "resolution": "1024x768" + "resolution": "1024x768", + "initial_screen_width_m": 1.6, + "initial_screen_distance_m": 2.0, + "game_screen_width_m": 2.8, + "game_screen_distance_m": 2.0, + "screen_width_offset_m": 1.0, + "screen_width_step_m": 0.6, + "screen_distance_offset_m": 1.0, + "screen_distance_step_m": 0.5 }, "general": { "language": "english" @@ -78,6 +88,56 @@ The game's internal rendering resolution. - **Possible values**: `"320x200"`, `"320x400"`, `"640x400"`, `"640x480"`, `"1024x768"` - **Restart required?**: No (live-appliable) +### `initial_screen_width_m`, `initial_screen_distance_m`, `game_screen_width_m`, `game_screen_distance_m` + +The virtual screen's physical width and distance from the player (both in +meters) - the game shows two independently configurable screen +"profiles", switched automatically based on what's currently on screen: + +- **Initial Screen** (`initial_screen_*`) - the splash screen, intro + cutscenes, and the game's own main menu. +- **Game Screen** (`game_screen_*`) - actual gameplay (the 3D view, + automap, cyberspace, etc.). + +Switching happens automatically - no player action needed - the instant +the game itself transitions between these (e.g. starting/loading a game, +or returning to the menu). + +- **Type**: number (meters) +- **Possible values**: the in-headset Video Options tab offers 5 presets + each - widths `1.0`, `1.6` (the original, pre-this-feature default), + `2.2`, `2.8`, `3.4`; distances `1.0`, `1.5`, `2.0` (the original, + pre-this-feature default), `2.5`, `3.0` - but a hand-edited value + doesn't need to match a preset exactly (it's clamped to a sane range - + width `[0.2, 10.0]`, distance `[0.5, 10.0]` - rather than snapped or + rejected; the Video Options tab just shows no button selected for an + in-between value). +- **Restart required?**: No (live-appliable) +- **Note**: the screen is always flat - there's no curve option. + +### `screen_width_offset_m`, `screen_width_step_m`, `screen_distance_offset_m`, `screen_distance_step_m` + +Reshapes the 5 preset buttons the Video Options tab offers for width and +distance (above) - config-file-only, there's no in-headset control for +these. Shared by both **Initial Screen** and **Game Screen** (both +columns' buttons show the same 5 values per row) - this only changes +what the buttons offer, not either profile's current value. + +Preset button `index` (0-4) shows `offset + step * index`. For example, +a distance offset of `0` and step of `2` shows buttons `0`, `2`, `4`, +`6`, `8`; the defaults below reproduce this feature's original launch +presets exactly. + +- **Type**: number (meters) +- **Default values**: width offset `1.0`, step `0.6` (buttons `1.0`, + `1.6`, `2.2`, `2.8`, `3.4`); distance offset `1.0`, step `0.5` (buttons + `1.0`, `1.5`, `2.0`, `2.5`, `3.0`). +- **Restart required?**: No (live-appliable) +- **Note**: not clamped - an unusual offset/step (e.g. negative step) is + accepted as-is. A resulting preset value outside the current value's + own clamp range (see above) still clamps normally when that preset + button is actually pressed. + ## General (`general`) ### `language` diff --git a/android/app/build.gradle b/android/app/build.gradle index 5e81e82..38aab1c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -104,10 +104,13 @@ def stageEngineTask = tasks.register("stageEngine", Exec) { inputs.dir("${projectDir}/../gl4es-src") inputs.dir("${projectDir}/../gl4es-patches") inputs.file("${repoRoot}/build-image/Dockerfile") + // Staged into assets/ by prepare-android-project.sh. + inputs.file("${repoRoot}/res/questshock.config.default.json") outputs.dir("${repoRoot}/build/android-engine") outputs.dir("${repoRoot}/build/android-gl4es") outputs.dir("${repoRoot}/build/android-prebuilt") outputs.file("${projectDir}/../engine.properties") + outputs.dir("${projectDir}/src/main/assets") } // preBuild is what every variant's compile/native-build tasks already diff --git a/android/app/src/main/cpp/qs_config.c b/android/app/src/main/cpp/qs_config.c index 512306f..cbf5cb5 100644 --- a/android/app/src/main/cpp/qs_config.c +++ b/android/app/src/main/cpp/qs_config.c @@ -28,6 +28,10 @@ #include "tools.h" #define QS_CONFIG_FILENAME "questshock.config.json" +// Bundled with the app (QuestShockActivity.java extracts it fresh on every +// launch) and read only at startup, only if QS_CONFIG_FILENAME doesn't +// exist yet - never touched by Save/Load Configuration. +#define QS_CONFIG_DEFAULT_FILENAME "questshock.config.default.json" // =========================================================================== // Minimal JSON parser @@ -330,6 +334,27 @@ static void qs_json_take_number_field(QsJsonParser *p, float *out, bool *has) { typedef struct { char renderer[16]; // "" = not present in the file char resolution[16]; // "" = not present in the file + // The virtual screen's physical width (meters) for each of the two + // screen profiles - see qs_screen_profile_notify_loop_mode()'s own + // comment. Like QsParsedPlayerPosition below (and unlike renderer/ + // resolution above), these are plain numbers, not string enums, and + // need their own has_* bools since 0.0f is a valid value. + float initial_screen_width_m, initial_screen_distance_m; + float game_screen_width_m, game_screen_distance_m; + bool has_initial_screen_width_m, has_initial_screen_distance_m; + bool has_game_screen_width_m, has_game_screen_distance_m; + // Each of the two options (width/distance) is offered as 5 preset + // buttons in the Video Options tab, generated as offset + step*index + // (index 0..4) rather than a fixed table - see qs_preset_value()'s + // own comment. Shared by both "Initial Screen" and "Game Screen" + // (both columns' preset buttons show the same 5 values per row) - + // config-file-only (no UI control): the buttons are for adjusting + // each profile's own current *value*, this offset/step pair is for + // reshaping what those buttons themselves offer. + float screen_width_offset_m, screen_width_step_m; + float screen_distance_offset_m, screen_distance_step_m; + bool has_screen_width_offset_m, has_screen_width_step_m; + bool has_screen_distance_offset_m, has_screen_distance_step_m; } QsParsedVideoOptions; typedef struct { @@ -359,6 +384,26 @@ static void qs_parse_video_options_field(const char *key, QsJsonParser *p, void qs_json_take_string_field(p, out->renderer, sizeof(out->renderer)); else if (strcmp(key, "resolution") == 0) qs_json_take_string_field(p, out->resolution, sizeof(out->resolution)); + else if (strcmp(key, "initial_screen_width_m") == 0) + qs_json_take_number_field(p, &out->initial_screen_width_m, &out->has_initial_screen_width_m); + else if (strcmp(key, "game_screen_width_m") == 0) + qs_json_take_number_field(p, &out->game_screen_width_m, &out->has_game_screen_width_m); + else if (strcmp(key, "initial_screen_distance_m") == 0) + qs_json_take_number_field(p, &out->initial_screen_distance_m, + &out->has_initial_screen_distance_m); + else if (strcmp(key, "game_screen_distance_m") == 0) + qs_json_take_number_field(p, &out->game_screen_distance_m, + &out->has_game_screen_distance_m); + else if (strcmp(key, "screen_width_offset_m") == 0) + qs_json_take_number_field(p, &out->screen_width_offset_m, &out->has_screen_width_offset_m); + else if (strcmp(key, "screen_width_step_m") == 0) + qs_json_take_number_field(p, &out->screen_width_step_m, &out->has_screen_width_step_m); + else if (strcmp(key, "screen_distance_offset_m") == 0) + qs_json_take_number_field(p, &out->screen_distance_offset_m, + &out->has_screen_distance_offset_m); + else if (strcmp(key, "screen_distance_step_m") == 0) + qs_json_take_number_field(p, &out->screen_distance_step_m, + &out->has_screen_distance_step_m); else qs_json_skip_value(p); } @@ -396,13 +441,13 @@ static void qs_parse_top_level_field(const char *key, QsJsonParser *p, void *ctx qs_json_skip_value(p); } -// Reads and parses QS_CONFIG_FILENAME into *out. Returns false (with *out -// zeroed) if the file doesn't exist, is empty, or fails to parse as valid -// JSON - logging a WARN only for the last case, since a missing file is the +// Reads and parses `filename` into *out. Returns false (with *out zeroed) +// if the file doesn't exist, is empty, or fails to parse as valid JSON - +// logging a WARN only for the last case, since a missing file is the // normal/expected state, not an error. -static bool qs_config_read_file(QsParsedConfig *out) { +static bool qs_config_read_file_named(const char *filename, QsParsedConfig *out) { memset(out, 0, sizeof(*out)); - FILE *f = fopen(QS_CONFIG_FILENAME, "rb"); + FILE *f = fopen(filename, "rb"); if (!f) return false; fseek(f, 0, SEEK_END); @@ -425,13 +470,17 @@ static bool qs_config_read_file(QsParsedConfig *out) { bool ok = qs_json_parse_object(&parser, qs_parse_top_level_field, out) && !parser.error; free(buf); if (!ok) { - WARN("%s: malformed JSON - ignoring file", QS_CONFIG_FILENAME); + WARN("%s: malformed JSON - ignoring file", filename); memset(out, 0, sizeof(*out)); return false; } return true; } +static bool qs_config_read_file(QsParsedConfig *out) { + return qs_config_read_file_named(QS_CONFIG_FILENAME, out); +} + // =========================================================================== // Video Options: renderer // =========================================================================== @@ -531,6 +580,149 @@ static const char *qs_current_resolution(void) { return "640x480"; // shouldn't happen - safe fallback for Save } +// =========================================================================== +// Video Options: screen profile (initial screen / game screen) +// =========================================================================== + +// Live-appliable: xr_session_set_game_screen_shape() (xr_session.c) is a +// plain state setter with no XR calls, same category as +// xr_session_set_player_transform() - safe to call live or at startup. +#define QS_SCREEN_PROFILE_REQUIRES_RESTART false + +// mainloop.h's own loop-mode #defines - declared locally rather than +// #include "mainloop.h", which pulls in an `extern LGRegion *_current_view;` +// this file can't resolve (LGRegion isn't visible via any header this file +// already includes) - same "avoid a header with declarations this file has +// no other reason to need" reasoning as screen_draw()/language_change() +// above. These two values must stay in sync with mainloop.h's own +// SETUP_LOOP/CUTSCENE_LOOP. +#define QS_SETUP_LOOP 4 +#define QS_CUTSCENE_LOOP 6 + +// Each of the two options (width/distance) offers 5 preset buttons in +// the Video Options tab, generated as offset+step*index (index 0..4) +// rather than a fixed table - see qs_preset_value() below. Shared by +// both "Initial Screen" and "Game Screen" (both columns' preset buttons +// show the same 5 values per row), config-file-only (no UI control): +// unlike the four *_screen_width_m-style current-value statics below +// (button-driven, live-appliable, one pair per profile), these only +// reshape what the buttons themselves offer. +#define QS_NUM_SCREEN_PRESETS 5 +static float s_screen_width_offset_m = 1.0f; +static float s_screen_width_step_m = 0.6f; +static float s_screen_distance_offset_m = 1.0f; +static float s_screen_distance_step_m = 0.5f; + +// Generates preset index `index`'s value (index 0..QS_NUM_SCREEN_PRESETS-1) +// for a given offset/step pair - e.g. offset 1.0/step 0.6 generates +// 1.0, 1.6, 2.2, 2.8, 3.4 (this feature's original width presets). +static float qs_preset_value(float offset, float step, int index) { + return offset + step * (float)index; +} + +// Inverse of qs_preset_value() - returns -1 (a legitimate result here, +// unlike kResolutions'-style "shouldn't happen" fallbacks - a hand-edited +// config file can set any value in between presets) if the current value +// doesn't exactly match one of the 5 presets this offset/step pair +// generates. +static int qs_preset_index(float offset, float step, float value) { + for (int i = 0; i < QS_NUM_SCREEN_PRESETS; i++) + if (qs_preset_value(offset, step, i) == value) + return i; + return -1; +} + +// 1.6m/2.0m (qs_preset_value() index 1/2 of the shared offset/step +// defaults above) for both profiles - the game screen's own original, +// pre-this-feature hardcoded shape (QUAD_DISTANCE_METERS in xr_session.c), +// so a config file/build that never touches these renders identically to +// before until the player opts in. +static float s_initial_screen_width_m = 1.6f; +static float s_initial_screen_distance_m = 2.0f; +static float s_game_screen_width_m = 1.6f; +static float s_game_screen_distance_m = 2.0f; +// Which profile xr_session.c's game-screen shape was last set to - so +// qs_screen_profile_notify_loop_mode() can skip re-applying anything on a +// same-profile transition (e.g. game -> automap -> game), and so a preset +// button tap while the *other* profile is active knows to just update the +// stored value without touching the live shape (see +// qs_config_handle_event()'s own screen-profile branches). +static bool s_screen_profile_is_game = false; + +static float qs_clamp_screen_width(float width_m) { + if (width_m < 0.2f) + return 0.2f; + if (width_m > 10.0f) + return 10.0f; + return width_m; +} + +static float qs_clamp_screen_distance(float distance_m) { + if (distance_m < 0.5f) + return 0.5f; + if (distance_m > 10.0f) + return 10.0f; + return distance_m; +} + +static void qs_screen_profile_reapply(void) { + if (s_screen_profile_is_game) + xr_session_set_game_screen_shape(s_game_screen_width_m, 0.0f, s_game_screen_distance_m); + else + xr_session_set_game_screen_shape(s_initial_screen_width_m, 0.0f, + s_initial_screen_distance_m); +} + +// Called directly from the engine (see android/engine-patches/ +// 22-android-screen-profile.patch's hook into mainloop.c's +// loopmode_switch(), and qs_config_load_startup() below for the initial +// call) - runs on the engine's own thread already, so unlike every other +// action in this file (all UI-thread-button-triggered), no cross-thread +// SDL-event dispatch is needed here. +void qs_screen_profile_notify_loop_mode(short new_mode) { + bool isGame = new_mode != QS_SETUP_LOOP && new_mode != QS_CUTSCENE_LOOP; + if (isGame == s_screen_profile_is_game) + return; + s_screen_profile_is_game = isGame; + qs_screen_profile_reapply(); +} + +static bool qs_apply_initial_screen_width(float width_m) { + width_m = qs_clamp_screen_width(width_m); + bool changed = s_initial_screen_width_m != width_m; + s_initial_screen_width_m = width_m; + if (changed && !s_screen_profile_is_game) + qs_screen_profile_reapply(); + return changed; +} + +static bool qs_apply_game_screen_width(float width_m) { + width_m = qs_clamp_screen_width(width_m); + bool changed = s_game_screen_width_m != width_m; + s_game_screen_width_m = width_m; + if (changed && s_screen_profile_is_game) + qs_screen_profile_reapply(); + return changed; +} + +static bool qs_apply_initial_screen_distance(float distance_m) { + distance_m = qs_clamp_screen_distance(distance_m); + bool changed = s_initial_screen_distance_m != distance_m; + s_initial_screen_distance_m = distance_m; + if (changed && !s_screen_profile_is_game) + qs_screen_profile_reapply(); + return changed; +} + +static bool qs_apply_game_screen_distance(float distance_m) { + distance_m = qs_clamp_screen_distance(distance_m); + bool changed = s_game_screen_distance_m != distance_m; + s_game_screen_distance_m = distance_m; + if (changed && s_screen_profile_is_game) + qs_screen_profile_reapply(); + return changed; +} + // =========================================================================== // General: language // =========================================================================== @@ -697,6 +889,45 @@ int qs_config_current_resolution_index(void) { int qs_config_current_language(void) { return gShockPrefs.goLanguage; } +// -1 (no exact preset match) is a legitimate result for all four of these - +// see qs_preset_index()'s own comment. +int qs_config_current_initial_screen_width_index(void) { + return qs_preset_index(s_screen_width_offset_m, s_screen_width_step_m, s_initial_screen_width_m); +} +int qs_config_current_initial_screen_distance_index(void) { + return qs_preset_index(s_screen_distance_offset_m, s_screen_distance_step_m, + s_initial_screen_distance_m); +} +int qs_config_current_game_screen_width_index(void) { + return qs_preset_index(s_screen_width_offset_m, s_screen_width_step_m, s_game_screen_width_m); +} +int qs_config_current_game_screen_distance_index(void) { + return qs_preset_index(s_screen_distance_offset_m, s_screen_distance_step_m, + s_game_screen_distance_m); +} + +// Returns the value preset button `index` (0..QS_NUM_SCREEN_PRESETS-1) +// currently shows for a given option - shared by both profiles' columns +// (see the offset/step statics' own comment) - so MenuOverlay.java can +// label its preset buttons correctly even after a config-file offset/step +// change. Out-of-range index clamps into range rather than returning a +// sentinel: unlike the index getters above, there's no "not present" +// concept here - every index 0..4 always has a value. +static int qs_clamp_preset_index(int index) { + if (index < 0) + return 0; + if (index >= QS_NUM_SCREEN_PRESETS) + return QS_NUM_SCREEN_PRESETS - 1; + return index; +} +float qs_config_screen_width_preset_value(int index) { + return qs_preset_value(s_screen_width_offset_m, s_screen_width_step_m, qs_clamp_preset_index(index)); +} +float qs_config_screen_distance_preset_value(int index) { + return qs_preset_value(s_screen_distance_offset_m, s_screen_distance_step_m, + qs_clamp_preset_index(index)); +} + // =========================================================================== // Apply / Save orchestration // =========================================================================== @@ -734,6 +965,41 @@ static void qs_config_apply_parsed(const QsParsedConfig *parsed, bool live, char if (changed && QS_RESOLUTION_REQUIRES_RESTART) qs_append_restart_reason(restart_needed, restart_needed_size, "resolution"); } + if (parsed->video_options.has_initial_screen_width_m) { + bool changed = qs_apply_initial_screen_width(parsed->video_options.initial_screen_width_m); + if (changed && QS_SCREEN_PROFILE_REQUIRES_RESTART) + qs_append_restart_reason(restart_needed, restart_needed_size, "initial_screen_width_m"); + } + if (parsed->video_options.has_game_screen_width_m) { + bool changed = qs_apply_game_screen_width(parsed->video_options.game_screen_width_m); + if (changed && QS_SCREEN_PROFILE_REQUIRES_RESTART) + qs_append_restart_reason(restart_needed, restart_needed_size, "game_screen_width_m"); + } + if (parsed->video_options.has_initial_screen_distance_m) { + bool changed = + qs_apply_initial_screen_distance(parsed->video_options.initial_screen_distance_m); + if (changed && QS_SCREEN_PROFILE_REQUIRES_RESTART) + qs_append_restart_reason(restart_needed, restart_needed_size, + "initial_screen_distance_m"); + } + if (parsed->video_options.has_game_screen_distance_m) { + bool changed = + qs_apply_game_screen_distance(parsed->video_options.game_screen_distance_m); + if (changed && QS_SCREEN_PROFILE_REQUIRES_RESTART) + qs_append_restart_reason(restart_needed, restart_needed_size, + "game_screen_distance_m"); + } + // The four offset/step fields below only reshape what the preset + // buttons offer (see their own statics' comment) - no live-reapply + // or restart consideration needed, just a direct assignment. + if (parsed->video_options.has_screen_width_offset_m) + s_screen_width_offset_m = parsed->video_options.screen_width_offset_m; + if (parsed->video_options.has_screen_width_step_m) + s_screen_width_step_m = parsed->video_options.screen_width_step_m; + if (parsed->video_options.has_screen_distance_offset_m) + s_screen_distance_offset_m = parsed->video_options.screen_distance_offset_m; + if (parsed->video_options.has_screen_distance_step_m) + s_screen_distance_step_m = parsed->video_options.screen_distance_step_m; } if (parsed->has_general) { if (parsed->general.language[0] != '\0') { @@ -758,7 +1024,15 @@ static bool qs_config_write_file(void) { fprintf(f, "{\n"); fprintf(f, " \"video_options\": {\n"); fprintf(f, " \"renderer\": \"%s\",\n", qs_current_renderer()); - fprintf(f, " \"resolution\": \"%s\"\n", qs_current_resolution()); + fprintf(f, " \"resolution\": \"%s\",\n", qs_current_resolution()); + fprintf(f, " \"initial_screen_width_m\": %f,\n", s_initial_screen_width_m); + fprintf(f, " \"initial_screen_distance_m\": %f,\n", s_initial_screen_distance_m); + fprintf(f, " \"game_screen_width_m\": %f,\n", s_game_screen_width_m); + fprintf(f, " \"game_screen_distance_m\": %f,\n", s_game_screen_distance_m); + fprintf(f, " \"screen_width_offset_m\": %f,\n", s_screen_width_offset_m); + fprintf(f, " \"screen_width_step_m\": %f,\n", s_screen_width_step_m); + fprintf(f, " \"screen_distance_offset_m\": %f,\n", s_screen_distance_offset_m); + fprintf(f, " \"screen_distance_step_m\": %f\n", s_screen_distance_step_m); fprintf(f, " },\n"); fprintf(f, " \"general\": {\n"); fprintf(f, " \"language\": \"%s\"\n", qs_current_language_name()); @@ -853,24 +1127,36 @@ 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; +static Uint32 s_set_initial_screen_width_event_type; +static Uint32 s_set_initial_screen_distance_event_type; +static Uint32 s_set_game_screen_width_event_type; +static Uint32 s_set_game_screen_distance_event_type; void qs_config_load_startup(void) { // Registers the custom SDL event types qs_config_request_save()/_load()/ - // _set_renderer()/_set_resolution()/_set_language()/_reset_position() - // 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); + // _set_renderer()/_set_resolution()/_set_language()/_reset_position()/ + // the four _set_*_screen_*() screen-profile setters enqueue - done + // here, once, on the engine's own thread and before the menu/its + // buttons could possibly exist yet, rather than lazily from those + // functions themselves (which run on Android's UI thread and could + // otherwise race each other doing this lazily). + Uint32 base = SDL_RegisterEvents(10); 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; + s_set_initial_screen_width_event_type = base + 6; + s_set_game_screen_width_event_type = base + 7; + s_set_initial_screen_distance_event_type = base + 8; + s_set_game_screen_distance_event_type = base + 9; QsParsedConfig parsed; - if (qs_config_read_file(&parsed)) + bool haveConfig = qs_config_read_file(&parsed); + if (!haveConfig) + haveConfig = qs_config_read_file_named(QS_CONFIG_DEFAULT_FILENAME, &parsed); + if (haveConfig) qs_config_apply_parsed(&parsed, /*live=*/false, NULL, 0); // A saved player_position only means anything if all three fields are @@ -884,6 +1170,19 @@ void qs_config_load_startup(void) { 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(); + + // The engine's own startup state is always QS_SETUP_LOOP (mainloop.c: + // `short _current_loop = SETUP_LOOP;`, Shock.c's main() never changes + // it before entering mainloop()), matching s_screen_profile_is_game's + // own default (false/"initial") - so calling + // qs_screen_profile_notify_loop_mode() here would see "no change" and + // skip actually applying anything (its whole point is to skip + // redundant re-applies on a same-profile transition, but there's no + // "previous" profile yet this early). Call the apply step directly + // instead, unconditionally, so whichever profile's shape was just + // loaded/defaulted above actually reaches xr_session.c before the + // first frame renders. + qs_screen_profile_reapply(); } void qs_config_request_save(void) { @@ -931,6 +1230,42 @@ void qs_config_request_reset_position(void) { SDL_PushEvent(&event); } +// Screen profile preset buttons (Video Options tab) - index into the +// shared width/distance offset+step-generated presets (see +// qs_preset_value()'s own comment), same enqueue-only reasoning as every +// other button in this file. +void qs_config_request_set_initial_screen_width(int preset_index) { + SDL_Event event; + SDL_zero(event); + event.type = s_set_initial_screen_width_event_type; + event.user.code = preset_index; + SDL_PushEvent(&event); +} + +void qs_config_request_set_game_screen_width(int preset_index) { + SDL_Event event; + SDL_zero(event); + event.type = s_set_game_screen_width_event_type; + event.user.code = preset_index; + SDL_PushEvent(&event); +} + +void qs_config_request_set_initial_screen_distance(int preset_index) { + SDL_Event event; + SDL_zero(event); + event.type = s_set_initial_screen_distance_event_type; + event.user.code = preset_index; + SDL_PushEvent(&event); +} + +void qs_config_request_set_game_screen_distance(int preset_index) { + SDL_Event event; + SDL_zero(event); + event.type = s_set_game_screen_distance_event_type; + event.user.code = preset_index; + SDL_PushEvent(&event); +} + bool qs_config_handle_event(const SDL_Event *event) { if (event->type == s_save_event_type) { if (qs_config_write_file()) @@ -982,5 +1317,33 @@ bool qs_config_handle_event(const SDL_Event *event) { message_info("Recentered"); return true; } + if (event->type == s_set_initial_screen_width_event_type) { + int index = event->user.code; + if (index >= 0 && index < QS_NUM_SCREEN_PRESETS) + qs_apply_initial_screen_width( + qs_preset_value(s_screen_width_offset_m, s_screen_width_step_m, index)); + return true; + } + if (event->type == s_set_game_screen_width_event_type) { + int index = event->user.code; + if (index >= 0 && index < QS_NUM_SCREEN_PRESETS) + qs_apply_game_screen_width( + qs_preset_value(s_screen_width_offset_m, s_screen_width_step_m, index)); + return true; + } + if (event->type == s_set_initial_screen_distance_event_type) { + int index = event->user.code; + if (index >= 0 && index < QS_NUM_SCREEN_PRESETS) + qs_apply_initial_screen_distance( + qs_preset_value(s_screen_distance_offset_m, s_screen_distance_step_m, index)); + return true; + } + if (event->type == s_set_game_screen_distance_event_type) { + int index = event->user.code; + if (index >= 0 && index < QS_NUM_SCREEN_PRESETS) + qs_apply_game_screen_distance( + qs_preset_value(s_screen_distance_offset_m, s_screen_distance_step_m, index)); + return true; + } return false; } diff --git a/android/app/src/main/cpp/qs_config.h b/android/app/src/main/cpp/qs_config.h index 341c212..8179466 100644 --- a/android/app/src/main/cpp/qs_config.h +++ b/android/app/src/main/cpp/qs_config.h @@ -66,15 +66,42 @@ void qs_config_request_set_language(int language_index); // player's current real head pose instead. void qs_config_request_reset_position(void); -// Returns true and handles the event if it's one of the six custom events +// Same cross-thread-dispatch reasoning as the four above - called from the +// Video Options tab's "Initial Screen"/"Game Screen" width/distance +// preset rows (MenuOverlay.java). preset_index indexes qs_config.c's own +// shared, offset+step-generated width/distance preset lists (both in +// meters; the offset/step themselves are config-file-only, not exposed +// as buttons), read back via the four qs_config_current_*_screen_*_index() +// getters below. Applies live immediately if the just-changed profile is +// the currently active one (see CONFIG.md) - otherwise just updates the +// stored preference, invisibly, until that profile becomes active (e.g. +// tweaking "Initial Screen" mid-game). +void qs_config_request_set_initial_screen_width(int preset_index); +void qs_config_request_set_initial_screen_distance(int preset_index); +void qs_config_request_set_game_screen_width(int preset_index); +void qs_config_request_set_game_screen_distance(int preset_index); + +// Returns true and handles the event if it's one of the ten custom events // qs_config_request_save()/_load()/_set_renderer()/_set_resolution()/ -// _set_language()/_reset_position() enqueue - called from pump_events() -// (sdl_events.c, see android/engine-patches/21-android-config.patch) on the -// engine's own thread, once per polled event, before its normal switch on -// well-known SDL_* event types. Returns false (no-op) for any other event -// type, including before the six custom types have even been registered. +// _set_language()/_reset_position()/the four _set_*_screen_*() setters +// enqueue - called from pump_events() (sdl_events.c, see +// android/engine-patches/21-android-config.patch) on the engine's own +// thread, once per polled event, before its normal switch on well-known +// SDL_* event types. Returns false (no-op) for any other event type, +// including before the ten custom types have even been registered. bool qs_config_handle_event(const SDL_Event *event); +// Called directly (no event-queue dispatch - see +// android/engine-patches/22-android-screen-profile.patch and this +// function's own definition in qs_config.c for why) from the engine's +// mainloop.c whenever the loop mode changes, and once from +// qs_config_load_startup() - picks between the "Initial Screen" (menu/ +// intro) and "Game Screen" (actual gameplay) presets based on new_mode +// (mainloop.h's SETUP_LOOP/CUTSCENE_LOOP vs. everything else) and applies +// the newly-active one's shape to xr_session.c, but only if it's actually +// different from what was already active. +void qs_screen_profile_notify_loop_mode(short new_mode); + // Read-only queries for the Video Options tab's own selection highlight // (MenuOverlay.java re-reads these whenever the tab is (re)shown, so it // reflects the live value even if it was last changed some other way, e.g. @@ -87,6 +114,28 @@ bool qs_config_handle_event(const SDL_Event *event); bool qs_config_current_renderer_is_opengl(void); int qs_config_current_resolution_index(void); +// -1 (not just an unlikely fallback, a routine, expected result here - +// unlike qs_config_current_resolution_index() above) if the profile's +// current value doesn't exactly match one of qs_config.c's own +// offset+step-generated width/distance presets, e.g. after a hand-edited +// config file sets an in-between value - MenuOverlay.java's own +// setSelected() naturally shows no button selected in that case, no +// special-casing needed. +int qs_config_current_initial_screen_width_index(void); +int qs_config_current_initial_screen_distance_index(void); +int qs_config_current_game_screen_width_index(void); +int qs_config_current_game_screen_distance_index(void); + +// Returns the value preset button `index` (0..4) currently shows for the +// width/distance option - shared by both "Initial Screen" and "Game +// Screen" columns (their offset/step is config-file-only, not per-profile +// - see qs_config.c's own comment on the underlying statics), so +// MenuOverlay.java can label its preset buttons correctly even after a +// Load Configuration changes the offset/step. Index is clamped into +// range, not validated - every index 0..4 always has a value. +float qs_config_screen_width_preset_value(int index); +float qs_config_screen_distance_preset_value(int index); + // gShockPrefs.goLanguage (0 English/1 French/2 German, Prefs.h) - read by // MenuOverlay.java's/KeyboardOverlay.java's periodic header-refresh timer // to pick which language's chrome text (Strings.java) to display, so a diff --git a/android/app/src/main/cpp/questshock_native.c b/android/app/src/main/cpp/questshock_native.c index be9851a..7b79921 100644 --- a/android/app/src/main/cpp/questshock_native.c +++ b/android/app/src/main/cpp/questshock_native.c @@ -132,6 +132,33 @@ Java_de_ladkau_questshock_MenuOverlay_nativeRequestResetPosition(JNIEnv *env, jc qs_config_request_reset_position(); } +// MenuOverlay's Video Options tab - "Initial Screen"/"Game Screen" width/ +// distance preset rows, same enqueue-only reasoning as above. +JNIEXPORT void JNICALL +Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetInitialScreenWidth(JNIEnv *env, jclass clazz, + jint presetIndex) { + qs_config_request_set_initial_screen_width(presetIndex); +} + +JNIEXPORT void JNICALL +Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetGameScreenWidth(JNIEnv *env, jclass clazz, + jint presetIndex) { + qs_config_request_set_game_screen_width(presetIndex); +} + +JNIEXPORT void JNICALL +Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetInitialScreenDistance(JNIEnv *env, + jclass clazz, + jint presetIndex) { + qs_config_request_set_initial_screen_distance(presetIndex); +} + +JNIEXPORT void JNICALL +Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetGameScreenDistance(JNIEnv *env, jclass clazz, + jint presetIndex) { + qs_config_request_set_game_screen_distance(presetIndex); +} + // Read-only queries - safe to call directly (no enqueue needed, see // qs_config.h's own comment on each of these). JNIEXPORT jboolean JNICALL @@ -144,6 +171,42 @@ Java_de_ladkau_questshock_MenuOverlay_nativeGetResolutionIndex(JNIEnv *env, jcla return qs_config_current_resolution_index(); } +JNIEXPORT jint JNICALL +Java_de_ladkau_questshock_MenuOverlay_nativeGetInitialScreenWidthIndex(JNIEnv *env, jclass clazz) { + return qs_config_current_initial_screen_width_index(); +} + +JNIEXPORT jint JNICALL +Java_de_ladkau_questshock_MenuOverlay_nativeGetGameScreenWidthIndex(JNIEnv *env, jclass clazz) { + return qs_config_current_game_screen_width_index(); +} + +JNIEXPORT jint JNICALL +Java_de_ladkau_questshock_MenuOverlay_nativeGetInitialScreenDistanceIndex(JNIEnv *env, + jclass clazz) { + return qs_config_current_initial_screen_distance_index(); +} + +JNIEXPORT jint JNICALL +Java_de_ladkau_questshock_MenuOverlay_nativeGetGameScreenDistanceIndex(JNIEnv *env, jclass clazz) { + return qs_config_current_game_screen_distance_index(); +} + +// Preset button *labels* (not the current value) - shared by both +// "Initial Screen" and "Game Screen" columns, since their offset/step is +// config-file-only, not per-profile (see qs_config.h's own comment). +JNIEXPORT jfloat JNICALL +Java_de_ladkau_questshock_MenuOverlay_nativeGetScreenWidthPresetValue(JNIEnv *env, jclass clazz, + jint index) { + return qs_config_screen_width_preset_value(index); +} + +JNIEXPORT jfloat JNICALL +Java_de_ladkau_questshock_MenuOverlay_nativeGetScreenDistancePresetValue(JNIEnv *env, jclass clazz, + jint index) { + return qs_config_screen_distance_preset_value(index); +} + JNIEXPORT jint JNICALL Java_de_ladkau_questshock_MenuOverlay_nativeGetLanguage(JNIEnv *env, jclass clazz) { return qs_config_current_language(); diff --git a/android/app/src/main/cpp/xr_input.c b/android/app/src/main/cpp/xr_input.c index 8385528..32ffcd2 100644 --- a/android/app/src/main/cpp/xr_input.c +++ b/android/app/src/main/cpp/xr_input.c @@ -48,7 +48,7 @@ static const float kHandColor[2][4] = { // MenuOverlay.HEIGHT). #define KEYBOARD_TITLE_BAR_V_FRACTION (90.0f / 768.0f) #define KEYBOARD_CLOSE_BUTTON_U_FRACTION (1.0f - 160.0f / 1024.0f) -#define MENU_TITLE_BAR_V_FRACTION (56.0f / 552.0f) +#define MENU_TITLE_BAR_V_FRACTION (56.0f / 800.0f) #define MENU_DRAG_HANDLE_U_FRACTION 1.0f static XrInstance g_instance = XR_NULL_HANDLE; @@ -642,35 +642,97 @@ static void xr_input_build_beam(int hand, const XrSpaceLocation *location, float g_beam_quad_valid[hand] = true; } -// Hit-tests this hand's ray against the game quad (xr_get_game_quad_extent()) +// Hit-tests this hand's ray against the game screen (xr_get_game_quad_extent()) // - the lowest-priority target, tried only once neither the keyboard nor // menu overlay claimed the ray this frame (or, symmetrically, once this -// hand already has a pending mouse-down on the game quad - see +// hand already has a pending mouse-down on the game screen - see // g_game_touch_active and the keyboard/menu call sites in // xr_input_sync_and_draw()). outU/outV (only meaningful when the // returned hit is true) let the caller drive the game's own mouse cursor // via xr_mouse.h. Returns plain `hit` - drag/touch dispatch for actual // clicks is the caller's responsibility (xr_mouse_click()), not this // function's, unlike xr_input_try_overlay(). +// +// Two shapes: a flat plane (centralAngle == 0, the default and the only +// shape this function supported before screen curving existed - kept +// byte-for-byte unchanged as its own branch below, so nobody who never +// touches the new Video Options curve setting sees any change at all), or +// a curved cylinder segment. The curve is horizontal-only (like a real +// curved monitor) - the cylinder's vertical axis runs parallel to the +// screen's own local Y at (x=0, z=Zc) for every y, so y drops out of the +// lateral-surface intersection test entirely; halfHeight bounds the +// vertical extent exactly like the flat case, unchanged. static bool xr_input_try_game_quad(int hand, const XrSpaceLocation *location, float fx, float fy, float fz, bool *outPlaneHit, float *outPlaneDistance, float *outU, float *outV) { - float distance, halfWidth, halfHeight; - xr_get_game_quad_extent(&distance, &halfWidth, &halfHeight); + float distance, halfWidth, halfHeight, radius, centralAngle; + xr_get_game_quad_extent(&distance, &halfWidth, &halfHeight, &radius, ¢ralAngle); bool planeHit = false, hit = false; float u = 0.0f, v = 0.0f, t = 0.0f; - if (fabsf(fz) > 1e-5f) { - t = (-distance - location->pose.position.z) / fz; - if (t > 0.0f) { - planeHit = true; - float cx = (location->pose.position.x + t * fx) / halfWidth; - float cy = (location->pose.position.y + t * fy) / halfHeight; - hit = fabsf(cx) <= 1.0f && fabsf(cy) <= 1.0f; - u = (cx + 1.0f) * 0.5f; - v = (1.0f - cy) * 0.5f; + + if (centralAngle <= 0.0f) { + if (fabsf(fz) > 1e-5f) { + t = (-distance - location->pose.position.z) / fz; + if (t > 0.0f) { + planeHit = true; + float cx = (location->pose.position.x + t * fx) / halfWidth; + float cy = (location->pose.position.y + t * fy) / halfHeight; + hit = fabsf(cx) <= 1.0f && fabsf(cy) <= 1.0f; + u = (cx + 1.0f) * 0.5f; + v = (1.0f - cy) * 0.5f; + } + } + } else { + // Zc: the cylinder's axis position along Z (see xr_session.c's + // own concavity-derivation comment - same offset the render path + // uses to place the actual XrCompositionLayerCylinderKHR, so the + // two can never disagree about where the surface actually is). + float Zc = -distance + radius; + float x0 = location->pose.position.x; + float dz0 = location->pose.position.z - Zc; + + // Ray-vs-infinite-cylinder quadratic (only x/z participate, per + // this function's own comment above) - A<=~0 means the ray runs + // parallel to the axis (never crosses the lateral surface), + // mirroring the flat case's fabsf(fz)>1e-5f guard. + float A = fx * fx + fz * fz; + if (A > 1e-8f) { + float B = 2.0f * (fx * x0 + fz * dz0); + float C = x0 * x0 + dz0 * dz0 - radius * radius; + float disc = B * B - 4.0f * A * C; + if (disc >= 0.0f) { + float sq = sqrtf(disc); + // Smallest positive root - the near intersection, correct + // both for the normal case (viewer inside the cylinder, + // so exactly one root is positive) and if the viewer ever + // ends up outside it. + float t1 = (-B - sq) / (2.0f * A); + float t2 = (-B + sq) / (2.0f * A); + if (t1 > 0.0f) + t = t1; + else if (t2 > 0.0f) + t = t2; + if (t > 0.0f) { + planeHit = true; + float px = location->pose.position.x + t * fx; + float py = location->pose.position.y + t * fy; + float pz = location->pose.position.z + t * fz; + // Signed angle from center (angle 0 is dead ahead, + // matching the flat case's cx==0 being dead center) - + // bounded by the configured centralAngle instead of a + // halfWidth. + float phi = atan2f(px, Zc - pz); + float halfAngle = centralAngle * 0.5f; + float cy = py / halfHeight; + hit = fabsf(phi) <= halfAngle && fabsf(cy) <= 1.0f; + u = (phi / halfAngle + 1.0f) * 0.5f; + v = (1.0f - cy) * 0.5f; + } + } } } + *outPlaneHit = planeHit; *outPlaneDistance = t; *outU = u; diff --git a/android/app/src/main/cpp/xr_session.c b/android/app/src/main/cpp/xr_session.c index abc5146..d10056f 100644 --- a/android/app/src/main/cpp/xr_session.c +++ b/android/app/src/main/cpp/xr_session.c @@ -26,14 +26,18 @@ #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) -// Fixed pose/size for the single game quad - 2m in front of the local space -// origin, sized to preserve the game's own aspect ratio at a comfortable -// visual angle. No per-eye view/projection work is needed for a quad layer -// at all - the compositor handles reprojecting it per eye itself, which is -// exactly why this milestone doesn't need xrLocateViews or any stereo -// rendering (see the plan's step A notes). +// Original, pre-screen-profile-feature default distance for the game +// screen - 2m in front of the local space origin - now just the initial +// value of g_screen_distance_m (below) rather than a fixed constant. +// Width/curve/distance are all configurable (see g_screen_width_m/ +// g_screen_curve_deg/g_screen_distance_m above and CONFIG.md's Video +// Options "Initial Screen"/"Game Screen" settings), sized to preserve the +// game's own pixel aspect ratio at whatever physical width is currently +// configured. No per-eye view/projection work is needed for a quad/ +// cylinder layer at all - the compositor handles reprojecting it per eye +// itself, which is exactly why this milestone doesn't need xrLocateViews +// or any stereo rendering (see the plan's step A notes). #define QUAD_DISTANCE_METERS 2.0f -#define QUAD_WIDTH_METERS 1.6f // The menu launcher (just a "Keyboard" button - see MenuOverlay.java) is // small and sits above where the keyboard defaults to; the keyboard keeps @@ -41,14 +45,16 @@ // xr_overlay.h) so both can be shown/hidden/dragged separately - deliberately // positioned apart by default so they don't start out overlapping. #define MENU_JAVA_CLASS "de.ladkau.questshock.MenuOverlay" -// 768x552 (was 512x256, then 768x512) - grown to fit the header/tabs/Video -// Options content added on top of the original single "Keyboard" button, -// then again to fit the stats ribbon below the tab bar; taller in world -// space too since MENU_WIDTH_METERS (the physical size) stays fixed and -// height_m is derived from it by aspect ratio (see xr_overlay.c's -// xr_overlay_init()). +// 768x800 (was 512x256, then 768x512, then 768x552) - grown to fit the +// header/tabs/Video Options content added on top of the original single +// "Keyboard" button, then again to fit the stats ribbon below the tab +// bar, then again to fit the Video Options tab's screen-size grid; taller +// in world space too since MENU_WIDTH_METERS (the physical size) stays +// fixed and height_m is derived from it by aspect ratio (see +// xr_overlay.c's xr_overlay_init()). Must match MenuOverlay.java's own +// WIDTH/HEIGHT, and xr_input.c's MENU_TITLE_BAR_V_FRACTION denominator. #define MENU_WIDTH 768 -#define MENU_HEIGHT 552 +#define MENU_HEIGHT 800 #define MENU_DISTANCE_METERS 1.3f // Physical size - was 0.35m, too small to read comfortably at // MENU_DISTANCE_METERS given how much text the header/tabs/stats ribbon @@ -127,6 +133,24 @@ static PFN_xrQueryPerformanceMetricsCounterMETA g_xrQueryPerformanceMetricsCount static XrPath g_cpu_utilization_path = XR_NULL_PATH; static XrPath g_gpu_utilization_path = XR_NULL_PATH; +// XR_KHR_composition_layer_cylinder support - same "only if the runtime +// advertised it" reasoning as g_performance_metrics_supported above. If +// unsupported, the curve is forced to 0 (flat) everywhere regardless of +// configuration (see xr_get_game_quad_extent()) so the game screen just +// silently stays flat instead of failing to submit a layer at all. +static bool g_cylinder_layer_supported = false; + +// The game screen's currently-configured shape (see qs_config.c's +// qs_screen_profile_notify_loop_mode(), which is what actually calls +// xr_session_set_game_screen_shape() below - this file has no notion of +// "which profile" is active, only "what shape is current"). Defaults +// match the game quad's own original, pre-this-feature hardcoded width +// (1.6m) and lack of curve, so a build that never touches the new config +// options renders identically to before. +static float g_screen_width_m = 1.6f; +static float g_screen_curve_deg = 0.0f; +static float g_screen_distance_m = QUAD_DISTANCE_METERS; + static bool xr_check(XrResult result, const char *what) { if (XR_SUCCEEDED(result)) return true; @@ -355,12 +379,24 @@ static bool xr_create_instance_and_session(int game_width, int game_height) { g_performance_metrics_supported = xr_instance_extension_supported(XR_META_PERFORMANCE_METRICS_EXTENSION_NAME); + g_cylinder_layer_supported = + xr_instance_extension_supported(XR_KHR_COMPOSITION_LAYER_CYLINDER_EXTENSION_NAME); - const char *extensions[3] = { + // The two KHR extensions here are always required; the other two are + // each independently optional device capabilities (see + // g_performance_metrics_supported/g_cylinder_layer_supported's own + // comments) - appended only if actually advertised, since listing an + // unsupported extension fails xrCreateInstance entirely, not just that + // one feature. + const char *extensions[4] = { XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME, XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME, - XR_META_PERFORMANCE_METRICS_EXTENSION_NAME, }; + uint32_t extensionCount = 2; + if (g_performance_metrics_supported) + extensions[extensionCount++] = XR_META_PERFORMANCE_METRICS_EXTENSION_NAME; + if (g_cylinder_layer_supported) + extensions[extensionCount++] = XR_KHR_COMPOSITION_LAYER_CYLINDER_EXTENSION_NAME; XrInstanceCreateInfoAndroidKHR androidInfo = {XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR}; androidInfo.applicationVM = vm; @@ -368,7 +404,7 @@ static bool xr_create_instance_and_session(int game_width, int game_height) { XrInstanceCreateInfo createInfo = {XR_TYPE_INSTANCE_CREATE_INFO}; createInfo.next = &androidInfo; - createInfo.enabledExtensionCount = g_performance_metrics_supported ? 3 : 2; + createInfo.enabledExtensionCount = extensionCount; createInfo.enabledExtensionNames = extensions; strncpy(createInfo.applicationInfo.applicationName, "QuestShock", XR_MAX_APPLICATION_NAME_SIZE - 1); @@ -663,30 +699,54 @@ void xr_frame_end(void) { if (!xr_is_session_running()) return; - // Up to 5 layers: the game quad (if a frame was actually rendered), - // the menu launcher and keyboard overlays while visible - each gets - // its own acquire/render/release cycle against its own swapchain (see - // xr_overlay_render_and_build_layer()) any time before xrEndFrame, - // unlike the game quad there's no per-frame engine rendering to wrap - // around here, just each overlay's own blit - and, per hand, a - // laser-beam quad while that hand's ray is aimed at the keyboard, - // menu, or the game quad itself (see xr_input_get_beam_layer()). + // Up to 5 layers: the game screen (if a frame was actually rendered) - + // a flat quad, or a curved cylinder if the currently-active screen + // profile has a nonzero curve (see xr_get_game_quad_extent()'s own + // comment; xr_input.c's xr_input_try_game_quad() hit-tests whichever + // shape is actually submitted here, always agreeing since both read + // the same getter) - the menu launcher and keyboard overlays while + // visible - each gets its own acquire/render/release cycle against + // its own swapchain (see xr_overlay_render_and_build_layer()) any + // time before xrEndFrame, unlike the game screen there's no per-frame + // engine rendering to wrap around here, just each overlay's own blit - + // and, per hand, a laser-beam quad while that hand's ray is aimed at + // the keyboard, menu, or the game screen itself (see + // xr_input_get_beam_layer()). + float gameDistance, gameHalfWidth, gameHalfHeight, gameRadius, gameCentralAngle; + xr_get_game_quad_extent(&gameDistance, &gameHalfWidth, &gameHalfHeight, &gameRadius, + &gameCentralAngle); + bool gameScreenCurved = gameCentralAngle > 0.0f; + XrCompositionLayerQuad gameQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD}; - gameQuad.space = g_play_space; - gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; - gameQuad.subImage.swapchain = g_game_swapchain.swapchain; - gameQuad.subImage.imageRect.extent.width = g_game_swapchain.width; - gameQuad.subImage.imageRect.extent.height = g_game_swapchain.height; - gameQuad.pose.orientation.w = 1.0f; - gameQuad.pose.position.z = -QUAD_DISTANCE_METERS; - gameQuad.size.width = QUAD_WIDTH_METERS; - gameQuad.size.height = - QUAD_WIDTH_METERS * (float)g_game_swapchain.height / (float)g_game_swapchain.width; + XrCompositionLayerCylinderKHR gameCylinder = {XR_TYPE_COMPOSITION_LAYER_CYLINDER_KHR}; + if (gameScreenCurved) { + gameCylinder.space = g_play_space; + gameCylinder.eyeVisibility = XR_EYE_VISIBILITY_BOTH; + gameCylinder.subImage.swapchain = g_game_swapchain.swapchain; + gameCylinder.subImage.imageRect.extent.width = g_game_swapchain.width; + gameCylinder.subImage.imageRect.extent.height = g_game_swapchain.height; + gameCylinder.pose.orientation.w = 1.0f; + gameCylinder.pose.position.z = -gameDistance; + gameCylinder.radius = gameRadius; + gameCylinder.centralAngle = gameCentralAngle; + gameCylinder.aspectRatio = (float)g_game_swapchain.width / (float)g_game_swapchain.height; + } else { + gameQuad.space = g_play_space; + gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; + gameQuad.subImage.swapchain = g_game_swapchain.swapchain; + gameQuad.subImage.imageRect.extent.width = g_game_swapchain.width; + gameQuad.subImage.imageRect.extent.height = g_game_swapchain.height; + gameQuad.pose.orientation.w = 1.0f; + gameQuad.pose.position.z = -gameDistance; + gameQuad.size.width = gameHalfWidth * 2.0f; + gameQuad.size.height = gameHalfHeight * 2.0f; + } const XrCompositionLayerBaseHeader *layers[5]; uint32_t layerCount = 0; if (g_have_acquired_game_image) - layers[layerCount++] = (XrCompositionLayerBaseHeader *)&gameQuad; + layers[layerCount++] = gameScreenCurved ? (XrCompositionLayerBaseHeader *)&gameCylinder + : (XrCompositionLayerBaseHeader *)&gameQuad; XrCompositionLayerQuad menuQuad; if (g_frame_should_render && xr_overlay_render_and_build_layer(g_menu_overlay, &menuQuad)) { @@ -770,13 +830,45 @@ void xr_shutdown(void) { g_xrQueryPerformanceMetricsCounterMETA = NULL; g_cpu_utilization_path = XR_NULL_PATH; g_gpu_utilization_path = XR_NULL_PATH; + + g_cylinder_layer_supported = false; + // g_screen_width_m/g_screen_curve_deg/g_screen_distance_m deliberately + // NOT reset here - same reasoning as xr_session_set_player_transform()'s + // own state: it's meant to persist across a shutdown/reinit within the + // same process (e.g. an Android lifecycle pause/resume), not just + // within one XR session. } -void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m) { - *distance_m = QUAD_DISTANCE_METERS; - *half_width_m = QUAD_WIDTH_METERS * 0.5f; - *half_height_m = - QUAD_WIDTH_METERS * 0.5f * (float)g_game_swapchain.height / (float)g_game_swapchain.width; +void xr_session_set_game_screen_shape(float width_m, float curve_deg, float distance_m) { + g_screen_width_m = width_m; + g_screen_curve_deg = curve_deg; + g_screen_distance_m = distance_m; +} + +// Single source of truth for the game screen's current shape, read by +// both xr_frame_end() (to build/submit the actual layer) and +// xr_input_try_game_quad() (xr_input.c, to hit-test the laser pointer +// against it). half_width_m is meaningful only when *central_angle_rad is +// 0 (flat); a curved screen's horizontal extent is bounded by +// *central_angle_rad instead, with *half_height_m always the vertical +// bound either way. +void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m, + float *radius_m, float *central_angle_rad) { + float curveDeg = g_cylinder_layer_supported ? g_screen_curve_deg : 0.0f; + float aspect = (float)g_game_swapchain.height / (float)g_game_swapchain.width; + + *distance_m = g_screen_distance_m; + *half_width_m = g_screen_width_m * 0.5f; + *half_height_m = g_screen_width_m * 0.5f * aspect; + + if (curveDeg > 0.0f) { + float theta = curveDeg * QS_DEG_TO_RAD; + *central_angle_rad = theta; + *radius_m = g_screen_width_m / theta; + } else { + *central_angle_rad = 0.0f; + *radius_m = 0.0f; + } } void xr_get_game_resolution(int *width, int *height) { diff --git a/android/app/src/main/cpp/xr_session.h b/android/app/src/main/cpp/xr_session.h index 7f16be5..9f56d89 100644 --- a/android/app/src/main/cpp/xr_session.h +++ b/android/app/src/main/cpp/xr_session.h @@ -68,11 +68,30 @@ void xr_frame_end(void); void xr_shutdown(void); -// Play-space geometry of the single game quad xr_frame_end() submits +// Play-space geometry of the single game screen xr_frame_end() submits // (distance in front of the play space origin, half-width/half-height, // all in meters) - shared with xr_input.c's ray/quad hit-testing so the -// laser pointer always matches whatever's actually visible. -void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m); +// laser pointer always matches whatever's actually visible. *radius_m/ +// *central_angle_rad are 0 when flat, in which case *half_width_m is the +// horizontal extent; otherwise the screen is a curved cylinder segment +// and the caller uses *central_angle_rad instead of *half_width_m for the +// horizontal bound (*half_height_m stays meaningful either way). Curving +// is currently unused - qs_config.c has no config surface for it - but +// kept working so it can be re-enabled later. +void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m, + float *radius_m, float *central_angle_rad); + +// Sets the game screen's current physical width (meters) and curve (wrap +// angle in degrees, 0 = flat) - a plain state setter, no XR calls, same +// category as xr_session_set_player_transform() above (safe to call +// before xr_init() has even run). Read by both xr_frame_end() (to build/ +// submit the actual layer) and, via xr_get_game_quad_extent() above, +// xr_input.c's hit-testing. Driven by qs_config.c's +// qs_screen_profile_notify_loop_mode(), which decides which of the +// "Initial Screen"/"Game Screen" config profiles (see CONFIG.md) is +// currently active - this function itself has no notion of "profiles", +// only "what shape is current". +void xr_session_set_game_screen_shape(float width_m, float curve_deg, float distance_m); // The engine's current screen resolution (gScreenWide/gScreenHigh, // InitMac.h) - not the game quad's own swapchain size, which is fixed at diff --git a/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java b/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java index e38d7af..8a1980a 100644 --- a/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java +++ b/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java @@ -46,17 +46,28 @@ import java.util.Locale; public class MenuOverlay extends OverlayPanel { // Grown from the original 512x256 (still just a single "Keyboard" // button back then) to fit the header/tabs/Video Options content added - // since, then again to fit the stats ribbon below the tab bar - see + // since, then again to fit the stats ribbon below the tab bar, then + // again to fit the screen-size grid (width/distance rows) - see // xr_session.c's matching MENU_WIDTH/MENU_HEIGHT and xr_input.c's // matching MENU_TITLE_BAR_V_FRACTION. private static final int WIDTH = 768; - private static final int HEIGHT = 552; + private static final int HEIGHT = 800; private static final int HEADER_HEIGHT = 56; private static final int TAB_BAR_HEIGHT = 48; private static final int STATS_BAR_HEIGHT = 40; private static final long STATS_REFRESH_MS = 500; private static final String[] RESOLUTION_LABELS = {"320x200", "320x400", "640x400", "640x480", "1024x768"}; + // Construction-time placeholder text only, matching qs_config.c's own + // default offset/step - buildVideoOptionsTab() below uses these just + // to give the screen-profile preset buttons *some* label before the + // tab is ever shown; refreshVideoOptionsSelection()'s refreshPresetRow() + // immediately overwrites them with the real, possibly config-file- + // adjusted values once the tab is (re)shown (switchTab() always calls + // it first), so these never need to change even if the corresponding + // qs_config.c defaults ever do. + private static final String[] SCREEN_WIDTH_LABELS = {"1.0m", "1.6m", "2.2m", "2.8m", "3.4m"}; + private static final String[] SCREEN_DISTANCE_LABELS = {"1.0m", "1.5m", "2.0m", "2.5m", "3.0m"}; // Order must match qs_config.c's kLanguages[]/gShockPrefs.goLanguage's // own numbering (Prefs.h: 0 English/1 French/2 German) - also the keys // this same language's own Strings.java translation is filed under. @@ -110,6 +121,19 @@ public class MenuOverlay extends OverlayPanel { private Button openglButton; private Button[] resolutionButtons; + // Video Options tab's screen-size grid (two columns - Initial Screen/ + // Game Screen - each with a Width row and a Distance row) + private TextView initialScreenTitleView; + private TextView initialScreenWidthLabel; + private TextView initialScreenDistanceLabel; + private Button[] initialScreenWidthButtons; + private Button[] initialScreenDistanceButtons; + private TextView gameScreenTitleView; + private TextView gameScreenWidthLabel; + private TextView gameScreenDistanceLabel; + private Button[] gameScreenWidthButtons; + private Button[] gameScreenDistanceButtons; + private int currentLanguage = Strings.ENGLISH; private final Handler handler = new Handler(Looper.getMainLooper()); @@ -306,6 +330,108 @@ public class MenuOverlay extends OverlayPanel { } tab.addView(resolutionRow); + // Two columns side by side (Initial Screen / Game Screen) rather + // than 4 fully-stacked rows - keeps the added vertical space to + // roughly 2 row-heights instead of 4. See CONFIG.md's Video + // Options "Initial Screen"/"Game Screen" settings. + LinearLayout screenColumns = new LinearLayout(activity); + screenColumns.setOrientation(LinearLayout.HORIZONTAL); + screenColumns.setPadding(0, 16, 0, 0); + + LinearLayout initialColumn = new LinearLayout(activity); + initialColumn.setOrientation(LinearLayout.VERTICAL); + + initialScreenTitleView = new TextView(activity); + initialScreenTitleView.setTextColor(0xFFFFFFFF); + initialScreenTitleView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18); + initialScreenTitleView.setText(Strings.get("initial_screen", currentLanguage)); + initialColumn.addView(initialScreenTitleView); + + initialScreenWidthLabel = new TextView(activity); + initialScreenWidthLabel.setTextColor(0xFFFFFFFF); + initialScreenWidthLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); + initialScreenWidthLabel.setPadding(0, 8, 0, 0); + initialScreenWidthLabel.setText(Strings.get("width", currentLanguage)); + initialColumn.addView(initialScreenWidthLabel); + + LinearLayout initialWidthRow = new LinearLayout(activity); + initialWidthRow.setOrientation(LinearLayout.HORIZONTAL); + initialScreenWidthButtons = new Button[SCREEN_WIDTH_LABELS.length]; + for (int i = 0; i < SCREEN_WIDTH_LABELS.length; i++) { + final int index = i; + initialScreenWidthButtons[i] = addSelectableButton(initialWidthRow, SCREEN_WIDTH_LABELS[i], + v -> nativeRequestSetInitialScreenWidth(index)); + } + initialColumn.addView(initialWidthRow); + + initialScreenDistanceLabel = new TextView(activity); + initialScreenDistanceLabel.setTextColor(0xFFFFFFFF); + initialScreenDistanceLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); + initialScreenDistanceLabel.setPadding(0, 8, 0, 0); + initialScreenDistanceLabel.setText(Strings.get("distance", currentLanguage)); + initialColumn.addView(initialScreenDistanceLabel); + + LinearLayout initialDistanceRow = new LinearLayout(activity); + initialDistanceRow.setOrientation(LinearLayout.HORIZONTAL); + initialScreenDistanceButtons = new Button[SCREEN_DISTANCE_LABELS.length]; + for (int i = 0; i < SCREEN_DISTANCE_LABELS.length; i++) { + final int index = i; + initialScreenDistanceButtons[i] = addSelectableButton(initialDistanceRow, + SCREEN_DISTANCE_LABELS[i], v -> nativeRequestSetInitialScreenDistance(index)); + } + initialColumn.addView(initialDistanceRow); + + screenColumns.addView(initialColumn, new LinearLayout.LayoutParams( + 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + + LinearLayout gameColumn = new LinearLayout(activity); + gameColumn.setOrientation(LinearLayout.VERTICAL); + + gameScreenTitleView = new TextView(activity); + gameScreenTitleView.setTextColor(0xFFFFFFFF); + gameScreenTitleView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18); + gameScreenTitleView.setText(Strings.get("game_screen", currentLanguage)); + gameColumn.addView(gameScreenTitleView); + + gameScreenWidthLabel = new TextView(activity); + gameScreenWidthLabel.setTextColor(0xFFFFFFFF); + gameScreenWidthLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); + gameScreenWidthLabel.setPadding(0, 8, 0, 0); + gameScreenWidthLabel.setText(Strings.get("width", currentLanguage)); + gameColumn.addView(gameScreenWidthLabel); + + LinearLayout gameWidthRow = new LinearLayout(activity); + gameWidthRow.setOrientation(LinearLayout.HORIZONTAL); + gameScreenWidthButtons = new Button[SCREEN_WIDTH_LABELS.length]; + for (int i = 0; i < SCREEN_WIDTH_LABELS.length; i++) { + final int index = i; + gameScreenWidthButtons[i] = addSelectableButton(gameWidthRow, SCREEN_WIDTH_LABELS[i], + v -> nativeRequestSetGameScreenWidth(index)); + } + gameColumn.addView(gameWidthRow); + + gameScreenDistanceLabel = new TextView(activity); + gameScreenDistanceLabel.setTextColor(0xFFFFFFFF); + gameScreenDistanceLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); + gameScreenDistanceLabel.setPadding(0, 8, 0, 0); + gameScreenDistanceLabel.setText(Strings.get("distance", currentLanguage)); + gameColumn.addView(gameScreenDistanceLabel); + + LinearLayout gameDistanceRow = new LinearLayout(activity); + gameDistanceRow.setOrientation(LinearLayout.HORIZONTAL); + gameScreenDistanceButtons = new Button[SCREEN_DISTANCE_LABELS.length]; + for (int i = 0; i < SCREEN_DISTANCE_LABELS.length; i++) { + final int index = i; + gameScreenDistanceButtons[i] = addSelectableButton(gameDistanceRow, + SCREEN_DISTANCE_LABELS[i], v -> nativeRequestSetGameScreenDistance(index)); + } + gameColumn.addView(gameDistanceRow); + + screenColumns.addView(gameColumn, new LinearLayout.LayoutParams( + 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + + tab.addView(screenColumns); + return tab; } @@ -438,6 +564,41 @@ public class MenuOverlay extends OverlayPanel { for (int i = 0; i < resolutionButtons.length; i++) { resolutionButtons[i].setSelected(i == index); } + + // -1 (no exact preset match, e.g. a hand-edited config value) just + // means no button in the row ends up selected - setSelected(false) + // for every entry, nothing special-cased. Each row's *labels* are + // also refreshed here, not just fixed at construction time - the + // offset/step generating them is config-file-only (see + // qs_config.h's own comment) and can change under Load + // Configuration while this tab is open. + refreshPresetRow(initialScreenWidthButtons, nativeGetInitialScreenWidthIndex(), + MenuOverlay::nativeGetScreenWidthPresetValue); + refreshPresetRow(initialScreenDistanceButtons, nativeGetInitialScreenDistanceIndex(), + MenuOverlay::nativeGetScreenDistancePresetValue); + refreshPresetRow(gameScreenWidthButtons, nativeGetGameScreenWidthIndex(), + MenuOverlay::nativeGetScreenWidthPresetValue); + refreshPresetRow(gameScreenDistanceButtons, nativeGetGameScreenDistanceIndex(), + MenuOverlay::nativeGetScreenDistancePresetValue); + } + + // Functional interface for the two nativeGetScreen*PresetValue() + // getters below - each takes a preset index (0..4) and returns that + // preset's current value, shared by both "Initial Screen" and "Game + // Screen" columns (only which button is selected differs per column). + private interface PresetValueGetter { + float get(int index); + } + + private void refreshPresetRow(Button[] buttons, int selectedIndex, PresetValueGetter valueGetter) { + for (int i = 0; i < buttons.length; i++) { + buttons[i].setText(formatMeters(valueGetter.get(i))); + buttons[i].setSelected(i == selectedIndex); + } + } + + private static String formatMeters(float meters) { + return String.format(Locale.US, "%.1fm", meters); } // Same reasoning as refreshVideoOptionsSelection() above, for the Main @@ -531,6 +692,16 @@ public class MenuOverlay extends OverlayPanel { for (int i = 0; i < languageButtons.length; i++) { languageButtons[i].setText(Strings.get(LANGUAGE_KEYS[i], currentLanguage)); } + // The screen-profile preset buttons' own labels are never + // translated (plain measurements, like RESOLUTION_LABELS above) - + // refreshVideoOptionsSelection()'s refreshPresetRow() keeps them + // current independently of language. + initialScreenTitleView.setText(Strings.get("initial_screen", currentLanguage)); + initialScreenWidthLabel.setText(Strings.get("width", currentLanguage)); + initialScreenDistanceLabel.setText(Strings.get("distance", currentLanguage)); + gameScreenTitleView.setText(Strings.get("game_screen", currentLanguage)); + gameScreenWidthLabel.setText(Strings.get("width", currentLanguage)); + gameScreenDistanceLabel.setText(Strings.get("distance", currentLanguage)); } // "Dismiss" on the restart dialog - the live-appliable subset was @@ -584,11 +755,31 @@ public class MenuOverlay extends OverlayPanel { // xr_input_recenter_player_to_screen()) - a live-only action, unrelated // to questshock.config.json. private static native void nativeRequestResetPosition(); + // Video Options tab's "Initial Screen"/"Game Screen" width/distance + // preset rows - same enqueue-only reasoning as above. presetIndex + // (0..4) selects one of qs_config.c's own offset+step-generated + // presets (nativeGetScreen*PresetValue() below returns the actual + // value, for the button's label). + private static native void nativeRequestSetInitialScreenWidth(int presetIndex); + private static native void nativeRequestSetInitialScreenDistance(int presetIndex); + private static native void nativeRequestSetGameScreenWidth(int presetIndex); + private static native void nativeRequestSetGameScreenDistance(int presetIndex); // Read-only queries - safe to call directly, see qs_config.h's own // comment on each of these. private static native boolean nativeGetRendererIsOpenGl(); private static native int nativeGetResolutionIndex(); + // -1 = no exact preset match (see refreshVideoOptionsSelection()). + private static native int nativeGetInitialScreenWidthIndex(); + private static native int nativeGetInitialScreenDistanceIndex(); + private static native int nativeGetGameScreenWidthIndex(); + private static native int nativeGetGameScreenDistanceIndex(); + // Preset button *labels* (not the current value) - shared by both + // "Initial Screen" and "Game Screen" columns, since their offset/step + // is config-file-only, not per-profile (see qs_config.h's own + // comment). Index is clamped into range, not validated. + private static native float nativeGetScreenWidthPresetValue(int index); + private static native float nativeGetScreenDistancePresetValue(int index); // Package-visible (not private): KeyboardOverlay also calls this // directly, once, at its own construction time, to show the right // language from the start rather than waiting for this class's own diff --git a/android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java b/android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java index d2fa75a..054bb17 100644 --- a/android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java +++ b/android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java @@ -23,7 +23,7 @@ import org.libsdl.app.SDLSurface; * SDL_RWops, so Android's asset-manager fallback for SDL_RWFromFile * doesn't apply here) expects to find it - see android/engine-patches/ for * why this is done from Java instead of patching engine/ itself, which is - * never modified. + * never modified.q * * super.onCreate() (SDLActivity's) must always run first and unconditionally * - Android throws SuperNotCalledException otherwise, checked right after @@ -144,10 +144,17 @@ public class QuestShockActivity extends SDLActivity { File marker = new File(gameDir, "GET_ASSETS_QUEST.txt"); if (!marker.exists()) { copyAssetDir("shaders", new File(gameDir, "shaders")); - copyAssetFile("res/soundfont.sf2", new File(gameDir, "res/soundfont.sf2")); - copyAssetFile("GET_ASSETS_QUEST.txt", marker); + copyAssetFile("res/soundfont.sf2", new File(gameDir, "res/soundfont.sf2"), false); + copyAssetFile("GET_ASSETS_QUEST.txt", marker, false); } + // Refreshed on every launch (unlike the marker-gated block above) so + // it always matches the installed build - qs_config.c reads this as + // the initial config only when questshock.config.json doesn't exist + // yet, never overwriting a user's own saved config. + copyAssetFile("questshock.config.default.json", + new File(gameDir, "questshock.config.default.json"), true); + // Shockolate's engine/ is never patched to check this itself (see the // class doc above) - it just does plain fopen("res/data/...", ...) // and, on a fresh install with no game data copied in yet, that @@ -211,8 +218,8 @@ public class QuestShockActivity extends SDLActivity { .show(); } - private void copyAssetFile(String assetPath, File dest) { - if (dest.exists()) { + private void copyAssetFile(String assetPath, File dest, boolean overwrite) { + if (dest.exists() && !overwrite) { return; } File parent = dest.getParentFile(); @@ -246,7 +253,7 @@ public class QuestShockActivity extends SDLActivity { } destDir.mkdirs(); for (String entry : entries) { - copyAssetFile(assetDir + "/" + entry, new File(destDir, entry)); + copyAssetFile(assetDir + "/" + entry, new File(destDir, entry), false); } } } diff --git a/android/app/src/main/java/de/ladkau/questshock/Strings.java b/android/app/src/main/java/de/ladkau/questshock/Strings.java index 2531150..4b79040 100644 --- a/android/app/src/main/java/de/ladkau/questshock/Strings.java +++ b/android/app/src/main/java/de/ladkau/questshock/Strings.java @@ -50,6 +50,10 @@ final class Strings { put("english", "English", "Anglais", "Englisch"); put("french", "French", "Français", "Französisch"); put("german", "German", "Allemand", "Deutsch"); + put("initial_screen", "Initial Screen", "Écran initial", "Startbildschirm"); + put("game_screen", "Game Screen", "Écran de jeu", "Spielbildschirm"); + put("width", "Width", "Largeur", "Breite"); + put("distance", "Distance", "Distance", "Abstand"); // "OpenGL" is a proper/brand name - not translated, same in every // language, so it has no entry here; call sites just use it as a // literal. diff --git a/android/engine-patches/22-android-screen-profile.patch b/android/engine-patches/22-android-screen-profile.patch new file mode 100644 index 0000000..097a2da --- /dev/null +++ b/android/engine-patches/22-android-screen-profile.patch @@ -0,0 +1,30 @@ +--- a/src/GameSrc/mainloop.c ++++ b/src/GameSrc/mainloop.c +@@ -57,6 +57,10 @@ + #include "tools.h" + #include "wrapper.h" + ++#ifdef __ANDROID__ ++#include "qs_config.h" ++#endif ++ + // how is the game doing, anyway, set to true at end of time + uchar cit_success = FALSE; + +@@ -103,6 +107,16 @@ + _last_mode = *cmode; + (*exit_modes[_last_mode])(); + *cmode = _new_mode; ++#ifdef __ANDROID__ ++ // Notifies qs_config.c of every loop-mode transition so it can pick ++ // between the "Initial Screen" and "Game Screen" virtual-screen ++ // presets - see qs_config.h/CONFIG.md. Called synchronously, directly, ++ // on this thread (mainloop()'s own) - unlike the UI-thread-triggered ++ // config actions elsewhere in qs_config.c, this doesn't need the ++ // cross-thread SDL-event dance since it already originates on the ++ // right thread. ++ qs_screen_profile_notify_loop_mode(*cmode); ++#endif + _static_change = 0; + if (*cmode >= 0) + (*enter_modes[*cmode])(); diff --git a/build-image/prepare-android-project.sh b/build-image/prepare-android-project.sh index 18b149b..1b01ce7 100755 --- a/build-image/prepare-android-project.sh +++ b/build-image/prepare-android-project.sh @@ -81,7 +81,7 @@ fi echo "prebuiltDir=$PREBUILT_DIR_PROP" } > "$ANDROID_DIR/engine.properties" -echo "== Staging bundled assets (shaders, soundfont, get-assets text) ==" +echo "== Staging bundled assets (shaders, soundfont, get-assets text, default config) ==" ASSETS_DIR="$ANDROID_DIR/app/src/main/assets" rm -rf "$ASSETS_DIR" mkdir -p "$ASSETS_DIR/shaders" "$ASSETS_DIR/res" @@ -91,6 +91,7 @@ mkdir -p "$ASSETS_DIR/shaders" "$ASSETS_DIR/res" cp -a "$ANDROID_DIR/gles-shaders/." "$ASSETS_DIR/shaders/" 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/questshock.config.default.json" "$ASSETS_DIR/questshock.config.default.json" 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 diff --git a/res/questshock.config.default.json b/res/questshock.config.default.json new file mode 100644 index 0000000..b1e3e55 --- /dev/null +++ b/res/questshock.config.default.json @@ -0,0 +1,17 @@ +{ + "video_options": { + "renderer": "opengl", + "resolution": "1024x768", + "initial_screen_width_m": 1.600000, + "initial_screen_distance_m": 2.000000, + "game_screen_width_m": 3.400000, + "game_screen_distance_m": 2.000000, + "screen_width_offset_m": 1.000000, + "screen_width_step_m": 0.600000, + "screen_distance_offset_m": 1.000000, + "screen_distance_step_m": 0.500000 + }, + "general": { + "language": "english" + } +}