From 6a57b80448e1e5ecc45f89a09fa7f755136998e8 Mon Sep 17 00:00:00 2001 From: rapidsamphire Date: Fri, 31 Oct 2025 01:33:47 -0500 Subject: [PATCH] Add controller rumble support and Lua bindings --- apps/launcher/settingspage.cpp | 2 + apps/launcher/ui/settingspage.ui | 22 ++- apps/openmw/mwbase/inputmanager.hpp | 4 + apps/openmw/mwclass/creature.cpp | 4 + apps/openmw/mwclass/npc.cpp | 10 ++ apps/openmw/mwinput/controllermanager.cpp | 145 ++++++++++++++++++ apps/openmw/mwinput/controllermanager.hpp | 20 +++ apps/openmw/mwinput/inputmanagerimp.cpp | 18 +++ apps/openmw/mwinput/inputmanagerimp.hpp | 4 + apps/openmw/mwlua/inputbindings.cpp | 42 +++++ apps/openmw/mwmechanics/combat.cpp | 6 + apps/openmw/mwmechanics/rumble.hpp | 126 +++++++++++++++ apps/openmw/mwmechanics/spellcasting.cpp | 15 ++ components/settings/categories/input.hpp | 3 + .../reference/modding/settings/input.rst | 18 +++ .../scripts/omw/input/gamepadcontrols.lua | 23 ++- files/lua_api/openmw/input.lua | 19 +++ files/settings-default.cfg | 6 + 18 files changed, 480 insertions(+), 7 deletions(-) create mode 100644 apps/openmw/mwmechanics/rumble.hpp diff --git a/apps/launcher/settingspage.cpp b/apps/launcher/settingspage.cpp index 0f42825b0a1..0816fc0ebee 100644 --- a/apps/launcher/settingspage.cpp +++ b/apps/launcher/settingspage.cpp @@ -305,6 +305,7 @@ bool Launcher::SettingsPage::loadSettings() loadSettingBool(Settings::gui().mStretchMenuBackground, *stretchBackgroundCheckBox); connect(controllerMenusCheckBox, &QCheckBox::toggled, this, &SettingsPage::slotControllerMenusToggled); loadSettingBool(Settings::gui().mControllerMenus, *controllerMenusCheckBox); + loadSettingBool(Settings::input().mEnableControllerRumble, *controllerRumbleCheckBox); loadSettingBool(Settings::gui().mControllerTooltips, *controllerMenuTooltipsCheckBox); loadSettingBool(Settings::map().mAllowZooming, *useZoomOnMapCheckBox); loadSettingBool(Settings::game().mGraphicHerbalism, *graphicHerbalismCheckBox); @@ -500,6 +501,7 @@ void Launcher::SettingsPage::saveSettings() saveSettingInt(*showOwnedComboBox, Settings::game().mShowOwned); saveSettingBool(*stretchBackgroundCheckBox, Settings::gui().mStretchMenuBackground); saveSettingBool(*controllerMenusCheckBox, Settings::gui().mControllerMenus); + saveSettingBool(*controllerRumbleCheckBox, Settings::input().mEnableControllerRumble); saveSettingBool(*controllerMenuTooltipsCheckBox, Settings::gui().mControllerTooltips); saveSettingBool(*useZoomOnMapCheckBox, Settings::map().mAllowZooming); saveSettingBool(*graphicHerbalismCheckBox, Settings::game().mGraphicHerbalism); diff --git a/apps/launcher/ui/settingspage.ui b/apps/launcher/ui/settingspage.ui index 92a9337e4d2..cbdea2f8e24 100644 --- a/apps/launcher/ui/settingspage.ui +++ b/apps/launcher/ui/settingspage.ui @@ -1412,12 +1412,22 @@ Enable Controller Menus - - - - - false - + + + + + <html><head/><body><p>Enable haptic feedback for controllers that support rumble.</p></body></html> + + + Enable Controller Rumble + + + + + + + false + <html><head/><body><p>When using controller menus, make tooltips visible by default.</p></body></html> diff --git a/apps/openmw/mwbase/inputmanager.hpp b/apps/openmw/mwbase/inputmanager.hpp index 2861ab88e99..68676025055 100644 --- a/apps/openmw/mwbase/inputmanager.hpp +++ b/apps/openmw/mwbase/inputmanager.hpp @@ -64,6 +64,10 @@ namespace MWBase virtual float getActionValue(int action) const = 0; // returns value in range [0, 1] virtual bool isControllerButtonPressed(SDL_GameControllerButton button) const = 0; virtual float getControllerAxisValue(SDL_GameControllerAxis axis) const = 0; // returns value in range [-1, 1] + virtual bool controllerHasRumble() const = 0; + virtual void playControllerRumble(float lowFrequencyStrength, float highFrequencyStrength, + float durationSeconds) = 0; + virtual void stopControllerRumble() = 0; virtual int getMouseMoveX() const = 0; virtual int getMouseMoveY() const = 0; virtual void warpMouseToWidget(MyGUI::Widget* widget) = 0; diff --git a/apps/openmw/mwclass/creature.cpp b/apps/openmw/mwclass/creature.cpp index e847ba9d0e7..97f94407d2e 100644 --- a/apps/openmw/mwclass/creature.cpp +++ b/apps/openmw/mwclass/creature.cpp @@ -22,6 +22,7 @@ #include "../mwmechanics/magiceffects.hpp" #include "../mwmechanics/movement.hpp" #include "../mwmechanics/npcstats.hpp" +#include "../mwmechanics/rumble.hpp" #include "../mwmechanics/setbaseaisetting.hpp" #include "../mwbase/environment.hpp" @@ -453,6 +454,9 @@ namespace MWClass stats.setHitRecovery(true); // Is this supposed to always occur? } } + + if (!attacker.isEmpty() && attacker == MWMechanics::getPlayer() && hasHealthDamage && healthDamage > 0.0f) + MWMechanics::Rumble::onPlayerDealtDamage(healthDamage); } std::unique_ptr Creature::activate(const MWWorld::Ptr& ptr, const MWWorld::Ptr& actor) const diff --git a/apps/openmw/mwclass/npc.cpp b/apps/openmw/mwclass/npc.cpp index 3295c6586c0..700f9c8b3ea 100644 --- a/apps/openmw/mwclass/npc.cpp +++ b/apps/openmw/mwclass/npc.cpp @@ -42,6 +42,7 @@ #include "../mwmechanics/inventory.hpp" #include "../mwmechanics/movement.hpp" #include "../mwmechanics/npcstats.hpp" +#include "../mwmechanics/rumble.hpp" #include "../mwmechanics/setbaseaisetting.hpp" #include "../mwmechanics/spellcasting.hpp" #include "../mwmechanics/weapontype.hpp" @@ -817,7 +818,16 @@ namespace MWClass if (hasHealthDamage && healthDamage > 0.0f) { if (ptr == MWMechanics::getPlayer()) + { MWBase::Environment::get().getWindowManager()->activateHitOverlay(); + MWMechanics::Rumble::onPlayerDamaged(ptr, healthDamage); + } + } + + if (!attacker.isEmpty() && attacker == MWMechanics::getPlayer() && hasHealthDamage && healthDamage > 0.0f + && ptr != MWMechanics::getPlayer()) + { + MWMechanics::Rumble::onPlayerDealtDamage(healthDamage); } if (!wasDead && getCreatureStats(ptr).isDead()) diff --git a/apps/openmw/mwinput/controllermanager.cpp b/apps/openmw/mwinput/controllermanager.cpp index c1f73ae7c94..ec13c2f9305 100644 --- a/apps/openmw/mwinput/controllermanager.cpp +++ b/apps/openmw/mwinput/controllermanager.cpp @@ -5,6 +5,9 @@ #include +#include +#include + #include #include #include @@ -33,6 +36,9 @@ namespace MWInput , mGuiCursorEnabled(true) , mJoystickLastUsed(false) , mGamepadMousePressed(false) + , mActiveController(nullptr) + , mControllerHasRumble(false) + , mRumbleState() { if (!controllerBindingsFile.empty()) { @@ -80,10 +86,15 @@ namespace MWInput } mBindingsManager->setJoystickDeadZone(Settings::input().mJoystickDeadZone); + + refreshActiveController(); } void ControllerManager::update(float dt) { + refreshActiveController(); + updateRumble(dt); + if (mGuiCursorEnabled && !(mJoystickLastUsed && !mGamepadGuiCursorEnabled)) { float xAxis = mBindingsManager->getActionValue(A_MoveLeftRight) * 2.0f - 1.0f; @@ -243,11 +254,145 @@ namespace MWInput { mBindingsManager->controllerAdded(deviceID, arg); enableGyroSensor(); + refreshActiveController(); } void ControllerManager::controllerRemoved(const SDL_ControllerDeviceEvent& arg) { mBindingsManager->controllerRemoved(arg); + refreshActiveController(); + } + + void ControllerManager::refreshActiveController() + { + SDL_GameController* controller = mBindingsManager->getControllerOrNull(); + if (controller == mActiveController) + return; + + if (mActiveController != nullptr) + { + stopRumbleInternal(); + clearRumbleState(); + } + + mActiveController = controller; +#if SDL_VERSION_ATLEAST(2, 0, 9) + if (mActiveController) + mControllerHasRumble = SDL_GameControllerHasRumble(mActiveController) == SDL_TRUE; + else + mControllerHasRumble = false; +#else + mControllerHasRumble = false; +#endif + } + + void ControllerManager::playRumble( + float lowFrequencyStrength, float highFrequencyStrength, float durationSeconds) + { + refreshActiveController(); + + if (!Settings::input().mEnableController || !Settings::input().mEnableControllerRumble) + return; + + if (!mActiveController || !mControllerHasRumble) + return; + + durationSeconds = std::max(durationSeconds, 0.0f); + if (durationSeconds == 0.0f) + { + stopRumbleInternal(); + clearRumbleState(); + return; + } + + const float strengthScale = Settings::input().mControllerRumbleStrength; + const float lowScaled = std::clamp(lowFrequencyStrength, 0.0f, 1.0f) * strengthScale; + const float highScaled = std::clamp(highFrequencyStrength, 0.0f, 1.0f) * strengthScale; + + if (lowScaled <= 0.0f && highScaled <= 0.0f) + { + stopRumbleInternal(); + clearRumbleState(); + return; + } + + const float limitedDuration = std::min(durationSeconds, 30.0f); + const Uint32 durationMs = static_cast(std::round(limitedDuration * 1000.0f)); + if (durationMs == 0) + { + stopRumbleInternal(); + clearRumbleState(); + return; + } + + const Uint16 lowAmplitude = static_cast(std::round(std::clamp(lowScaled, 0.0f, 1.0f) * 65535.0f)); + const Uint16 highAmplitude = static_cast(std::round(std::clamp(highScaled, 0.0f, 1.0f) * 65535.0f)); +#if SDL_VERSION_ATLEAST(2, 0, 9) + if (SDL_GameControllerRumble(mActiveController, lowAmplitude, highAmplitude, durationMs) != 0) + { + Log(Debug::Warning) << "Failed to start controller rumble: " << SDL_GetError(); + clearRumbleState(); + return; + } +#else + Log(Debug::Warning) << "Controller rumble requested but SDL version does not support it"; + clearRumbleState(); + return; +#endif + + mRumbleState.mActive = true; + mRumbleState.mLowStrength = lowScaled; + mRumbleState.mHighStrength = highScaled; + mRumbleState.mRemainingTime = limitedDuration; + } + + void ControllerManager::stopRumble() + { + refreshActiveController(); + stopRumbleInternal(); + clearRumbleState(); + } + + void ControllerManager::updateRumble(float dt) + { + if (!mRumbleState.mActive) + return; + + if (!Settings::input().mEnableController || !Settings::input().mEnableControllerRumble || !mActiveController + || !mControllerHasRumble) + { + stopRumbleInternal(); + clearRumbleState(); + return; + } + + if (dt > 0.0f) + { + mRumbleState.mRemainingTime = std::max(0.0f, mRumbleState.mRemainingTime - dt); + if (mRumbleState.mRemainingTime <= 0.0f) + { + stopRumbleInternal(); + clearRumbleState(); + } + } + } + + void ControllerManager::stopRumbleInternal() + { + if (!mActiveController) + return; + +#if SDL_VERSION_ATLEAST(2, 0, 9) + if (SDL_GameControllerRumble(mActiveController, 0, 0, 0) != 0) + Log(Debug::Warning) << "Failed to stop controller rumble: " << SDL_GetError(); +#else + SDL_GameControllerRumble(mActiveController, 0, 0, 0); +#endif + } + + void ControllerManager::clearRumbleState() + { + mRumbleState = {}; } bool ControllerManager::gamepadToGuiControl(const SDL_ControllerButtonEvent& arg) diff --git a/apps/openmw/mwinput/controllermanager.hpp b/apps/openmw/mwinput/controllermanager.hpp index 535ee85fd59..0028dcbc8a2 100644 --- a/apps/openmw/mwinput/controllermanager.hpp +++ b/apps/openmw/mwinput/controllermanager.hpp @@ -5,6 +5,8 @@ #include #include +#include + #include #include @@ -37,6 +39,10 @@ namespace MWInput void setJoystickLastUsed(bool enabled) { mJoystickLastUsed = enabled; } bool joystickLastUsed() const { return mJoystickLastUsed; } + bool controllerHasRumble() const { return mControllerHasRumble; } + void playRumble(float lowFrequencyStrength, float highFrequencyStrength, float durationSeconds); + void stopRumble(); + void setGuiCursorEnabled(bool enabled) { mGuiCursorEnabled = enabled; } void setGamepadGuiCursorEnabled(bool enabled) { mGamepadGuiCursorEnabled = enabled; } @@ -59,6 +65,10 @@ namespace MWInput void enableGyroSensor(); int getControllerType(); + void refreshActiveController(); + void updateRumble(float dt); + void stopRumbleInternal(); + void clearRumbleState(); BindingsManager* mBindingsManager; MouseManager* mMouseManager; @@ -68,6 +78,16 @@ namespace MWInput bool mGuiCursorEnabled; bool mJoystickLastUsed; bool mGamepadMousePressed; + SDL_GameController* mActiveController; + bool mControllerHasRumble; + struct RumbleState + { + float mRemainingTime = 0.f; + float mLowStrength = 0.f; + float mHighStrength = 0.f; + bool mActive = false; + }; + RumbleState mRumbleState; }; } #endif diff --git a/apps/openmw/mwinput/inputmanagerimp.cpp b/apps/openmw/mwinput/inputmanagerimp.cpp index 250b25aeaaa..fadef413551 100644 --- a/apps/openmw/mwinput/inputmanagerimp.cpp +++ b/apps/openmw/mwinput/inputmanagerimp.cpp @@ -64,6 +64,7 @@ namespace MWInput if (disableControls) { + mControllerManager->update(0.f); mMouseManager->updateCursorMode(); return; } @@ -179,6 +180,23 @@ namespace MWInput return mControllerManager->getAxisValue(axis); } + bool InputManager::controllerHasRumble() const + { + return Settings::input().mEnableController && Settings::input().mEnableControllerRumble + && mControllerManager->controllerHasRumble(); + } + + void InputManager::playControllerRumble( + float lowFrequencyStrength, float highFrequencyStrength, float durationSeconds) + { + mControllerManager->playRumble(lowFrequencyStrength, highFrequencyStrength, durationSeconds); + } + + void InputManager::stopControllerRumble() + { + mControllerManager->stopRumble(); + } + int InputManager::getMouseMoveX() const { return mMouseManager->getMouseMoveX(); diff --git a/apps/openmw/mwinput/inputmanagerimp.hpp b/apps/openmw/mwinput/inputmanagerimp.hpp index 3e964ae2a8b..675cf971e4f 100644 --- a/apps/openmw/mwinput/inputmanagerimp.hpp +++ b/apps/openmw/mwinput/inputmanagerimp.hpp @@ -79,6 +79,10 @@ namespace MWInput float getActionValue(int action) const override; bool isControllerButtonPressed(SDL_GameControllerButton button) const override; float getControllerAxisValue(SDL_GameControllerAxis axis) const override; + bool controllerHasRumble() const override; + void playControllerRumble(float lowFrequencyStrength, float highFrequencyStrength, + float durationSeconds) override; + void stopControllerRumble() override; int getMouseMoveX() const override; int getMouseMoveY() const override; void warpMouseToWidget(MyGUI::Widget* widget) override; diff --git a/apps/openmw/mwlua/inputbindings.cpp b/apps/openmw/mwlua/inputbindings.cpp index 167d234f0ea..d3439343b52 100644 --- a/apps/openmw/mwlua/inputbindings.cpp +++ b/apps/openmw/mwlua/inputbindings.cpp @@ -9,6 +9,9 @@ #include #include +#include +#include + #include "../mwbase/environment.hpp" #include "../mwbase/inputmanager.hpp" #include "../mwbase/windowmanager.hpp" @@ -202,6 +205,45 @@ namespace MWLua api["getRangeActionValue"] = [manager = context.mLuaManager](std::string_view key) { return manager->inputActions().valueOfType(key, LuaUtil::InputAction::Type::Range); }; + api["controllerHasRumble"] = [input]() { return input->controllerHasRumble(); }; + api["controllerStartRumble"] = [input](const sol::object& options) { + if (!options.valid() || options.is()) + throw std::domain_error("controllerStartRumble expects an options table"); + if (!options.is()) + throw std::domain_error("controllerStartRumble expects an options table"); + + sol::table params = options.as(); + const auto extractOptional = [](const sol::table& table, const char* key) -> std::optional { + sol::object value = table[key]; + if (!value.valid() || value.is()) + return std::nullopt; + return value.as(); + }; + + const std::optional duration = extractOptional(params, "duration"); + if (!duration.has_value()) + throw std::domain_error("controllerStartRumble requires options.duration"); + if (*duration <= 0.0f) + throw std::domain_error("controllerStartRumble options.duration must be positive"); + + const std::optional strength = extractOptional(params, "strength"); + const std::optional low = extractOptional(params, "low"); + const std::optional high = extractOptional(params, "high"); + if (!strength.has_value() && !low.has_value() && !high.has_value()) + throw std::domain_error("controllerStartRumble requires strength, low, or high"); + + const float lowStrength = low.value_or(strength.value_or(0.0f)); + float highStrength = high.value_or(lowStrength); + if (strength.has_value() && !high.has_value()) + highStrength = *strength; + + if (!input->controllerHasRumble()) + return false; + + input->playControllerRumble(lowStrength, highStrength, *duration); + return true; + }; + api["controllerStopRumble"] = [input]() { input->stopControllerRumble(); }; api["triggers"] = std::ref(context.mLuaManager->inputTriggers()); api["registerTrigger"] diff --git a/apps/openmw/mwmechanics/combat.cpp b/apps/openmw/mwmechanics/combat.cpp index 7c0c6749868..51e14612601 100644 --- a/apps/openmw/mwmechanics/combat.cpp +++ b/apps/openmw/mwmechanics/combat.cpp @@ -28,6 +28,7 @@ #include "movement.hpp" #include "npcstats.hpp" #include "pathfinding.hpp" +#include "rumble.hpp" #include "spellcasting.hpp" #include "spellresistance.hpp" @@ -145,6 +146,11 @@ namespace MWMechanics else if (skill == ESM::Skill::HeavyArmor) sndMgr->playSound3D(blocker, ESM::RefId::stringRefId("Heavy Armor Hit"), 1.0f, 1.0f); + if (blocker == MWMechanics::getPlayer()) + MWMechanics::Rumble::onPlayerBlock(attackStrength, damage); + if (attacker == MWMechanics::getPlayer()) + MWMechanics::Rumble::onPlayerAttackWasBlocked(damage); + // Reduce shield durability by incoming damage int shieldhealth = shield->getClass().getItemHealth(*shield); diff --git a/apps/openmw/mwmechanics/rumble.hpp b/apps/openmw/mwmechanics/rumble.hpp new file mode 100644 index 00000000000..67177c1e805 --- /dev/null +++ b/apps/openmw/mwmechanics/rumble.hpp @@ -0,0 +1,126 @@ +#ifndef OPENMW_MWMECHANICS_RUMBLE_HPP +#define OPENMW_MWMECHANICS_RUMBLE_HPP + +#include + +#include "../mwbase/environment.hpp" +#include "../mwbase/inputmanager.hpp" + +#include "../mwworld/class.hpp" +#include "../mwworld/ptr.hpp" + +#include "actorutil.hpp" +#include "creaturestats.hpp" + +namespace MWMechanics::Rumble +{ + inline MWBase::InputManager* getInputManager() + { + return MWBase::Environment::get().getInputManager(); + } + + inline bool prepare(MWBase::InputManager*& inputManager) + { + inputManager = getInputManager(); + return inputManager != nullptr && inputManager->controllerHasRumble(); + } + + inline void onPlayerDamaged(const MWWorld::Ptr& player, float healthDamage) + { + if (player != MWMechanics::getPlayer() || healthDamage <= 0.f) + return; + + MWBase::InputManager* inputManager = nullptr; + if (!prepare(inputManager)) + return; + + const MWMechanics::CreatureStats& stats = player.getClass().getCreatureStats(player); + const float maxHealth = stats.getHealth().getModified(); + if (maxHealth <= 0.f) + return; + + const float normalized = std::clamp(healthDamage / maxHealth * 2.f, 0.f, 1.f); + const float low = 0.3f + 0.5f * normalized; + const float high = 0.6f + 0.4f * normalized; + const float duration = 0.25f + 0.25f * normalized; + + inputManager->playControllerRumble(low, high, duration); + } + + inline void onPlayerDealtDamage(float healthDamage) + { + if (healthDamage <= 0.f) + return; + + MWBase::InputManager* inputManager = nullptr; + if (!prepare(inputManager)) + return; + + const float normalized = std::clamp(healthDamage / 35.f, 0.f, 1.f); + const float low = 0.25f + 0.45f * normalized; + const float high = 0.35f + 0.55f * normalized; + const float duration = 0.16f + 0.12f * normalized; + + inputManager->playControllerRumble(low, high, duration); + } + + inline void onPlayerBlock(float attackStrength, float damageBlocked) + { + if (damageBlocked < 0.f) + damageBlocked = 0.f; + + MWBase::InputManager* inputManager = nullptr; + if (!prepare(inputManager)) + return; + + const float normalizedStrength = std::clamp(attackStrength, 0.f, 1.f); + const float impact = std::clamp(damageBlocked / 25.f, 0.f, 1.f); + const float low = 0.2f + 0.6f * impact; + const float high = 0.15f + 0.55f * normalizedStrength; + const float duration = 0.12f + 0.10f * std::max(impact, normalizedStrength); + + inputManager->playControllerRumble(low, high, duration); + } + + inline void onPlayerAttackWasBlocked(float damageBlocked) + { + if (damageBlocked <= 0.f) + return; + + MWBase::InputManager* inputManager = nullptr; + if (!prepare(inputManager)) + return; + + const float normalized = std::clamp(damageBlocked / 30.f, 0.f, 1.f); + const float low = 0.25f + 0.4f * normalized; + const float high = 0.3f + 0.4f * normalized; + const float duration = 0.1f + 0.08f * normalized; + + inputManager->playControllerRumble(low, high, duration); + } + + inline void onPlayerCastSpell(float baseCost, bool hasProjectileComponent) + { + if (baseCost < 0.f) + baseCost = 0.f; + + MWBase::InputManager* inputManager = nullptr; + if (!prepare(inputManager)) + return; + + const float normalizedCost = std::clamp(baseCost / 100.f, 0.f, 1.f); + float low = 0.25f + 0.45f * normalizedCost; + float high = 0.35f + 0.55f * normalizedCost; + float duration = 0.18f + 0.14f * normalizedCost; + + if (hasProjectileComponent) + { + high = std::min(1.f, high + 0.1f); + duration += 0.05f; + } + + inputManager->playControllerRumble(low, high, duration); + } +} + +#endif diff --git a/apps/openmw/mwmechanics/spellcasting.cpp b/apps/openmw/mwmechanics/spellcasting.cpp index bf9d6aa025c..7f7c6557355 100644 --- a/apps/openmw/mwmechanics/spellcasting.cpp +++ b/apps/openmw/mwmechanics/spellcasting.cpp @@ -23,6 +23,8 @@ #include "actorutil.hpp" #include "creaturestats.hpp" #include "spelleffects.hpp" +#include "spellpriority.hpp" +#include "rumble.hpp" #include "spellutil.hpp" #include "weapontype.hpp" @@ -364,6 +366,13 @@ namespace MWMechanics else if (isProjectile || !mTarget.isEmpty()) inflict(mTarget, enchantment->mEffects, ESM::RT_Target); + if (mCaster == MWMechanics::getPlayer()) + { + const bool hasProjectileComponent + = launchProjectile || isProjectile || (getRangeTypes(enchantment->mEffects) & Target) != 0; + Rumble::onPlayerCastSpell(static_cast(enchantment->mData.mCost), hasProjectileComponent); + } + return true; } @@ -440,6 +449,12 @@ namespace MWMechanics launchMagicBolt(); + if (mCaster == MWMechanics::getPlayer()) + { + const bool hasProjectileComponent = (getRangeTypes(spell->mEffects) & Target) != 0 || !mTarget.isEmpty(); + Rumble::onPlayerCastSpell(static_cast(spell->mData.mCost), hasProjectileComponent); + } + return true; } diff --git a/components/settings/categories/input.hpp b/components/settings/categories/input.hpp index 0a450f1dcd3..158e3247549 100644 --- a/components/settings/categories/input.hpp +++ b/components/settings/categories/input.hpp @@ -29,6 +29,9 @@ namespace Settings makeMaxStrictSanitizerFloat(0) }; SettingValue mJoystickDeadZone{ mIndex, "Input", "joystick dead zone", makeClampSanitizerFloat(0, 0.5f) }; + SettingValue mEnableControllerRumble{ mIndex, "Input", "enable controller rumble" }; + SettingValue mControllerRumbleStrength{ mIndex, "Input", "controller rumble strength", + makeClampSanitizerFloat(0, 1.0f) }; SettingValue mEnableGyroscope{ mIndex, "Input", "enable gyroscope" }; SettingValue mGyroHorizontalAxis{ mIndex, "Input", "gyro horizontal axis" }; SettingValue mGyroVerticalAxis{ mIndex, "Input", "gyro vertical axis" }; diff --git a/docs/source/reference/modding/settings/input.rst b/docs/source/reference/modding/settings/input.rst index bc6a4945116..06881c4a31d 100644 --- a/docs/source/reference/modding/settings/input.rst +++ b/docs/source/reference/modding/settings/input.rst @@ -100,6 +100,24 @@ Input Settings Values inside the zone are ignored. Can be set to 0.0 when using third-party dead zone tools. +.. omw-setting:: + :title: enable controller rumble + :type: boolean + :range: true, false + :default: true + + Enables haptic feedback for supported controllers. + Disable if your device lacks rumble motors or you prefer silent controllers. + +.. omw-setting:: + :title: controller rumble strength + :type: float32 + :range: 0.0 to 1.0 + :default: 1.0 + + Global multiplier applied to all rumble effects. + Lower the value for subtler feedback. + .. omw-setting:: :title: enable gyroscope :type: boolean diff --git a/files/data/scripts/omw/input/gamepadcontrols.lua b/files/data/scripts/omw/input/gamepadcontrols.lua index 594e89e6ad0..7a1396c0ecf 100644 --- a/files/data/scripts/omw/input/gamepadcontrols.lua +++ b/files/data/scripts/omw/input/gamepadcontrols.lua @@ -11,7 +11,7 @@ return { interface = { --- Interface version -- @field [parent=#GamepadControls] #number version - version = 1, + version = 2, --- Checks if the gamepad cursor is active. If it is active, the left stick can move the cursor, and A will be interpreted as a mouse click. -- @function [parent=#GamepadControls] isGamepadCursorActive @@ -33,5 +33,26 @@ return { setGamepadCursorActive = function(state) input._setGamepadCursorActive(state) end, + + --- Check if the connected controller supports rumble and the feature is enabled in settings. + -- @function [parent=#GamepadControls] hasRumble + -- @return #boolean + hasRumble = function() + return input.controllerHasRumble() + end, + + --- Trigger a rumble effect on the active controller. + -- @function [parent=#GamepadControls] startRumble + -- @param options Table passed to @{openmw.input#controllerStartRumble}. + -- @return #boolean ``true`` if rumble started, ``false`` otherwise. + startRumble = function(options) + return input.controllerStartRumble(options) + end, + + --- Stop any active controller rumble effect. + -- @function [parent=#GamepadControls] stopRumble + stopRumble = function() + input.controllerStopRumble() + end, } } diff --git a/files/lua_api/openmw/input.lua b/files/lua_api/openmw/input.lua index 0a8b04b9b29..38367870f97 100644 --- a/files/lua_api/openmw/input.lua +++ b/files/lua_api/openmw/input.lua @@ -97,6 +97,25 @@ -- @param #number axisId Index of a controller axis, one of @{openmw.input#CONTROLLER_AXIS}. -- @return #number Value in range [-1, 1]. +--- +-- Check if the current controller supports rumble and it is enabled in settings. +-- @function [parent=#input] controllerHasRumble +-- @return #boolean + +--- +-- Start a rumble effect on the active controller. +-- @function [parent=#input] controllerStartRumble +-- @param options Table of options. +-- @param options.duration #number Duration in seconds (> 0). +-- @param[opt] options.strength #number Strength applied to both motors (0.0-1.0). Can be overridden by ``options.low`` or ``options.high``. +-- @param[opt] options.low #number Low-frequency motor strength (0.0-1.0). Defaults to ``options.strength``. +-- @param[opt] options.high #number High-frequency motor strength (0.0-1.0). Defaults to ``options.low``. +-- @return #boolean ``true`` if rumble started, ``false`` if the controller can't rumble. + +--- +-- Stop any active controller rumble effect. +-- @function [parent=#input] controllerStopRumble + --- -- Returns a human readable name for the given key code -- @function [parent=#input] getKeyName diff --git a/files/settings-default.cfg b/files/settings-default.cfg index 2c4bbab9538..9f14efeac66 100644 --- a/files/settings-default.cfg +++ b/files/settings-default.cfg @@ -559,6 +559,12 @@ gamepad cursor speed = 1.0 # Set dead zone for joysticks (gamepad or on-screen ones) joystick dead zone = 0.1 +# Enable rumble/haptic feedback for controllers that support it. +enable controller rumble = true + +# Scale factor applied to all controller rumble effects. (0.0-1.0) +controller rumble strength = 1.0 + # Enable gyroscope support. enable gyroscope = false