diff --git a/README.md b/README.md index 48439d191..20774f65f 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Most important: * Support for any resolution and aspect ratio * Fullscreen, windowed, borderless window modes * Enhanced mouse input +* Controller Support * Ability to skip cutscenes * Restored water/lava rising functionality in Geothermal Plant * Enhanced graphics options such as anti-aliasing and full color range lighting diff --git a/common/include/common/config/AlpineCoreConfig.h b/common/include/common/config/AlpineCoreConfig.h index 9806798b8..97df2dd1b 100644 --- a/common/include/common/config/AlpineCoreConfig.h +++ b/common/include/common/config/AlpineCoreConfig.h @@ -12,6 +12,7 @@ class AlpineCoreConfig public: // Configurable fields bool vsync = false; + bool gamepad_rawinput_enabled = false; std::vector orphaned_lines; bool load(const std::string& filename = "alpine_system.ini"); diff --git a/common/src/config/AlpineCoreConfig.cpp b/common/src/config/AlpineCoreConfig.cpp index be2a2f5f3..cf78f413a 100644 --- a/common/src/config/AlpineCoreConfig.cpp +++ b/common/src/config/AlpineCoreConfig.cpp @@ -39,6 +39,11 @@ bool AlpineCoreConfig::load(const std::string& filename) processed.insert("VerticalSync"); } + if (settings.count("GamepadRawInput")) { + gamepad_rawinput_enabled = string_to_bool(settings["GamepadRawInput"]); + processed.insert("GamepadRawInput"); + } + for (const auto& [key, value] : settings) { if (!processed.contains(key) && key.rfind("AFCC", 0) == std::string::npos) { orphaned_lines.push_back(key + "=" + value); @@ -85,6 +90,7 @@ void AlpineCoreConfig::save(const std::string& filename) const file << "\n[Configuration]\n"; file << "VerticalSync=" << bool_to_string(vsync) << "\n"; + file << "GamepadRawInput=" << bool_to_string(gamepad_rawinput_enabled) << "\n"; file.close(); xlog::info("Saved Alpine core config to {}", filename); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 91f5d06f7..17c3d97a3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,38 @@ ⚙️⛏ Alpine Faction Changelog ⛏⚙️ =================================== +Version 1.4.0 (TBA) +note: this is a placeholder list, actual patch notes will be added for the next release candidate +-------------------------------- +### Major features + +[@AL2009man](https://github.com/AL2009man) & [@nickalreadyinuse](https://github.com/nickalreadyinuse) +- Controller Support +> [!WARNING] +> When using Steam Input or Steam Hardware Inputs (Steam Deck, Steam Controller 2026, Steam Controller 2025): change the controller configuration to one of the many `Gamepad` templates to enable controller support. + - Full controller support via SDL3 Gamepad API + - Glyph support for Xbox, PlayStation, Nintendo, and Steam Hardware (Steam Controller 2015, Steam Deck, and Steam Controller 2026) + - Dual-Analog support for movement and camera controls + - Initial Rumble support + - only covers all Weapon Fire and select Environmental effects + - Trigger Rumble/Impulse Triggers are also supported (requires enabling `GamepadRawInput` in `alpine_settings.ini`) + - Gyro Aiming support + - Natural Sensitivity Scale / Real World Sensitivity support + - Gyro Space orientation support for Yaw, Roll, Local Space, Player Space and World Space + - Gyro Autocalibration system + - Gyro Modifier / Gyro Ratcheting support (via Game Action remapping) + - Flick Stick support + - Best paired with Gyro Aiming or Mixed Input + - Simultaneous Controller+Keyboard/Mouse / Mixed Input support + - if using SteamInput's supported Input Camera styles: We recommend selecting `ms_scale`'s Modern (`2`) and change the mouse sensitivity to `2.5000`. If it's set to ``6545px`` (default slider on SteamInput): this will skip "[Input] Angles/Degrees to Mouse Pixels" slider. + - Partial controller menu navigation support (Mouse controls are handled via Left Stick, Scrollbar are handled by Right Stick) + - Additional Controller settings are available in `alpine_settings.ini`and/or console commands + +### Minor features, changes, and enhancements +[@AL2009man](https://github.com/AL2009man) +- Add `GamepadRawInput` option (via `alpine_settings.ini`) to enable RawInput driver for better handling of XInput controllers, while allowing Trigger Rumble/Impulse Trigger support. +- Add the ability to scroll thought Alpine Settings menu panels with the mouse wheel or right stick, enabling more options in the near future. As of this version: this only applies for Input settings panel + Version 1.3.0 (Bakeapple): Not yet released -------------------------------- ### Major features diff --git a/game_patch/CMakeLists.txt b/game_patch/CMakeLists.txt index 83bae0a6b..20a1f531d 100644 --- a/game_patch/CMakeLists.txt +++ b/game_patch/CMakeLists.txt @@ -100,10 +100,19 @@ set(SRCS graphics/d3d11/gr_d3d11_outline.cpp graphics/d3d11/gr_d3d11_gamma.h graphics/d3d11/gr_d3d11_gamma.cpp + input/input.cpp input/input.h input/mouse.h input/mouse.cpp input/key.cpp + input/gamepad.cpp + input/gamepad.h + input/rumble.cpp + input/rumble.h + input/glyph.cpp + input/glyph.h + input/gyro.cpp + input/gyro.h hud/hud_colors.cpp hud/hud_scale.cpp hud/hud_personas.cpp @@ -301,6 +310,7 @@ target_include_directories(AlpineFaction PRIVATE ${CMAKE_SOURCE_DIR}/vendor/dds ${CMAKE_SOURCE_DIR}/vendor/toml++ ${CMAKE_SOURCE_DIR}/vendor/nlohmann + ${CMAKE_SOURCE_DIR}/vendor/gamepadmotionhelpers ) if(NOT MSVC) @@ -334,4 +344,14 @@ target_link_libraries(AlpineFaction freetype stb_vorbis stb_image + SDL3::SDL3 + GamepadMotionHelpers ) + +if(WIN32) + target_link_libraries(AlpineFaction + setupapi + imm32 + cfgmgr32 + ) +endif() diff --git a/game_patch/input/gamepad.cpp b/game_patch/input/gamepad.cpp new file mode 100644 index 000000000..535d38260 --- /dev/null +++ b/game_patch/input/gamepad.cpp @@ -0,0 +1,2154 @@ +#include "gamepad.h" +#include "gyro.h" +#include "rumble.h" +#include "input.h" +#include "glyph.h" +#include "../hud/multi_spectate.h" +#include +#include +#include +#include +#include +#include +#include "../os/console.h" +#include "../rf/input.h" +#include "../rf/ui.h" +#include "../rf/player/player.h" +#include "../rf/player/camera.h" +#include "../rf/player/control_config.h" +#include "../rf/player/player_fpgun.h" +#include "../rf/weapon.h" +#include "../rf/vmesh.h" +#include "../rf/entity.h" +#include "../rf/os/frametime.h" +#include "../rf/gameseq.h" +#include "../misc/alpine_settings.h" +#include "../misc/misc.h" +#include "../main/main.h" +#include +#include +#include "../rf/os/os.h" + +static SDL_Gamepad* g_gamepad = nullptr; +static bool g_motion_sensors_supported = false; +static bool g_rumble_supported = false; +static bool g_trigger_rumble_supported = false; + +static float g_camera_gamepad_dx = 0.0f; +static float g_camera_gamepad_dy = 0.0f; + +static float g_gamepad_scope_sensitivity_value = 0.25f; +static float g_gamepad_scanner_sensitivity_value = 0.25f; +static float g_gamepad_scope_gyro_sensitivity_value = 0.25f; +static float g_gamepad_scanner_gyro_sensitivity_value = 0.25f; +static float g_gamepad_scope_applied_dynamic_sensitivity_value = 1.0f; +static float g_gamepad_scope_gyro_applied_dynamic_sensitivity_value = 1.0f; + +static int g_button_map[SDL_GAMEPAD_BUTTON_COUNT]; +static int g_button_map_alt[SDL_GAMEPAD_BUTTON_COUNT]; +static int g_trigger_action[2] = {rf::CC_ACTION_CROUCH, rf::CC_ACTION_SECONDARY_ATTACK}; // [0] = LT, [1] = RT + +// Menu-only maps for context-sensitive AF actions (spectate, vote, menus) that share buttons with gameplay. +static int g_menu_button_map[SDL_GAMEPAD_BUTTON_COUNT]; +static int g_menu_trigger_action[2] = {-1, -1}; + +static bool g_lt_was_down = false; +static bool g_rt_was_down = false; + +static constexpr int k_action_count = 128; +static bool g_action_prev[k_action_count] = {}; +static bool g_action_curr[k_action_count] = {}; + +static int g_rebind_pending_sc = -1; // scan code captured during rebind, -1 = none pending +static bool g_last_input_was_gamepad = false; +static float g_message_log_close_cooldown = 0.0f; +static int g_pending_scroll_delta = 0; + +struct MenuNavState { + int deferred_btn_down = -1; // SDL button queued from poll for button-down + int deferred_btn_up = -1; // SDL button queued from poll for button-up + int repeat_btn = -1; // D-pad button held for auto-repeat, -1 = none + float repeat_timer = 0.0f; // seconds until next repeat tick + float scroll_timer = 0.0f; // cooldown between right-stick scroll ticks + bool lclick_held = false; // WM_LBUTTONDOWN sent, WM_LBUTTONUP awaiting release + bool last_nav_was_dpad = true; // true = D-pad drove focus; false = left stick moved cursor +}; +static MenuNavState g_menu_nav; + +static float g_move_lx = 0.0f, g_move_ly = 0.0f; +static float g_move_mag = 0.0f; + +static bool g_flickstick_was_in_flick_zone = false; // stick was past the flick deadzone last frame +static float g_flickstick_flick_progress = 0.0f; // seconds into the current flick animation +static float g_flickstick_flick_size = 0.0f; // yaw to output over the flick animation (rad) +static float g_flickstick_prev_stick_angle = 0.0f; // stick angle from the previous frame +static constexpr int k_turn_smooth_buf_size = 5; // ring buffer size for turn smoothing +static float g_flickstick_turn_smooth_buf[k_turn_smooth_buf_size] = {}; +static int g_flickstick_turn_smooth_idx = 0; + +static rf::VMesh* g_local_player_body_vmesh = nullptr; +static bool g_scaling_fpgun_vmesh = false; + +static Uint64 g_sensor_last_gyro_ts = 0; +static Uint64 g_sensor_last_accel_ts = 0; +static float g_sensor_accel[3] = {}; +static float g_sensor_gyro[3] = {}; + +static bool is_gamepad_input_active() +{ + return g_gamepad && rf::is_main_wnd_active; +} + +static bool is_freelook_camera() +{ + return rf::local_player && rf::local_player->cam + && rf::local_player->cam->mode == rf::CameraMode::CAMERA_FREELOOK; +} + +static void update_gamepad_scoped_sensitivities() +{ + g_gamepad_scope_sensitivity_value = g_alpine_game_config.gamepad_scope_sensitivity_modifier; + g_gamepad_scanner_sensitivity_value = g_alpine_game_config.gamepad_scanner_sensitivity_modifier; + g_gamepad_scope_gyro_sensitivity_value = g_alpine_game_config.gamepad_scope_gyro_sensitivity_modifier; + g_gamepad_scanner_gyro_sensitivity_value = g_alpine_game_config.gamepad_scanner_gyro_sensitivity_modifier; + g_gamepad_scope_applied_dynamic_sensitivity_value = + (1.0f / (4.0f * g_alpine_game_config.gamepad_scope_sensitivity_modifier)) * rf::scope_sensitivity_constant; + g_gamepad_scope_gyro_applied_dynamic_sensitivity_value = + (1.0f / (4.0f * g_alpine_game_config.gamepad_scope_gyro_sensitivity_modifier)) * rf::scope_sensitivity_constant; +} + +static bool is_menu_only_action(int action_idx) +{ + if (action_idx < 0) return false; + if (action_idx == static_cast(rf::CC_ACTION_CHAT) + || action_idx == static_cast(rf::CC_ACTION_TEAM_CHAT) + || action_idx == static_cast(rf::CC_ACTION_MP_STATS)) + return true; + using rf::AlpineControlConfigAction; + return action_idx == static_cast(get_af_control(AlpineControlConfigAction::AF_ACTION_VOTE_YES)) + || action_idx == static_cast(get_af_control(AlpineControlConfigAction::AF_ACTION_VOTE_NO)) + || action_idx == static_cast(get_af_control(AlpineControlConfigAction::AF_ACTION_READY)) + || action_idx == static_cast(get_af_control(AlpineControlConfigAction::AF_ACTION_DROP_FLAG)) + || action_idx == static_cast(get_af_control(AlpineControlConfigAction::AF_ACTION_CHAT_MENU)) + || action_idx == static_cast(get_af_control(AlpineControlConfigAction::AF_ACTION_TAUNT_MENU)) + || action_idx == static_cast(get_af_control(AlpineControlConfigAction::AF_ACTION_COMMAND_MENU)) + || action_idx == static_cast(get_af_control(AlpineControlConfigAction::AF_ACTION_PING_LOCATION)) + || action_idx == static_cast(get_af_control(AlpineControlConfigAction::AF_ACTION_SPECTATE_MENU)) + || action_idx == static_cast(get_af_control(AlpineControlConfigAction::AF_ACTION_REMOTE_SERVER_CFG)) + || action_idx == static_cast(get_af_control(AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE_FREELOOK)) + || action_idx == static_cast(get_af_control(AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE)); +} + +static bool is_gamepad_menu_state() +{ + if (!rf::gameseq_in_gameplay()) return true; + if (!rf::keep_mouse_centered) return true; + if (is_freelook_camera()) return false; + return !rf::local_player_entity || rf::entity_is_dying(rf::local_player_entity); +} + +static bool is_gamepad_menu_navigation_state() +{ + const rf::GameState state = rf::gameseq_get_state(); + if (state == rf::GS_MULTI_LIMBO || state == rf::GS_LEVEL_TRANSITION || state == rf::GS_NEW_LEVEL) + return false; + return is_gamepad_menu_state(); +} + +static void reset_gamepad_input_state() +{ + g_camera_gamepad_dx = 0.0f; + g_camera_gamepad_dy = 0.0f; + memset(g_action_curr, 0, sizeof(g_action_curr)); + g_move_lx = g_move_ly = 0.0f; + g_move_mag = 0.0f; + g_menu_nav = {}; + g_rebind_pending_sc = -1; + g_flickstick_was_in_flick_zone = false; + g_flickstick_flick_progress = 0.0f; + g_flickstick_flick_size = 0.0f; + g_flickstick_prev_stick_angle = 0.0f; + memset(g_flickstick_turn_smooth_buf, 0, sizeof(g_flickstick_turn_smooth_buf)); + g_flickstick_turn_smooth_idx = 0; + g_lt_was_down = false; + g_rt_was_down = false; + g_last_input_was_gamepad = false; + g_sensor_last_gyro_ts = 0; + g_sensor_last_accel_ts = 0; + memset(g_sensor_accel, 0, sizeof(g_sensor_accel)); + memset(g_sensor_gyro, 0, sizeof(g_sensor_gyro)); + g_pending_scroll_delta = 0; +} + +// Normalize an axis value, strip the deadzone band, and rescale the remainder to [-1, 1]. +// Per-axis (cross-shaped) deadzone: each axis is independently deadzoned and rescaled. +static float get_axis(SDL_GamepadAxis axis, float deadzone) +{ + if (!g_gamepad) return 0.0f; + float v = SDL_GetGamepadAxis(g_gamepad, axis) / static_cast(SDL_MAX_SINT16); + if (v > deadzone) return (v - deadzone) / (1.0f - deadzone); + if (v < -deadzone) return (v + deadzone) / (1.0f - deadzone); + return 0.0f; +} + +// Radial (circular) deadzone: deadzone applied to stick magnitude; preserves direction. +static void get_axis_circular(SDL_GamepadAxis axis_x, SDL_GamepadAxis axis_y, float deadzone, + float& out_x, float& out_y) +{ + if (!g_gamepad) { out_x = out_y = 0.0f; return; } + float raw_x = SDL_GetGamepadAxis(g_gamepad, axis_x) / static_cast(SDL_MAX_SINT16); + float raw_y = SDL_GetGamepadAxis(g_gamepad, axis_y) / static_cast(SDL_MAX_SINT16); + float mag = std::hypot(raw_x, raw_y); + float remapped = (mag > deadzone) ? (mag - deadzone) / (1.0f - deadzone) : 0.0f; + float scale = mag > 0.0f ? remapped / mag : 0.0f; + out_x = raw_x * scale; + out_y = raw_y * scale; +} + +static float wrap_angle_pi(float a) +{ + while (a > 3.14159265f) a -= 2.0f * 3.14159265f; + while (a <= -3.14159265f) a += 2.0f * 3.14159265f; + return a; +} + +static float angle_diff(float target, float current) +{ + return wrap_angle_pi(target - current); +} + + +static bool action_is_down(rf::ControlConfigAction action) +{ + int i = static_cast(action); + return i >= 0 && i < k_action_count && g_action_curr[i]; +} + +static bool try_enable_gamepad_sensors() +{ + if (!g_gamepad) return false; + + if (!SDL_GamepadHasSensor(g_gamepad, SDL_SENSOR_GYRO) || + !SDL_GamepadHasSensor(g_gamepad, SDL_SENSOR_ACCEL)) { + xlog::info("Motion sensors are not supported"); + return false; + } + + if (!SDL_SetGamepadSensorEnabled(g_gamepad, SDL_SENSOR_GYRO, true) || + !SDL_SetGamepadSensorEnabled(g_gamepad, SDL_SENSOR_ACCEL, true)) { + xlog::warn("Failed to enable motion sensors: {}", SDL_GetError()); + return false; + } + + xlog::info("Motion sensors are supported"); + g_motion_sensors_supported = true; + gyro_reset_full(); + return true; +} + +static bool try_enable_gamepad_rumble() +{ + if (!g_gamepad) return false; + + if (!SDL_GetBooleanProperty(SDL_GetGamepadProperties(g_gamepad), SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN, false)) { + xlog::info("Rumble is not supported"); + return false; + } + + xlog::info("Rumble is supported"); + g_rumble_supported = true; + return true; +} + +static bool try_enable_gamepad_trigger_rumble() +{ + if (!g_gamepad) return false; + + if (!SDL_GetBooleanProperty(SDL_GetGamepadProperties(g_gamepad), SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN, false)) { + xlog::info("Trigger rumble is not supported"); + return false; + } + + xlog::info("Trigger rumble is supported"); + g_trigger_rumble_supported = true; + return true; +} + +static void try_open_gamepad(SDL_JoystickID id) +{ + g_gamepad = SDL_OpenGamepad(id); + if (!g_gamepad) { + xlog::warn("Failed to open gamepad: {}", SDL_GetError()); + return; + } + + xlog::info("Gamepad connected: {}", SDL_GetGamepadName(g_gamepad)); + try_enable_gamepad_rumble(); + try_enable_gamepad_trigger_rumble(); + try_enable_gamepad_sensors(); +} + +static void inject_action_key(int action, bool down) +{ + if (!rf::gameseq_in_gameplay()) return; + if (rf::console::console_is_visible()) return; + if (!rf::local_player || action < 0 || action >= rf::local_player->settings.controls.num_bindings) + return; + int16_t sc = rf::local_player->settings.controls.bindings[action].scan_codes[0]; + if (sc > 0) + rf::key_process_event(sc, down ? 1 : 0, 0); +} + +static void force_release_action_key(int action) +{ + if (rf::console::console_is_visible()) return; + if (!rf::local_player || action < 0 || action >= rf::local_player->settings.controls.num_bindings) + return; + int16_t sc = rf::local_player->settings.controls.bindings[action].scan_codes[0]; + if (sc > 0) + rf::key_process_event(sc, 0, 0); +} + +static void menu_nav_inject_key(int key) +{ + if (rf::console::console_is_visible()) return; + rf::key_process_event(key, 1, 0); + rf::key_process_event(key, 0, 0); +} + +// Returns true if `state` is a UI overlay where Cancel should be handled by the gamepad menu +// system (close/escape) rather than injecting a raw ESC into gameplay. +static bool is_gamepad_cancellable_menu_state(rf::GameState state) +{ + return state == rf::GS_MESSAGE_LOG + || state == rf::GS_OPTIONS_MENU + || state == rf::GS_MULTI_MENU + || state == rf::GS_HELP + || state == rf::GS_EXTRAS_MENU + || state == rf::GS_MULTI_SERVER_LIST + || state == rf::GS_SAVE_GAME_MENU + || state == rf::GS_LOAD_GAME_MENU + || state == rf::GS_MAIN_MENU + || state == rf::GS_LEVEL_TRANSITION + || state == rf::GS_MULTI_LIMBO + || state == rf::GS_FRAMERATE_TEST_END + || state == rf::GS_CREDITS + || state == rf::GS_BOMB_DEFUSE; +} + +static void menu_nav_handle_confirm() +{ + if (rf::ui::options_controls_waiting_for_key) return; + + if (g_menu_nav.last_nav_was_dpad) { + menu_nav_inject_key(rf::KEY_ENTER); + } else { + POINT pt; + GetCursorPos(&pt); + ScreenToClient(rf::main_wnd, &pt); + SendMessage(rf::main_wnd, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(pt.x, pt.y)); + g_menu_nav.lclick_held = true; + } +} + +static void menu_nav_handle_cancel() +{ + rf::GameState current_state = rf::gameseq_get_state(); + if (is_gamepad_cancellable_menu_state(current_state)) { + if (current_state == rf::GS_MESSAGE_LOG) { + rf::gameseq_set_state(rf::GS_GAMEPLAY, false); + g_message_log_close_cooldown = 0.2f; + } else if (current_state == rf::GS_MULTI_LIMBO + || current_state == rf::GS_LEVEL_TRANSITION + || current_state == rf::GS_NEW_LEVEL) { + } else { + menu_nav_inject_key(rf::KEY_ESC); + } + return; + } + + if ((rf::local_player_entity && rf::entity_is_dying(rf::local_player_entity)) + || (rf::local_player && rf::player_is_dead(rf::local_player))) { + return; + } + + menu_nav_inject_key(rf::KEY_ESC); +} + +static void menu_nav_release_click() +{ + if (!g_menu_nav.lclick_held) return; + POINT pt; + GetCursorPos(&pt); + ScreenToClient(rf::main_wnd, &pt); + SendMessage(rf::main_wnd, WM_LBUTTONUP, 0, MAKELPARAM(pt.x, pt.y)); + g_menu_nav.lclick_held = false; +} + +static void menu_nav_move_cursor(int dx, int dy) +{ + POINT pt; + GetCursorPos(&pt); + + RECT rc; + GetClientRect(rf::main_wnd, &rc); + POINT tl{rc.left, rc.top}, br{rc.right - 1, rc.bottom - 1}; + ClientToScreen(rf::main_wnd, &tl); + ClientToScreen(rf::main_wnd, &br); + pt.x = std::clamp(pt.x + dx, tl.x, br.x); + pt.y = std::clamp(pt.y + dy, tl.y, br.y); + SetCursorPos(pt.x, pt.y); + + POINT client = pt; + ScreenToClient(rf::main_wnd, &client); + SendMessage(rf::main_wnd, WM_MOUSEMOVE, 0, MAKELPARAM(client.x, client.y)); +} + +static int dpad_btn_to_navkey(int btn) +{ + switch (btn) { + case SDL_GAMEPAD_BUTTON_DPAD_UP: return rf::KEY_UP; + case SDL_GAMEPAD_BUTTON_DPAD_DOWN: return rf::KEY_DOWN; + case SDL_GAMEPAD_BUTTON_DPAD_LEFT: return rf::KEY_LEFT; + case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: return rf::KEY_RIGHT; + default: return 0; + } +} + +static void sync_extra_actions_for_scancode(int16_t sc, bool down, int primary_action) +{ + if (!rf::local_player) return; + auto& cc = rf::local_player->settings.controls; + for (int i = 0; i < cc.num_bindings && i < k_action_count; ++i) { + if (i == primary_action) continue; + if (cc.bindings[i].scan_codes[0] == sc || cc.bindings[i].scan_codes[1] == sc) + g_action_curr[i] = down; + } +} + +static void update_trigger_actions() +{ + float rt = SDL_GetGamepadAxis(g_gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) / static_cast(SDL_MAX_SINT16); + float lt = SDL_GetGamepadAxis(g_gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER) / static_cast(SDL_MAX_SINT16); + bool lt_down = lt > 0.5f; + bool rt_down = rt > 0.5f; + + if (lt_down != g_lt_was_down) { + if (lt_down) + inject_action_key(g_trigger_action[0], true); + else + force_release_action_key(g_trigger_action[0]); + if (g_trigger_action[0] >= 0 && g_trigger_action[0] < k_action_count) + g_action_curr[g_trigger_action[0]] = lt_down; + if (g_menu_trigger_action[0] >= 0 && g_menu_trigger_action[0] < k_action_count) + g_action_curr[g_menu_trigger_action[0]] = lt_down; + sync_extra_actions_for_scancode(static_cast(CTRL_GAMEPAD_LEFT_TRIGGER), lt_down, g_trigger_action[0]); + } + if (rt_down != g_rt_was_down) { + if (rt_down) + inject_action_key(g_trigger_action[1], true); + else + force_release_action_key(g_trigger_action[1]); + if (g_trigger_action[1] >= 0 && g_trigger_action[1] < k_action_count) + g_action_curr[g_trigger_action[1]] = rt_down; + if (g_menu_trigger_action[1] >= 0 && g_menu_trigger_action[1] < k_action_count) + g_action_curr[g_menu_trigger_action[1]] = rt_down; + sync_extra_actions_for_scancode(static_cast(CTRL_GAMEPAD_RIGHT_TRIGGER), rt_down, g_trigger_action[1]); + } + + g_lt_was_down = lt_down; + g_rt_was_down = rt_down; +} + + +static bool is_action_held_by_button(int action_idx) +{ + if (!g_gamepad) return false; + for (int b = 0; b < SDL_GAMEPAD_BUTTON_COUNT; ++b) + if ((g_button_map[b] == action_idx || g_button_map_alt[b] == action_idx) + && SDL_GetGamepadButton(g_gamepad, static_cast(b))) + return true; + if (g_trigger_action[0] == action_idx && g_lt_was_down) return true; + if (g_trigger_action[1] == action_idx && g_rt_was_down) return true; + return false; +} + +static void set_movement_key(rf::ControlConfigAction action, bool down) +{ + int idx = static_cast(action); + // A digital button binding takes priority: don't release the key while a button holds it. + // Only applies during gameplay — outside of it no scan codes should be injected at all. + bool in_gameplay = rf::gameseq_in_gameplay(); + if (in_gameplay) + down = down || is_action_held_by_button(idx); + if (g_action_curr[idx] == down) return; + if (in_gameplay && rf::local_player && !rf::console::console_is_visible()) { + int16_t sc = rf::local_player->settings.controls.bindings[idx].scan_codes[0]; + if (sc >= 0) + rf::key_process_event(sc, down ? 1 : 0, 0); + } + g_action_curr[idx] = down; +} + +static void release_movement_keys() +{ + g_move_lx = g_move_ly = 0.0f; + g_move_mag = 0.0f; + + static constexpr rf::ControlConfigAction k_move_actions[] = { + rf::CC_ACTION_FORWARD, + rf::CC_ACTION_BACKWARD, + rf::CC_ACTION_SLIDE_LEFT, + rf::CC_ACTION_SLIDE_RIGHT, + }; + for (rf::ControlConfigAction action : k_move_actions) { + int idx = static_cast(action); + if (g_action_curr[idx] && rf::local_player && !rf::console::console_is_visible()) { + int16_t sc = rf::local_player->settings.controls.bindings[idx].scan_codes[0]; + if (sc >= 0) + rf::key_process_event(sc, 0, 0); + } + g_action_curr[idx] = false; + } +} + +static void update_stick_movement() +{ + if (!rf::local_player) + return; + + if (!rf::gameseq_in_gameplay() || is_gamepad_menu_state()) { + if (!is_freelook_camera()) { + release_movement_keys(); + return; + } + } + + if (rf::local_player_entity && rf::entity_is_dying(rf::local_player_entity)) { + release_movement_keys(); + reset_gamepad_input_state(); + return; + } + + // Suppress movement input while viewing a security camera + if (rf::local_player && rf::local_player->view_from_handle != -1) { + release_movement_keys(); + return; + } + + SDL_GamepadAxis mov_x = g_alpine_game_config.gamepad_swap_sticks ? SDL_GAMEPAD_AXIS_RIGHTX : SDL_GAMEPAD_AXIS_LEFTX; + SDL_GamepadAxis mov_y = g_alpine_game_config.gamepad_swap_sticks ? SDL_GAMEPAD_AXIS_RIGHTY : SDL_GAMEPAD_AXIS_LEFTY; + float mov_dz = g_alpine_game_config.gamepad_swap_sticks ? g_alpine_game_config.gamepad_look_deadzone + : g_alpine_game_config.gamepad_move_deadzone; + + // Cross-shaped deadzone for movement; slightly enlarged to tighten the neutral zone. + constexpr float k_movement_dz_multiplier = 1.1f; + float lx = get_axis(mov_x, mov_dz * k_movement_dz_multiplier); + float ly = get_axis(mov_y, mov_dz * k_movement_dz_multiplier); + + g_move_lx = lx; + g_move_ly = ly; + g_move_mag = std::min(1.0f, std::sqrt(lx * lx + ly * ly)); + + set_movement_key(rf::CC_ACTION_FORWARD, ly < 0.0f); + set_movement_key(rf::CC_ACTION_BACKWARD, ly > 0.0f); + set_movement_key(rf::CC_ACTION_SLIDE_LEFT, lx < 0.0f); + set_movement_key(rf::CC_ACTION_SLIDE_RIGHT, lx > 0.0f); +} + +static SDL_GamepadButton get_menu_confirm_button() +{ + if (g_gamepad && SDL_GetGamepadButtonLabel(g_gamepad, SDL_GAMEPAD_BUTTON_EAST) == SDL_GAMEPAD_BUTTON_LABEL_A) + return SDL_GAMEPAD_BUTTON_EAST; + return SDL_GAMEPAD_BUTTON_SOUTH; +} + +static SDL_GamepadButton get_menu_cancel_button() +{ + if (g_gamepad && SDL_GetGamepadButtonLabel(g_gamepad, SDL_GAMEPAD_BUTTON_SOUTH) == SDL_GAMEPAD_BUTTON_LABEL_B) + return SDL_GAMEPAD_BUTTON_SOUTH; + return SDL_GAMEPAD_BUTTON_EAST; +} + +static bool menu_nav_on_button_down(int btn) +{ + const SDL_GamepadButton confirm_btn = get_menu_confirm_button(); + const SDL_GamepadButton cancel_btn = get_menu_cancel_button(); + + if (btn == static_cast(confirm_btn)) { + menu_nav_handle_confirm(); + return true; + } + if (btn == static_cast(cancel_btn)) { + menu_nav_handle_cancel(); + return true; + } + switch (btn) { + case SDL_GAMEPAD_BUTTON_DPAD_UP: + case SDL_GAMEPAD_BUTTON_DPAD_DOWN: + case SDL_GAMEPAD_BUTTON_DPAD_LEFT: + case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: + if (!rf::ui::options_controls_waiting_for_key) { + menu_nav_inject_key(dpad_btn_to_navkey(btn)); + g_menu_nav.last_nav_was_dpad = true; + g_menu_nav.repeat_btn = btn; + g_menu_nav.repeat_timer = 0.4f; + } + return true; + default: + return false; + } +} + +static void menu_nav_on_button_up(int btn) +{ + if (btn == g_menu_nav.repeat_btn) + g_menu_nav.repeat_btn = -1; + if (btn == static_cast(get_menu_confirm_button())) + menu_nav_release_click(); +} + +static void disconnect_active_gamepad() +{ + SDL_CloseGamepad(g_gamepad); + g_gamepad = nullptr; + g_motion_sensors_supported = false; + g_rumble_supported = false; + g_trigger_rumble_supported = false; + release_movement_keys(); + for (int b = 0; b < SDL_GAMEPAD_BUTTON_COUNT; ++b) { + inject_action_key(g_button_map[b], false); + inject_action_key(g_button_map_alt[b], false); + } + inject_action_key(g_trigger_action[0], false); + inject_action_key(g_trigger_action[1], false); + menu_nav_release_click(); + reset_gamepad_input_state(); +} + +static void handle_gamepad_added(const SDL_GamepadDeviceEvent& ev) +{ + if (!g_gamepad) { + try_open_gamepad(ev.which); + return; + } + if (SDL_GetGamepadID(g_gamepad) == ev.which) + return; + xlog::info("New gamepad connected, hotswapping from '{}' to '{}'", + SDL_GetGamepadName(g_gamepad), SDL_GetGamepadNameForID(ev.which)); + disconnect_active_gamepad(); + try_open_gamepad(ev.which); +} + +static void handle_gamepad_removed(const SDL_GamepadDeviceEvent& ev) +{ + if (!(g_gamepad && SDL_GetGamepadID(g_gamepad) == ev.which)) + return; + + xlog::info("Gamepad disconnected"); + disconnect_active_gamepad(); + + // Fall back to any remaining connected gamepad + int fallback_count = 0; + SDL_JoystickID* fallback_ids = SDL_GetGamepads(&fallback_count); + if (fallback_ids) { + for (int i = 0; i < fallback_count; ++i) { + if (fallback_ids[i] != ev.which) { + try_open_gamepad(fallback_ids[i]); + break; + } + } + SDL_free(fallback_ids); + } + if (g_gamepad) + xlog::info("Fell back to gamepad: '{}'", SDL_GetGamepadName(g_gamepad)); +} + +static void handle_gamepad_button_down(const SDL_GamepadButtonEvent& ev) +{ + if (g_message_log_close_cooldown > 0.0f) return; + if (!is_gamepad_input_active() || SDL_GetGamepadID(g_gamepad) != ev.which) return; + + g_last_input_was_gamepad = true; + + if (ui_ctrl_bindings_view_active() && rf::ui::options_controls_waiting_for_key) { + if (ev.button == SDL_GAMEPAD_BUTTON_START) { + menu_nav_inject_key(rf::KEY_ESC); + } else { + g_rebind_pending_sc = CTRL_GAMEPAD_SCAN_BASE + ev.button; + rf::key_process_event(static_cast(CTRL_REBIND_SENTINEL), 1, 0); + } + return; + } + + if (ev.button == SDL_GAMEPAD_BUTTON_START) { + // START always behaves exactly like ESC — unconditionally, in any state. + // The Cancel face button has its own restricted logic via menu_nav_handle_cancel(). + menu_nav_inject_key(rf::KEY_ESC); + } + + bool in_menu_nav_state = is_gamepad_menu_navigation_state(); + bool in_spectate_state = multi_spectate_is_spectating(); + if (in_menu_nav_state) + g_menu_nav.deferred_btn_down = ev.button; + + bool is_menu_nav_button = in_menu_nav_state && !in_spectate_state + && (ev.button == static_cast(get_menu_confirm_button()) + || ev.button == static_cast(get_menu_cancel_button())); + + if (!is_menu_nav_button && ev.button < SDL_GAMEPAD_BUTTON_COUNT) { + int mapped = g_button_map[ev.button]; + if (mapped >= 0) { + inject_action_key(mapped, true); + g_action_curr[mapped] = true; + } + int alt_mapped = g_button_map_alt[ev.button]; + if (alt_mapped >= 0) { + inject_action_key(alt_mapped, true); + g_action_curr[alt_mapped] = true; + } + int menu_mapped = g_menu_button_map[ev.button]; + if (menu_mapped >= 0) + g_action_curr[menu_mapped] = true; + int16_t gp_sc = static_cast(CTRL_GAMEPAD_SCAN_BASE + ev.button); + sync_extra_actions_for_scancode(gp_sc, true, mapped); + } +} + +static void handle_gamepad_button_up(const SDL_GamepadButtonEvent& ev) +{ + if (!is_gamepad_input_active() || SDL_GetGamepadID(g_gamepad) != ev.which) return; + + if (is_gamepad_menu_navigation_state()) + g_menu_nav.deferred_btn_up = ev.button; + + if (ev.button < SDL_GAMEPAD_BUTTON_COUNT) { + int mapped = g_button_map[ev.button]; + if (mapped >= 0) { + force_release_action_key(mapped); + g_action_curr[mapped] = false; + } + int alt_mapped = g_button_map_alt[ev.button]; + if (alt_mapped >= 0) { + force_release_action_key(alt_mapped); + g_action_curr[alt_mapped] = false; + } + int menu_mapped = g_menu_button_map[ev.button]; + if (menu_mapped >= 0) + g_action_curr[menu_mapped] = false; + int16_t gp_sc = static_cast(CTRL_GAMEPAD_SCAN_BASE + ev.button); + sync_extra_actions_for_scancode(gp_sc, false, mapped); + } +} + +static void handle_gamepad_axis_motion(const SDL_GamepadAxisEvent& ev) +{ + if (g_message_log_close_cooldown > 0.0f) return; + if (!is_gamepad_input_active() || SDL_GetGamepadID(g_gamepad) != ev.which) return; + + float v = ev.value / static_cast(SDL_MAX_SINT16); + float deadzone = 0.0f; + switch (static_cast(ev.axis)) { + case SDL_GAMEPAD_AXIS_LEFTX: + case SDL_GAMEPAD_AXIS_LEFTY: + deadzone = g_alpine_game_config.gamepad_move_deadzone; + break; + case SDL_GAMEPAD_AXIS_RIGHTX: + case SDL_GAMEPAD_AXIS_RIGHTY: + deadzone = g_alpine_game_config.gamepad_look_deadzone; + break; + case SDL_GAMEPAD_AXIS_LEFT_TRIGGER: + case SDL_GAMEPAD_AXIS_RIGHT_TRIGGER: + deadzone = 0.5f; + break; + default: + break; + } + if (std::abs(v) > deadzone) + g_last_input_was_gamepad = true; +} + +static void handle_gamepad_sensor_update(const SDL_GamepadSensorEvent& ev) +{ + if (!g_motion_sensors_supported) return; + if (!g_gamepad || SDL_GetGamepadID(g_gamepad) != ev.which) return; + + constexpr float rad2deg = 180.0f / 3.14159265f; + + switch (ev.sensor) { + case SDL_SENSOR_GYRO: + g_sensor_gyro[0] = ev.data[0] * rad2deg; + g_sensor_gyro[1] = ev.data[1] * rad2deg; + g_sensor_gyro[2] = ev.data[2] * rad2deg; + break; + case SDL_SENSOR_ACCEL: + g_sensor_accel[0] = ev.data[0] / SDL_STANDARD_GRAVITY; + g_sensor_accel[1] = ev.data[1] / SDL_STANDARD_GRAVITY; + g_sensor_accel[2] = ev.data[2] / SDL_STANDARD_GRAVITY; + g_sensor_last_accel_ts = ev.sensor_timestamp; + break; + default: + break; + } + + if (ev.sensor == SDL_SENSOR_GYRO && g_sensor_last_gyro_ts && g_sensor_last_accel_ts) { + float dt = static_cast(ev.sensor_timestamp - g_sensor_last_gyro_ts) * 1e-9f; + if (dt > 0.0f && dt < 0.1f) { + gyro_process_motion( + g_sensor_gyro[0], g_sensor_gyro[1], g_sensor_gyro[2], + g_sensor_accel[0], g_sensor_accel[1], g_sensor_accel[2], + dt); + } + } + + if (ev.sensor == SDL_SENSOR_GYRO) + g_sensor_last_gyro_ts = ev.sensor_timestamp; +} + +void gamepad_sdl_poll() +{ + memcpy(g_action_prev, g_action_curr, sizeof(g_action_curr)); + + SDL_Event events[64]; + int n; + while ((n = SDL_PeepEvents(events, static_cast(std::size(events)), + SDL_GETEVENT, SDL_EVENT_GAMEPAD_AXIS_MOTION, + SDL_EVENT_GAMEPAD_STEAM_HANDLE_UPDATED)) > 0) { + for (int i = 0; i < n; ++i) { + const SDL_Event& ev = events[i]; + switch (ev.type) { + case SDL_EVENT_GAMEPAD_AXIS_MOTION: + handle_gamepad_axis_motion(ev.gaxis); + break; + case SDL_EVENT_GAMEPAD_BUTTON_DOWN: + handle_gamepad_button_down(ev.gbutton); + break; + case SDL_EVENT_GAMEPAD_BUTTON_UP: + handle_gamepad_button_up(ev.gbutton); + break; + case SDL_EVENT_GAMEPAD_ADDED: + handle_gamepad_added(ev.gdevice); + break; + case SDL_EVENT_GAMEPAD_REMOVED: + handle_gamepad_removed(ev.gdevice); + break; + case SDL_EVENT_GAMEPAD_SENSOR_UPDATE: + handle_gamepad_sensor_update(ev.gsensor); + break; + default: + break; + } + } + } + if (n < 0) + xlog::warn("SDL Events error: {}", SDL_GetError()); + + // Discard non-gamepad SDL events that accumulated in the queue. + SDL_FlushEvents(SDL_EVENT_FIRST, + static_cast(SDL_EVENT_GAMEPAD_AXIS_MOTION - 1)); + SDL_FlushEvents( + static_cast(SDL_EVENT_GAMEPAD_STEAM_HANDLE_UPDATED + 1), + SDL_EVENT_LAST); +} + +static void menu_nav_handle_cursor_frame() +{ + constexpr float k_menu_stick_deadzone = 0.24f; + constexpr float k_base_speed = 1000.0f; + float sx, sy; + get_axis_circular(SDL_GAMEPAD_AXIS_LEFTX, SDL_GAMEPAD_AXIS_LEFTY, k_menu_stick_deadzone, sx, sy); + if (sx == 0.0f && sy == 0.0f) return; + float speed = k_base_speed * (static_cast(rf::gr::screen_height()) / 600.0f); + int dx = static_cast(sx * speed * rf::frametime); + int dy = static_cast(sy * speed * rf::frametime); + if (dx == 0 && dy == 0) return; + menu_nav_move_cursor(dx, dy); + g_menu_nav.last_nav_was_dpad = false; + g_last_input_was_gamepad = true; +} + +static void menu_nav_tick_dpad_repeat() +{ + if (g_menu_nav.repeat_btn < 0 || rf::ui::options_controls_waiting_for_key) return; + g_menu_nav.repeat_timer -= rf::frametime; + if (g_menu_nav.repeat_timer <= 0.0f) { + menu_nav_inject_key(dpad_btn_to_navkey(g_menu_nav.repeat_btn)); + g_menu_nav.repeat_timer = 0.12f; + } +} + +static void menu_nav_tick_scroll() +{ + constexpr float k_scroll_deadzone = 0.24f; + float ry = get_axis(SDL_GAMEPAD_AXIS_RIGHTY, k_scroll_deadzone); + if (ry == 0.0f) { + g_menu_nav.scroll_timer = 0.0f; + return; + } + g_menu_nav.scroll_timer -= rf::frametime; + if (g_menu_nav.scroll_timer > 0.0f) return; + rf::mouse_dz = (ry < 0.0f) ? 1 : -1; + g_pending_scroll_delta = rf::mouse_dz; + if (rf::gameseq_get_state() == rf::GS_MESSAGE_LOG) { + if (ry < 0.0f) + rf::ui::message_log_up_on_click(-1, -1); + else + rf::ui::message_log_down_on_click(-1, -1); + } + g_menu_nav.scroll_timer = 0.12f; +} + +int gamepad_consume_menu_scroll() +{ + int v = g_pending_scroll_delta; + g_pending_scroll_delta = 0; + return v; +} + +static void gamepad_do_menu_frame() +{ + if (g_menu_nav.deferred_btn_down != -1) { + if (menu_nav_on_button_down(g_menu_nav.deferred_btn_down)) + g_last_input_was_gamepad = true; + g_menu_nav.deferred_btn_down = -1; + } + if (g_menu_nav.deferred_btn_up != -1) { + menu_nav_on_button_up(g_menu_nav.deferred_btn_up); + g_menu_nav.deferred_btn_up = -1; + } + + menu_nav_handle_cursor_frame(); + menu_nav_tick_dpad_repeat(); + menu_nav_tick_scroll(); +} + +void gamepad_do_frame() +{ + gamepad_sdl_poll(); + + if (g_message_log_close_cooldown > 0.0f) { + g_message_log_close_cooldown -= rf::frametime; + if (g_message_log_close_cooldown < 0.0f) + g_message_log_close_cooldown = 0.0f; + return; + } + + gyro_update_calibration_mode(); + + if (!is_gamepad_input_active()) + return; + + if (ui_ctrl_bindings_view_active() && rf::ui::options_controls_waiting_for_key) { + float lt = SDL_GetGamepadAxis(g_gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER) / static_cast(SDL_MAX_SINT16); + float rt = SDL_GetGamepadAxis(g_gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) / static_cast(SDL_MAX_SINT16); + if (lt > 0.5f && !g_lt_was_down) { + g_rebind_pending_sc = CTRL_GAMEPAD_LEFT_TRIGGER; + rf::key_process_event(static_cast(CTRL_REBIND_SENTINEL), 1, 0); + } + if (rt > 0.5f && !g_rt_was_down) { + g_rebind_pending_sc = CTRL_GAMEPAD_RIGHT_TRIGGER; + rf::key_process_event(static_cast(CTRL_REBIND_SENTINEL), 1, 0); + } + } + + update_trigger_actions(); + update_stick_movement(); + + if (is_gamepad_menu_navigation_state()) + gamepad_do_menu_frame(); + + g_local_player_body_vmesh = rf::local_player ? rf::get_player_entity_parent_vmesh(rf::local_player) : nullptr; + + if (g_gamepad) + rumble_do_frame(); +} + +static bool is_gamepad_controls_rebind_active() +{ + return ui_ctrl_bindings_view_active() && rf::ui::options_controls_waiting_for_key; +} + +static bool is_key_allowed_during_rebind(int scan_code) +{ + if (scan_code == CTRL_REBIND_SENTINEL) + return true; + if ((scan_code & rf::KEY_MASK) == rf::KEY_ESC) + return true; + return false; +} + +FunHook key_process_event_hook{ + 0x0051E6C0, + [](int scan_code, int key_down, int delta_time) { + if (is_gamepad_controls_rebind_active() && !is_key_allowed_during_rebind(scan_code)) + return; + key_process_event_hook.call_target(scan_code, key_down, delta_time); + } +}; + +FunHook mouse_was_button_pressed_hook{ + 0x0051E5D0, + [](int btn_idx) -> int { + if (is_gamepad_controls_rebind_active()) + return 0; + return mouse_was_button_pressed_hook.call_target(btn_idx); + } +}; + +// Flick stick is based on GyroWiki documents +// http://gyrowiki.jibbsmart.com/blog:good-gyro-controls-part-2:the-flick-stick +static void gamepad_apply_flickstick(SDL_GamepadAxis cam_x, SDL_GamepadAxis cam_y, + float& yaw_delta, float& pitch_delta) +{ + yaw_delta = 0.0f; + pitch_delta = 0.0f; + + // Raw axes — no deadzone remapping to avoid quadrant snapping in the angle math. + float rx = g_gamepad ? SDL_GetGamepadAxis(g_gamepad, cam_x) / static_cast(SDL_MAX_SINT16) : 0.0f; + float ry = g_gamepad ? SDL_GetGamepadAxis(g_gamepad, cam_y) / static_cast(SDL_MAX_SINT16) : 0.0f; + + float stick_mag = std::hypot(rx, ry); + bool in_flick_zone = stick_mag > g_alpine_game_config.gamepad_flickstick_deadzone; + bool fully_released = stick_mag <= g_alpine_game_config.gamepad_flickstick_release_deadzone; + float smooth = g_alpine_game_config.gamepad_flickstick_smoothing; + float sweep = g_alpine_game_config.gamepad_flickstick_sweep; + + float flick_angle = std::atan2(rx, -ry); + + if (in_flick_zone) { + if (!g_flickstick_was_in_flick_zone) { + g_flickstick_flick_progress = 0.0f; + g_flickstick_flick_size = flick_angle * sweep; + } else { + float turn_delta = angle_diff(flick_angle, g_flickstick_prev_stick_angle) * sweep; + + if (smooth > 0.0f) { + constexpr float k_max_threshold = 0.3f; + float threshold2 = smooth * k_max_threshold; + float threshold1 = threshold2 * 0.5f; + float direct_weight = std::clamp((std::abs(turn_delta) - threshold1) / (threshold2 - threshold1), 0.0f, 1.0f); + g_flickstick_turn_smooth_idx = (g_flickstick_turn_smooth_idx + 1) % k_turn_smooth_buf_size; + g_flickstick_turn_smooth_buf[g_flickstick_turn_smooth_idx] = turn_delta * (1.0f - direct_weight); + float avg = 0.0f; + for (int i = 0; i < k_turn_smooth_buf_size; ++i) avg += g_flickstick_turn_smooth_buf[i]; + turn_delta = turn_delta * direct_weight + avg / k_turn_smooth_buf_size; + } + + yaw_delta += turn_delta; + } + } else if (fully_released && g_flickstick_was_in_flick_zone) { + memset(g_flickstick_turn_smooth_buf, 0, sizeof(g_flickstick_turn_smooth_buf)); + g_flickstick_turn_smooth_idx = 0; + } + + g_flickstick_prev_stick_angle = flick_angle; + g_flickstick_was_in_flick_zone = in_flick_zone || (g_flickstick_was_in_flick_zone && !fully_released); + + constexpr float k_flick_time = 0.1f; + if (g_flickstick_flick_progress < k_flick_time) { + float last_t = g_flickstick_flick_progress / k_flick_time; + g_flickstick_flick_progress = std::min(g_flickstick_flick_progress + rf::frametime, k_flick_time); + float this_t = g_flickstick_flick_progress / k_flick_time; + auto warp_ease_out = [](float t) -> float { float f = 1.0f - t; return 1.0f - f * f; }; + yaw_delta += (warp_ease_out(this_t) - warp_ease_out(last_t)) * g_flickstick_flick_size; + } +} + +static void gamepad_apply_joystick(SDL_GamepadAxis cam_x, SDL_GamepadAxis cam_y, float cam_dz, + float zoom_sens, float& yaw_delta, float& pitch_delta) +{ + float rx, ry; + get_axis_circular(cam_x, cam_y, cam_dz, rx, ry); + + float joy_pitch_sign = g_alpine_game_config.gamepad_joy_invert_y ? 1.0f : -1.0f; + // Reset flickstick state so switching back to flickstick always starts a fresh flick. + g_flickstick_was_in_flick_zone = false; + g_flickstick_flick_size = 0.0f; + memset(g_flickstick_turn_smooth_buf, 0, sizeof(g_flickstick_turn_smooth_buf)); + g_flickstick_turn_smooth_idx = 0; + yaw_delta = rf::frametime * g_alpine_game_config.gamepad_joy_sensitivity * rx * zoom_sens; + pitch_delta = joy_pitch_sign * rf::frametime * g_alpine_game_config.gamepad_joy_sensitivity * ry * zoom_sens; +} + +static void gamepad_apply_gyro(bool has_player_entity, float zoom_sens, float& yaw_delta, float& pitch_delta) +{ + float gyro_pitch, gyro_yaw; + gyro_get_axis_orientation(gyro_pitch, gyro_yaw); + gyro_apply_smoothing(gyro_pitch, gyro_yaw); + gyro_apply_tightening(gyro_pitch, gyro_yaw); + + constexpr float deg2rad = 3.14159265f / 180.0f; + float sens = g_alpine_game_config.gamepad_gyro_sensitivity * deg2rad * rf::frametime; + + float gyro_zoom_sens = 1.0f; + if (has_player_entity) { + if (rf::local_player->fpgun_data.scanning_for_target) { + gyro_zoom_sens *= g_gamepad_scanner_gyro_sensitivity_value; + } else { + float zoom = rf::local_player->fpgun_data.zoom_factor; + if (zoom > 1.0f) { + if (g_alpine_game_config.scope_static_sensitivity) { + gyro_zoom_sens *= g_gamepad_scope_gyro_sensitivity_value; + } else { + constexpr float zoom_scale = 30.0f; + float divisor = (zoom - 1.0f) * g_gamepad_scope_gyro_applied_dynamic_sensitivity_value * zoom_scale; + if (divisor > 1.0f) { + gyro_zoom_sens /= divisor; + } + } + } + } + } + + float out_yaw = -gyro_yaw * sens * gyro_zoom_sens; + float out_pitch = gyro_pitch * sens * gyro_zoom_sens; + + if (g_alpine_game_config.gamepad_gyro_invert_y) + out_pitch = -out_pitch; + gyro_apply_vh_mixer(out_pitch, out_yaw); + + yaw_delta += out_yaw; + pitch_delta += out_pitch; +} + +void consume_raw_gamepad_deltas(float& pitch_delta, float& yaw_delta) +{ + pitch_delta = 0.0f; + yaw_delta = 0.0f; + + if (g_message_log_close_cooldown > 0.0f) { + return; + } + + const bool has_player_entity = rf::local_player_entity && !rf::entity_is_dying(rf::local_player_entity); + const bool is_freelook = !has_player_entity && is_freelook_camera(); + if (!is_gamepad_input_active() || !rf::keep_mouse_centered) { + reset_gamepad_input_state(); + return; + } + if (!has_player_entity && !is_freelook) { + reset_gamepad_input_state(); + return; + } + + // Suppress look input while viewing a security camera + if (rf::local_player && rf::local_player->view_from_handle != -1) { + release_movement_keys(); + reset_gamepad_input_state(); + return; + } + + SDL_GamepadAxis cam_x = g_alpine_game_config.gamepad_swap_sticks ? SDL_GAMEPAD_AXIS_LEFTX : SDL_GAMEPAD_AXIS_RIGHTX; + SDL_GamepadAxis cam_y = g_alpine_game_config.gamepad_swap_sticks ? SDL_GAMEPAD_AXIS_LEFTY : SDL_GAMEPAD_AXIS_RIGHTY; + float cam_dz = g_alpine_game_config.gamepad_swap_sticks ? g_alpine_game_config.gamepad_move_deadzone + : g_alpine_game_config.gamepad_look_deadzone; + + bool is_scoped_or_scanning = has_player_entity + && (rf::player_fpgun_is_zoomed(rf::local_player) || rf::local_player->fpgun_data.scanning_for_target); + + update_gamepad_scoped_sensitivities(); + + float gamepad_zoom_sens = 1.0f; + if (has_player_entity) { + if (rf::local_player->fpgun_data.scanning_for_target) { + gamepad_zoom_sens *= g_gamepad_scanner_sensitivity_value; + } else { + float zoom = rf::local_player->fpgun_data.zoom_factor; + if (zoom > 1.0f) { + if (g_alpine_game_config.scope_static_sensitivity) { + gamepad_zoom_sens *= g_gamepad_scope_sensitivity_value; + } else { + constexpr float zoom_scale = 30.0f; + float divisor = (zoom - 1.0f) * g_gamepad_scope_applied_dynamic_sensitivity_value * zoom_scale; + if (divisor > 1.0f) { + gamepad_zoom_sens /= divisor; + } + } + } + } + } + + // Use flickstick when not scoped/scanning; joystick while scoped/scanning for consistent aim. + if (g_alpine_game_config.gamepad_joy_camera && !is_freelook && !is_scoped_or_scanning) { + gamepad_apply_flickstick(cam_x, cam_y, yaw_delta, pitch_delta); + yaw_delta *= gamepad_zoom_sens; + pitch_delta *= gamepad_zoom_sens; + } else { + gamepad_apply_joystick(cam_x, cam_y, cam_dz, gamepad_zoom_sens, yaw_delta, pitch_delta); + } + + bool allow_gyro = !is_freelook + && g_motion_sensors_supported + && g_alpine_game_config.gamepad_gyro_enabled + && g_alpine_game_config.gamepad_gyro_sensitivity > 0.0f + && gyro_modifier_is_active(); + + if (allow_gyro) + gamepad_apply_gyro(has_player_entity, gamepad_zoom_sens, yaw_delta, pitch_delta); + + g_camera_gamepad_dx += pitch_delta; + g_camera_gamepad_dy += yaw_delta; + pitch_delta = g_camera_gamepad_dx; + yaw_delta = g_camera_gamepad_dy; + g_camera_gamepad_dx = 0.0f; + g_camera_gamepad_dy = 0.0f; +} + +void flush_freelook_gamepad_deltas() +{ + if (!is_freelook_camera() || !rf::local_player || !rf::local_player->cam) + return; + rf::Entity* cam_entity = rf::local_player->cam->camera_entity; + if (!cam_entity) + return; + + float gamepad_pitch = 0.0f, gamepad_yaw = 0.0f; + consume_raw_gamepad_deltas(gamepad_pitch, gamepad_yaw); + if (gamepad_pitch == 0.0f && gamepad_yaw == 0.0f) + return; + + cam_entity->control_data.eye_phb.x += gamepad_pitch; + cam_entity->control_data.phb.y += gamepad_yaw; +} + +FunHook control_is_control_down_hook{ + 0x00430F40, + [](rf::ControlConfig* ccp, rf::ControlConfigAction action) -> bool { + return control_is_control_down_hook.call_target(ccp, action) || action_is_down(action); + }, +}; + +FunHook control_config_check_pressed_hook{ + 0x0043D4F0, + [](rf::ControlConfig* ccp, rf::ControlConfigAction action, bool* just_pressed) -> bool { + bool result = control_config_check_pressed_hook.call_target(ccp, action, just_pressed); + if (result) return true; + + int idx = static_cast(action); + if (idx < 0 || idx >= k_action_count || !g_action_curr[idx]) + return false; + + bool is_just_pressed = !g_action_prev[idx]; + if (ccp->bindings[idx].press_mode != 0 || is_just_pressed) { + if (just_pressed) *just_pressed = is_just_pressed; + return true; + } + return false; + }, +}; + +static bool is_local_player_vehicle(rf::Entity* entity) +{ + if (!rf::local_player_entity || !rf::entity_in_vehicle(rf::local_player_entity)) + return false; + rf::Entity* vehicle = rf::entity_from_handle(rf::local_player_entity->host_handle); + return vehicle == entity; +} + +FunHook physics_simulate_entity_hook{ + 0x0049F3C0, + [](rf::Entity* entity) { + if (entity == rf::local_player_entity && rf::entity_is_dying(entity)) { + entity->ai.ci.move.x = 0.0f; + entity->ai.ci.move.z = 0.0f; + } else if (is_gamepad_input_active() && entity == rf::local_player_entity && g_move_mag > 0.001f) { + if (rf::is_multi) { + float inv_mag = 1.0f / g_move_mag; + entity->ai.ci.move.x = g_move_lx * inv_mag; + entity->ai.ci.move.z = -g_move_ly * inv_mag; + } else { + entity->ai.ci.move.x = g_move_lx; + entity->ai.ci.move.z = -g_move_ly; + } + } + + // Inject stick + gyro into vehicle rotation (ci.rot, range ±1.0 like keyboard input). + if (is_gamepad_input_active() && is_local_player_vehicle(entity)) { + SDL_GamepadAxis rot_x = g_alpine_game_config.gamepad_swap_sticks ? SDL_GAMEPAD_AXIS_LEFTX : SDL_GAMEPAD_AXIS_RIGHTX; + SDL_GamepadAxis rot_y = g_alpine_game_config.gamepad_swap_sticks ? SDL_GAMEPAD_AXIS_LEFTY : SDL_GAMEPAD_AXIS_RIGHTY; + float rot_dz = g_alpine_game_config.gamepad_swap_sticks ? g_alpine_game_config.gamepad_move_deadzone + : g_alpine_game_config.gamepad_look_deadzone; + constexpr float k_vehicle_dz_multiplier = 1.2f; + float rx = get_axis(rot_x, rot_dz * k_vehicle_dz_multiplier); + float ry = get_axis(rot_y, rot_dz * k_vehicle_dz_multiplier); + float joy_pitch_sign = g_alpine_game_config.gamepad_joy_invert_y ? 1.0f : -1.0f; + // Normalize so that the default sensitivity (2.5) produces 1:1 vehicle scale. + constexpr float k_default_sens = 2.5f; + float joy_sens = g_alpine_game_config.gamepad_joy_sensitivity / k_default_sens; + entity->ai.ci.rot.y += std::clamp(rx * joy_sens, -1.0f, 1.0f); + entity->ai.ci.rot.x += std::clamp(joy_pitch_sign * ry * joy_sens, -1.0f, 1.0f); + + // 1/90 scale: 90 deg/s gyro = full keyboard deflection at default sensitivity. + // Normalized by k_default_sens so gyro and joystick sensitivity values are equivalent. + if (g_motion_sensors_supported && g_alpine_game_config.gamepad_gyro_enabled + && g_alpine_game_config.gamepad_gyro_vehicle_camera + && g_alpine_game_config.gamepad_gyro_sensitivity > 0.0f + && gyro_modifier_is_active()) { + float gyro_pitch, gyro_yaw; + gyro_get_axis_orientation(gyro_pitch, gyro_yaw); + gyro_apply_smoothing(gyro_pitch, gyro_yaw); + gyro_apply_tightening(gyro_pitch, gyro_yaw); + gyro_apply_vh_mixer(gyro_pitch, gyro_yaw); + + constexpr float gyro_to_rot = 1.0f / 90.0f; + float sens = g_alpine_game_config.gamepad_gyro_sensitivity / k_default_sens; + float pitch_sign = g_alpine_game_config.gamepad_gyro_invert_y ? -1.0f : 1.0f; + entity->ai.ci.rot.y += std::clamp(-gyro_yaw * gyro_to_rot * sens, -1.0f, 1.0f); + entity->ai.ci.rot.x += std::clamp(pitch_sign * gyro_pitch * gyro_to_rot * sens, -1.0f, 1.0f); + } + } + + physics_simulate_entity_hook.call_target(entity); + }, +}; + +static bool fpgun_should_scale(rf::Player* player) +{ + if (player != rf::local_player || rf::is_multi) + return false; + if (!(g_move_mag > 0.001f && g_move_mag < 0.999f)) + return false; + if (!(rf::player_fpgun_is_in_state_anim(player, rf::WS_IDLE) + || rf::player_fpgun_is_in_state_anim(player, rf::WS_RUN))) + return false; + + if (rf::player_fpgun_action_anim_is_playing(player, rf::WA_FIRE) + || rf::player_fpgun_action_anim_is_playing(player, rf::WA_ALT_FIRE) + || rf::player_fpgun_action_anim_is_playing(player, rf::WA_FIRE_FAIL) + || rf::player_fpgun_action_anim_is_playing(player, rf::WA_DRAW) + || rf::player_fpgun_action_anim_is_playing(player, rf::WA_HOLSTER) + || rf::player_fpgun_action_anim_is_playing(player, rf::WA_RELOAD) + || rf::player_fpgun_action_anim_is_playing(player, rf::WA_JUMP) + || rf::player_fpgun_action_anim_is_playing(player, rf::WA_CUSTOM_START) + || rf::player_fpgun_action_anim_is_playing(player, rf::WA_CUSTOM_LEAVE)) + { + return false; + } + + return true; +} + +static FunHook player_fpgun_process_hook{ + 0x004AA6D0, + [](rf::Player* player) { + bool scale = fpgun_should_scale(player); + if (scale) + g_scaling_fpgun_vmesh = true; + + player_fpgun_process_hook.call_target(player); + + if (scale) + g_scaling_fpgun_vmesh = false; + }, +}; + +static FunHook vmesh_process_hook{ + 0x00503360, + [](rf::VMesh* vmesh, float frametime, int increment_only, rf::Vector3* pos, rf::Matrix3* orient, int lod_level) { + bool is_player_body = vmesh == g_local_player_body_vmesh; + if (!rf::is_multi && g_move_mag > 0.001f && g_move_mag < 0.999f + && (is_player_body || g_scaling_fpgun_vmesh)) + frametime *= g_move_mag; + vmesh_process_hook.call_target(vmesh, frametime, increment_only, pos, orient, lod_level); + }, +}; + +ConsoleCommand2 joy_sens_cmd{ + "joy_sens", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_joy_sensitivity = std::max(0.0f, val.value()); + rf::console::print("Gamepad sensitivity: {:.4f}", g_alpine_game_config.gamepad_joy_sensitivity); + }, + "Set gamepad look sensitivity (default 5.0)", + "joy_sens [value]", +}; + +ConsoleCommand2 joy_move_deadzone_cmd{ + "joy_move_deadzone", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_move_deadzone = std::clamp(val.value(), 0.0f, 0.9f); + rf::console::print("Gamepad move (left stick) deadzone: {:.2f}", g_alpine_game_config.gamepad_move_deadzone); + }, + "Set left stick deadzone 0.0-0.9 (default 0.25)", + "joy_move_deadzone [value]", +}; + +ConsoleCommand2 joy_look_deadzone_cmd{ + "joy_look_deadzone", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_look_deadzone = std::clamp(val.value(), 0.0f, 0.9f); + rf::console::print("Gamepad look (right stick) deadzone: {:.2f}", g_alpine_game_config.gamepad_look_deadzone); + }, + "Set right stick deadzone 0.0-0.9 (default 0.15)", + "joy_look_deadzone [value]", +}; + +ConsoleCommand2 joy_scope_sens_cmd{ + "joy_scope_sens", + [](std::optional val) { + if (val) g_alpine_game_config.set_gamepad_scope_sens_mod(val.value()); + rf::console::print("Gamepad scope sensitivity modifier: {:.4f}", g_alpine_game_config.gamepad_scope_sensitivity_modifier); + }, + "Set gamepad scope sensitivity modifier (default 0.25)", + "joy_scope_sens [value]", +}; + +ConsoleCommand2 joy_scanner_sens_cmd{ + "joy_scanner_sens", + [](std::optional val) { + if (val) g_alpine_game_config.set_gamepad_scanner_sens_mod(val.value()); + rf::console::print("Gamepad scanner sensitivity modifier: {:.4f}", g_alpine_game_config.gamepad_scanner_sensitivity_modifier); + }, + "Set gamepad scanner sensitivity modifier (default 0.25)", + "joy_scanner_sens [value]", +}; + +ConsoleCommand2 joy_flickstick_cmd{ + "joy_flickstick", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_joy_camera = val.value() != 0; + rf::console::print("Joy flick-stick: {}", g_alpine_game_config.gamepad_joy_camera ? "enabled" : "disabled"); + }, + "Enable/disable flick-stick mode (default 0)", + "joy_flickstick [0|1]", +}; + +ConsoleCommand2 joy_flickstick_sweep_cmd{ + "joy_flickstick_sweep", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_flickstick_sweep = std::clamp(val.value(), 0.01f, 6.0f); + rf::console::print("Gamepad flickstick sweep: {:.2f}", g_alpine_game_config.gamepad_flickstick_sweep); + }, + "Set flick-stick sweep sensitivity 0.01-6.0 (default 1.00)", + "joy_flickstick_sweep [value]", +}; + +ConsoleCommand2 joy_flickstick_smoothing_cmd{ + "joy_flickstick_smoothing", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_flickstick_smoothing = std::clamp(val.value(), 0.0f, 1.0f); + rf::console::print("Gamepad flickstick smoothing: {:.2f}", g_alpine_game_config.gamepad_flickstick_smoothing); + }, + "Set flick-stick smoothing factor 0.0-1.0 (default 0.75)", + "joy_flickstick_smoothing [value]", +}; + +ConsoleCommand2 joy_flickstick_deadzone_cmd{ + "joy_flickstick_deadzone", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_flickstick_deadzone = std::clamp(val.value(), 0.0f, 0.9f); + rf::console::print("Gamepad flickstick deadzone: {:.2f}", g_alpine_game_config.gamepad_flickstick_deadzone); + }, + "Set flick-stick activation deadzone 0.0-0.9 (default 0.80)", + "joy_flickstick_deadzone [value]", +}; + +ConsoleCommand2 joy_flickstick_release_deadzone_cmd{ + "joy_flickstick_release_deadzone", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_flickstick_release_deadzone = std::clamp(val.value(), 0.0f, 0.9f); + rf::console::print("Gamepad flickstick release deadzone: {:.2f}", g_alpine_game_config.gamepad_flickstick_release_deadzone); + }, + "Set flick-stick release deadzone 0.0-0.9 (default 0.70)", + "joy_flickstick_release_deadzone [value]", +}; + +ConsoleCommand2 joy_rumble_cmd{ + "joy_rumble", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_rumble_intensity = std::clamp(val.value(), 0.0f, 1.0f); + rf::console::print("Gamepad rumble intensity: {:.2f}", g_alpine_game_config.gamepad_rumble_intensity); + }, + "Set gamepad rumble intensity 0.0-1.0 (default 1.0)", + "joy_rumble [value]", +}; + +ConsoleCommand2 joy_rumble_triggers_cmd{ + "joy_rumble_triggers", + [](std::optional val) { + if (!g_trigger_rumble_supported) { + rf::console::print("Value blocked, gamepad does not support Trigger Rumbles"); + return; + } + if (val) + g_alpine_game_config.gamepad_trigger_rumble_intensity = std::clamp(val.value(), 0.0f, 1.0f); + rf::console::print("Trigger rumble intensity: {:.2f}", g_alpine_game_config.gamepad_trigger_rumble_intensity); + }, + "Set gamepad trigger rumble intensity 0.0-1.0 (default 1.0, if supported by controller)", + "joy_rumble_triggers [value]", +}; + +ConsoleCommand2 joy_rumble_weapon_cmd{ + "joy_rumble_weapon", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_weapon_rumble_enabled = val.value() != 0; + rf::console::print("Weapon rumble: {}", g_alpine_game_config.gamepad_weapon_rumble_enabled ? "enabled" : "disabled"); + }, + "Enable/disable weapon rumble (default 1)", + "joy_rumble_weapon [0|1]", +}; + +ConsoleCommand2 joy_rumble_environmental_cmd{ + "joy_rumble_environmental", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_environmental_rumble_enabled = val.value() != 0; + rf::console::print("Environmental rumble: {}", g_alpine_game_config.gamepad_environmental_rumble_enabled ? "enabled" : "disabled"); + }, + "Enable/disable environmental rumble (default 1)", + "joy_rumble_environmental [0|1]", +}; + +ConsoleCommand2 joy_rumble_when_primary_cmd{ + "joy_rumble_when_primary", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_rumble_when_primary = val.value() != 0; + rf::console::print("Gamepad rumble only when gamepad is primary input: {}", g_alpine_game_config.gamepad_rumble_when_primary ? "enabled" : "disabled"); + }, + "Enable/disable rumble only when gamepad is the primary input device (default 1)", + "joy_rumble_when_primary [0|1]", +}; + +ConsoleCommand2 joy_rumble_vibration_filter_cmd{ + "joy_rumble_vibration_filter", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_rumble_vibration_filter = std::clamp(val.value(), 0, 2); + auto mode_name = g_alpine_game_config.gamepad_rumble_vibration_filter == 0 ? "Off" : + g_alpine_game_config.gamepad_rumble_vibration_filter == 1 ? "Auto (reduces low-freq motor while gyro is active)" : "On (always reduce)"; + rf::console::print("Gamepad rumble vibration filter: {} ({})", g_alpine_game_config.gamepad_rumble_vibration_filter, mode_name); + }, + "Set vibration filter mode 0=Off, 1=Auto (default, low-freq motor while gyro is active), 2=On (reduces low-freq motor)", + "joy_rumble_vibration_filter [0|1|2]", +}; + +ConsoleCommand2 gyro_camera_cmd{ + "gyro_camera", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_gyro_enabled = val.value() != 0; + rf::console::print("Gyro camera: {}", g_alpine_game_config.gamepad_gyro_enabled ? "enabled" : "disabled"); + }, + "Enable/disable gyro camera (default 0)", + "gyro_camera [0|1]", +}; + +ConsoleCommand2 gyro_vehicle_camera_cmd{ + "gyro_vehicle_camera", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_gyro_vehicle_camera = val.value() != 0; + rf::console::print("Gyro camera for vehicles: {}", g_alpine_game_config.gamepad_gyro_vehicle_camera ? "enabled" : "disabled"); + }, + "Enable/disable gyro camera while in vehicles (default 0)", + "gyro_vehicle_camera [0|1]", +}; + +ConsoleCommand2 gyro_sens_cmd{ + "gyro_sens", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_gyro_sensitivity = std::clamp(val.value(), 0.0f, 30.0f); + rf::console::print("Gyro sensitivity: {:.4f}", g_alpine_game_config.gamepad_gyro_sensitivity); + }, + "Set gyro sensitivity 0-30 (default 2.5)", + "gyro_sens [value]", +}; + +ConsoleCommand2 gyro_scope_sens_cmd{ + "gyro_scope_sens", + [](std::optional val) { + if (val) g_alpine_game_config.set_gamepad_scope_gyro_sens_mod(val.value()); + rf::console::print("Gamepad scope gyro sensitivity modifier: {:.4f}", g_alpine_game_config.gamepad_scope_gyro_sensitivity_modifier); + }, + "Set gamepad scope gyro sensitivity modifier (default 0.25)", + "gyro_scope_sens [value]", +}; + +ConsoleCommand2 gyro_scanner_sens_cmd{ + "gyro_scanner_sens", + [](std::optional val) { + if (val) g_alpine_game_config.set_gamepad_scanner_gyro_sens_mod(val.value()); + rf::console::print("Gamepad scanner gyro sensitivity modifier: {:.4f}", g_alpine_game_config.gamepad_scanner_gyro_sensitivity_modifier); + }, + "Set gamepad scanner gyro sensitivity modifier (default 0.25)", + "gyro_scanner_sens [value]", +}; + +ConsoleCommand2 input_prompts_cmd{ + "input_prompts", + [](std::optional val) { + if (val) { + g_alpine_game_config.input_prompt_override = std::clamp(*val, 0, 2); + } + static const char* modes[] = {"Auto", "Controller", "Keyboard/Mouse"}; + rf::console::print("Input prompts: {} ({})", modes[g_alpine_game_config.input_prompt_override], g_alpine_game_config.input_prompt_override); + }, + "Set input prompt display: 0=Auto, 1=Controller, 2=Keyboard/Mouse", + "input_prompts [0|1|2]", +}; + +ConsoleCommand2 gamepad_prompts_cmd{ + "gamepad_prompts", + [](std::optional val) { + static const char* icon_names[] = { + "Auto", "Generic", "Xbox 360 Controller", "Xbox Wireless Controller", + "DualShock 3", "DualShock 4", "DualSense", "Nintendo Switch Controller", "Nintendo GameCube Controller", + "Steam Controller (2015)", "Steam Deck", + }; + if (val) g_alpine_game_config.gamepad_icon_override = std::clamp(val.value(), 0, 10); + rf::console::print("Gamepad icons: {} ({})", + icon_names[g_alpine_game_config.gamepad_icon_override], + g_alpine_game_config.gamepad_icon_override); + }, + "Set gamepad button icon style: 0=Auto, 1=Generic, 2=Xbox 360 Controller, 3=Xbox Wireless Controller, 4=DualShock 3, 5=DualShock 4, 6=DualSense, 7=Nintendo Switch Controller, 8=Nintendo GameCube Controller, 9=Steam Controller (2015), 10=Steam Deck", + "gamepad_prompts [0-10]", +}; + +ConsoleCommand2 joy_reconnect_cmd{ + "joy_reset", + [](std::optional) { + if (!g_gamepad) { + // No gamepad open — try to pick up any connected one. + if (SDL_HasGamepad()) { + int count = 0; + SDL_JoystickID* ids = SDL_GetGamepads(&count); + if (ids) { + if (count > 0) + try_open_gamepad(ids[0]); + SDL_free(ids); + } + } + if (g_gamepad) + rf::console::print("Gamepad reset: opened {}", SDL_GetGamepadName(g_gamepad)); + else + rf::console::print("Gamepad reset: no gamepad found"); + return; + } + + SDL_JoystickID prev_id = SDL_GetGamepadID(g_gamepad); + disconnect_active_gamepad(); + try_open_gamepad(prev_id); + + if (g_gamepad) + rf::console::print("Gamepad reset: reopened {}", SDL_GetGamepadName(g_gamepad)); + else + rf::console::print("Gamepad reset: failed to reopen gamepad"); + }, + "Close and reopen the SDL gamepad (re-enables sensors, resets gyro state)", +}; + +// Returns the secondary (alt) scan code for the action bound to the given primary +// scan code, or -1 if there is no secondary. Used by the binding list renderer. +int gamepad_get_alt_sc_for_primary_sc(int primary_sc) +{ + // Menu-only actions use CTRL_GAMEPAD_MENU_BASE codes and never carry a secondary binding. + if (primary_sc >= CTRL_GAMEPAD_MENU_BASE && primary_sc < CTRL_GAMEPAD_MENU_BASE + SDL_GAMEPAD_BUTTON_COUNT) + return -1; + + // Resolve which gameplay action index owns this primary scan code. + int action = -1; + int offset = primary_sc - CTRL_GAMEPAD_SCAN_BASE; + if (offset >= 0 && offset < SDL_GAMEPAD_BUTTON_COUNT) + action = g_button_map[offset]; + else if (primary_sc == static_cast(CTRL_GAMEPAD_LEFT_TRIGGER)) + action = g_trigger_action[0]; + else if (primary_sc == static_cast(CTRL_GAMEPAD_RIGHT_TRIGGER)) + action = g_trigger_action[1]; + + if (action < 0) return -1; + + // Look for an extended-button secondary bound to the same action. + for (int b = SDL_GAMEPAD_BUTTON_MISC1; b < SDL_GAMEPAD_BUTTON_COUNT; ++b) + if (g_button_map_alt[b] == action) + return CTRL_GAMEPAD_SCAN_BASE + b; + return -1; +} + +bool gamepad_is_motionsensors_supported() +{ + return g_motion_sensors_supported; +} + +bool gamepad_is_trigger_rumble_supported() +{ + return g_trigger_rumble_supported; +} + +bool gamepad_is_last_input_gamepad() +{ + if (g_alpine_game_config.input_prompt_override == 1) return true; + if (g_alpine_game_config.input_prompt_override == 2) return false; + return g_last_input_was_gamepad; +} + +bool gamepad_is_menu_only_action(int action_idx) +{ + return is_menu_only_action(action_idx); +} + +void gamepad_set_last_input_keyboard() +{ + g_last_input_was_gamepad = false; +} + +int gamepad_get_button_for_action(int action_idx) +{ + for (int b = 0; b < SDL_GAMEPAD_BUTTON_COUNT; ++b) + if (g_button_map[b] == action_idx || g_menu_button_map[b] == action_idx) + return b; + return -1; +} + +// Returns the primary and secondary button indices for a gameplay action. +// The secondary is the extended-button (paddle/misc/touchpad) secondary binding, if any. +// Either output is set to -1 if not present. +void gamepad_get_buttons_for_action(int action_idx, int* btn_primary, int* btn_secondary) +{ + *btn_primary = -1; + *btn_secondary = -1; + for (int b = 0; b < SDL_GAMEPAD_BUTTON_COUNT; ++b) { + if (*btn_primary < 0 && (g_button_map[b] == action_idx || g_menu_button_map[b] == action_idx)) + *btn_primary = b; + if (*btn_secondary < 0 && g_button_map_alt[b] == action_idx) + *btn_secondary = b; + } +} + +int gamepad_get_trigger_for_action(int action_idx) +{ + if (g_trigger_action[0] == action_idx || g_menu_trigger_action[0] == action_idx) return 0; + if (g_trigger_action[1] == action_idx || g_menu_trigger_action[1] == action_idx) return 1; + return -1; +} + +int gamepad_get_button_count() +{ + return SDL_GAMEPAD_BUTTON_COUNT; +} + +const char* gamepad_get_scan_code_name(int scan_code) +{ + auto icon_pref = static_cast(g_alpine_game_config.gamepad_icon_override); + int menu_offset = scan_code - CTRL_GAMEPAD_MENU_BASE; + if (menu_offset >= 0 && menu_offset < SDL_GAMEPAD_BUTTON_COUNT) + return gamepad_get_effective_display_name(icon_pref, g_gamepad, menu_offset); + int offset = scan_code - CTRL_GAMEPAD_SCAN_BASE; + if (offset >= 0 && offset < SDL_GAMEPAD_BUTTON_COUNT + 2) + return gamepad_get_effective_display_name(icon_pref, g_gamepad, offset); + return ""; +} + +void gamepad_clear_all_bindings() +{ + memset(g_button_map, -1, sizeof(g_button_map)); + memset(g_button_map_alt, -1, sizeof(g_button_map_alt)); + g_trigger_action[0] = g_trigger_action[1] = -1; + memset(g_menu_button_map, -1, sizeof(g_menu_button_map)); + g_menu_trigger_action[0] = g_menu_trigger_action[1] = -1; +} + +void gamepad_sync_bindings_from_scan_codes() +{ + if (!rf::local_player) return; + gamepad_clear_all_bindings(); + auto& cc = rf::local_player->settings.controls; + for (int i = 0; i < cc.num_bindings; ++i) { + // Primary slot (scan_codes[0]) + { + int16_t sc = cc.bindings[i].scan_codes[0]; + bool menu_only = is_menu_only_action(i); + + int menu_offset = static_cast(sc) - CTRL_GAMEPAD_MENU_BASE; + if (menu_only && menu_offset >= 0 && menu_offset < SDL_GAMEPAD_BUTTON_COUNT) { + if (menu_offset != SDL_GAMEPAD_BUTTON_START) + g_menu_button_map[menu_offset] = i; + } + else { + int offset = static_cast(sc) - CTRL_GAMEPAD_SCAN_BASE; + if (offset >= 0 && offset < SDL_GAMEPAD_BUTTON_COUNT) { + if (offset != SDL_GAMEPAD_BUTTON_START) { // Start is reserved, never rebindable + if (menu_only) + g_menu_button_map[offset] = i; + else + g_button_map[offset] = i; + } + } + else if (sc == static_cast(CTRL_GAMEPAD_LEFT_TRIGGER)) { + if (menu_only) g_menu_trigger_action[0] = i; + else g_trigger_action[0] = i; + } + else if (sc == static_cast(CTRL_GAMEPAD_RIGHT_TRIGGER)) { + if (menu_only) g_menu_trigger_action[1] = i; + else g_trigger_action[1] = i; + } + } + } + // Secondary slot (scan_codes[1]) — extended-button secondary for gameplay actions only. + if (!is_menu_only_action(i)) { + int16_t sc1 = cc.bindings[i].scan_codes[1]; + int offset1 = static_cast(sc1) - CTRL_GAMEPAD_SCAN_BASE; + if (offset1 >= SDL_GAMEPAD_BUTTON_MISC1 && offset1 < SDL_GAMEPAD_BUTTON_COUNT) + g_button_map_alt[offset1] = i; + } + } +} + +bool gamepad_has_pending_rebind() +{ + return g_rebind_pending_sc >= 0; +} + +void gamepad_apply_rebind() +{ + rf::key_process_event(static_cast(CTRL_REBIND_SENTINEL), 0, 0); + + if (!rf::local_player) { + g_rebind_pending_sc = -1; + return; + } + + auto& cc = rf::local_player->settings.controls; + + auto new_code = g_rebind_pending_sc >= 0 ? static_cast(g_rebind_pending_sc) : int16_t{-1}; + g_rebind_pending_sc = -1; + + for (int i = 0; i < cc.num_bindings; ++i) { + if (cc.bindings[i].scan_codes[0] != CTRL_REBIND_SENTINEL) + continue; + + if (new_code != -1) { + bool target_is_menu_only = is_menu_only_action(i); + int new_offset = static_cast(new_code) - CTRL_GAMEPAD_SCAN_BASE; + bool new_is_extended = (new_offset >= SDL_GAMEPAD_BUTTON_MISC1 && new_offset < SDL_GAMEPAD_BUTTON_COUNT); + + // Menu-only actions use the CTRL_GAMEPAD_MENU_BASE scan-code namespace so they are + // never confused with gameplay actions that share the same physical button. + if (target_is_menu_only && new_offset >= 0 && new_offset < SDL_GAMEPAD_BUTTON_COUNT) + new_code = static_cast(CTRL_GAMEPAD_MENU_BASE + new_offset); + + // For gameplay actions: if binding an extended button (paddle/misc/touchpad) and this + // action already has a standard primary in g_button_map OR a trigger, store as + // secondary instead of replacing the primary. + if (new_is_extended && !target_is_menu_only) { + // Check standard buttons first. + int existing_primary = -1; + for (int b = 0; b < SDL_GAMEPAD_BUTTON_MISC1; ++b) + if (g_button_map[b] == i) { existing_primary = b; break; } + + // Also check triggers — they can be the primary for this action. + int existing_trigger = -1; // 0 = LT, 1 = RT + if (g_trigger_action[0] == i) existing_trigger = 0; + else if (g_trigger_action[1] == i) existing_trigger = 1; + + if (existing_primary >= 0 || existing_trigger >= 0) { + // Conflict-clear this extended button from other actions' secondary slots. + for (int j = 0; j < cc.num_bindings; ++j) + if (j != i && cc.bindings[j].scan_codes[1] == new_code) + cc.bindings[j].scan_codes[1] = -1; + // Determine the scan code that represents the existing primary. + int16_t primary_sc; + if (existing_primary >= 0) + primary_sc = static_cast(CTRL_GAMEPAD_SCAN_BASE + existing_primary); + else + primary_sc = (existing_trigger == 0) + ? static_cast(CTRL_GAMEPAD_LEFT_TRIGGER) + : static_cast(CTRL_GAMEPAD_RIGHT_TRIGGER); + // Restore sc[0] to the known primary and set sc[1] as secondary. + cc.bindings[i].scan_codes[0] = primary_sc; + cc.bindings[i].scan_codes[1] = new_code; + break; + } + } + + // Standard primary rebind (or extended button with no existing standard primary). + for (int j = 0; j < cc.num_bindings; ++j) { + if (j == i) continue; + // Clear from primary if the same binding context. + if (cc.bindings[j].scan_codes[0] == new_code + && is_menu_only_action(j) == target_is_menu_only) { + cc.bindings[j].scan_codes[0] = -1; + // If j still has an extended secondary, promote it to primary now — otherwise + // it would become orphaned (secondary with no primary → shows as empty in UI). + int16_t sc1_j = cc.bindings[j].scan_codes[1]; + int off1_j = static_cast(sc1_j) - CTRL_GAMEPAD_SCAN_BASE; + if (sc1_j != -1 + && off1_j >= SDL_GAMEPAD_BUTTON_MISC1 && off1_j < SDL_GAMEPAD_BUTTON_COUNT + && !is_menu_only_action(j)) { + cc.bindings[j].scan_codes[0] = sc1_j; + cc.bindings[j].scan_codes[1] = -1; + } + } + // Always clear from secondary slots to avoid a button appearing in two places. + if (cc.bindings[j].scan_codes[1] == new_code) + cc.bindings[j].scan_codes[1] = -1; + } + // If the target action itself already holds new_code as its secondary (e.g. the user + // presses the same extended button again after its primary was moved away), clear the + // secondary to prevent "Mic / Mic" after sc[0] is written below. + if (cc.bindings[i].scan_codes[1] == new_code) + cc.bindings[i].scan_codes[1] = -1; + } + + cc.bindings[i].scan_codes[0] = new_code; + // When clearing the primary binding, also clear any secondary. + if (new_code == -1) + cc.bindings[i].scan_codes[1] = -1; + + break; + } +} + +int gamepad_get_button_binding(int button_idx) +{ + if (button_idx < 0 || button_idx >= SDL_GAMEPAD_BUTTON_COUNT) return -1; + return g_button_map[button_idx]; +} + +void gamepad_set_button_binding(int button_idx, int action_idx) +{ + if (button_idx < 0 || button_idx >= SDL_GAMEPAD_BUTTON_COUNT) return; + if (is_menu_only_action(action_idx)) + g_menu_button_map[button_idx] = action_idx; + else + g_button_map[button_idx] = action_idx; +} + +int gamepad_get_button_alt_binding(int button_idx) +{ + if (button_idx < 0 || button_idx >= SDL_GAMEPAD_BUTTON_COUNT) return -1; + return g_button_map_alt[button_idx]; +} + +void gamepad_set_button_alt_binding(int button_idx, int action_idx) +{ + if (button_idx < 0 || button_idx >= SDL_GAMEPAD_BUTTON_COUNT) return; + // Secondary/alt bindings are only for gameplay (non-menu) actions. + if (!is_menu_only_action(action_idx)) + g_button_map_alt[button_idx] = action_idx; +} + +int gamepad_get_trigger_action(int trigger_idx) +{ + if (trigger_idx < 0 || trigger_idx > 1) return -1; + return g_trigger_action[trigger_idx]; +} + +void gamepad_set_trigger_action(int trigger_idx, int action_idx) +{ + if (trigger_idx < 0 || trigger_idx > 1) return; + if (is_menu_only_action(action_idx)) + g_menu_trigger_action[trigger_idx] = action_idx; + else + g_trigger_action[trigger_idx] = action_idx; +} + +void gamepad_reset_to_defaults() +{ + memset(g_button_map, -1, sizeof(g_button_map)); + memset(g_button_map_alt, -1, sizeof(g_button_map_alt)); + memset(g_menu_button_map, -1, sizeof(g_menu_button_map)); + g_trigger_action[0] = g_trigger_action[1] = -1; + g_menu_trigger_action[0] = g_menu_trigger_action[1] = -1; + + g_button_map[SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER] = rf::CC_ACTION_PRIMARY_ATTACK; + g_button_map[SDL_GAMEPAD_BUTTON_LEFT_SHOULDER] = rf::CC_ACTION_JUMP; + g_button_map[SDL_GAMEPAD_BUTTON_SOUTH] = rf::CC_ACTION_USE; + g_button_map[SDL_GAMEPAD_BUTTON_NORTH] = rf::CC_ACTION_RELOAD; + g_button_map[SDL_GAMEPAD_BUTTON_EAST] = rf::CC_ACTION_NEXT_WEAPON; + g_button_map[SDL_GAMEPAD_BUTTON_WEST] = rf::CC_ACTION_PREV_WEAPON; + g_button_map[SDL_GAMEPAD_BUTTON_DPAD_LEFT] = rf::CC_ACTION_HIDE_WEAPON; + g_button_map[SDL_GAMEPAD_BUTTON_DPAD_RIGHT] = rf::CC_ACTION_MESSAGES; + g_menu_button_map[SDL_GAMEPAD_BUTTON_DPAD_DOWN] = static_cast(get_af_control(rf::AlpineControlConfigAction::AF_ACTION_CENTER_VIEW)); + g_menu_button_map[SDL_GAMEPAD_BUTTON_DPAD_RIGHT] = rf::CC_ACTION_MP_STATS; + g_menu_button_map[SDL_GAMEPAD_BUTTON_BACK] = static_cast(get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SKIP_CUTSCENE)); + + // Spectator / multiplayer-only actions + g_menu_button_map[SDL_GAMEPAD_BUTTON_EAST] = static_cast(get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE_FREELOOK)); + g_menu_button_map[SDL_GAMEPAD_BUTTON_WEST] = static_cast(get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE)); + g_menu_button_map[SDL_GAMEPAD_BUTTON_NORTH] = static_cast(get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_MENU)); + + // Trigger defaults (gameplay action) + g_trigger_action[0] = rf::CC_ACTION_CROUCH; + g_trigger_action[1] = rf::CC_ACTION_SECONDARY_ATTACK; +} + +void gamepad_apply_patch() +{ + gamepad_reset_to_defaults(); + + control_is_control_down_hook.install(); + control_config_check_pressed_hook.install(); + physics_simulate_entity_hook.install(); + player_fpgun_process_hook.install(); + vmesh_process_hook.install(); + key_process_event_hook.install(); + mouse_was_button_pressed_hook.install(); + joy_sens_cmd.register_cmd(); + joy_move_deadzone_cmd.register_cmd(); + joy_look_deadzone_cmd.register_cmd(); + joy_scope_sens_cmd.register_cmd(); + joy_scanner_sens_cmd.register_cmd(); + gyro_scope_sens_cmd.register_cmd(); + gyro_scanner_sens_cmd.register_cmd(); + joy_flickstick_cmd.register_cmd(); + joy_flickstick_sweep_cmd.register_cmd(); + joy_flickstick_smoothing_cmd.register_cmd(); + joy_flickstick_deadzone_cmd.register_cmd(); + joy_flickstick_release_deadzone_cmd.register_cmd(); + joy_rumble_cmd.register_cmd(); + joy_rumble_triggers_cmd.register_cmd(); + joy_rumble_weapon_cmd.register_cmd(); + joy_rumble_environmental_cmd.register_cmd(); + joy_rumble_when_primary_cmd.register_cmd(); + joy_rumble_vibration_filter_cmd.register_cmd(); + gyro_sens_cmd.register_cmd(); + gyro_camera_cmd.register_cmd(); + gyro_vehicle_camera_cmd.register_cmd(); + input_prompts_cmd.register_cmd(); + gamepad_prompts_cmd.register_cmd(); + joy_reconnect_cmd.register_cmd(); + gyro_apply_patch(); +} + +static void gamepad_msg_handler(UINT msg, WPARAM w_param, LPARAM) +{ + if (msg != WM_ACTIVATEAPP || w_param) + return; + // Focus lost: release all gamepad input so nothing stays held while unfocused. + if (g_gamepad) { + release_movement_keys(); + for (int b = 0; b < SDL_GAMEPAD_BUTTON_COUNT; ++b) { + inject_action_key(g_button_map[b], false); + inject_action_key(g_button_map_alt[b], false); + } + inject_action_key(g_trigger_action[0], false); + inject_action_key(g_trigger_action[1], false); + if (g_menu_nav.lclick_held) { + POINT pt; + GetCursorPos(&pt); + ScreenToClient(rf::main_wnd, &pt); + SendMessage(rf::main_wnd, WM_LBUTTONUP, 0, MAKELPARAM(pt.x, pt.y)); + g_menu_nav.lclick_held = false; + } + } + reset_gamepad_input_state(); +} + +void gamepad_rumble(uint16_t low_freq, uint16_t high_freq, uint32_t duration_ms) +{ + if (!g_gamepad || !g_rumble_supported) + return; + if (g_alpine_game_config.gamepad_rumble_when_primary && !g_last_input_was_gamepad) + return; + if (g_alpine_game_config.gamepad_rumble_intensity <= 0.0f) + return; + low_freq = static_cast(low_freq * g_alpine_game_config.gamepad_rumble_intensity); + high_freq = static_cast(high_freq * g_alpine_game_config.gamepad_rumble_intensity); + int filter_mode = g_alpine_game_config.gamepad_rumble_vibration_filter; + if (filter_mode == 2 || (filter_mode == 1 && g_motion_sensors_supported && g_alpine_game_config.gamepad_gyro_enabled)) + low_freq = static_cast(low_freq * 0.02f); + SDL_RumbleGamepad(g_gamepad, low_freq, high_freq, duration_ms); +} + +void gamepad_play_rumble(const RumbleEffect& effect, bool is_alt_fire) +{ + if (!g_gamepad) + return; + + // No trigger motor requested — plain body rumble. + if (!effect.trigger_motor || g_alpine_game_config.gamepad_trigger_rumble_intensity <= 0.0f) { + gamepad_rumble(effect.lo_motor, effect.hi_motor, effect.duration_ms); + return; + } + + constexpr int primary_idx = static_cast(rf::CC_ACTION_PRIMARY_ATTACK); + constexpr int secondary_idx = static_cast(rf::CC_ACTION_SECONDARY_ATTACK); + + // Resolve which trigger (if any) each fire action is bound to. + // g_trigger_action[0] = Left Trigger, g_trigger_action[1] = Right Trigger. + bool primary_on_lt = g_trigger_action[0] == primary_idx; + bool primary_on_rt = g_trigger_action[1] == primary_idx; + bool secondary_on_lt = g_trigger_action[0] == secondary_idx; + bool secondary_on_rt = g_trigger_action[1] == secondary_idx; + + // Check which fire action is active this frame or was active last frame. + bool primary_active = primary_idx < k_action_count && (g_action_curr[primary_idx] || g_action_prev[primary_idx]); + bool secondary_active; + if (is_alt_fire && (secondary_on_lt || secondary_on_rt)) { + // Alt-fire confirmed by next_fire_secondary advancement: the secondary attack action + // is responsible regardless of current button state. + secondary_active = true; + } else { + secondary_active = secondary_idx < k_action_count && (g_action_curr[secondary_idx] || g_action_prev[secondary_idx]); + } + + bool use_lt = (primary_active && primary_on_lt) || (secondary_active && secondary_on_lt); + bool use_rt = (primary_active && primary_on_rt) || (secondary_active && secondary_on_rt); + + if (!use_lt && !use_rt) { + // No fire action is bound to a trigger — fall back to standard body rumble. + gamepad_rumble(effect.lo_motor, effect.hi_motor, effect.duration_ms); + return; + } + + // Route to the trigger motor(s) matching the active fire binding. + uint16_t lt_motor = use_lt ? static_cast(effect.trigger_motor * g_alpine_game_config.gamepad_trigger_rumble_intensity) : 0; + uint16_t rt_motor = use_rt ? static_cast(effect.trigger_motor * g_alpine_game_config.gamepad_trigger_rumble_intensity) : 0; + if (!SDL_RumbleGamepadTriggers(g_gamepad, lt_motor, rt_motor, effect.duration_ms)) + gamepad_rumble(effect.lo_motor, effect.hi_motor, effect.duration_ms); +} + +void gamepad_stop_rumble() +{ + if (!g_gamepad) + return; + SDL_RumbleGamepad(g_gamepad, 0, 0, 0); + if (g_trigger_rumble_supported) + SDL_RumbleGamepadTriggers(g_gamepad, 0, 0, 0); +} + +void gamepad_sdl_init() +{ + SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS3, "1"); + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS3_SIXAXIS_DRIVER, "1"); + if (g_alpine_system_config.gamepad_rawinput_enabled) { + SDL_SetHint(SDL_HINT_JOYSTICK_RAWINPUT, "1"); + SDL_SetHint(SDL_HINT_JOYSTICK_RAWINPUT_CORRELATE_XINPUT, "1"); + } else { + SDL_SetHint(SDL_HINT_JOYSTICK_RAWINPUT, "0"); + SDL_SetHint(SDL_HINT_JOYSTICK_RAWINPUT_CORRELATE_XINPUT, "0"); + } + + if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) { + xlog::error("Failed to initialize SDL gamepad subsystem: {}", SDL_GetError()); + return; + } + + // Load SDL_GameControllerDB + // note: this might not work right now... + for (const auto& dir : {get_module_dir(g_hmodule), get_module_dir(nullptr)}) { + std::string path = dir + "gamecontrollerdb.txt"; + if (GetFileAttributesA(path.c_str()) == INVALID_FILE_ATTRIBUTES) + continue; + int n = SDL_AddGamepadMappingsFromFile(path.c_str()); + if (n < 0) + xlog::warn("SDL_GameControllerDB: failed to load {}: {}", path, SDL_GetError()); + else + xlog::info("SDL_GameControllerDB: loaded {} mappings from {}", n, path); + break; + } + + if (SDL_HasGamepad()) { + int count = 0; + SDL_JoystickID* ids = SDL_GetGamepads(&count); + if (ids) { + for (int i = 0; i < count; ++i) + xlog::info("Gamepad found: {}", SDL_GetGamepadNameForID(ids[i])); + if (count > 0) + try_open_gamepad(ids[0]); + SDL_free(ids); + } + } + // Flush ADDED events from subsystem init during gamepad connection + SDL_FlushEvents(SDL_EVENT_GAMEPAD_ADDED, SDL_EVENT_GAMEPAD_ADDED); + + rf::os_add_msg_handler(gamepad_msg_handler); + xlog::info("Gamepad support initialized"); +} diff --git a/game_patch/input/gamepad.h b/game_patch/input/gamepad.h new file mode 100644 index 000000000..5f48ca315 --- /dev/null +++ b/game_patch/input/gamepad.h @@ -0,0 +1,63 @@ +#pragma once +#include + +void gamepad_apply_patch(); +void gamepad_sdl_init(); + + +struct RumbleEffect +{ + uint16_t lo_motor = 0; // low-frequency (left) body motor + uint16_t hi_motor = 0; // high-frequency (right) body motor + uint16_t trigger_motor = 0; // trigger motor strength (if supported by SDL_RumbleGamepadTriggers) + uint32_t duration_ms = 0; +}; + +void gamepad_rumble(uint16_t low_freq, uint16_t high_freq, uint32_t duration_ms); +void gamepad_play_rumble(const RumbleEffect& effect, bool is_alt_fire = false); +void gamepad_stop_rumble(); // immediately silence all rumble motors + +void gamepad_do_frame(); +void consume_raw_gamepad_deltas(float& pitch_delta, float& yaw_delta); +void flush_freelook_gamepad_deltas(); +bool gamepad_is_motionsensors_supported(); +bool gamepad_is_trigger_rumble_supported(); +bool gamepad_is_last_input_gamepad(); +void gamepad_set_last_input_keyboard(); + +// Controller binding UI +int gamepad_get_button_for_action(int action_idx); // -1 if unbound (primary only) +void gamepad_get_buttons_for_action(int action_idx, int* btn_primary, int* btn_secondary); // both primary and secondary +int gamepad_get_trigger_for_action(int action_idx); // 0=LT, 1=RT, -1 if unbound +const char* gamepad_get_scan_code_name(int scan_code); +int gamepad_get_button_count(); +void gamepad_reset_to_defaults(); +void gamepad_sync_bindings_from_scan_codes(); + +// Scan codes used while the CONTROLLER tab is active (unused gap in RF's key table) +static constexpr int CTRL_GAMEPAD_SCAN_BASE = 0x59; // SDL button 0 +static constexpr int CTRL_GAMEPAD_EXTENDED_BASE = CTRL_GAMEPAD_SCAN_BASE + 15; // SDL_GAMEPAD_BUTTON_MISC1 (0x68) +static constexpr int CTRL_GAMEPAD_LEFT_TRIGGER = 0x73; // SCAN_BASE + 26 +static constexpr int CTRL_GAMEPAD_RIGHT_TRIGGER = 0x74; // SCAN_BASE + 27 +// Separate scan-code namespace for menu-only actions (spectate, vote, menus). +// Placed after the trigger slots so it never overlaps gameplay button codes. +static constexpr int CTRL_GAMEPAD_MENU_BASE = 0x75; // SCAN_BASE + 28 + +// Returns true if an action index should live in g_menu_button_map +bool gamepad_is_menu_only_action(int action_idx); + +// Per-binding get/set for save/load +int gamepad_get_button_binding(int button_idx); +void gamepad_set_button_binding(int button_idx, int action_idx); +int gamepad_get_button_alt_binding(int button_idx); // secondary (extended button) binding +void gamepad_set_button_alt_binding(int button_idx, int action_idx); +int gamepad_get_alt_sc_for_primary_sc(int primary_sc); // combined name lookup for binding list +int gamepad_get_trigger_action(int trigger_idx); +void gamepad_set_trigger_action(int trigger_idx, int action_idx); + +// rebind gamepad buttons/triggers +void gamepad_apply_rebind(); +bool gamepad_has_pending_rebind(); // true if a gamepad button/trigger was captured for the current rebind + +// Returns and clears any pending scroll delta produced by the right-stick menu scroll tick (+1=up, -1=down, 0=none) +int gamepad_consume_menu_scroll(); diff --git a/game_patch/input/glyph.cpp b/game_patch/input/glyph.cpp new file mode 100644 index 000000000..9ca7703b4 --- /dev/null +++ b/game_patch/input/glyph.cpp @@ -0,0 +1,318 @@ +#include "glyph.h" +#include + + +struct ButtonOverride { + int button_idx; + const char* name; +}; + +template +static const char* search_overrides(const ButtonOverride (&table)[N], int button_idx) +{ + for (const auto& entry : table) + if (entry.button_idx == button_idx) + return entry.name; + return nullptr; +} + +// Maps SDL face-button labels to display strings. +// Face button glyphs takes priority +static const char* get_label_name(SDL_GamepadButtonLabel label) +{ + switch (label) { + case SDL_GAMEPAD_BUTTON_LABEL_A: return "A"; + case SDL_GAMEPAD_BUTTON_LABEL_B: return "B"; + case SDL_GAMEPAD_BUTTON_LABEL_X: return "X"; + case SDL_GAMEPAD_BUTTON_LABEL_Y: return "Y"; + case SDL_GAMEPAD_BUTTON_LABEL_CROSS: return "Cross"; + case SDL_GAMEPAD_BUTTON_LABEL_CIRCLE: return "Circle"; + case SDL_GAMEPAD_BUTTON_LABEL_SQUARE: return "Square"; + case SDL_GAMEPAD_BUTTON_LABEL_TRIANGLE: return "Triangle"; + default: return nullptr; + } +} + +// shared names — shared by PlayStation and Steam Deck families. +// Provides L1/R1/L2/R2/L3/R3/L4/R4/L5/R5 and misc button names for buttons +// not covered by a family-specific override table. +static const ButtonOverride shared_glyphs[] = { + { 7, "L3" }, // Left stick click + { 8, "R3" }, // Right stick click + { 9, "L1" }, // Left shoulder + { 10, "R1" }, // Right shoulder + { 15, "M1" }, + { 16, "L4" }, // Left back upper paddle + { 17, "L5" }, // Left back lower paddle + { 18, "R4" }, // Right back upper paddle + { 19, "R5" }, // Right back lower paddle + { 21, "M2" }, + { 22, "M3" }, + { 23, "M4" }, + { 26, "L2" }, + { 27, "R2" }, +}; + +// Xbox 360-specific overrides (Back/Start differ from Xbox One's View/Menu) +static const ButtonOverride xbox360_overrides[] = { + { 4, "Back" }, + { 6, "Start" }, +}; + +// Xbox One/Series overrides — also used as fallback for Xbox 360 +static const ButtonOverride xboxone_overrides[] = { + { 4, "View" }, + { 5, "Xbox" }, + { 6, "Menu" }, + { 7, "LS" }, + { 8, "RS" }, + { 9, "LB" }, + { 10, "RB" }, + { 15, "Share" }, + { 16, "Paddle 1" }, + { 17, "Paddle 2" }, + { 18, "Paddle 3" }, + { 19, "Paddle 4" }, + { 26, "LT" }, + { 27, "RT" }, +}; + +static const ButtonOverride ps3_overrides[] = { + { 4, "Select" }, + { 5, "PS" }, + { 6, "Start" }, +}; + +static const ButtonOverride ps4_overrides[] = { + { 4, "Share" }, + { 5, "PS" }, + { 6, "Options" }, + { 20, "Touchpad Click" }, +}; + +static const ButtonOverride ps5_overrides[] = { + { 4, "Create" }, + { 5, "PS" }, + { 6, "Options" }, + { 15, "Mic" }, + { 16, "RB Paddle" }, + { 17, "LB Paddle" }, + { 18, "Right Fn" }, + { 19, "Left Fn" }, + { 20, "Touchpad Click" }, +}; + +static const ButtonOverride switchpro_overrides[] = { + { 4, "-" }, + { 5, "Home" }, + { 6, "+" }, + { 9, "L" }, + { 10, "R" }, + { 15, "Capture" }, + { 16, "Right SR" }, + { 17, "Left SL" }, + { 18, "Right SL" }, + { 19, "Left SR" }, + { 26, "ZL" }, + { 27, "ZR" }, +}; + +static const ButtonOverride gamecube_overrides[] = { + { 9, "Z" }, // SDL_GAMEPAD_BUTTON_LEFT_SHOULDER → Z + { 10, "R" }, // SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER → R + { 22, "L Click" }, // SDL_GAMEPAD_BUTTON_MISC3 → L trigger digital + { 23, "R Click" }, // SDL_GAMEPAD_BUTTON_MISC4 → R trigger digital + { 26, "L" }, + { 27, "R" }, +}; + +static const ButtonOverride steamdeck_overrides[] = { + { 4, "View" }, + { 5, "Steam" }, + { 6, "Menu" }, + { 15, "..." }, // Quick Access button +}; + +static const ButtonOverride steamcontroller_overrides[] = { + { 4, "Back" }, + { 5, "Steam" }, + { 6, "Start" }, + { 7, "Stick Click" }, + { 8, "Right Trackpad Click" }, + { 16, "RG" }, // Right grip + { 17, "LG" }, // Left grip +}; + +const char* gamepad_get_button_name(int button_idx) +{ + static const char* names[] = { + "South", // SDL_GAMEPAD_BUTTON_SOUTH 0 + "East", // SDL_GAMEPAD_BUTTON_EAST 1 + "West", // SDL_GAMEPAD_BUTTON_WEST 2 + "North", // SDL_GAMEPAD_BUTTON_NORTH 3 + "Back", // SDL_GAMEPAD_BUTTON_BACK 4 + "Guide", // SDL_GAMEPAD_BUTTON_GUIDE 5 + "Start", // SDL_GAMEPAD_BUTTON_START 6 + "Left Stick Click", // SDL_GAMEPAD_BUTTON_LEFT_STICK 7 + "Right Stick Click", // SDL_GAMEPAD_BUTTON_RIGHT_STICK 8 + "Left Shoulder", // SDL_GAMEPAD_BUTTON_LEFT_SHOULDER 9 + "Right Shoulder", // SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER 10 + "D-Pad Up", // SDL_GAMEPAD_BUTTON_DPAD_UP 11 + "D-Pad Down", // SDL_GAMEPAD_BUTTON_DPAD_DOWN 12 + "D-Pad Left", // SDL_GAMEPAD_BUTTON_DPAD_LEFT 13 + "D-Pad Right", // SDL_GAMEPAD_BUTTON_DPAD_RIGHT 14 + "Misc 1", // SDL_GAMEPAD_BUTTON_MISC1 15 + "Right Paddle 1", // SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1 16 + "Left Paddle 1", // SDL_GAMEPAD_BUTTON_LEFT_PADDLE1 17 + "Right Paddle 2", // SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2 18 + "Left Paddle 2", // SDL_GAMEPAD_BUTTON_LEFT_PADDLE2 19 + "Touchpad", // SDL_GAMEPAD_BUTTON_TOUCHPAD 20 + "Misc 2", // SDL_GAMEPAD_BUTTON_MISC2 21 + "Misc 3", // SDL_GAMEPAD_BUTTON_MISC3 22 + "Misc 4", // SDL_GAMEPAD_BUTTON_MISC4 23 + "Misc 5", // SDL_GAMEPAD_BUTTON_MISC5 24 + "Misc 6", // SDL_GAMEPAD_BUTTON_MISC6 25 + "Left Trigger", // Left Trigger 26 + "Right Trigger", // Right Trigger 27 + }; + static_assert(sizeof(names) / sizeof(names[0]) == SDL_GAMEPAD_BUTTON_COUNT + 2, + "Input name table size mismatch — update when SDL_GAMEPAD_BUTTON_COUNT changes"); + if (button_idx < 0 || button_idx >= static_cast(sizeof(names) / sizeof(names[0]))) + return ""; + return names[button_idx]; +} + +// Maps ControllerIconType to SDL_GamepadType for face-button label lookup. +static SDL_GamepadType icon_type_to_sdl(ControllerIconType icon) +{ + switch (icon) { + case ControllerIconType::Xbox360: return SDL_GAMEPAD_TYPE_XBOX360; + case ControllerIconType::XboxOne: return SDL_GAMEPAD_TYPE_XBOXONE; + case ControllerIconType::PS3: return SDL_GAMEPAD_TYPE_PS3; + case ControllerIconType::PS4: return SDL_GAMEPAD_TYPE_PS4; + case ControllerIconType::PS5: return SDL_GAMEPAD_TYPE_PS5; + case ControllerIconType::NintendoSwitch: return SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO; +#if SDL_VERSION_ATLEAST(3, 2, 0) + case ControllerIconType::NintendoGameCube: return SDL_GAMEPAD_TYPE_GAMECUBE; +#endif + // Steam devices use Xbox-style A/B/X/Y face labels + case ControllerIconType::SteamController: + case ControllerIconType::SteamDeck: return SDL_GAMEPAD_TYPE_XBOXONE; + default: return SDL_GAMEPAD_TYPE_UNKNOWN; + } +} + +// Maps SDL_GamepadType to ControllerIconType for auto-detection. +static ControllerIconType sdl_type_to_icon(SDL_GamepadType type) +{ + switch (type) { + case SDL_GAMEPAD_TYPE_XBOX360: return ControllerIconType::Xbox360; + case SDL_GAMEPAD_TYPE_XBOXONE: return ControllerIconType::XboxOne; + case SDL_GAMEPAD_TYPE_PS3: return ControllerIconType::PS3; + case SDL_GAMEPAD_TYPE_PS4: return ControllerIconType::PS4; + case SDL_GAMEPAD_TYPE_PS5: return ControllerIconType::PS5; + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT: return ControllerIconType::NintendoSwitch; +#if SDL_VERSION_ATLEAST(3, 2, 0) + case SDL_GAMEPAD_TYPE_GAMECUBE: return ControllerIconType::NintendoGameCube; +#endif + default: return ControllerIconType::Generic; + } +} + +// Returns true for controller families that use shared naming as their shared standard tier +// (L1/R1/L2/R2/L3/R3 etc.) for buttons not covered by a platform-specific override. +static bool uses_shared_glyphs(ControllerIconType type) +{ + switch (type) { + case ControllerIconType::PS3: + case ControllerIconType::PS4: + case ControllerIconType::PS5: + case ControllerIconType::SteamDeck: + return true; + default: + return false; + } +} + +const char* gamepad_get_button_display_name(ControllerIconType type, int button_idx) +{ + // Tier 1: SDL face-button label (buttons 0–3). + // SDL handles per-controller A/B/X/Y label mapping including Switch A/B swap. + // Generic/Auto have no SDL type (→ UNKNOWN), so this tier is naturally skipped for them. + if (button_idx >= 0 && button_idx < 4) { + SDL_GamepadType sdl_type = icon_type_to_sdl(type); + if (sdl_type != SDL_GAMEPAD_TYPE_UNKNOWN) { + const char* label = get_label_name( + SDL_GetGamepadButtonLabelForType(sdl_type, static_cast(button_idx))); + if (label) + return label; + } + } + + // Tier 2: Family-specific override table. + const char* result = nullptr; + switch (type) { + case ControllerIconType::Xbox360: + result = search_overrides(xbox360_overrides, button_idx); + if (result) return result; + result = search_overrides(xboxone_overrides, button_idx); // LB/RB/LS/RS/LT/RT/etc. + break; + case ControllerIconType::XboxOne: + result = search_overrides(xboxone_overrides, button_idx); + break; + case ControllerIconType::PS3: + result = search_overrides(ps3_overrides, button_idx); + break; + case ControllerIconType::PS4: + result = search_overrides(ps4_overrides, button_idx); + break; + case ControllerIconType::PS5: + result = search_overrides(ps5_overrides, button_idx); + break; + case ControllerIconType::NintendoSwitch: + result = search_overrides(switchpro_overrides, button_idx); + break; + case ControllerIconType::NintendoGameCube: + result = search_overrides(gamecube_overrides, button_idx); + break; + case ControllerIconType::SteamController: + result = search_overrides(steamcontroller_overrides, button_idx); + if (result) return result; + result = search_overrides(xboxone_overrides, button_idx); // LB/RB/LT/RT + break; + case ControllerIconType::SteamDeck: + result = search_overrides(steamdeck_overrides, button_idx); + break; + default: + break; + } + + if (result) + return result; + + // Tier 3: Apple MFi shared standard (L1/R1/L2/R2/L3/R3 etc.) + // Covers stick clicks, shoulders, and paddles for PlayStation and Steam Deck families. + if (uses_shared_glyphs(type)) { + const char* mfi = search_overrides(shared_glyphs, button_idx); + if (mfi) + return mfi; + } + + // Tier 4: SDL positional fallback — always defined for every button index. + return gamepad_get_button_name(button_idx); +} + +const char* gamepad_get_effective_display_name(ControllerIconType icon_pref, SDL_Gamepad* ctrl, int button_idx) +{ + ControllerIconType type; + if (icon_pref == ControllerIconType::Auto) { + SDL_GamepadType sdl_type = ctrl ? SDL_GetGamepadType(ctrl) : SDL_GAMEPAD_TYPE_UNKNOWN; + type = get_steam_virtual_controller_detection(ctrl, sdl_type_to_icon(sdl_type)); + } else { + type = icon_pref; + } + return gamepad_get_button_display_name(type, button_idx); +} diff --git a/game_patch/input/glyph.h b/game_patch/input/glyph.h new file mode 100644 index 000000000..233106eb1 --- /dev/null +++ b/game_patch/input/glyph.h @@ -0,0 +1,87 @@ +#pragma once +#include + +// Valve Corporation VID/PID definitions +#define VALVE_VENDOR_ID 0x28de +#define STEAM_CONTROLLER_LEGACY_PID 0x1101 // Valve Legacy Steam Controller (CHELL) +#define STEAM_CONTROLLER_WIRED_PID 0x1102 // Valve wired Steam Controller (D0G) +#define STEAM_CONTROLLER_BT_1_PID 0x1105 // Valve Bluetooth Steam Controller (D0G) +#define STEAM_CONTROLLER_BT_2_PID 0x1106 // Valve Bluetooth Steam Controller (D0G) +#define STEAM_CONTROLLER_WIRELESS_PID 0x1142 // Valve wireless Steam Controller +#define STEAM_CONTROLLER_V2_WIRED_PID 0x1201 // Valve wired Steam Controller (HEADCRAB) +#define STEAM_CONTROLLER_V2_BT_PID 0x1202 // Valve Bluetooth Steam Controller (HEADCRAB) +#define STEAM_VIRTUAL_GAMEPAD_PID 0x11ff // Steam Virtual Gamepad +#define STEAM_DECK_BUILTIN_PID 0x1205 // Steam Deck Builtin +#define STEAM_TRITON_WIRED_PID 0x1302 // Steam Controller 2 (GORDON) wired +#define STEAM_TRITON_BLE_PID 0x1303 // Steam Controller 2 (GORDON) BLE + +enum class ControllerIconType { + Auto = 0, + Generic, + Xbox360, + XboxOne, + PS3, + PS4, + PS5, + NintendoSwitch, + NintendoGameCube, + SteamController, + SteamDeck, +}; + +// Returns true if the product ID matches any Steam Controller (2015) variant +inline bool is_steam_controller_pid(Uint16 pid) +{ + return (pid == STEAM_CONTROLLER_LEGACY_PID || + pid == STEAM_CONTROLLER_WIRED_PID || + pid == STEAM_CONTROLLER_BT_1_PID || + pid == STEAM_CONTROLLER_BT_2_PID || + pid == STEAM_CONTROLLER_WIRELESS_PID || + pid == STEAM_CONTROLLER_V2_WIRED_PID || + pid == STEAM_CONTROLLER_V2_BT_PID); +} + +// Returns true if the product ID matches any Steam Triton / Steam Controller 2 variant +inline bool is_steam_triton_controller_pid(Uint16 pid) +{ + return (pid == STEAM_TRITON_WIRED_PID || + pid == STEAM_TRITON_BLE_PID); +} + +// Checks Valve VID/PID to resolve Steam-specific controller icon types. +// For Steam Virtual Gamepad (0x11ff), passes through the supplied fallback icon type. +// For Steam Deck/SteamController 2, returns Steam Deck glyphs; for Steam Controller (2015), returns Steam Controller glyphs. +inline ControllerIconType get_steam_virtual_controller_detection(SDL_Gamepad* ctrl, ControllerIconType fallback) +{ + if (!ctrl) + return fallback; + + Uint16 vendor = SDL_GetGamepadVendor(ctrl); + if (vendor != VALVE_VENDOR_ID) + return fallback; + + Uint16 product = SDL_GetGamepadProduct(ctrl); + + if (product == STEAM_VIRTUAL_GAMEPAD_PID) + return fallback; + + if (product == STEAM_DECK_BUILTIN_PID) + return ControllerIconType::SteamDeck; + + if (is_steam_controller_pid(product)) + return ControllerIconType::SteamController; + + if (is_steam_triton_controller_pid(product)) + return ControllerIconType::SteamDeck; + + return fallback; +} + +// Positional (controller-agnostic) name for a button index +const char* gamepad_get_button_name(int button_idx); + +// Controller-aware display name: controller-specific label if known, falls back to positional name +const char* gamepad_get_button_display_name(ControllerIconType type, int button_idx); + +// Returns the display name for the given scan code, which may be a keyboard key or a gamepad button/trigger. +const char* gamepad_get_effective_display_name(ControllerIconType icon_pref, SDL_Gamepad* ctrl, int button_idx); diff --git a/game_patch/input/gyro.cpp b/game_patch/input/gyro.cpp new file mode 100644 index 000000000..9234a9449 --- /dev/null +++ b/game_patch/input/gyro.cpp @@ -0,0 +1,379 @@ +#include "gyro.h" +#include "input.h" +#include "gamepad.h" +#include "../rf/gameseq.h" +#include +#include +#include +#include +#include "../os/console.h" +#include "../misc/alpine_settings.h" +#include "../rf/player/player.h" + +static GamepadMotion g_motion; +static GamepadMotionHelpers::CalibrationMode g_last_calibration_mode = static_cast(-1); +static float g_smooth_pitch_prev = 0.0f; +static float g_smooth_yaw_prev = 0.0f; + +void gyro_update_calibration_mode() +{ + using CM = GamepadMotionHelpers::CalibrationMode; + int mode = std::clamp(g_alpine_game_config.gamepad_gyro_autocalibration_mode, 0, 2); + + // Autocalibration should only run when gyro aiming is enabled. + bool gyro_enabled = g_alpine_game_config.gamepad_gyro_enabled && gamepad_is_motionsensors_supported(); + if (!gyro_enabled) { + mode = 0; // force manual calibration when gyro is disabled + } + + CM desired; + switch (mode) { + case 1: // Menu Only — only calibrate when not in gameplay + desired = rf::gameseq_in_gameplay() + ? CM::Manual + : (CM::Stillness); + break; + case 2: // Always - will try to calibrate whenever possible + desired = CM::Stillness | CM::SensorFusion; + break; + default: // Off - disable auto calibration + desired = CM::Manual; + break; + } + + if (desired == g_last_calibration_mode) + return; + g_last_calibration_mode = desired; + + // Preserve calibrated offset and confidence across mode changes. + float ox, oy, oz; + g_motion.GetCalibrationOffset(ox, oy, oz); + float confidence = g_motion.GetAutoCalibrationConfidence(); + + g_motion.SetCalibrationMode(desired); + g_motion.SetCalibrationOffset(ox, oy, oz, 1); + g_motion.SetAutoCalibrationConfidence(confidence); +} + +void gyro_reset() +{ + g_motion.ResetContinuousCalibration(); + g_motion.ResetMotion(); + g_smooth_pitch_prev = 0.0f; + g_smooth_yaw_prev = 0.0f; + gyro_update_calibration_mode(); +} + +void gyro_reset_full() +{ + // Invalidate the mode cache so Gyro Autocalibration modes unconditionally re-applies it. + g_last_calibration_mode = static_cast(-1); + gyro_reset(); +} + +void gyro_set_autocalibration_mode(int mode) +{ + mode = std::clamp(mode, 0, 2); + g_alpine_game_config.gamepad_gyro_autocalibration_mode = mode; + gyro_update_calibration_mode(); +} + +float gyro_get_autocalibration_confidence() +{ + return g_motion.GetAutoCalibrationConfidence(); +} + +bool gyro_is_autocalibration_steady() +{ + return g_motion.GetAutoCalibrationIsSteady(); +} + +void gyro_process_motion(float gyro_x, float gyro_y, float gyro_z, + float accel_x, float accel_y, float accel_z, float delta_time) +{ + g_motion.ProcessMotion(gyro_x, gyro_y, gyro_z, accel_x, accel_y, accel_z, delta_time); +} + +static const char* gyro_space_names[] = { "Yaw", "Roll", "Local", "Player", "World" }; + +const char* gyro_get_space_name(int space) +{ + if (space >= 0 && space <= 4) return gyro_space_names[space]; + return gyro_space_names[0]; +} + +void gyro_get_axis_orientation(float& out_pitch_dps, float& out_yaw_dps) +{ + auto space = static_cast(g_alpine_game_config.gamepad_gyro_space); + float x, y, z; + + switch (space) { + case GyroSpace::PlayerSpace: + g_motion.GetPlayerSpaceGyro(x, y); + out_pitch_dps = x; + out_yaw_dps = y; + break; + case GyroSpace::WorldSpace: + g_motion.GetWorldSpaceGyro(x, y); + out_pitch_dps = x; + out_yaw_dps = y; + break; + case GyroSpace::Roll: + g_motion.GetCalibratedGyro(x, y, z); + out_pitch_dps = x; + out_yaw_dps = -z; + break; + case GyroSpace::LocalSpace: { + // Local Space code is based on http://gyrowiki.jibbsmart.com/blog:player-space-gyro-and-alternatives-explained + // Combines the gravity vector to avoid axis conflict + float gx, gy, gz; + g_motion.GetCalibratedGyro(x, y, z); + g_motion.GetGravity(gx, gy, gz); + out_pitch_dps = x; + out_yaw_dps = -(gy * y + gz * z); + break; + } + default: // Yaw + g_motion.GetCalibratedGyro(x, y, z); + out_pitch_dps = x; + out_yaw_dps = y; + break; + } +} + +// Gyro tightening is based on GyroWiki documents +// http://gyrowiki.jibbsmart.com/blog:good-gyro-controls-part-1:the-gyro-is-a-mouse#toc9 +void gyro_apply_tightening(float& pitch_dps, float& yaw_dps) +{ + // 0-100 user value maps to 0-50 deg/s threshold (percentage scale) + float threshold = g_alpine_game_config.gamepad_gyro_tightening * 0.5f; + if (threshold > 0.0f) { + float mag = std::hypot(pitch_dps, yaw_dps); + if (mag > 0.0f && mag < threshold) { + float scale = mag / threshold; + pitch_dps *= scale; + yaw_dps *= scale; + } + } +} + +void gyro_apply_smoothing(float& pitch_dps, float& yaw_dps) +{ + float factor = g_alpine_game_config.gamepad_gyro_smoothing / 100.0f; + if (factor <= 0.0f) return; + factor = std::min(factor, 0.99f); // prevent full freeze at 100 + + g_smooth_pitch_prev = pitch_dps * (1.0f - factor) + g_smooth_pitch_prev * factor; + g_smooth_yaw_prev = yaw_dps * (1.0f - factor) + g_smooth_yaw_prev * factor; + + pitch_dps = g_smooth_pitch_prev; + yaw_dps = g_smooth_yaw_prev; +} + +void gyro_apply_vh_mixer(float& pitch_dps, float& yaw_dps) +{ + int raw = std::clamp(g_alpine_game_config.gamepad_gyro_vh_mixer, -100, 100); + if (raw == 0) return; + float mixer = raw / 100.0f; + float h_scale = mixer >= 0.0f ? (1.0f - mixer) : 1.0f; + float v_scale = mixer <= 0.0f ? (1.0f + mixer) : 1.0f; + yaw_dps *= h_scale; + pitch_dps *= v_scale; +} + +static bool gyro_action_has_binding(rf::ControlConfigAction action) +{ + if (!rf::local_player) return false; + auto& cc = rf::local_player->settings.controls; + int idx = static_cast(action); + if (idx < 0 || idx >= cc.num_bindings) return false; + const auto& b = cc.bindings[idx]; + return b.scan_codes[0] > 0 || b.scan_codes[1] > 0 || b.mouse_btn_id >= 0 + || gamepad_get_button_for_action(idx) >= 0 + || gamepad_get_trigger_for_action(idx) >= 0; +} + +// Toggle state for Gyro Modifier binding. +static bool g_gyro_toggle_state = true; +static bool g_gyro_toggle_prev_down = false; + +// Returns whether gyro input should be applied this frame. +// If Gyro Modifier is unbound -> always active. +// Behavior while bound is determined by gamepad_gyro_modifier_mode: +// 0 = Always -> always active regardless of binding +// 1 = HoldOff -> active while NOT held +// 2 = HoldOn -> active while held +// 3 = Toggle -> button press flips on/off (starts on) +bool gyro_modifier_is_active() +{ + using namespace rf; + + if (!local_player) return true; + + const auto action = get_af_control(AlpineControlConfigAction::AF_ACTION_GYRO_MODIFIER); + + int mode = std::clamp(g_alpine_game_config.gamepad_gyro_modifier_mode, 0, 3); + + if (mode == 0) // Always + return true; + + if (!gyro_action_has_binding(action)) + return true; // no modifier bound — gyro always on + + auto& cc = local_player->settings.controls; + + bool down = control_is_control_down(&cc, action); + + if (mode == 3) { // Toggle + if (down && !g_gyro_toggle_prev_down) + g_gyro_toggle_state = !g_gyro_toggle_state; + g_gyro_toggle_prev_down = down; + return g_gyro_toggle_state; + } + + g_gyro_toggle_prev_down = down; + + if (mode == 1) // HoldOff + return !down; + + return down; // HoldOn (mode == 2) +} + +ConsoleCommand2 gyro_modifier_mode_cmd{ + "gyro_modifier_mode", + [](std::optional val) { + if (val) { + g_alpine_game_config.gamepad_gyro_modifier_mode = std::clamp(val.value(), 0, 3); + g_gyro_toggle_state = true; + g_gyro_toggle_prev_down = false; + } + int mode = g_alpine_game_config.gamepad_gyro_modifier_mode; + static const char* mode_names[] = {"Always", "Hold Off", "Hold On", "Toggle"}; + rf::console::print("Gyro modifier mode: {} ({})", mode_names[mode], mode); + }, + "Set gyro modifier mode: 0=Always, 1=Hold Off, 2=Hold On, 3=Toggle (default 0)", + "gyro_modifier_mode [0|1|2|3]", +}; + +ConsoleCommand2 gyro_autocalibration_cmd{ + "gyro_autocalibration", + [](std::optional val) { + if (val) { + gyro_set_autocalibration_mode(val.value()); + } + + int mode = std::clamp(g_alpine_game_config.gamepad_gyro_autocalibration_mode, 0, 2); + const char* mode_name = "unknown"; + switch (mode) { + case 0: + mode_name = "Off"; + break; + case 1: + mode_name = "Menu Only"; + break; + case 2: + mode_name = "Always"; + break; + } + + rf::console::print("Gyro autocalibration mode: {} ({})", mode_name, mode); + }, + "Set gyro auto-calibration mode: 0=Off, 1=Menu Only, 2=Always (default 2)", + "gyro_autocalibration [0|1|2]", +}; + +ConsoleCommand2 gyro_reset_autocalibration_partial_cmd{ + "gyro_reset_autocalibration_partial", + [](std::optional) { + g_motion.SetAutoCalibrationConfidence(0.0f); + gyro_update_calibration_mode(); + rf::console::print("Gyro auto-calibration partial reset (offset preserved)"); + }, + "Reset gyro auto-calibration confidence (preserves offset)", +}; + +ConsoleCommand2 gyro_reset_autocalibration_full_cmd{ + "gyro_reset_autocalibration_full", + [](std::optional) { + g_motion.SetAutoCalibrationConfidence(0.0f); + g_motion.ResetContinuousCalibration(); + g_motion.ResetMotion(); + gyro_update_calibration_mode(); + rf::console::print("Gyro auto-calibration full reset"); + }, + "Reset gyro auto-calibration confidence and clear offset", +}; + +ConsoleCommand2 gyro_space_cmd{ + "gyro_space", + [](std::optional val) { + if (val) { + g_alpine_game_config.gamepad_gyro_space = std::clamp(val.value(), 0, 4); + g_motion.ResetMotion(); + } + int s = g_alpine_game_config.gamepad_gyro_space; + rf::console::print("Gyro space: {} ({})", s, gyro_space_names[s]); + }, + "Set gyro camera space: 0=Yaw 1=Roll 2=Local 3=Player 4=World", + "gyro_space [0-4]", +}; + +ConsoleCommand2 gyro_invert_y_cmd{ + "gyro_invert_y", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_gyro_invert_y = val.value() != 0; + rf::console::print("Gyro invert Y: {}", g_alpine_game_config.gamepad_gyro_invert_y ? "on" : "off"); + }, + "Toggle Gyro Y-axis invert", + "gyro_invert_y [0|1]", +}; + +ConsoleCommand2 gyro_tightening_cmd{ + "gyro_tightening", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_gyro_tightening = std::clamp(val.value(), 0.0f, 100.0f); + rf::console::print("Gyro tightening threshold: {:.1f}", + g_alpine_game_config.gamepad_gyro_tightening); + }, + "Set gyro tightening threshold (0 = disabled, max 100)", + "gyro_tightening [value]", +}; + +ConsoleCommand2 gyro_smoothing_cmd{ + "gyro_smoothing", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_gyro_smoothing = std::clamp(val.value(), 0.0f, 100.0f); + rf::console::print("Gyro smoothing threshold: {:.1f}", + g_alpine_game_config.gamepad_gyro_smoothing); + }, + "Set gyro soft-tier smoothing threshold (0 = disabled, max 100)", + "gyro_smoothing [value]", +}; + +ConsoleCommand2 gyro_vh_cmd{ + "gyro_vh", + [](std::optional val) { + if (val) g_alpine_game_config.gamepad_gyro_vh_mixer = std::clamp(val.value(), -100, 100); + rf::console::print("Gyro V/H output mixer: {}", g_alpine_game_config.gamepad_gyro_vh_mixer); + }, + "Set gyro V/H output mixer (-100 = reduce vertical, 0 = 1:1, 100 = reduce horizontal)", + "gyro_vh_mixer [-100 to 100]", +}; + +void gyro_apply_patch() +{ + g_motion.Settings.MinStillnessCorrectionTime = 1.0f; // default 2.0 + g_motion.Settings.StillnessCalibrationEaseInTime = 1.5f; // default 3.0 + + gyro_update_calibration_mode(); + gyro_modifier_mode_cmd.register_cmd(); + gyro_autocalibration_cmd.register_cmd(); + gyro_reset_autocalibration_partial_cmd.register_cmd(); + gyro_reset_autocalibration_full_cmd.register_cmd(); + gyro_space_cmd.register_cmd(); + gyro_invert_y_cmd.register_cmd(); + gyro_tightening_cmd.register_cmd(); + gyro_smoothing_cmd.register_cmd(); + gyro_vh_cmd.register_cmd(); + xlog::info("Gyro processing initialized"); +} diff --git a/game_patch/input/gyro.h b/game_patch/input/gyro.h new file mode 100644 index 000000000..e8e7fdef2 --- /dev/null +++ b/game_patch/input/gyro.h @@ -0,0 +1,33 @@ +#pragma once + +// Gyro Axis orientation modes +enum class GyroSpace : int { + Yaw = 0, + Roll = 1, + LocalSpace = 2, + PlayerSpace = 3, + WorldSpace = 4, +}; + +// Gyro autocalibration modes. +enum class GyroAutocalibrationMode : int { + Off = 0, + MenuOnly = 1, + Always = 2, +}; + +void gyro_reset(); +void gyro_reset_full(); // Full reset: clears offset, confidence, and motion state (use on controller hotswap) +void gyro_update_calibration_mode(); +void gyro_set_autocalibration_mode(int mode); // 0=Off,1=MenuOnly,2=Always +float gyro_get_autocalibration_confidence(); +bool gyro_is_autocalibration_steady(); +void gyro_process_motion(float gyro_x, float gyro_y, float gyro_z, + float accel_x, float accel_y, float accel_z, float delta_time); +void gyro_get_axis_orientation(float& out_pitch_dps, float& out_yaw_dps); +void gyro_apply_tightening(float& pitch_dps, float& yaw_dps); +void gyro_apply_smoothing(float& pitch_dps, float& yaw_dps); +void gyro_apply_vh_mixer(float& pitch_dps, float& yaw_dps); +const char* gyro_get_space_name(int space); +bool gyro_modifier_is_active(); +void gyro_apply_patch(); diff --git a/game_patch/input/input.cpp b/game_patch/input/input.cpp new file mode 100644 index 000000000..aabb592a3 --- /dev/null +++ b/game_patch/input/input.cpp @@ -0,0 +1,10 @@ +// Central SDL event pump for all input subsystems. +#include +#include "input.h" + +void sdl_input_poll() +{ + if (SDL_WasInit(SDL_INIT_EVENTS) != 0) { + SDL_PumpEvents(); + } +} diff --git a/game_patch/input/input.h b/game_patch/input/input.h index 347b0badb..5f0e82549 100644 --- a/game_patch/input/input.h +++ b/game_patch/input/input.h @@ -2,7 +2,15 @@ #include "../rf/player/control_config.h" +// Sentinel scan code injected into Input Rebind UI, allowing additional input bindings. +static constexpr int CTRL_REBIND_SENTINEL = 0x58; // KEY_F12 + rf::ControlConfigAction get_af_control(rf::AlpineControlConfigAction alpine_control); rf::String get_action_bind_name(int action); void mouse_apply_patch(); +void camera_start_reset_to_horizon(); void key_apply_patch(); +void sdl_input_poll(); +void gamepad_apply_patch(); +void gamepad_sdl_poll(); +void gamepad_do_frame(); diff --git a/game_patch/input/key.cpp b/game_patch/input/key.cpp index 15ac17891..5c41b8807 100644 --- a/game_patch/input/key.cpp +++ b/game_patch/input/key.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -7,6 +8,7 @@ #include "../hud/hud.h" #include "../misc/player.h" #include "../misc/achievements.h" +#include "../misc/misc.h" #include "../misc/alpine_settings.h" #include "../misc/waypoints_utils.h" #include "../multi/multi.h" @@ -21,15 +23,44 @@ #include "../rf/os/os.h" #include "../multi/alpine_packets.h" #include "../os/console.h" +#include +#include "gamepad.h" +#include "input.h" static int starting_alpine_control_index = -1; rf::String get_action_bind_name(int action) { + // Prefer gamepad button name when a controller is active + if (gamepad_is_last_input_gamepad()) { + int btn_primary = -1, btn_secondary = -1; + gamepad_get_buttons_for_action(action, &btn_primary, &btn_secondary); + if (btn_primary >= 0 || btn_secondary >= 0) { + if (btn_primary >= 0 && btn_secondary >= 0) { + // Both primary and secondary assigned — combine as "Primary / Secondary". + char combined[128]; + std::snprintf(combined, sizeof(combined), "%s / %s", + gamepad_get_scan_code_name(CTRL_GAMEPAD_SCAN_BASE + btn_primary), + gamepad_get_scan_code_name(CTRL_GAMEPAD_SCAN_BASE + btn_secondary)); + return rf::String(combined); + } + int btn = (btn_primary >= 0) ? btn_primary : btn_secondary; + return gamepad_get_scan_code_name(CTRL_GAMEPAD_SCAN_BASE + btn); + } + int trig = gamepad_get_trigger_for_action(action); + if (trig == 0) return gamepad_get_scan_code_name(CTRL_GAMEPAD_LEFT_TRIGGER); + if (trig == 1) return gamepad_get_scan_code_name(CTRL_GAMEPAD_RIGHT_TRIGGER); + } + auto& config_item = rf::local_player->settings.controls.bindings[action]; rf::String name; if (config_item.scan_codes[0] >= 0) { - rf::control_config_get_key_name(&name, config_item.scan_codes[0]); + int sc = static_cast(config_item.scan_codes[0]); + if (sc >= CTRL_GAMEPAD_SCAN_BASE && sc <= CTRL_GAMEPAD_RIGHT_TRIGGER) { + name = gamepad_get_scan_code_name(sc); + } else { + rf::control_config_get_key_name(&name, sc); + } } else if (config_item.mouse_btn_id >= 0) { rf::control_config_get_mouse_button_name(&name, config_item.mouse_btn_id); @@ -146,9 +177,31 @@ FunHook get_key_name_hook{ CodeInjection key_name_in_options_patch{ 0x00450328, [](auto& regs) { - static char buf[32]; + static char buf[64]; int key = regs.edx; - get_key_name(key, buf, std::size(buf)); + // Gamepad scan codes installed by the CONTROLLER binding view. + if (key >= CTRL_GAMEPAD_SCAN_BASE && key <= CTRL_GAMEPAD_RIGHT_TRIGGER) { + // Gameplay action: may carry a combined "Primary / Secondary" name. + int alt_sc = gamepad_get_alt_sc_for_primary_sc(key); + if (alt_sc >= 0) { + std::snprintf(buf, std::size(buf), "%s / %s", + gamepad_get_scan_code_name(key), + gamepad_get_scan_code_name(alt_sc)); + } else { + std::strncpy(buf, gamepad_get_scan_code_name(key), std::size(buf) - 1); + buf[std::size(buf) - 1] = '\0'; + } + } else if (key >= CTRL_GAMEPAD_MENU_BASE && key < CTRL_GAMEPAD_MENU_BASE + gamepad_get_button_count()) { + // Menu-only action: separate namespace, never carries a secondary binding. + std::strncpy(buf, gamepad_get_scan_code_name(key), std::size(buf) - 1); + buf[std::size(buf) - 1] = '\0'; + } else if (key == 0 && ui_ctrl_bindings_view_active()) { + // Unbound action in CONTROLLER view — show placeholder + std::strncpy(buf, "", std::size(buf) - 1); + buf[std::size(buf) - 1] = '\0'; + } else { + get_key_name(key, buf, std::size(buf)); + } regs.edi = buf; regs.eip = 0x0045032F; }, @@ -162,18 +215,27 @@ FunHook key_get_hook{ const rf::Key key = key_get_hook.call_target(); - if (rf::close_app_req) { - goto MAYBE_CANCEL_BINK; - } - - if ((key & rf::KEY_MASK) == rf::KEY_ESC - && key & rf::KEY_SHIFTED + if (!rf::close_app_req + && (key & rf::KEY_MASK) == rf::KEY_ESC + && (key & rf::KEY_SHIFTED) && g_alpine_game_config.quick_exit) { rf::gameseq_set_state(rf::GameState::GS_QUITING, false); - MAYBE_CANCEL_BINK: - // If we are playing a video, cancel it. - const int bink_handle = addr_as_ref(0x018871E4); - return bink_handle ? rf::KEY_ESC : rf::KEY_NONE; + } + + const int bink_handle = addr_as_ref(0x018871E4); + if (bink_handle) { + if ((key & rf::KEY_MASK) == rf::KEY_ESC) { + return rf::KEY_ESC; + } + SDL_PumpEvents(); + SDL_Event sdl_ev; + while (SDL_PeepEvents(&sdl_ev, 1, SDL_GETEVENT, + SDL_EVENT_GAMEPAD_BUTTON_DOWN, + SDL_EVENT_GAMEPAD_BUTTON_DOWN) > 0) { + if (sdl_ev.gbutton.button == SDL_GAMEPAD_BUTTON_START) { + return rf::KEY_ESC; + } + } } return key; @@ -272,6 +334,16 @@ CodeInjection control_config_init_patch{ rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE_FREELOOK); alpine_control_config_add_item(ccp, "Toggle Spectate", false, rf::KEY_DIVIDE, -1, -1, rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE); + alpine_control_config_add_item(ccp, "Center View", false, -1, -1, -1, + rf::AlpineControlConfigAction::AF_ACTION_CENTER_VIEW); + alpine_control_config_add_item(ccp, "Gyro Modifier", false, -1, -1, -1, + rf::AlpineControlConfigAction::AF_ACTION_GYRO_MODIFIER); + + // Only reset gamepad defaults if settings were not loaded (first run / no settings file). + // If the user's settings file was already loaded, skip this reset to preserve saved bindings. + if (!g_loaded_alpine_settings_file) { + gamepad_reset_to_defaults(); + } }, }; @@ -315,6 +387,10 @@ CodeInjection player_execute_action_patch{ static_cast(rf::AlpineControlConfigAction::AF_ACTION_INSPECT_WEAPON)) { fpgun_play_random_idle_anim(); } + else if (action_index == starting_alpine_control_index + + static_cast(rf::AlpineControlConfigAction::AF_ACTION_CENTER_VIEW)) { + camera_start_reset_to_horizon(); + } } }, }; @@ -418,7 +494,7 @@ CodeInjection controls_process_patch{ [](auto& regs) { int index = regs.edi; if (index >= starting_alpine_control_index && - index <= static_cast(rf::AlpineControlConfigAction::_AF_ACTION_LAST_VARIANT)) { + index <= starting_alpine_control_index + static_cast(rf::AlpineControlConfigAction::_AF_ACTION_LAST_VARIANT)) { //xlog::warn("passing control {}", index); regs.eip = 0x00430E24; } @@ -492,8 +568,16 @@ FunHook key_msg_handler_hook{ 0x0051EBA0, [] (const int msg, const int w_param, int l_param) { switch (msg) { + case WM_LBUTTONDOWN: + case WM_RBUTTONDOWN: + case WM_MBUTTONDOWN: + case WM_XBUTTONDOWN: + gamepad_set_last_input_keyboard(); + break; case WM_KEYDOWN: case WM_SYSKEYDOWN: + gamepad_set_last_input_keyboard(); + [[fallthrough]]; case WM_KEYUP: case WM_SYSKEYUP: { // For num pads, RF requires `KF_EXTENDED` to be set. diff --git a/game_patch/input/rumble.cpp b/game_patch/input/rumble.cpp new file mode 100644 index 000000000..2e726f7e9 --- /dev/null +++ b/game_patch/input/rumble.cpp @@ -0,0 +1,224 @@ +#include "rumble.h" +#include "gamepad.h" +#include "../rf/entity.h" +#include "../rf/player/player.h" +#include "../rf/weapon.h" +#include "../misc/alpine_settings.h" +#include + +// Weapon fire: continuous (flamethrower, etc.) +static constexpr RumbleEffect k_rumble_weapon_continuous{ 0x2000, 0xC000, 0x6000, 90u }; +// Rocket launcher — short sharp bump on launch +static constexpr RumbleEffect k_rumble_rocket_launch{ 0xC000, 0xFFFF, 0xFFFF, 100u }; +// Railgun — heavier single-shot kick, slightly stronger than rocket launcher +static constexpr RumbleEffect k_rumble_railgun_shot{ 0xA000, 0xFFFF, 0xFFFF, 110u }; +// Shotgun blast — fixed strength +static constexpr RumbleEffect k_rumble_shotgun_blast{ 0xFFFF, 0xFFFF, 0xFFFF, 140u }; +// SMG (machine pistol) — light consistent tick per shot +static constexpr RumbleEffect k_rumble_smg_shot{ 0x1800, 0x1000, 0x2000, 65u }; +// Sniper / precision rifle — restrained body kick, strong trigger snap +static constexpr RumbleEffect k_rumble_sniper_shot{ 0x9000, 0xFFFF, 0xFFFF, 180u }; +// Turret shot: strong body-only pulse (no trigger routing for turrets) +static constexpr RumbleEffect k_rumble_turret_shot{ 0xA800, 0x8C00, 0, 120u }; + +struct WeaponRumbleProfile { + const RumbleEffect* preset = nullptr; + uint16_t lo_mul = 0; + uint16_t hi_mul = 0; + uint16_t tr_mul = 0; +}; + +static constexpr WeaponRumbleProfile k_wp_rocket { &k_rumble_rocket_launch, 0, 0, 0 }; +static constexpr WeaponRumbleProfile k_wp_railgun { &k_rumble_railgun_shot, 0, 0, 0 }; +static constexpr WeaponRumbleProfile k_wp_shotgun { &k_rumble_shotgun_blast, 0, 0, 0 }; +static constexpr WeaponRumbleProfile k_wp_smg { &k_rumble_smg_shot, 0, 0, 0 }; // machine pistol +static constexpr WeaponRumbleProfile k_wp_sniper { &k_rumble_sniper_shot, 0, 0, 0 }; // sniper / precision rifle +static constexpr WeaponRumbleProfile k_wp_ar { nullptr, 0x7800, 0x5C00, 0x9400 }; // assault rifle +static constexpr WeaponRumbleProfile k_wp_pistol { nullptr, 0x4200, 0x3300, 0x5100 }; // pistol +static constexpr WeaponRumbleProfile k_wp_default { nullptr, 0x8000, 0x6000, 0xA000 }; // all other weapons + +static const WeaponRumbleProfile& get_weapon_profile(int wt) +{ + if (wt == rf::rocket_launcher_weapon_type || wt == rf::shoulder_cannon_weapon_type) + return k_wp_rocket; + if (wt == rf::rail_gun_weapon_type) + return k_wp_railgun; + if (rf::weapon_is_shotgun(wt)) + return k_wp_shotgun; + if (wt == rf::machine_pistol_weapon_type + || wt == rf::machine_pistol_special_weapon_type) + return k_wp_smg; + if (wt == rf::assault_rifle_weapon_type) + return k_wp_ar; + if (rf::weapon_is_glock(wt)) + return k_wp_pistol; + if (rf::weapon_has_scanner(wt)) + return k_wp_sniper; + return k_wp_default; +} + +static constexpr RumbleEffect k_rumble_hit_melee_max { 0x3000, 0x8000, 0, 120u }; +static constexpr RumbleEffect k_rumble_hit_explosive_max{ 0xC000, 0xA000, 0, 300u }; +static constexpr RumbleEffect k_rumble_hit_fire_max { 0x3000, 0x9000, 0, 150u }; + +// Scale a body-only preset by [0,1]. trigger_motor stays 0. +static RumbleEffect rumble_scale(const RumbleEffect& base, float scale) +{ + return { + static_cast(scale * base.lo_motor), + static_cast(scale * base.hi_motor), + 0u, + base.duration_ms, + }; +} + +static RumbleEffect rumble_weapon_build(int wt, const rf::WeaponInfo& winfo, bool is_alt_fire) +{ + const WeaponRumbleProfile& prof = get_weapon_profile(wt); + + // Fixed preset — strength is constant regardless of fire rate (e.g. shotgun, rocket launcher). + if (prof.preset) + return *prof.preset; + + // Fire-rate factor: slower weapons earn a stronger per-shot kick. + float raw_wait = is_alt_fire ? winfo.alt_fire_wait : winfo.fire_wait; + float fire_wait = raw_wait > 0.0f ? raw_wait : 0.5f; + float factor = std::clamp(fire_wait / 0.5f, 0.6f, 1.0f); + + bool continuous = (winfo.flags & (rf::WTF_CONTINUOUS_FIRE | rf::WTF_ALT_CONTINUOUS_FIRE)) != 0; + bool burst = (winfo.flags & rf::WTF_BURST_MODE) != 0; + + return { + static_cast(factor * prof.lo_mul), + static_cast(factor * prof.hi_mul), + static_cast(factor * prof.tr_mul), + continuous ? 70u : burst ? 55u : 100u, + }; +} + +static bool rumble_weapon_is_skipped(int wt, const rf::WeaponInfo& winfo) +{ + return (winfo.flags & rf::WTF_MELEE) + || (winfo.flags & rf::WTF_REMOTE_CHARGE) + || (winfo.flags & rf::WTF_DETONATOR) + || (wt == rf::grenade_weapon_type); +} + +static void rumble_weapon_do_frame() +{ + if (!g_alpine_game_config.gamepad_weapon_rumble_enabled) + return; + static int s_last_fired_ts = -2; // -2 = uninitialized sentinel; -1 = game's own "invalid" + static int s_last_secondary_ts = -2; // tracks ai.next_fire_secondary.value to detect alt-fire + static bool s_pending_alt_fire = false; // secondary ts advanced before last_fired_timestamp (delayed projectile) + + auto* lpe = rf::local_player_entity; + if (!lpe) { + s_last_fired_ts = -2; + s_last_secondary_ts = -2; + s_pending_alt_fire = false; + return; + } + + // Turret shots are detected via rumble_on_turret_fire() called from the entity-fire hook. + if (rf::entity_is_on_turret(lpe)) { + s_last_fired_ts = -2; + s_last_secondary_ts = -2; + s_pending_alt_fire = false; + return; + } + + // While viewing a security camera the player is not firing; keep the sentinel stale + // so there is no spurious rumble pulse when the camera view is dismissed. + if (lpe->local_player && lpe->local_player->view_from_handle != -1) { + s_last_fired_ts = -2; + s_last_secondary_ts = -2; + s_pending_alt_fire = false; + return; + } + + int wt = lpe->ai.current_primary_weapon; + int cur_fired = lpe->last_fired_timestamp.value; + int cur_secondary_ts = lpe->ai.next_fire_secondary.value; + + if (wt < 0 || wt >= rf::num_weapon_types) { + s_last_fired_ts = -2; + s_last_secondary_ts = -2; + s_pending_alt_fire = false; + return; + } + + const auto& winfo = rf::weapon_types[wt]; + + // Flamethrower is a continuous beam: it never updates last_fired_timestamp. + // Drive its rumble from entity_weapon_is_on instead. + if (rf::weapon_is_flamethrower(wt)) { + if (rf::entity_weapon_is_on(lpe->handle, wt)) + gamepad_play_rumble(k_rumble_weapon_continuous); + s_last_fired_ts = cur_fired; + s_last_secondary_ts = cur_secondary_ts; + s_pending_alt_fire = false; + return; + } + + if (s_last_secondary_ts != -2 && cur_secondary_ts != s_last_secondary_ts + && !(winfo.flags & rf::WTF_ALT_ZOOM)) + s_pending_alt_fire = true; + + if (!rumble_weapon_is_skipped(wt, winfo) + && s_last_fired_ts != -2 + && cur_fired != s_last_fired_ts) { + bool is_alt_fire = s_pending_alt_fire; + gamepad_play_rumble(rumble_weapon_build(wt, winfo, is_alt_fire), is_alt_fire); + s_pending_alt_fire = false; + } + + s_last_fired_ts = cur_fired; + s_last_secondary_ts = cur_secondary_ts; +} + +void rumble_do_frame() +{ + if (g_alpine_game_config.gamepad_rumble_intensity <= 0.0f) + return; + rumble_weapon_do_frame(); +} + +void rumble_on_player_hit(float damage, int damage_type) +{ + if (g_alpine_game_config.gamepad_rumble_intensity <= 0.0f) + return; + if (!g_alpine_game_config.gamepad_environmental_rumble_enabled) + return; + + RumbleEffect effect; + if (damage_type == rf::DT_BASH) { + float scale = std::min(damage / 40.0f, 1.0f); + effect = rumble_scale(k_rumble_hit_melee_max, scale); + } + else if (damage_type == rf::DT_EXPLOSIVE) { + float scale = std::min(damage / 75.0f, 1.0f); + effect = rumble_scale(k_rumble_hit_explosive_max, scale); + } + else if (damage_type == rf::DT_FIRE) { + float scale = std::min(damage / 20.0f, 1.0f); + effect = rumble_scale(k_rumble_hit_fire_max, scale); + } + else { + return; + } + + gamepad_play_rumble(effect); +} + +void rumble_on_turret_fire(rf::Entity* firer) +{ + if (g_alpine_game_config.gamepad_rumble_intensity <= 0.0f || !g_alpine_game_config.gamepad_weapon_rumble_enabled) + return; + if (!rf::entity_is_turret(firer)) + return; + auto* lpe = rf::local_player_entity; + if (!lpe || !rf::entity_is_on_turret(lpe)) + return; + gamepad_play_rumble(k_rumble_turret_shot); +} diff --git a/game_patch/input/rumble.h b/game_patch/input/rumble.h new file mode 100644 index 000000000..970340b2d --- /dev/null +++ b/game_patch/input/rumble.h @@ -0,0 +1,14 @@ +#pragma once + +namespace rf { struct Entity; } + +// Main per-frame rumble coordinator. Called once per frame from gamepad_do_frame(). +void rumble_do_frame(); + +// Called from entity_damage_hook when the local player takes damage. +// Handles melee (DT_BASH), explosive (DT_EXPLOSIVE), and fire (DT_FIRE) hits. +void rumble_on_player_hit(float damage, int damage_type); + +// Called from the entity_fire_primary_weapon hook for every entity weapon fire event. +// Triggers a strong body-only rumble pulse when firer is the turret the local player is on. +void rumble_on_turret_fire(rf::Entity* firer); diff --git a/game_patch/main/main.cpp b/game_patch/main/main.cpp index 35129b137..ef688d54f 100644 --- a/game_patch/main/main.cpp +++ b/game_patch/main/main.cpp @@ -43,6 +43,7 @@ #include "../misc/level.h" #include "../object/alpine_corona.h" #include "../input/input.h" +#include "../input/gamepad.h" #include "../rf/gr/gr.h" #include "../rf/multi.h" #include "../rf/level.h" @@ -71,13 +72,14 @@ void initialize_random_generator() { CallHook rf_init_hook{ 0x004B27CD, - [] { - const uint64_t start_ticks = GetTickCount64(); + []() { + auto start_ticks = GetTickCount64(); xlog::info("Initializing game..."); initialize_alpine_core_config(); rf_init_hook.call_target(); vpackfile_disable_overriding(); xlog::info("Game initialized ({} ms).", GetTickCount64() - start_ticks); + gamepad_sdl_init(); }, }; @@ -153,6 +155,7 @@ FunHook rf_do_frame_hook{ server_do_frame(); client_bot_do_frame(); koth_do_frame(); + gamepad_do_frame(); alpine_mesh_do_frame(); int result = rf_do_frame_hook.call_target(); maybe_autosave(); @@ -272,6 +275,7 @@ FunHook level_init_post_hook{ } } + gamepad_stop_rumble(); // ensure no rumble bleeds in from the previous level waypoints_level_init(); // Flow 2B: Normal clients with autodl_download_awps — fire-and-forget AWP download @@ -560,6 +564,7 @@ extern "C" DWORD __declspec(dllexport) Init([[maybe_unused]] void* unused) dedi_cfg_init(); mouse_apply_patch(); key_apply_patch(); + gamepad_apply_patch(); #if !defined(NDEBUG) && defined(HAS_EXPERIMENTAL) experimental_init(); #endif diff --git a/game_patch/misc/alpine_settings.cpp b/game_patch/misc/alpine_settings.cpp index 1b89709a4..cfc36577b 100644 --- a/game_patch/misc/alpine_settings.cpp +++ b/game_patch/misc/alpine_settings.cpp @@ -17,6 +17,8 @@ #include "../rf/gr/gr.h" #include "../rf/multi.h" #include "../sound/sound.h" +#include "../input/gyro.h" +#include "../input/gamepad.h" #include "../multi/multi.h" #include #include @@ -462,10 +464,6 @@ bool alpine_player_settings_load(rf::Player* player) g_alpine_game_config.entity_pain_sounds = std::stoi(settings["EntityPainSounds"]); processed_keys.insert("EntityPainSounds"); } - if (settings.count("Footsteps")) { - g_alpine_game_config.footsteps = std::stoi(settings["Footsteps"]); - processed_keys.insert("Footsteps"); - } // Load video settings if (settings.count("Gamma")) { @@ -1080,13 +1078,150 @@ bool alpine_player_settings_load(rf::Player* player) g_alpine_game_config.direct_input = std::stoi(settings["DirectInput"]); processed_keys.insert("DirectInput"); } + if (settings.count("MouseScale")) { + g_alpine_game_config.mouse_scale = std::clamp(std::stoi(settings["MouseScale"]), 0, 2); + processed_keys.insert("MouseScale"); + } if (settings.count("MouseLinearPitch")) { g_alpine_game_config.mouse_linear_pitch = std::stoi(settings["MouseLinearPitch"]); processed_keys.insert("MouseLinearPitch"); } - if (settings.count("MouseScale")) { - g_alpine_game_config.mouse_scale = std::clamp(std::stoi(settings["MouseScale"]), 0, 2); - processed_keys.insert("MouseScale"); + if (settings.count("GamepadCameraSensitivity")) { + g_alpine_game_config.gamepad_joy_sensitivity = std::max(0.0f, std::stof(settings["GamepadCameraSensitivity"])); + processed_keys.insert("GamepadCameraSensitivity"); + } + if (settings.count("GamepadScopeSensitivityModifier")) { + g_alpine_game_config.set_gamepad_scope_sens_mod(std::stof(settings["GamepadScopeSensitivityModifier"])); + processed_keys.insert("GamepadScopeSensitivityModifier"); + } + if (settings.count("GamepadScannerSensitivityModifier")) { + g_alpine_game_config.set_gamepad_scanner_sens_mod(std::stof(settings["GamepadScannerSensitivityModifier"])); + processed_keys.insert("GamepadScannerSensitivityModifier"); + } + if (settings.count("GamepadMoveDeadzone")) { + g_alpine_game_config.gamepad_move_deadzone = std::clamp(std::stof(settings["GamepadMoveDeadzone"]), 0.0f, 0.9f); + processed_keys.insert("GamepadMoveDeadzone"); + } + else if (settings.count("GamepadDeadzone")) { + g_alpine_game_config.gamepad_move_deadzone = std::clamp(std::stof(settings["GamepadDeadzone"]), 0.0f, 0.9f); + processed_keys.insert("GamepadDeadzone"); + } + if (settings.count("GamepadLookDeadzone")) { + g_alpine_game_config.gamepad_look_deadzone = std::clamp(std::stof(settings["GamepadLookDeadzone"]), 0.0f, 0.9f); + processed_keys.insert("GamepadLookDeadzone"); + } + if (settings.count("GamepadGyroCameraSensitivity")) { + g_alpine_game_config.gamepad_gyro_sensitivity = std::clamp(std::stof(settings["GamepadGyroCameraSensitivity"]), 0.0f, 30.0f); + processed_keys.insert("GamepadGyroCameraSensitivity"); + } + if (settings.count("GamepadGyroEnabled")) { + g_alpine_game_config.gamepad_gyro_enabled = std::stoi(settings["GamepadGyroEnabled"]) != 0; + processed_keys.insert("GamepadGyroEnabled"); + } + if (settings.count("GamepadGyroVehicleEnabled")) { + g_alpine_game_config.gamepad_gyro_vehicle_camera = std::stoi(settings["GamepadGyroVehicleEnabled"]) != 0; + processed_keys.insert("GamepadGyroVehicleEnabled"); + } + if (settings.count("GamepadGyroAutocalibrationMode")) { + int mode = std::clamp(std::stoi(settings["GamepadGyroAutocalibrationMode"]), 0, 2); + g_alpine_game_config.gamepad_gyro_autocalibration_mode = mode; + processed_keys.insert("GamepadGyroAutocalibrationMode"); + } + if (settings.count("GamepadGyroSpace")) { + g_alpine_game_config.gamepad_gyro_space = std::clamp(std::stoi(settings["GamepadGyroSpace"]), 0, 4); + processed_keys.insert("GamepadGyroSpace"); + } + if (settings.count("GamepadGyroModifierMode")) { + g_alpine_game_config.gamepad_gyro_modifier_mode = std::clamp(std::stoi(settings["GamepadGyroModifierMode"]), 0, 3); + processed_keys.insert("GamepadGyroModifierMode"); + } + if (settings.count("GamepadGyroInvertY")) { + g_alpine_game_config.gamepad_gyro_invert_y = std::stoi(settings["GamepadGyroInvertY"]) != 0; + processed_keys.insert("GamepadGyroInvertY"); + } + if (settings.count("GamepadGyroTightening")) { + g_alpine_game_config.gamepad_gyro_tightening = std::clamp(std::stof(settings["GamepadGyroTightening"]), 0.0f, 100.0f); + processed_keys.insert("GamepadGyroTightening"); + } + if (settings.count("GamepadGyroSmoothing")) { + g_alpine_game_config.gamepad_gyro_smoothing = std::clamp(std::stof(settings["GamepadGyroSmoothing"]), 0.0f, 100.0f); + processed_keys.insert("GamepadGyroSmoothing"); + } + if (settings.count("GamepadJoyCamera")) { + g_alpine_game_config.gamepad_joy_camera = std::stoi(settings["GamepadJoyCamera"]) != 0; + processed_keys.insert("GamepadJoyCamera"); + } + if (settings.count("GamepadFlickstickSweep")) { + g_alpine_game_config.gamepad_flickstick_sweep = std::clamp(std::stof(settings["GamepadFlickstickSweep"]), 0.01f, 10.0f); + processed_keys.insert("GamepadFlickstickSweep"); + } + if (settings.count("GamepadFlickstickSmoothing")) { + g_alpine_game_config.gamepad_flickstick_smoothing = std::clamp(std::stof(settings["GamepadFlickstickSmoothing"]), 0.0f, 1.0f); + processed_keys.insert("GamepadFlickstickSmoothing"); + } + if (settings.count("GamepadFlickstickDeadzone")) { + g_alpine_game_config.gamepad_flickstick_deadzone = std::clamp(std::stof(settings["GamepadFlickstickDeadzone"]), 0.0f, 0.9f); + processed_keys.insert("GamepadFlickstickDeadzone"); + } + if (settings.count("GamepadFlickstickReleaseDeadzone")) { + g_alpine_game_config.gamepad_flickstick_release_deadzone = std::clamp(std::stof(settings["GamepadFlickstickReleaseDeadzone"]), 0.0f, 0.9f); + processed_keys.insert("GamepadFlickstickReleaseDeadzone"); + } + if (settings.count("GamepadIconOverride")) { + g_alpine_game_config.gamepad_icon_override = std::clamp(std::stoi(settings["GamepadIconOverride"]), 0, 10); + processed_keys.insert("GamepadIconOverride"); + } + if (settings.count("InputPromptMode")) { + g_alpine_game_config.input_prompt_override = std::clamp(std::stoi(settings["InputPromptMode"]), 0, 2); + processed_keys.insert("InputPromptMode"); + } + if (settings.count("GamepadJoyInvertY")) { + g_alpine_game_config.gamepad_joy_invert_y = std::stoi(settings["GamepadJoyInvertY"]) != 0; + processed_keys.insert("GamepadJoyInvertY"); + } + if (settings.count("GamepadSwapSticks")) { + g_alpine_game_config.gamepad_swap_sticks = std::stoi(settings["GamepadSwapSticks"]) != 0; + processed_keys.insert("GamepadSwapSticks"); + } + if (settings.count("GamepadRumble")) { + g_alpine_game_config.gamepad_rumble_intensity = std::clamp(std::stof(settings["GamepadRumble"]), 0.0f, 1.0f); + processed_keys.insert("GamepadRumble"); + } + if (settings.count("GamepadWeaponRumble")) { + g_alpine_game_config.gamepad_weapon_rumble_enabled = std::stoi(settings["GamepadWeaponRumble"]) != 0; + processed_keys.insert("GamepadWeaponRumble"); + } + if (settings.count("GamepadEnvironmentalRumble")) { + g_alpine_game_config.gamepad_environmental_rumble_enabled = std::stoi(settings["GamepadEnvironmentalRumble"]) != 0; + processed_keys.insert("GamepadEnvironmentalRumble"); + } + if (settings.count("GamepadTriggerRumble")) { + g_alpine_game_config.gamepad_trigger_rumble_intensity = std::clamp(std::stof(settings["GamepadTriggerRumble"]), 0.0f, 1.0f); + processed_keys.insert("GamepadTriggerRumble"); + } + if (settings.count("GamepadRumbleVibrationFilter")) { + g_alpine_game_config.gamepad_rumble_vibration_filter = std::clamp(std::stoi(settings["GamepadRumbleVibrationFilter"]), 0, 2); + processed_keys.insert("GamepadRumbleVibrationFilter"); + } + if (settings.count("GamepadRumbleWhenPrimary")) { + g_alpine_game_config.gamepad_rumble_when_primary = std::stoi(settings["GamepadRumbleWhenPrimary"]) != 0; + processed_keys.insert("GamepadRumbleWhenPrimary"); + } + // Per-button gamepad bindings + for (int b = 0; b < gamepad_get_button_count(); ++b) { + std::string key = "GamepadBtn_" + std::to_string(b); + if (settings.count(key)) { + gamepad_set_button_binding(b, std::stoi(settings[key])); + processed_keys.insert(key); + } + } + if (settings.count("GamepadLTAction")) { + gamepad_set_trigger_action(0, std::stoi(settings["GamepadLTAction"])); + processed_keys.insert("GamepadLTAction"); + } + if (settings.count("GamepadRTAction")) { + gamepad_set_trigger_action(1, std::stoi(settings["GamepadRTAction"])); + processed_keys.insert("GamepadRTAction"); } if (settings.count("SwapARBinds")) { g_alpine_game_config.swap_ar_controls = std::stoi(settings["SwapARBinds"]); @@ -1122,6 +1257,14 @@ bool alpine_player_settings_load(rf::Player* player) update_scanner_sensitivity(); processed_keys.insert("ScannerSensitivityModifier"); } + if (settings.count("GamepadGyroScopeSensitivityModifier")) { + g_alpine_game_config.set_gamepad_scope_gyro_sens_mod(std::stof(settings["GamepadGyroScopeSensitivityModifier"])); + processed_keys.insert("GamepadGyroScopeSensitivityModifier"); + } + if (settings.count("GamepadGyroScannerSensitivityModifier")) { + g_alpine_game_config.set_gamepad_scanner_gyro_sens_mod(std::stof(settings["GamepadGyroScannerSensitivityModifier"])); + processed_keys.insert("GamepadGyroScannerSensitivityModifier"); + } // Load binds for (const auto& [key, value] : settings) { @@ -1144,6 +1287,27 @@ bool alpine_player_settings_load(rf::Player* player) player->settings.controls.bindings[bind_id].scan_codes[1] = scan2.empty() ? -1 : std::stoi(scan2); player->settings.controls.bindings[bind_id].mouse_btn_id = mouse_btn.empty() ? -1 : std::stoi(mouse_btn); + // Optional 5th field: primary gamepad scan code + std::string gp_sc_str; + if (std::getline(bind_values, gp_sc_str, ',') && !gp_sc_str.empty()) { + int gp_sc = std::stoi(gp_sc_str); + int offset = gp_sc - CTRL_GAMEPAD_SCAN_BASE; + if (offset >= 0 && offset < gamepad_get_button_count()) + gamepad_set_button_binding(offset, bind_id); + else if (gp_sc == CTRL_GAMEPAD_LEFT_TRIGGER) + gamepad_set_trigger_action(0, bind_id); + else if (gp_sc == CTRL_GAMEPAD_RIGHT_TRIGGER) + gamepad_set_trigger_action(1, bind_id); + } + + // Optional 6th field: secondary (extended-button) gamepad scan code + std::string gp_sc_alt_str; + if (std::getline(bind_values, gp_sc_alt_str, ',') && !gp_sc_alt_str.empty()) { + int gp_sc_alt = std::stoi(gp_sc_alt_str); + if (gp_sc_alt >= CTRL_GAMEPAD_EXTENDED_BASE && gp_sc_alt < CTRL_GAMEPAD_LEFT_TRIGGER) + gamepad_set_button_alt_binding(gp_sc_alt - CTRL_GAMEPAD_SCAN_BASE, bind_id); + } + xlog::info("Loaded Bind: {} = {}, {}, {}, {}", action_name, bind_id, scan1, scan2, mouse_btn); processed_keys.insert(key); } @@ -1200,25 +1364,74 @@ void alpine_control_config_serialize(std::ofstream& file, const rf::ControlConfi file << "ScopeSensitivityModifier=" << g_alpine_game_config.scope_sensitivity_modifier << "\n"; file << "ScannerSensitivityModifier=" << g_alpine_game_config.scanner_sensitivity_modifier << "\n"; file << "QuickExit=" << g_alpine_game_config.quick_exit << "\n"; + file << "GamepadCameraSensitivity=" << g_alpine_game_config.gamepad_joy_sensitivity << "\n"; + file << "GamepadMoveDeadzone=" << g_alpine_game_config.gamepad_move_deadzone << "\n"; + file << "GamepadLookDeadzone=" << g_alpine_game_config.gamepad_look_deadzone << "\n"; + file << "GamepadScopeSensitivityModifier=" << g_alpine_game_config.gamepad_scope_sensitivity_modifier << "\n"; + file << "GamepadScannerSensitivityModifier=" << g_alpine_game_config.gamepad_scanner_sensitivity_modifier << "\n"; + file << "GamepadJoyCamera=" << g_alpine_game_config.gamepad_joy_camera << "\n"; + file << "GamepadFlickstickSweep=" << g_alpine_game_config.gamepad_flickstick_sweep << "\n"; + file << "GamepadFlickstickDeadzone=" << g_alpine_game_config.gamepad_flickstick_deadzone << "\n"; + file << "GamepadFlickstickReleaseDeadzone=" << g_alpine_game_config.gamepad_flickstick_release_deadzone << "\n"; + file << "GamepadFlickstickSmoothing=" << g_alpine_game_config.gamepad_flickstick_smoothing << "\n"; + file << "GamepadGyroEnabled=" << g_alpine_game_config.gamepad_gyro_enabled << "\n"; + file << "GamepadGyroVehicleEnabled=" << g_alpine_game_config.gamepad_gyro_vehicle_camera << "\n"; + file << "GamepadGyroCameraSensitivity=" << g_alpine_game_config.gamepad_gyro_sensitivity << "\n"; + file << "GamepadGyroScopeSensitivityModifier=" << g_alpine_game_config.gamepad_scope_gyro_sensitivity_modifier << "\n"; + file << "GamepadGyroScannerSensitivityModifier=" << g_alpine_game_config.gamepad_scanner_gyro_sensitivity_modifier << "\n"; + file << "GamepadGyroAutocalibrationMode=" << g_alpine_game_config.gamepad_gyro_autocalibration_mode << "\n"; + file << "GamepadGyroSpace=" << g_alpine_game_config.gamepad_gyro_space << "\n"; + file << "GamepadGyroModifierMode=" << g_alpine_game_config.gamepad_gyro_modifier_mode << "\n"; + file << "GamepadGyroInvertY=" << g_alpine_game_config.gamepad_gyro_invert_y << "\n"; + file << "GamepadGyroTightening=" << g_alpine_game_config.gamepad_gyro_tightening << "\n"; + file << "GamepadGyroSmoothing=" << g_alpine_game_config.gamepad_gyro_smoothing << "\n"; + file << "GamepadIconOverride=" << g_alpine_game_config.gamepad_icon_override << "\n"; + file << "InputPromptMode=" << g_alpine_game_config.input_prompt_override << "\n"; + file << "GamepadJoyInvertY=" << g_alpine_game_config.gamepad_joy_invert_y << "\n"; + file << "GamepadSwapSticks=" << g_alpine_game_config.gamepad_swap_sticks << "\n"; + file << "GamepadRumble=" << g_alpine_game_config.gamepad_rumble_intensity << "\n"; + file << "GamepadWeaponRumble=" << g_alpine_game_config.gamepad_weapon_rumble_enabled << "\n"; + file << "GamepadEnvironmentalRumble=" << g_alpine_game_config.gamepad_environmental_rumble_enabled << "\n"; + file << "GamepadTriggerRumble=" << g_alpine_game_config.gamepad_trigger_rumble_intensity << "\n"; + file << "GamepadRumbleVibrationFilter=" << g_alpine_game_config.gamepad_rumble_vibration_filter << "\n"; + file << "GamepadRumbleWhenPrimary=" << g_alpine_game_config.gamepad_rumble_when_primary << "\n"; file << "\n[ActionBinds]\n"; - file << "; Format is Bind:{Name}={ID},{ScanCode0},{ScanCode1},{MouseButtonID}\n"; + file << "; Format is Bind:{Name}={ID},{ScanCode0},{ScanCode1},{MouseButtonID},{GamepadScanCode},{GamepadScanCodeAlt}\n"; - // Key bind format: Bind:ActionName=ID,PrimaryScanCode,SecondaryScanCode,MouseButtonID + // Key bind format: Bind:ActionName=ID,PrimaryScanCode,SecondaryScanCode,MouseButtonID,GamepadScanCode,GamepadScanCodeAlt // Note ActionName is not used when loading, ID is. ActionName is included for readability. + // GamepadScanCode is -1 if no gamepad button is bound to this action. + // GamepadScanCodeAlt is -1 if no secondary (extended) button is bound to this action. for (int i = 0; i < cc.num_bindings; ++i) { - xlog::info("Saving Bind: {} = {}, {}, {}, {}", - cc.bindings[i].name, - i, - cc.bindings[i].scan_codes[0], - cc.bindings[i].scan_codes[1], - cc.bindings[i].mouse_btn_id); - - file << "Bind:" << cc.bindings[i].name << "=" - << i << "," - << cc.bindings[i].scan_codes[0] << "," + int16_t gp_sc = -1; + int btn = gamepad_get_button_for_action(i); + int trig = gamepad_get_trigger_for_action(i); + if (btn >= 0) gp_sc = static_cast(CTRL_GAMEPAD_SCAN_BASE + btn); + else if (trig == 0) gp_sc = static_cast(CTRL_GAMEPAD_LEFT_TRIGGER); + else if (trig == 1) gp_sc = static_cast(CTRL_GAMEPAD_RIGHT_TRIGGER); + + int16_t gp_sc_alt = -1; + int btn_alt = -1, btn_primary_unused = -1; + gamepad_get_buttons_for_action(i, &btn_primary_unused, &btn_alt); + if (btn_alt >= 0) gp_sc_alt = static_cast(CTRL_GAMEPAD_SCAN_BASE + btn_alt); + + xlog::info("Saving Bind: {} = {}, {}, {}, {}, {}, {}", + cc.bindings[i].name, + i, + cc.bindings[i].scan_codes[0], + cc.bindings[i].scan_codes[1], + cc.bindings[i].mouse_btn_id, + gp_sc, + gp_sc_alt); + + file << "Bind:" << cc.bindings[i].name << "=" + << i << "," + << cc.bindings[i].scan_codes[0] << "," << cc.bindings[i].scan_codes[1] << "," - << cc.bindings[i].mouse_btn_id << "\n"; + << cc.bindings[i].mouse_btn_id << "," + << gp_sc << "," + << gp_sc_alt << "\n"; } } @@ -1325,7 +1538,6 @@ void alpine_player_settings_save(rf::Player* player) file << "MessagesVolume=" << rf::snd_get_group_volume(2) << "\n"; file << "LevelSoundVolume=" << g_alpine_game_config.level_sound_volume << "\n"; file << "EntityPainSounds=" << g_alpine_game_config.entity_pain_sounds << "\n"; - file << "Footsteps=" << g_alpine_game_config.footsteps << "\n"; // Video file << "\n[VideoSettings]\n"; @@ -1570,6 +1782,7 @@ CallHook player_settings_load_hook{ player_settings_load_hook.call_target(player); // load players.cfg set_alpine_config_defaults(); + gamepad_reset_to_defaults(); // Display restart popup due to players.cfg import // players.cfg from legacy client version will import fine on first load, apart from Alpine controls @@ -1591,6 +1804,9 @@ CallHook player_settings_load_hook{ } } + // Re-apply gyro calibration system + gyro_update_calibration_mode(); + // display popup recommending ff link (skip for bots) if (ff_link_prompt && !g_game_config.suppress_ff_link_prompt && !client_bot_launch_enabled()) { g_game_config.suppress_ff_link_prompt = true; // only display popup once diff --git a/game_patch/misc/alpine_settings.h b/game_patch/misc/alpine_settings.h index 564f6e5ab..170686872 100644 --- a/game_patch/misc/alpine_settings.h +++ b/game_patch/misc/alpine_settings.h @@ -5,6 +5,7 @@ #include "../rf/os/timestamp.h" #include "../hud/hud.h" #include "../hud/remote_server_cfg_ui.h" +#include "../input/gyro.h" extern bool g_loaded_alpine_settings_file; @@ -47,6 +48,30 @@ struct AlpineGameSettings scanner_sensitivity_modifier = std::clamp(mod, min_sens_mod, max_sens_mod); } + float gamepad_scope_sensitivity_modifier = 0.25f; + void set_gamepad_scope_sens_mod(float mod) + { + gamepad_scope_sensitivity_modifier = std::clamp(mod, min_sens_mod, max_sens_mod); + } + + float gamepad_scanner_sensitivity_modifier = 0.25f; + void set_gamepad_scanner_sens_mod(float mod) + { + gamepad_scanner_sensitivity_modifier = std::clamp(mod, min_sens_mod, max_sens_mod); + } + + float gamepad_scope_gyro_sensitivity_modifier = 0.25f; + void set_gamepad_scope_gyro_sens_mod(float mod) + { + gamepad_scope_gyro_sensitivity_modifier = std::clamp(mod, min_sens_mod, max_sens_mod); + } + + float gamepad_scanner_gyro_sensitivity_modifier = 0.25f; + void set_gamepad_scanner_gyro_sens_mod(float mod) + { + gamepad_scanner_gyro_sensitivity_modifier = std::clamp(mod, min_sens_mod, max_sens_mod); + } + float level_sound_volume = 1.0f; void set_level_sound_volume(float scale) { @@ -151,6 +176,34 @@ struct AlpineGameSettings bool verbose_time_left_display = true; bool nearest_texture_filtering = false; bool direct_input = true; + float gamepad_joy_sensitivity = 2.5f; + float gamepad_move_deadzone = 0.14f; + float gamepad_look_deadzone = 0.33f; + bool gamepad_gyro_enabled = false; + float gamepad_gyro_sensitivity = 2.5f; + bool gamepad_gyro_vehicle_camera = false; + int gamepad_gyro_autocalibration_mode = 1; // 0=Off, 1=MenuOnly, 2=Always + int gamepad_gyro_space = 3; // GyroSpace: Yaw=0 Roll=1 Local=2 Player=3 World=4 + int gamepad_gyro_modifier_mode = 0; // 0=Always, 1=HoldOff, 2=HoldOn, 3=Toggle + bool gamepad_gyro_invert_y = false; + float gamepad_gyro_tightening = 8.0f; + float gamepad_gyro_smoothing = 7.0f; + int gamepad_gyro_vh_mixer = 0; // -100 = reduce vertical, 0 = 1:1, +100 = reduce horizontal + bool gamepad_joy_camera = false; + float gamepad_flickstick_sweep = 1.0f; + float gamepad_flickstick_deadzone = 0.90f; + float gamepad_flickstick_release_deadzone = 0.70f; + float gamepad_flickstick_smoothing = 0.75f; + int gamepad_icon_override = 0; // 0=Auto, 1=Generic, 2=Xbox360, 3=XboxOne, 4=DS3, 5=DS4, 6=DualSense, 7=NintendoSwitch, 8=NintendoGameCube, 9=SteamController, 10=SteamDeck + int input_prompt_override = 0; // 0=Auto, 1=Controller, 2=Keyboard + bool gamepad_joy_invert_y = false; + bool gamepad_swap_sticks = false; + float gamepad_rumble_intensity = 1.0f; + bool gamepad_weapon_rumble_enabled = true; + bool gamepad_environmental_rumble_enabled = true; + float gamepad_trigger_rumble_intensity = 0.5f; + int gamepad_rumble_vibration_filter = 1; // 0=Off, 1=Auto (reduce low-freq when gyro is active), 2=On (always reduce low-freq) + bool gamepad_rumble_when_primary = true; // disable rumble when keyboard/mouse was last used bool scoreboard_anim = true; bool legacy_bob = false; bool scoreboard_split_simple = true; diff --git a/game_patch/misc/camera.cpp b/game_patch/misc/camera.cpp index e86e4a25d..f498323f4 100644 --- a/game_patch/misc/camera.cpp +++ b/game_patch/misc/camera.cpp @@ -10,6 +10,7 @@ #include "../rf/multi.h" #include "../os/console.h" #include "../input/input.h" +#include "../input/gamepad.h" #include "../input/mouse.h" #include "../rf/entity.h" #include "../misc/misc.h" @@ -34,6 +35,11 @@ constexpr float freelook_accel_scroll_step = 0.25f; bool server_side_restrict_disable_ss = false; +static bool s_camera_resetting = false; +static bool s_camera_reset_prev_down = false; +static float s_camera_reset_start_pitch = 0.0f; +static float s_camera_reset_elapsed = 0.0f; + FunHook camera_update_shake_hook{ 0x0040DB70, [](rf::Camera *camera) { @@ -180,6 +186,7 @@ CodeInjection free_camera_do_frame_patch{ if (!rf::is_multi) { cep->info->acceleration = freelook_cam_base_accel; // `camera2` in SP ignores accel scale + flush_freelook_gamepad_deltas(); return; } else { @@ -200,6 +207,7 @@ CodeInjection free_camera_do_frame_patch{ } cep->info->acceleration = freelook_cam_base_accel * freelook_cam_accel_scale; + flush_freelook_gamepad_deltas(); } } } @@ -338,7 +346,7 @@ static float convert_pitch_delta_to_non_linear_space( const float pitch_delta, const float yaw_delta ) { - // Convert to linear space. See `physics_make_orient`. + // Convert to linear space. See `physics_make_orient`. const rf::Vector3 fvec = fw_vector_from_non_linear_yaw_pitch(current_yaw, current_pitch_non_lin); const float current_pitch_lin = linear_pitch_from_forward_vector(fvec); @@ -356,10 +364,50 @@ static float convert_pitch_delta_to_non_linear_space( // Update non-linear pitch delta. const float new_pitch_delta = new_pitch_non_lin - current_pitch_non_lin; + xlog::trace( + "non-lin {} lin {} delta {} new {}", + current_pitch_non_lin, + current_pitch_lin, + pitch_delta, + new_pitch_delta + ); return new_pitch_delta; } +static void apply_camera_reset_to_horizon(rf::Entity* entity, float& pitch_delta) +{ + // Track button state every frame so a held button doesn't re-arm after completion. + if (rf::local_player) { + const auto action = get_af_control(rf::AlpineControlConfigAction::AF_ACTION_CENTER_VIEW); + const bool down = rf::control_is_control_down(&rf::local_player->settings.controls, action); + + if (!s_camera_resetting && down && !s_camera_reset_prev_down) { + // Rising edge: capture the starting pitch and begin the animation. + s_camera_resetting = true; + s_camera_reset_start_pitch = entity->control_data.eye_phb.x; + s_camera_reset_elapsed = 0.0f; + } + + s_camera_reset_prev_down = down; + } + + if (!s_camera_resetting) + return; + + // Advance time and compute a smoothstep [0,1] parameter. + // Smoothstep has zero first-derivative at both endpoints, giving a natural ease-in/out feel. + constexpr float duration = 0.3f; + s_camera_reset_elapsed += rf::frametime; + const float t = std::min(s_camera_reset_elapsed / duration, 1.0f); + const float t_smooth = t * t * (3.0f - 2.0f * t); + const float target_pitch = s_camera_reset_start_pitch * (1.0f - t_smooth); + pitch_delta = target_pitch - entity->control_data.eye_phb.x; + + if (t >= 1.0f) + s_camera_resetting = false; +} + // Applies raw/modern mouse deltas and linear pitch correction at the entity // control injection point. For the player entity, this is where accumulated raw // mouse deltas are consumed (freelook camera deltas are consumed earlier in @@ -384,6 +432,12 @@ CodeInjection linear_pitch_patch{ yaw_delta += mouse_yaw; } + // Add gamepad rotation deltas to the game's computed deltas + float gamepad_pitch = 0.0f, gamepad_yaw = 0.0f; + consume_raw_gamepad_deltas(gamepad_pitch, gamepad_yaw); + pitch_delta += gamepad_pitch; + yaw_delta += gamepad_yaw; + // Apply linear pitch correction to combined delta if (g_alpine_game_config.mouse_linear_pitch && pitch_delta != 0.0f) { const float current_yaw = entity->control_data.phb.y; @@ -395,9 +449,17 @@ CodeInjection linear_pitch_patch{ yaw_delta ); } + + // Apply camera reset to horizon if Center View action is pressed + apply_camera_reset_to_horizon(entity, pitch_delta); }, }; +void camera_start_reset_to_horizon() +{ + s_camera_resetting = true; +} + ConsoleCommand2 linear_pitch_cmd{ "cl_linearpitch", []() { @@ -440,7 +502,7 @@ void camera_do_patch() // Improve freelook spectate logic after level transition. multi_get_state_info_camera_enter_fixed_patch.install(); - // Linear pitch correction + // linear pitch correction linear_pitch_patch.install(); linear_pitch_cmd.register_cmd(); } diff --git a/game_patch/misc/main_menu.cpp b/game_patch/misc/main_menu.cpp index 32b940bdc..33ec4d8b2 100644 --- a/game_patch/misc/main_menu.cpp +++ b/game_patch/misc/main_menu.cpp @@ -521,6 +521,11 @@ CodeInjection options_do_frame_patch{ break; } } + } else { + // Options menu has closed — restore keyboard scan codes if the + // controller bindings view was still active. + if (ui_ctrl_bindings_view_active()) + ui_ctrl_bindings_view_reset(); } }, }; diff --git a/game_patch/misc/misc.h b/game_patch/misc/misc.h index 4b4387020..96069f80a 100644 --- a/game_patch/misc/misc.h +++ b/game_patch/misc/misc.h @@ -25,3 +25,13 @@ void clear_explicit_upcoming_game_type_request(); bool file_loaded_from_alpinefaction_vpp(const char* filename); bool weapon_reticle_is_customized(int weapon_id, bool bighud); bool rocket_locked_reticle_is_customized(bool bighud); + +// Controller bindings tab (ui.cpp) — queried from main_menu.cpp +bool ui_ctrl_bindings_view_active(); +void ui_ctrl_bindings_view_reset(); // restore keyboard scan codes and deactivate tab + +// Redraw currently active HUD token message after input icon/settings change +void hud_refresh_action_tokens(); + +// Schedule a HUD token refresh on the next game frame (safe to call while menus are open) +void hud_mark_bindings_dirty(); diff --git a/game_patch/misc/ui.cpp b/game_patch/misc/ui.cpp index 44fb50348..81f9fc7e0 100644 --- a/game_patch/misc/ui.cpp +++ b/game_patch/misc/ui.cpp @@ -21,6 +21,9 @@ #include "../object/object.h" #include "../graphics/d3d11/gr_d3d11_mesh.h" #include "../rf/level.h" +#include "../input/gyro.h" +#include "../input/gamepad.h" +#include "../input/input.h" #define DEBUG_UI_LAYOUT 0 #define SHARP_UI_TEXT 1 @@ -29,6 +32,19 @@ static rf::ui::Gadget* new_gadgets[6]; // Allocate space for 6 options buttons static rf::ui::Button alpine_options_btn; +// Controller bindings overlay (shown when the CONTROLLER tab is active on options panel 3) +static bool g_ctrl_bind_view = false; // KEYBOARD vs CONTROLLER tab toggle +static bool g_ctrl_codes_installed = false; // true while gamepad scan codes are in cc.bindings + +static int16_t g_saved_scan_codes[128] = {}; // keyboard scan_codes[0] per binding slot +static int16_t g_saved_sc1[128] = {}; // keyboard scan_codes[1] per binding slot +static int16_t g_saved_mouse_btn_ids[128] = {}; // keyboard mouse_btn_id per binding slot + +// CONTROLLER mode checkbox (integrated into the controls panel) +static rf::ui::Checkbox g_ctrl_mode_cbox; +static bool g_ctrl_mode_btns_initialized = false; +static bool g_alpine_options_hud_dirty = false; + // alpine options panel elements static rf::ui::Panel alpine_options_panel; // parent to all subpanels static rf::ui::Panel alpine_options_panel0; @@ -40,6 +56,41 @@ std::vector alpine_options_panel_settings; std::vector alpine_options_panel_labels; std::vector alpine_options_panel_tab_labels; +// scroll state and content-area bounds for the scrollable settings panels +static int alpine_options_scroll_offsets[4] = {0, 0, 0, 0}; +static bool alpine_options_panel_scrollable[4] = {false, false, true, false}; +static constexpr int AO_CONTENT_TOP = 44; +static constexpr int AO_CONTENT_BOTTOM = 344; +static constexpr int AO_SCROLL_STEP = 30; + +// scrollbar geometry cached each frame for hit-testing +static int g_sb_x = 0, g_sb_y = 0, g_sb_pw = 0, g_sb_ph = 0; +static int g_sb_thumb_y = 0, g_sb_thumb_h = 0; +static bool g_sb_visible = false; +static bool g_sb_dragging = false; +static int g_sb_drag_origin_y = 0, g_sb_drag_origin_scroll = 0; + +// Column layout packer: assigns Y positions to gadget rows in order, +// advancing by AO_SCROLL_STEP for each visible row. Construct with the +// starting Y, then call add_checkbox / add_inputbox for each row. +struct AoColumn { + int y; + + void add_checkbox(rf::ui::Checkbox& cbox, rf::ui::Label& lbl, bool visible = true) + { + if (!visible) return; + cbox.y = y; lbl.y = y + 6; + y += AO_SCROLL_STEP; + } + + void add_inputbox(rf::ui::Checkbox& cbox, rf::ui::Label& lbl, rf::ui::Label& butlbl, bool visible = true) + { + if (!visible) return; + cbox.y = y; lbl.y = y + 6; butlbl.y = y + 6; + y += AO_SCROLL_STEP; + } +}; + // alpine options tabs static rf::ui::Checkbox ao_tab_0_cbox; static rf::ui::Label ao_tab_0_label; @@ -76,6 +127,17 @@ static rf::ui::Label ao_scopesens_label; static rf::ui::Label ao_scopesens_butlabel; static char ao_scopesens_butlabel_text[9]; static rf::ui::Checkbox ao_maxfps_cbox; + +static rf::ui::Checkbox ao_gamepad_icon_override_cbox; +static rf::ui::Label ao_gamepad_icon_override_label; +static rf::ui::Label ao_gamepad_icon_override_butlabel; +static char ao_gamepad_icon_override_butlabel_text[20]; + +static rf::ui::Checkbox ao_input_prompt_mode_cbox; +static rf::ui::Label ao_input_prompt_mode_label; +static rf::ui::Label ao_input_prompt_mode_butlabel; +static char ao_input_prompt_mode_butlabel_text[16]; + static rf::ui::Label ao_maxfps_label; static rf::ui::Label ao_maxfps_butlabel; static char ao_maxfps_butlabel_text[9]; @@ -188,6 +250,113 @@ static rf::ui::Label ao_exposuredamage_label; static rf::ui::Checkbox ao_painsounds_cbox; static rf::ui::Label ao_painsounds_label; +// gamepad settings +static rf::ui::Checkbox ao_gyro_enabled_cbox; +static rf::ui::Label ao_gyro_enabled_label; +static rf::ui::Checkbox ao_joy_sensitivity_cbox; +static rf::ui::Label ao_joy_sensitivity_label; +static rf::ui::Label ao_joy_sensitivity_butlabel; +static char ao_joy_sensitivity_butlabel_text[9]; +static char ao_joy_sensitivity_label_text[17]; +static rf::ui::Checkbox ao_move_deadzone_cbox; +static rf::ui::Label ao_move_deadzone_label; +static rf::ui::Label ao_move_deadzone_butlabel; +static char ao_move_deadzone_butlabel_text[9]; +static rf::ui::Checkbox ao_look_deadzone_cbox; +static rf::ui::Label ao_look_deadzone_label; +static rf::ui::Label ao_look_deadzone_butlabel; +static char ao_look_deadzone_butlabel_text[9]; +static rf::ui::Checkbox ao_gyro_sensitivity_cbox; +static rf::ui::Label ao_gyro_sensitivity_label; +static rf::ui::Label ao_gyro_sensitivity_butlabel; +static char ao_gyro_sensitivity_butlabel_text[9]; +static rf::ui::Checkbox ao_gyro_autocalibration_cbox; +static rf::ui::Label ao_gyro_autocalibration_label; +static rf::ui::Label ao_gyro_autocalibration_butlabel; +static char ao_gyro_autocalibration_butlabel_text[16]; +static rf::ui::Checkbox ao_gyro_modifier_mode_cbox; +static rf::ui::Label ao_gyro_modifier_mode_label; +static rf::ui::Label ao_gyro_modifier_mode_butlabel; +static char ao_gyro_modifier_mode_butlabel_text[12]; +static rf::ui::Checkbox ao_gyro_space_cbox; +static rf::ui::Label ao_gyro_space_label; +static rf::ui::Label ao_gyro_space_butlabel; +static char ao_gyro_space_butlabel_text[12]; +static rf::ui::Checkbox ao_gyro_invert_y_cbox; +static rf::ui::Label ao_gyro_invert_y_label; +static rf::ui::Checkbox ao_gyro_vehicle_cbox; +static rf::ui::Label ao_gyro_vehicle_label; +static rf::ui::Checkbox ao_gyro_tightening_cbox; +static rf::ui::Label ao_gyro_tightening_label; +static rf::ui::Label ao_gyro_tightening_butlabel; +static char ao_gyro_tightening_butlabel_text[9]; +static rf::ui::Checkbox ao_gyro_smoothing_cbox; +static rf::ui::Label ao_gyro_smoothing_label; +static rf::ui::Label ao_gyro_smoothing_butlabel; +static char ao_gyro_smoothing_butlabel_text[9]; +static rf::ui::Checkbox ao_gyro_vh_mixer_cbox; +static rf::ui::Label ao_gyro_vh_mixer_label; +static rf::ui::Label ao_gyro_vh_mixer_butlabel; +static char ao_gyro_vh_mixer_butlabel_text[9]; +static rf::ui::Checkbox ao_gyro_scannersens_cbox; +static rf::ui::Label ao_gyro_scannersens_label; +static rf::ui::Label ao_gyro_scannersens_butlabel; +static char ao_gyro_scannersens_butlabel_text[9]; +static rf::ui::Checkbox ao_gyro_scopesens_cbox; +static rf::ui::Label ao_gyro_scopesens_label; +static rf::ui::Label ao_gyro_scopesens_butlabel; +static char ao_gyro_scopesens_butlabel_text[9]; +static rf::ui::Checkbox ao_rumble_intensity_cbox; +static rf::ui::Label ao_rumble_intensity_label; +static rf::ui::Label ao_rumble_intensity_butlabel; +static char ao_rumble_intensity_butlabel_text[9]; +static rf::ui::Checkbox ao_rumble_trigger_cbox; +static rf::ui::Label ao_rumble_trigger_label; +static rf::ui::Label ao_rumble_trigger_butlabel; +static char ao_rumble_trigger_butlabel_text[9]; +static rf::ui::Checkbox ao_rumble_filter_cbox; +static rf::ui::Label ao_rumble_filter_label; +static rf::ui::Label ao_rumble_filter_butlabel; +static char ao_rumble_filter_butlabel_text[9]; +static rf::ui::Checkbox ao_rumble_weapon_cbox; +static rf::ui::Label ao_rumble_weapon_label; +static rf::ui::Checkbox ao_rumble_env_cbox; +static rf::ui::Label ao_rumble_env_label; +static rf::ui::Checkbox ao_rumble_primary_cbox; +static rf::ui::Label ao_rumble_primary_label; +static rf::ui::Checkbox ao_joy_camera_cbox; +static rf::ui::Label ao_joy_camera_label; +static rf::ui::Label ao_joy_camera_butlabel; +static char ao_joy_camera_butlabel_text[12]; +static rf::ui::Checkbox ao_joy_invert_y_cbox; +static rf::ui::Label ao_joy_invert_y_label; +static rf::ui::Checkbox ao_swap_sticks_cbox; +static rf::ui::Label ao_swap_sticks_label; +static rf::ui::Checkbox ao_flickstick_sweep_cbox; +static rf::ui::Label ao_flickstick_sweep_label; +static rf::ui::Label ao_flickstick_sweep_butlabel; +static char ao_flickstick_sweep_butlabel_text[9]; +static rf::ui::Checkbox ao_flickstick_deadzone_cbox; +static rf::ui::Label ao_flickstick_deadzone_label; +static rf::ui::Label ao_flickstick_deadzone_butlabel; +static char ao_flickstick_deadzone_butlabel_text[9]; +static rf::ui::Checkbox ao_flickstick_release_dz_cbox; +static rf::ui::Label ao_flickstick_release_dz_label; +static rf::ui::Label ao_flickstick_release_dz_butlabel; +static char ao_flickstick_release_dz_butlabel_text[9]; +static rf::ui::Checkbox ao_flickstick_smoothing_cbox; +static rf::ui::Label ao_flickstick_smoothing_label; +static rf::ui::Label ao_flickstick_smoothing_butlabel; +static char ao_flickstick_smoothing_butlabel_text[9]; +static rf::ui::Checkbox ao_joy_scannersens_cbox; +static rf::ui::Label ao_joy_scannersens_label; +static rf::ui::Label ao_joy_scannersens_butlabel; +static char ao_joy_scannersens_butlabel_text[9]; +static rf::ui::Checkbox ao_joy_scopesens_cbox; +static rf::ui::Label ao_joy_scopesens_label; +static rf::ui::Label ao_joy_scopesens_butlabel; +static char ao_joy_scopesens_butlabel_text[9]; + // levelsounds audio options slider std::vector alpine_audio_panel_settings; std::vector alpine_audio_panel_settings_buttons; @@ -629,7 +798,7 @@ void ao_scannersens_cbox_on_click_callback() { } } void ao_scannersens_cbox_on_click(int x, int y) { - rf::ui::popup_message("Enter new scanner sensitivity modifier value:", "", ao_scannersens_cbox_on_click_callback, 1); + rf::ui::popup_message("Enter new mouse scanner sensitivity modifier value:", "", ao_scannersens_cbox_on_click_callback, 1); } // scope ms @@ -647,7 +816,75 @@ void ao_scopesens_cbox_on_click_callback() { } } void ao_scopesens_cbox_on_click(int x, int y) { - rf::ui::popup_message("Enter new scope sensitivity modifier value:", "", ao_scopesens_cbox_on_click_callback, 1); + rf::ui::popup_message("Enter new mouse scope sensitivity modifier value:", "", ao_scopesens_cbox_on_click_callback, 1); +} + +// joy scanner modifier +void ao_joy_scannersens_cbox_on_click_callback() { + char str_buffer[7] = ""; + rf::ui::popup_get_input(str_buffer, sizeof(str_buffer)); + std::string str = str_buffer; + try { + float new_scale = std::stof(str); + g_alpine_game_config.set_gamepad_scanner_sens_mod(new_scale); + } + catch (const std::exception& e) { + xlog::info("Invalid modifier input: '{}', reason: {}", str, e.what()); + } +} +void ao_joy_scannersens_cbox_on_click(int x, int y) { + rf::ui::popup_message("Enter new joy scanner sensitivity modifier value:", "", ao_joy_scannersens_cbox_on_click_callback, 1); +} + +// joy scope modifier +void ao_joy_scopesens_cbox_on_click_callback() { + char str_buffer[7] = ""; + rf::ui::popup_get_input(str_buffer, sizeof(str_buffer)); + std::string str = str_buffer; + try { + float new_scale = std::stof(str); + g_alpine_game_config.set_gamepad_scope_sens_mod(new_scale); + } + catch (const std::exception& e) { + xlog::info("Invalid modifier input: '{}', reason: {}", str, e.what()); + } +} +void ao_joy_scopesens_cbox_on_click(int x, int y) { + rf::ui::popup_message("Enter new joy scope sensitivity modifier value:", "", ao_joy_scopesens_cbox_on_click_callback, 1); +} + +// gyro scanner modifier +void ao_gyro_scannersens_cbox_on_click_callback() { + char str_buffer[7] = ""; + rf::ui::popup_get_input(str_buffer, sizeof(str_buffer)); + std::string str = str_buffer; + try { + float new_scale = std::stof(str); + g_alpine_game_config.set_gamepad_scanner_gyro_sens_mod(new_scale); + } + catch (const std::exception& e) { + xlog::info("Invalid modifier input: '{}', reason: {}", str, e.what()); + } +} +void ao_gyro_scannersens_cbox_on_click(int x, int y) { + rf::ui::popup_message("Enter new gyro scanner sensitivity modifier value:", "", ao_gyro_scannersens_cbox_on_click_callback, 1); +} + +// gyro scope modifier +void ao_gyro_scopesens_cbox_on_click_callback() { + char str_buffer[7] = ""; + rf::ui::popup_get_input(str_buffer, sizeof(str_buffer)); + std::string str = str_buffer; + try { + float new_scale = std::stof(str); + g_alpine_game_config.set_gamepad_scope_gyro_sens_mod(new_scale); + } + catch (const std::exception& e) { + xlog::info("Invalid modifier input: '{}', reason: {}", str, e.what()); + } +} +void ao_gyro_scopesens_cbox_on_click(int x, int y) { + rf::ui::popup_message("Enter new gyro scope sensitivity modifier value:", "", ao_gyro_scopesens_cbox_on_click_callback, 1); } // reticle scale @@ -780,6 +1017,303 @@ void ao_togglecrouch_cbox_on_click(int x, int y) { ao_play_button_snd(rf::local_player->settings.toggle_crouch); } +// gamepad settings +void ao_gyro_enabled_cbox_on_click(int x, int y) { + if (!gamepad_is_motionsensors_supported()) return; + g_alpine_game_config.gamepad_gyro_enabled = !g_alpine_game_config.gamepad_gyro_enabled; + ao_gyro_enabled_cbox.checked = g_alpine_game_config.gamepad_gyro_enabled; + ao_play_button_snd(g_alpine_game_config.gamepad_gyro_enabled); + gyro_update_calibration_mode(); +} + +void ao_joy_sensitivity_cbox_on_click_callback() { + char str_buffer[7] = ""; + rf::ui::popup_get_input(str_buffer, sizeof(str_buffer)); + std::string str = str_buffer; + try { + float val = std::stof(str); + g_alpine_game_config.gamepad_joy_sensitivity = std::max(0.0f, val); + } + catch (const std::exception& e) { + xlog::info("Invalid sensitivity input: '{}', reason: {}", str, e.what()); + } +} +void ao_joy_sensitivity_cbox_on_click(int x, int y) { + rf::ui::popup_message("Enter new joy stick sensitivity value:", "", ao_joy_sensitivity_cbox_on_click_callback, 1); +} + +void ao_move_deadzone_cbox_on_click_callback() { + char str_buffer[7] = ""; + rf::ui::popup_get_input(str_buffer, sizeof(str_buffer)); + std::string str = str_buffer; + try { + float val = std::stof(str); + g_alpine_game_config.gamepad_move_deadzone = std::clamp(val, 0.0f, 0.9f); + } + catch (const std::exception& e) { + xlog::info("Invalid deadzone input: '{}', reason: {}", str, e.what()); + } +} +void ao_move_deadzone_cbox_on_click(int x, int y) { + rf::ui::popup_message("Enter new joy move deadzone value (0.0 - 0.9):", "", ao_move_deadzone_cbox_on_click_callback, 1); +} + +void ao_look_deadzone_cbox_on_click_callback() { + char str_buffer[7] = ""; + rf::ui::popup_get_input(str_buffer, sizeof(str_buffer)); + std::string str = str_buffer; + try { + float val = std::stof(str); + g_alpine_game_config.gamepad_look_deadzone = std::clamp(val, 0.0f, 0.9f); + } + catch (const std::exception& e) { + xlog::info("Invalid deadzone input: '{}', reason: {}", str, e.what()); + } +} +void ao_look_deadzone_cbox_on_click(int x, int y) { + rf::ui::popup_message("Enter new joy look deadzone value (0.0 - 0.9):", "", ao_look_deadzone_cbox_on_click_callback, 1); +} + +void ao_gyro_sensitivity_cbox_on_click_callback() { + char str_buffer[7] = ""; + rf::ui::popup_get_input(str_buffer, sizeof(str_buffer)); + std::string str = str_buffer; + try { + float val = std::stof(str); + g_alpine_game_config.gamepad_gyro_sensitivity = std::clamp(val, 0.0f, 30.0f); + } + catch (const std::exception& e) { + xlog::info("Invalid sensitivity input: '{}', reason: {}", str, e.what()); + } +} +void ao_gyro_sensitivity_cbox_on_click(int x, int y) { + rf::ui::popup_message("Enter new gyro sensitivity value (0.0-30.00):", "", ao_gyro_sensitivity_cbox_on_click_callback, 1); +} + +void ao_gyro_autocalibration_cbox_on_click([[maybe_unused]] int x, [[maybe_unused]] int y) { + int mode = static_cast(g_alpine_game_config.gamepad_gyro_autocalibration_mode); + gyro_set_autocalibration_mode((mode + 1) % 3); + ao_play_button_snd(true); +} + +void ao_gyro_modifier_mode_cbox_on_click([[maybe_unused]] int x, [[maybe_unused]] int y) { + g_alpine_game_config.gamepad_gyro_modifier_mode = (g_alpine_game_config.gamepad_gyro_modifier_mode + 1) % 4; + ao_play_button_snd(true); +} + +void ao_gyro_space_cbox_on_click([[maybe_unused]] int x, [[maybe_unused]] int y) { + g_alpine_game_config.gamepad_gyro_space = (g_alpine_game_config.gamepad_gyro_space + 1) % 5; + ao_play_button_snd(true); +} + +void ao_gamepad_icon_override_cbox_on_click([[maybe_unused]] int x, [[maybe_unused]] int y) { + g_alpine_game_config.gamepad_icon_override = (g_alpine_game_config.gamepad_icon_override + 1) % 11; + ao_play_button_snd(true); + g_alpine_options_hud_dirty = true; +} + +void ao_input_prompt_mode_cbox_on_click([[maybe_unused]] int x, [[maybe_unused]] int y) { + g_alpine_game_config.input_prompt_override = (g_alpine_game_config.input_prompt_override + 1) % 3; + ao_play_button_snd(true); + g_alpine_options_hud_dirty = true; +} + +void ao_gyro_invert_y_cbox_on_click(int x, int y) { + g_alpine_game_config.gamepad_gyro_invert_y = !g_alpine_game_config.gamepad_gyro_invert_y; + ao_gyro_invert_y_cbox.checked = g_alpine_game_config.gamepad_gyro_invert_y; + ao_play_button_snd(g_alpine_game_config.gamepad_gyro_invert_y); +} + +void ao_gyro_vehicle_cbox_on_click(int x, int y) { + g_alpine_game_config.gamepad_gyro_vehicle_camera = !g_alpine_game_config.gamepad_gyro_vehicle_camera; + ao_gyro_vehicle_cbox.checked = g_alpine_game_config.gamepad_gyro_vehicle_camera; + ao_play_button_snd(g_alpine_game_config.gamepad_gyro_vehicle_camera); +} + +void ao_rumble_intensity_cbox_on_click_callback() { + char str_buffer[7] = ""; + rf::ui::popup_get_input(str_buffer, sizeof(str_buffer)); + std::string str = str_buffer; + try { + float val = std::clamp(std::stof(str), 0.0f, 1.0f); + g_alpine_game_config.gamepad_rumble_intensity = val; + } + catch (const std::exception& e) { + xlog::info("Invalid intensity input: '{}', reason: {}", str, e.what()); + } +} +void ao_rumble_intensity_cbox_on_click(int x, int y) { + rf::ui::popup_message("Enter new rumble intensity value (0.0-1.0):", "", ao_rumble_intensity_cbox_on_click_callback, 1); +} + +void ao_rumble_trigger_cbox_on_click_callback() { + char str_buffer[7] = ""; + rf::ui::popup_get_input(str_buffer, sizeof(str_buffer)); + std::string str = str_buffer; + try { + float val = std::clamp(std::stof(str), 0.0f, 1.0f); + g_alpine_game_config.gamepad_trigger_rumble_intensity = val; + } + catch (const std::exception& e) { + xlog::info("Invalid intensity input: '{}', reason: {}", str, e.what()); + } +} +void ao_rumble_trigger_cbox_on_click(int x, int y) { + rf::ui::popup_message("Enter new trigger rumble intensity value (0.0-1.0):", "", ao_rumble_trigger_cbox_on_click_callback, 1); +} + +void ao_rumble_filter_cbox_on_click([[maybe_unused]] int x, [[maybe_unused]] int y) { + g_alpine_game_config.gamepad_rumble_vibration_filter = (g_alpine_game_config.gamepad_rumble_vibration_filter + 1) % 3; + ao_play_button_snd(true); +} + +void ao_rumble_weapon_cbox_on_click(int x, int y) { + g_alpine_game_config.gamepad_weapon_rumble_enabled = !g_alpine_game_config.gamepad_weapon_rumble_enabled; + ao_rumble_weapon_cbox.checked = g_alpine_game_config.gamepad_weapon_rumble_enabled; + ao_play_button_snd(g_alpine_game_config.gamepad_weapon_rumble_enabled); +} + +void ao_rumble_env_cbox_on_click(int x, int y) { + g_alpine_game_config.gamepad_environmental_rumble_enabled = !g_alpine_game_config.gamepad_environmental_rumble_enabled; + ao_rumble_env_cbox.checked = g_alpine_game_config.gamepad_environmental_rumble_enabled; + ao_play_button_snd(g_alpine_game_config.gamepad_environmental_rumble_enabled); +} + +void ao_rumble_primary_cbox_on_click(int x, int y) { + g_alpine_game_config.gamepad_rumble_when_primary = !g_alpine_game_config.gamepad_rumble_when_primary; + ao_rumble_primary_cbox.checked = g_alpine_game_config.gamepad_rumble_when_primary; + ao_play_button_snd(g_alpine_game_config.gamepad_rumble_when_primary); +} + +void ao_gyro_tightening_cbox_on_click_callback() { + char str_buffer[7] = ""; + rf::ui::popup_get_input(str_buffer, sizeof(str_buffer)); + std::string str = str_buffer; + try { + float val = std::stof(str); + g_alpine_game_config.gamepad_gyro_tightening = std::clamp(val, 0.0f, 100.0f); + } + catch (const std::exception& e) { + xlog::info("Invalid tightening input: '{}', reason: {}", str, e.what()); + } +} +void ao_gyro_tightening_cbox_on_click(int x, int y) { + rf::ui::popup_message("Enter new gyro tightening value (0.0-100.0):", "", ao_gyro_tightening_cbox_on_click_callback, 1); +} + +void ao_gyro_smoothing_cbox_on_click_callback() { + char str_buffer[7] = ""; + rf::ui::popup_get_input(str_buffer, sizeof(str_buffer)); + std::string str = str_buffer; + try { + float val = std::stof(str); + g_alpine_game_config.gamepad_gyro_smoothing = std::clamp(val, 0.0f, 100.0f); + } + catch (const std::exception& e) { + xlog::info("Invalid smoothing input: '{}', reason: {}", str, e.what()); + } +} +void ao_gyro_smoothing_cbox_on_click(int x, int y) { + rf::ui::popup_message("Enter new gyro smoothing value (0.0-100.0):", "", ao_gyro_smoothing_cbox_on_click_callback, 1); +} + +void ao_gyro_vh_mixer_cbox_on_click_callback() { + char str_buffer[7] = ""; + rf::ui::popup_get_input(str_buffer, sizeof(str_buffer)); + std::string str = str_buffer; + try { + int val = std::stoi(str); + g_alpine_game_config.gamepad_gyro_vh_mixer = std::clamp(val, -100, 100); + } + catch (const std::exception& e) { + xlog::info("Invalid mixer input: '{}', reason: {}", str, e.what()); + } +} +void ao_gyro_vh_mixer_cbox_on_click(int x, int y) { + rf::ui::popup_message("Enter Gyro V/H Mixer (Vertical: -100 to -1 , Horizontal: 1 to 100):", "", ao_gyro_vh_mixer_cbox_on_click_callback, 1); +} + +void ao_joy_camera_cbox_on_click([[maybe_unused]] int x, [[maybe_unused]] int y) { + g_alpine_game_config.gamepad_joy_camera = !g_alpine_game_config.gamepad_joy_camera; + ao_play_button_snd(true); +} + +void ao_flickstick_sweep_cbox_on_click_callback() { + char str_buffer[7] = ""; + rf::ui::popup_get_input(str_buffer, sizeof(str_buffer)); + std::string str = str_buffer; + try { + float val = std::stof(str); + g_alpine_game_config.gamepad_flickstick_sweep = std::clamp(val, 0.01f, 10.0f); + } + catch (const std::exception& e) { + xlog::info("Invalid sweep input: '{}', reason: {}", str, e.what()); + } +} +void ao_flickstick_sweep_cbox_on_click(int x, int y) { + rf::ui::popup_message("Enter new flick stick sweep value (0.01-10.0):", "", ao_flickstick_sweep_cbox_on_click_callback, 1); +} + +void ao_flickstick_deadzone_cbox_on_click_callback() { + char str_buffer[7] = ""; + rf::ui::popup_get_input(str_buffer, sizeof(str_buffer)); + std::string str = str_buffer; + try { + float val = std::stof(str); + g_alpine_game_config.gamepad_flickstick_deadzone = std::clamp(val, 0.0f, 0.9f); + } + catch (const std::exception& e) { + xlog::info("Invalid deadzone input: '{}', reason: {}", str, e.what()); + } +} +void ao_flickstick_deadzone_cbox_on_click(int x, int y) { + rf::ui::popup_message("Enter new flick stick deadzone value (0.0-0.9):", "", ao_flickstick_deadzone_cbox_on_click_callback, 1); +} + +void ao_flickstick_release_dz_cbox_on_click_callback() { + char str_buffer[7] = ""; + rf::ui::popup_get_input(str_buffer, sizeof(str_buffer)); + std::string str = str_buffer; + try { + float val = std::stof(str); + g_alpine_game_config.gamepad_flickstick_release_deadzone = std::clamp(val, 0.0f, 0.9f); + } + catch (const std::exception& e) { + xlog::info("Invalid release deadzone input: '{}', reason: {}", str, e.what()); + } +} +void ao_flickstick_release_dz_cbox_on_click(int x, int y) { + rf::ui::popup_message("Enter new flick stick release deadzone value (0.0-0.9):", "", ao_flickstick_release_dz_cbox_on_click_callback, 1); +} + +void ao_flickstick_smoothing_cbox_on_click_callback() { + char str_buffer[7] = ""; + rf::ui::popup_get_input(str_buffer, sizeof(str_buffer)); + std::string str = str_buffer; + try { + float val = std::stof(str); + g_alpine_game_config.gamepad_flickstick_smoothing = std::clamp(val, 0.0f, 1.0f); + } + catch (const std::exception& e) { + xlog::info("Invalid smoothing input: '{}', reason: {}", str, e.what()); + } +} +void ao_flickstick_smoothing_cbox_on_click(int x, int y) { + rf::ui::popup_message("Enter new flick stick smoothing value (0.0-1.0):", "", ao_flickstick_smoothing_cbox_on_click_callback, 1); +} + +void ao_joy_invert_y_cbox_on_click(int x, int y) { + g_alpine_game_config.gamepad_joy_invert_y = !g_alpine_game_config.gamepad_joy_invert_y; + ao_joy_invert_y_cbox.checked = g_alpine_game_config.gamepad_joy_invert_y; + ao_play_button_snd(g_alpine_game_config.gamepad_joy_invert_y); +} + +void ao_swap_sticks_cbox_on_click(int x, int y) { + g_alpine_game_config.gamepad_swap_sticks = !g_alpine_game_config.gamepad_swap_sticks; + ao_swap_sticks_cbox.checked = g_alpine_game_config.gamepad_swap_sticks; + ao_play_button_snd(g_alpine_game_config.gamepad_swap_sticks); +} + void ao_joinbeep_cbox_on_click(int x, int y) { g_alpine_game_config.player_join_beep = !g_alpine_game_config.player_join_beep; ao_joinbeep_cbox.checked = g_alpine_game_config.player_join_beep; @@ -1005,9 +1539,34 @@ void ao_enemybullets_cbox_on_click(int x, int y) { ao_play_button_snd(g_alpine_game_config.show_enemy_bullets); } +static rf::ui::Panel* ao_get_active_subpanel() +{ + switch (alpine_options_panel_current_tab) { + case 0: return &alpine_options_panel0; + case 1: return &alpine_options_panel1; + case 2: return &alpine_options_panel2; + case 3: return &alpine_options_panel3; + default: return nullptr; + } +} + +static int ao_compute_max_scroll(rf::ui::Panel* subpanel) +{ + if (!subpanel) return 0; + int max_bottom = 0; + for (auto* g : alpine_options_panel_settings) { + if (g && g->enabled && g->parent == subpanel) + max_bottom = std::max(max_bottom, g->y + g->h); + } + for (auto* l : alpine_options_panel_labels) { + if (l && l->enabled && l->parent == subpanel) + max_bottom = std::max(max_bottom, l->y + l->h); + } + int raw_max = std::max(0, max_bottom - AO_CONTENT_BOTTOM); + return (raw_max / AO_SCROLL_STEP) * AO_SCROLL_STEP; +} + void alpine_options_panel_handle_key(rf::Key* key){ - // todo: more key support (tab, etc.) - // close panel on escape if (*key == rf::Key::KEY_ESC) { rf::ui::options_close_current_panel(); rf::snd_play(43, 0, 0.0f, 1.0f); @@ -1016,20 +1575,71 @@ void alpine_options_panel_handle_key(rf::Key* key){ } void alpine_options_panel_handle_mouse(int x, int y) { + for (auto* gadget : alpine_options_panel_settings) + if (gadget) gadget->highlighted = false; + + if (g_sb_dragging) { + if (!rf::mouse_button_is_down(0)) { + g_sb_dragging = false; + } else { + if (g_sb_visible && g_sb_ph > g_sb_thumb_h) { + const int tab = alpine_options_panel_current_tab; + int max_scroll = ao_compute_max_scroll(ao_get_active_subpanel()); + int drag_delta = y - g_sb_drag_origin_y; + int scroll_range_px = g_sb_ph - g_sb_thumb_h; + int raw = g_sb_drag_origin_scroll + drag_delta * max_scroll / scroll_range_px; + int snapped = (raw / AO_SCROLL_STEP) * AO_SCROLL_STEP; + alpine_options_scroll_offsets[tab] = std::clamp(snapped, 0, max_scroll); + } + return; + } + } + + if (g_sb_visible && rf::mouse_was_button_pressed(0)) { + const bool in_track = (x >= g_sb_x && x < g_sb_x + g_sb_pw + && y >= g_sb_y && y < g_sb_y + g_sb_ph); + if (in_track) { + const bool in_thumb = (y >= g_sb_thumb_y && y < g_sb_thumb_y + g_sb_thumb_h); + if (in_thumb) { + g_sb_dragging = true; + g_sb_drag_origin_y = y; + g_sb_drag_origin_scroll = alpine_options_scroll_offsets[alpine_options_panel_current_tab]; + } else { + const int tab = alpine_options_panel_current_tab; + int max_scroll = ao_compute_max_scroll(ao_get_active_subpanel()); + int step = (y < g_sb_thumb_y) ? -(AO_SCROLL_STEP * 5) : (AO_SCROLL_STEP * 5); + alpine_options_scroll_offsets[tab] = std::clamp( + alpine_options_scroll_offsets[tab] + step, 0, max_scroll); + } + return; + } + } + + int dx = 0, dy = 0, dz = 0; + rf::mouse_get_delta(dx, dy, dz); + if (dz == 0) + dz = gamepad_consume_menu_scroll(); + if (dz != 0 && alpine_options_panel_scrollable[alpine_options_panel_current_tab]) { + const int tab = alpine_options_panel_current_tab; + int step = (dz > 0) ? -AO_SCROLL_STEP : AO_SCROLL_STEP; + int max_scroll = ao_compute_max_scroll(ao_get_active_subpanel()); + alpine_options_scroll_offsets[tab] = std::clamp(alpine_options_scroll_offsets[tab] + step, 0, max_scroll); + } + + rf::ui::Panel* active_subpanel = ao_get_active_subpanel(); + int current_scroll = alpine_options_scroll_offsets[alpine_options_panel_current_tab]; + int hovered_index = -1; - //xlog::warn("handling mouse {}, {}", x, y); - // Check which gadget is being hovered over for (size_t i = 0; i < alpine_options_panel_settings.size(); ++i) { auto* gadget = alpine_options_panel_settings[i]; if (gadget && gadget->enabled) { + int scroll_adj = (active_subpanel && gadget->parent == active_subpanel) ? current_scroll : 0; int abs_x = static_cast(gadget->get_absolute_x() * rf::ui::scale_x); - int abs_y = static_cast(gadget->get_absolute_y() * rf::ui::scale_y); + int abs_y = static_cast((gadget->get_absolute_y() - scroll_adj) * rf::ui::scale_y); int abs_w = static_cast(gadget->w * rf::ui::scale_x); int abs_h = static_cast(gadget->h * rf::ui::scale_y); - //xlog::warn("Checking gadget {} at ({}, {}) size ({}, {})", i, abs_x, abs_y, abs_w, abs_h); - if (x >= abs_x && x <= abs_x + abs_w && y >= abs_y && y <= abs_y + abs_h) { hovered_index = static_cast(i); @@ -1037,30 +1647,13 @@ void alpine_options_panel_handle_mouse(int x, int y) { } } } - //xlog::warn("hovered {}", hovered_index); if (hovered_index >= 0) { auto* gadget = alpine_options_panel_settings[hovered_index]; - - if (gadget) { - if (rf::mouse_was_button_pressed(0)) { // Left mouse button pressed - //xlog::warn("Clicked on gadget index {}", hovered_index); - - // Call on_click if assigned - if (gadget->on_click) { - gadget->on_click(x, y); - } - } - else if (rf::mouse_button_is_down(0) && gadget->on_mouse_btn_down) { - // Handle mouse button being held down - gadget->on_mouse_btn_down(x, y); - } - } - } - - // Update all gadgets - for (auto* gadget : alpine_options_panel_settings) { - if (gadget) { - gadget->highlighted = false; + if (rf::mouse_was_button_pressed(0)) { + if (gadget->on_click) + gadget->on_click(x, y); + } else if (rf::mouse_button_is_down(0) && gadget->on_mouse_btn_down) { + gadget->on_mouse_btn_down(x, y); } } @@ -1117,6 +1710,9 @@ void alpine_options_panel_tab_init(rf::ui::Checkbox* tab_button, rf::ui::Label* } void alpine_options_panel_init() { + // reset per-tab scroll positions whenever the panel is (re-)opened + std::fill(std::begin(alpine_options_scroll_offsets), std::end(alpine_options_scroll_offsets), 0); + // panels alpine_options_panel.create("alpine_options_panelp.tga", rf::ui::options_panel_x, rf::ui::options_panel_y); alpine_options_panel0.create("alpine_options_panel0.tga", 0, 0); @@ -1212,6 +1808,15 @@ void alpine_options_panel_init() { alpine_options_panel_checkbox_init( &ao_always_show_spectators_cbox, &ao_always_show_spectators_label, &alpine_options_panel1, ao_always_show_spectators_cbox_on_click, g_alpine_game_config.always_show_spectators, 280, 264, "Show spectators"); + alpine_options_panel_inputbox_init( + &ao_input_prompt_mode_cbox, &ao_input_prompt_mode_label, &ao_input_prompt_mode_butlabel, + &alpine_options_panel1, ao_input_prompt_mode_cbox_on_click, 280, 294, "Input Glyph"); + ao_input_prompt_mode_butlabel.x -= 8; + alpine_options_panel_inputbox_init( + &ao_gamepad_icon_override_cbox, &ao_gamepad_icon_override_label, &ao_gamepad_icon_override_butlabel, + &alpine_options_panel1, ao_gamepad_icon_override_cbox_on_click, 280, 324, "Gamepad Glyph"); + ao_gamepad_icon_override_butlabel.x -= 8; + // panel 2 alpine_options_panel_checkbox_init( &ao_dinput_cbox, &ao_dinput_label, &alpine_options_panel2, ao_dinput_cbox_on_click, g_alpine_game_config.direct_input, 112, 54, "DirectInput"); @@ -1234,6 +1839,84 @@ void alpine_options_panel_init() { &ao_staticscope_cbox, &ao_staticscope_label, &alpine_options_panel2, ao_staticscope_cbox_on_click, g_alpine_game_config.scope_static_sensitivity, 280, 144, "Linear scope"); alpine_options_panel_checkbox_init( &ao_togglecrouch_cbox, &ao_togglecrouch_label, &alpine_options_panel2, ao_togglecrouch_cbox_on_click, rf::local_player->settings.toggle_crouch, 280, 174, "Toggle crouch"); + alpine_options_panel_inputbox_init( + &ao_joy_camera_cbox, &ao_joy_camera_label, &ao_joy_camera_butlabel, &alpine_options_panel2, ao_joy_camera_cbox_on_click, 112, 234, "Joy cam modes"); + alpine_options_panel_inputbox_init( + &ao_joy_sensitivity_cbox, &ao_joy_sensitivity_label, &ao_joy_sensitivity_butlabel, &alpine_options_panel2, ao_joy_sensitivity_cbox_on_click, 112, 234, "Joy sensitivity"); + alpine_options_panel_inputbox_init( + &ao_flickstick_sweep_cbox, &ao_flickstick_sweep_label, &ao_flickstick_sweep_butlabel, &alpine_options_panel2, ao_flickstick_sweep_cbox_on_click, 112, 234, "Flick sweep"); + alpine_options_panel_inputbox_init( + &ao_flickstick_deadzone_cbox, &ao_flickstick_deadzone_label, &ao_flickstick_deadzone_butlabel, &alpine_options_panel2, ao_flickstick_deadzone_cbox_on_click, 112, 294, "Flick deadzone"); + alpine_options_panel_inputbox_init( + &ao_flickstick_release_dz_cbox, &ao_flickstick_release_dz_label, &ao_flickstick_release_dz_butlabel, &alpine_options_panel2, ao_flickstick_release_dz_cbox_on_click, 112, 324, "Flick release dz"); + alpine_options_panel_inputbox_init( + &ao_flickstick_smoothing_cbox, &ao_flickstick_smoothing_label, &ao_flickstick_smoothing_butlabel, &alpine_options_panel2, ao_flickstick_smoothing_cbox_on_click, 112, 354, "Flick smoothing"); + alpine_options_panel_inputbox_init( + &ao_move_deadzone_cbox, &ao_move_deadzone_label, &ao_move_deadzone_butlabel, &alpine_options_panel2, ao_move_deadzone_cbox_on_click, 112, 204, "Joy move dz"); + alpine_options_panel_inputbox_init( + &ao_look_deadzone_cbox, &ao_look_deadzone_label, &ao_look_deadzone_butlabel, &alpine_options_panel2, ao_look_deadzone_cbox_on_click, 112, 294, "Joy cam dz"); + alpine_options_panel_checkbox_init( + &ao_joy_invert_y_cbox, &ao_joy_invert_y_label, &alpine_options_panel2, ao_joy_invert_y_cbox_on_click, g_alpine_game_config.gamepad_joy_invert_y, 112, 384, "Joy cam Y-Invert"); + alpine_options_panel_checkbox_init( + &ao_swap_sticks_cbox, &ao_swap_sticks_label, &alpine_options_panel2, ao_swap_sticks_cbox_on_click, g_alpine_game_config.gamepad_swap_sticks, 112, 414, "Swap joysticks"); + alpine_options_panel_inputbox_init( + &ao_joy_scannersens_cbox, &ao_joy_scannersens_label, &ao_joy_scannersens_butlabel, &alpine_options_panel2, ao_joy_scannersens_cbox_on_click, 112, 324, "Joy scanner mod"); + alpine_options_panel_inputbox_init( + &ao_joy_scopesens_cbox, &ao_joy_scopesens_label, &ao_joy_scopesens_butlabel, &alpine_options_panel2, ao_joy_scopesens_cbox_on_click, 112, 354, "Joy scope mod"); + alpine_options_panel_checkbox_init( + &ao_gyro_enabled_cbox, &ao_gyro_enabled_label, &alpine_options_panel2, ao_gyro_enabled_cbox_on_click, g_alpine_game_config.gamepad_gyro_enabled, 280, 204, "Gyro aiming"); + alpine_options_panel_inputbox_init( + &ao_gyro_sensitivity_cbox, &ao_gyro_sensitivity_label, &ao_gyro_sensitivity_butlabel, &alpine_options_panel2, ao_gyro_sensitivity_cbox_on_click, 280, 234, "Gyro sensitivity"); + alpine_options_panel_checkbox_init( + &ao_gyro_invert_y_cbox, &ao_gyro_invert_y_label, + &alpine_options_panel2, ao_gyro_invert_y_cbox_on_click, + g_alpine_game_config.gamepad_gyro_invert_y, 280, 264, "Gyro Y-Invert"); + alpine_options_panel_checkbox_init( + &ao_gyro_vehicle_cbox, &ao_gyro_vehicle_label, + &alpine_options_panel2, ao_gyro_vehicle_cbox_on_click, + g_alpine_game_config.gamepad_gyro_vehicle_camera, 280, 294, "Gyro vehicle cam"); + alpine_options_panel_inputbox_init( + &ao_gyro_space_cbox, &ao_gyro_space_label, &ao_gyro_space_butlabel, + &alpine_options_panel2, ao_gyro_space_cbox_on_click, 280, 294, "Gyro space"); + alpine_options_panel_inputbox_init( + &ao_gyro_autocalibration_cbox, &ao_gyro_autocalibration_label, &ao_gyro_autocalibration_butlabel, + &alpine_options_panel2, ao_gyro_autocalibration_cbox_on_click, 280, 324, "Gyro auto-calib"); + alpine_options_panel_inputbox_init( + &ao_gyro_modifier_mode_cbox, &ao_gyro_modifier_mode_label, &ao_gyro_modifier_mode_butlabel, + &alpine_options_panel2, ao_gyro_modifier_mode_cbox_on_click, 280, 354, "Gyro modifier"); + alpine_options_panel_inputbox_init( + &ao_gyro_tightening_cbox, &ao_gyro_tightening_label, &ao_gyro_tightening_butlabel, + &alpine_options_panel2, ao_gyro_tightening_cbox_on_click, 280, 354, "Gyro tightening"); + alpine_options_panel_inputbox_init( + &ao_gyro_smoothing_cbox, &ao_gyro_smoothing_label, &ao_gyro_smoothing_butlabel, + &alpine_options_panel2, ao_gyro_smoothing_cbox_on_click, 280, 384, "Gyro smoothing"); + alpine_options_panel_inputbox_init( + &ao_gyro_vh_mixer_cbox, &ao_gyro_vh_mixer_label, &ao_gyro_vh_mixer_butlabel, + &alpine_options_panel2, ao_gyro_vh_mixer_cbox_on_click, 280, 414, "Gyro V/H mixer"); + alpine_options_panel_inputbox_init( + &ao_gyro_scannersens_cbox, &ao_gyro_scannersens_label, &ao_gyro_scannersens_butlabel, + &alpine_options_panel2, ao_gyro_scannersens_cbox_on_click, 280, 414, "Gyro scanner mod"); + alpine_options_panel_inputbox_init( + &ao_gyro_scopesens_cbox, &ao_gyro_scopesens_label, &ao_gyro_scopesens_butlabel, + &alpine_options_panel2, ao_gyro_scopesens_cbox_on_click, 280, 444, "Gyro scope mod"); + alpine_options_panel_inputbox_init( + &ao_rumble_intensity_cbox, &ao_rumble_intensity_label, &ao_rumble_intensity_butlabel, + &alpine_options_panel2, ao_rumble_intensity_cbox_on_click, 280, 474, "Rumble intensity"); + alpine_options_panel_inputbox_init( + &ao_rumble_trigger_cbox, &ao_rumble_trigger_label, &ao_rumble_trigger_butlabel, + &alpine_options_panel2, ao_rumble_trigger_cbox_on_click, 280, 504, "Trigger rumble"); + alpine_options_panel_inputbox_init( + &ao_rumble_filter_cbox, &ao_rumble_filter_label, &ao_rumble_filter_butlabel, + &alpine_options_panel2, ao_rumble_filter_cbox_on_click, 280, 534, "Vibration filter"); + alpine_options_panel_checkbox_init( + &ao_rumble_weapon_cbox, &ao_rumble_weapon_label, + &alpine_options_panel2, ao_rumble_weapon_cbox_on_click, g_alpine_game_config.gamepad_weapon_rumble_enabled, 280, 564, "Weapon rumble"); + alpine_options_panel_checkbox_init( + &ao_rumble_env_cbox, &ao_rumble_env_label, + &alpine_options_panel2, ao_rumble_env_cbox_on_click, g_alpine_game_config.gamepad_environmental_rumble_enabled, 280, 594, "Environ. rumble"); + alpine_options_panel_checkbox_init( + &ao_rumble_primary_cbox, &ao_rumble_primary_label, + &alpine_options_panel2, ao_rumble_primary_cbox_on_click, g_alpine_game_config.gamepad_rumble_when_primary, 280, 624, "Rumble priority"); // panel 3 alpine_options_panel_checkbox_init( @@ -1326,11 +2009,24 @@ void alpine_options_panel_do_frame(int x) break; } - // render dynamic elements across all panels + // prepare scroll state for this frame + rf::ui::Panel* active_subpanel = ao_get_active_subpanel(); + int& current_scroll = alpine_options_scroll_offsets[alpine_options_panel_current_tab]; + + // render all gadgets – tab buttons are unscrolled; content items get a Y offset for (auto* ui_element : alpine_options_panel_settings) { if (ui_element) { auto checkbox = static_cast(ui_element); - if (checkbox) { + if (!checkbox || !checkbox->enabled) continue; + if (checkbox->parent == active_subpanel) { + // skip items whose top edge is outside the content viewport + int scrolled_y = checkbox->y - current_scroll; + if (scrolled_y < AO_CONTENT_TOP || scrolled_y >= AO_CONTENT_BOTTOM) + continue; + checkbox->y = scrolled_y; + checkbox->render(); + checkbox->y = scrolled_y + current_scroll; + } else { checkbox->render(); } } @@ -1390,18 +2086,340 @@ void alpine_options_panel_do_frame(int x) meshlight_mode_names[std::clamp(g_alpine_game_config.mesh_lighting_mode, 0, 2)]); ao_meshlight_butlabel.text = ao_meshlight_butlabel_text; - // render button labels + // gamepad settings + snprintf(ao_joy_camera_butlabel_text, sizeof(ao_joy_camera_butlabel_text), "%s", + g_alpine_game_config.gamepad_joy_camera ? "Flick Stick" : "Standard"); + ao_joy_camera_butlabel.text = ao_joy_camera_butlabel_text; + ao_joy_camera_butlabel.align = rf::gr::ALIGN_CENTER; + + snprintf(ao_flickstick_sweep_butlabel_text, sizeof(ao_flickstick_sweep_butlabel_text), "%6.4f", g_alpine_game_config.gamepad_flickstick_sweep); + ao_flickstick_sweep_butlabel.text = ao_flickstick_sweep_butlabel_text; + + snprintf(ao_flickstick_deadzone_butlabel_text, sizeof(ao_flickstick_deadzone_butlabel_text), "%6.4f", g_alpine_game_config.gamepad_flickstick_deadzone); + ao_flickstick_deadzone_butlabel.text = ao_flickstick_deadzone_butlabel_text; + + snprintf(ao_flickstick_release_dz_butlabel_text, sizeof(ao_flickstick_release_dz_butlabel_text), "%6.4f", g_alpine_game_config.gamepad_flickstick_release_deadzone); + ao_flickstick_release_dz_butlabel.text = ao_flickstick_release_dz_butlabel_text; + + snprintf(ao_flickstick_smoothing_butlabel_text, sizeof(ao_flickstick_smoothing_butlabel_text), "%6.4f", g_alpine_game_config.gamepad_flickstick_smoothing); + ao_flickstick_smoothing_butlabel.text = ao_flickstick_smoothing_butlabel_text; + + snprintf(ao_joy_sensitivity_butlabel_text, sizeof(ao_joy_sensitivity_butlabel_text), "%6.4f", g_alpine_game_config.gamepad_joy_sensitivity); + ao_joy_sensitivity_butlabel.text = ao_joy_sensitivity_butlabel_text; + + snprintf(ao_move_deadzone_butlabel_text, sizeof(ao_move_deadzone_butlabel_text), "%6.4f", g_alpine_game_config.gamepad_move_deadzone); + ao_move_deadzone_butlabel.text = ao_move_deadzone_butlabel_text; + + snprintf(ao_look_deadzone_butlabel_text, sizeof(ao_look_deadzone_butlabel_text), "%6.4f", g_alpine_game_config.gamepad_look_deadzone); + ao_look_deadzone_butlabel.text = ao_look_deadzone_butlabel_text; + + snprintf(ao_gyro_sensitivity_butlabel_text, sizeof(ao_gyro_sensitivity_butlabel_text), "%6.4f", g_alpine_game_config.gamepad_gyro_sensitivity); + ao_gyro_sensitivity_butlabel.text = ao_gyro_sensitivity_butlabel_text; + + snprintf(ao_gyro_tightening_butlabel_text, sizeof(ao_gyro_tightening_butlabel_text), "%6.4f", g_alpine_game_config.gamepad_gyro_tightening); + ao_gyro_tightening_butlabel.text = ao_gyro_tightening_butlabel_text; + + snprintf(ao_gyro_smoothing_butlabel_text, sizeof(ao_gyro_smoothing_butlabel_text), "%6.4f", g_alpine_game_config.gamepad_gyro_smoothing); + ao_gyro_smoothing_butlabel.text = ao_gyro_smoothing_butlabel_text; + + { + int v = g_alpine_game_config.gamepad_gyro_vh_mixer; + if (v == 0) + snprintf(ao_gyro_vh_mixer_butlabel_text, sizeof(ao_gyro_vh_mixer_butlabel_text), "0%%"); + else if (v > 0) + snprintf(ao_gyro_vh_mixer_butlabel_text, sizeof(ao_gyro_vh_mixer_butlabel_text), "%d%% H", v); + else + snprintf(ao_gyro_vh_mixer_butlabel_text, sizeof(ao_gyro_vh_mixer_butlabel_text), "%d%% V", v); + } + ao_gyro_vh_mixer_butlabel.text = ao_gyro_vh_mixer_butlabel_text; + ao_gyro_vh_mixer_butlabel.align = rf::gr::ALIGN_CENTER; + + snprintf(ao_joy_scannersens_butlabel_text, sizeof(ao_joy_scannersens_butlabel_text), "%6.4f", g_alpine_game_config.gamepad_scanner_sensitivity_modifier); + ao_joy_scannersens_butlabel.text = ao_joy_scannersens_butlabel_text; + + snprintf(ao_joy_scopesens_butlabel_text, sizeof(ao_joy_scopesens_butlabel_text), "%6.4f", g_alpine_game_config.gamepad_scope_sensitivity_modifier); + ao_joy_scopesens_butlabel.text = ao_joy_scopesens_butlabel_text; + + snprintf(ao_gyro_scannersens_butlabel_text, sizeof(ao_gyro_scannersens_butlabel_text), "%6.4f", g_alpine_game_config.gamepad_scanner_gyro_sensitivity_modifier); + ao_gyro_scannersens_butlabel.text = ao_gyro_scannersens_butlabel_text; + + snprintf(ao_gyro_scopesens_butlabel_text, sizeof(ao_gyro_scopesens_butlabel_text), "%6.4f", g_alpine_game_config.gamepad_scope_gyro_sensitivity_modifier); + ao_gyro_scopesens_butlabel.text = ao_gyro_scopesens_butlabel_text; + + const char* mode_name = "(unknown)"; + switch (std::clamp(g_alpine_game_config.gamepad_gyro_autocalibration_mode, 0, 2)) { + case 0: + mode_name = "Off"; + break; + case 1: + mode_name = "Menu"; + break; + case 2: + mode_name = "Always"; + break; + } + + snprintf(ao_gyro_autocalibration_butlabel_text, sizeof(ao_gyro_autocalibration_butlabel_text), "%s", mode_name); + ao_gyro_autocalibration_butlabel.text = ao_gyro_autocalibration_butlabel_text; + ao_gyro_autocalibration_butlabel.align = rf::gr::ALIGN_CENTER; + + static const char* gyro_modifier_mode_names[] = {"Always", "Hold (Off)", "Hold (On)", "Toggle"}; + snprintf(ao_gyro_modifier_mode_butlabel_text, sizeof(ao_gyro_modifier_mode_butlabel_text), "%s", + gyro_modifier_mode_names[std::clamp(g_alpine_game_config.gamepad_gyro_modifier_mode, 0, 3)]); + ao_gyro_modifier_mode_butlabel.text = ao_gyro_modifier_mode_butlabel_text; + ao_gyro_modifier_mode_butlabel.align = rf::gr::ALIGN_CENTER; + + snprintf(ao_gyro_space_butlabel_text, sizeof(ao_gyro_space_butlabel_text), "%s", gyro_get_space_name(g_alpine_game_config.gamepad_gyro_space)); + ao_gyro_space_butlabel.text = ao_gyro_space_butlabel_text; + ao_gyro_space_butlabel.align = rf::gr::ALIGN_CENTER; + + snprintf(ao_rumble_intensity_butlabel_text, sizeof(ao_rumble_intensity_butlabel_text), "%6.4f", g_alpine_game_config.gamepad_rumble_intensity); + ao_rumble_intensity_butlabel.text = ao_rumble_intensity_butlabel_text; + + snprintf(ao_rumble_trigger_butlabel_text, sizeof(ao_rumble_trigger_butlabel_text), "%6.4f", g_alpine_game_config.gamepad_trigger_rumble_intensity); + ao_rumble_trigger_butlabel.text = ao_rumble_trigger_butlabel_text; + + static const char* rumble_filter_names[] = {"Off", "Auto", "On"}; + snprintf(ao_rumble_filter_butlabel_text, sizeof(ao_rumble_filter_butlabel_text), "%s", + rumble_filter_names[std::clamp(g_alpine_game_config.gamepad_rumble_vibration_filter, 0, 2)]); + ao_rumble_filter_butlabel.text = ao_rumble_filter_butlabel_text; + ao_rumble_filter_butlabel.align = rf::gr::ALIGN_CENTER; + + static const char* gamepad_icon_names[] = {"auto", "generic", "xbox360", "xboxone", "ds3", "ds4", "dualsense", "ns switch", "gamecube", "sc1", "sd"}; + int gamepad_icon_index = std::clamp(g_alpine_game_config.gamepad_icon_override, 0, 10); + if (gamepad_icon_index == 0) { + snprintf(ao_gamepad_icon_override_butlabel_text, sizeof(ao_gamepad_icon_override_butlabel_text), " auto "); + } else { + snprintf(ao_gamepad_icon_override_butlabel_text, sizeof(ao_gamepad_icon_override_butlabel_text), "%s", gamepad_icon_names[gamepad_icon_index]); + } + ao_gamepad_icon_override_butlabel.text = ao_gamepad_icon_override_butlabel_text; + ao_gamepad_icon_override_butlabel.align = rf::gr::ALIGN_CENTER; + + static const char* input_prompt_names[] = {"auto", "gamepad", "kb/mouse"}; + int input_prompt_index = std::clamp(g_alpine_game_config.input_prompt_override, 0, 2); + if (input_prompt_index == 0) { + snprintf(ao_input_prompt_mode_butlabel_text, sizeof(ao_input_prompt_mode_butlabel_text), " auto "); + } else { + snprintf(ao_input_prompt_mode_butlabel_text, sizeof(ao_input_prompt_mode_butlabel_text), "%s", input_prompt_names[input_prompt_index]); + } + ao_input_prompt_mode_butlabel.text = ao_input_prompt_mode_butlabel_text; + ao_input_prompt_mode_butlabel.align = rf::gr::ALIGN_CENTER; + + ao_joy_camera_butlabel.x = 112 + 50; + ao_gyro_vh_mixer_butlabel.x = 280 + 50; + ao_gyro_space_butlabel.x = 280 + 50; + ao_gyro_autocalibration_butlabel.x = 280 + 50; + ao_gyro_modifier_mode_butlabel.x = 280 + 50; + ao_rumble_filter_butlabel.x = 280 + 50; + ao_gamepad_icon_override_butlabel.x = 280 + 50; + ao_input_prompt_mode_butlabel.x = 280 + 50; + + // show/hide gyro ui if gamepad supports motion sensors and (for subcontrols) gyro aiming is enabled + bool gyro_hw = gamepad_is_motionsensors_supported(); + bool gyro_enabled = gyro_hw && g_alpine_game_config.gamepad_gyro_enabled; + + ao_gyro_enabled_cbox.enabled = gyro_hw; + ao_gyro_enabled_label.enabled = gyro_hw; + + ao_gyro_sensitivity_cbox.enabled = gyro_enabled; + ao_gyro_sensitivity_label.enabled = gyro_enabled; + ao_gyro_sensitivity_butlabel.enabled = gyro_enabled; + + ao_gyro_autocalibration_cbox.enabled = gyro_enabled; + ao_gyro_autocalibration_label.enabled = gyro_enabled; + ao_gyro_autocalibration_butlabel.enabled = gyro_enabled; + + ao_gyro_modifier_mode_cbox.enabled = gyro_enabled; + ao_gyro_modifier_mode_label.enabled = gyro_enabled; + ao_gyro_modifier_mode_butlabel.enabled = gyro_enabled; + + ao_gyro_invert_y_cbox.enabled = gyro_enabled; + ao_gyro_invert_y_label.enabled = gyro_enabled; + ao_gyro_vehicle_cbox.enabled = gyro_enabled; + ao_gyro_vehicle_label.enabled = gyro_enabled; + ao_gyro_space_cbox.enabled = gyro_enabled; + ao_gyro_space_label.enabled = gyro_enabled; + ao_gyro_space_butlabel.enabled = gyro_enabled; + ao_gyro_tightening_cbox.enabled = gyro_enabled; + ao_gyro_tightening_label.enabled = gyro_enabled; + ao_gyro_tightening_butlabel.enabled = gyro_enabled; + ao_gyro_smoothing_cbox.enabled = gyro_enabled; + ao_gyro_smoothing_label.enabled = gyro_enabled; + ao_gyro_smoothing_butlabel.enabled = gyro_enabled; + ao_gyro_vh_mixer_cbox.enabled = gyro_enabled; + ao_gyro_vh_mixer_label.enabled = gyro_enabled; + ao_gyro_vh_mixer_butlabel.enabled = gyro_enabled; + ao_gyro_scannersens_cbox.enabled = gyro_enabled; + ao_gyro_scannersens_label.enabled = gyro_enabled; + ao_gyro_scannersens_butlabel.enabled = gyro_enabled; + ao_gyro_scopesens_cbox.enabled = gyro_enabled; + ao_gyro_scopesens_label.enabled = gyro_enabled; + ao_gyro_scopesens_butlabel.enabled = gyro_enabled; + + ao_rumble_intensity_cbox.enabled = true; + ao_rumble_intensity_label.enabled = true; + ao_rumble_intensity_butlabel.enabled = true; + bool trigger_rumble_hw = gamepad_is_trigger_rumble_supported(); + ao_rumble_trigger_cbox.enabled = trigger_rumble_hw; + ao_rumble_trigger_label.enabled = trigger_rumble_hw; + ao_rumble_trigger_butlabel.enabled = trigger_rumble_hw; + ao_rumble_filter_cbox.enabled = true; + ao_rumble_filter_label.enabled = true; + ao_rumble_filter_butlabel.enabled = true; + ao_rumble_weapon_cbox.enabled = true; + ao_rumble_weapon_label.enabled = true; + ao_rumble_env_cbox.enabled = true; + ao_rumble_env_label.enabled = true; + ao_rumble_primary_cbox.enabled = true; + ao_rumble_primary_label.enabled = true; + + ao_joy_scannersens_cbox.enabled = true; + ao_joy_scannersens_label.enabled = true; + ao_joy_scannersens_butlabel.enabled = true; + ao_joy_scopesens_cbox.enabled = true; + ao_joy_scopesens_label.enabled = true; + ao_joy_scopesens_butlabel.enabled = true; + + // dynamic right-column layout: pack gyro and rumble items tightly based on what's active + { + AoColumn rc{204}; + rc.add_checkbox(ao_gyro_enabled_cbox, ao_gyro_enabled_label, gyro_hw); + rc.add_checkbox(ao_gyro_vehicle_cbox, ao_gyro_vehicle_label, gyro_enabled); + rc.add_inputbox(ao_gyro_sensitivity_cbox, ao_gyro_sensitivity_label, ao_gyro_sensitivity_butlabel, gyro_enabled); + rc.add_inputbox(ao_gyro_scopesens_cbox, ao_gyro_scopesens_label, ao_gyro_scopesens_butlabel, gyro_enabled); + rc.add_inputbox(ao_gyro_scannersens_cbox, ao_gyro_scannersens_label, ao_gyro_scannersens_butlabel, gyro_enabled); + rc.add_inputbox(ao_gyro_modifier_mode_cbox, ao_gyro_modifier_mode_label, ao_gyro_modifier_mode_butlabel, gyro_enabled); + rc.add_inputbox(ao_gyro_tightening_cbox, ao_gyro_tightening_label, ao_gyro_tightening_butlabel, gyro_enabled); + rc.add_inputbox(ao_gyro_smoothing_cbox, ao_gyro_smoothing_label, ao_gyro_smoothing_butlabel, gyro_enabled); + rc.add_inputbox(ao_gyro_vh_mixer_cbox, ao_gyro_vh_mixer_label, ao_gyro_vh_mixer_butlabel, gyro_enabled); + rc.add_inputbox(ao_gyro_space_cbox, ao_gyro_space_label, ao_gyro_space_butlabel, gyro_enabled); + rc.add_checkbox(ao_gyro_invert_y_cbox, ao_gyro_invert_y_label, gyro_enabled); + rc.add_inputbox(ao_gyro_autocalibration_cbox, ao_gyro_autocalibration_label, ao_gyro_autocalibration_butlabel, gyro_enabled); + rc.add_inputbox(ao_rumble_intensity_cbox, ao_rumble_intensity_label, ao_rumble_intensity_butlabel); + rc.add_inputbox(ao_rumble_trigger_cbox, ao_rumble_trigger_label, ao_rumble_trigger_butlabel, trigger_rumble_hw); + rc.add_checkbox(ao_rumble_primary_cbox, ao_rumble_primary_label); + rc.add_inputbox(ao_rumble_filter_cbox, ao_rumble_filter_label, ao_rumble_filter_butlabel); + rc.add_checkbox(ao_rumble_weapon_cbox, ao_rumble_weapon_label); + rc.add_checkbox(ao_rumble_env_cbox, ao_rumble_env_label); + } + + ao_joy_camera_cbox.enabled = true; + ao_joy_camera_label.enabled = true; + ao_joy_camera_butlabel.enabled = true; + + // toggle regular stick vs flick stick controls, then reflow left column Y positions to avoid dead space + bool flick_stick = g_alpine_game_config.gamepad_joy_camera; + // Joy sensitivity is always shown: as "Joy sensitivity" in joystick mode, "Joy sens (misc)" in flick-stick mode. + snprintf(ao_joy_sensitivity_label_text, sizeof(ao_joy_sensitivity_label_text), "%s", + flick_stick ? "Joy sens (misc)" : "Joy sensitivity"); + ao_joy_sensitivity_label.text = ao_joy_sensitivity_label_text; + ao_joy_sensitivity_cbox.enabled = true; + ao_joy_sensitivity_label.enabled = true; + ao_joy_sensitivity_butlabel.enabled = true; + ao_look_deadzone_cbox.enabled = !flick_stick; + ao_look_deadzone_label.enabled = !flick_stick; + ao_look_deadzone_butlabel.enabled = !flick_stick; + ao_flickstick_sweep_cbox.enabled = flick_stick; + ao_flickstick_sweep_label.enabled = flick_stick; + ao_flickstick_sweep_butlabel.enabled = flick_stick; + ao_flickstick_deadzone_cbox.enabled = flick_stick; + ao_flickstick_deadzone_label.enabled = flick_stick; + ao_flickstick_deadzone_butlabel.enabled = flick_stick; + ao_flickstick_release_dz_cbox.enabled = flick_stick; + ao_flickstick_release_dz_label.enabled = flick_stick; + ao_flickstick_release_dz_butlabel.enabled = flick_stick; + ao_flickstick_smoothing_cbox.enabled = flick_stick; + ao_flickstick_smoothing_label.enabled = flick_stick; + ao_flickstick_smoothing_butlabel.enabled = flick_stick; + ao_joy_invert_y_cbox.enabled = !flick_stick; + ao_joy_invert_y_label.enabled = !flick_stick; + ao_swap_sticks_cbox.enabled = true; + ao_swap_sticks_label.enabled = true; + + // dynamic left-column layout: pack items tightly based on active mode + { + AoColumn lc{264}; + lc.add_inputbox(ao_flickstick_sweep_cbox, ao_flickstick_sweep_label, ao_flickstick_sweep_butlabel, flick_stick); + lc.add_inputbox(ao_joy_sensitivity_cbox, ao_joy_sensitivity_label, ao_joy_sensitivity_butlabel); + lc.add_inputbox(ao_flickstick_deadzone_cbox, ao_flickstick_deadzone_label, ao_flickstick_deadzone_butlabel, flick_stick); + lc.add_inputbox(ao_flickstick_release_dz_cbox, ao_flickstick_release_dz_label, ao_flickstick_release_dz_butlabel, flick_stick); + lc.add_inputbox(ao_flickstick_smoothing_cbox, ao_flickstick_smoothing_label, ao_flickstick_smoothing_butlabel, flick_stick); + lc.add_inputbox(ao_look_deadzone_cbox, ao_look_deadzone_label, ao_look_deadzone_butlabel, !flick_stick); + lc.add_inputbox(ao_joy_scannersens_cbox, ao_joy_scannersens_label, ao_joy_scannersens_butlabel); + lc.add_inputbox(ao_joy_scopesens_cbox, ao_joy_scopesens_label, ao_joy_scopesens_butlabel); + lc.add_checkbox(ao_joy_invert_y_cbox, ao_joy_invert_y_label, !flick_stick); + lc.add_checkbox(ao_swap_sticks_cbox, ao_swap_sticks_label); + } + + ao_gamepad_icon_override_cbox.enabled = true; + ao_gamepad_icon_override_label.enabled = true; + ao_gamepad_icon_override_butlabel.enabled = true; + + ao_input_prompt_mode_cbox.enabled = true; + ao_input_prompt_mode_label.enabled = true; + ao_input_prompt_mode_butlabel.enabled = true; + + // clamp scroll after all dynamic layout passes have updated item positions + const int max_scroll = ao_compute_max_scroll(active_subpanel); + current_scroll = std::clamp(current_scroll, 0, max_scroll); + for (auto* ui_label : alpine_options_panel_labels) { - if (ui_label) { + if (!ui_label || !ui_label->enabled) continue; + if (active_subpanel && ui_label->parent == active_subpanel) { + // clip against row base (label y is item_y+6) so labels never orphan at viewport edges + if ((ui_label->y - 6 - current_scroll) < AO_CONTENT_TOP || + (ui_label->y - 6 - current_scroll) >= AO_CONTENT_BOTTOM) + continue; + int scrolled_y = ui_label->y - current_scroll; + ui_label->y = scrolled_y; + ui_label->render(); + ui_label->y = scrolled_y + current_scroll; + } else { ui_label->render(); } } + + g_sb_visible = false; + if (alpine_options_panel_scrollable[alpine_options_panel_current_tab] && max_scroll > 0) { + constexpr int sb_width = 6; + constexpr int sb_margin = 39; + constexpr int AO_PANEL_W = 512; + constexpr int sb_y_offset = 10; + constexpr float viewport_h_l = static_cast(AO_CONTENT_BOTTOM - AO_CONTENT_TOP); + const float total_h = static_cast(max_scroll) + viewport_h_l; + + const int sb_x = static_cast((x + AO_PANEL_W - sb_margin - sb_width) * rf::ui::scale_x); + const int sb_y = static_cast((alpine_options_panel.y + AO_CONTENT_TOP + sb_y_offset) * rf::ui::scale_y); + const int sb_pw = static_cast(sb_width * rf::ui::scale_x); + const int sb_ph = static_cast(viewport_h_l * rf::ui::scale_y); + + rf::gr::set_color(40, 40, 40, 180); + rf::gr::rect(sb_x, sb_y, sb_pw, sb_ph); + + const float thumb_h_f = viewport_h_l * (viewport_h_l / total_h); + const float scroll_ratio = static_cast(current_scroll) / static_cast(max_scroll); + const int thumb_y = sb_y + static_cast(scroll_ratio * (sb_ph - thumb_h_f * rf::ui::scale_y)); + const int thumb_h = std::max(static_cast(4 * rf::ui::scale_y), + static_cast(thumb_h_f * rf::ui::scale_y)); + + rf::gr::set_color(0, 200, 210, 255); + rf::gr::rect(sb_x, thumb_y, sb_pw, thumb_h); + + g_sb_x = sb_x; g_sb_y = sb_y; + g_sb_pw = sb_pw; g_sb_ph = sb_ph; + g_sb_thumb_y = thumb_y; g_sb_thumb_h = thumb_h; + g_sb_visible = true; + } } static void options_alpine_on_click() { constexpr int alpine_options_panel_id = 4; if (rf::ui::options_current_panel == alpine_options_panel_id) { + if (g_alpine_options_hud_dirty) { + hud_refresh_action_tokens(); + g_alpine_options_hud_dirty = false; + } rf::ui::options_close_current_panel(); return; } @@ -1530,13 +2548,175 @@ static void handle_ctrl_camscale_btns(int x, int y) ctrl_camscale_on_click(x, y); } +// Controller bindings tab strip (drawn on top of options panel 3 = Controls) +// Write the current gamepad binding for every action into scan_codes[0] using the +// CTRL_GAMEPAD_SCAN_BASE encoding, saving the original keyboard scan codes first. +static void restore_keyboard_fields() +{ + if (!rf::local_player || !g_ctrl_codes_installed) return; + auto& cc = rf::local_player->settings.controls; + int n = std::min(cc.num_bindings, static_cast(std::size(g_saved_scan_codes))); + for (int i = 0; i < n; ++i) { + cc.bindings[i].scan_codes[0] = g_saved_scan_codes[i]; + cc.bindings[i].scan_codes[1] = g_saved_sc1[i]; + cc.bindings[i].mouse_btn_id = g_saved_mouse_btn_ids[i]; + } +} + +static void install_ctrl_gamepad_codes() +{ + if (!rf::local_player || g_ctrl_codes_installed) return; + auto& cc = rf::local_player->settings.controls; + int n = std::min(cc.num_bindings, static_cast(std::size(g_saved_scan_codes))); + for (int i = 0; i < n; ++i) { + g_saved_scan_codes[i] = cc.bindings[i].scan_codes[0]; + g_saved_sc1[i] = cc.bindings[i].scan_codes[1]; + g_saved_mouse_btn_ids[i] = cc.bindings[i].mouse_btn_id; + bool menu_only = gamepad_is_menu_only_action(i); + int btn = -1, btn_alt = -1; + gamepad_get_buttons_for_action(i, &btn, &btn_alt); + int trig = gamepad_get_trigger_for_action(i); + int16_t code = 0; // unbound + if (btn >= 0) + code = menu_only ? static_cast(CTRL_GAMEPAD_MENU_BASE + btn) + : static_cast(CTRL_GAMEPAD_SCAN_BASE + btn); + else if (trig == 0) code = static_cast(CTRL_GAMEPAD_LEFT_TRIGGER); + else if (trig == 1) code = static_cast(CTRL_GAMEPAD_RIGHT_TRIGGER); + cc.bindings[i].scan_codes[0] = code; + // Menu-only actions never have secondary bindings. + cc.bindings[i].scan_codes[1] = (!menu_only && btn_alt >= 0) + ? static_cast(CTRL_GAMEPAD_SCAN_BASE + btn_alt) : int16_t{0}; + cc.bindings[i].mouse_btn_id = -1; // no mouse binding in gamepad view + } + g_ctrl_codes_installed = true; +} + +// Rewrite scan_codes[0] and scan_codes[1] from the current g_button_map/g_trigger_action state. +// Called after a bind completes so the list immediately reflects the new assignment. +static void refresh_ctrl_gamepad_codes() +{ + if (!rf::local_player || !g_ctrl_codes_installed) return; + auto& cc = rf::local_player->settings.controls; + int n = std::min(cc.num_bindings, static_cast(std::size(g_saved_scan_codes))); + for (int i = 0; i < n; ++i) { + bool menu_only = gamepad_is_menu_only_action(i); + int btn = -1, btn_alt = -1; + gamepad_get_buttons_for_action(i, &btn, &btn_alt); + int trig = gamepad_get_trigger_for_action(i); + int16_t code = 0; + if (btn >= 0) + code = menu_only ? static_cast(CTRL_GAMEPAD_MENU_BASE + btn) + : static_cast(CTRL_GAMEPAD_SCAN_BASE + btn); + else if (trig == 0) code = static_cast(CTRL_GAMEPAD_LEFT_TRIGGER); + else if (trig == 1) code = static_cast(CTRL_GAMEPAD_RIGHT_TRIGGER); + cc.bindings[i].scan_codes[0] = code; + cc.bindings[i].scan_codes[1] = (!menu_only && btn_alt >= 0) + ? static_cast(CTRL_GAMEPAD_SCAN_BASE + btn_alt) : int16_t{0}; + } +} + +// Harvest whatever RF wrote into scan_codes[0/1] back into the button maps, +// then restore the original keyboard/mouse binding fields. +static void uninstall_ctrl_gamepad_codes() +{ + if (!rf::local_player || !g_ctrl_codes_installed) return; + gamepad_sync_bindings_from_scan_codes(); + restore_keyboard_fields(); + g_ctrl_codes_installed = false; +} + +bool ui_ctrl_bindings_view_active() +{ + return g_ctrl_bind_view; +} + +void ui_ctrl_bindings_view_reset() +{ + uninstall_ctrl_gamepad_codes(); + g_ctrl_bind_view = false; +} + +// X/Y position for the CONTROLLER mode checkbox in UI 640x480 space. +// Sits to the right of the stock "Change Binding" button on the same row. +static constexpr int CTRL_CHK_X = 265; +static constexpr int CTRL_CHK_Y = 350; + +static void ctrl_mode_cbox_on_click(int, int) +{ + if (g_ctrl_bind_view) { + uninstall_ctrl_gamepad_codes(); + g_ctrl_bind_view = false; + } else { + g_ctrl_bind_view = true; + install_ctrl_gamepad_codes(); + } + g_ctrl_mode_cbox.checked = g_ctrl_bind_view; + rf::snd_play(43, 0, 0.0f, 1.0f); +} + +// Create the checkbox once (called lazily on first Controls panel render). +static void init_ctrl_mode_btns() +{ + if (g_ctrl_mode_btns_initialized) return; + g_ctrl_mode_cbox.create("checkbox.tga", "checkbox_selected.tga", "checkbox_checked.tga", + CTRL_CHK_X, CTRL_CHK_Y, 0, "", rf::ui::medium_font_0); + g_ctrl_mode_cbox.enabled = true; + g_ctrl_mode_cbox.on_click = ctrl_mode_cbox_on_click; + g_ctrl_mode_btns_initialized = true; +} + +// Render the checkbox and a label indicating what it controls. +static void render_ctrl_mode_btns() +{ + init_ctrl_mode_btns(); + g_ctrl_mode_cbox.checked = g_ctrl_bind_view; + g_ctrl_mode_cbox.x = CTRL_CHK_X + static_cast(rf::ui::options_animated_offset); + g_ctrl_mode_cbox.render(); + int lx = static_cast((g_ctrl_mode_cbox.x + g_ctrl_mode_cbox.w + 5) * rf::ui::scale_x); + int cbox_screen_h = static_cast(g_ctrl_mode_cbox.h * rf::ui::scale_y); + int font_h = rf::gr::get_font_height(rf::ui::medium_font_0); + int ly = static_cast(CTRL_CHK_Y * rf::ui::scale_y) + (cbox_screen_h - font_h) / 2; + rf::gr::set_color(0, 0, 0, 255); + rf::gr::string(lx, ly, g_ctrl_bind_view ? "Switch to Keyboard" : "Switch to Gamepad", rf::ui::medium_font_0); +} + +// Handle a click on the checkbox. +static void handle_ctrl_mode_btns(int x, int y) +{ + if (!g_ctrl_mode_btns_initialized) + return; + + // Use absolute position so hit-testing tracks the panel animation offset. + int bx = static_cast(g_ctrl_mode_cbox.get_absolute_x() * rf::ui::scale_x); + int by = static_cast(g_ctrl_mode_cbox.get_absolute_y() * rf::ui::scale_y); + int bw = static_cast(g_ctrl_mode_cbox.w * rf::ui::scale_x); + int bh = static_cast(g_ctrl_mode_cbox.h * rf::ui::scale_y); + + bool inside = (x >= bx && x < bx + bw && y >= by && y < by + bh); + + // Keep hover state in sync with cursor position. + g_ctrl_mode_cbox.highlighted = inside; + + // Do not react to clicks while the controls panel is waiting for a key/mouse binding. + if (!inside || rf::ui::options_controls_waiting_for_key || !rf::mouse_was_button_pressed(0)) + return; + + ctrl_mode_cbox_on_click(x, y); +} + // handle alpine options panel rendering CodeInjection options_render_alpine_panel_patch{ 0x0044F80B, []() { int index = rf::ui::options_current_panel; - //xlog::warn("render index {}", index); + // Restore keyboard bindings if user has navigated away from the Controls panel + if (index != 3 && g_ctrl_bind_view) { + uninstall_ctrl_gamepad_codes(); + g_ctrl_bind_view = false; + } + + // how mouse scale toggle when Controls panel is active if (index == 3 && !rf::ui::options_controls_waiting_for_key) { render_ctrl_camscale_btns(); } @@ -1545,6 +2725,46 @@ CodeInjection options_render_alpine_panel_patch{ if (index == 4) { alpine_options_panel_do_frame(static_cast(rf::ui::options_animated_offset)); } + + // Detect bind completion (falling edge of waiting_for_key). + static bool s_was_waiting = false; + bool now_waiting = (index == 3) && rf::ui::options_controls_waiting_for_key; + + if (s_was_waiting && !now_waiting && g_ctrl_bind_view) { + if (gamepad_has_pending_rebind()) { + gamepad_apply_rebind(); + gamepad_sync_bindings_from_scan_codes(); + } + restore_keyboard_fields(); + refresh_ctrl_gamepad_codes(); + hud_mark_bindings_dirty(); + } + s_was_waiting = now_waiting; + + if (index == 3 && g_ctrl_codes_installed && rf::local_player) { + auto& cc = rf::local_player->settings.controls; + int n = std::min(cc.num_bindings, static_cast(std::size(g_saved_scan_codes))); + bool defaults_hit = false; + for (int i = 0; i < n && !defaults_hit; ++i) { + int16_t sc = cc.bindings[i].scan_codes[0]; + bool is_gamepad = (sc >= static_cast(CTRL_GAMEPAD_SCAN_BASE) + && sc <= static_cast(CTRL_GAMEPAD_RIGHT_TRIGGER)) + || (sc >= static_cast(CTRL_GAMEPAD_MENU_BASE) + && sc < static_cast(CTRL_GAMEPAD_MENU_BASE + gamepad_get_button_count())); + if (sc != 0 && sc != static_cast(CTRL_REBIND_SENTINEL) && !is_gamepad) + defaults_hit = true; + } + if (defaults_hit) { + for (int i = 0; i < n; ++i) + g_saved_scan_codes[i] = cc.bindings[i].scan_codes[0]; + gamepad_reset_to_defaults(); + refresh_ctrl_gamepad_codes(); + hud_mark_bindings_dirty(); + } + } + + if (index == 3) + render_ctrl_mode_btns(); }, }; @@ -1574,6 +2794,7 @@ CodeInjection options_handle_mouse_patch{ } if (index == 3) { handle_ctrl_camscale_btns(x, y); + handle_ctrl_mode_btns(x, y); } }, }; diff --git a/game_patch/multi/server.cpp b/game_patch/multi/server.cpp index 6f93f5d42..52ca1601e 100644 --- a/game_patch/multi/server.cpp +++ b/game_patch/multi/server.cpp @@ -28,6 +28,7 @@ #include "../misc/alpine_options.h" #include "../main/main.h" #include "../misc/achievements.h" +#include "../input/rumble.h" #include "../misc/alpine_settings.h" #include "../rf/file/file.h" #include "../rf/math/vector.h" @@ -1318,6 +1319,11 @@ FunHook entity_damage_hook{ achievement_player_killed_entity(damaged_ep, damage_type, damaged_ep->killer_handle); } + // Rumble feedback for local player being hit by melee or explosions + if (damaged_ep == rf::local_player_entity && real_damage > 0.0f) { + rumble_on_player_hit(damage, damage_type); + } + return real_damage; }, }; diff --git a/game_patch/object/event.cpp b/game_patch/object/event.cpp index 01b02646a..47a48547f 100644 --- a/game_patch/object/event.cpp +++ b/game_patch/object/event.cpp @@ -20,6 +20,8 @@ #include "../rf/player/player.h" #include "../rf/os/console.h" #include "../os/console.h" +#include "../input/input.h" +#include "../input/gamepad.h" #include "../rf/v3d.h" #include "../rf/vmesh.h" #include @@ -496,29 +498,11 @@ FunHook hud_translate_special_character_token_hook{ const auto get_binding_or_unbound = [](const char* action_name) -> std::string { if (!rf::local_player) return "UNBOUND"; - int action_index = rf::control_config_find_action_by_name(&rf::local_player->settings, action_name); if (action_index < 0) return "UNBOUND"; - - const auto& binding = rf::local_player->settings.controls.bindings[action_index]; - std::string result; - - if (binding.scan_codes[0] >= 0) { - rf::String key_name; - rf::control_config_get_key_name(&key_name, binding.scan_codes[0]); - result = std::string(key_name.c_str()); - } - - if (binding.mouse_btn_id >= 0) { - rf::String mouse_name; - rf::control_config_get_mouse_button_name(&mouse_name, binding.mouse_btn_id); - if (!result.empty()) - result += ", "; - result += std::string(mouse_name.c_str()); - } - - return result.empty() ? "UNBOUND" : result; + rf::String name = get_action_bind_name(action_index); + return name.c_str()[0] ? std::string{name.c_str()} : "UNBOUND"; }; // Match known HUD tokens @@ -572,6 +556,55 @@ FunHook hud_translate_special_character_token_hook{ }, }; +// Cache the raw $TOKEN$ template so we can re-display it with fresh bindings when the +// player switches between gamepad and keyboard/mouse while the message is still on screen. +static std::string g_hud_msg_template; +static bool g_hud_msg_was_gamepad = false; +static rf::Timestamp g_hud_msg_expire; +static bool g_hud_bindings_dirty = false; + +void hud_mark_bindings_dirty() +{ + g_hud_bindings_dirty = true; +} + +void hud_refresh_action_tokens() +{ + if (!g_hud_msg_template.empty() && g_hud_msg_expire.valid() && !g_hud_msg_expire.elapsed()) { + g_hud_msg_was_gamepad = gamepad_is_last_input_gamepad(); + rf::hud_msg(g_hud_msg_template.c_str(), 0, std::max(1, g_hud_msg_expire.time_until()), nullptr); + } +} + +FunHook hud_msg_hook{ + 0x004383C0, + [](const char* text, int arg2, int duration, rf::Color* color) { + if (text && std::strchr(text, '$')) { + g_hud_msg_template = text; + g_hud_msg_was_gamepad = gamepad_is_last_input_gamepad(); + g_hud_msg_expire.set(duration > 0 ? duration : 8000); + } else if (text) { + g_hud_msg_template.clear(); + g_hud_msg_expire.invalidate(); + } + hud_msg_hook.call_target(text, arg2, duration, color); + }, +}; + +FunHook hud_do_frame_input_sync_hook{ + 0x00437B80, + [](rf::Player* player) { + hud_do_frame_input_sync_hook.call_target(player); + bool is_gamepad = gamepad_is_last_input_gamepad(); + if (!g_hud_msg_template.empty() && g_hud_msg_expire.valid() && !g_hud_msg_expire.elapsed() + && (is_gamepad != g_hud_msg_was_gamepad || g_hud_bindings_dirty)) { + g_hud_msg_was_gamepad = is_gamepad; + g_hud_bindings_dirty = false; + rf::hud_msg(g_hud_msg_template.c_str(), 0, std::max(1, g_hud_msg_expire.time_until()), nullptr); + } + }, +}; + void apply_event_patches() { // allow custom directional events @@ -579,6 +612,8 @@ void apply_event_patches() // HUD Message magic word handling hud_translate_special_character_token_hook.install(); + hud_msg_hook.install(); + hud_do_frame_input_sync_hook.install(); // fix some events not working if delay value is specified (alpine levels only) EventUnhide__process_patch.install(); diff --git a/game_patch/object/weapon.cpp b/game_patch/object/weapon.cpp index 184fc097a..0dc35623c 100644 --- a/game_patch/object/weapon.cpp +++ b/game_patch/object/weapon.cpp @@ -16,6 +16,7 @@ #include "../multi/multi.h" #include "../misc/misc.h" #include "../misc/alpine_settings.h" +#include "../input/rumble.h" static std::array weapon_reticle_custom_mask{}; // bit 0 = _0, bit 1 = _1 static std::pair rocket_locked_custom_reticle = {false, false}; @@ -305,6 +306,8 @@ CodeInjection entity_fire_primary_weapon_semi_auto_patch { } } } + + rumble_on_turret_fire(entity); }, }; diff --git a/game_patch/os/os.cpp b/game_patch/os/os.cpp index dcdfdcde9..26a7359f3 100644 --- a/game_patch/os/os.cpp +++ b/game_patch/os/os.cpp @@ -12,7 +12,8 @@ #include "../multi/multi.h" #include "os.h" #include "win32_console.h" - +#include "../input/input.h" +#include #include const char* get_win_msg_name(UINT msg); @@ -34,6 +35,8 @@ FunHook os_poll_hook{ if (win32_console_is_enabled()) { win32_console_poll_input(); } + + sdl_input_poll(); }, }; @@ -345,4 +348,4 @@ void os_apply_patch() timer_apply_patch(); win32_console_pre_init(); -} +} \ No newline at end of file diff --git a/game_patch/os/os.h b/game_patch/os/os.h index 372148826..47457e4a0 100644 --- a/game_patch/os/os.h +++ b/game_patch/os/os.h @@ -174,4 +174,4 @@ class HighResTimer { void restart() { _start_time.emplace(clock::now()); } -}; +}; \ No newline at end of file diff --git a/game_patch/rf/player/control_config.h b/game_patch/rf/player/control_config.h index b0bb3254e..d05f43a2e 100644 --- a/game_patch/rf/player/control_config.h +++ b/game_patch/rf/player/control_config.h @@ -55,7 +55,9 @@ namespace rf AF_ACTION_INSPECT_WEAPON = 0xE, AF_ACTION_SPECTATE_TOGGLE_FREELOOK = 0xF, AF_ACTION_SPECTATE_TOGGLE = 0x10, - _AF_ACTION_LAST_VARIANT = AF_ACTION_SPECTATE_TOGGLE + AF_ACTION_CENTER_VIEW = 0x11, + AF_ACTION_GYRO_MODIFIER = 0x12, + _AF_ACTION_LAST_VARIANT = AF_ACTION_GYRO_MODIFIER }; struct ControlConfigItem diff --git a/resources/licensing-info.txt b/resources/licensing-info.txt index ca164937d..1b53cfa77 100644 --- a/resources/licensing-info.txt +++ b/resources/licensing-info.txt @@ -514,3 +514,56 @@ freely, subject to the following restrictions: 3. This notice may not be removed or altered from any source distribution. René Nyffenegger rene.nyffenegger@adp-gmbh.ch + +############################################################################### +## SDL +############################################################################### + +Source code: https://github.com/libsdl-org/SDL + +Copyright (C) 1997-2026 Sam Lantinga + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + + +############################################################################### +## GamepadMotionHelpers +############################################################################### + +Source code: https://github.com/JibbSmart/GamepadMotionHelpers + +MIT License + +Copyright (c) 2020-2023 Julian "Jibb" Smart + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/CMakeLists.txt b/vendor/CMakeLists.txt index 2814f31dc..74173058a 100644 --- a/vendor/CMakeLists.txt +++ b/vendor/CMakeLists.txt @@ -12,3 +12,5 @@ add_subdirectory(sha1) add_subdirectory(ed25519) add_subdirectory(base64) add_subdirectory(stb) +add_subdirectory(sdl) +add_subdirectory(gamepadmotionhelpers) \ No newline at end of file diff --git a/vendor/gamepadmotionhelpers/CMakeLists.txt b/vendor/gamepadmotionhelpers/CMakeLists.txt new file mode 100644 index 000000000..27f7748b3 --- /dev/null +++ b/vendor/gamepadmotionhelpers/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.8) + +project(GamepadMotionHelpers LANGUAGES CXX) + +add_library(${PROJECT_NAME} INTERFACE) +add_library(${PROJECT_NAME}::${PROJECT_NAME} ALIAS ${PROJECT_NAME}) +target_include_directories(${PROJECT_NAME} + INTERFACE + $ + $) + \ No newline at end of file diff --git a/vendor/gamepadmotionhelpers/GamepadMotion.hpp b/vendor/gamepadmotionhelpers/GamepadMotion.hpp new file mode 100644 index 000000000..a0a4cca2f --- /dev/null +++ b/vendor/gamepadmotionhelpers/GamepadMotion.hpp @@ -0,0 +1,1313 @@ +// Copyright (c) 2020-2023 Julian "Jibb" Smart +// Released under the MIT license. See https://github.com/JibbSmart/GamepadMotionHelpers/blob/main/LICENSE for more info +// Version 9 + +#pragma once + +#define _USE_MATH_DEFINES +#include +#include +#include // std::min, std::max and std::clamp + +// You don't need to look at these. These will just be used internally by the GamepadMotion class declared below. +// You can ignore anything in namespace GamepadMotionHelpers. +class GamepadMotionSettings; +class GamepadMotion; + +namespace GamepadMotionHelpers +{ + struct GyroCalibration + { + float X; + float Y; + float Z; + float AccelMagnitude; + int NumSamples; + }; + + struct Quat + { + float w; + float x; + float y; + float z; + + Quat(); + Quat(float inW, float inX, float inY, float inZ); + void Set(float inW, float inX, float inY, float inZ); + Quat& operator*=(const Quat& rhs); + friend Quat operator*(Quat lhs, const Quat& rhs); + void Normalize(); + Quat Normalized() const; + void Invert(); + Quat Inverse() const; + }; + + struct Vec + { + float x; + float y; + float z; + + Vec(); + Vec(float inValue); + Vec(float inX, float inY, float inZ); + void Set(float inX, float inY, float inZ); + float Length() const; + float LengthSquared() const; + void Normalize(); + Vec Normalized() const; + float Dot(const Vec& other) const; + Vec Cross(const Vec& other) const; + Vec Min(const Vec& other) const; + Vec Max(const Vec& other) const; + Vec Abs() const; + Vec Lerp(const Vec& other, float factor) const; + Vec Lerp(const Vec& other, const Vec& factor) const; + Vec& operator+=(const Vec& rhs); + friend Vec operator+(Vec lhs, const Vec& rhs); + Vec& operator-=(const Vec& rhs); + friend Vec operator-(Vec lhs, const Vec& rhs); + Vec& operator*=(const float rhs); + friend Vec operator*(Vec lhs, const float rhs); + Vec& operator/=(const float rhs); + friend Vec operator/(Vec lhs, const float rhs); + Vec& operator*=(const Quat& rhs); + friend Vec operator*(Vec lhs, const Quat& rhs); + Vec operator-() const; + }; + + struct SensorMinMaxWindow + { + Vec MinGyro; + Vec MaxGyro; + Vec MeanGyro; + Vec MinAccel; + Vec MaxAccel; + Vec MeanAccel; + Vec StartAccel; + int NumSamples = 0; + float TimeSampled = 0.f; + + SensorMinMaxWindow(); + void Reset(float remainder); + void AddSample(const Vec& inGyro, const Vec& inAccel, float deltaTime); + Vec GetMidGyro(); + }; + + struct AutoCalibration + { + SensorMinMaxWindow MinMaxWindow; + Vec SmoothedAngularVelocityGyro; + Vec SmoothedAngularVelocityAccel; + Vec SmoothedPreviousAccel; + Vec PreviousAccel; + + AutoCalibration(); + void Reset(); + bool AddSampleStillness(const Vec& inGyro, const Vec& inAccel, float deltaTime, bool doSensorFusion); + void NoSampleStillness(); + bool AddSampleSensorFusion(const Vec& inGyro, const Vec& inAccel, float deltaTime); + void NoSampleSensorFusion(); + void SetCalibrationData(GyroCalibration* calibrationData); + void SetSettings(GamepadMotionSettings* settings); + + float Confidence = 0.f; + bool IsSteady() { return bIsSteady; } + + private: + Vec MinDeltaGyro = Vec(1.f); + Vec MinDeltaAccel = Vec(0.25f); + float RecalibrateThreshold = 1.f; + float SensorFusionSkippedTime = 0.f; + float TimeSteadySensorFusion = 0.f; + float TimeSteadyStillness = 0.f; + bool bIsSteady = false; + + GyroCalibration* CalibrationData; + GamepadMotionSettings* Settings; + }; + + struct Motion + { + Quat Quaternion; + Vec Accel; + Vec Grav; + + Vec SmoothAccel = Vec(); + float Shakiness = 0.f; + const float ShortSteadinessHalfTime = 0.25f; + const float LongSteadinessHalfTime = 1.f; + + Motion(); + void Reset(); + void Update(float inGyroX, float inGyroY, float inGyroZ, float inAccelX, float inAccelY, float inAccelZ, float gravityLength, float deltaTime); + void SetSettings(GamepadMotionSettings* settings); + + private: + GamepadMotionSettings* Settings; + }; + + enum CalibrationMode + { + Manual = 0, + Stillness = 1, + SensorFusion = 2, + }; + + // https://stackoverflow.com/a/1448478/1130520 + inline CalibrationMode operator|(CalibrationMode a, CalibrationMode b) + { + return static_cast(static_cast(a) | static_cast(b)); + } + + inline CalibrationMode operator&(CalibrationMode a, CalibrationMode b) + { + return static_cast(static_cast(a) & static_cast(b)); + } + + inline CalibrationMode operator~(CalibrationMode a) + { + return static_cast(~static_cast(a)); + } + + // https://stackoverflow.com/a/23152590/1130520 + inline CalibrationMode& operator|=(CalibrationMode& a, CalibrationMode b) + { + return (CalibrationMode&)((int&)(a) |= static_cast(b)); + } + + inline CalibrationMode& operator&=(CalibrationMode& a, CalibrationMode b) + { + return (CalibrationMode&)((int&)(a) &= static_cast(b)); + } +} + +// Note that I'm using a Y-up coordinate system. This is to follow the convention set by the motion sensors in +// PlayStation controllers, which was what I was using when writing in this. But for the record, Z-up is +// better for most games (XY ground-plane in 3D games simplifies using 2D vectors in navigation, for example). + +// Gyro units should be degrees per second. Accelerometer should be g-force (approx. 9.8 m/s^2 = 1 g). If you're using +// radians per second, meters per second squared, etc, conversion should be simple. + +class GamepadMotionSettings +{ +public: + int MinStillnessSamples = 10; + float MinStillnessCollectionTime = 0.5f; + float MinStillnessCorrectionTime = 2.f; + float MaxStillnessError = 2.f; + float StillnessSampleDeteriorationRate = 0.2f; + float StillnessErrorClimbRate = 0.1f; + float StillnessErrorDropOnRecalibrate = 0.1f; + float StillnessCalibrationEaseInTime = 3.f; + float StillnessCalibrationHalfTime = 0.1f; + float StillnessConfidenceRate = 1.f; + + float StillnessGyroDelta = -1.f; + float StillnessAccelDelta = -1.f; + + float SensorFusionCalibrationSmoothingStrength = 2.f; + float SensorFusionAngularAccelerationThreshold = 20.f; + float SensorFusionCalibrationEaseInTime = 3.f; + float SensorFusionCalibrationHalfTime = 0.1f; + float SensorFusionConfidenceRate = 1.f; + + float GravityCorrectionShakinessMaxThreshold = 0.4f; + float GravityCorrectionShakinessMinThreshold = 0.01f; + + float GravityCorrectionStillSpeed = 1.f; + float GravityCorrectionShakySpeed = 0.1f; + + float GravityCorrectionGyroFactor = 0.1f; + float GravityCorrectionGyroMinThreshold = 0.05f; + float GravityCorrectionGyroMaxThreshold = 0.25f; + + float GravityCorrectionMinimumSpeed = 0.01f; +}; + +class GamepadMotion +{ +public: + GamepadMotion(); + + void Reset(); + + void ProcessMotion(float gyroX, float gyroY, float gyroZ, + float accelX, float accelY, float accelZ, float deltaTime); + + // reading the current state + void GetCalibratedGyro(float& x, float& y, float& z); + void GetGravity(float& x, float& y, float& z); + void GetProcessedAcceleration(float& x, float& y, float& z); + void GetOrientation(float& w, float& x, float& y, float& z); + void GetPlayerSpaceGyro(float& x, float& y, const float yawRelaxFactor = 1.41f); + static void CalculatePlayerSpaceGyro(float& x, float& y, const float gyroX, const float gyroY, const float gyroZ, const float gravX, const float gravY, const float gravZ, const float yawRelaxFactor = 1.41f); + void GetWorldSpaceGyro(float& x, float& y, const float sideReductionThreshold = 0.125f); + static void CalculateWorldSpaceGyro(float& x, float& y, const float gyroX, const float gyroY, const float gyroZ, const float gravX, const float gravY, const float gravZ, const float sideReductionThreshold = 0.125f); + + // gyro calibration functions + void StartContinuousCalibration(); + void PauseContinuousCalibration(); + void ResetContinuousCalibration(); + void GetCalibrationOffset(float& xOffset, float& yOffset, float& zOffset); + void SetCalibrationOffset(float xOffset, float yOffset, float zOffset, int weight); + float GetAutoCalibrationConfidence(); + void SetAutoCalibrationConfidence(float newConfidence); + bool GetAutoCalibrationIsSteady(); + + GamepadMotionHelpers::CalibrationMode GetCalibrationMode(); + void SetCalibrationMode(GamepadMotionHelpers::CalibrationMode calibrationMode); + + void ResetMotion(); + + GamepadMotionSettings Settings; + +private: + GamepadMotionHelpers::Vec Gyro; + GamepadMotionHelpers::Vec RawAccel; + GamepadMotionHelpers::Motion Motion; + GamepadMotionHelpers::GyroCalibration GyroCalibration; + GamepadMotionHelpers::AutoCalibration AutoCalibration; + GamepadMotionHelpers::CalibrationMode CurrentCalibrationMode; + + bool IsCalibrating; + void PushSensorSamples(float gyroX, float gyroY, float gyroZ, float accelMagnitude); + void GetCalibratedSensor(float& gyroOffsetX, float& gyroOffsetY, float& gyroOffsetZ, float& accelMagnitude); +}; + +///////////// Everything below here are just implementation details ///////////// + +namespace GamepadMotionHelpers +{ + inline Quat::Quat() + { + w = 1.0f; + x = 0.0f; + y = 0.0f; + z = 0.0f; + } + + inline Quat::Quat(float inW, float inX, float inY, float inZ) + { + w = inW; + x = inX; + y = inY; + z = inZ; + } + + inline static Quat AngleAxis(float inAngle, float inX, float inY, float inZ) + { + const float sinHalfAngle = sinf(inAngle * 0.5f); + Vec inAxis = Vec(inX, inY, inZ); + inAxis.Normalize(); + inAxis *= sinHalfAngle; + Quat result = Quat(cosf(inAngle * 0.5f), inAxis.x, inAxis.y, inAxis.z); + return result; + } + + inline void Quat::Set(float inW, float inX, float inY, float inZ) + { + w = inW; + x = inX; + y = inY; + z = inZ; + } + + inline Quat& Quat::operator*=(const Quat& rhs) + { + Set(w * rhs.w - x * rhs.x - y * rhs.y - z * rhs.z, + w * rhs.x + x * rhs.w + y * rhs.z - z * rhs.y, + w * rhs.y - x * rhs.z + y * rhs.w + z * rhs.x, + w * rhs.z + x * rhs.y - y * rhs.x + z * rhs.w); + return *this; + } + + inline Quat operator*(Quat lhs, const Quat& rhs) + { + lhs *= rhs; + return lhs; + } + + inline void Quat::Normalize() + { + const float length = sqrtf(w * w + x * x + y * y + z * z); + const float fixFactor = 1.0f / length; + + w *= fixFactor; + x *= fixFactor; + y *= fixFactor; + z *= fixFactor; + + return; + } + + inline Quat Quat::Normalized() const + { + Quat result = *this; + result.Normalize(); + return result; + } + + inline void Quat::Invert() + { + x = -x; + y = -y; + z = -z; + return; + } + + inline Quat Quat::Inverse() const + { + Quat result = *this; + result.Invert(); + return result; + } + + inline Vec::Vec() + { + x = 0.0f; + y = 0.0f; + z = 0.0f; + } + + inline Vec::Vec(float inValue) + { + x = inValue; + y = inValue; + z = inValue; + } + + inline Vec::Vec(float inX, float inY, float inZ) + { + x = inX; + y = inY; + z = inZ; + } + + inline void Vec::Set(float inX, float inY, float inZ) + { + x = inX; + y = inY; + z = inZ; + } + + inline float Vec::Length() const + { + return sqrtf(x * x + y * y + z * z); + } + + inline float Vec::LengthSquared() const + { + return x * x + y * y + z * z; + } + + inline void Vec::Normalize() + { + const float length = Length(); + if (length == 0.0) + { + return; + } + const float fixFactor = 1.0f / length; + + x *= fixFactor; + y *= fixFactor; + z *= fixFactor; + return; + } + + inline Vec Vec::Normalized() const + { + Vec result = *this; + result.Normalize(); + return result; + } + + inline Vec& Vec::operator+=(const Vec& rhs) + { + Set(x + rhs.x, y + rhs.y, z + rhs.z); + return *this; + } + + inline Vec operator+(Vec lhs, const Vec& rhs) + { + lhs += rhs; + return lhs; + } + + inline Vec& Vec::operator-=(const Vec& rhs) + { + Set(x - rhs.x, y - rhs.y, z - rhs.z); + return *this; + } + + inline Vec operator-(Vec lhs, const Vec& rhs) + { + lhs -= rhs; + return lhs; + } + + inline Vec& Vec::operator*=(const float rhs) + { + Set(x * rhs, y * rhs, z * rhs); + return *this; + } + + inline Vec operator*(Vec lhs, const float rhs) + { + lhs *= rhs; + return lhs; + } + + inline Vec& Vec::operator/=(const float rhs) + { + Set(x / rhs, y / rhs, z / rhs); + return *this; + } + + inline Vec operator/(Vec lhs, const float rhs) + { + lhs /= rhs; + return lhs; + } + + inline Vec& Vec::operator*=(const Quat& rhs) + { + Quat temp = rhs * Quat(0.0f, x, y, z) * rhs.Inverse(); + Set(temp.x, temp.y, temp.z); + return *this; + } + + inline Vec operator*(Vec lhs, const Quat& rhs) + { + lhs *= rhs; + return lhs; + } + + inline Vec Vec::operator-() const + { + Vec result = Vec(-x, -y, -z); + return result; + } + + inline float Vec::Dot(const Vec& other) const + { + return x * other.x + y * other.y + z * other.z; + } + + inline Vec Vec::Cross(const Vec& other) const + { + return Vec(y * other.z - z * other.y, + z * other.x - x * other.z, + x * other.y - y * other.x); + } + + inline Vec Vec::Min(const Vec& other) const + { + return Vec(x < other.x ? x : other.x, + y < other.y ? y : other.y, + z < other.z ? z : other.z); + } + + inline Vec Vec::Max(const Vec& other) const + { + return Vec(x > other.x ? x : other.x, + y > other.y ? y : other.y, + z > other.z ? z : other.z); + } + + inline Vec Vec::Abs() const + { + return Vec(x > 0 ? x : -x, + y > 0 ? y : -y, + z > 0 ? z : -z); + } + + inline Vec Vec::Lerp(const Vec& other, float factor) const + { + return *this + (other - *this) * factor; + } + + inline Vec Vec::Lerp(const Vec& other, const Vec& factor) const + { + return Vec(this->x + (other.x - this->x) * factor.x, + this->y + (other.y - this->y) * factor.y, + this->z + (other.z - this->z) * factor.z); + } + + inline Motion::Motion() + { + Reset(); + } + + inline void Motion::Reset() + { + Quaternion.Set(1.f, 0.f, 0.f, 0.f); + Accel.Set(0.f, 0.f, 0.f); + Grav.Set(0.f, 0.f, 0.f); + SmoothAccel.Set(0.f, 0.f, 0.f); + Shakiness = 0.f; + } + + /// + /// The gyro inputs should be calibrated degrees per second but have no other processing. Acceleration is in G units (1 = approx. 9.8m/s^2) + /// + inline void Motion::Update(float inGyroX, float inGyroY, float inGyroZ, float inAccelX, float inAccelY, float inAccelZ, float gravityLength, float deltaTime) + { + if (!Settings) + { + return; + } + + // get settings + const float gravityCorrectionShakinessMinThreshold = Settings->GravityCorrectionShakinessMinThreshold; + const float gravityCorrectionShakinessMaxThreshold = Settings->GravityCorrectionShakinessMaxThreshold; + const float gravityCorrectionStillSpeed = Settings->GravityCorrectionStillSpeed; + const float gravityCorrectionShakySpeed = Settings->GravityCorrectionShakySpeed; + const float gravityCorrectionGyroFactor = Settings->GravityCorrectionGyroFactor; + const float gravityCorrectionGyroMinThreshold = Settings->GravityCorrectionGyroMinThreshold; + const float gravityCorrectionGyroMaxThreshold = Settings->GravityCorrectionGyroMaxThreshold; + const float gravityCorrectionMinimumSpeed = Settings->GravityCorrectionMinimumSpeed; + + const Vec axis = Vec(inGyroX, inGyroY, inGyroZ); + const Vec accel = Vec(inAccelX, inAccelY, inAccelZ); + const float angleSpeed = axis.Length() * std::numbers::pi_v / 180.0f; + const float angle = angleSpeed * deltaTime; + + // rotate + Quat rotation = AngleAxis(angle, axis.x, axis.y, axis.z); + Quaternion *= rotation; // do it this way because it's a local rotation, not global + + //printf("Quat: %.4f %.4f %.4f %.4f\n", + // Quaternion.w, Quaternion.x, Quaternion.y, Quaternion.z); + float accelMagnitude = accel.Length(); + if (accelMagnitude > 0.0f) + { + const Vec accelNorm = accel / accelMagnitude; + // account for rotation when tracking smoothed acceleration + SmoothAccel *= rotation.Inverse(); + //printf("Absolute Accel: %.4f %.4f %.4f\n", + // absoluteAccel.x, absoluteAccel.y, absoluteAccel.z); + const float smoothFactor = ShortSteadinessHalfTime <= 0.f ? 0.f : exp2f(-deltaTime / ShortSteadinessHalfTime); + Shakiness *= smoothFactor; + Shakiness = std::max(Shakiness, (accel - SmoothAccel).Length()); + SmoothAccel = accel.Lerp(SmoothAccel, smoothFactor); + + //printf("Shakiness: %.4f\n", Shakiness); + + // update grav by rotation + Grav *= rotation.Inverse(); + // we want to close the gap between grav and raw acceleration. What's the difference + const Vec gravToAccel = (accelNorm * -gravityLength) - Grav; + const Vec gravToAccelDir = gravToAccel.Normalized(); + // adjustment rate + float gravCorrectionSpeed; + if (gravityCorrectionShakinessMinThreshold < gravityCorrectionShakinessMaxThreshold) + { + gravCorrectionSpeed = gravityCorrectionStillSpeed + (gravityCorrectionShakySpeed - gravityCorrectionStillSpeed) * std::clamp((Shakiness - gravityCorrectionShakinessMinThreshold) / (gravityCorrectionShakinessMaxThreshold - gravityCorrectionShakinessMinThreshold), 0.f, 1.f); + } + else + { + gravCorrectionSpeed = Shakiness < gravityCorrectionShakinessMaxThreshold ? gravityCorrectionStillSpeed : gravityCorrectionShakySpeed; + } + // we also limit it to be no faster than a given proportion of the gyro rate, or the minimum gravity correction speed + const float gyroGravCorrectionLimit = std::max(angleSpeed * gravityCorrectionGyroFactor, gravityCorrectionMinimumSpeed); + if (gravCorrectionSpeed > gyroGravCorrectionLimit) + { + float closeEnoughFactor; + if (gravityCorrectionGyroMinThreshold < gravityCorrectionGyroMaxThreshold) + { + closeEnoughFactor = std::clamp((gravToAccel.Length() - gravityCorrectionGyroMinThreshold) / (gravityCorrectionGyroMaxThreshold - gravityCorrectionGyroMinThreshold), 0.f, 1.f); + } + else + { + closeEnoughFactor = gravToAccel.Length() < gravityCorrectionGyroMaxThreshold ? 0.f : 1.f; + } + gravCorrectionSpeed = gyroGravCorrectionLimit + (gravCorrectionSpeed - gyroGravCorrectionLimit) * closeEnoughFactor; + } + const Vec gravToAccelDelta = gravToAccelDir * gravCorrectionSpeed * deltaTime; + if (gravToAccelDelta.LengthSquared() < gravToAccel.LengthSquared()) + { + Grav += gravToAccelDelta; + } + else + { + Grav = accelNorm * -gravityLength; + } + + const Vec gravityDirection = Grav.Normalized() * Quaternion.Inverse(); // absolute gravity direction + const float errorAngle = acosf(std::clamp(Vec(0.0f, -1.0f, 0.0f).Dot(gravityDirection), -1.f, 1.f)); + const Vec flattened = Vec(0.0f, -1.0f, 0.0f).Cross(gravityDirection); + Quat correctionQuat = AngleAxis(errorAngle, flattened.x, flattened.y, flattened.z); + Quaternion = Quaternion * correctionQuat; + + Accel = accel + Grav; + } + else + { + Grav *= rotation.Inverse(); + Accel = Grav; + } + Quaternion.Normalize(); + } + + inline void Motion::SetSettings(GamepadMotionSettings* settings) + { + Settings = settings; + } + + inline SensorMinMaxWindow::SensorMinMaxWindow() + { + Reset(0.f); + } + + inline void SensorMinMaxWindow::Reset(float remainder) + { + NumSamples = 0; + TimeSampled = remainder; + } + + inline void SensorMinMaxWindow::AddSample(const Vec& inGyro, const Vec& inAccel, float deltaTime) + { + if (NumSamples == 0) + { + MaxGyro = inGyro; + MinGyro = inGyro; + MeanGyro = inGyro; + MaxAccel = inAccel; + MinAccel = inAccel; + MeanAccel = inAccel; + StartAccel = inAccel; + NumSamples = 1; + TimeSampled += deltaTime; + return; + } + + MaxGyro = MaxGyro.Max(inGyro); + MinGyro = MinGyro.Min(inGyro); + MaxAccel = MaxAccel.Max(inAccel); + MinAccel = MinAccel.Min(inAccel); + + NumSamples++; + TimeSampled += deltaTime; + + Vec delta = inGyro - MeanGyro; + MeanGyro += delta * (1.f / NumSamples); + delta = inAccel - MeanAccel; + MeanAccel += delta * (1.f / NumSamples); + } + + inline Vec SensorMinMaxWindow::GetMidGyro() + { + return MeanGyro; + } + + inline AutoCalibration::AutoCalibration() + { + CalibrationData = nullptr; + Reset(); + } + + inline void AutoCalibration::Reset() + { + MinMaxWindow.Reset(0.f); + Confidence = 0.f; + bIsSteady = false; + MinDeltaGyro = Vec(1.f); + MinDeltaAccel = Vec(0.25f); + RecalibrateThreshold = 1.f; + SensorFusionSkippedTime = 0.f; + TimeSteadySensorFusion = 0.f; + TimeSteadyStillness = 0.f; + } + + inline bool AutoCalibration::AddSampleStillness(const Vec& inGyro, const Vec& inAccel, float deltaTime, bool doSensorFusion) + { + if (inGyro.x == 0.f && inGyro.y == 0.f && inGyro.z == 0.f && + inAccel.x == 0.f && inAccel.y == 0.f && inAccel.z == 0.f) + { + // zeroes are almost certainly not valid inputs + return false; + } + + if (!Settings) + { + return false; + } + + if (!CalibrationData) + { + return false; + } + + // get settings + const int minStillnessSamples = Settings->MinStillnessSamples; + const float minStillnessCollectionTime = Settings->MinStillnessCollectionTime; + const float minStillnessCorrectionTime = Settings->MinStillnessCorrectionTime; + const float maxStillnessError = Settings->MaxStillnessError; + const float stillnessSampleDeteriorationRate = Settings->StillnessSampleDeteriorationRate; + const float stillnessErrorClimbRate = Settings->StillnessErrorClimbRate; + const float stillnessErrorDropOnRecalibrate = Settings->StillnessErrorDropOnRecalibrate; + const float stillnessCalibrationEaseInTime = Settings->StillnessCalibrationEaseInTime; + const float stillnessCalibrationHalfTime = Settings->StillnessCalibrationHalfTime * Confidence; + const float stillnessConfidenceRate = Settings->StillnessConfidenceRate; + const float stillnessGyroDelta = Settings->StillnessGyroDelta; + const float stillnessAccelDelta = Settings->StillnessAccelDelta; + + MinMaxWindow.AddSample(inGyro, inAccel, deltaTime); + // get deltas + const Vec gyroDelta = MinMaxWindow.MaxGyro - MinMaxWindow.MinGyro; + const Vec accelDelta = MinMaxWindow.MaxAccel - MinMaxWindow.MinAccel; + + bool calibrated = false; + bool isSteady = false; + const Vec climbThisTick = Vec(stillnessSampleDeteriorationRate * deltaTime); + if (stillnessGyroDelta < 0.f) + { + if (Confidence < 1.f) + { + MinDeltaGyro += climbThisTick; + } + } + else + { + MinDeltaGyro = Vec(stillnessGyroDelta); + } + if (stillnessAccelDelta < 0.f) + { + if (Confidence < 1.f) + { + MinDeltaAccel += climbThisTick; + } + } + else + { + MinDeltaAccel = Vec(stillnessAccelDelta); + } + + //printf("Deltas: %.4f %.4f %.4f; %.4f %.4f %.4f\n", + // gyroDelta.x, gyroDelta.y, gyroDelta.z, + // accelDelta.x, accelDelta.y, accelDelta.z); + + if (MinMaxWindow.NumSamples >= minStillnessSamples && MinMaxWindow.TimeSampled >= minStillnessCollectionTime) + { + MinDeltaGyro = MinDeltaGyro.Min(gyroDelta); + MinDeltaAccel = MinDeltaAccel.Min(accelDelta); + } + else + { + RecalibrateThreshold = std::min(RecalibrateThreshold + stillnessErrorClimbRate * deltaTime, maxStillnessError); + return false; + } + + // check that all inputs are below appropriate thresholds to be considered "still" + if (gyroDelta.x <= MinDeltaGyro.x * RecalibrateThreshold && + gyroDelta.y <= MinDeltaGyro.y * RecalibrateThreshold && + gyroDelta.z <= MinDeltaGyro.z * RecalibrateThreshold && + accelDelta.x <= MinDeltaAccel.x * RecalibrateThreshold && + accelDelta.y <= MinDeltaAccel.y * RecalibrateThreshold && + accelDelta.z <= MinDeltaAccel.z * RecalibrateThreshold) + { + if (MinMaxWindow.NumSamples >= minStillnessSamples && MinMaxWindow.TimeSampled >= minStillnessCorrectionTime) + { + /*if (TimeSteadyStillness == 0.f) + { + printf("Still!\n"); + }/**/ + + TimeSteadyStillness = std::min(TimeSteadyStillness + deltaTime, stillnessCalibrationEaseInTime); + const float calibrationEaseIn = stillnessCalibrationEaseInTime <= 0.f ? 1.f : TimeSteadyStillness / stillnessCalibrationEaseInTime; + + const Vec calibratedGyro = MinMaxWindow.GetMidGyro(); + + const Vec oldGyroBias = Vec(CalibrationData->X, CalibrationData->Y, CalibrationData->Z) / std::max((float)CalibrationData->NumSamples, 1.f); + const float stillnessLerpFactor = stillnessCalibrationHalfTime <= 0.f ? 0.f : exp2f(-calibrationEaseIn * deltaTime / stillnessCalibrationHalfTime); + Vec newGyroBias = calibratedGyro.Lerp(oldGyroBias, stillnessLerpFactor); + Confidence = std::min(Confidence + deltaTime * stillnessConfidenceRate, 1.f); + isSteady = true; + + if (doSensorFusion) + { + const Vec previousNormal = MinMaxWindow.StartAccel.Normalized(); + const Vec thisNormal = inAccel.Normalized(); + Vec angularVelocity = thisNormal.Cross(previousNormal); + const float crossLength = angularVelocity.Length(); + if (crossLength > 0.f) + { + const float thisDotPrev = std::clamp(thisNormal.Dot(previousNormal), -1.f, 1.f); + const float angleChange = acosf(thisDotPrev) * 180.0f / std::numbers::pi_v; + const float anglePerSecond = angleChange / MinMaxWindow.TimeSampled; + angularVelocity *= anglePerSecond / crossLength; + } + + Vec axisCalibrationStrength = thisNormal.Abs(); + Vec sensorFusionBias = (calibratedGyro - angularVelocity).Lerp(oldGyroBias, stillnessLerpFactor); + if (axisCalibrationStrength.x <= 0.7f) + { + newGyroBias.x = sensorFusionBias.x; + } + if (axisCalibrationStrength.y <= 0.7f) + { + newGyroBias.y = sensorFusionBias.y; + } + if (axisCalibrationStrength.z <= 0.7f) + { + newGyroBias.z = sensorFusionBias.z; + } + } + + CalibrationData->X = newGyroBias.x; + CalibrationData->Y = newGyroBias.y; + CalibrationData->Z = newGyroBias.z; + + CalibrationData->AccelMagnitude = MinMaxWindow.MeanAccel.Length(); + CalibrationData->NumSamples = 1; + + calibrated = true; + } + else + { + RecalibrateThreshold = std::min(RecalibrateThreshold + stillnessErrorClimbRate * deltaTime, maxStillnessError); + } + } + else if (TimeSteadyStillness > 0.f) + { + //printf("Moved!\n"); + RecalibrateThreshold -= stillnessErrorDropOnRecalibrate; + if (RecalibrateThreshold < 1.f) RecalibrateThreshold = 1.f; + + TimeSteadyStillness = 0.f; + MinMaxWindow.Reset(0.f); + } + else + { + RecalibrateThreshold = std::min(RecalibrateThreshold + stillnessErrorClimbRate * deltaTime, maxStillnessError); + MinMaxWindow.Reset(0.f); + } + + bIsSteady = isSteady; + return calibrated; + } + + inline void AutoCalibration::NoSampleStillness() + { + MinMaxWindow.Reset(0.f); + } + + inline bool AutoCalibration::AddSampleSensorFusion(const Vec& inGyro, const Vec& inAccel, float deltaTime) + { + if (deltaTime <= 0.f) + { + return false; + } + + if (inGyro.x == 0.f && inGyro.y == 0.f && inGyro.z == 0.f && + inAccel.x == 0.f && inAccel.y == 0.f && inAccel.z == 0.f) + { + // all zeroes are almost certainly not valid inputs + TimeSteadySensorFusion = 0.f; + SensorFusionSkippedTime = 0.f; + PreviousAccel = inAccel; + SmoothedPreviousAccel = inAccel; + SmoothedAngularVelocityGyro = GamepadMotionHelpers::Vec(); + SmoothedAngularVelocityAccel = GamepadMotionHelpers::Vec(); + return false; + } + + if (PreviousAccel.x == 0.f && PreviousAccel.y == 0.f && PreviousAccel.z == 0.f) + { + TimeSteadySensorFusion = 0.f; + SensorFusionSkippedTime = 0.f; + PreviousAccel = inAccel; + SmoothedPreviousAccel = inAccel; + SmoothedAngularVelocityGyro = GamepadMotionHelpers::Vec(); + SmoothedAngularVelocityAccel = GamepadMotionHelpers::Vec(); + return false; + } + + // in case the controller state hasn't updated between samples + if (inAccel.x == PreviousAccel.x && inAccel.y == PreviousAccel.y && inAccel.z == PreviousAccel.z) + { + SensorFusionSkippedTime += deltaTime; + return false; + } + + if (!Settings) + { + return false; + } + + // get settings + const float sensorFusionCalibrationSmoothingStrength = Settings->SensorFusionCalibrationSmoothingStrength; + const float sensorFusionAngularAccelerationThreshold = Settings->SensorFusionAngularAccelerationThreshold; + const float sensorFusionCalibrationEaseInTime = Settings->SensorFusionCalibrationEaseInTime; + const float sensorFusionCalibrationHalfTime = Settings->SensorFusionCalibrationHalfTime * Confidence; + const float sensorFusionConfidenceRate = Settings->SensorFusionConfidenceRate; + + deltaTime += SensorFusionSkippedTime; + SensorFusionSkippedTime = 0.f; + bool calibrated = false; + bool isSteady = false; + + // framerate independent lerp smoothing: https://www.gamasutra.com/blogs/ScottLembcke/20180404/316046/Improved_Lerp_Smoothing.php + const float smoothingLerpFactor = exp2f(-sensorFusionCalibrationSmoothingStrength * deltaTime); + // velocity from smoothed accel matches better if we also smooth gyro + const Vec previousGyro = SmoothedAngularVelocityGyro; + SmoothedAngularVelocityGyro = inGyro.Lerp(SmoothedAngularVelocityGyro, smoothingLerpFactor); // smooth what remains + const float gyroAccelerationMag = (SmoothedAngularVelocityGyro - previousGyro).Length() / deltaTime; + // get angle between old and new accel + const Vec previousNormal = SmoothedPreviousAccel.Normalized(); + const Vec thisAccel = inAccel.Lerp(SmoothedPreviousAccel, smoothingLerpFactor); + const Vec thisNormal = thisAccel.Normalized(); + Vec angularVelocity = thisNormal.Cross(previousNormal); + const float crossLength = angularVelocity.Length(); + if (crossLength > 0.f) + { + const float thisDotPrev = std::clamp(thisNormal.Dot(previousNormal), -1.f, 1.f); + const float angleChange = acosf(thisDotPrev) * 180.0f / std::numbers::pi_v; + const float anglePerSecond = angleChange / deltaTime; + angularVelocity *= anglePerSecond / crossLength; + } + SmoothedAngularVelocityAccel = angularVelocity; + + // apply corrections + if (gyroAccelerationMag > sensorFusionAngularAccelerationThreshold || CalibrationData == nullptr) + { + /*if (TimeSteadySensorFusion > 0.f) + { + printf("Shaken!\n"); + }/**/ + TimeSteadySensorFusion = 0.f; + //printf("No calibration due to acceleration of %.4f\n", gyroAccelerationMag); + } + else + { + /*if (TimeSteadySensorFusion == 0.f) + { + printf("Steady!\n"); + }/**/ + + TimeSteadySensorFusion = std::min(TimeSteadySensorFusion + deltaTime, sensorFusionCalibrationEaseInTime); + const float calibrationEaseIn = sensorFusionCalibrationEaseInTime <= 0.f ? 1.f : TimeSteadySensorFusion / sensorFusionCalibrationEaseInTime; + const Vec oldGyroBias = Vec(CalibrationData->X, CalibrationData->Y, CalibrationData->Z) / std::max((float)CalibrationData->NumSamples, 1.f); + // recalibrate over time proportional to the difference between the calculated bias and the current assumed bias + const float sensorFusionLerpFactor = sensorFusionCalibrationHalfTime <= 0.f ? 0.f : exp2f(-calibrationEaseIn * deltaTime / sensorFusionCalibrationHalfTime); + Vec newGyroBias = (SmoothedAngularVelocityGyro - SmoothedAngularVelocityAccel).Lerp(oldGyroBias, sensorFusionLerpFactor); + Confidence = std::min(Confidence + deltaTime * sensorFusionConfidenceRate, 1.f); + isSteady = true; + // don't change bias in axes that can't be affected by the gravity direction + Vec axisCalibrationStrength = thisNormal.Abs(); + if (axisCalibrationStrength.x > 0.7f) + { + axisCalibrationStrength.x = 1.f; + } + if (axisCalibrationStrength.y > 0.7f) + { + axisCalibrationStrength.y = 1.f; + } + if (axisCalibrationStrength.z > 0.7f) + { + axisCalibrationStrength.z = 1.f; + } + newGyroBias = newGyroBias.Lerp(oldGyroBias, axisCalibrationStrength.Min(Vec(1.f))); + + CalibrationData->X = newGyroBias.x; + CalibrationData->Y = newGyroBias.y; + CalibrationData->Z = newGyroBias.z; + + CalibrationData->AccelMagnitude = thisAccel.Length(); + + CalibrationData->NumSamples = 1; + + calibrated = true; + + //printf("Recalibrating at a strength of %.4f\n", calibrationEaseIn); + } + + SmoothedPreviousAccel = thisAccel; + PreviousAccel = inAccel; + + //printf("Gyro: %.4f, %.4f, %.4f | Accel: %.4f, %.4f, %.4f\n", + // SmoothedAngularVelocityGyro.x, SmoothedAngularVelocityGyro.y, SmoothedAngularVelocityGyro.z, + // SmoothedAngularVelocityAccel.x, SmoothedAngularVelocityAccel.y, SmoothedAngularVelocityAccel.z); + + bIsSteady = isSteady; + + return calibrated; + } + + inline void AutoCalibration::NoSampleSensorFusion() + { + TimeSteadySensorFusion = 0.f; + SensorFusionSkippedTime = 0.f; + PreviousAccel = GamepadMotionHelpers::Vec(); + SmoothedPreviousAccel = GamepadMotionHelpers::Vec(); + SmoothedAngularVelocityGyro = GamepadMotionHelpers::Vec(); + SmoothedAngularVelocityAccel = GamepadMotionHelpers::Vec(); + } + + inline void AutoCalibration::SetCalibrationData(GyroCalibration* calibrationData) + { + CalibrationData = calibrationData; + } + + inline void AutoCalibration::SetSettings(GamepadMotionSettings* settings) + { + Settings = settings; + } + +} // namespace GamepadMotionHelpers + +inline GamepadMotion::GamepadMotion() +{ + IsCalibrating = false; + CurrentCalibrationMode = GamepadMotionHelpers::CalibrationMode::Manual; + Reset(); + AutoCalibration.SetCalibrationData(&GyroCalibration); + AutoCalibration.SetSettings(&Settings); + Motion.SetSettings(&Settings); +} + +inline void GamepadMotion::Reset() +{ + GyroCalibration = {}; + Gyro = {}; + RawAccel = {}; + Settings = GamepadMotionSettings(); + Motion.Reset(); +} + +inline void GamepadMotion::ProcessMotion(float gyroX, float gyroY, float gyroZ, + float accelX, float accelY, float accelZ, float deltaTime) +{ + if (gyroX == 0.f && gyroY == 0.f && gyroZ == 0.f && + accelX == 0.f && accelY == 0.f && accelZ == 0.f) + { + // all zeroes are almost certainly not valid inputs + return; + } + + float accelMagnitude = sqrtf(accelX * accelX + accelY * accelY + accelZ * accelZ); + + if (IsCalibrating) + { + // manual calibration + PushSensorSamples(gyroX, gyroY, gyroZ, accelMagnitude); + AutoCalibration.NoSampleSensorFusion(); + AutoCalibration.NoSampleStillness(); + } + else if (CurrentCalibrationMode & GamepadMotionHelpers::CalibrationMode::Stillness) + { + AutoCalibration.AddSampleStillness(GamepadMotionHelpers::Vec(gyroX, gyroY, gyroZ), GamepadMotionHelpers::Vec(accelX, accelY, accelZ), deltaTime, CurrentCalibrationMode & GamepadMotionHelpers::CalibrationMode::SensorFusion); + AutoCalibration.NoSampleSensorFusion(); + } + else + { + AutoCalibration.NoSampleStillness(); + if (CurrentCalibrationMode & GamepadMotionHelpers::CalibrationMode::SensorFusion) + { + AutoCalibration.AddSampleSensorFusion(GamepadMotionHelpers::Vec(gyroX, gyroY, gyroZ), GamepadMotionHelpers::Vec(accelX, accelY, accelZ), deltaTime); + } + else + { + AutoCalibration.NoSampleSensorFusion(); + } + } + + float gyroOffsetX, gyroOffsetY, gyroOffsetZ; + GetCalibratedSensor(gyroOffsetX, gyroOffsetY, gyroOffsetZ, accelMagnitude); + + gyroX -= gyroOffsetX; + gyroY -= gyroOffsetY; + gyroZ -= gyroOffsetZ; + + Motion.Update(gyroX, gyroY, gyroZ, accelX, accelY, accelZ, accelMagnitude, deltaTime); + + Gyro.x = gyroX; + Gyro.y = gyroY; + Gyro.z = gyroZ; + RawAccel.x = accelX; + RawAccel.y = accelY; + RawAccel.z = accelZ; +} + +// reading the current state +inline void GamepadMotion::GetCalibratedGyro(float& x, float& y, float& z) +{ + x = Gyro.x; + y = Gyro.y; + z = Gyro.z; +} + +inline void GamepadMotion::GetGravity(float& x, float& y, float& z) +{ + x = Motion.Grav.x; + y = Motion.Grav.y; + z = Motion.Grav.z; +} + +inline void GamepadMotion::GetProcessedAcceleration(float& x, float& y, float& z) +{ + x = Motion.Accel.x; + y = Motion.Accel.y; + z = Motion.Accel.z; +} + +inline void GamepadMotion::GetOrientation(float& w, float& x, float& y, float& z) +{ + w = Motion.Quaternion.w; + x = Motion.Quaternion.x; + y = Motion.Quaternion.y; + z = Motion.Quaternion.z; +} + +inline void GamepadMotion::GetPlayerSpaceGyro(float& x, float& y, const float yawRelaxFactor) +{ + CalculatePlayerSpaceGyro(x, y, Gyro.x, Gyro.y, Gyro.z, Motion.Grav.x, Motion.Grav.y, Motion.Grav.z, yawRelaxFactor); +} + +inline void GamepadMotion::CalculatePlayerSpaceGyro(float& x, float& y, const float gyroX, const float gyroY, const float gyroZ, const float gravX, const float gravY, const float gravZ, const float yawRelaxFactor) +{ + // take gravity into account without taking on any error from gravity. Explained in depth at http://gyrowiki.jibbsmart.com/blog:player-space-gyro-and-alternatives-explained#toc7 + const float worldYaw = -(gravY * gyroY + gravZ * gyroZ); + const float worldYawSign = worldYaw < 0.f ? -1.f : 1.f; + y = worldYawSign * std::min(std::abs(worldYaw) * yawRelaxFactor, sqrtf(gyroY * gyroY + gyroZ * gyroZ)); + x = gyroX; +} + +inline void GamepadMotion::GetWorldSpaceGyro(float& x, float& y, const float sideReductionThreshold) +{ + CalculateWorldSpaceGyro(x, y, Gyro.x, Gyro.y, Gyro.z, Motion.Grav.x, Motion.Grav.y, Motion.Grav.z, sideReductionThreshold); +} + +inline void GamepadMotion::CalculateWorldSpaceGyro(float& x, float& y, const float gyroX, const float gyroY, const float gyroZ, const float gravX, const float gravY, const float gravZ, const float sideReductionThreshold) +{ + // use the gravity direction as the yaw axis, and derive an appropriate pitch axis. Explained in depth at http://gyrowiki.jibbsmart.com/blog:player-space-gyro-and-alternatives-explained#toc6 + const float worldYaw = -gravX * gyroX - gravY * gyroY - gravZ * gyroZ; + // project local pitch axis (X) onto gravity plane + const float gravDotPitchAxis = gravX; + GamepadMotionHelpers::Vec pitchAxis(1.f - gravX * gravDotPitchAxis, + -gravY * gravDotPitchAxis, + -gravZ * gravDotPitchAxis); + // normalize + const float pitchAxisLengthSquared = pitchAxis.LengthSquared(); + if (pitchAxisLengthSquared > 0.f) + { + const float pitchAxisLength = sqrtf(pitchAxisLengthSquared); + const float lengthReciprocal = 1.f / pitchAxisLength; + pitchAxis *= lengthReciprocal; + + const float flatness = std::abs(gravY); + const float upness = std::abs(gravZ); + const float sideReduction = sideReductionThreshold <= 0.f ? 1.f : std::clamp((std::max(flatness, upness) - sideReductionThreshold) / sideReductionThreshold, 0.f, 1.f); + + x = sideReduction * pitchAxis.Dot(GamepadMotionHelpers::Vec(gyroX, gyroY, gyroZ)); + } + else + { + x = 0.f; + } + + y = worldYaw; +} + +// gyro calibration functions +inline void GamepadMotion::StartContinuousCalibration() +{ + IsCalibrating = true; +} + +inline void GamepadMotion::PauseContinuousCalibration() +{ + IsCalibrating = false; +} + +inline void GamepadMotion::ResetContinuousCalibration() +{ + GyroCalibration = {}; + AutoCalibration.Reset(); +} + +inline void GamepadMotion::GetCalibrationOffset(float& xOffset, float& yOffset, float& zOffset) +{ + float accelMagnitude; + GetCalibratedSensor(xOffset, yOffset, zOffset, accelMagnitude); +} + +inline void GamepadMotion::SetCalibrationOffset(float xOffset, float yOffset, float zOffset, int weight) +{ + if (GyroCalibration.NumSamples > 1) + { + GyroCalibration.AccelMagnitude *= ((float)weight) / GyroCalibration.NumSamples; + } + else + { + GyroCalibration.AccelMagnitude = (float)weight; + } + + GyroCalibration.NumSamples = weight; + GyroCalibration.X = xOffset * weight; + GyroCalibration.Y = yOffset * weight; + GyroCalibration.Z = zOffset * weight; +} + +inline float GamepadMotion::GetAutoCalibrationConfidence() +{ + return AutoCalibration.Confidence; +} + +inline void GamepadMotion::SetAutoCalibrationConfidence(float newConfidence) +{ + AutoCalibration.Confidence = newConfidence; +} + +inline bool GamepadMotion::GetAutoCalibrationIsSteady() +{ + return AutoCalibration.IsSteady(); +} + +inline GamepadMotionHelpers::CalibrationMode GamepadMotion::GetCalibrationMode() +{ + return CurrentCalibrationMode; +} + +inline void GamepadMotion::SetCalibrationMode(GamepadMotionHelpers::CalibrationMode calibrationMode) +{ + CurrentCalibrationMode = calibrationMode; +} + +inline void GamepadMotion::ResetMotion() +{ + Motion.Reset(); +} + +// Private Methods + +inline void GamepadMotion::PushSensorSamples(float gyroX, float gyroY, float gyroZ, float accelMagnitude) +{ + // accumulate + GyroCalibration.NumSamples++; + GyroCalibration.X += gyroX; + GyroCalibration.Y += gyroY; + GyroCalibration.Z += gyroZ; + GyroCalibration.AccelMagnitude += accelMagnitude; +} + +inline void GamepadMotion::GetCalibratedSensor(float& gyroOffsetX, float& gyroOffsetY, float& gyroOffsetZ, float& accelMagnitude) +{ + if (GyroCalibration.NumSamples <= 0) + { + gyroOffsetX = 0.f; + gyroOffsetY = 0.f; + gyroOffsetZ = 0.f; + accelMagnitude = 1.f; + return; + } + + const float inverseSamples = 1.f / GyroCalibration.NumSamples; + gyroOffsetX = GyroCalibration.X * inverseSamples; + gyroOffsetY = GyroCalibration.Y * inverseSamples; + gyroOffsetZ = GyroCalibration.Z * inverseSamples; + accelMagnitude = GyroCalibration.AccelMagnitude * inverseSamples; +} diff --git a/vendor/gamepadmotionhelpers/LICENSE b/vendor/gamepadmotionhelpers/LICENSE new file mode 100644 index 000000000..a46396a3c --- /dev/null +++ b/vendor/gamepadmotionhelpers/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2023 Julian "Jibb" Smart + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/gamepadmotionhelpers/README.md b/vendor/gamepadmotionhelpers/README.md new file mode 100644 index 000000000..c5c1bc954 --- /dev/null +++ b/vendor/gamepadmotionhelpers/README.md @@ -0,0 +1,76 @@ +# GamepadMotionHelpers +GamepadMotionHelpers is a lightweight header-only library for sensor fusion, gyro calibration, etc. BYO input library (eg [SDL2](https://github.com/libsdl-org/SDL)). + +## Units +Convert your gyro units into **degrees per second** and accelerometer units to **g-force** (1 g = 9.8 m/s^2). You don't have to use these units in your application, but convert to these units when writing to GamepadMotionHelpers and convert back when reading from it. Your input reader might prefer radians per second and metres per second squared, but the datasheets for every IMU I've seen talk about degrees per second and g-force. + +## Coordinate Space +This library uses a Y-up coordinate system. While Z-up is (only slightly) preferable for many games, PlayStation controllers use Y-up, and have set the standard for input libraries like [SDL2](https://github.com/libsdl-org/SDL) and [JSL](https://github.com/JibbSmart/JoyShockLibrary). These libraries convert inputs from other controller types to the same space used by PlayStation's DualShock 4 and DualSense, so that's what's used here. + +## Basic Use +Include the GamepadMotion.hpp file in your C++ project. That's it! Everything you need is in that file, and its only dependency is ``````. + +For each controller with gyro (and optionally accelerometer), create a ```GamepadMotion``` object. At regular intervals, whether when a new report comes in from the controller or when polling the controller's state, you should call ```ProcessMotion(...)```. This is when you tell your GamepadMotion object the latest gyro (in degrees per second) and accelerometer (in g-force) inputs. You'll also give it the time since the last update for this controller (in seconds). + +ProcessMotion takes these inputs, updates some internal values, and then you can use any of the following to read its current state: +- ```GetCalibratedGyro(float& x, float& y, float& z)``` - Get the controller's angular velocity in degrees per second. This is just the raw gyro you gave it minus the gyro's bias as determined by your calibration settings (more on that below). +- ```GetGravity(float& x, float& y, float& z)``` - Get the gravity direction in the controller's local space. When the controller is still on a flat surface it'll be approximately (0, -1, 0). The controller can't detect the gravity direction when it's in freefall or being shaken around, but it can make a pretty good guess if its gyro is correctly calibrated and then make further corrections when the controller is still again. +- ```GetProcessedAcceleration(float& x, float& y, float& z)``` - Get the controller's current acceleration in g-force with gravity removed. Raw accelerometer input includes gravity -- it is only (0, 0, 0) when the controller is in freefall. However, using the gravity direction as calculated for GetGravity, it can remove that component and detect how you're shaking the controller about. This function gives you that acceleration vector with the gravity removed. +- ```GetOrientation(float& w, float& x, float& y, float& z)``` - Get the controller's orientation. Gyro and accelerometer input are combined to give a good estimate of the controller's orientation. + +Additional helper functions are available for taking gravity into account and returning a "world space" or "player space" rotation in two axes. Bear in mind that the **X** and **Y** set by these functions is still around the controller's axes. This means **Y** is the *horizontal* part of the rotation, and **X** is the vertical part. To convert to a mouse-like input, you'll treat the **Y** as the horizontal or yaw input and **X** as the vertical or pitch input. This might be unintuitive, but since it's also true of the "local space" angular velocities obtained from GetCalibratedGyro, this makes it simple to let the user choose between *local space*, *world space*, and *player space* in your game or application by just swapping GetCalibratedGyro for these functions depending on that selection: +- ```GetWorldSpaceGyro(float& x, float& y, const float sideReductionThreshold = 0.125f)``` - Get the controller's angular velocity in *world space* as described on GyroWiki in the [player space article here](http://gyrowiki.jibbsmart.com/blog:player-space-gyro-and-alternatives-explained#toc6). Yaw input will be derived from motion around the gravity axis, and pitch input from an appropriate pitch axis calculated from the controller's orientation with respect to the gravity axis. Any errors in the calculated gravity axis (though likely very small) will be taken on by the calculated world space gyro rotation, making it slightly less robust than using calibrated gyro directly ("local space" gyro) or using *player space* gyro below. More info in the linked article. +- ```GetPlayerSpaceGyro(float& x, float& y, const float yawRelaxFactor = 1.41f)``` - Get the controller's angular velocity in *player space* as described on GyroWiki in the [player space article here](http://gyrowiki.jibbsmart.com/blog:player-space-gyro-and-alternatives-explained#toc7). Yaw input will be derived from motion approximately around the gravity axis, without any impact from errors in the gravity calculation. Pitch is just local pitch. It is robust, accommodates players who are used to both local space and world space gyro, while taking on most of the advantages of each. It is proven in popular games and is an ideal default for players using a standalone controller. For handheld (where the screen is part of the controller, such as mobile, Switch, or Steam Deck) local space (using the calibrated gyro input directly) may be preferable. More info in the linked article. + +If you want to plug in the gyro and gravity values yourself (perhaps you're using an externally calculated gravity), you can use ```CalculateWorldSpaceGyro``` and ```CalculatePlayerSpaceGyro``` instead. Make sure you use this GamepadMotionHelpers' coordinate space, units, and gravity is normalized, since those are all assumed for these functions. + +## Sensor Fusion +Combining multiple types of sensor like this to get a better picture of the controller's state is called "sensor fusion". Moment-to-moment changes in orientation are detected using the gyro, but that only gives local angular velocity and needs to be correctly calibrated. Errors can accumulate over time. The gravity vector as detected by the accelerometer is used to make corrections to the relevant components of the controller's orientation. + +But this cannot be used to correct the controller's orientation around the gravity vector (the **yaw** axis). If you're using the controller's absolute orientation for some reason, this "yaw drift" may need to be accounted for somehow. Some devices also have a magnetometer (compass) to counter yaw drift, but since popular game controllers don't have a magnetometer, I haven't tried it myself. In future, if I get such a device, I'd like to add the option for GamepadMotionHelpers to accept magnetometer input and account for it when calculating values for the above functions. + +## Gyro Calibration +Modern gyroscopes often need calibration. This is like how a [weighing scale](https://en.wikipedia.org/wiki/Weighing_scale) can need calibration to tell it what 'zero' is. Like a weighing scale, a correctly calibrated gyroscope will give an accurate reading. If you're using the gyro input as a mouse, which is the simplest application of a controller's gyro, you can find essential reading on [GyroWiki here](http://gyrowiki.jibbsmart.com/blog:good-gyro-controls-part-1:the-gyro-is-a-mouse). + +Calibration just means having the controller sit still and remembering the average reported angular velocity in each axis. This is the gyro's "bias". In GamepadMotionHelpers, I call our best guess at the controller's bias the "calibration offset". GamepadMotionHelpers has some options to help with calibrating: + +At any time, you can begin manually calibrating a controller by calling ```StartContinuousCalibration()```. This will start recording the average angular velocity and apply it immediately to any subsequent **GetGalibratedGyro(...)** call. At any time you can ```PauseContinuousCalibration()``` to no longer add current values to the average angular velocity being recorded. You can ```ResetContinousCalibration()``` to remove the recorded average before starting over with **StartContinuousCalibration** again. + +You can read the stored calibration values using ```GetCalibrationOffset(float& xOffset, float& yOffset, float& zOffset)```. You can manually set the calibration offset yourself with ```SetCalibrationOffset(float xOffset, float yOffset, float zOffset, int weight)```. This will override all stored values. The **weight** argument at the end determines how strongly these values should be considered over time if Continuous Calibration is still active (new values are still being added to the average). Each new sample has a weight of 1, so if you **SetCalibrationOffset** with a weight of 10, it'll have the weight of 10 samples when calculating the average. If you're not continuing to add samples (Continuous Calibration is not active), the weight will be meaningless. Setting this manually is unusual, so don't worry about it too much if that sounds complicated. + +Most games don't ask the user to calibrate the gyro themselves. They have built-in automatic calibration, which I like to call "auto-calibration". There's no such thing as a "good enough" auto-calibration solution -- at least not with only gyro and accelerometer. Every game that has an auto-calibration solution would be made better for more serious players with the option to manually calibrate their gyro, so I urge you to provide players the option to do the same in your game. Having said that, auto-calibration is a useful option for casual players, and you may choose to have it enabled in your game by default. + +So GamepadMotionHelpers provides some auto-calibration options. You can call ```SetCalibrationMode(CalibrationMode)``` on each GamepadMotion instance with the following options: +- ```CalibrationMode::Manual``` - No auto-calibration. This is the default. +- ```CalibrationMode::Stillness``` - Automatically try to detect when the controller is being held still and update the calibration offset accordingly. +- ```CalibrationMode::SensorFusion``` - Calculate an angular velocity from changes in the gravity direction as detected by the accelerometer. If these are steady enough, use them to make corrections to the calibration offset. This will only apply to relevant axes. + +Many players are already aware of the shortcomings of trying to automatically detect stillness to automatically calibrate the gyro. Whether on Switch, PlayStation, or using PlayStation controllers on PC, players have tried to track a slow or distant target only to have the aimer suddenly stop moving! The game or the platform has **misinterpreted their slow and steady input as the controller being held still**, and they've incorrectly recalibrated accordingly. Players *hate it* when this happens. + +**This is why it's important to let players manually calibrate their gyro** if they want to. + +Auto-calibration is used so widely in console games that it's speculated that game developers may not have the option to disable it on these platforms. If this is the case, GamepadMotionHelpers offers a big advantage over those platforms: you can disable it and enable it at any time. + +You, the game developer, can have your game tell if the player is tracking a distant or slow-moving target. You can tell if the player's aimer is moving towards a visible target or roughly following the movement of one. When it is, maybe disabling the auto-calibration (```SetCalibrationMode(CalibrationMode::Manual)```) could be the difference between good and bad auto-calibration. I don't know if the GamepadMotionHelpers auto-calibration functions are better or worse than their Switch and PlayStation counterparts generally, but by letting you take the game's context into account, you may be able to offer players a way better experience without them having to manually calibrate. + +But still give them the option to calibrate manually, please :) + +The **SensorFusion** calibration mode has shortcomings of its own. It's much harder to accidentally trick the game into incorrectly calibrating, but the angular velocity calculated from the accelerometer moment-to-moment is generally much less precise. Leaving the controller still, you'll notice the calibrated gyro moving slightly up and down over time. So while the **Stillness** mode is characterised by good behaviour occasionally punctuated by frustrating errors, the **SensorFusion** mode will tend to be more consistently not-quite-right without being terrible. + +Secondly, this library currently only combines accelerometer and gyro, so the **SensorFusion** auto-calibration cannot correct the gyro in all axes at the same time. The **SensorFusion**-only mode will be more useful in future when magnetometer input is supported, which can account for the axes that the accelerometer can't. + +Both auto-calibration modes can be combined by passing ```CalibrationMode::Stillness | CalibrationMode::SensorFusion``` to **SetCalibrationMode**. In this case, it'll use **Stillness** auto-calibration, but it'll adjust the calibration offset based on any angular velocity implied by changes in the accelerometer input. This tends to give better results than just using **Stillness** or **SensorFusion** on their own. + +If you aren't sure what to choose, I'd suggest using the combined ```CalibrationMode::Stillness | CalibrationMode::SensorFusion``` when auto calibration is enabled, but also allowing the player to manually calibrate. + +**TODO** This is a clunky way to let the user set up what is obviously the best solution. Maybe I should just call it "hybrid" or something and be done with it? + +Auto-calibration can also be used to communicate manual calibration to the player. ```GetAutoCalibrationIsSteady()``` will tell you whether GamepadMotionHelpers thinks the controller is currently being held steady (if auto-calibration is enabled). ```GetAutoCalibrationConfidence()``` will tell you how confident GamepadMotionHelpers is that it has a good calibration value from auto-calibration, from 0-1. Higher confidence means that new calibration changes will be applied more gradually. You can use these functions to detect when a controller needs to be calibrated, prompt the player to put their controller down, detect when they have put their controller down, and show progress for calibration (default 1 second once it starts). You can also override the confidence yourself (```SetAutoCalibrationConfidence()```), and resetting calibration will reset confidence to 0. How quickly confidence grows as well as other calibration settings can be customised in **GamepadMotionSettings**. + +## In the Wild +GamepadMotionHelpers is currently used in: +- [JoyShockMapper](https://github.com/Electronicks/JoyShockMapper) +- [JoyShockLibrary](https://github.com/JibbSmart/JoyShockLibrary) +- JoyShockOverlay + +If you know of any other games or applications using GamepadMotionHelpers, please let me know! \ No newline at end of file diff --git a/vendor/sdl/CMakeLists.txt b/vendor/sdl/CMakeLists.txt new file mode 100644 index 000000000..cb40a2960 --- /dev/null +++ b/vendor/sdl/CMakeLists.txt @@ -0,0 +1,18 @@ +# SDL3 built from source via FetchContent: +# To upgrade, update SDL3_VERSION and SDL3_HASH to match the desired release tag. +# Hash can be found at: https://github.com/libsdl-org/SDL/releases +# See: https://wiki.libsdl.org/SDL3/README-cmake +set(SDL3_VERSION "3.4.4") +set(SDL3_HASH "ee712dbe6a89bb140bbfc2ce72358fb5ee5cc2240abeabd54855012db30b3864") + +include(FetchContent) +FetchContent_Declare( + SDL3 + URL "https://github.com/libsdl-org/SDL/releases/download/release-${SDL3_VERSION}/SDL3-${SDL3_VERSION}.tar.gz" + URL_HASH SHA256=${SDL3_HASH} + DOWNLOAD_EXTRACT_TIMESTAMP FALSE +) +set(SDL_STATIC ON CACHE BOOL "" FORCE) +set(SDL_SHARED OFF CACHE BOOL "" FORCE) +set(SDL_TEST OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(SDL3) \ No newline at end of file diff --git a/vendor/sdl/LICENSE.txt b/vendor/sdl/LICENSE.txt new file mode 100644 index 000000000..e9adee448 --- /dev/null +++ b/vendor/sdl/LICENSE.txt @@ -0,0 +1,18 @@ +Copyright (C) 1997-2026 Sam Lantinga + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. +