diff --git a/CMakeLists.txt b/CMakeLists.txt index 60f4bc43ff..9fe7b5819a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -449,6 +449,7 @@ if(WIN32) psapi winhttp bcrypt + xinput ) endif() diff --git a/src/gfx_func.h b/src/gfx_func.h index ed9d2c2cec..1de95284b7 100644 --- a/src/gfx_func.h +++ b/src/gfx_func.h @@ -75,6 +75,7 @@ void HandleTextInput(std::string_view str, bool marked = false, std::optional insert_location = std::nullopt, std::optional replacement_end = std::nullopt); void HandleCtrlChanged(); void HandleMouseEvents(); +void HandleGamepadScrolling(int stick_x, int stick_y, int max_axis_value); void UpdateWindows(); void ChangeGameSpeed(bool enable_fast_forward); diff --git a/src/lang/english.txt b/src/lang/english.txt index 30c6848c6c..abb2cc41e2 100644 --- a/src/lang/english.txt +++ b/src/lang/english.txt @@ -1689,6 +1689,25 @@ STR_CONFIG_SETTING_SCROLLWHEEL_ZOOM :Zoom map STR_CONFIG_SETTING_SCROLLWHEEL_SCROLL :Scroll map STR_CONFIG_SETTING_SCROLLWHEEL_OFF :Off +STR_CONFIG_SETTING_GAMEPAD_STICK_SELECTION :Gamepad stick for scrolling: {STRING2} +STR_CONFIG_SETTING_GAMEPAD_STICK_SELECTION_HELPTEXT :Select which analog stick to use for map scrolling +###length 3 +STR_CONFIG_SETTING_GAMEPAD_STICK_DISABLED :Disabled +STR_CONFIG_SETTING_GAMEPAD_STICK_LEFT :Left stick +STR_CONFIG_SETTING_GAMEPAD_STICK_RIGHT :Right stick + +STR_CONFIG_SETTING_GAMEPAD_DEADZONE :Gamepad deadzone: {STRING2}% +STR_CONFIG_SETTING_GAMEPAD_DEADZONE_HELPTEXT :Minimum stick movement required before scrolling starts (0-100%) + +STR_CONFIG_SETTING_GAMEPAD_SENSITIVITY :Gamepad sensitivity: {STRING2} +STR_CONFIG_SETTING_GAMEPAD_SENSITIVITY_HELPTEXT :Control the sensitivity of gamepad analog stick scrolling + +STR_CONFIG_SETTING_GAMEPAD_INVERT_X :Invert gamepad X-axis: {STRING2} +STR_CONFIG_SETTING_GAMEPAD_INVERT_X_HELPTEXT :Invert the horizontal axis movement of the gamepad analog stick + +STR_CONFIG_SETTING_GAMEPAD_INVERT_Y :Invert gamepad Y-axis: {STRING2} +STR_CONFIG_SETTING_GAMEPAD_INVERT_Y_HELPTEXT :Invert the vertical axis movement of the gamepad analog stick + STR_CONFIG_SETTING_OSK_ACTIVATION :On screen keyboard: {STRING2} STR_CONFIG_SETTING_OSK_ACTIVATION_HELPTEXT :Select the method to open the on screen keyboard for entering text into editboxes only using the pointing device. This is meant for small devices without actual keyboard ###length 4 diff --git a/src/settingentry_gui.cpp b/src/settingentry_gui.cpp index fcc408adb3..28ddf09d8e 100644 --- a/src/settingentry_gui.cpp +++ b/src/settingentry_gui.cpp @@ -681,6 +681,11 @@ SettingsContainer &GetSettingsTree() * Since it's also able to completely disable the scrollwheel will we display it on all platforms anyway */ viewports->Add(new SettingEntry("gui.scrollwheel_scrolling")); viewports->Add(new SettingEntry("gui.scrollwheel_multiplier")); + viewports->Add(new SettingEntry("gui.gamepad_stick_selection")); + viewports->Add(new SettingEntry("gui.gamepad_deadzone")); + viewports->Add(new SettingEntry("gui.gamepad_sensitivity")); + viewports->Add(new SettingEntry("gui.gamepad_invert_x")); + viewports->Add(new SettingEntry("gui.gamepad_invert_y")); #ifdef __APPLE__ /* We might need to emulate a right mouse button on mac */ viewports->Add(new SettingEntry("gui.right_mouse_btn_emulation")); diff --git a/src/settings_type.h b/src/settings_type.h index 255038e537..ca94092b55 100644 --- a/src/settings_type.h +++ b/src/settings_type.h @@ -140,6 +140,13 @@ enum ScrollWheelScrollingSetting : uint8_t { SWS_OFF = 2 ///< Scroll wheel has no effect. }; +/** Settings related to gamepad stick selection. */ +enum GamepadStickSelection : uint8_t { + GSS_DISABLED = 0, ///< Gamepad scrolling disabled. + GSS_LEFT_STICK = 1, ///< Use left analog stick for scrolling. + GSS_RIGHT_STICK = 2, ///< Use right analog stick for scrolling. +}; + /** Settings related to the GUI and other stuff that is not saved in the savegame. */ struct GUISettings { bool sg_full_load_any; ///< new full load calculation, any cargo must be full read from pre v93 savegames @@ -183,6 +190,11 @@ struct GUISettings { uint8_t right_mouse_btn_emulation; ///< should we emulate right mouse clicking? uint8_t scrollwheel_scrolling; ///< scrolling using the scroll wheel? uint8_t scrollwheel_multiplier; ///< how much 'wheel' per incoming event from the OS? + uint8_t gamepad_deadzone; ///< deadzone for gamepad analog sticks (0-100) + uint8_t gamepad_sensitivity; ///< sensitivity multiplier for gamepad scrolling + bool gamepad_invert_x; ///< invert X axis for gamepad scrolling? + bool gamepad_invert_y; ///< invert Y axis for gamepad scrolling? + uint8_t gamepad_stick_selection; ///< which stick to use for scrolling (left/right/disabled) bool timetable_arrival_departure; ///< show arrivals and departures in vehicle timetables RightClickClose right_click_wnd_close; ///< close window with right click bool pause_on_newgame; ///< whether to start new games paused or not diff --git a/src/table/settings/gui_settings.ini b/src/table/settings/gui_settings.ini index 3d9ee1b75b..0f3079b080 100644 --- a/src/table/settings/gui_settings.ini +++ b/src/table/settings/gui_settings.ini @@ -912,3 +912,62 @@ post_cb = [](auto) { SetupWidgetDimensions(); ReInitAllWindows(true); } cat = SC_BASIC startup = true +[SDTC_VAR] +var = gui.gamepad_deadzone +type = SLE_UINT8 +flags = SettingFlag::NotInSave, SettingFlag::NoNetworkSync +def = 10 +min = 0 +max = 100 +interval = 5 +str = STR_CONFIG_SETTING_GAMEPAD_DEADZONE +strhelp = STR_CONFIG_SETTING_GAMEPAD_DEADZONE_HELPTEXT +strval = STR_JUST_COMMA +cat = SC_BASIC +startup = true + +[SDTC_VAR] +var = gui.gamepad_sensitivity +type = SLE_UINT8 +flags = SettingFlag::NotInSave, SettingFlag::NoNetworkSync +def = 10 +min = 1 +max = 100 +interval = 5 +str = STR_CONFIG_SETTING_GAMEPAD_SENSITIVITY +strhelp = STR_CONFIG_SETTING_GAMEPAD_SENSITIVITY_HELPTEXT +strval = STR_JUST_COMMA +cat = SC_BASIC +startup = true + +[SDTC_BOOL] +var = gui.gamepad_invert_x +flags = SettingFlag::NotInSave, SettingFlag::NoNetworkSync +def = false +str = STR_CONFIG_SETTING_GAMEPAD_INVERT_X +strhelp = STR_CONFIG_SETTING_GAMEPAD_INVERT_X_HELPTEXT +cat = SC_BASIC +startup = true + +[SDTC_BOOL] +var = gui.gamepad_invert_y +flags = SettingFlag::NotInSave, SettingFlag::NoNetworkSync +def = false +str = STR_CONFIG_SETTING_GAMEPAD_INVERT_Y +strhelp = STR_CONFIG_SETTING_GAMEPAD_INVERT_Y_HELPTEXT +cat = SC_BASIC +startup = true + +[SDTC_VAR] +var = gui.gamepad_stick_selection +type = SLE_UINT8 +flags = SettingFlag::NotInSave, SettingFlag::NoNetworkSync, SettingFlag::GuiDropdown +def = GSS_LEFT_STICK +min = GSS_DISABLED +max = GSS_RIGHT_STICK +str = STR_CONFIG_SETTING_GAMEPAD_STICK_SELECTION +strhelp = STR_CONFIG_SETTING_GAMEPAD_STICK_SELECTION_HELPTEXT +strval = STR_CONFIG_SETTING_GAMEPAD_STICK_DISABLED +cat = SC_BASIC +startup = true + diff --git a/src/video/sdl2_v.cpp b/src/video/sdl2_v.cpp index 7f4312a289..504288ce72 100644 --- a/src/video/sdl2_v.cpp +++ b/src/video/sdl2_v.cpp @@ -20,6 +20,7 @@ #include "../fileio_func.h" #include "../framerate_type.h" #include "../window_func.h" +#include "../zoom_func.h" #include "sdl2_v.h" #include #ifdef __EMSCRIPTEN__ @@ -524,6 +525,27 @@ bool VideoDriver_SDL_Base::PollEvent() } break; } + + case SDL_CONTROLLERDEVICEADDED: { + Debug(driver, 1, "SDL2: Gamepad device added, index: {}", ev.cdevice.which); + /* Try to open the newly connected gamepad */ + if (this->gamepad == nullptr) { + this->OpenGamepad(); + } + break; + } + + case SDL_CONTROLLERDEVICEREMOVED: { + Debug(driver, 1, "SDL2: Gamepad device removed, instance ID: {}", ev.cdevice.which); + /* Close gamepad if it was removed */ + if (this->gamepad != nullptr && ev.cdevice.which == SDL_JoystickInstanceID(SDL_GameControllerGetJoystick(this->gamepad))) { + Debug(driver, 1, "SDL2: Current gamepad was removed, closing and reopening"); + this->CloseGamepad(); + /* Try to open another gamepad if available */ + this->OpenGamepad(); + } + break; + } } return true; @@ -539,6 +561,12 @@ static std::optional InitializeSDL() #endif if (SDL_InitSubSystem(SDL_INIT_VIDEO) < 0) return SDL_GetError(); + + /* Initialize gamepad subsystem for gamepad scrolling support */ + if (SDL_InitSubSystem(SDL_INIT_GAMECONTROLLER) < 0) { + Debug(driver, 1, "SDL2: Failed to initialize gamepad subsystem: {}", SDL_GetError()); + } + return std::nullopt; } @@ -590,6 +618,11 @@ std::optional VideoDriver_SDL_Base::Start(const StringList &pa SDL_StopTextInput(); this->edit_box_focused = false; + /* Initialize gamepad for scrolling */ + Debug(driver, 1, "SDL2: Attempting to initialize gamepad support"); + Debug(driver, 1, "SDL2: Gamepad stick selection setting: {}", _settings_client.gui.gamepad_stick_selection); + this->OpenGamepad(); + #ifdef __EMSCRIPTEN__ this->is_game_threaded = false; #else @@ -601,6 +634,8 @@ std::optional VideoDriver_SDL_Base::Start(const StringList &pa void VideoDriver_SDL_Base::Stop() { + this->CloseGamepad(); + SDL_QuitSubSystem(SDL_INIT_GAMECONTROLLER); SDL_QuitSubSystem(SDL_INIT_VIDEO); if (SDL_WasInit(SDL_INIT_EVERYTHING) == 0) { SDL_Quit(); // If there's nothing left, quit SDL @@ -629,6 +664,9 @@ void VideoDriver_SDL_Base::InputLoop() (keys[SDL_SCANCODE_DOWN] ? 8 : 0); if (old_ctrl_pressed != _ctrl_pressed) HandleCtrlChanged(); + + /* Process gamepad input for scrolling */ + this->ProcessGamepadInput(); } void VideoDriver_SDL_Base::LoopOnce() @@ -755,3 +793,84 @@ void VideoDriver_SDL_Base::UnlockVideoBuffer() this->buffer_locked = false; } + +void VideoDriver_SDL_Base::OpenGamepad() +{ + /* Don't open gamepad if already open or if gamepad scrolling is disabled */ + if (this->gamepad != nullptr) { + Debug(driver, 1, "SDL2: Gamepad already open, skipping"); + return; + } + + if (_settings_client.gui.gamepad_stick_selection == GSS_DISABLED) { + Debug(driver, 1, "SDL2: Gamepad scrolling disabled, not opening gamepad"); + return; + } + + /* Check if any gamepads are available */ + int num_gamepads = SDL_NumJoysticks(); + Debug(driver, 1, "SDL2: Found {} joystick(s)", num_gamepads); + + for (int i = 0; i < num_gamepads; i++) { + if (SDL_IsGameController(i)) { + Debug(driver, 1, "SDL2: Joystick {} is a gamepad, attempting to open", i); + this->gamepad = SDL_GameControllerOpen(i); + if (this->gamepad != nullptr) { + Debug(driver, 1, "SDL2: Opened gamepad: {}", SDL_GameControllerName(this->gamepad)); + break; + } else { + Debug(driver, 1, "SDL2: Failed to open gamepad {}: {}", i, SDL_GetError()); + } + } else { + Debug(driver, 1, "SDL2: Joystick {} is not a gamepad", i); + } + } + + if (this->gamepad == nullptr) { + Debug(driver, 1, "SDL2: No gamepad opened"); + } +} + +void VideoDriver_SDL_Base::CloseGamepad() +{ + if (this->gamepad != nullptr) { + SDL_GameControllerClose(this->gamepad); + this->gamepad = nullptr; + Debug(driver, 1, "SDL2: Closed gamepad"); + } +} + +void VideoDriver_SDL_Base::ProcessGamepadInput() +{ + /* Skip if gamepad is not available */ + if (this->gamepad == nullptr) { + static bool logged_no_gamepad = false; + if (!logged_no_gamepad) { + Debug(driver, 2, "SDL2: No gamepad available for input processing"); + logged_no_gamepad = true; + } + return; + } + + /* Check if gamepad is still connected */ + if (!SDL_GameControllerGetAttached(this->gamepad)) { + Debug(driver, 1, "SDL2: Gamepad disconnected, closing and reopening"); + this->CloseGamepad(); + return; + } + + /* Get analog stick values based on stick selection */ + Sint16 stick_x = 0, stick_y = 0; + if (_settings_client.gui.gamepad_stick_selection == GSS_LEFT_STICK) { + stick_x = SDL_GameControllerGetAxis(this->gamepad, SDL_CONTROLLER_AXIS_LEFTX); + stick_y = SDL_GameControllerGetAxis(this->gamepad, SDL_CONTROLLER_AXIS_LEFTY); + Debug(driver, 3, "SDL2: Left stick raw values: x={}, y={}", stick_x, stick_y); + } else if (_settings_client.gui.gamepad_stick_selection == GSS_RIGHT_STICK) { + stick_x = SDL_GameControllerGetAxis(this->gamepad, SDL_CONTROLLER_AXIS_RIGHTX); + stick_y = SDL_GameControllerGetAxis(this->gamepad, SDL_CONTROLLER_AXIS_RIGHTY); + Debug(driver, 3, "SDL2: Right stick raw values: x={}, y={}", stick_x, stick_y); + } + + /* Use the common gamepad handling function */ + HandleGamepadScrolling(stick_x, stick_y, 32767); +} diff --git a/src/video/sdl2_v.h b/src/video/sdl2_v.h index 360c783612..6098270aeb 100644 --- a/src/video/sdl2_v.h +++ b/src/video/sdl2_v.h @@ -14,6 +14,10 @@ #include "video_driver.hpp" +/* Forward declaration of SDL_GameController */ +struct _SDL_GameController; +typedef struct _SDL_GameController SDL_GameController; + /** The SDL video driver. */ class VideoDriver_SDL_Base : public VideoDriver { public: @@ -69,6 +73,13 @@ protected: /** Create the main window. */ virtual bool CreateMainWindow(uint w, uint h, uint flags = 0); +protected: + /** Gamepad support for map scrolling */ + SDL_GameController *gamepad = nullptr; ///< Currently opened gamepad. + void OpenGamepad(); + void CloseGamepad(); + void ProcessGamepadInput(); + private: void LoopOnce(); void MainLoopCleanup(); diff --git a/src/video/win32_v.cpp b/src/video/win32_v.cpp index 798a2dc475..70df9a007e 100644 --- a/src/video/win32_v.cpp +++ b/src/video/win32_v.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #if defined(_MSC_VER) && defined(NTDDI_WIN10_RS4) #include #endif @@ -950,6 +951,9 @@ void VideoDriver_Win32Base::InputLoop() } if (old_ctrl_pressed != _ctrl_pressed) HandleCtrlChanged(); + + /* Process gamepad input for scrolling */ + this->ProcessGamepadInput(); } bool VideoDriver_Win32Base::PollEvent() @@ -1109,6 +1113,106 @@ void VideoDriver_Win32Base::UnlockVideoBuffer() this->buffer_locked = false; } +void VideoDriver_Win32Base::OpenGamepad() +{ + /* Don't open gamepad if already open or if gamepad scrolling is disabled */ + if (this->gamepad_user_index != XUSER_MAX_COUNT) { + Debug(driver, 1, "Win32: Gamepad already open, skipping"); + return; + } + + if (_settings_client.gui.gamepad_stick_selection == GSS_DISABLED) { + Debug(driver, 1, "Win32: Gamepad scrolling disabled, not opening gamepad"); + return; + } + + /* Check for any connected gamepads */ + for (DWORD i = 0; i < XUSER_MAX_COUNT; i++) { + XINPUT_STATE state = {}; + + if (XInputGetState(i, &state) == ERROR_SUCCESS) { + this->gamepad_user_index = i; + Debug(driver, 1, "Win32: Opened gamepad at index {}", i); + return; + } + } +} + +void VideoDriver_Win32Base::CloseGamepad() +{ + if (this->gamepad_user_index != XUSER_MAX_COUNT) { + this->gamepad_user_index = XUSER_MAX_COUNT; + Debug(driver, 1, "Win32: Closed gamepad"); + } +} + +void VideoDriver_Win32Base::ProcessGamepadInput() +{ + /* Skip if gamepad scrolling is disabled */ + if (_settings_client.gui.gamepad_stick_selection == GSS_DISABLED) { + return; + } + + /* If no gamepad is currently open, try to reconnect periodically */ + if (this->gamepad_user_index == XUSER_MAX_COUNT) { + static bool logged_no_gamepad = false; + + /* Only try to reconnect every 60 frames (~1 second at 60 FPS) to avoid spam */ + if (this->gamepad_reconnect_timer > 0) { + this->gamepad_reconnect_timer--; + if (!logged_no_gamepad) { + Debug(driver, 2, "Win32: No gamepad available for input processing"); + logged_no_gamepad = true; + } + return; + } + + /* Try to open gamepad */ + this->OpenGamepad(); + + /* If still no gamepad, set timer for next retry */ + if (this->gamepad_user_index == XUSER_MAX_COUNT) { + this->gamepad_reconnect_timer = 60; /* Retry in ~1 second */ + if (!logged_no_gamepad) { + Debug(driver, 2, "Win32: No gamepad available for input processing"); + logged_no_gamepad = true; + } + return; + } else { + /* Successfully reconnected */ + logged_no_gamepad = false; + } + } + + /* Get gamepad state */ + XINPUT_STATE state = {}; + + if (XInputGetState(this->gamepad_user_index, &state) != ERROR_SUCCESS) { + Debug(driver, 1, "Win32: Gamepad disconnected, closing and will retry connection"); + this->CloseGamepad(); + this->gamepad_reconnect_timer = 60; /* Start retry timer */ + return; + } + + /* Get analog stick values based on stick selection + * Note: XInput uses SHORT values for stick positions, but we have to extend to INT + * to avoid overflow when inverting the Y-axis value */ + INT stick_x = 0, stick_y = 0; + if (_settings_client.gui.gamepad_stick_selection == GSS_LEFT_STICK) { + stick_x = state.Gamepad.sThumbLX; + stick_y = state.Gamepad.sThumbLY; + Debug(driver, 3, "Win32: Left stick raw values: x={}, y={}", stick_x, stick_y); + } else if (_settings_client.gui.gamepad_stick_selection == GSS_RIGHT_STICK) { + stick_x = state.Gamepad.sThumbRX; + stick_y = state.Gamepad.sThumbRY; + Debug(driver, 3, "Win32: Right stick raw values: x={}, y={}", stick_x, stick_y); + } + stick_y = -stick_y; // Xinput Y-axis is inverted from other libraries + + /* Use the common gamepad handling function */ + HandleGamepadScrolling(stick_x, stick_y, 32767); +} + static FVideoDriver_Win32GDI iFVideoDriver_Win32GDI; @@ -1124,6 +1228,11 @@ std::optional VideoDriver_Win32GDI::Start(const StringList &pa MarkWholeScreenDirty(); + /* Initialize gamepad for scrolling */ + Debug(driver, 1, "Win32: Attempting to initialize gamepad support"); + Debug(driver, 1, "Win32: Gamepad stick selection setting: {}", _settings_client.gui.gamepad_stick_selection); + this->OpenGamepad(); + this->is_game_threaded = !GetDriverParamBool(param, "no_threads") && !GetDriverParamBool(param, "no_thread"); return std::nullopt; @@ -1131,6 +1240,7 @@ std::optional VideoDriver_Win32GDI::Start(const StringList &pa void VideoDriver_Win32GDI::Stop() { + this->CloseGamepad(); DeleteObject(this->gdi_palette); DeleteObject(this->dib_sect); @@ -1438,6 +1548,11 @@ std::optional VideoDriver_Win32OpenGL::Start(const StringList MarkWholeScreenDirty(); + /* Initialize gamepad for scrolling */ + Debug(driver, 1, "Win32: Attempting to initialize gamepad support"); + Debug(driver, 1, "Win32: Gamepad stick selection setting: {}", _settings_client.gui.gamepad_stick_selection); + this->OpenGamepad(); + this->is_game_threaded = !GetDriverParamBool(param, "no_threads") && !GetDriverParamBool(param, "no_thread"); return std::nullopt; @@ -1445,6 +1560,7 @@ std::optional VideoDriver_Win32OpenGL::Start(const StringList void VideoDriver_Win32OpenGL::Stop() { + this->CloseGamepad(); this->DestroyContext(); this->VideoDriver_Win32Base::Stop(); } diff --git a/src/video/win32_v.h b/src/video/win32_v.h index 9b2cb8535e..b806aeeadf 100644 --- a/src/video/win32_v.h +++ b/src/video/win32_v.h @@ -14,6 +14,7 @@ #include #include #include +#include /** Base class for Windows video drivers. */ class VideoDriver_Win32Base : public VideoDriver { @@ -48,6 +49,13 @@ protected: bool buffer_locked; ///< Video buffer was locked by the main thread. + /** Gamepad support for map scrolling */ + DWORD gamepad_user_index = XUSER_MAX_COUNT; ///< Index of currently opened gamepad (XUSER_MAX_COUNT = no gamepad). + uint32_t gamepad_reconnect_timer = 0; ///< Timer for retrying gamepad connection after disconnect. + void OpenGamepad(); + void CloseGamepad(); + void ProcessGamepadInput(); + Dimension GetScreenSize() const override; float GetDPIScale() override; void InputLoop() override; diff --git a/src/window.cpp b/src/window.cpp index 6ae18eda28..0a1e223f9c 100644 --- a/src/window.cpp +++ b/src/window.cpp @@ -3572,3 +3572,81 @@ void PickerWindowBase::Close([[maybe_unused]] int data) ResetObjectToPlace(); this->Window::Close(); } + +/** + * Process gamepad analog stick input for viewport scrolling. + * This is a common function that can be used by any video driver that supports gamepads. + * @param stick_x Raw analog stick X value (typically -32768 to 32767 range) + * @param stick_y Raw analog stick Y value (typically -32768 to 32767 range) + * @param max_axis_value Maximum value for the analog stick axes (e.g., 32767 for SDL2) + */ +void HandleGamepadScrolling(int stick_x, int stick_y, int max_axis_value) +{ + /* Skip if gamepad stick selection is disabled */ + if (_settings_client.gui.gamepad_stick_selection == GSS_DISABLED) { + return; + } + + /* Apply deadzone (convert percentage to axis range) */ + const int deadzone = (_settings_client.gui.gamepad_deadzone * max_axis_value) / 100; + + if (abs(stick_x) < deadzone) stick_x = 0; + if (abs(stick_y) < deadzone) stick_y = 0; + + /* Skip if no movement after deadzone */ + if (stick_x == 0 && stick_y == 0) { + return; + } + + /* Calculate scroll delta with sensitivity */ + float sensitivity = _settings_client.gui.gamepad_sensitivity / 10.0f; + int delta_x = (int)(stick_x * sensitivity / (max_axis_value / 16)); // Scale down from axis range + int delta_y = (int)(stick_y * sensitivity / (max_axis_value / 16)); + + /* Apply axis inversion */ + if (_settings_client.gui.gamepad_invert_x) delta_x = -delta_x; + if (_settings_client.gui.gamepad_invert_y) delta_y = -delta_y; + + /* Skip if deltas are too small */ + if (abs(delta_x) < 1 && abs(delta_y) < 1) { + return; + } + + /* Apply scrolling based on cursor position */ + if (_game_mode != GM_MENU && _game_mode != GM_BOOTSTRAP) { + Window *target_window = nullptr; + + /* Check if cursor is over a window with a viewport */ + Window *w = FindWindowFromPt(_cursor.pos.x, _cursor.pos.y); + if (w != nullptr && w->viewport != nullptr) { + /* Check if cursor is actually over the viewport area within the window */ + Point pt = { _cursor.pos.x - w->left, _cursor.pos.y - w->top }; + if (pt.x >= w->viewport->left - w->left && + pt.x < w->viewport->left - w->left + w->viewport->width && + pt.y >= w->viewport->top - w->top && + pt.y < w->viewport->top - w->top + w->viewport->height) { + target_window = w; + } + } + + /* If no viewport under cursor, use main window */ + if (target_window == nullptr) { + target_window = GetMainWindow(); + } + + /* Apply scrolling to the target viewport */ + if (target_window != nullptr && target_window->viewport != nullptr) { + /* Check if the viewport is following a vehicle (similar to mouse scroll behavior) */ + if (target_window == GetMainWindow() && target_window->viewport->follow_vehicle != VehicleID::Invalid()) { + /* If following a vehicle, center on it and stop following (like mouse scroll) */ + const Vehicle *veh = Vehicle::Get(target_window->viewport->follow_vehicle); + ScrollMainWindowTo(veh->x_pos, veh->y_pos, veh->z_pos, true); // This also resets follow_vehicle + return; // Don't apply gamepad scroll, just like mouse scroll returns ES_NOT_HANDLED + } + + /* Apply the scroll using the same method as keyboard scrolling */ + target_window->viewport->dest_scrollpos_x += ScaleByZoom(delta_x, target_window->viewport->zoom); + target_window->viewport->dest_scrollpos_y += ScaleByZoom(delta_y, target_window->viewport->zoom); + } + } +}