Compare commits
2 Commits
e0a2e1f4bf
...
09280dd410
| Author | SHA1 | Date | |
|---|---|---|---|
| 09280dd410 | |||
| c4fd4eb3dd |
@@ -0,0 +1,71 @@
|
||||
# Persistent configuration (`questshock.config.json`)
|
||||
|
||||
QuestShock can load a persistent, human-editable configuration file at
|
||||
startup, in addition to (and independent of) the engine's own `prefs.txt`.
|
||||
|
||||
- **Location**: `/sdcard/questshock/questshock.config.json` - the same
|
||||
folder you already copy `res/data`/`res/sound` into (see README.md
|
||||
section 5.1).
|
||||
- **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).
|
||||
- **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
|
||||
Configuration** to re-read the file and apply it without restarting the
|
||||
app.
|
||||
- **Live vs. restart-required**: every option below is tagged as either
|
||||
*live-appliable* (applying it while already playing takes effect
|
||||
immediately) or *restart-required*. Pressing **Load Configuration**
|
||||
applies every live-appliable change straight away; if the file also
|
||||
changes something restart-required, a dialog lists exactly which
|
||||
option(s) need it and offers:
|
||||
- **Dismiss** - keep playing with the live-appliable changes already
|
||||
applied; the restart-required option(s) stay as they were.
|
||||
- **Restart Now** - relaunches the app, which re-runs this same startup
|
||||
load and so applies every group in the file, including the
|
||||
restart-required ones.
|
||||
|
||||
(Both options currently documented below are live-appliable, so this
|
||||
dialog won't actually appear yet - it's built in for options added in a
|
||||
future version that do need a restart.)
|
||||
- Unrecognized groups/keys are ignored (harmless if you're editing the file
|
||||
in advance for a future version). An invalid value for a *specific*
|
||||
option (see each option's possible values below) is logged and that one
|
||||
option is skipped - every other valid option in the file still applies.
|
||||
A file that isn't valid JSON at all is ignored entirely, with nothing
|
||||
applied.
|
||||
- `questshock.config.json` and `prefs.txt` are fully independent - loading
|
||||
or saving one never reads or writes the other.
|
||||
|
||||
## Example
|
||||
|
||||
```json
|
||||
{
|
||||
"video_options": {
|
||||
"renderer": "opengl",
|
||||
"resolution": "1024x768"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Video Options (`video_options`)
|
||||
|
||||
### `renderer`
|
||||
|
||||
Which renderer the game uses to draw the 3D view.
|
||||
|
||||
- **Type**: enum
|
||||
- **Possible values**: `"software"`, `"opengl"`
|
||||
- **Restart required?**: No (live-appliable)
|
||||
|
||||
### `resolution`
|
||||
|
||||
The game's internal rendering resolution.
|
||||
|
||||
- **Type**: enum
|
||||
- **Possible values**: `"320x200"`, `"320x400"`, `"640x400"`, `"640x480"`, `"1024x768"`
|
||||
- **Restart required?**: No (live-appliable)
|
||||
@@ -270,6 +270,11 @@ debugging enabled.)
|
||||
`/sdcard/questshock/res/`.
|
||||
4. Launch it again.
|
||||
|
||||
`/sdcard/questshock/` also holds an optional persistent settings file,
|
||||
`questshock.config.json`, loaded automatically at startup and saved/loaded
|
||||
from the in-headset menu - see [CONFIG.md](CONFIG.md) for the documented
|
||||
options.
|
||||
|
||||
### 5.2. Building natively in Android Studio
|
||||
|
||||
`make apk` always compiles the engine and links the APK inside the
|
||||
@@ -508,6 +513,11 @@ each one does:
|
||||
goes through the OpenXR swapchain's own FBOs, set up separately in
|
||||
`xr_swapchain.c`) and is also functionally dead there regardless -
|
||||
skipped entirely, Android-only.
|
||||
- **`21-android-config.patch`** - hooks up the persistent JSON config file
|
||||
(see [CONFIG.md](CONFIG.md), `qs_config.c`): one call to
|
||||
`qs_config_load_startup()` in `Shock.c`'s `main()`, right after
|
||||
`LoadPrefs()`, and one check in `sdl_events.c`'s `pump_events()` to drain
|
||||
the custom SDL events the menu's Save/Load Configuration buttons enqueue.
|
||||
|
||||
`android/gl4es-patches/`:
|
||||
|
||||
|
||||
@@ -29,9 +29,33 @@ if (prebuiltDir == null) {
|
||||
}
|
||||
|
||||
// Set by build-apk.sh (see build-image/version.sh, shared with the
|
||||
// desktop tarball's versioning) via -PquestshockVersionName/Code. Default
|
||||
// here only covers a direct, unsupported `gradlew` invocation.
|
||||
def questshockVersionName = project.hasProperty('questshockVersionName') ? project.property('questshockVersionName') : '0.0.0-dev'
|
||||
// desktop tarball's versioning) via -PquestshockVersionName/Code. Building
|
||||
// directly from Android Studio (the normal day-to-day workflow - see
|
||||
// README) never passes that property, so the default below has to carry
|
||||
// its own weight rather than just being a fallback for some rare, other,
|
||||
// unsupported path: it runs `git describe` itself, the same way
|
||||
// engine/CMakeLists.txt's own PROJECT_REVERSION_STRING does for the
|
||||
// engine's *own* separate version string - "vX.Y.Z" on a clean tagged
|
||||
// commit, or "vX.Y.Z-N-gHASH[-dirty]" otherwise, so a build between
|
||||
// releases is still identifiable from the in-headset menu (see
|
||||
// MenuOverlay.java's header, which reads BuildConfig.VERSION_NAME).
|
||||
def gitDescribeVersionName = {
|
||||
try {
|
||||
def proc = ["git", "-C", projectDir.toString(), "describe", "--tags", "--always", "--dirty"].execute()
|
||||
proc.waitFor()
|
||||
if (proc.exitValue() == 0) {
|
||||
def out = proc.text.trim()
|
||||
if (out) {
|
||||
return out
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// git not on PATH, or this isn't a git checkout at all (e.g. a
|
||||
// source tarball) - fall through to the placeholder below.
|
||||
}
|
||||
return "0.0.0-dev"
|
||||
}
|
||||
def questshockVersionName = project.hasProperty('questshockVersionName') ? project.property('questshockVersionName') : gitDescribeVersionName()
|
||||
def questshockVersionCode = project.hasProperty('questshockVersionCode') ? project.property('questshockVersionCode').toInteger() : 1
|
||||
|
||||
// Auto-refreshes the staged, patched engine/ and gl4es-src/ copies and
|
||||
@@ -171,7 +195,7 @@ android {
|
||||
"-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", \
|
||||
"-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH", \
|
||||
"-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384", \
|
||||
"-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c;${projectDir}/src/main/cpp/xr_session.c;${projectDir}/src/main/cpp/xr_input.c;${projectDir}/src/main/cpp/xr_overlay.c;${projectDir}/src/main/cpp/xr_swapchain.c;${projectDir}/src/main/cpp/xr_mouse.c"
|
||||
"-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c;${projectDir}/src/main/cpp/xr_session.c;${projectDir}/src/main/cpp/xr_input.c;${projectDir}/src/main/cpp/xr_overlay.c;${projectDir}/src/main/cpp/xr_swapchain.c;${projectDir}/src/main/cpp/xr_mouse.c;${projectDir}/src/main/cpp/qs_config.c"
|
||||
abiFilters 'arm64-v8a'
|
||||
}
|
||||
}
|
||||
@@ -189,6 +213,13 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// Generates BuildConfig (specifically BuildConfig.VERSION_NAME, read by
|
||||
// MenuOverlay.java's header) - off by default since AGP 8.0, so this
|
||||
// has to be requested explicitly.
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
// Persistent, human-editable JSON config file - see qs_config.h and
|
||||
// CONFIG.md (repo root) for the documented schema and behavior. This file
|
||||
// deliberately touches engine internals directly (gShockPrefs, mode_id,
|
||||
// change_svga_screen_mode(), message_info()) - unlike xr_session.c/
|
||||
// xr_input.c/xr_swapchain.c, which stay purely XR/SDL-facing and never
|
||||
// reach into engine state this way. It's still compiled into the same
|
||||
// target as the rest of the engine (see android/app/build.gradle's
|
||||
// ANDROID_EXTRA_SOURCES, same as those files), so engine headers resolve
|
||||
// via the same include_directories() every engine .c file already uses.
|
||||
|
||||
#include <ctype.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <SDL.h>
|
||||
#include <jni.h>
|
||||
|
||||
#include "qs_config.h"
|
||||
|
||||
#include "Prefs.h"
|
||||
#include "fullscrn.h"
|
||||
#include "lg.h"
|
||||
#include "tools.h"
|
||||
|
||||
#define QS_CONFIG_FILENAME "questshock.config.json"
|
||||
|
||||
// ===========================================================================
|
||||
// Minimal JSON parser
|
||||
//
|
||||
// Just enough to read this file's own schema (nested one level: group ->
|
||||
// string-valued option) while safely skipping over anything it doesn't
|
||||
// recognize - unknown keys, wrong-typed values, arrays, nested objects - so
|
||||
// a hand-edited file with extra/future content never breaks parsing of the
|
||||
// parts this version does care about. No third-party JSON library exists
|
||||
// anywhere in this repo; this matches the project's own existing style of a
|
||||
// small hand-rolled parser for its persisted files (see Prefs.c's prefs.txt/
|
||||
// keybinds.txt parsing).
|
||||
//
|
||||
// `error` is sticky: once any function below sets it, every other parser
|
||||
// function becomes a no-op returning false, so a syntax error anywhere
|
||||
// cleanly aborts the whole parse rather than continuing from a
|
||||
// cursor position left in an indeterminate spot by the failure.
|
||||
// ===========================================================================
|
||||
|
||||
typedef struct {
|
||||
const char *p;
|
||||
const char *end;
|
||||
bool error;
|
||||
} QsJsonParser;
|
||||
|
||||
static void qs_json_fail(QsJsonParser *p) { p->error = true; }
|
||||
|
||||
static void qs_json_skip_ws(QsJsonParser *p) {
|
||||
while (!p->error && p->p < p->end && isspace((unsigned char)*p->p))
|
||||
p->p++;
|
||||
}
|
||||
|
||||
static bool qs_json_expect(QsJsonParser *p, char c) {
|
||||
if (p->error || p->p >= p->end || *p->p != c) {
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
p->p++;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parses (and unescapes \" \\ \/ \n \t \r \b \f) a JSON string literal at
|
||||
// the cursor into out, NUL-terminated, truncated (not treated as an error)
|
||||
// if longer than out_size - 1 bytes. out may be NULL (e.g. when skipping a
|
||||
// string value we don't care about) - the bytes are simply discarded.
|
||||
// \uXXXX escapes aren't supported (not needed for anything this file writes
|
||||
// or a realistic hand edit) and fail the parse like any other malformed
|
||||
// input.
|
||||
static bool qs_json_parse_string(QsJsonParser *p, char *out, size_t out_size) {
|
||||
if (p->error || !qs_json_expect(p, '"'))
|
||||
return false;
|
||||
size_t n = 0;
|
||||
while (p->p < p->end && *p->p != '"') {
|
||||
char c = *p->p;
|
||||
if (c == '\\') {
|
||||
p->p++;
|
||||
if (p->p >= p->end) {
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
switch (*p->p) {
|
||||
case '"':
|
||||
c = '"';
|
||||
break;
|
||||
case '\\':
|
||||
c = '\\';
|
||||
break;
|
||||
case '/':
|
||||
c = '/';
|
||||
break;
|
||||
case 'n':
|
||||
c = '\n';
|
||||
break;
|
||||
case 't':
|
||||
c = '\t';
|
||||
break;
|
||||
case 'r':
|
||||
c = '\r';
|
||||
break;
|
||||
case 'b':
|
||||
c = '\b';
|
||||
break;
|
||||
case 'f':
|
||||
c = '\f';
|
||||
break;
|
||||
default:
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (out && n + 1 < out_size)
|
||||
out[n++] = c;
|
||||
p->p++;
|
||||
}
|
||||
if (p->p >= p->end) {
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
p->p++; // closing quote
|
||||
if (out)
|
||||
out[n] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool qs_json_skip_number(QsJsonParser *p) {
|
||||
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++;
|
||||
if (p->p == start) {
|
||||
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) {
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
p->p += len;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool qs_json_skip_value(QsJsonParser *p);
|
||||
|
||||
typedef void (*QsJsonFieldCallback)(const char *key, QsJsonParser *p, void *ctx);
|
||||
|
||||
// Parses a JSON object (cursor at '{'). For each "key": value pair, calls
|
||||
// field_cb with the cursor positioned right after the colon - field_cb must
|
||||
// fully consume that value itself, one way or another (see
|
||||
// qs_json_take_string_field() below), since this function only ever
|
||||
// advances past the key + colon on its own.
|
||||
static bool qs_json_parse_object(QsJsonParser *p, QsJsonFieldCallback field_cb, void *ctx) {
|
||||
if (p->error || !qs_json_expect(p, '{'))
|
||||
return false;
|
||||
qs_json_skip_ws(p);
|
||||
if (!p->error && p->p < p->end && *p->p == '}') {
|
||||
p->p++;
|
||||
return true;
|
||||
}
|
||||
while (!p->error) {
|
||||
char key[64];
|
||||
if (!qs_json_parse_string(p, key, sizeof(key)))
|
||||
return false;
|
||||
qs_json_skip_ws(p);
|
||||
if (!qs_json_expect(p, ':'))
|
||||
return false;
|
||||
qs_json_skip_ws(p);
|
||||
field_cb(key, p, ctx);
|
||||
qs_json_skip_ws(p);
|
||||
if (p->error || p->p >= p->end) {
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
if (*p->p == ',') {
|
||||
p->p++;
|
||||
qs_json_skip_ws(p);
|
||||
continue;
|
||||
}
|
||||
if (*p->p == '}') {
|
||||
p->p++;
|
||||
return true;
|
||||
}
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool qs_json_skip_array(QsJsonParser *p) {
|
||||
if (p->error || !qs_json_expect(p, '['))
|
||||
return false;
|
||||
qs_json_skip_ws(p);
|
||||
if (!p->error && p->p < p->end && *p->p == ']') {
|
||||
p->p++;
|
||||
return true;
|
||||
}
|
||||
while (!p->error) {
|
||||
if (!qs_json_skip_value(p))
|
||||
return false;
|
||||
qs_json_skip_ws(p);
|
||||
if (p->error || p->p >= p->end) {
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
if (*p->p == ',') {
|
||||
p->p++;
|
||||
qs_json_skip_ws(p);
|
||||
continue;
|
||||
}
|
||||
if (*p->p == ']') {
|
||||
p->p++;
|
||||
return true;
|
||||
}
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// field_cb for qs_json_parse_object() when it's only being used to skip a
|
||||
// nested object we don't recognize - every field's value is itself skipped.
|
||||
static void qs_json_skip_field(const char *key, QsJsonParser *p, void *ctx) {
|
||||
(void)key;
|
||||
(void)ctx;
|
||||
qs_json_skip_value(p);
|
||||
}
|
||||
|
||||
static bool qs_json_skip_value(QsJsonParser *p) {
|
||||
if (p->error)
|
||||
return false;
|
||||
qs_json_skip_ws(p);
|
||||
if (p->p >= p->end) {
|
||||
qs_json_fail(p);
|
||||
return false;
|
||||
}
|
||||
switch (*p->p) {
|
||||
case '"':
|
||||
return qs_json_parse_string(p, NULL, 0);
|
||||
case '{':
|
||||
return qs_json_parse_object(p, qs_json_skip_field, NULL);
|
||||
case '[':
|
||||
return qs_json_skip_array(p);
|
||||
case 't':
|
||||
return qs_json_skip_literal(p, "true");
|
||||
case 'f':
|
||||
return qs_json_skip_literal(p, "false");
|
||||
case 'n':
|
||||
return qs_json_skip_literal(p, "null");
|
||||
default:
|
||||
return qs_json_skip_number(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Tries to parse the value at the cursor as a string into out (truncating
|
||||
// safely if too long); if it isn't a string at all, skips it instead so the
|
||||
// parse can continue past this field regardless of a wrong-typed value in
|
||||
// the file. Either way, fully consumes exactly one value.
|
||||
static void qs_json_take_string_field(QsJsonParser *p, char *out, size_t out_size) {
|
||||
if (!p->error && p->p < p->end && *p->p == '"')
|
||||
qs_json_parse_string(p, out, out_size);
|
||||
else
|
||||
qs_json_skip_value(p);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Schema: "video_options" group
|
||||
// ===========================================================================
|
||||
|
||||
typedef struct {
|
||||
char renderer[16]; // "" = not present in the file
|
||||
char resolution[16]; // "" = not present in the file
|
||||
} QsParsedVideoOptions;
|
||||
|
||||
typedef struct {
|
||||
QsParsedVideoOptions video_options;
|
||||
bool has_video_options;
|
||||
} QsParsedConfig;
|
||||
|
||||
static void qs_parse_video_options_field(const char *key, QsJsonParser *p, void *ctx) {
|
||||
QsParsedVideoOptions *out = (QsParsedVideoOptions *)ctx;
|
||||
if (strcmp(key, "renderer") == 0)
|
||||
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
|
||||
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
|
||||
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
|
||||
// normal/expected state, not an error.
|
||||
static bool qs_config_read_file(QsParsedConfig *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
FILE *f = fopen(QS_CONFIG_FILENAME, "rb");
|
||||
if (!f)
|
||||
return false;
|
||||
fseek(f, 0, SEEK_END);
|
||||
long size = ftell(f);
|
||||
fseek(f, 0, SEEK_SET);
|
||||
if (size <= 0) {
|
||||
fclose(f);
|
||||
return false;
|
||||
}
|
||||
char *buf = (char *)malloc((size_t)size);
|
||||
if (!buf) {
|
||||
fclose(f);
|
||||
return false;
|
||||
}
|
||||
size_t got = fread(buf, 1, (size_t)size, f);
|
||||
fclose(f);
|
||||
|
||||
QsJsonParser parser = {buf, buf + got, false};
|
||||
qs_json_skip_ws(&parser);
|
||||
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);
|
||||
memset(out, 0, sizeof(*out));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Video Options: renderer
|
||||
// ===========================================================================
|
||||
|
||||
// Already proven live-safe by the existing Ctrl+G renderer-toggle hotkey
|
||||
// (toggle_opengl(), OpenGL.cc), which does nothing more than flip this same
|
||||
// field - every render-mode check elsewhere in the engine reads
|
||||
// gShockPrefs.doUseOpenGL directly, every frame, so no extra "apply" step is
|
||||
// needed beyond setting it.
|
||||
#define QS_RENDERER_REQUIRES_RESTART false
|
||||
|
||||
static bool qs_apply_renderer(const char *value, bool live) {
|
||||
(void)live;
|
||||
bool want_opengl;
|
||||
if (strcmp(value, "opengl") == 0) {
|
||||
want_opengl = true;
|
||||
} else if (strcmp(value, "software") == 0) {
|
||||
want_opengl = false;
|
||||
} else {
|
||||
WARN("%s: video_options.renderer=\"%s\" is invalid (expected \"software\" or \"opengl\") - ignoring",
|
||||
QS_CONFIG_FILENAME, value);
|
||||
return false;
|
||||
}
|
||||
bool changed = gShockPrefs.doUseOpenGL != want_opengl;
|
||||
gShockPrefs.doUseOpenGL = want_opengl;
|
||||
return changed;
|
||||
}
|
||||
|
||||
static const char *qs_current_renderer(void) { return gShockPrefs.doUseOpenGL ? "opengl" : "software"; }
|
||||
|
||||
// ===========================================================================
|
||||
// Video Options: resolution
|
||||
// ===========================================================================
|
||||
|
||||
// Live-appliable: change_svga_screen_mode() (below) handles a real mode_id
|
||||
// change fully, including reallocating the screen buffer - see
|
||||
// qs_apply_resolution()'s own comment for the one extra step that takes
|
||||
// (screen_draw()) that a naive call site doesn't get for free.
|
||||
#define QS_RESOLUTION_REQUIRES_RESTART false
|
||||
|
||||
// mode_id (fullscrn.c) is the index change_svga_screen_mode() actually
|
||||
// reads - matches the same field Prefs.c's own SavePrefs()/SetShockGlobals()
|
||||
// read/write for gShockPrefs.doVideoMode.
|
||||
extern short mode_id;
|
||||
|
||||
// screen.c - declared locally (game_screen.h's other declarations pull in
|
||||
// UI-region types this file has no other reason to need) rather than
|
||||
// #include "game_screen.h", matching how mode_id above is declared.
|
||||
extern errtype screen_draw(void);
|
||||
|
||||
static const struct {
|
||||
const char *name;
|
||||
short mode;
|
||||
} kResolutions[] = {
|
||||
{"320x200", 0}, {"320x400", 1}, {"640x400", 2}, {"640x480", 3}, {"1024x768", 4},
|
||||
};
|
||||
#define QS_NUM_RESOLUTIONS (sizeof(kResolutions) / sizeof(kResolutions[0]))
|
||||
|
||||
static bool qs_apply_resolution(const char *value, bool live) {
|
||||
for (size_t i = 0; i < QS_NUM_RESOLUTIONS; i++) {
|
||||
if (strcmp(value, kResolutions[i].name) != 0)
|
||||
continue;
|
||||
bool changed = mode_id != kResolutions[i].mode;
|
||||
gShockPrefs.doVideoMode = kResolutions[i].mode;
|
||||
mode_id = kResolutions[i].mode;
|
||||
if (changed && live) {
|
||||
change_svga_screen_mode();
|
||||
// change_svga_screen_mode() only reallocates the screen buffer
|
||||
// (gr_alloc_screen()/gr_set_screen(), fullscrn.c) when mode_id
|
||||
// actually differs from its own last-applied value
|
||||
// (convert_use_mode) - true here, since changed is set above.
|
||||
// screen.c's screen_start() (the normal "enter the 3D game
|
||||
// screen" path, and the only other caller that hits this same
|
||||
// branch) always calls screen_draw() immediately afterward to
|
||||
// repaint the background/MFD/side-icon/inventory/status chrome
|
||||
// onto whatever buffer is now current; nothing else does, so a
|
||||
// mid-session mode-index change like this one otherwise leaves
|
||||
// that chrome blank (all still functional/redrawing correctly
|
||||
// frame to frame - automap content, inventory grid values,
|
||||
// etc. - just missing the static art around them) until the
|
||||
// next full mode transition. screen_draw() itself doesn't
|
||||
// assume any particular UI mode is active (automap/inventory/
|
||||
// main view), so it's safe to call regardless of which one the
|
||||
// resolution change happened during.
|
||||
screen_draw();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
WARN("%s: video_options.resolution=\"%s\" is invalid - ignoring", QS_CONFIG_FILENAME, value);
|
||||
return false;
|
||||
}
|
||||
|
||||
static const char *qs_current_resolution(void) {
|
||||
for (size_t i = 0; i < QS_NUM_RESOLUTIONS; i++)
|
||||
if (kResolutions[i].mode == mode_id)
|
||||
return kResolutions[i].name;
|
||||
return "640x480"; // shouldn't happen - safe fallback for Save
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Read-only queries for the Video Options tab (see qs_config.h)
|
||||
// ===========================================================================
|
||||
|
||||
bool qs_config_current_renderer_is_opengl(void) { return gShockPrefs.doUseOpenGL; }
|
||||
|
||||
int qs_config_current_resolution_index(void) {
|
||||
for (size_t i = 0; i < QS_NUM_RESOLUTIONS; i++)
|
||||
if (kResolutions[i].mode == mode_id)
|
||||
return (int)i;
|
||||
return 3; // shouldn't happen - 640x480, matches qs_current_resolution()'s own fallback
|
||||
}
|
||||
|
||||
int qs_config_current_language(void) { return gShockPrefs.goLanguage; }
|
||||
|
||||
// ===========================================================================
|
||||
// Apply / Save orchestration
|
||||
// ===========================================================================
|
||||
|
||||
static void qs_append_restart_reason(char *buf, size_t buf_size, const char *name) {
|
||||
if (!buf || buf_size == 0)
|
||||
return;
|
||||
size_t len = strlen(buf);
|
||||
if (len > 0 && len + 2 < buf_size) {
|
||||
strcat(buf, ", ");
|
||||
len += 2;
|
||||
}
|
||||
if (len < buf_size - 1)
|
||||
strncat(buf, name, buf_size - len - 1);
|
||||
}
|
||||
|
||||
// Applies every option present in *parsed (empty string = not present in
|
||||
// the file - left untouched). live=false is the startup path (no restart
|
||||
// distinction applies that early - see qs_config_load_startup()); live=true
|
||||
// is the mid-session "Load Configuration" button path, where restart_needed
|
||||
// (may be NULL) collects a human-readable, comma-separated list of every
|
||||
// option that both actually changed and is flagged *_REQUIRES_RESTART.
|
||||
static void qs_config_apply_parsed(const QsParsedConfig *parsed, bool live, char *restart_needed,
|
||||
size_t restart_needed_size) {
|
||||
if (restart_needed && restart_needed_size > 0)
|
||||
restart_needed[0] = '\0';
|
||||
if (!parsed->has_video_options)
|
||||
return;
|
||||
if (parsed->video_options.renderer[0] != '\0') {
|
||||
bool changed = qs_apply_renderer(parsed->video_options.renderer, live);
|
||||
if (changed && QS_RENDERER_REQUIRES_RESTART)
|
||||
qs_append_restart_reason(restart_needed, restart_needed_size, "renderer");
|
||||
}
|
||||
if (parsed->video_options.resolution[0] != '\0') {
|
||||
bool changed = qs_apply_resolution(parsed->video_options.resolution, live);
|
||||
if (changed && QS_RESOLUTION_REQUIRES_RESTART)
|
||||
qs_append_restart_reason(restart_needed, restart_needed_size, "resolution");
|
||||
}
|
||||
}
|
||||
|
||||
static bool qs_config_write_file(void) {
|
||||
FILE *f = fopen(QS_CONFIG_FILENAME, "w");
|
||||
if (!f) {
|
||||
WARN("%s: failed to open for writing", QS_CONFIG_FILENAME);
|
||||
return false;
|
||||
}
|
||||
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, " }\n");
|
||||
fprintf(f, "}\n");
|
||||
fclose(f);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Native -> Java: restart-required dialog
|
||||
// ===========================================================================
|
||||
|
||||
// Mirrors xr_overlay.c's own xr_overlay_find_class() helper (also file-local
|
||||
// there) - FindClass() from this thread can't see app classes on its own
|
||||
// since this thread was attached to the JVM via SDL_AndroidGetJNIEnv()
|
||||
// rather than spawned from Java; the activity's own ClassLoader can.
|
||||
static jclass qs_find_class(JNIEnv *env, jobject activity, const char *name) {
|
||||
jclass activityClass = (*env)->GetObjectClass(env, activity);
|
||||
jmethodID getClassLoader =
|
||||
(*env)->GetMethodID(env, activityClass, "getClassLoader", "()Ljava/lang/ClassLoader;");
|
||||
jobject classLoader = (*env)->CallObjectMethod(env, activity, getClassLoader);
|
||||
jclass classLoaderClass = (*env)->FindClass(env, "java/lang/ClassLoader");
|
||||
jmethodID loadClass =
|
||||
(*env)->GetMethodID(env, classLoaderClass, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;");
|
||||
jstring className = (*env)->NewStringUTF(env, name);
|
||||
jclass result = (jclass)(*env)->CallObjectMethod(env, classLoader, loadClass, className);
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
(*env)->ExceptionClear(env);
|
||||
return NULL;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static void qs_show_restart_dialog(const char *restart_needed) {
|
||||
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
|
||||
jobject activity = (jobject)SDL_AndroidGetActivity();
|
||||
if (!env || !activity)
|
||||
return;
|
||||
|
||||
jclass menuOverlayClass = qs_find_class(env, activity, "de.ladkau.questshock.MenuOverlay");
|
||||
if (!menuOverlayClass)
|
||||
return;
|
||||
|
||||
jmethodID showRestartDialog =
|
||||
(*env)->GetStaticMethodID(env, menuOverlayClass, "showRestartDialog", "(Ljava/lang/String;)V");
|
||||
if (!showRestartDialog) {
|
||||
(*env)->ExceptionClear(env);
|
||||
return;
|
||||
}
|
||||
|
||||
char message[256];
|
||||
snprintf(message, sizeof(message),
|
||||
"The following changes require a restart to take effect: %s. Everything else has "
|
||||
"already been applied.",
|
||||
restart_needed);
|
||||
jstring messageStr = (*env)->NewStringUTF(env, message);
|
||||
(*env)->CallStaticVoidMethod(env, menuOverlayClass, showRestartDialog, messageStr);
|
||||
if ((*env)->ExceptionCheck(env))
|
||||
(*env)->ExceptionClear(env);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Public API (see qs_config.h)
|
||||
// ===========================================================================
|
||||
|
||||
static Uint32 s_save_event_type;
|
||||
static Uint32 s_load_event_type;
|
||||
static Uint32 s_set_renderer_event_type;
|
||||
static Uint32 s_set_resolution_event_type;
|
||||
|
||||
void qs_config_load_startup(void) {
|
||||
// Registers the custom SDL event types qs_config_request_save()/_load()/
|
||||
// _set_renderer()/_set_resolution() 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(4);
|
||||
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;
|
||||
|
||||
QsParsedConfig parsed;
|
||||
if (qs_config_read_file(&parsed))
|
||||
qs_config_apply_parsed(&parsed, /*live=*/false, NULL, 0);
|
||||
}
|
||||
|
||||
void qs_config_request_save(void) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_save_event_type;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_request_load(void) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_load_event_type;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_request_set_renderer(bool opengl) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_set_renderer_event_type;
|
||||
event.user.code = opengl ? 1 : 0;
|
||||
SDL_PushEvent(&event);
|
||||
}
|
||||
|
||||
void qs_config_request_set_resolution(int resolution_index) {
|
||||
SDL_Event event;
|
||||
SDL_zero(event);
|
||||
event.type = s_set_resolution_event_type;
|
||||
event.user.code = resolution_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())
|
||||
message_info("Configuration saved");
|
||||
else
|
||||
message_info("Failed to save configuration");
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_load_event_type) {
|
||||
QsParsedConfig parsed;
|
||||
if (!qs_config_read_file(&parsed)) {
|
||||
message_info("No configuration file to load");
|
||||
return true;
|
||||
}
|
||||
char restart_needed[128];
|
||||
qs_config_apply_parsed(&parsed, /*live=*/true, restart_needed, sizeof(restart_needed));
|
||||
if (restart_needed[0] != '\0')
|
||||
qs_show_restart_dialog(restart_needed);
|
||||
else
|
||||
message_info("Configuration loaded");
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_set_renderer_event_type) {
|
||||
qs_apply_renderer(event->user.code ? "opengl" : "software", /*live=*/true);
|
||||
return true;
|
||||
}
|
||||
if (event->type == s_set_resolution_event_type) {
|
||||
int index = event->user.code;
|
||||
if (index >= 0 && (size_t)index < QS_NUM_RESOLUTIONS)
|
||||
qs_apply_resolution(kResolutions[index].name, /*live=*/true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#ifndef QUESTSHOCK_QS_CONFIG_H
|
||||
#define QUESTSHOCK_QS_CONFIG_H
|
||||
|
||||
#include <SDL.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// Persistent, human-editable JSON config file - see CONFIG.md at the repo
|
||||
// root for the documented schema. Lives at a plain relative path
|
||||
// ("questshock.config.json") since the whole engine already runs with its
|
||||
// cwd chdir()'d to the user-facing /sdcard/questshock/ folder (see
|
||||
// QuestShockActivity.java's nativeChdir()) before main() ever runs - the
|
||||
// same way prefs.txt/keybinds.txt (Prefs.c) already do.
|
||||
//
|
||||
// Deliberately independent of prefs.txt: this file is never read or written
|
||||
// as a side effect of prefs.txt load/save, and vice versa.
|
||||
|
||||
// Parses the config file if it exists (silent no-op if it doesn't - this
|
||||
// never creates the file itself) and applies every recognized option
|
||||
// directly into gShockPrefs/mode_id. Call once, early in main() - see
|
||||
// android/engine-patches/21-android-config.patch - after LoadPrefs() (so the
|
||||
// config file can override prefs.txt) and before init_all() (so there's no
|
||||
// live-vs-restart distinction to worry about: nothing is initialized yet, so
|
||||
// every field takes full effect as part of this same startup).
|
||||
void qs_config_load_startup(void);
|
||||
|
||||
// Called from the "Save Configuration"/"Load Configuration" button JNI
|
||||
// handlers (questshock_native.c), which run on Android's UI thread. These
|
||||
// only enqueue a custom SDL event - the actual file I/O and any
|
||||
// gShockPrefs/mode_id/message_info() work happens on the engine's own SDL
|
||||
// main thread instead (see qs_config_handle_event() below), matching this
|
||||
// codebase's existing convention for crossing this exact thread boundary
|
||||
// (xr_session.c's xr_poll_events(), questshock_native.c's SDL_TEXTINPUT
|
||||
// push for keyboard input).
|
||||
void qs_config_request_save(void);
|
||||
void qs_config_request_load(void);
|
||||
|
||||
// Same cross-thread-dispatch reasoning as qs_config_request_save()/_load()
|
||||
// above - called from the Video Options tab's controls (MenuOverlay.java),
|
||||
// which are just as much on Android's UI thread as the Save/Load buttons.
|
||||
// opengl=true/false selects "opengl"/"software"; resolution_index is 0-4,
|
||||
// indexing the same table qs_config_current_resolution_index() below
|
||||
// reads. Applies live immediately (no restart involved - see CONFIG.md);
|
||||
// unlike Load Configuration, these don't post a message_info() HUD
|
||||
// notification for every single tap - the tab's own selection highlight is
|
||||
// the feedback.
|
||||
void qs_config_request_set_renderer(bool opengl);
|
||||
void qs_config_request_set_resolution(int resolution_index);
|
||||
|
||||
// Returns true and handles the event if it's one of the four custom events
|
||||
// qs_config_request_save()/_load()/_set_renderer()/_set_resolution()
|
||||
// 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 four custom types have even been registered.
|
||||
bool qs_config_handle_event(const SDL_Event *event);
|
||||
|
||||
// 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.
|
||||
// the Ctrl+G hotkey or Load Configuration). Safe to call directly from
|
||||
// Android's UI thread despite not going through the event-queue dispatch
|
||||
// the mutating calls above use - same low-risk reasoning already used for
|
||||
// Save Configuration's own reads of gShockPrefs/mode_id: these are simple,
|
||||
// independent POD scalar reads, not a sequence that needs to be atomic
|
||||
// with anything else.
|
||||
bool qs_config_current_renderer_is_opengl(void);
|
||||
int qs_config_current_resolution_index(void);
|
||||
|
||||
// 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
|
||||
// language change made mid-session via the game's own options menu is
|
||||
// picked up without needing to reopen either panel. Same
|
||||
// off-thread-scalar-read reasoning as the two getters above.
|
||||
int qs_config_current_language(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // QUESTSHOCK_QS_CONFIG_H
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include <SDL.h>
|
||||
|
||||
#include "qs_config.h"
|
||||
#include "xr_overlay.h"
|
||||
#include "xr_session.h"
|
||||
|
||||
@@ -88,3 +89,70 @@ JNIEXPORT void JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeShowKeyboard(JNIEnv *env, jclass clazz) {
|
||||
xr_overlay_set_visible(xr_session_get_keyboard_overlay(), true);
|
||||
}
|
||||
|
||||
// MenuOverlay's "Save Configuration"/"Load Configuration" buttons - see
|
||||
// qs_config.h for why these only enqueue an event rather than doing the
|
||||
// actual file/engine-state work here directly (this runs on Android's UI
|
||||
// thread, not the engine's own).
|
||||
JNIEXPORT void JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSaveConfig(JNIEnv *env, jclass clazz) {
|
||||
qs_config_request_save();
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeRequestLoadConfig(JNIEnv *env, jclass clazz) {
|
||||
qs_config_request_load();
|
||||
}
|
||||
|
||||
// MenuOverlay's Video Options tab - live-appliable controls, see
|
||||
// qs_config.h. Same enqueue-only reasoning as the Save/Load buttons above.
|
||||
JNIEXPORT void JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetRenderer(JNIEnv *env, jclass clazz,
|
||||
jboolean opengl) {
|
||||
qs_config_request_set_renderer(opengl);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetResolution(JNIEnv *env, jclass clazz,
|
||||
jint resolutionIndex) {
|
||||
qs_config_request_set_resolution(resolutionIndex);
|
||||
}
|
||||
|
||||
// Read-only queries - safe to call directly (no enqueue needed, see
|
||||
// qs_config.h's own comment on each of these).
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeGetRendererIsOpenGl(JNIEnv *env, jclass clazz) {
|
||||
return qs_config_current_renderer_is_opengl();
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeGetResolutionIndex(JNIEnv *env, jclass clazz) {
|
||||
return qs_config_current_resolution_index();
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeGetLanguage(JNIEnv *env, jclass clazz) {
|
||||
return qs_config_current_language();
|
||||
}
|
||||
|
||||
// MenuOverlay's header - polled on a periodic timer (see its own comment),
|
||||
// not event-driven, so these are plain synchronous reads too.
|
||||
JNIEXPORT jfloat JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeGetFps(JNIEnv *env, jclass clazz) {
|
||||
return xr_session_get_fps();
|
||||
}
|
||||
|
||||
JNIEXPORT jfloat JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeGetFrameTimeMs(JNIEnv *env, jclass clazz) {
|
||||
return xr_session_get_frame_ms();
|
||||
}
|
||||
|
||||
JNIEXPORT jfloat JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeGetCpuUtilization(JNIEnv *env, jclass clazz) {
|
||||
return xr_session_get_cpu_utilization();
|
||||
}
|
||||
|
||||
JNIEXPORT jfloat JNICALL
|
||||
Java_de_ladkau_questshock_MenuOverlay_nativeGetGpuUtilization(JNIEnv *env, jclass clazz) {
|
||||
return xr_session_get_gpu_utilization();
|
||||
}
|
||||
|
||||
@@ -26,17 +26,24 @@ static const float kHandColor[2][4] = {
|
||||
{1.0f, 0.85f, 0.1f, 1.0f}, // right: amber
|
||||
};
|
||||
|
||||
// The keyboard overlay's title bar (drag handle + Close button) is handled
|
||||
// entirely here, before a hit would normally be dispatched to
|
||||
// KeyboardOverlay as a touch - these fractions must agree with
|
||||
// KeyboardOverlay.java's TITLE_BAR_HEIGHT/CLOSE_BUTTON_WIDTH constants (in
|
||||
// pixels, out of KeyboardOverlay.WIDTH/HEIGHT there). A hit in the top
|
||||
// TITLE_BAR_V_FRACTION of the quad, at or left of CLOSE_BUTTON_U_FRACTION,
|
||||
// is the drag handle; right of that (still within the title bar) is the
|
||||
// Close button, which is dispatched as an ordinary click instead. The menu
|
||||
// launcher overlay has no title bar - it's a plain click-only panel.
|
||||
#define TITLE_BAR_V_FRACTION (90.0f / 768.0f)
|
||||
#define CLOSE_BUTTON_U_FRACTION (1.0f - 160.0f / 1024.0f)
|
||||
// Both overlays' title bars (drag handle, plus the keyboard's Close button)
|
||||
// are handled entirely here, before a hit would normally be dispatched to
|
||||
// the Java side as a touch. A hit in the top *_TITLE_BAR_V_FRACTION of a
|
||||
// quad, at or left of its own drag-handle-U-fraction, is the drag handle;
|
||||
// right of that (still within the title bar) is dispatched as an ordinary
|
||||
// click instead - the keyboard's Close button, or (the menu has no such
|
||||
// button - MENU_DRAG_HANDLE_U_FRACTION is 1.0f, so its whole title bar
|
||||
// width is the drag handle) nothing.
|
||||
//
|
||||
// KEYBOARD_TITLE_BAR_V_FRACTION/KEYBOARD_CLOSE_BUTTON_U_FRACTION must agree
|
||||
// with KeyboardOverlay.java's TITLE_BAR_HEIGHT/CLOSE_BUTTON_WIDTH constants
|
||||
// (in pixels, out of KeyboardOverlay.WIDTH/HEIGHT there);
|
||||
// MENU_TITLE_BAR_V_FRACTION with MenuOverlay.java's HEADER_HEIGHT (out of
|
||||
// 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_DRAG_HANDLE_U_FRACTION 1.0f
|
||||
|
||||
static XrInstance g_instance = XR_NULL_HANDLE;
|
||||
static XrSession g_session = XR_NULL_HANDLE;
|
||||
@@ -75,17 +82,24 @@ static bool g_menu_touch_active[2] = {false, false};
|
||||
static bool g_keyboard_prev_hit[2] = {false, false};
|
||||
static bool g_keyboard_touch_active[2] = {false, false};
|
||||
|
||||
// Drag state for repositioning the keyboard overlay via its title bar (see
|
||||
// TITLE_BAR_V_FRACTION/CLOSE_BUTTON_U_FRACTION above) - the menu launcher
|
||||
// has no title bar, so it's never draggable. g_keyboard_drag_hand is -1
|
||||
// when g_keyboard_dragging is false. The offset is the vector from the
|
||||
// quad's center to the grabbed point at drag-start, kept constant for the
|
||||
// whole gesture so the quad follows the hand rigidly rather than
|
||||
// re-centering under the ray each frame.
|
||||
static bool g_keyboard_dragging = false;
|
||||
static int g_keyboard_drag_hand = -1;
|
||||
static float g_keyboard_drag_offset_x = 0.0f;
|
||||
static float g_keyboard_drag_offset_y = 0.0f;
|
||||
// Drag state for repositioning an overlay via its title bar (see the
|
||||
// *_TITLE_BAR_V_FRACTION/*_DRAG_HANDLE_U_FRACTION or
|
||||
// *_CLOSE_BUTTON_U_FRACTION defines above). hand is -1 when dragging is
|
||||
// false. offset_x/offset_y is the vector from the quad's center to the
|
||||
// grabbed point at drag-start, kept constant for the whole gesture so the
|
||||
// quad follows the hand rigidly rather than re-centering under the ray
|
||||
// each frame. Each draggable overlay (keyboard, menu) gets its own
|
||||
// instance so they can be dragged independently/simultaneously by
|
||||
// different hands.
|
||||
typedef struct {
|
||||
bool dragging;
|
||||
int hand;
|
||||
float offset_x;
|
||||
float offset_y;
|
||||
} XrDragState;
|
||||
|
||||
static XrDragState g_keyboard_drag = {false, -1, 0.0f, 0.0f};
|
||||
static XrDragState g_menu_drag = {false, -1, 0.0f, 0.0f};
|
||||
|
||||
// A "view" reference space, located once per frame (not per hand) to get
|
||||
// an approximate head position for the laser-beam billboard math below -
|
||||
@@ -341,17 +355,21 @@ bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_for
|
||||
|
||||
// Hit-tests/dispatches this hand's ray against one overlay quad - shared
|
||||
// by the menu launcher and keyboard overlays in xr_input_sync_and_draw()
|
||||
// below. dragCapable enables the keyboard-only title-bar/drag-handle
|
||||
// handling (see TITLE_BAR_V_FRACTION/CLOSE_BUTTON_U_FRACTION); the menu
|
||||
// launcher has no title bar, so it's always false there and every hit is a
|
||||
// plain click. Returns true only when this hand's ray is actually
|
||||
// relevant to this overlay this frame - landing on it now, or continuing
|
||||
// a touch/drag begun on a previous frame for this hand that hasn't been
|
||||
// released yet - not merely because the overlay is visible. The caller
|
||||
// should only stop trying other targets (menu/keyboard/game quad) when
|
||||
// this returns true; a visible-but-unclaimed overlay falls through so a
|
||||
// lower-priority target (ultimately the game quad) can still be aimed at.
|
||||
static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand,
|
||||
// below. titleBarVFraction/dragHandleUFraction and dragState select the
|
||||
// per-overlay title-bar/drag-handle geometry and drag bookkeeping (see the
|
||||
// *_TITLE_BAR_V_FRACTION/*_DRAG_HANDLE_U_FRACTION/*_CLOSE_BUTTON_U_FRACTION
|
||||
// defines above and g_keyboard_drag/g_menu_drag) - both overlays are
|
||||
// draggable via their title bar, and any hit below titleBarVFraction is
|
||||
// always a plain click regardless of overlay. Returns true only when this
|
||||
// hand's ray is actually relevant to this overlay this frame - landing on
|
||||
// it now, or continuing a touch/drag begun on a previous frame for this
|
||||
// hand that hasn't been released yet - not merely because the overlay is
|
||||
// visible. The caller should only stop trying other targets (menu/
|
||||
// keyboard/game quad) when this returns true; a visible-but-unclaimed
|
||||
// overlay falls through so a lower-priority target (ultimately the game
|
||||
// quad) can still be aimed at.
|
||||
static bool xr_input_try_overlay(XrOverlay *overlay, float titleBarVFraction,
|
||||
float dragHandleUFraction, XrDragState *dragState, int hand,
|
||||
const XrSpaceLocation *location, float fx, float fy, float fz,
|
||||
bool selectDownEdge, bool selectUpEdge, bool *touchActive,
|
||||
bool *prevHit, const char *quadName, float *outCursorU,
|
||||
@@ -367,7 +385,7 @@ static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand,
|
||||
// the ray this frame even if it has strayed off the panel's current
|
||||
// bounds - see `claims` below.
|
||||
bool hadTouch = *touchActive;
|
||||
bool hadDrag = dragCapable && g_keyboard_dragging && g_keyboard_drag_hand == hand;
|
||||
bool hadDrag = dragState->dragging && dragState->hand == hand;
|
||||
|
||||
float quadCenterX, quadCenterY, distance, halfWidth, halfHeight;
|
||||
xr_overlay_get_quad_extent(overlay, &quadCenterX, &quadCenterY, &distance, &halfWidth,
|
||||
@@ -417,34 +435,23 @@ static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand,
|
||||
// it's visible.
|
||||
bool claims = hit || hadTouch || hadDrag;
|
||||
|
||||
if (!dragCapable) {
|
||||
if (selectDownEdge && hit) {
|
||||
xr_overlay_touch(overlay, u, v, true);
|
||||
*touchActive = true;
|
||||
} else if (selectUpEdge && *touchActive) {
|
||||
xr_overlay_touch(overlay, u, v, false);
|
||||
*touchActive = false;
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
|
||||
bool onTitleBar = hit && v < TITLE_BAR_V_FRACTION;
|
||||
bool onDragHandle = onTitleBar && u <= CLOSE_BUTTON_U_FRACTION;
|
||||
bool onTitleBar = hit && v < titleBarVFraction;
|
||||
bool onDragHandle = onTitleBar && u <= dragHandleUFraction;
|
||||
|
||||
if (selectDownEdge) {
|
||||
if (onDragHandle) {
|
||||
g_keyboard_dragging = true;
|
||||
g_keyboard_drag_hand = hand;
|
||||
g_keyboard_drag_offset_x = worldX - quadCenterX;
|
||||
g_keyboard_drag_offset_y = worldY - quadCenterY;
|
||||
dragState->dragging = true;
|
||||
dragState->hand = hand;
|
||||
dragState->offset_x = worldX - quadCenterX;
|
||||
dragState->offset_y = worldY - quadCenterY;
|
||||
} else if (hit) {
|
||||
xr_overlay_touch(overlay, u, v, true);
|
||||
*touchActive = true;
|
||||
}
|
||||
} else if (selectUpEdge) {
|
||||
if (g_keyboard_dragging && g_keyboard_drag_hand == hand) {
|
||||
g_keyboard_dragging = false;
|
||||
g_keyboard_drag_hand = -1;
|
||||
if (dragState->dragging && dragState->hand == hand) {
|
||||
dragState->dragging = false;
|
||||
dragState->hand = -1;
|
||||
} else if (*touchActive) {
|
||||
xr_overlay_touch(overlay, u, v, false);
|
||||
*touchActive = false;
|
||||
@@ -454,9 +461,9 @@ static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand,
|
||||
// Re-intersects against the same fixed-distance plane every frame the
|
||||
// drag continues, so the quad tracks the hand in real time rather than
|
||||
// only jumping on the down/up edges above.
|
||||
if (g_keyboard_dragging && g_keyboard_drag_hand == hand && planeHit) {
|
||||
xr_overlay_set_position(overlay, worldX - g_keyboard_drag_offset_x,
|
||||
worldY - g_keyboard_drag_offset_y);
|
||||
if (dragState->dragging && dragState->hand == hand && planeHit) {
|
||||
xr_overlay_set_position(overlay, worldX - dragState->offset_x,
|
||||
worldY - dragState->offset_y);
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
@@ -630,8 +637,12 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
|
||||
// A drag can't get stuck active across a hide/reopen - e.g. Close
|
||||
// being hit on one hand mid-drag on the other, or the session ending.
|
||||
if (!keyboardVisible) {
|
||||
g_keyboard_dragging = false;
|
||||
g_keyboard_drag_hand = -1;
|
||||
g_keyboard_drag.dragging = false;
|
||||
g_keyboard_drag.hand = -1;
|
||||
}
|
||||
if (!menuVisible) {
|
||||
g_menu_drag.dragging = false;
|
||||
g_menu_drag.hand = -1;
|
||||
}
|
||||
|
||||
// A hand only ever gets a laser-beam quad this frame if it actually
|
||||
@@ -813,11 +824,12 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
|
||||
bool keyboardPlaneHit = false;
|
||||
float keyboardPlaneDistance = 0.0f;
|
||||
if (!g_game_touch_active[hand] &&
|
||||
xr_input_try_overlay(keyboardOverlay, true, hand, &location, fx, fy, fz,
|
||||
selectDownEdge, selectUpEdge, &g_keyboard_touch_active[hand],
|
||||
&g_keyboard_prev_hit[hand], "keyboard", &keyboardCursorU,
|
||||
&keyboardCursorV, &keyboardCursorHit, &keyboardPlaneHit,
|
||||
&keyboardPlaneDistance)) {
|
||||
xr_input_try_overlay(keyboardOverlay, KEYBOARD_TITLE_BAR_V_FRACTION,
|
||||
KEYBOARD_CLOSE_BUTTON_U_FRACTION, &g_keyboard_drag, hand,
|
||||
&location, fx, fy, fz, selectDownEdge, selectUpEdge,
|
||||
&g_keyboard_touch_active[hand], &g_keyboard_prev_hit[hand],
|
||||
"keyboard", &keyboardCursorU, &keyboardCursorV,
|
||||
&keyboardCursorHit, &keyboardPlaneHit, &keyboardPlaneDistance)) {
|
||||
if (haveHead && keyboardPlaneHit)
|
||||
xr_input_build_beam(hand, &location, fx, fy, fz,
|
||||
fminf(keyboardPlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc);
|
||||
@@ -826,10 +838,11 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
|
||||
bool menuPlaneHit = false;
|
||||
float menuPlaneDistance = 0.0f;
|
||||
if (!g_game_touch_active[hand] &&
|
||||
xr_input_try_overlay(menuOverlay, false, hand, &location, fx, fy, fz, selectDownEdge,
|
||||
selectUpEdge, &g_menu_touch_active[hand], &g_menu_prev_hit[hand],
|
||||
"menu", &menuCursorU, &menuCursorV, &menuCursorHit, &menuPlaneHit,
|
||||
&menuPlaneDistance)) {
|
||||
xr_input_try_overlay(menuOverlay, MENU_TITLE_BAR_V_FRACTION,
|
||||
MENU_DRAG_HANDLE_U_FRACTION, &g_menu_drag, hand, &location, fx,
|
||||
fy, fz, selectDownEdge, selectUpEdge, &g_menu_touch_active[hand],
|
||||
&g_menu_prev_hit[hand], "menu", &menuCursorU, &menuCursorV,
|
||||
&menuCursorHit, &menuPlaneHit, &menuPlaneDistance)) {
|
||||
if (haveHead && menuPlaneHit)
|
||||
xr_input_build_beam(hand, &location, fx, fy, fz,
|
||||
fminf(menuPlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc);
|
||||
@@ -920,6 +933,8 @@ void xr_input_shutdown(void) {
|
||||
g_prev_menu = false;
|
||||
g_prev_space_key = false;
|
||||
g_prev_forward_key = false;
|
||||
g_keyboard_dragging = false;
|
||||
g_keyboard_drag_hand = -1;
|
||||
g_keyboard_drag.dragging = false;
|
||||
g_keyboard_drag.hand = -1;
|
||||
g_menu_drag.dragging = false;
|
||||
g_menu_drag.hand = -1;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "xr_session.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
@@ -39,8 +40,14 @@
|
||||
// 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"
|
||||
#define MENU_WIDTH 512
|
||||
#define MENU_HEIGHT 256
|
||||
// 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()).
|
||||
#define MENU_WIDTH 768
|
||||
#define MENU_HEIGHT 552
|
||||
#define MENU_DISTANCE_METERS 1.3f
|
||||
#define MENU_WIDTH_METERS 0.35f
|
||||
#define MENU_DEFAULT_CENTER_X_METERS 0.0f
|
||||
@@ -79,6 +86,22 @@ static XrTime g_predicted_display_time = 0;
|
||||
static bool g_frame_should_render = false;
|
||||
static bool g_have_acquired_game_image = false;
|
||||
|
||||
// XR_META_performance_metrics support (see xr_session_init_performance_metrics()
|
||||
// below) - only present if the runtime advertised it at xrCreateInstance
|
||||
// time (checked via xr_instance_extension_supported()), since unlike the
|
||||
// two always-required KHR extensions above, this one is a device-capability
|
||||
// extra that a build shouldn't hard-depend on.
|
||||
static bool g_performance_metrics_supported = false;
|
||||
static PFN_xrSetPerformanceMetricsStateMETA g_xrSetPerformanceMetricsStateMETA = NULL;
|
||||
static PFN_xrQueryPerformanceMetricsCounterMETA g_xrQueryPerformanceMetricsCounterMETA = NULL;
|
||||
// Resolved once, at startup, by string-matching the runtime's own
|
||||
// human-readable counter path names (see xr_session_init_performance_metrics())
|
||||
// rather than hardcoding the exact path strings the spec defines - a
|
||||
// substring match is robust to spec/runtime wording we can't verify without
|
||||
// on-device testing. XR_NULL_PATH (0) if no match was found.
|
||||
static XrPath g_cpu_utilization_path = XR_NULL_PATH;
|
||||
static XrPath g_gpu_utilization_path = XR_NULL_PATH;
|
||||
|
||||
static bool xr_check(XrResult result, const char *what) {
|
||||
if (XR_SUCCEEDED(result))
|
||||
return true;
|
||||
@@ -99,6 +122,106 @@ static PFN_xrVoidFunction xr_get_proc(const char *name) {
|
||||
return fn;
|
||||
}
|
||||
|
||||
// Queried before xrCreateInstance (which is why this takes no instance
|
||||
// handle - xrEnumerateInstanceExtensionProperties is one of the few
|
||||
// functions that works without one) to decide whether it's safe to add
|
||||
// `name` to enabledExtensionNames - passing an unsupported extension there
|
||||
// would fail instance creation outright, unlike a missing optional feature
|
||||
// discovered afterward.
|
||||
static bool xr_instance_extension_supported(const char *name) {
|
||||
uint32_t count = 0;
|
||||
if (!xr_check(xrEnumerateInstanceExtensionProperties(NULL, 0, &count, NULL),
|
||||
"xrEnumerateInstanceExtensionProperties"))
|
||||
return false;
|
||||
XrExtensionProperties *props = (XrExtensionProperties *)malloc(sizeof(XrExtensionProperties) * count);
|
||||
for (uint32_t i = 0; i < count; i++)
|
||||
props[i].type = XR_TYPE_EXTENSION_PROPERTIES;
|
||||
bool supported = false;
|
||||
if (xr_check(xrEnumerateInstanceExtensionProperties(NULL, count, &count, props),
|
||||
"xrEnumerateInstanceExtensionProperties")) {
|
||||
for (uint32_t i = 0; i < count; i++) {
|
||||
if (strcmp(props[i].extensionName, name) == 0) {
|
||||
supported = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
free(props);
|
||||
return supported;
|
||||
}
|
||||
|
||||
// Case-insensitively checks whether `needle` occurs anywhere in `haystack` -
|
||||
// used to identify a counter path (e.g. ".../device/cpu_utilization_average")
|
||||
// by a short, stable substring instead of matching its exact full spelling.
|
||||
static bool xr_path_name_contains(const char *haystack, const char *needle) {
|
||||
size_t hlen = strlen(haystack);
|
||||
char lower[256];
|
||||
if (hlen >= sizeof(lower))
|
||||
hlen = sizeof(lower) - 1;
|
||||
for (size_t i = 0; i < hlen; i++)
|
||||
lower[i] = (char)tolower((unsigned char)haystack[i]);
|
||||
lower[hlen] = '\0';
|
||||
return strstr(lower, needle) != NULL;
|
||||
}
|
||||
|
||||
// Enables XR_META_performance_metrics (if the runtime advertised it - see
|
||||
// xr_instance_extension_supported() above) and resolves the CPU/GPU
|
||||
// device-utilization counter paths xr_session_update_frame_stats() polls
|
||||
// every frame. Called once, right after xrCreateSession succeeds. Leaves
|
||||
// g_cpu_utilization_path/g_gpu_utilization_path at XR_NULL_PATH (their
|
||||
// getters then report -1.0f) if anything here doesn't pan out - this is a
|
||||
// nice-to-have stats display, not something worth failing session bring-up
|
||||
// over.
|
||||
static void xr_session_init_performance_metrics(void) {
|
||||
if (!g_performance_metrics_supported)
|
||||
return;
|
||||
|
||||
PFN_xrEnumeratePerformanceMetricsCounterPathsMETA enumeratePaths =
|
||||
(PFN_xrEnumeratePerformanceMetricsCounterPathsMETA)xr_get_proc(
|
||||
"xrEnumeratePerformanceMetricsCounterPathsMETA");
|
||||
g_xrSetPerformanceMetricsStateMETA = (PFN_xrSetPerformanceMetricsStateMETA)xr_get_proc(
|
||||
"xrSetPerformanceMetricsStateMETA");
|
||||
g_xrQueryPerformanceMetricsCounterMETA = (PFN_xrQueryPerformanceMetricsCounterMETA)xr_get_proc(
|
||||
"xrQueryPerformanceMetricsCounterMETA");
|
||||
if (enumeratePaths == NULL || g_xrSetPerformanceMetricsStateMETA == NULL ||
|
||||
g_xrQueryPerformanceMetricsCounterMETA == NULL)
|
||||
return;
|
||||
|
||||
XrPerformanceMetricsStateMETA state = {XR_TYPE_PERFORMANCE_METRICS_STATE_META};
|
||||
state.enabled = XR_TRUE;
|
||||
if (!xr_check(g_xrSetPerformanceMetricsStateMETA(g_session, &state),
|
||||
"xrSetPerformanceMetricsStateMETA"))
|
||||
return;
|
||||
|
||||
uint32_t pathCount = 0;
|
||||
if (!xr_check(enumeratePaths(g_instance, 0, &pathCount, NULL),
|
||||
"xrEnumeratePerformanceMetricsCounterPathsMETA"))
|
||||
return;
|
||||
XrPath *paths = (XrPath *)malloc(sizeof(XrPath) * pathCount);
|
||||
if (!xr_check(enumeratePaths(g_instance, pathCount, &pathCount, paths),
|
||||
"xrEnumeratePerformanceMetricsCounterPathsMETA")) {
|
||||
free(paths);
|
||||
return;
|
||||
}
|
||||
|
||||
for (uint32_t i = 0; i < pathCount; i++) {
|
||||
char pathName[256];
|
||||
uint32_t pathNameLen = 0;
|
||||
if (!xr_check(xrPathToString(g_instance, paths[i], sizeof(pathName), &pathNameLen, pathName),
|
||||
"xrPathToString"))
|
||||
continue;
|
||||
if (g_cpu_utilization_path == XR_NULL_PATH && xr_path_name_contains(pathName, "cpu_utilization"))
|
||||
g_cpu_utilization_path = paths[i];
|
||||
if (g_gpu_utilization_path == XR_NULL_PATH && xr_path_name_contains(pathName, "gpu_utilization"))
|
||||
g_gpu_utilization_path = paths[i];
|
||||
}
|
||||
free(paths);
|
||||
|
||||
LOGI("XR: performance metrics cpu_utilization=%s gpu_utilization=%s",
|
||||
g_cpu_utilization_path != XR_NULL_PATH ? "found" : "not found",
|
||||
g_gpu_utilization_path != XR_NULL_PATH ? "found" : "not found");
|
||||
}
|
||||
|
||||
// The Android OpenXR loader needs the JavaVM/context before anything else -
|
||||
// separate from XR_KHR_android_create_instance below, which only affects
|
||||
// xrCreateInstance itself. Skipping this is a common cause of
|
||||
@@ -146,9 +269,13 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
|
||||
if (!xr_initialize_android_loader(vm, activity))
|
||||
return false;
|
||||
|
||||
const char *extensions[] = {
|
||||
g_performance_metrics_supported =
|
||||
xr_instance_extension_supported(XR_META_PERFORMANCE_METRICS_EXTENSION_NAME);
|
||||
|
||||
const char *extensions[3] = {
|
||||
XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME,
|
||||
XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME,
|
||||
XR_META_PERFORMANCE_METRICS_EXTENSION_NAME,
|
||||
};
|
||||
|
||||
XrInstanceCreateInfoAndroidKHR androidInfo = {XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
|
||||
@@ -157,7 +284,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 = sizeof(extensions) / sizeof(extensions[0]);
|
||||
createInfo.enabledExtensionCount = g_performance_metrics_supported ? 3 : 2;
|
||||
createInfo.enabledExtensionNames = extensions;
|
||||
strncpy(createInfo.applicationInfo.applicationName, "QuestShock",
|
||||
XR_MAX_APPLICATION_NAME_SIZE - 1);
|
||||
@@ -206,6 +333,8 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
|
||||
if (!xr_check(xrCreateSession(g_instance, &sessionInfo, &g_session), "xrCreateSession"))
|
||||
return false;
|
||||
|
||||
xr_session_init_performance_metrics();
|
||||
|
||||
XrReferenceSpaceCreateInfo spaceInfo = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
|
||||
spaceInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
|
||||
spaceInfo.poseInReferenceSpace.orientation.w = 1.0f;
|
||||
@@ -354,6 +483,65 @@ bool xr_frame_begin(void) {
|
||||
return g_have_acquired_game_image;
|
||||
}
|
||||
|
||||
// Backs xr_session_get_fps()/get_frame_ms() (see xr_session.h) - exponential
|
||||
// smoothing (rather than a plain per-frame instantaneous value) so
|
||||
// MenuOverlay's header display doesn't visibly jitter every single frame.
|
||||
// Called once per xr_frame_end() that reaches its own end (i.e. once per
|
||||
// real XR frame, whether or not the game itself actually rendered anything
|
||||
// new into it this frame - matches the OpenXR compositor's own frame
|
||||
// cadence, which is what "FPS" means to someone looking at this display).
|
||||
static float s_fps = 0.0f;
|
||||
static float s_frame_ms = 0.0f;
|
||||
static float s_cpu_utilization = -1.0f;
|
||||
static float s_gpu_utilization = -1.0f;
|
||||
|
||||
// Reads one XR_META_performance_metrics counter (if it was found at
|
||||
// startup - see xr_session_init_performance_metrics()) and folds it into
|
||||
// *smoothed with the same exponential smoothing as FPS/frame time above.
|
||||
// Leaves *smoothed at -1.0f (its "unsupported" sentinel) untouched if the
|
||||
// path wasn't found, the query fails, or the runtime didn't mark its
|
||||
// floatValue valid this call.
|
||||
static void xr_session_update_utilization(XrPath path, float *smoothed) {
|
||||
if (path == XR_NULL_PATH || g_xrQueryPerformanceMetricsCounterMETA == NULL)
|
||||
return;
|
||||
XrPerformanceMetricsCounterMETA counter = {XR_TYPE_PERFORMANCE_METRICS_COUNTER_META};
|
||||
if (!xr_check(g_xrQueryPerformanceMetricsCounterMETA(g_session, path, &counter),
|
||||
"xrQueryPerformanceMetricsCounterMETA"))
|
||||
return;
|
||||
if (!(counter.counterFlags & XR_PERFORMANCE_METRICS_COUNTER_FLOAT_VALUE_VALID_BIT_META))
|
||||
return;
|
||||
const float kSmoothing = 0.1f;
|
||||
*smoothed =
|
||||
(*smoothed < 0.0f) ? counter.floatValue : *smoothed * (1.0f - kSmoothing) + counter.floatValue * kSmoothing;
|
||||
}
|
||||
|
||||
static void xr_session_update_frame_stats(void) {
|
||||
static Uint32 s_last_ticks = 0;
|
||||
Uint32 now = SDL_GetTicks();
|
||||
if (s_last_ticks != 0) {
|
||||
Uint32 delta_ms = now - s_last_ticks;
|
||||
if (delta_ms > 0) {
|
||||
float instant_fps = 1000.0f / (float)delta_ms;
|
||||
const float kSmoothing = 0.1f;
|
||||
s_fps = (s_fps == 0.0f) ? instant_fps : s_fps * (1.0f - kSmoothing) + instant_fps * kSmoothing;
|
||||
s_frame_ms =
|
||||
(s_frame_ms == 0.0f) ? (float)delta_ms : s_frame_ms * (1.0f - kSmoothing) + (float)delta_ms * kSmoothing;
|
||||
}
|
||||
}
|
||||
s_last_ticks = now;
|
||||
|
||||
xr_session_update_utilization(g_cpu_utilization_path, &s_cpu_utilization);
|
||||
xr_session_update_utilization(g_gpu_utilization_path, &s_gpu_utilization);
|
||||
}
|
||||
|
||||
float xr_session_get_fps(void) { return s_fps; }
|
||||
|
||||
float xr_session_get_frame_ms(void) { return s_frame_ms; }
|
||||
|
||||
float xr_session_get_cpu_utilization(void) { return s_cpu_utilization; }
|
||||
|
||||
float xr_session_get_gpu_utilization(void) { return s_gpu_utilization; }
|
||||
|
||||
void xr_frame_end(void) {
|
||||
if (g_session == XR_NULL_HANDLE)
|
||||
return;
|
||||
@@ -444,6 +632,8 @@ void xr_frame_end(void) {
|
||||
endInfo.layers = layerCount > 0 ? layers : NULL;
|
||||
|
||||
xr_check(xrEndFrame(g_session, &endInfo), "xrEndFrame");
|
||||
|
||||
xr_session_update_frame_stats();
|
||||
}
|
||||
|
||||
void xr_shutdown(void) {
|
||||
@@ -470,6 +660,12 @@ void xr_shutdown(void) {
|
||||
|
||||
g_session_state = XR_SESSION_STATE_UNKNOWN;
|
||||
g_session_running = false;
|
||||
|
||||
g_performance_metrics_supported = false;
|
||||
g_xrSetPerformanceMetricsStateMETA = NULL;
|
||||
g_xrQueryPerformanceMetricsCounterMETA = NULL;
|
||||
g_cpu_utilization_path = XR_NULL_PATH;
|
||||
g_gpu_utilization_path = XR_NULL_PATH;
|
||||
}
|
||||
|
||||
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m) {
|
||||
|
||||
@@ -83,6 +83,21 @@ void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half
|
||||
// swapchain.
|
||||
void xr_get_game_resolution(int *width, int *height);
|
||||
|
||||
// Exponentially-smoothed frame rate/frame time, updated once per real
|
||||
// rendered frame in xr_frame_end() - read by MenuOverlay's header (see
|
||||
// questshock_native.c's nativeGetFps()/nativeGetFrameTimeMs()). 0 before
|
||||
// the second frame has run (nothing to compute a delta from yet).
|
||||
float xr_session_get_fps(void);
|
||||
float xr_session_get_frame_ms(void);
|
||||
|
||||
// Device-wide CPU/GPU utilization percentage (0-100), via the
|
||||
// XR_META_performance_metrics extension - same per-frame update cadence as
|
||||
// the FPS/frame-time getters above. Returns -1.0f if the extension isn't
|
||||
// supported by the runtime, or its cpu_utilization/gpu_utilization counter
|
||||
// couldn't be found among the ones it advertises.
|
||||
float xr_session_get_cpu_utilization(void);
|
||||
float xr_session_get_gpu_utilization(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -59,11 +59,12 @@ public class KeyboardOverlay extends OverlayPanel {
|
||||
private static final int TYPED_TEXT_HEIGHT = 100;
|
||||
|
||||
// Height of the title bar (buildTitleBar()) and width of its Close
|
||||
// button - xr_input.c's TITLE_BAR_V_FRACTION/CLOSE_BUTTON_U_FRACTION
|
||||
// must be kept in sync with these (as fractions of WIDTH/HEIGHT above),
|
||||
// since the drag-handle-vs-Close-button-vs-rest-of-the-keyboard
|
||||
// decision happens entirely on the native side, before a hit ever
|
||||
// reaches this class's own touch dispatch.
|
||||
// button - xr_input.c's KEYBOARD_TITLE_BAR_V_FRACTION/
|
||||
// KEYBOARD_CLOSE_BUTTON_U_FRACTION must be kept in sync with these (as
|
||||
// fractions of WIDTH/HEIGHT above), since the drag-handle-vs-Close-
|
||||
// button-vs-rest-of-the-keyboard decision happens entirely on the
|
||||
// native side, before a hit ever reaches this class's own touch
|
||||
// dispatch.
|
||||
private static final int TITLE_BAR_HEIGHT = 90;
|
||||
private static final int CLOSE_BUTTON_WIDTH = 160;
|
||||
|
||||
@@ -76,6 +77,17 @@ public class KeyboardOverlay extends OverlayPanel {
|
||||
private TextView typedTextView;
|
||||
private final StringBuilder typedText = new StringBuilder();
|
||||
|
||||
// Title bar chrome text - the only pieces of this panel that get
|
||||
// translated (see Strings.java's own comment on why the actual key
|
||||
// labels don't). Set from MenuOverlay.nativeGetLanguage() at the top of
|
||||
// buildContent() (see OverlayPanel's constructor note on why a field a
|
||||
// subclass's buildContent() depends on can't rely on normal field-
|
||||
// initializer timing) and kept in sync afterward by applyTranslations()
|
||||
// below, called from MenuOverlay's own periodic language-refresh check.
|
||||
private int currentLanguage = Strings.ENGLISH;
|
||||
private TextView titleLabel;
|
||||
private Button closeButton;
|
||||
|
||||
// Shift is a local-only layer toggle (never itself sent to the game -
|
||||
// see updateShiftVisual()); Ctrl/Alt are one-shot "armed" modifiers,
|
||||
// sent to the game the moment they're pressed and released again as
|
||||
@@ -142,6 +154,7 @@ public class KeyboardOverlay extends OverlayPanel {
|
||||
@Override
|
||||
protected View buildContent() {
|
||||
charKeys = new ArrayList<>();
|
||||
currentLanguage = MenuOverlay.nativeGetLanguage();
|
||||
|
||||
LinearLayout panel = new LinearLayout(activity);
|
||||
panel.setOrientation(LinearLayout.VERTICAL);
|
||||
@@ -214,26 +227,31 @@ public class KeyboardOverlay extends OverlayPanel {
|
||||
// xr_input.c before a hit ever reaches here) plus a real Close button,
|
||||
// which is dispatched as an ordinary click like any other key. Its
|
||||
// height and the Close button's width must stay in sync with
|
||||
// xr_input.c's TITLE_BAR_V_FRACTION/CLOSE_BUTTON_U_FRACTION.
|
||||
// xr_input.c's KEYBOARD_TITLE_BAR_V_FRACTION/
|
||||
// KEYBOARD_CLOSE_BUTTON_U_FRACTION.
|
||||
private LinearLayout buildTitleBar() {
|
||||
LinearLayout bar = new LinearLayout(activity);
|
||||
bar.setOrientation(LinearLayout.HORIZONTAL);
|
||||
bar.setBackgroundColor(0xFF3A3A3A);
|
||||
// Matches MenuOverlay's own header bar style.
|
||||
bar.setBackgroundColor(0xFF000080);
|
||||
|
||||
TextView label = new TextView(activity);
|
||||
label.setText("Keyboard - drag here to move");
|
||||
label.setTextColor(0xFFFFFFFF);
|
||||
label.setTextSize(TypedValue.COMPLEX_UNIT_SP, 22);
|
||||
label.setGravity(Gravity.CENTER_VERTICAL);
|
||||
label.setPadding(24, 0, 0, 0);
|
||||
bar.addView(label, new LinearLayout.LayoutParams(
|
||||
titleLabel = new TextView(activity);
|
||||
titleLabel.setText(Strings.get("keyboard_drag_title", currentLanguage));
|
||||
titleLabel.setTextColor(0xFFFFFFFF);
|
||||
titleLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
|
||||
titleLabel.setGravity(Gravity.CENTER_VERTICAL);
|
||||
titleLabel.setPadding(24, 0, 0, 0);
|
||||
bar.addView(titleLabel, new LinearLayout.LayoutParams(
|
||||
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
|
||||
|
||||
Button close = new Button(activity);
|
||||
close.setText("Close");
|
||||
close.setOnClickListener(v -> nativeRequestClose());
|
||||
close.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
||||
bar.addView(close,
|
||||
closeButton = new Button(activity);
|
||||
closeButton.setText(Strings.get("close", currentLanguage));
|
||||
closeButton.setOnClickListener(v -> nativeRequestClose());
|
||||
closeButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
||||
closeButton.setBackground(buttonBackground());
|
||||
closeButton.setTextColor(buttonTextColors());
|
||||
registerHoverable(closeButton);
|
||||
bar.addView(closeButton,
|
||||
new LinearLayout.LayoutParams(CLOSE_BUTTON_WIDTH, ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
return bar;
|
||||
@@ -253,6 +271,9 @@ public class KeyboardOverlay extends OverlayPanel {
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
for (Object item : items) {
|
||||
Button key = new Button(activity);
|
||||
key.setBackground(buttonBackground());
|
||||
key.setTextColor(buttonTextColors());
|
||||
registerHoverable(key);
|
||||
float weight;
|
||||
if (item instanceof CharKey) {
|
||||
CharKey charKey = (CharKey) item;
|
||||
@@ -517,6 +538,26 @@ public class KeyboardOverlay extends OverlayPanel {
|
||||
// the keyboard (see this class's doc comment and xr_input.c).
|
||||
private static native void nativeRequestClose();
|
||||
|
||||
// Called from MenuOverlay's own periodic header-refresh timer when it
|
||||
// detects the game's language preference changed mid-session (via its
|
||||
// own options menu) - re-labels this panel's title bar chrome to match.
|
||||
// A plain Java call (both classes are in this same package), not JNI -
|
||||
// runOnUiThread() here is defensive/self-documenting more than
|
||||
// strictly needed today, since MenuOverlay's own timer already runs on
|
||||
// the UI thread itself (Activity.runOnUiThread() is a no-op wrapper
|
||||
// when already on that thread).
|
||||
public static void applyTranslations(final int language) {
|
||||
if (instance == null) {
|
||||
return;
|
||||
}
|
||||
instance.activity.runOnUiThread(() -> {
|
||||
instance.currentLanguage = language;
|
||||
instance.titleLabel.setText(Strings.get("keyboard_drag_title", language));
|
||||
instance.closeButton.setText(Strings.get("close", language));
|
||||
instance.redrawContent();
|
||||
});
|
||||
}
|
||||
|
||||
// Called from xr_overlay.c's xr_overlay_init_jni(), on the render
|
||||
// thread, right after it resolves this class via the activity's own
|
||||
// ClassLoader (plain FindClass() can't see app classes from a thread
|
||||
|
||||
@@ -1,53 +1,529 @@
|
||||
package de.ladkau.questshock;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.ActivityManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.graphics.drawable.StateListDrawable;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.TypedValue;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The small main menu launcher quad (see OverlayPanel for the shared off-
|
||||
* screen-render/touch/cursor plumbing this builds on) - just a "Keyboard"
|
||||
* button for now. Toggled by the controller's menu button (see
|
||||
* xr_input.c); its "Keyboard" click only opens the (fully independent)
|
||||
* keyboard overlay quad via nativeShowKeyboard() - it never touches this
|
||||
* panel's own visibility, so both can be shown together (see
|
||||
* KeyboardOverlay for the keyboard itself).
|
||||
* The main menu launcher quad (see OverlayPanel for the shared off-screen-
|
||||
* render/touch/cursor plumbing this builds on). Toggled by the controller's
|
||||
* menu button (see xr_input.c); none of its buttons ever touch this panel's
|
||||
* own visibility, so it can stay open alongside whatever they open/do.
|
||||
*
|
||||
* Layout, top to bottom (see res/sketch_main_menu.svg): a header (title +
|
||||
* version, FPS/frame-time stats, refreshed on a periodic timer), a content
|
||||
* area holding one tab's content at a time, and a bottom tab bar. Tab 1
|
||||
* ("Main Menu") is the original "Keyboard"/"Save Configuration"/"Load
|
||||
* Configuration" row plus the restart-confirmation view (shown instead of
|
||||
* that row - see CONFIG.md and qs_config.c's *_REQUIRES_RESTART comments).
|
||||
* Tab 2 ("Video Options") has live-appliable controls for the config file's
|
||||
* video_options group, mirroring the same buttons' selection to whatever's
|
||||
* actually active (queried fresh whenever the tab is shown, and on the same
|
||||
* periodic timer as the header stats while it's the visible tab, so e.g.
|
||||
* pressing Ctrl+G while looking at this tab is reflected here too).
|
||||
*
|
||||
* "Keyboard" opens the (fully independent) keyboard overlay quad via
|
||||
* nativeShowKeyboard() - see KeyboardOverlay for the keyboard itself, which
|
||||
* this class also pushes language changes to (see refreshStats()).
|
||||
*/
|
||||
public class MenuOverlay extends OverlayPanel {
|
||||
// Matches xr_session.c's MENU_WIDTH/MENU_HEIGHT swapchain size - fixed,
|
||||
// not tied to any real display metric, since this tree is never shown
|
||||
// in a real window.
|
||||
private static final int WIDTH = 512;
|
||||
private static final int HEIGHT = 256;
|
||||
// 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
|
||||
// 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 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"};
|
||||
|
||||
private static MenuOverlay instance;
|
||||
|
||||
// Kept so relayoutAndRedraw() (see its own comment) can re-measure/
|
||||
// re-layout the same View object OverlayPanel's own private "root"
|
||||
// field also points at - toggling a child's visibility on this
|
||||
// permanently-unattached tree doesn't get a layout pass for free the
|
||||
// way it would on a real window (nothing ever schedules one), so this
|
||||
// class has to force one itself on every toggle.
|
||||
private View root;
|
||||
|
||||
// Header
|
||||
private TextView titleView;
|
||||
private TextView statsView;
|
||||
|
||||
// Content area (tabs) - only one of mainMenuTab/videoOptionsTab is
|
||||
// VISIBLE at a time; currentTab tracks which.
|
||||
private View mainMenuTab;
|
||||
private View videoOptionsTab;
|
||||
private View currentTab;
|
||||
private Button mainMenuTabButton;
|
||||
private Button videoOptionsTabButton;
|
||||
|
||||
// Main Menu tab's own two states (only one VISIBLE at a time, within
|
||||
// mainMenuTab)
|
||||
private LinearLayout buttonRow;
|
||||
private LinearLayout restartDialog;
|
||||
private TextView restartMessageView;
|
||||
private Button keyboardButton;
|
||||
private Button saveButton;
|
||||
private Button loadButton;
|
||||
private Button dismissButton;
|
||||
private Button restartNowButton;
|
||||
|
||||
// Video Options tab controls
|
||||
private TextView rendererLabel;
|
||||
private TextView resolutionLabel;
|
||||
private Button softwareButton;
|
||||
private Button openglButton;
|
||||
private Button[] resolutionButtons;
|
||||
|
||||
private int currentLanguage = Strings.ENGLISH;
|
||||
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
private final Runnable statsRefresh = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
refreshStats();
|
||||
handler.postDelayed(this, STATS_REFRESH_MS);
|
||||
}
|
||||
};
|
||||
|
||||
private MenuOverlay(Activity activity) {
|
||||
super(activity, WIDTH, HEIGHT);
|
||||
handler.postDelayed(statsRefresh, STATS_REFRESH_MS);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected View buildContent() {
|
||||
FrameLayout panel = new FrameLayout(activity);
|
||||
// Read before anything below uses it - buildContent() runs from
|
||||
// OverlayPanel's own constructor, before this subclass's field
|
||||
// initializers have (see OverlayPanel's contentAlpha() comment for
|
||||
// the general caveat this same construction order creates).
|
||||
currentLanguage = nativeGetLanguage();
|
||||
|
||||
LinearLayout panel = new LinearLayout(activity);
|
||||
panel.setOrientation(LinearLayout.VERTICAL);
|
||||
panel.setBackgroundColor(0xFF202020);
|
||||
Button keyboardButton = new Button(activity);
|
||||
keyboardButton.setText("Keyboard");
|
||||
keyboardButton.setOnClickListener(v -> nativeShowKeyboard());
|
||||
keyboardButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
||||
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(300, 100);
|
||||
lp.gravity = Gravity.CENTER;
|
||||
panel.addView(keyboardButton, lp);
|
||||
panel.addView(buildHeader(), new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, HEADER_HEIGHT));
|
||||
|
||||
FrameLayout content = new FrameLayout(activity);
|
||||
mainMenuTab = buildMainMenuTab();
|
||||
videoOptionsTab = buildVideoOptionsTab();
|
||||
content.addView(mainMenuTab, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||
content.addView(videoOptionsTab, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||
videoOptionsTab.setVisibility(View.GONE);
|
||||
currentTab = mainMenuTab;
|
||||
panel.addView(content, new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
|
||||
|
||||
panel.addView(buildTabBar(), new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, TAB_BAR_HEIGHT));
|
||||
|
||||
panel.addView(buildStatsBar(), new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, STATS_BAR_HEIGHT));
|
||||
|
||||
root = panel;
|
||||
return panel;
|
||||
}
|
||||
|
||||
private View buildHeader() {
|
||||
LinearLayout header = new LinearLayout(activity);
|
||||
header.setOrientation(LinearLayout.HORIZONTAL);
|
||||
header.setBackgroundColor(0xFF000080);
|
||||
header.setGravity(Gravity.CENTER_VERTICAL);
|
||||
header.setPadding(16, 0, 16, 0);
|
||||
|
||||
titleView = new TextView(activity);
|
||||
titleView.setTextColor(0xFFFFFFFF);
|
||||
titleView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
|
||||
titleView.setText("Questshock (" + BuildConfig.VERSION_NAME + ")");
|
||||
header.addView(titleView,
|
||||
new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
// A separate ribbon below the tab bar (rather than sharing the header
|
||||
// row with the title, as before) - the FPS/frame-time/CPU/GPU/memory
|
||||
// text got too long for the title bar once CPU/GPU/memory were added,
|
||||
// crowding the title.
|
||||
private View buildStatsBar() {
|
||||
LinearLayout bar = new LinearLayout(activity);
|
||||
bar.setOrientation(LinearLayout.HORIZONTAL);
|
||||
bar.setBackgroundColor(0xFF000080);
|
||||
bar.setGravity(Gravity.CENTER);
|
||||
|
||||
statsView = new TextView(activity);
|
||||
statsView.setTextColor(0xFFFFFFFF);
|
||||
statsView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
|
||||
bar.addView(statsView, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
private View buildMainMenuTab() {
|
||||
FrameLayout tab = new FrameLayout(activity);
|
||||
|
||||
buttonRow = new LinearLayout(activity);
|
||||
buttonRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||
buttonRow.setGravity(Gravity.CENTER);
|
||||
keyboardButton = addRowButton(buttonRow, Strings.get("keyboard", currentLanguage),
|
||||
v -> nativeShowKeyboard());
|
||||
saveButton = addRowButton(buttonRow, Strings.get("save_configuration", currentLanguage),
|
||||
v -> nativeRequestSaveConfig());
|
||||
loadButton = addRowButton(buttonRow, Strings.get("load_configuration", currentLanguage),
|
||||
v -> nativeRequestLoadConfig());
|
||||
FrameLayout.LayoutParams buttonRowParams = new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT);
|
||||
buttonRowParams.gravity = Gravity.CENTER;
|
||||
tab.addView(buttonRow, buttonRowParams);
|
||||
|
||||
restartMessageView = new TextView(activity);
|
||||
restartMessageView.setTextColor(0xFFFFFFFF);
|
||||
restartMessageView.setGravity(Gravity.CENTER);
|
||||
restartMessageView.setPadding(16, 8, 16, 16);
|
||||
|
||||
LinearLayout restartButtonRow = new LinearLayout(activity);
|
||||
restartButtonRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||
restartButtonRow.setGravity(Gravity.CENTER);
|
||||
dismissButton = addRowButton(restartButtonRow, Strings.get("dismiss", currentLanguage),
|
||||
v -> showButtonRow());
|
||||
restartNowButton = addRowButton(restartButtonRow, Strings.get("restart_now", currentLanguage),
|
||||
v -> restartApp());
|
||||
|
||||
restartDialog = new LinearLayout(activity);
|
||||
restartDialog.setOrientation(LinearLayout.VERTICAL);
|
||||
restartDialog.setGravity(Gravity.CENTER);
|
||||
restartDialog.setVisibility(View.GONE);
|
||||
restartDialog.addView(restartMessageView,
|
||||
new LinearLayout.LayoutParams(WIDTH - 32, LinearLayout.LayoutParams.WRAP_CONTENT));
|
||||
restartDialog.addView(restartButtonRow);
|
||||
FrameLayout.LayoutParams restartDialogParams = new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT);
|
||||
tab.addView(restartDialog, restartDialogParams);
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
private View buildVideoOptionsTab() {
|
||||
LinearLayout tab = new LinearLayout(activity);
|
||||
tab.setOrientation(LinearLayout.VERTICAL);
|
||||
tab.setPadding(24, 16, 24, 16);
|
||||
|
||||
rendererLabel = new TextView(activity);
|
||||
rendererLabel.setTextColor(0xFFFFFFFF);
|
||||
rendererLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
|
||||
rendererLabel.setText(Strings.get("renderer", currentLanguage));
|
||||
tab.addView(rendererLabel);
|
||||
|
||||
LinearLayout rendererRow = new LinearLayout(activity);
|
||||
rendererRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||
softwareButton = addSelectableButton(rendererRow, Strings.get("software", currentLanguage),
|
||||
v -> nativeRequestSetRenderer(false));
|
||||
// "OpenGL" is a proper/brand name - not translated, see Strings.java.
|
||||
openglButton = addSelectableButton(rendererRow, "OpenGL", v -> nativeRequestSetRenderer(true));
|
||||
tab.addView(rendererRow);
|
||||
|
||||
resolutionLabel = new TextView(activity);
|
||||
resolutionLabel.setTextColor(0xFFFFFFFF);
|
||||
resolutionLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
|
||||
resolutionLabel.setPadding(0, 16, 0, 0);
|
||||
resolutionLabel.setText(Strings.get("resolution", currentLanguage));
|
||||
tab.addView(resolutionLabel);
|
||||
|
||||
LinearLayout resolutionRow = new LinearLayout(activity);
|
||||
resolutionRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||
resolutionButtons = new Button[RESOLUTION_LABELS.length];
|
||||
for (int i = 0; i < RESOLUTION_LABELS.length; i++) {
|
||||
final int index = i;
|
||||
resolutionButtons[i] = addSelectableButton(resolutionRow, RESOLUTION_LABELS[i],
|
||||
v -> nativeRequestSetResolution(index));
|
||||
}
|
||||
tab.addView(resolutionRow);
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
private View buildTabBar() {
|
||||
LinearLayout tabBar = new LinearLayout(activity);
|
||||
tabBar.setOrientation(LinearLayout.HORIZONTAL);
|
||||
tabBar.setBackgroundColor(0xFF000080);
|
||||
|
||||
mainMenuTabButton = new Button(activity);
|
||||
mainMenuTabButton.setText(Strings.get("main_menu", currentLanguage));
|
||||
mainMenuTabButton.setBackground(buttonBackground());
|
||||
mainMenuTabButton.setTextColor(buttonTextColors());
|
||||
mainMenuTabButton.setOnClickListener(v -> switchTab(mainMenuTab));
|
||||
mainMenuTabButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
||||
registerHoverable(mainMenuTabButton);
|
||||
tabBar.addView(mainMenuTabButton,
|
||||
new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
|
||||
|
||||
View separator = new View(activity);
|
||||
separator.setBackgroundColor(0xFFFFFFFF);
|
||||
tabBar.addView(separator, new LinearLayout.LayoutParams(2, ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
videoOptionsTabButton = new Button(activity);
|
||||
videoOptionsTabButton.setText(Strings.get("video_options", currentLanguage));
|
||||
videoOptionsTabButton.setBackground(buttonBackground());
|
||||
videoOptionsTabButton.setTextColor(buttonTextColors());
|
||||
videoOptionsTabButton.setOnClickListener(v -> switchTab(videoOptionsTab));
|
||||
videoOptionsTabButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
||||
registerHoverable(videoOptionsTabButton);
|
||||
tabBar.addView(videoOptionsTabButton,
|
||||
new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
|
||||
|
||||
return tabBar;
|
||||
}
|
||||
|
||||
private void switchTab(View tab) {
|
||||
if (tab == currentTab) {
|
||||
return;
|
||||
}
|
||||
currentTab.setVisibility(View.GONE);
|
||||
tab.setVisibility(View.VISIBLE);
|
||||
currentTab = tab;
|
||||
resetHover();
|
||||
if (tab == videoOptionsTab) {
|
||||
refreshVideoOptionsSelection();
|
||||
}
|
||||
relayoutAndRedraw();
|
||||
}
|
||||
|
||||
// Forces a fresh measure/layout pass over the whole tree before
|
||||
// redrawing - required after any visibility toggle (switchTab()/
|
||||
// showButtonRow()/showRestartDialog() below), since a hidden child
|
||||
// starts/becomes GONE and most ViewGroups skip measuring/laying out
|
||||
// GONE children entirely; without this, the first time it's shown it
|
||||
// would draw with a stale/zero layout from having never been measured.
|
||||
// Mirrors the exact measure()/layout() calls OverlayPanel's own
|
||||
// constructor already makes once, up front, on the same View object.
|
||||
private void relayoutAndRedraw() {
|
||||
int widthSpec = View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY);
|
||||
int heightSpec = View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY);
|
||||
root.measure(widthSpec, heightSpec);
|
||||
root.layout(0, 0, WIDTH, HEIGHT);
|
||||
redrawContent();
|
||||
}
|
||||
|
||||
private Button addRowButton(LinearLayout row, String text, View.OnClickListener onClick) {
|
||||
Button button = new Button(activity);
|
||||
button.setText(text);
|
||||
button.setOnClickListener(onClick);
|
||||
button.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
||||
button.setBackground(buttonBackground());
|
||||
button.setTextColor(buttonTextColors());
|
||||
registerHoverable(button);
|
||||
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, 100, 1f);
|
||||
lp.setMargins(8, 8, 8, 8);
|
||||
row.addView(button, lp);
|
||||
return button;
|
||||
}
|
||||
|
||||
// Like addRowButton(), but with a background that also has a distinct
|
||||
// (persistent, not just transient hover/press) "selected" appearance -
|
||||
// used for the Video Options tab's own controls, where exactly one
|
||||
// button per row always represents the currently-active value (see
|
||||
// refreshVideoOptionsSelection()). Same normal/hovered/pressed colors
|
||||
// as OverlayPanel.buttonBackground(), plus one more state.
|
||||
private static Drawable selectableButtonBackground() {
|
||||
StateListDrawable drawable = new StateListDrawable();
|
||||
drawable.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(PRESSED_COLOR));
|
||||
drawable.addState(new int[]{android.R.attr.state_hovered}, new ColorDrawable(HOVERED_COLOR));
|
||||
drawable.addState(new int[]{android.R.attr.state_selected}, new ColorDrawable(0xFF2A6E2A));
|
||||
drawable.addState(new int[]{}, new ColorDrawable(NORMAL_COLOR));
|
||||
return drawable;
|
||||
}
|
||||
|
||||
private Button addSelectableButton(LinearLayout row, String text, View.OnClickListener onClick) {
|
||||
Button button = new Button(activity);
|
||||
button.setText(text);
|
||||
button.setOnClickListener(onClick);
|
||||
button.setOnTouchListener(CLICK_ON_TOUCH_UP);
|
||||
button.setBackground(selectableButtonBackground());
|
||||
button.setTextColor(buttonTextColors());
|
||||
registerHoverable(button);
|
||||
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, 90, 1f);
|
||||
lp.setMargins(4, 8, 4, 4);
|
||||
row.addView(button, lp);
|
||||
return button;
|
||||
}
|
||||
|
||||
// Reflects the currently-active renderer/resolution (queried fresh via
|
||||
// the native getters, so it stays correct even if changed some other
|
||||
// way, e.g. the Ctrl+G hotkey) onto the Video Options tab's own
|
||||
// buttons' persistent "selected" state - called when the tab is shown
|
||||
// (switchTab()) and, while it's the visible tab, on the same periodic
|
||||
// timer as the header stats (refreshStats()).
|
||||
private void refreshVideoOptionsSelection() {
|
||||
boolean opengl = nativeGetRendererIsOpenGl();
|
||||
softwareButton.setSelected(!opengl);
|
||||
openglButton.setSelected(opengl);
|
||||
|
||||
int index = nativeGetResolutionIndex();
|
||||
for (int i = 0; i < resolutionButtons.length; i++) {
|
||||
resolutionButtons[i].setSelected(i == index);
|
||||
}
|
||||
}
|
||||
|
||||
// Polled every STATS_REFRESH_MS while this instance exists (started
|
||||
// from the constructor) rather than only while the menu is actually
|
||||
// visible - simpler than plumbing a new native->Java "menu became
|
||||
// visible" callback, and cheap enough not to matter (a couple of JNI
|
||||
// calls, a text update, and a redraw of a panel that isn't even being
|
||||
// sampled into the XR compositor while hidden).
|
||||
private void refreshStats() {
|
||||
float fps = nativeGetFps();
|
||||
float frameMs = nativeGetFrameTimeMs();
|
||||
// -1 (xr_session.h's sentinel) means the XR_META_performance_metrics
|
||||
// extension wasn't available/didn't expose this counter on this
|
||||
// runtime - omit it rather than showing a nonsense "-1%".
|
||||
float cpuUtilization = nativeGetCpuUtilization();
|
||||
float gpuUtilization = nativeGetGpuUtilization();
|
||||
StringBuilder stats = new StringBuilder();
|
||||
stats.append(String.format(Locale.US, "FPS: %.0f | %.1fms", fps, frameMs));
|
||||
if (cpuUtilization >= 0.0f) {
|
||||
stats.append(String.format(Locale.US, " | CPU: %.0f%%", cpuUtilization));
|
||||
}
|
||||
if (gpuUtilization >= 0.0f) {
|
||||
stats.append(String.format(Locale.US, " | GPU: %.0f%%", gpuUtilization));
|
||||
}
|
||||
stats.append(String.format(Locale.US, " | Free: %dMB", freeMemoryMb()));
|
||||
statsView.setText(stats.toString());
|
||||
|
||||
int language = nativeGetLanguage();
|
||||
if (language != currentLanguage) {
|
||||
currentLanguage = language;
|
||||
applyTranslations();
|
||||
KeyboardOverlay.applyTranslations(language);
|
||||
}
|
||||
|
||||
if (currentTab == videoOptionsTab) {
|
||||
refreshVideoOptionsSelection();
|
||||
}
|
||||
|
||||
// The stats text's width changes every tick (digit count, "ms"
|
||||
// widths, ...) - this view tree is never attached to a real window,
|
||||
// so nothing re-measures/lays it out on its own the way a real
|
||||
// window's layout pass would; see relayoutAndRedraw()'s own comment
|
||||
// for the GONE-child variant of the same underlying issue.
|
||||
relayoutAndRedraw();
|
||||
}
|
||||
|
||||
// System-wide available RAM, in megabytes - a plain Android API query
|
||||
// (no native/XR involvement, unlike the CPU/GPU stats above), matching
|
||||
// the "Free=NNNNMB" figure in the VrApi performance-HUD logcat line this
|
||||
// header's stats display was modeled on.
|
||||
private long freeMemoryMb() {
|
||||
ActivityManager activityManager =
|
||||
(ActivityManager) activity.getSystemService(Context.ACTIVITY_SERVICE);
|
||||
ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo();
|
||||
activityManager.getMemoryInfo(memoryInfo);
|
||||
return memoryInfo.availMem / (1024 * 1024);
|
||||
}
|
||||
|
||||
// Re-labels every piece of chrome text this class owns - called when
|
||||
// refreshStats() (above) detects the game's language preference
|
||||
// changed mid-session (via its own options menu).
|
||||
private void applyTranslations() {
|
||||
keyboardButton.setText(Strings.get("keyboard", currentLanguage));
|
||||
saveButton.setText(Strings.get("save_configuration", currentLanguage));
|
||||
loadButton.setText(Strings.get("load_configuration", currentLanguage));
|
||||
dismissButton.setText(Strings.get("dismiss", currentLanguage));
|
||||
restartNowButton.setText(Strings.get("restart_now", currentLanguage));
|
||||
mainMenuTabButton.setText(Strings.get("main_menu", currentLanguage));
|
||||
videoOptionsTabButton.setText(Strings.get("video_options", currentLanguage));
|
||||
rendererLabel.setText(Strings.get("renderer", currentLanguage));
|
||||
resolutionLabel.setText(Strings.get("resolution", currentLanguage));
|
||||
softwareButton.setText(Strings.get("software", currentLanguage));
|
||||
// openglButton's "OpenGL" label is never translated - see
|
||||
// buildVideoOptionsTab()'s own comment on it.
|
||||
}
|
||||
|
||||
// "Dismiss" on the restart dialog - the live-appliable subset was
|
||||
// already applied (on the SDL thread, before this dialog was ever
|
||||
// shown - see qs_config.c's qs_config_handle_event()), so this is
|
||||
// purely a local UI action, no native call needed.
|
||||
private void showButtonRow() {
|
||||
buttonRow.setVisibility(View.VISIBLE);
|
||||
restartDialog.setVisibility(View.GONE);
|
||||
resetHover();
|
||||
relayoutAndRedraw();
|
||||
}
|
||||
|
||||
// "Restart Now" on the restart dialog - relaunches the whole app via the
|
||||
// standard Android self-relaunch pattern (kill this process, start a
|
||||
// fresh one from the launcher intent) rather than trying to tear down
|
||||
// and re-init the engine's own OpenXR/session/swapchain/audio state in
|
||||
// place. The fresh process re-runs qs_config_load_startup() from
|
||||
// scratch, applying every group unconditionally. Purely a
|
||||
// Java/Activity/process action - no native call needed.
|
||||
private void restartApp() {
|
||||
Intent intent = activity.getPackageManager().getLaunchIntentForPackage(activity.getPackageName());
|
||||
if (intent == null) {
|
||||
return;
|
||||
}
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
activity.startActivity(intent);
|
||||
Runtime.getRuntime().exit(0);
|
||||
}
|
||||
|
||||
// Opens the keyboard overlay quad (questshock_native.c ->
|
||||
// xr_overlay_set_visible(xr_session_get_keyboard_overlay(), true)) -
|
||||
// see KeyboardOverlay for the panel itself and its own Close button,
|
||||
// the only way to hide it again.
|
||||
private static native void nativeShowKeyboard();
|
||||
|
||||
// "Save Configuration"/"Load Configuration" - see qs_config.h for why
|
||||
// these just enqueue work for the engine's own thread rather than
|
||||
// touching engine state directly from here (the UI thread).
|
||||
private static native void nativeRequestSaveConfig();
|
||||
private static native void nativeRequestLoadConfig();
|
||||
|
||||
// Video Options tab's controls - same enqueue-only reasoning as above.
|
||||
private static native void nativeRequestSetRenderer(boolean opengl);
|
||||
private static native void nativeRequestSetResolution(int resolutionIndex);
|
||||
|
||||
// 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();
|
||||
// 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
|
||||
// first periodic refresh to push a correction via applyTranslations().
|
||||
static native int nativeGetLanguage();
|
||||
private static native float nativeGetFps();
|
||||
private static native float nativeGetFrameTimeMs();
|
||||
// -1.0f if XR_META_performance_metrics (or its cpu/gpu_utilization
|
||||
// counter specifically) isn't available on this runtime - see
|
||||
// xr_session_get_cpu_utilization()/get_gpu_utilization()'s own comment.
|
||||
private static native float nativeGetCpuUtilization();
|
||||
private static native float nativeGetGpuUtilization();
|
||||
|
||||
// Called from xr_overlay.c's xr_overlay_init_jni(), on the render
|
||||
// thread, right after it resolves this class via the activity's own
|
||||
// ClassLoader (plain FindClass() can't see app classes from a thread
|
||||
@@ -58,6 +534,24 @@ public class MenuOverlay extends OverlayPanel {
|
||||
}
|
||||
}
|
||||
|
||||
// Called from qs_config.c's qs_show_restart_dialog(), on the engine's
|
||||
// own SDL thread (not the UI thread - hence runOnUiThread() here,
|
||||
// matching OverlayPanel's own dispatchTouch()/dispatchUpdateCursor())
|
||||
// after "Load Configuration" applies at least one live-appliable change
|
||||
// but finds another that's flagged requires-restart.
|
||||
public static void showRestartDialog(final String message) {
|
||||
if (instance == null) {
|
||||
return;
|
||||
}
|
||||
instance.activity.runOnUiThread(() -> {
|
||||
instance.restartMessageView.setText(message);
|
||||
instance.buttonRow.setVisibility(View.GONE);
|
||||
instance.restartDialog.setVisibility(View.VISIBLE);
|
||||
instance.resetHover();
|
||||
instance.relayoutAndRedraw();
|
||||
});
|
||||
}
|
||||
|
||||
public static byte[] nativeTakePixelsIfDirty() {
|
||||
return instance == null ? null : instance.takePixelsIfDirty();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
package de.ladkau.questshock;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.graphics.drawable.StateListDrawable;
|
||||
import android.os.SystemClock;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Shared machinery for an off-screen, never-attached-to-a-window View tree
|
||||
@@ -59,11 +66,59 @@ abstract class OverlayPanel {
|
||||
return true;
|
||||
};
|
||||
|
||||
// A plain, instantly-drawn StateListDrawable rather than the platform
|
||||
// default Button background (a Material ripple): ripples animate via
|
||||
// continuous invalidation callbacks tied to a real attached window's
|
||||
// vsync, which never fires here (this tree is never attached to a
|
||||
// window) - so setPressed()/setHovered() state changes would otherwise
|
||||
// produce no visible change at all, independent of this project's own
|
||||
// touch handling (which already calls setPressed() correctly - see
|
||||
// CLICK_ON_TOUCH_UP above). Every button this project creates
|
||||
// (MenuOverlay, KeyboardOverlay) should use this (or
|
||||
// MenuOverlay.selectableButtonBackground(), which adds a persistent
|
||||
// "selected" state on top of the same normal/hovered/pressed colors) -
|
||||
// paired with buttonTextColors() below, since the normal/hovered state
|
||||
// is light enough to need dark text while pressed/selected need white.
|
||||
protected static Drawable buttonBackground() {
|
||||
StateListDrawable drawable = new StateListDrawable();
|
||||
drawable.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(PRESSED_COLOR));
|
||||
drawable.addState(new int[]{android.R.attr.state_hovered}, new ColorDrawable(HOVERED_COLOR));
|
||||
drawable.addState(new int[]{}, new ColorDrawable(NORMAL_COLOR));
|
||||
return drawable;
|
||||
}
|
||||
|
||||
// See buttonBackground() above - light gray (normal/hovered) needs dark
|
||||
// text; the accent colors (pressed, and MenuOverlay's own "selected"
|
||||
// state) need white. Every button should get both this and
|
||||
// buttonBackground()/selectableButtonBackground() together.
|
||||
protected static ColorStateList buttonTextColors() {
|
||||
return new ColorStateList(
|
||||
new int[][]{
|
||||
new int[]{android.R.attr.state_pressed},
|
||||
new int[]{android.R.attr.state_selected},
|
||||
new int[]{},
|
||||
},
|
||||
new int[]{0xFFFFFFFF, 0xFFFFFFFF, 0xFF202020});
|
||||
}
|
||||
|
||||
protected static final int NORMAL_COLOR = 0xFFC8C8C8;
|
||||
protected static final int HOVERED_COLOR = 0xFFAEC2E8;
|
||||
protected static final int PRESSED_COLOR = 0xFF3355AA;
|
||||
|
||||
protected final Activity activity;
|
||||
private final int width;
|
||||
private final int height;
|
||||
private final View root;
|
||||
|
||||
// Views registered via registerHoverable() (every button, from both
|
||||
// buildContent() and any later-added content) - hit-tested against the
|
||||
// cursor position on every updateCursor() call so aiming at (not
|
||||
// clicking) one visibly highlights it via android.R.attr.state_hovered
|
||||
// (a real View state since API 14, paired with buttonBackground()
|
||||
// above).
|
||||
private final List<View> hoverableViews = new ArrayList<>();
|
||||
private View hoveredView = null;
|
||||
|
||||
// contentBitmap holds just the panel's own rendered content (no cursor),
|
||||
// redrawn only when that content actually changes (redrawContent(), via
|
||||
// handleTouch() or a subclass's own key handling) via root.draw() - a
|
||||
@@ -183,9 +238,88 @@ abstract class OverlayPanel {
|
||||
cursorVisible = visible;
|
||||
cursorX = x;
|
||||
cursorY = y;
|
||||
if (updateHoverState(x, y, visible)) {
|
||||
// redrawContent() (called inside updateHoverState()) already
|
||||
// composited and published this frame's pixels - nothing more
|
||||
// to do.
|
||||
return;
|
||||
}
|
||||
compositeAndPublish();
|
||||
}
|
||||
|
||||
// Registers a button (or any View) to be hit-tested against the cursor
|
||||
// position - called once per button, alongside buttonBackground()/
|
||||
// setOnTouchListener(), from buildContent() (or any later content
|
||||
// rebuild).
|
||||
protected final void registerHoverable(View view) {
|
||||
hoverableViews.add(view);
|
||||
}
|
||||
|
||||
// Clears whatever's currently hovered without waiting for the cursor to
|
||||
// actually move off of it - call after hiding/showing content (e.g. a
|
||||
// tab switch), since a view that was hovered right before becoming
|
||||
// GONE would otherwise still be internally tracked as hovered (and
|
||||
// wrongly show that way again) if it's later made VISIBLE again before
|
||||
// the cursor position next changes.
|
||||
protected final void resetHover() {
|
||||
if (hoveredView != null) {
|
||||
hoveredView.setHovered(false);
|
||||
hoveredView = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Absolute bounds of view within this panel's own coordinate space
|
||||
// (i.e. relative to root, which is always laid out at (0,0,width,
|
||||
// height) - see the constructor) - View's own getLeft()/getTop() are
|
||||
// parent-relative, so this walks up the parent chain summing them.
|
||||
// Assumes view is actually part of this panel's tree (every registered
|
||||
// view always is, since registerHoverable() is only ever called from
|
||||
// buildContent()).
|
||||
private Rect absoluteBounds(View view) {
|
||||
int left = 0;
|
||||
int top = 0;
|
||||
View current = view;
|
||||
while (true) {
|
||||
left += current.getLeft();
|
||||
top += current.getTop();
|
||||
if (current == root || !(current.getParent() instanceof View)) {
|
||||
break;
|
||||
}
|
||||
current = (View) current.getParent();
|
||||
}
|
||||
return new Rect(left, top, left + view.getWidth(), top + view.getHeight());
|
||||
}
|
||||
|
||||
// Returns true if the hovered view changed (and so redrawContent() -
|
||||
// which always publishes too - already ran); false if there's nothing
|
||||
// more for updateCursor() to do beyond its own compositeAndPublish().
|
||||
private boolean updateHoverState(float x, float y, boolean visible) {
|
||||
View newHovered = null;
|
||||
if (visible) {
|
||||
for (View candidate : hoverableViews) {
|
||||
if (candidate.getVisibility() != View.VISIBLE) {
|
||||
continue;
|
||||
}
|
||||
if (absoluteBounds(candidate).contains((int) x, (int) y)) {
|
||||
newHovered = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newHovered == hoveredView) {
|
||||
return false;
|
||||
}
|
||||
if (hoveredView != null) {
|
||||
hoveredView.setHovered(false);
|
||||
}
|
||||
if (newHovered != null) {
|
||||
newHovered.setHovered(true);
|
||||
}
|
||||
hoveredView = newHovered;
|
||||
redrawContent();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void handleTouch(float u, float v, boolean down) {
|
||||
float x = u * width;
|
||||
float y = v * height;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.ladkau.questshock;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Best-effort (not professionally reviewed) translations for this app's own
|
||||
* UI chrome text - the menu/keyboard panels this project draws itself, not
|
||||
* the game's own resource-string text (which already has its own separate
|
||||
* English/French/German data files). Selected by gShockPrefs.goLanguage (0
|
||||
* English/1 French/2 German, Prefs.h) via MenuOverlay's native language
|
||||
* query (qs_config_current_language()) - see MenuOverlay's periodic header-
|
||||
* refresh timer, which also pushes it to KeyboardOverlay.
|
||||
*
|
||||
* Deliberately excludes the keyboard's own alphanumeric key labels (a-z,
|
||||
* Tab, Space, Esc, ...) - those represent physical key positions/characters
|
||||
* rather than translatable UI copy - and the restart-confirmation dialog's
|
||||
* message text, which is built in native code (qs_config.c's
|
||||
* qs_show_restart_dialog()) with an interpolated option-name list and is
|
||||
* not currently reachable in practice (see CONFIG.md - neither Video
|
||||
* Options option is flagged restart-required today).
|
||||
*/
|
||||
final class Strings {
|
||||
static final int ENGLISH = 0;
|
||||
static final int FRENCH = 1;
|
||||
static final int GERMAN = 2;
|
||||
|
||||
private static final Map<String, String[]> TABLE = new HashMap<>();
|
||||
|
||||
private static void put(String key, String en, String fr, String de) {
|
||||
TABLE.put(key, new String[]{en, fr, de});
|
||||
}
|
||||
|
||||
static {
|
||||
put("keyboard", "Keyboard", "Clavier", "Tastatur");
|
||||
put("save_configuration", "Save Configuration", "Enregistrer la configuration",
|
||||
"Konfiguration speichern");
|
||||
put("load_configuration", "Load Configuration", "Charger la configuration",
|
||||
"Konfiguration laden");
|
||||
put("main_menu", "Main Menu", "Menu principal", "Hauptmenü");
|
||||
put("video_options", "Video Options", "Options vidéo", "Videooptionen");
|
||||
put("dismiss", "Dismiss", "Fermer", "Schließen");
|
||||
put("restart_now", "Restart Now", "Redémarrer maintenant", "Jetzt neu starten");
|
||||
put("renderer", "Renderer", "Moteur de rendu", "Renderer");
|
||||
put("resolution", "Resolution", "Résolution", "Auflösung");
|
||||
put("software", "Software", "Logiciel", "Software");
|
||||
// "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.
|
||||
put("keyboard_drag_title", "Keyboard - drag here to move",
|
||||
"Clavier - faites glisser ici pour déplacer",
|
||||
"Tastatur - hier ziehen zum Verschieben");
|
||||
put("close", "Close", "Fermer", "Schließen");
|
||||
}
|
||||
|
||||
private Strings() {}
|
||||
|
||||
static String get(String key, int language) {
|
||||
String[] variants = TABLE.get(key);
|
||||
if (variants == null) {
|
||||
return key;
|
||||
}
|
||||
if (language < 0 || language >= variants.length) {
|
||||
language = ENGLISH;
|
||||
}
|
||||
return variants[language];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
--- a/src/MacSrc/Shock.c
|
||||
+++ b/src/MacSrc/Shock.c
|
||||
@@ -31,6 +31,7 @@
|
||||
#include <SDL.h>
|
||||
|
||||
#ifdef __ANDROID__
|
||||
+#include "qs_config.h"
|
||||
#include "xr_session.h"
|
||||
#endif
|
||||
|
||||
@@ -109,6 +110,15 @@
|
||||
LoadHotkeyKeybinds();
|
||||
LoadMoveKeybinds();
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+ // Persistent, human-editable JSON config (questshock.config.json) - see
|
||||
+ // qs_config.h/CONFIG.md. After prefs.txt is loaded (above) so this file
|
||||
+ // can override it, before init_all() below so there's no live-vs-
|
||||
+ // restart distinction to worry about yet - every field just takes full
|
||||
+ // effect as part of this same startup.
|
||||
+ qs_config_load_startup();
|
||||
+#endif
|
||||
+
|
||||
// Process some startup arguments
|
||||
|
||||
bool show_splash = !CheckArgument("-nosplash");
|
||||
--- a/src/Libraries/INPUT/Source/sdl_events.c
|
||||
+++ b/src/Libraries/INPUT/Source/sdl_events.c
|
||||
@@ -29,6 +29,10 @@
|
||||
#include <SDL.h>
|
||||
#include <OpenGL.h>
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+#include "qs_config.h"
|
||||
+#endif
|
||||
+
|
||||
extern SDL_Window *window;
|
||||
extern SDL_Renderer *renderer;
|
||||
|
||||
@@ -481,6 +485,14 @@
|
||||
SDL_Event ev;
|
||||
|
||||
while (SDL_PollEvent(&ev)) {
|
||||
+#ifdef __ANDROID__
|
||||
+ // Custom event types qs_config_request_save()/_load() enqueue from
|
||||
+ // the "Save Configuration"/"Load Configuration" menu buttons (see
|
||||
+ // qs_config.h) - handled here, on this thread, rather than directly
|
||||
+ // from those JNI calls (which run on Android's UI thread).
|
||||
+ if (qs_config_handle_event(&ev))
|
||||
+ continue;
|
||||
+#endif
|
||||
switch (ev.type) {
|
||||
case SDL_QUIT:
|
||||
// a bit hacky at this place, but this would allow exiting the game via the window's [x] button
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 368 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 435 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 276 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 209 KiB |
@@ -0,0 +1,132 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="210mm"
|
||||
height="297mm"
|
||||
viewBox="0 0 210 297"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||
sodipodi:docname="sketch_main_menu.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="2.5287798"
|
||||
inkscape:cx="167.47208"
|
||||
inkscape:cy="123.18194"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="962"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="33"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<rect
|
||||
style="fill:#000080;stroke-width:0.193167"
|
||||
id="rect1"
|
||||
width="63.682735"
|
||||
height="4.0229344"
|
||||
x="4.4946499"
|
||||
y="3.4182527" />
|
||||
<rect
|
||||
style="fill:#b3b3b3;stroke-width:0.543629"
|
||||
id="rect1-5"
|
||||
width="63.682735"
|
||||
height="31.862694"
|
||||
x="4.5049582"
|
||||
y="7.3757277" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;stroke-width:0.264583"
|
||||
x="25.181696"
|
||||
y="20.839491"
|
||||
id="text2"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan2"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;stroke-width:0.264583"
|
||||
x="25.181696"
|
||||
y="20.839491">Menu content here</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="6.1205692"
|
||||
y="5.8075914"
|
||||
id="text3"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="6.1205692"
|
||||
y="5.8075914">Questshock (v3.0.2)</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="60.855247"
|
||||
y="5.7559261"
|
||||
id="text4"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan4"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="60.855247"
|
||||
y="5.7559261">[FPS: 70]</tspan></text>
|
||||
<rect
|
||||
style="fill:#000080;stroke-width:0.156477"
|
||||
id="rect1-2"
|
||||
width="63.682735"
|
||||
height="2.6398373"
|
||||
x="4.471632"
|
||||
y="39.1059" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="5.4945946"
|
||||
y="40.859875"
|
||||
id="text3-7"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3-5"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="5.4945946"
|
||||
y="40.859875">Main Menu</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.41111px;line-height:1.25;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="13.718193"
|
||||
y="40.915668"
|
||||
id="text3-7-8"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3-5-9"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:FreeSans;-inkscape-font-specification:FreeSans;fill:#ffffff;stroke-width:0.264583"
|
||||
x="13.718193"
|
||||
y="40.915668">Video Options</tspan></text>
|
||||
<rect
|
||||
style="fill:#1a1a1a;stroke:#ffffff;stroke-width:0.256016"
|
||||
id="rect4"
|
||||
width="0.055807162"
|
||||
height="2.4157476"
|
||||
x="12.918808"
|
||||
y="39.218266" />
|
||||
<rect
|
||||
style="fill:#1a1a1a;stroke:#ffffff;stroke-width:0.256016"
|
||||
id="rect4-1"
|
||||
width="0.055807162"
|
||||
height="2.4157476"
|
||||
x="22.843752"
|
||||
y="39.203552" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
Reference in New Issue
Block a user