From 3c96cc4e765a311350972c714b77d4c2f77732e2 Mon Sep 17 00:00:00 2001 From: Taylor Richards Date: Fri, 24 Jul 2026 17:24:46 -0400 Subject: [PATCH] add gamepad navigation support Gamepad navigation is enabled by default but can be disabled with settings. As the retail FreeSpace UI system doesn't lend itself all that well to proper gamepad mechanics, the gamepad inputs instead mimic a mouse and keyboard. Gamepad navigation is only active when at least one gamepad is connected and the mouse cursor is set to visible in the game. One exception is gamepad camera controls (Lab), where no cursor check is performed. A gamepad does NOT need to be set as a current joystick in order to work for navigation. This allows for a gamepad (real or virtual, if recognized by SDL) to be used for UI navigation without affecting the control setup used for gameplay. When gamepad navigation is enabled it also enables Dear ImGui's native gamepad navigation support. While in the Lab or In-game Settings the ImGui gamepad navigation controls will be used instead of the normal FreeSpace-style navigation controls. FreeSpace gamepad navigation controls: Mouse -------- The cursor is controlled via the Left Stick. Left click is simulated via the Action button (A / Cross, by default) Right click is simulated via the X / Square button Keyboard --------- Although they are used inconsistently, with usage and purpose occasionally varying between different menus, the retail UI code generally uses the following keyboard keys for navigation: The ESC key is simulated via the Cancel button (B / Circle, by default) The Arrow keys are simulated via D-Pad The TAB key is simulated via the Right Shoulder button The Shift-TAB combo is simulated via the Left Shoulder button The Page Down key is simulated via the Left Trigger The Page Up key is simulated via the Right Trigger Dear ImGui gamepad navigation controls: The gamepad is supported directly without simulating Mouse/Keyboard inputs. ImGui control layout infographics are available at: https://www.dearimgui.com/controls_sheets/ Camera controls (used in the Lab & chosen to not conflict with ImGui): Right Stick: Orient the displayed object Right Stick + Left Trigger: Rotate the camera Right Stick + Right Trigger: Zoom the camera in or out Right Stick + Both Triggers: Pan the camera on the X/Y plane --- code/controlconfig/controlsconfig.cpp | 5 + code/controlconfig/controlsconfig.h | 5 + code/cutscene/movie.cpp | 5 + code/globalincs/toolchain/clang.h | 1 + code/globalincs/toolchain/gcc.h | 1 + code/globalincs/toolchain/mingw.h | 1 + code/io/gamepad.cpp | 709 ++++++++++++++++++++++++++ code/io/gamepad.h | 164 ++++++ code/io/joy-sdl.cpp | 17 + code/io/joy.h | 6 + code/io/joy_ff.cpp | 5 +- code/io/mouse.cpp | 13 + code/io/mouse.h | 3 + code/lab/dialogs/lab_ui.cpp | 24 +- code/lab/manager/lab_manager.cpp | 30 +- code/localization/localize.cpp | 2 +- code/source_groups.cmake | 2 + freespace2/freespace.cpp | 13 +- 18 files changed, 988 insertions(+), 18 deletions(-) create mode 100644 code/io/gamepad.cpp create mode 100644 code/io/gamepad.h diff --git a/code/controlconfig/controlsconfig.cpp b/code/controlconfig/controlsconfig.cpp index 3b6a4938cce..d43827d4402 100644 --- a/code/controlconfig/controlsconfig.cpp +++ b/code/controlconfig/controlsconfig.cpp @@ -1531,6 +1531,11 @@ const char *control_config_tooltip_handler(const char *str) return NULL; } +bool control_config_special_mode() +{ + return (Binding_mode || Search_mode); +} + void control_config_init(bool API_Access) { int i; diff --git a/code/controlconfig/controlsconfig.h b/code/controlconfig/controlsconfig.h index 24e538d6882..5f659afa916 100644 --- a/code/controlconfig/controlsconfig.h +++ b/code/controlconfig/controlsconfig.h @@ -740,6 +740,11 @@ void control_config_common_init(); */ void control_config_common_close(); +/*! + * @brief detect whether control config is in search or binding modes + */ +bool control_config_special_mode(); + /*! * @brief init config menu */ diff --git a/code/cutscene/movie.cpp b/code/cutscene/movie.cpp index 0ac531a81db..53bd352ed30 100644 --- a/code/cutscene/movie.cpp +++ b/code/cutscene/movie.cpp @@ -28,6 +28,7 @@ #include "tracing/tracing.h" #include "io/timer.h" #include "io/key.h" +#include "io/gamepad.h" #include "mod_table/mod_table.h" #include "network/multi.h" #include "scripting/global_hooks.h" @@ -244,6 +245,10 @@ void movie_display_loop(Player* player, PlaybackState* state) { processEvents(); + if (io::gamepad::action_or_cancel()) { + state->playing = false; + } + // NOTE: This does not update mission time! If movies get enabled in places // other than through cutscenes then some refactoring should be done // to account for normal time progression rather than just timestamps diff --git a/code/globalincs/toolchain/clang.h b/code/globalincs/toolchain/clang.h index 06952ddf43b..3a19f325838 100644 --- a/code/globalincs/toolchain/clang.h +++ b/code/globalincs/toolchain/clang.h @@ -104,6 +104,7 @@ #define PUSH_SUPPRESS_WARNINGS \ _Pragma("clang diagnostic push") \ _Pragma("clang diagnostic ignored \"-Wattributes\"") \ +_Pragma("clang diagnostic ignored \"-Wshadow\"") \ /** * @brief Restored previous warning settings diff --git a/code/globalincs/toolchain/gcc.h b/code/globalincs/toolchain/gcc.h index 121424b630c..5f83a53be4f 100644 --- a/code/globalincs/toolchain/gcc.h +++ b/code/globalincs/toolchain/gcc.h @@ -96,6 +96,7 @@ #define PUSH_SUPPRESS_WARNINGS \ _Pragma("GCC diagnostic push") \ _Pragma("GCC diagnostic ignored \"-Wattributes\"") \ +_Pragma("GCC diagnostic ignored \"-Wshadow\"") \ /** * @brief Restored previous warning settings diff --git a/code/globalincs/toolchain/mingw.h b/code/globalincs/toolchain/mingw.h index cf6f14eb76d..92d73e0babb 100644 --- a/code/globalincs/toolchain/mingw.h +++ b/code/globalincs/toolchain/mingw.h @@ -92,6 +92,7 @@ #define PUSH_SUPPRESS_WARNINGS \ _Pragma("GCC diagnostic push") \ _Pragma("GCC diagnostic ignored \"-Wattributes\"") \ +_Pragma("GCC diagnostic ignored \"-Wshadow\"") \ /** * @brief Restored previous warning settings diff --git a/code/io/gamepad.cpp b/code/io/gamepad.cpp new file mode 100644 index 00000000000..ce567baecbf --- /dev/null +++ b/code/io/gamepad.cpp @@ -0,0 +1,709 @@ +/* + * Copyright (C) Volition, Inc. 1999. All rights reserved. + * + * All source code herein is the property of Volition, Inc. You may not sell + * or otherwise commercially exploit the source or things you created based on the + * source. + * + */ + +#include "globalincs/pstypes.h" +#include "io/gamepad.h" +#include "io/mouse.h" +#include "io/cursor.h" +#include "io/key.h" +#include "osapi/osapi.h" +#include "gamesequence/gamesequence.h" +#include "options/Option.h" +#include "scpui/rocket_ui.h" + +#include "imgui.h" + +// Our Assert conflicts with the definitions inside libRocket +#pragma push_macro("Assert") +#undef Assert + +PUSH_SUPPRESS_WARNINGS +#include +#include +POP_SUPPRESS_WARNINGS + +#pragma pop_macro("Assert") + + + +using namespace io::gamepad; + +namespace { + +bool initialized = false; + +typedef std::unique_ptr GamepadPtr; + +SCP_vector gamepads; + +constexpr uint32_t KEY_CHECK_INTERVAL_MS = 150; +constexpr uint32_t CURSOR_UPDATE_INTERVAL_MS = 20; // 50 Hz + +// config options ---- +bool NavEnabled = true; +bool SwapActionCancel = false; +int CursorSpeed = 6; +// ------------------- + +// NOTE: never return true from here since we want these events to cascade to +// other places +bool event_handler(const SDL_Event &evt) +{ + if ( !os::events::isWindowEvent(evt, os::getSDLMainWindow()) ) { + return false; + } + + const auto id = evt.gdevice.which; + + auto gamepad = std::find_if(gamepads.begin(), gamepads.end(), + [id](GamepadPtr &p) { return p->getId() == id; }); + + if (evt.type == SDL_EVENT_GAMEPAD_ADDED) { + // this event can fire more than once for the same device so we need to + // check for duplicates + if (gamepad == gamepads.end()) { + gamepads.push_back(GamepadPtr(new Gamepad(id))); + } + + return false; + } else if (evt.type == SDL_EVENT_GAMEPAD_REMOVED) { + if (gamepad != gamepads.end()) { + std::swap(*gamepad, gamepads.back()); + gamepads.pop_back(); + } + + return false; + } + + if (gamepad == gamepads.end()) { + return false; + } + + // skip these events if control config is in bind or search mode + if (control_config_special_mode()) { + return false; + } + + switch (evt.type) { + case SDL_EVENT_GAMEPAD_BUTTON_DOWN: + case SDL_EVENT_GAMEPAD_BUTTON_UP: + (*gamepad)->mark_button(evt.gbutton.button, evt.gbutton.down); + break; + + case SDL_EVENT_GAMEPAD_AXIS_MOTION: { + bool down = (evt.gaxis.value > 10000); + + if (evt.gaxis.axis == SDL_GAMEPAD_AXIS_LEFT_TRIGGER) { + (*gamepad)->mark_button(GAMEPAD_BUTTON_LEFT_TRIGGER, down); + } else if (evt.gaxis.axis == SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) { + (*gamepad)->mark_button(GAMEPAD_BUTTON_RIGHT_TRIGGER, down); + } + + break; + } + } + + return false; +} + +bool change_gamepad_nav_func(float new_val, bool initial) +{ + NavEnabled = new_val; + + if ( !initial ) { + if (new_val) { + io::gamepad::init(); + } else { + io::gamepad::shutdown(); + } + } + + return true; +} + +void parse_gamepad_nav_func() +{ + bool value; + stuff_boolean(&value); + + NavEnabled = value; +} + +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +auto GamepadNavOption __UNUSED = options::OptionBuilder("Input.GamepadNav", + std::pair{"Gamepad Navigation", 1933}, + std::pair{"Enable or disable gamepad UI control", 1934}) + .category(std::make_pair("Input", 1827)) + .level(options::ExpertLevel::Beginner) + .importance(2) + .default_func([]() { return NavEnabled; }) + .change_listener(change_gamepad_nav_func) + .parser(parse_gamepad_nav_func) + .finish(); + +void parse_gamepad_swap_func() +{ + bool value; + stuff_boolean(&value); + + SwapActionCancel = value; +} + +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +auto GamepadSwapOption __UNUSED = options::OptionBuilder("Input.GamepadSwapActionCancel", + std::pair{"Swap Action/Cancel Buttons", 1935}, + std::pair{"Swap gamepad buttons used for action and cancel", 1936}) + .category(std::make_pair("Input", 1827)) + .level(options::ExpertLevel::Beginner) + .importance(1) + .default_func([]() { return SwapActionCancel; }) + .bind_to(&SwapActionCancel) + .parser(parse_gamepad_swap_func) + .finish(); + +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +auto GamepadCursorSpeed __UNUSED = options::OptionBuilder("Input.GamepadCursorSpeed", + std::pair{"Gamepad Cursor Speed", 1937}, + std::pair{"Movement speed of cursor from gamepad inputs", 1938}) + .category(std::make_pair("Input", 1827)) + .level(options::ExpertLevel::Beginner) + .importance(0) + .range(1, 10) + .default_func([]() { return CursorSpeed; }) + .flags({options::OptionFlags::RangeTypeInteger}) + .bind_to(&CursorSpeed) + .finish(); + +} // namespace + + +namespace io::gamepad { + +Gamepad::Gamepad(SDL_JoystickID _id) : + m_id(_id) +{ + m_gamepad = SDL_OpenGamepad(m_id); + m_button_state.fill(false); +} + +Gamepad::~Gamepad() +{ + if (m_gamepad) { + SDL_CloseGamepad(m_gamepad); + } +} + +bool Gamepad::action() +{ + auto button = SwapActionCancel ? SDL_GAMEPAD_BUTTON_EAST : SDL_GAMEPAD_BUTTON_SOUTH; + + auto state = m_button_state[button]; + m_button_state[button] = false; // we only want this to be true once + + return state; +} + +bool Gamepad::cancel() +{ + auto button = SwapActionCancel ? SDL_GAMEPAD_BUTTON_SOUTH : SDL_GAMEPAD_BUTTON_EAST; + + auto state = m_button_state[button]; + m_button_state[button] = false; // we only want this to be true once + + return state; +} + +int Gamepad::get_key() +{ + int k = 0; + + if (cancel()) { + k = KEY_ESC; + // resets automatically + } else if (m_button_state[SDL_GAMEPAD_BUTTON_DPAD_DOWN]) { + k = KEY_DOWN; + // key repeats + } else if (m_button_state[SDL_GAMEPAD_BUTTON_DPAD_UP]) { + k = KEY_UP; + // key repeats + } else if (m_button_state[SDL_GAMEPAD_BUTTON_DPAD_LEFT]) { + k = KEY_LEFT; + // key repeats + } else if (m_button_state[SDL_GAMEPAD_BUTTON_DPAD_RIGHT]) { + k = KEY_RIGHT; + // key repeats + } else if (m_button_state[SDL_GAMEPAD_BUTTON_LEFT_SHOULDER]) { + k = KEY_SHIFTED | KEY_TAB; + // reset after use + m_button_state[SDL_GAMEPAD_BUTTON_LEFT_SHOULDER] = false; + } else if (m_button_state[SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER]) { + k = KEY_TAB; + // reset after use + m_button_state[SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER] = false; + } else if (m_button_state[GAMEPAD_BUTTON_LEFT_TRIGGER]) { + k = SDLK_PAGEDOWN; + // reset after use + m_button_state[GAMEPAD_BUTTON_LEFT_TRIGGER] = false; + } else if (m_button_state[GAMEPAD_BUTTON_RIGHT_TRIGGER]) { + k = SDLK_PAGEUP; + // reset after use + m_button_state[GAMEPAD_BUTTON_RIGHT_TRIGGER] = false; + } + + return k; +} + +bool Gamepad::update_mouse_pos() +{ + if ( !m_gamepad ) { + return false; + } + + // we poll directly here in order to get smooth movement + int gx = SDL_GetGamepadAxis(m_gamepad, SDL_GAMEPAD_AXIS_LEFTX); + int gy = SDL_GetGamepadAxis(m_gamepad, SDL_GAMEPAD_AXIS_LEFTY); + + // ignore possible stick drift + if (abs(gx) < DEAD_ZONE) gx = 0; + if (abs(gy) < DEAD_ZONE) gy = 0; + + if ( !gx && !gy ) { + return false; + } + + // scales delta to a range between -4..4 (slow, accurate) and -32..32 (fast, inaccurate) + const float sensitivity = 8000.f - (CursorSpeed - 1.f) * (7000.f / 9.f); + + float dx = gx / sensitivity; + float dy = gy / sensitivity; + + int x = 0; + int y = 0; + + mouse_get_real_pos(&x, &y); + + // update pos and deltas + mouse_update_pos_scaled(static_cast(x+dx), static_cast(y+dy), dx, dy); + + // now change position + SDL_HideCursor(); // prevents cursor getting stuck as non-game one + SDL_WarpMouseInWindow(os::getSDLMainWindow(), x+dx, y+dy); + SDL_ShowCursor(); + + return true; +} + +void Gamepad::mark_mouse_button(Uint8 button) +{ + const int left_button = SwapActionCancel ? SDL_GAMEPAD_BUTTON_EAST : SDL_GAMEPAD_BUTTON_SOUTH; + uint m_button = 0; + + // skip this if we're doing scpui events + if (scpui::getContext()) { + return; + } + + if (button == left_button) { + // "A" or "B" (if swapped) + m_button = MOUSE_LEFT_BUTTON; + } else if (button == SDL_GAMEPAD_BUTTON_WEST) { + // "X" + m_button = MOUSE_RIGHT_BUTTON; + } + + if ( !m_button ) { + return; + } + + mouse_mark_button(m_button, m_button_state[button] ? 1 : 0); +} + +void Gamepad::scpui_translate_button(Uint8 button) +{ + // skip this if we aren't doing scpui inputs + auto input_context = scpui::getContext(); + + if ( !input_context ) { + return; + } + + using namespace Rocket::Core; + + // mouse button events + const int left_button = SwapActionCancel ? SDL_GAMEPAD_BUTTON_EAST : SDL_GAMEPAD_BUTTON_SOUTH; + int mouse_button = -1; + + if (button == left_button) { + mouse_button = 0; + } else if (button == SDL_GAMEPAD_BUTTON_WEST) { + mouse_button = 1; + } + + if (mouse_button >= 0) { + if (m_button_state[button]) { + input_context->ProcessMouseButtonDown(mouse_button, 0); + } else { + input_context->ProcessMouseButtonUp(mouse_button, 0); + } + + // reset down state to avoid repeats + m_button_state[button] = false; + + return; + } + + // keyboard events + Input::KeyIdentifier key = Input::KI_UNKNOWN; + int mod = 0; + bool reset_state = true; + + if (cancel()) { + key = Input::KI_ESCAPE; + } else if (button == SDL_GAMEPAD_BUTTON_DPAD_UP) { + key = Input::KI_UP; + reset_state = false; + } else if (button == SDL_GAMEPAD_BUTTON_DPAD_DOWN) { + key = Input::KI_DOWN; + reset_state = false; + } else if (button == SDL_GAMEPAD_BUTTON_DPAD_LEFT) { + key = Input::KI_LEFT; + reset_state = false; + } else if (button == SDL_GAMEPAD_BUTTON_DPAD_RIGHT) { + key = Input::KI_RIGHT; + reset_state = false; + } else if (button == SDL_GAMEPAD_BUTTON_LEFT_SHOULDER) { + key = Input::KI_TAB; + mod = Input::KM_SHIFT; + } else if (button == SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER) { + key = Input::KI_TAB; + } else if (button == GAMEPAD_BUTTON_LEFT_TRIGGER) { + key = Input::KI_NEXT; + } else if (button == GAMEPAD_BUTTON_RIGHT_TRIGGER) { + key = Input::KI_PRIOR; + } + + if (key != Input::KI_UNKNOWN) { + if (m_button_state[button]) { + input_context->ProcessKeyDown(key, mod); + + // reset down state to avoid repeats + if (reset_state) { + m_button_state[button] = false; + } + } else { + input_context->ProcessKeyUp(key, mod); + } + } +} + +void Gamepad::mark_button(Uint8 button, bool down) +{ + if (button >= m_button_state.size()) { + return; + } + + m_button_state[button] = down; + + mark_mouse_button(button); + scpui_translate_button(button); +} + +bool Gamepad::get_camera_vals(int *dx, int *dy, int *dz, bool *lmb_down, bool *rmb_down, uint *key_flags) +{ + if ( !m_gamepad ) { + return false; + } + + // we poll directly here in order to get smooth movement + int gx = SDL_GetGamepadAxis(m_gamepad, SDL_GAMEPAD_AXIS_RIGHTX); + int gy = SDL_GetGamepadAxis(m_gamepad, SDL_GAMEPAD_AXIS_RIGHTY); + + // ignore possible stick drift + if (abs(gx) < DEAD_ZONE) gx = 0; + if (abs(gy) < DEAD_ZONE) gy = 0; + + if ( !gx && !gy ) { + return false; + } + + const bool LeftTrigger = m_button_state[GAMEPAD_BUTTON_LEFT_TRIGGER]; + const bool RightTrigger = m_button_state[GAMEPAD_BUTTON_RIGHT_TRIGGER]; + + if (LeftTrigger) { + if (rmb_down) *rmb_down = true; + + if (RightTrigger) { + if (key_flags) *key_flags |= KEY_SHIFTED; + } + } else if ( !RightTrigger ) { + if (lmb_down) *lmb_down = true; + } + + // scales delta to -3..3 (slow, accurate) + // NOTE: using purposefully slow scale due to dz speed issues + const float sensitivity = 10000.f; + + auto mdx = fl2i(gx / sensitivity); + auto mdy = fl2i(gy / sensitivity); + + // we should only set dz or dx/dy, not both, and don't set dz if "shifted" + if (RightTrigger && !LeftTrigger) { + if (mdy && dz) { + *dz = (mdy > 0) ? -1 : 1; + } + } else { + if (dx) *dx = fl2i(mdx); + if (dy) *dy = fl2i(mdy); + } + + return true; +} + + +// Initialize gamepad leanback +// NOTE: This should work independently of io::joystick! +void init() +{ + if (initialized) { + return; + } + + if (Is_standalone) { + return; + } + + if ( !Using_in_game_options ) { + NavEnabled = os_config_read_uint("Input", "GamepadNav", 1) == 1; + SwapActionCancel = os_config_read_uint("Input", "GamepadSwapActionCancel", 0) == 1; + CursorSpeed = os_config_read_uint("Input", "GamepadCursorSpeed", 6); + CLAMP(CursorSpeed, 1, 10); + } + + if ( !NavEnabled ) { + return; + } + + if ( !SDL_InitSubSystem(SDL_INIT_GAMEPAD) ) { + return; + } + + // TODO: SDL3 => It might be nice to eventually add touchpad support here + // for more precise cursor control. + + initialized = true; + + auto pads = SDL_GetGamepads(nullptr); + + if (pads) { + for (int i = 0; pads[i]; ++i) { + gamepads.push_back(GamepadPtr(new Gamepad(pads[i]))); + } + + SDL_free(pads); + pads = nullptr; + } + + if (ImGui::GetCurrentContext()) { + auto &io = ImGui::GetIO(); + + // enable gamepad nav for imgui + io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; + + io.ConfigNavSwapGamepadButtons = SwapActionCancel; + io.ConfigNavMoveSetMousePos = true; + } + + // These events should *not* be consumed, so that the joystick code can also + // make use of them + using namespace os::events; + addEventListener(SDL_EVENT_GAMEPAD_ADDED, DEFAULT_LISTENER_WEIGHT - 5, event_handler); + addEventListener(SDL_EVENT_GAMEPAD_REMOVED, DEFAULT_LISTENER_WEIGHT - 5, event_handler); + addEventListener(SDL_EVENT_GAMEPAD_AXIS_MOTION, DEFAULT_LISTENER_WEIGHT - 5, event_handler); + addEventListener(SDL_EVENT_GAMEPAD_BUTTON_DOWN, DEFAULT_LISTENER_WEIGHT - 5, event_handler); + addEventListener(SDL_EVENT_GAMEPAD_BUTTON_UP, DEFAULT_LISTENER_WEIGHT - 5, event_handler); +} + +void shutdown() +{ + if ( !initialized ) { + return; + } + + if (ImGui::GetCurrentContext()) { + auto &io = ImGui::GetIO(); + + io.ConfigFlags &= ~ImGuiConfigFlags_NavEnableGamepad; + + io.ConfigNavSwapGamepadButtons = false; + io.ConfigNavMoveSetMousePos = false; + } + + gamepads.clear(); + gamepads.shrink_to_fit(); + + SDL_QuitSubSystem(SDL_INIT_GAMEPAD); + + initialized = false; +} + +bool action() +{ + if ( !navActive() ) { + return false; + } + + // this should always work (cursor on/off, imgui, etc.) + + for (auto &p : gamepads) { + if (p->action()) { + return true; + } + } + + return false; +} + +bool cancel() +{ + if ( !navActive() ) { + return false; + } + + // this should always work (cursor on/off, imgui, etc.) + + for (auto &p : gamepads) { + if (p->cancel()) { + return true; + } + } + + return false; +} + +bool action_or_cancel() +{ + if ( !navActive() ) { + return false; + } + + // this should always work (cursor on/off, imgui, etc.) + + for (auto &p : gamepads) { + if (p->action_or_cancel()) { + return true; + } + } + + return false; +} + +int get_key() +{ + static Uint64 key_check_time = 0; + + if ( !navActive() ) { + return 0; + } + + // skip if we aren't doing ui stuff + if ( !io::mouse::CursorManager::get()->isCursorShown() ) { + return 0; + } + + // if imgui should be controlling the gamepad nav then let it + const auto game_state = gameseq_get_state(); + + if ((game_state == GS_STATE_LAB) || (game_state == GS_STATE_INGAME_OPTIONS)) { + return 0; + } + + // also skip this if we're doing scpui events + if (scpui::getContext()) { + return 0; + } + + if (SDL_GetTicks() < key_check_time) { + return 0; + } + + key_check_time = SDL_GetTicks() + KEY_CHECK_INTERVAL_MS; + + for (auto &p : gamepads) { + auto k = p->get_key(); + + if (k) { + return k; + } + } + + return 0; +} + +void do_frame() +{ + static Uint64 next_update = 0; + + if ( !navActive() ) { + return; + } + + // skip if we aren't doing ui stuff + if ( !io::mouse::CursorManager::get()->isCursorShown() ) { + return; + } + + // if imgui should be controlling the gamepad nav then let it + const auto game_state = gameseq_get_state(); + + if ((game_state == GS_STATE_LAB) || (game_state == GS_STATE_INGAME_OPTIONS)) { + return; + } + + // limit updates so that we aren't zooming all over the place at higher fps + if (next_update > SDL_GetTicks()) { + return; + } + + next_update = SDL_GetTicks() + CURSOR_UPDATE_INTERVAL_MS; + + for (auto &p : gamepads) { + if (p->update_mouse_pos()) { + return; + } + } +} + +bool get_camera_vals(int *dx, int *dy, int *dz, bool *lmb_down, bool *rmb_down, uint *key_flags) +{ + if ( !navActive() ) { + return false; + } + + if (dx) *dx = 0; + if (dy) *dy = 0; + if (dz) *dz = 0; + if (lmb_down) *lmb_down = false; + if (rmb_down) *rmb_down = false; + if (key_flags) *key_flags = 0; + + for (auto &p : gamepads) { + if (p->get_camera_vals(dx, dy, dz, lmb_down, rmb_down, key_flags)) { + return true; + } + } + + return false; +} + +bool navActive() +{ + return (initialized && NavEnabled && !gamepads.empty()); +} + +} // namespace io::gamepad diff --git a/code/io/gamepad.h b/code/io/gamepad.h new file mode 100644 index 00000000000..47963449deb --- /dev/null +++ b/code/io/gamepad.h @@ -0,0 +1,164 @@ +/* + * Copyright (C) Volition, Inc. 1999. All rights reserved. + * + * All source code herein is the property of Volition, Inc. You may not sell + * or otherwise commercially exploit the source or things you created based on the + * source. + * + */ + +#ifndef __GAMEPAD_H__ +#define __GAMEPAD_H__ + +namespace io::gamepad { + +constexpr Uint8 GAMEPAD_BUTTON_LEFT_TRIGGER = SDL_GAMEPAD_BUTTON_COUNT; +constexpr Uint8 GAMEPAD_BUTTON_RIGHT_TRIGGER = SDL_GAMEPAD_BUTTON_COUNT+1; + + +class Gamepad { +private: + /** + * @brief Minimum dead zone to avoid reading stick drift + * @note This is the generally recommended value, but not necessarily accurate for all gamepads + */ + static constexpr int DEAD_ZONE = 8000; + + /** + * @brief State of buttons on gamepad, plus 2 entries for the triggers + */ + std::array m_button_state; + + SDL_JoystickID m_id; //!< SDL joystick/gamepad id + SDL_Gamepad *m_gamepad; //!< SDL gamepad handle + + /** + * @brief Translates a gamepad button press into a mouse button press + * @param button Uint8 representation of SDL_GAMEPAD_BUTTON_* + */ + void mark_mouse_button(Uint8 button); + + /** + * @brief Translates a gamepad button press into a SCPUI mouse or keyboard input + * @param button Uint8 representation of SDL_GAMEPAD_BUTTON_* + */ + void scpui_translate_button(Uint8 button); + +public: + Gamepad(SDL_JoystickID _id); + ~Gamepad(); + + SDL_JoystickID getId() const { return m_id; } + + /** + * @brief Determines if the action button has been pressed (similar to left mouse button) + * @note Typically the A button on Xbox style gamepads + * + * @return @c true if the action button is pressed, @c false otherwise + */ + bool action(); + + /** + * @brief Determines if the cancel button has been pressed (similar to Esc key) + * @note Typically the B button on Xbox style gamepads + * + * @return @c true if the cancel button is pressed, @c false otherwise + */ + bool cancel(); + + /** + * @brief Determines if the action or cancel buttons have been pressed + * + * @return @c true if the action or cancel button is pressed, @c false otherwise + */ + bool action_or_cancel() { return (action() || cancel()); } + + /** + * @brief Get the keyboard equivalent of a gamepad button + * + * @return KEY_* value corresponding to gamepad button + */ + int get_key(); + + /** + * @brief Gets the down time of the given hat and position + * @param[in] button Uint8 representation of SDL_GAMEPAD_BUTTON_* + * @param[in] down @c true if button is pressed, @c false if button is released + */ + void mark_button(Uint8 button, bool down); + + /** + * @brief Update mouse/cursor position based on gamepad stick movement + */ + bool update_mouse_pos(); + + /** + * @brief Translate gamepad state into mouse states for camera/object movement + * + * @details These controls were chosen due to ImGui not using them by default. If those defaults + * change an alternate mapping may be required. + * + * @param[out] dx x-axis delta of right stick + * @param[out] dy y-axis delta of right stick + * @param[out] dz z-axis delta (mouse wheel, only if right trigger pressed) + * @param[out] lmb_down @c true if left mouse button down ( dx/dy has value and no triggers pressed) + * @param[out] rmb_down @c true if riight mouse button down (dx/dy has value and left trigger pressed) + * @param[out] key_flags will have @c KEY_SHIFTED set if both triggers are pressed + * + * @returns @c true if any of the values were set + */ + bool get_camera_vals(int *dx, int *dy, int *dz, bool *lmb_down, bool *rmb_down, uint *key_flags); +}; + +void init(); +void shutdown(); +void do_frame(); + +/** + * @brief Returns @c true if gamepad navigation is enabled and active + */ +bool navActive(); + +/** + * @brief Returns @c true if the defined Action button has been pressed + */ +bool action(); + +/** + * @brief Returns @c true if the defined Cancel button has been pressed + */ +bool cancel(); + +/** + * @brief Returns @c true if the defined Action or Cancel buttons have been pressed + */ +bool action_or_cancel(); + +/** + * @brief Get the keyboard equivalent of any pressed gamepad buttons + * + * @return KEY_* value corresponding to a gamepad button + */ +int get_key(); + +/** + * @brief Translate gamepad state into mouse states for camera/object movement + * + * @details These controls were chosen due to ImGui not using them by default. If those defaults + * change an alternate mapping may be required. + * + * @param[out] dx x-axis delta of right stick + * @param[out] dy y-axis delta of right stick + * @param[out] dz z-axis delta (mouse wheel, only if right trigger pressed) + * @param[out] lmb_down @c true if left mouse button down ( dx/dy has value and no triggers pressed) + * @param[out] rmb_down @c true if riight mouse button down (dx/dy has value and left trigger pressed) + * @param[out] key_flags will have @c KEY_SHIFTED set if both triggers are pressed + * + * @returns @c true if any of the values were set to something other than defaults + * + * @warning This function sets all passed arguments to default values if gamepad navigation is active! + */ +bool get_camera_vals(int *dx, int *dy, int *dz, bool *lmb_down, bool *rmb_down, uint *key_flags); +} + +#endif diff --git a/code/io/joy-sdl.cpp b/code/io/joy-sdl.cpp index 44921aa550b..f2c232d51ef 100644 --- a/code/io/joy-sdl.cpp +++ b/code/io/joy-sdl.cpp @@ -921,6 +921,14 @@ namespace joystick } } + void Joystick::flush() + { + for (auto &b : _button) { + b.DownTimestamp = UI_TIMESTAMP::invalid(); + b.DownCount = 0; + } + } + SDL_Joystick *Joystick::getJoystick() { return _joystick; @@ -1480,3 +1488,12 @@ short joy_get_button_axis(short cid, short btn) return static_cast(SDL_GAMEPAD_AXIS_LEFT_TRIGGER + axis_button); } + +void joy_flush() +{ + for (auto pJoy : pJoystick) { + if (pJoy) { + pJoy->flush(); + } + } +} diff --git a/code/io/joy.h b/code/io/joy.h index 3b68a1be954..e7dcc9c4dc5 100644 --- a/code/io/joy.h +++ b/code/io/joy.h @@ -254,6 +254,11 @@ namespace io */ json_t* getJSON(); + /** + * @brief Clears internal button state + */ + void flush(); + private: Joystick(const Joystick &); Joystick &operator=(const Joystick &); @@ -390,5 +395,6 @@ short joy_get_button_axis(const short cid, short btn); */ bool joy_present(short cid); +void joy_flush(); #endif /* __JOY_H__ */ diff --git a/code/io/joy_ff.cpp b/code/io/joy_ff.cpp index c856f1c1395..4e55154e8ee 100644 --- a/code/io/joy_ff.cpp +++ b/code/io/joy_ff.cpp @@ -33,6 +33,7 @@ static auto ForceFeedbackOption = options::OptionBuilder("Input.ForceFeedb std::pair{"Enable or disable force feedback", 1729}) .category(std::make_pair("Input", 1827)) .level(options::ExpertLevel::Beginner) + .importance(25) .default_val(true) .change_listener([](bool val, bool) { if (val) joy_ff_init(); @@ -47,6 +48,7 @@ static auto HitEffectOption = options::OptionBuilder("Input.HitEffect", std::pair{"Enable or disable the directional hit effect", 1731}) .category(std::make_pair("Input", 1827)) .level(options::ExpertLevel::Beginner) + .importance(24) .default_val(true) .change_listener([](bool val, bool) { Joy_ff_directional_hit_effect_enabled = val; @@ -57,9 +59,10 @@ static auto HitEffectOption = options::OptionBuilder("Input.HitEffect", // coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton static auto ForceFeedbackStrength = options::OptionBuilder("Input.FFStrength", std::pair{"Force Feedback Strength", 1756}, - std::pair{"The realtive strength of Force Feedback effects", 1757}) + std::pair{"The relative strength of Force Feedback effects", 1757}) .category(std::make_pair("Input", 1827)) .level(options::ExpertLevel::Beginner) + .importance(23) .range(0, 100) .default_val(100) .flags({options::OptionFlags::RangeTypeInteger}) diff --git a/code/io/mouse.cpp b/code/io/mouse.cpp index 185b2da8dff..32e0991bffe 100644 --- a/code/io/mouse.cpp +++ b/code/io/mouse.cpp @@ -721,3 +721,16 @@ short bit_distance(short x) { return i; } + +// update mouse with position which is already scaled for max_w/max_h +void mouse_update_pos_scaled(int x, int y, float dx, float dy) +{ + CAP(x, 0, gr_screen.max_w-1); + CAP(y, 0, gr_screen.max_h-1); + + Mouse_x = x; + Mouse_y = y; + + Mouse_dx += fl2i(dx); + Mouse_dy += fl2i(dy); +} diff --git a/code/io/mouse.h b/code/io/mouse.h index 3a2fa7c29ee..ceaa37b2fe3 100644 --- a/code/io/mouse.h +++ b/code/io/mouse.h @@ -124,4 +124,7 @@ extern void mouse_force_pos(float x, float y); */ short bit_distance(short x); +// update mouse with position which is already scaled for max_w/max_h +void mouse_update_pos_scaled(int x, int y, float dx, float dy); + #endif diff --git a/code/lab/dialogs/lab_ui.cpp b/code/lab/dialogs/lab_ui.cpp index a534a02aef4..8185c7a9b1e 100644 --- a/code/lab/dialogs/lab_ui.cpp +++ b/code/lab/dialogs/lab_ui.cpp @@ -15,6 +15,7 @@ #include "mission/missionload.h" #include "prop/prop.h" #include "controlconfig/controlsconfig.h" +#include "io/gamepad.h" using namespace ImGui; @@ -84,7 +85,7 @@ void LabUi::build_species_entry(const species_info &species_def, int species_idx TreeNodeEx(node_label.c_str(), ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen, "%s", class_def.name); - if (IsItemClicked() && !IsItemToggledOpen()) { + if (IsItemActivated() && !IsItemToggledOpen()) { getLabManager()->changeDisplayedObject(LabMode::Ship, ship_info_idx); } } @@ -121,7 +122,7 @@ void LabUi::build_weapon_subtype_list() const ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen, "%s", class_def.name); - if (IsItemClicked() && !IsItemToggledOpen()) { + if (IsItemActivated() && !IsItemToggledOpen()) { getLabManager()->changeDisplayedObject(LabMode::Weapon, weapon_idx); } } @@ -147,7 +148,7 @@ void LabUi::build_prop_subtype_list() "%s", class_def.name.c_str()); - if (IsItemClicked() && !IsItemToggledOpen()) { + if (IsItemActivated() && !IsItemToggledOpen()) { getLabManager()->changeDisplayedObject(LabMode::Prop, prop_idx); } } @@ -179,7 +180,7 @@ void LabUi::build_asteroid_list() info.name, subtype.type_name.c_str()); - if (IsItemClicked() && !IsItemToggledOpen()) { + if (IsItemActivated() && !IsItemToggledOpen()) { getLabManager()->changeDisplayedObject(LabMode::Asteroid, asteroid_idx, subtype_idx); } @@ -210,7 +211,7 @@ void LabUi::build_debris_list() "%s", info.name); - if (IsItemClicked() && !IsItemToggledOpen()) { + if (IsItemActivated() && !IsItemToggledOpen()) { getLabManager()->changeDisplayedObject(LabMode::Asteroid, debris_idx, 0); // Debris subtype is always 0 } @@ -275,7 +276,7 @@ void LabUi::build_background_list() ImGuiTreeNodeFlags node_flags = ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen; TreeNodeEx(LAB_MISSION_NONE_STRING, node_flags, LAB_MISSION_NONE_STRING); - if (IsItemClicked() && !IsItemToggledOpen()) { + if (IsItemActivated() && !IsItemToggledOpen()) { getLabManager()->Renderer->useBackground(LAB_MISSION_NONE_STRING); } @@ -291,7 +292,7 @@ void LabUi::build_background_list() for (const auto& mission_name : directory.second) { TreeNodeEx(mission_name.c_str(), node_flags, "%s", mission_name.c_str()); - if (IsItemClicked() && !IsItemToggledOpen()) { + if (IsItemActivated() && !IsItemToggledOpen()) { getLabManager()->Renderer->useBackground(mission_name); } } @@ -397,6 +398,15 @@ void LabUi::show_controls_reference() TextWrapped("Rotation axis limits and rotation speed apply only to object orientation (LMB), not " "camera controls (RMB)."); + if (io::gamepad::navActive()) { + Separator(); + TextWrapped("Gamepad controls"); + controls_reference_entry("RStick", "Orient the displayed object."); + controls_reference_entry("RStick + LTrigger", "Rotate the camera."); + controls_reference_entry("RStick + LTrigger + RTrigger", "Pan the camera on the X/Y plane."); + controls_reference_entry("RStick + RTrigger", "Zoom the camera in or out."); + } + Separator(); TextWrapped("Keyboard shortcuts"); controls_reference_entry("R", "Cycle object orientation (LMB) axis mode (Yaw, Pitch, Roll, or Both)."); diff --git a/code/lab/manager/lab_manager.cpp b/code/lab/manager/lab_manager.cpp index 24560967a3a..22607ca8309 100644 --- a/code/lab/manager/lab_manager.cpp +++ b/code/lab/manager/lab_manager.cpp @@ -19,6 +19,7 @@ #include "freespace.h" #include "io/mouse.h" +#include "io/gamepad.h" #undef LOCAL #include "extensions/ImGuizmo.h" @@ -129,20 +130,33 @@ void LabManager::onFrame(float frametime) { int mouse_y = 0; mouse_get_pos(&mouse_x, &mouse_y); - const bool lmb_down = mouse_down(MOUSE_LEFT_BUTTON) != 0; + auto key_flags = key_get_shift_status(); + + bool lmb_down = mouse_down(MOUSE_LEFT_BUTTON) != 0; + bool rmb_down = mouse_down(MOUSE_RIGHT_BUTTON) != 0; + bool lmb_pressed = lmb_down && !LastLmbDown; - LastLmbDown = lmb_down; - if (lmb_pressed && ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow)) { - lmb_pressed = false; - } + // don't let gamepad nav override mouse control + if (io::gamepad::navActive() && !lmb_down && !rmb_down) { + io::gamepad::get_camera_vals(&dx, &dy, &dz, &lmb_down, &rmb_down, &key_flags); + lmb_pressed = lmb_down && !LastLmbDown; + } else if (ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow)) { + // NOTE: when using gamepad nav a window will always be hovered + if (dz != 0) { + dz = 0; + } - if (dz != 0 && ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow)) { - dz = 0; + if (lmb_pressed) { + lmb_pressed = false; + } } + + LastLmbDown = lmb_down; + auto& current_camera = Renderer->getCurrentCamera(); current_camera->handleInput( - dx, dy, dz, lmb_down, lmb_pressed, mouse_down(MOUSE_RIGHT_BUTTON) != 0, key_get_shift_status(), mouse_x, mouse_y); + dx, dy, dz, lmb_down, lmb_pressed, rmb_down, key_flags, mouse_x, mouse_y); if (!current_camera->handlesObjectPlacement()) { const bool over_camera_overlay = Renderer->getShowOrientationWidget() && current_camera->isOverlayHit(mouse_x, mouse_y); diff --git a/code/localization/localize.cpp b/code/localization/localize.cpp index 17bd9d20588..22b616f0c8f 100644 --- a/code/localization/localize.cpp +++ b/code/localization/localize.cpp @@ -65,7 +65,7 @@ bool *Lcl_unexpected_tstring_check = nullptr; // NOTE: with map storage of XSTR strings, the indexes no longer need to be contiguous, // but internal strings should still increment XSTR_SIZE to avoid collisions. // retail XSTR_SIZE = 1570 -// #define XSTR_SIZE 1933 // This is the next available ID +// #define XSTR_SIZE 1939 // This is the next available ID // struct to allow for strings.tbl-determined x offset // offset is 0 for english, by default diff --git a/code/source_groups.cmake b/code/source_groups.cmake index 913aeff5543..508aca0d20c 100644 --- a/code/source_groups.cmake +++ b/code/source_groups.cmake @@ -752,6 +752,8 @@ add_file_folder("Io" io/joy_rumble.cpp io/spacemouse.cpp io/spacemouse.h + io/gamepad.cpp + io/gamepad.h ) # jpgutils files diff --git a/freespace2/freespace.cpp b/freespace2/freespace.cpp index c4e1e53c5dc..3722b498266 100644 --- a/freespace2/freespace.cpp +++ b/freespace2/freespace.cpp @@ -80,6 +80,7 @@ #include "hud/hudtargetbox.h" #include "iff_defs/iff_defs.h" #include "io/cursor.h" +#include "io/gamepad.h" #include "io/joy.h" #include "io/joy_ff.h" #include "io/key.h" @@ -2108,6 +2109,7 @@ void game_init() // standalone's don't use the joystick and it seems to sometimes cause them to not get shutdown properly if(!Is_standalone){ + io::gamepad::init(); io::joystick::init(); } @@ -4693,6 +4695,7 @@ void game_flush() { key_flush(); mouse_flush(); + joy_flush(); snazzy_flush(); Joymouse_button_status = 0; @@ -4716,6 +4719,10 @@ int game_check_key() if ((k & KEY_MASK) == KEY_PADENTER) k = (k & ~KEY_MASK) | KEY_ENTER; + if ( !k ) { + k = io::gamepad::get_key(); + } + return k; } @@ -4753,7 +4760,8 @@ int game_poll() int k = key_inkey(); // Move the mouse cursor with the joystick. Currently uses Joystick0 - if (os_foreground() && !io::mouse::CursorManager::get()->isCursorShown() && (Use_joy_mouse)) { + // (NOTE: ignore this when using gamepad navigation to avoid a mess) + if (Use_joy_mouse && os_foreground() && io::mouse::CursorManager::get()->isCursorShown() && !io::gamepad::navActive()) { // Move the mouse cursor with the joystick int mx, my; @@ -6453,6 +6461,8 @@ void game_do_state_common(int state,int no_networking) #endif Last_frame_ui_timestamp = ui_timestamp(); + io::gamepad::do_frame(); + io::mouse::CursorManager::doFrame(); // determine if to draw the mouse this frame snd_do_frame(); // update sound system event_music_do_frame(); // music needs to play across many states @@ -7130,6 +7140,7 @@ void game_shutdown(void) control_config_common_close(); io::joystick::shutdown(); + io::gamepad::shutdown(); audiostream_close(); snd_close();