diff --git a/android/app/build.gradle b/android/app/build.gradle index 05fd060..5e81e82 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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 @@ -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 } diff --git a/android/app/src/main/cpp/qs_config.c b/android/app/src/main/cpp/qs_config.c index fb114b9..a79813b 100644 --- a/android/app/src/main/cpp/qs_config.c +++ b/android/app/src/main/cpp/qs_config.c @@ -442,6 +442,21 @@ static const char *qs_current_resolution(void) { 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 // =========================================================================== @@ -557,16 +572,21 @@ static void qs_show_restart_dialog(const char *restart_needed) { 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() - // 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(2); + // 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)) @@ -587,6 +607,22 @@ void qs_config_request_load(void) { 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()) @@ -609,5 +645,15 @@ bool qs_config_handle_event(const SDL_Event *event) { 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; } diff --git a/android/app/src/main/cpp/qs_config.h b/android/app/src/main/cpp/qs_config.h index 2c60803..ddcf311 100644 --- a/android/app/src/main/cpp/qs_config.h +++ b/android/app/src/main/cpp/qs_config.h @@ -37,14 +37,47 @@ void qs_config_load_startup(void); void qs_config_request_save(void); void qs_config_request_load(void); -// Returns true and handles the event if it's one of the two custom events -// qs_config_request_save()/_load() 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 two custom types have even been registered. +// 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 diff --git a/android/app/src/main/cpp/questshock_native.c b/android/app/src/main/cpp/questshock_native.c index 2aacd47..5ead13b 100644 --- a/android/app/src/main/cpp/questshock_native.c +++ b/android/app/src/main/cpp/questshock_native.c @@ -103,3 +103,56 @@ 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(); +} diff --git a/android/app/src/main/cpp/xr_input.c b/android/app/src/main/cpp/xr_input.c index 3487839..6910527 100644 --- a/android/app/src/main/cpp/xr_input.c +++ b/android/app/src/main/cpp/xr_input.c @@ -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; } diff --git a/android/app/src/main/cpp/xr_session.c b/android/app/src/main/cpp/xr_session.c index fb7adfa..a8d6a1c 100644 --- a/android/app/src/main/cpp/xr_session.c +++ b/android/app/src/main/cpp/xr_session.c @@ -1,5 +1,6 @@ #include "xr_session.h" +#include #include #include @@ -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) { diff --git a/android/app/src/main/cpp/xr_session.h b/android/app/src/main/cpp/xr_session.h index 6b39b50..d71fd89 100644 --- a/android/app/src/main/cpp/xr_session.h +++ b/android/app/src/main/cpp/xr_session.h @@ -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 diff --git a/android/app/src/main/java/de/ladkau/questshock/KeyboardOverlay.java b/android/app/src/main/java/de/ladkau/questshock/KeyboardOverlay.java index 882b73e..efdf53a 100644 --- a/android/app/src/main/java/de/ladkau/questshock/KeyboardOverlay.java +++ b/android/app/src/main/java/de/ladkau/questshock/KeyboardOverlay.java @@ -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 diff --git a/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java b/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java index 3758553..b1e794e 100644 --- a/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java +++ b/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java @@ -1,68 +1,205 @@ 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). 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. + * 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. * - * Two top-level children share this panel, only one visible at a time: - * buttonRow (the normal "Keyboard"/"Save Configuration"/"Load Configuration" - * row) and restartDialog (shown only when "Load Configuration" applies at - * least one live-appliable change but finds another flagged requires-restart - * - see CONFIG.md and qs_config.c's comments on the *_REQUIRES_RESTART - * flags). "Keyboard" opens the (fully independent) keyboard overlay quad via - * nativeShowKeyboard() - see KeyboardOverlay for the keyboard itself. + * 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. + // 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); + 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); - addRowButton(buttonRow, "Keyboard", v -> nativeShowKeyboard()); - addRowButton(buttonRow, "Save Configuration", v -> nativeRequestSaveConfig()); - addRowButton(buttonRow, "Load Configuration", v -> nativeRequestLoadConfig()); - FrameLayout.LayoutParams buttonRowParams = - new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT); + 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; - panel.addView(buttonRow, buttonRowParams); + tab.addView(buttonRow, buttonRowParams); restartMessageView = new TextView(activity); restartMessageView.setTextColor(0xFFFFFFFF); @@ -72,8 +209,10 @@ public class MenuOverlay extends OverlayPanel { LinearLayout restartButtonRow = new LinearLayout(activity); restartButtonRow.setOrientation(LinearLayout.HORIZONTAL); restartButtonRow.setGravity(Gravity.CENTER); - addRowButton(restartButtonRow, "Dismiss", v -> showButtonRow()); - addRowButton(restartButtonRow, "Restart Now", v -> restartApp()); + 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); @@ -84,17 +223,101 @@ public class MenuOverlay extends OverlayPanel { restartDialog.addView(restartButtonRow); FrameLayout.LayoutParams restartDialogParams = new FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT); - panel.addView(restartDialog, restartDialogParams); + tab.addView(restartDialog, restartDialogParams); - root = panel; - return panel; + 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 (showButtonRow()/ - // showRestartDialog() below), since restartDialog starts GONE and most - // ViewGroups (FrameLayout included) skip measuring/laying out GONE - // children entirely; without this, the first time it's ever shown it + // 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. @@ -106,14 +329,138 @@ public class MenuOverlay extends OverlayPanel { redrawContent(); } - private void addRowButton(LinearLayout row, String text, View.OnClickListener onClick) { + 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 @@ -123,6 +470,7 @@ public class MenuOverlay extends OverlayPanel { private void showButtonRow() { buttonRow.setVisibility(View.VISIBLE); restartDialog.setVisibility(View.GONE); + resetHover(); relayoutAndRedraw(); } @@ -155,6 +503,27 @@ public class MenuOverlay extends OverlayPanel { 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 @@ -178,6 +547,7 @@ public class MenuOverlay extends OverlayPanel { instance.restartMessageView.setText(message); instance.buttonRow.setVisibility(View.GONE); instance.restartDialog.setVisibility(View.VISIBLE); + instance.resetHover(); instance.relayoutAndRedraw(); }); } diff --git a/android/app/src/main/java/de/ladkau/questshock/OverlayPanel.java b/android/app/src/main/java/de/ladkau/questshock/OverlayPanel.java index 304a5ab..6733d7b 100644 --- a/android/app/src/main/java/de/ladkau/questshock/OverlayPanel.java +++ b/android/app/src/main/java/de/ladkau/questshock/OverlayPanel.java @@ -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 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; diff --git a/android/app/src/main/java/de/ladkau/questshock/Strings.java b/android/app/src/main/java/de/ladkau/questshock/Strings.java new file mode 100644 index 0000000..586cc7b --- /dev/null +++ b/android/app/src/main/java/de/ladkau/questshock/Strings.java @@ -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 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]; + } +} diff --git a/res/screenshots/gallery_013.jpg b/res/screenshots/gallery_013.jpg new file mode 100644 index 0000000..10a0d1e Binary files /dev/null and b/res/screenshots/gallery_013.jpg differ diff --git a/res/screenshots/gallery_014.jpg b/res/screenshots/gallery_014.jpg new file mode 100644 index 0000000..ea4d3c3 Binary files /dev/null and b/res/screenshots/gallery_014.jpg differ diff --git a/res/screenshots/gallery_015.jpg b/res/screenshots/gallery_015.jpg new file mode 100644 index 0000000..2133bd1 Binary files /dev/null and b/res/screenshots/gallery_015.jpg differ