From 0e2e386dc99f08f42361c8581f63081c147f458f Mon Sep 17 00:00:00 2001 From: uramer Date: Fri, 29 Dec 2023 18:56:59 +0000 Subject: [PATCH] Lua actions take 3 --- CI/file_name_exceptions.txt | 1 + apps/openmw/mwbase/luamanager.hpp | 8 + apps/openmw/mwlua/inputbindings.cpp | 124 +++++++ apps/openmw/mwlua/luamanagerimp.cpp | 12 +- apps/openmw/mwlua/luamanagerimp.hpp | 7 + apps/openmw_test_suite/CMakeLists.txt | 1 + .../lua/test_inputactions.cpp | 65 ++++ components/CMakeLists.txt | 2 +- components/lua/inputactions.cpp | 288 +++++++++++++++ components/lua/inputactions.hpp | 153 ++++++++ components/misc/strings/algorithm.hpp | 7 + .../lua-scripting/engine_handlers.rst | 3 +- .../lua-scripting/setting_renderers.rst | 24 ++ files/data/CMakeLists.txt | 2 + files/data/builtin.omwscripts | 2 + files/data/l10n/OMWControls/en.yaml | 73 +++- files/data/l10n/OMWControls/fr.yaml | 3 + files/data/l10n/OMWControls/ru.yaml | 2 + files/data/l10n/OMWControls/sv.yaml | 3 + files/data/scripts/omw/camera/camera.lua | 64 ++-- files/data/scripts/omw/camera/move360.lua | 41 ++- .../data/scripts/omw/input/actionbindings.lua | 255 +++++++++++++ .../data/scripts/omw/input/smoothmovement.lua | 92 +++++ files/data/scripts/omw/playercontrols.lua | 344 +++++++++++------- files/lua_api/openmw/input.lua | 115 +++++- 25 files changed, 1490 insertions(+), 201 deletions(-) create mode 100644 apps/openmw_test_suite/lua/test_inputactions.cpp create mode 100644 components/lua/inputactions.cpp create mode 100644 components/lua/inputactions.hpp create mode 100644 files/data/scripts/omw/input/actionbindings.lua create mode 100644 files/data/scripts/omw/input/smoothmovement.lua diff --git a/CI/file_name_exceptions.txt b/CI/file_name_exceptions.txt index 5035d73f27..c3bcee8661 100644 --- a/CI/file_name_exceptions.txt +++ b/CI/file_name_exceptions.txt @@ -19,6 +19,7 @@ apps/openmw_test_suite/lua/test_serialization.cpp apps/openmw_test_suite/lua/test_storage.cpp apps/openmw_test_suite/lua/test_ui_content.cpp apps/openmw_test_suite/lua/test_utilpackage.cpp +apps/openmw_test_suite/lua/test_inputactions.cpp apps/openmw_test_suite/misc/test_endianness.cpp apps/openmw_test_suite/misc/test_resourcehelpers.cpp apps/openmw_test_suite/misc/test_stringops.cpp diff --git a/apps/openmw/mwbase/luamanager.hpp b/apps/openmw/mwbase/luamanager.hpp index e4b16ff725..6e611aa88f 100644 --- a/apps/openmw/mwbase/luamanager.hpp +++ b/apps/openmw/mwbase/luamanager.hpp @@ -29,6 +29,14 @@ namespace ESM struct LuaScripts; } +namespace LuaUtil +{ + namespace InputAction + { + class Registry; + } +} + namespace MWBase { // \brief LuaManager is the central interface through which the engine invokes lua scripts. diff --git a/apps/openmw/mwlua/inputbindings.cpp b/apps/openmw/mwlua/inputbindings.cpp index 02babf0399..8763dce28d 100644 --- a/apps/openmw/mwlua/inputbindings.cpp +++ b/apps/openmw/mwlua/inputbindings.cpp @@ -4,12 +4,14 @@ #include #include +#include #include #include #include "../mwbase/environment.hpp" #include "../mwbase/inputmanager.hpp" #include "../mwinput/actions.hpp" +#include "luamanagerimp.hpp" namespace sol { @@ -17,6 +19,16 @@ namespace sol struct is_automagical : std::false_type { }; + + template <> + struct is_automagical : std::false_type + { + }; + + template <> + struct is_automagical : std::false_type + { + }; } namespace MWLua @@ -46,9 +58,121 @@ namespace MWLua touchpadEvent["pressure"] = sol::readonly_property([](const SDLUtil::TouchEvent& e) -> float { return e.mPressure; }); + auto inputActions = context.mLua->sol().new_usertype("InputActions"); + inputActions[sol::meta_function::index] + = [](LuaUtil::InputAction::Registry& registry, std::string_view key) { return registry[key]; }; + { + auto pairs = [](LuaUtil::InputAction::Registry& registry) { + auto next = [](LuaUtil::InputAction::Registry& registry, std::string_view key) + -> sol::optional> { + std::optional nextKey(registry.nextKey(key)); + if (!nextKey.has_value()) + return sol::nullopt; + else + return std::make_tuple(*nextKey, registry[*nextKey].value()); + }; + return std::make_tuple(next, registry, registry.firstKey()); + }; + inputActions[sol::meta_function::pairs] = pairs; + } + + auto actionInfo = context.mLua->sol().new_usertype("ActionInfo", "key", + sol::property([](const LuaUtil::InputAction::Info& info) { return info.mKey; }), "name", + sol::property([](const LuaUtil::InputAction::Info& info) { return info.mName; }), "description", + sol::property([](const LuaUtil::InputAction::Info& info) { return info.mDescription; }), "type", + sol::property([](const LuaUtil::InputAction::Info& info) { return info.mType; }), "l10n", + sol::property([](const LuaUtil::InputAction::Info& info) { return info.mL10n; }), "defaultValue", + sol::property([](const LuaUtil::InputAction::Info& info) { return info.mDefaultValue; })); + + auto inputTriggers = context.mLua->sol().new_usertype("InputTriggers"); + inputTriggers[sol::meta_function::index] + = [](LuaUtil::InputTrigger::Registry& registry, std::string_view key) { return registry[key]; }; + { + auto pairs = [](LuaUtil::InputTrigger::Registry& registry) { + auto next = [](LuaUtil::InputTrigger::Registry& registry, std::string_view key) + -> sol::optional> { + std::optional nextKey(registry.nextKey(key)); + if (!nextKey.has_value()) + return sol::nullopt; + else + return std::make_tuple(*nextKey, registry[*nextKey].value()); + }; + return std::make_tuple(next, registry, registry.firstKey()); + }; + inputTriggers[sol::meta_function::pairs] = pairs; + } + + auto triggerInfo = context.mLua->sol().new_usertype("TriggerInfo", "key", + sol::property([](const LuaUtil::InputTrigger::Info& info) { return info.mKey; }), "name", + sol::property([](const LuaUtil::InputTrigger::Info& info) { return info.mName; }), "description", + sol::property([](const LuaUtil::InputTrigger::Info& info) { return info.mDescription; }), "l10n", + sol::property([](const LuaUtil::InputTrigger::Info& info) { return info.mL10n; })); + MWBase::InputManager* input = MWBase::Environment::get().getInputManager(); sol::table api(context.mLua->sol(), sol::create); + api["ACTION_TYPE"] + = LuaUtil::makeStrictReadOnly(context.mLua->tableFromPairs({ + { "Boolean", LuaUtil::InputAction::Type::Boolean }, + { "Number", LuaUtil::InputAction::Type::Number }, + { "Range", LuaUtil::InputAction::Type::Range }, + })); + + api["actions"] = std::ref(context.mLuaManager->inputActions()); + api["registerAction"] = [manager = context.mLuaManager](sol::table options) { + LuaUtil::InputAction::Info parsedOptions; + parsedOptions.mKey = options["key"].get(); + parsedOptions.mType = options["type"].get(); + parsedOptions.mL10n = options["l10n"].get(); + parsedOptions.mName = options["name"].get(); + parsedOptions.mDescription = options["description"].get(); + parsedOptions.mDefaultValue = options["defaultValue"].get(); + manager->inputActions().insert(parsedOptions); + }; + api["bindAction"] = [manager = context.mLuaManager]( + std::string_view key, const sol::table& callback, sol::table dependencies) { + std::vector parsedDependencies; + parsedDependencies.reserve(dependencies.size()); + for (size_t i = 1; i <= dependencies.size(); ++i) + { + sol::object dependency = dependencies[i]; + if (!dependency.is()) + throw std::domain_error("The dependencies argument must be a list of Action keys"); + parsedDependencies.push_back(dependency.as()); + } + if (!manager->inputActions().bind(key, LuaUtil::Callback::fromLua(callback), parsedDependencies)) + throw std::domain_error("Cyclic action binding"); + }; + api["registerActionHandler"] + = [manager = context.mLuaManager](std::string_view key, const sol::table& callback) { + manager->inputActions().registerHandler(key, LuaUtil::Callback::fromLua(callback)); + }; + api["getBooleanActionValue"] = [manager = context.mLuaManager](std::string_view key) { + return manager->inputActions().valueOfType(key, LuaUtil::InputAction::Type::Boolean); + }; + api["getNumberActionValue"] = [manager = context.mLuaManager](std::string_view key) { + return manager->inputActions().valueOfType(key, LuaUtil::InputAction::Type::Number); + }; + api["getRangeActionValue"] = [manager = context.mLuaManager](std::string_view key) { + return manager->inputActions().valueOfType(key, LuaUtil::InputAction::Type::Range); + }; + + api["triggers"] = std::ref(context.mLuaManager->inputTriggers()); + api["registerTrigger"] = [manager = context.mLuaManager](sol::table options) { + LuaUtil::InputTrigger::Info parsedOptions; + parsedOptions.mKey = options["key"].get(); + parsedOptions.mL10n = options["l10n"].get(); + parsedOptions.mName = options["name"].get(); + parsedOptions.mDescription = options["description"].get(); + manager->inputTriggers().insert(parsedOptions); + }; + api["registerTriggerHandler"] + = [manager = context.mLuaManager](std::string_view key, const sol::table& callback) { + manager->inputTriggers().registerHandler(key, LuaUtil::Callback::fromLua(callback)); + }; + api["activateTrigger"] + = [manager = context.mLuaManager](std::string_view key) { manager->inputTriggers().activate(key); }; + api["isIdle"] = [input]() { return input->isIdle(); }; api["isActionPressed"] = [input](int action) { return input->actionIsActive(action); }; api["isKeyPressed"] = [](SDL_Scancode code) -> bool { diff --git a/apps/openmw/mwlua/luamanagerimp.cpp b/apps/openmw/mwlua/luamanagerimp.cpp index c324360287..baf13edac4 100644 --- a/apps/openmw/mwlua/luamanagerimp.cpp +++ b/apps/openmw/mwlua/luamanagerimp.cpp @@ -225,10 +225,12 @@ namespace MWLua playerScripts->processInputEvent(event); } mInputEvents.clear(); + double frameDuration = MWBase::Environment::get().getWorld()->getTimeManager()->isPaused() + ? 0.0 + : MWBase::Environment::get().getFrameDuration(); + mInputActions.update(frameDuration); if (playerScripts) - playerScripts->onFrame(MWBase::Environment::get().getWorld()->getTimeManager()->isPaused() - ? 0.0 - : MWBase::Environment::get().getFrameDuration()); + playerScripts->onFrame(frameDuration); mProcessingInputEvents = false; for (const std::string& message : mUIMessages) @@ -291,6 +293,8 @@ namespace MWLua } mGlobalStorage.clearTemporaryAndRemoveCallbacks(); mPlayerStorage.clearTemporaryAndRemoveCallbacks(); + mInputActions.clear(); + mInputTriggers.clear(); for (int i = 0; i < 5; ++i) lua_gc(mLua.sol(), LUA_GCCOLLECT, 0); } @@ -520,6 +524,8 @@ namespace MWLua MWBase::Environment::get().getL10nManager()->dropCache(); mUiResourceManager.clear(); mLua.dropScriptCache(); + mInputActions.clear(); + mInputTriggers.clear(); initConfiguration(); { // Reload global scripts diff --git a/apps/openmw/mwlua/luamanagerimp.hpp b/apps/openmw/mwlua/luamanagerimp.hpp index ae16689562..8bd189d8e9 100644 --- a/apps/openmw/mwlua/luamanagerimp.hpp +++ b/apps/openmw/mwlua/luamanagerimp.hpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -144,6 +145,9 @@ namespace MWLua void reportStats(unsigned int frameNumber, osg::Stats& stats) const; std::string formatResourceUsageStats() const override; + LuaUtil::InputAction::Registry& inputActions() { return mInputActions; } + LuaUtil::InputTrigger::Registry& inputTriggers() { return mInputTriggers; } + private: void initConfiguration(); LocalScripts* createLocalScripts(const MWWorld::Ptr& ptr, @@ -206,6 +210,9 @@ namespace MWLua LuaUtil::LuaStorage mGlobalStorage{ mLua.sol() }; LuaUtil::LuaStorage mPlayerStorage{ mLua.sol() }; + + LuaUtil::InputAction::Registry mInputActions; + LuaUtil::InputTrigger::Registry mInputTriggers; }; } diff --git a/apps/openmw_test_suite/CMakeLists.txt b/apps/openmw_test_suite/CMakeLists.txt index 4f93319c96..c8679ab7f4 100644 --- a/apps/openmw_test_suite/CMakeLists.txt +++ b/apps/openmw_test_suite/CMakeLists.txt @@ -28,6 +28,7 @@ file(GLOB UNITTEST_SRC_FILES lua/test_l10n.cpp lua/test_storage.cpp lua/test_async.cpp + lua/test_inputactions.cpp lua/test_ui_content.cpp diff --git a/apps/openmw_test_suite/lua/test_inputactions.cpp b/apps/openmw_test_suite/lua/test_inputactions.cpp new file mode 100644 index 0000000000..5bdd39ada1 --- /dev/null +++ b/apps/openmw_test_suite/lua/test_inputactions.cpp @@ -0,0 +1,65 @@ +#include "gmock/gmock.h" +#include + +#include +#include + +#include "../testing_util.hpp" + +namespace +{ + using namespace testing; + using namespace TestingOpenMW; + + TEST(LuaInputActionsTest, MultiTree) + { + { + LuaUtil::InputAction::MultiTree tree; + auto a = tree.insert(); + auto b = tree.insert(); + auto c = tree.insert(); + auto d = tree.insert(); + EXPECT_TRUE(tree.multiEdge(c, { a, b })); + EXPECT_TRUE(tree.multiEdge(a, { d })); + EXPECT_FALSE(tree.multiEdge(d, { c })); + } + + { + LuaUtil::InputAction::MultiTree tree; + auto a = tree.insert(); + auto b = tree.insert(); + auto c = tree.insert(); + EXPECT_TRUE(tree.multiEdge(b, { a })); + EXPECT_TRUE(tree.multiEdge(c, { a, b })); + } + } + + TEST(LuaInputActionsTest, Registry) + { + sol::state lua; + LuaUtil::InputAction::Registry registry; + LuaUtil::InputAction::Info a({ "a", LuaUtil::InputAction::Type::Boolean, "test", "a_name", "a_description", + sol::make_object(lua, false) }); + registry.insert(a); + LuaUtil::InputAction::Info b({ "b", LuaUtil::InputAction::Type::Boolean, "test", "b_name", "b_description", + sol::make_object(lua, false) }); + registry.insert(b); + LuaUtil::Callback bindA({ lua.load("return function() return true end")(), sol::table(lua, sol::create) }); + LuaUtil::Callback bindBToA( + { lua.load("return function(_, _, aValue) return aValue end")(), sol::table(lua, sol::create) }); + EXPECT_TRUE(registry.bind("a", bindA, {})); + EXPECT_TRUE(registry.bind("b", bindBToA, { "a" })); + registry.update(1.0); + sol::object bValue = registry.valueOfType("b", LuaUtil::InputAction::Type::Boolean); + EXPECT_TRUE(bValue.is()); + LuaUtil::Callback badA( + { lua.load("return function() return 'not_a_bool' end")(), sol::table(lua, sol::create) }); + EXPECT_TRUE(registry.bind("a", badA, {})); + testing::internal::CaptureStderr(); + registry.update(1.0); + sol::object aValue = registry.valueOfType("a", LuaUtil::InputAction::Type::Boolean); + EXPECT_TRUE(aValue.is()); + bValue = registry.valueOfType("b", LuaUtil::InputAction::Type::Boolean); + EXPECT_TRUE(bValue.is() && bValue.as() == aValue.as()); + } +} diff --git a/components/CMakeLists.txt b/components/CMakeLists.txt index f61a5bd0b2..95e523d07c 100644 --- a/components/CMakeLists.txt +++ b/components/CMakeLists.txt @@ -44,7 +44,7 @@ list (APPEND COMPONENT_FILES "${OpenMW_BINARY_DIR}/${VERSION_CPP_FILE}") add_component_dir (lua luastate scriptscontainer asyncpackage utilpackage serialization configuration l10n storage utf8 - shapes/box + shapes/box inputactions ) add_component_dir (l10n diff --git a/components/lua/inputactions.cpp b/components/lua/inputactions.cpp new file mode 100644 index 0000000000..c21fbcf112 --- /dev/null +++ b/components/lua/inputactions.cpp @@ -0,0 +1,288 @@ +#include "inputactions.hpp" + +#include +#include + +#include +#include + +#include "luastate.hpp" + +namespace LuaUtil +{ + namespace InputAction + { + namespace + { + std::string_view typeName(Type actionType) + { + switch (actionType) + { + case Type::Boolean: + return "Boolean"; + case Type::Number: + return "Number"; + case Type::Range: + return "Range"; + default: + throw std::logic_error("Unknown input action type"); + } + } + } + + MultiTree::Node MultiTree::insert() + { + size_t nextId = size(); + mChildren.push_back({}); + mParents.push_back({}); + return nextId; + } + + bool MultiTree::validateTree() const + { + std::vector complete(size(), false); + traverse([&complete](Node node) { complete[node] = true; }); + return std::find(complete.begin(), complete.end(), false) == complete.end(); + } + + template + void MultiTree::traverse(Function callback) const + { + std::queue nodeQueue; + std::vector complete(size(), false); + for (Node root = 0; root < size(); ++root) + { + if (!complete[root]) + nodeQueue.push(root); + while (!nodeQueue.empty()) + { + Node node = nodeQueue.back(); + nodeQueue.pop(); + + bool isComplete = true; + for (Node parent : mParents[node]) + isComplete = isComplete && complete[parent]; + complete[node] = isComplete; + if (isComplete) + { + callback(node); + for (Node child : mChildren[node]) + nodeQueue.push(child); + } + } + } + } + + bool MultiTree::multiEdge(Node target, const std::vector& source) + { + mParents[target].reserve(mParents[target].size() + source.size()); + for (Node s : source) + { + mParents[target].push_back(s); + mChildren[s].push_back(target); + } + bool validTree = validateTree(); + if (!validTree) + { + for (Node s : source) + { + mParents[target].pop_back(); + mChildren[s].pop_back(); + } + } + return validTree; + } + + namespace + { + bool validateActionValue(sol::object value, Type type) + { + switch (type) + { + case Type::Boolean: + return value.get_type() == sol::type::boolean; + case Type::Number: + return value.get_type() == sol::type::number; + case Type::Range: + if (value.get_type() != sol::type::number) + return false; + double d = value.as(); + return 0.0 <= d && d <= 1.0; + } + throw std::invalid_argument("Unknown action type"); + } + } + + void Registry::insert(Info info) + { + if (mIds.find(info.mKey) != mIds.end()) + throw std::domain_error(Misc::StringUtils::format("Action key \"%s\" is already in use", info.mKey)); + if (info.mKey.empty()) + throw std::domain_error("Action key can't be an empty string"); + if (info.mL10n.empty()) + throw std::domain_error("Localization context can't be empty"); + if (!validateActionValue(info.mDefaultValue, info.mType)) + throw std::logic_error(Misc::StringUtils::format( + "Invalid value: \"%s\" for action \"%s\"", LuaUtil::toString(info.mDefaultValue), info.mKey)); + Id id = mBindingTree.insert(); + mKeys.push_back(info.mKey); + mIds[std::string(info.mKey)] = id; + mInfo.push_back(info); + mHandlers.push_back({}); + mBindings.push_back({}); + mValues.push_back(info.mDefaultValue); + } + + std::optional Registry::nextKey(std::string_view key) const + { + auto it = mIds.find(key); + if (it == mIds.end()) + return std::nullopt; + auto nextId = it->second + 1; + if (nextId >= mKeys.size()) + return std::nullopt; + return mKeys.at(nextId); + } + + std::optional Registry::operator[](std::string_view actionKey) + { + auto iter = mIds.find(actionKey); + if (iter == mIds.end()) + return std::nullopt; + return mInfo[iter->second]; + } + + Registry::Id Registry::safeIdByKey(std::string_view key) + { + auto iter = mIds.find(key); + if (iter == mIds.end()) + throw std::logic_error(Misc::StringUtils::format("Unknown action key: \"%s\"", key)); + return iter->second; + } + + bool Registry::bind( + std::string_view key, const LuaUtil::Callback& callback, const std::vector& dependencies) + { + Id id = safeIdByKey(key); + std::vector dependencyIds; + dependencyIds.reserve(dependencies.size()); + for (std::string_view s : dependencies) + dependencyIds.push_back(safeIdByKey(s)); + bool validEdge = mBindingTree.multiEdge(id, dependencyIds); + if (validEdge) + mBindings[id].push_back(Binding{ + callback, + std::move(dependencyIds), + }); + return validEdge; + } + + sol::object Registry::valueOfType(std::string_view key, Type type) + { + Id id = safeIdByKey(key); + Info info = mInfo[id]; + if (info.mType != type) + throw std::logic_error( + Misc::StringUtils::format("Attempt to get value of type \"%s\" from action \"%s\" with type \"%s\"", + typeName(type), key, typeName(info.mType))); + return mValues[id]; + } + + void Registry::update(double dt) + { + std::vector dependencyValues; + mBindingTree.traverse([this, &dependencyValues, dt](Id node) { + sol::main_object newValue = mValues[node]; + std::vector& bindings = mBindings[node]; + bindings.erase(std::remove_if(bindings.begin(), bindings.end(), + [&](const Binding& binding) { + if (!binding.mCallback.isValid()) + return true; + + dependencyValues.clear(); + for (Id parent : binding.mDependencies) + dependencyValues.push_back(mValues[parent]); + try + { + newValue = sol::main_object( + binding.mCallback.call(dt, newValue, sol::as_args(dependencyValues))); + } + catch (std::exception& e) + { + if (!validateActionValue(newValue, mInfo[node].mType)) + Log(Debug::Error) << Misc::StringUtils::format( + "Error due to invalid value of action \"%s\"(\"%s\"): ", mKeys[node], + LuaUtil::toString(newValue)) + << e.what(); + else + Log(Debug::Error) << "Error in callback: " << e.what(); + } + return false; + }), + bindings.end()); + + if (!validateActionValue(newValue, mInfo[node].mType)) + Log(Debug::Error) << Misc::StringUtils::format( + "Invalid value of action \"%s\": %s", mKeys[node], LuaUtil::toString(newValue)); + if (mValues[node] != newValue) + { + mValues[node] = sol::object(newValue); + std::vector& handlers = mHandlers[node]; + handlers.erase(std::remove_if(handlers.begin(), handlers.end(), + [&](const LuaUtil::Callback& handler) { + if (!handler.isValid()) + return true; + handler.tryCall(newValue); + return false; + }), + handlers.end()); + } + }); + } + } + + namespace InputTrigger + { + Registry::Id Registry::safeIdByKey(std::string_view key) + { + auto it = mIds.find(key); + if (it == mIds.end()) + throw std::domain_error(Misc::StringUtils::format("Unknown trigger key \"%s\"", key)); + return it->second; + } + + void Registry::insert(Info info) + { + if (mIds.find(info.mKey) != mIds.end()) + throw std::domain_error(Misc::StringUtils::format("Trigger key \"%s\" is already in use", info.mKey)); + if (info.mKey.empty()) + throw std::domain_error("Trigger key can't be an empty string"); + if (info.mL10n.empty()) + throw std::domain_error("Localization context can't be empty"); + Id id = mIds.size(); + mIds[info.mKey] = id; + mInfo.push_back(info); + mHandlers.push_back({}); + } + + void Registry::registerHandler(std::string_view key, const LuaUtil::Callback& callback) + { + Id id = safeIdByKey(key); + mHandlers[id].push_back(callback); + } + + void Registry::activate(std::string_view key) + { + Id id = safeIdByKey(key); + std::vector& handlers = mHandlers[id]; + handlers.erase(std::remove_if(handlers.begin(), handlers.end(), + [&](const LuaUtil::Callback& handler) { + if (!handler.isValid()) + return true; + handler.tryCall(); + return false; + }), + handlers.end()); + } + } +} diff --git a/components/lua/inputactions.hpp b/components/lua/inputactions.hpp new file mode 100644 index 0000000000..ac3907b55d --- /dev/null +++ b/components/lua/inputactions.hpp @@ -0,0 +1,153 @@ +#ifndef COMPONENTS_LUA_INPUTACTIONS +#define COMPONENTS_LUA_INPUTACTIONS + +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace LuaUtil::InputAction +{ + enum class Type + { + Boolean, + Number, + Range, + }; + + struct Info + { + std::string mKey; + Type mType; + std::string mL10n; + std::string mName; + std::string mDescription; + sol::main_object mDefaultValue; + }; + + class MultiTree + { + public: + using Node = size_t; + + Node insert(); + bool multiEdge(Node target, const std::vector& source); + size_t size() const { return mParents.size(); } + + template // Function = void(Node) + void traverse(Function callback) const; + + void clear() + { + mParents.clear(); + mChildren.clear(); + } + + private: + std::vector> mParents; + std::vector> mChildren; + + bool validateTree() const; + }; + + class Registry + { + public: + using ConstIterator = std::vector::const_iterator; + void insert(Info info); + size_t size() const { return mKeys.size(); } + std::optional firstKey() const { return mKeys.empty() ? std::nullopt : std::optional(mKeys[0]); } + std::optional nextKey(std::string_view key) const; + std::optional operator[](std::string_view actionKey); + bool bind( + std::string_view key, const LuaUtil::Callback& callback, const std::vector& dependencies); + sol::object valueOfType(std::string_view key, Type type); + void update(double dt); + void registerHandler(std::string_view key, const LuaUtil::Callback& handler) + { + mHandlers[safeIdByKey(key)].push_back(handler); + } + void clear() + { + mKeys.clear(); + mIds.clear(); + mInfo.clear(); + mHandlers.clear(); + mBindings.clear(); + mValues.clear(); + mBindingTree.clear(); + } + + private: + using Id = MultiTree::Node; + Id safeIdByKey(std::string_view key); + struct Binding + { + LuaUtil::Callback mCallback; + std::vector mDependencies; + }; + std::vector mKeys; + std::unordered_map> mIds; + std::vector mInfo; + std::vector> mHandlers; + std::vector> mBindings; + std::vector mValues; + MultiTree mBindingTree; + }; +} + +namespace LuaUtil::InputTrigger +{ + struct Info + { + std::string mKey; + std::string mL10n; + std::string mName; + std::string mDescription; + }; + + class Registry + { + public: + std::optional firstKey() const + { + return mIds.empty() ? std::nullopt : std::optional(mIds.begin()->first); + } + std::optional nextKey(std::string_view key) const + { + auto it = mIds.find(key); + if (it == mIds.end() || ++it == mIds.end()) + return std::nullopt; + return it->first; + } + std::optional operator[](std::string_view key) + { + Id id = safeIdByKey(key); + return mInfo[id]; + } + void insert(Info info); + void registerHandler(std::string_view key, const LuaUtil::Callback& callback); + void activate(std::string_view key); + void clear() + { + mInfo.clear(); + mHandlers.clear(); + mIds.clear(); + } + + private: + using Id = size_t; + Id safeIdByKey(std::string_view key); + std::unordered_map> mIds; + std::vector mInfo; + std::vector> mHandlers; + }; +} + +#endif // COMPONENTS_LUA_INPUTACTIONS diff --git a/components/misc/strings/algorithm.hpp b/components/misc/strings/algorithm.hpp index f34801b8d3..28bc696cd3 100644 --- a/components/misc/strings/algorithm.hpp +++ b/components/misc/strings/algorithm.hpp @@ -99,6 +99,13 @@ namespace Misc::StringUtils bool operator()(std::string_view left, std::string_view right) const { return ciLess(left, right); } }; + struct StringHash + { + using is_transparent = void; + [[nodiscard]] size_t operator()(std::string_view sv) const { return std::hash{}(sv); } + [[nodiscard]] size_t operator()(const std::string& s) const { return std::hash{}(s); } + }; + /** @brief Replaces all occurrences of a string in another string. * * @param str The string to operate on. diff --git a/docs/source/reference/lua-scripting/engine_handlers.rst b/docs/source/reference/lua-scripting/engine_handlers.rst index 1ffa1820f3..10ed3ee555 100644 --- a/docs/source/reference/lua-scripting/engine_handlers.rst +++ b/docs/source/reference/lua-scripting/engine_handlers.rst @@ -109,7 +109,8 @@ Engine handler is a function defined by a script, that can be called by the engi | Usage example: | ``if id == input.CONTROLLER_BUTTON.LeftStick then ...`` * - onInputAction(id) - - | `Game control `_ is pressed. + - | (DEPRECATED, use `registerActionHandler `_) + | `Game control `_ is pressed. | Usage example: | ``if id == input.ACTION.ToggleWeapon then ...`` * - onTouchPress(touchEvent) diff --git a/docs/source/reference/lua-scripting/setting_renderers.rst b/docs/source/reference/lua-scripting/setting_renderers.rst index 966246d503..7f40eb08bd 100644 --- a/docs/source/reference/lua-scripting/setting_renderers.rst +++ b/docs/source/reference/lua-scripting/setting_renderers.rst @@ -126,3 +126,27 @@ Table with the following optional fields: * - disabled - bool (false) - Disables changing the setting from the UI + +inputBinding +----- + +Allows the user to bind inputs to an action or trigger + +**Argument** + +Table with the following fields: + +.. list-table:: + :header-rows: 1 + :widths: 20 20 60 + + * - name + - type (default) + - description + * - type + - 'keyboardPress', 'keyboardHold' + - The type of input that's allowed to be bound + * - key + - #string + - Key of the action or trigger to which the input is bound + diff --git a/files/data/CMakeLists.txt b/files/data/CMakeLists.txt index dbf86cc44d..cec613d128 100644 --- a/files/data/CMakeLists.txt +++ b/files/data/CMakeLists.txt @@ -92,6 +92,8 @@ set(BUILTIN_DATA_FILES scripts/omw/ui.lua scripts/omw/usehandlers.lua scripts/omw/worldeventhandlers.lua + scripts/omw/input/actionbindings.lua + scripts/omw/input/smoothmovement.lua shaders/adjustments.omwfx shaders/bloomlinear.omwfx diff --git a/files/data/builtin.omwscripts b/files/data/builtin.omwscripts index ec08c5299d..e4338df533 100644 --- a/files/data/builtin.omwscripts +++ b/files/data/builtin.omwscripts @@ -13,6 +13,8 @@ GLOBAL: scripts/omw/worldeventhandlers.lua PLAYER: scripts/omw/mechanics/playercontroller.lua PLAYER: scripts/omw/playercontrols.lua PLAYER: scripts/omw/camera/camera.lua +PLAYER: scripts/omw/input/actionbindings.lua +PLAYER: scripts/omw/input/smoothmovement.lua NPC,CREATURE: scripts/omw/ai.lua # User interface diff --git a/files/data/l10n/OMWControls/en.yaml b/files/data/l10n/OMWControls/en.yaml index de6edde19a..9c45c1d1e5 100644 --- a/files/data/l10n/OMWControls/en.yaml +++ b/files/data/l10n/OMWControls/en.yaml @@ -10,7 +10,76 @@ alwaysRunDescription: | toggleSneak: "Toggle sneak" toggleSneakDescription: | - This setting causes the behavior of the sneak key (bound to Ctrl by default) - to toggle sneaking on and off rather than requiring the key to be held down while sneaking. + This setting causes the sneak key (bound to Ctrl by default) to toggle sneaking on and off + rather than requiring the key to be held down while sneaking. Players that spend significant time sneaking may find the character easier to control with this option enabled. +smoothControllerMovement: "Smooth controller movement" +smoothControllerMovementDescription: | + Enables smooth movement with controller stick, with no abrupt switch from walking to running. + +TogglePOV_name: "Toggle POV" +TogglePOV_description: "Toggle between first and third person view. Hold to enter preview mode." + +Zoom3rdPerson_name: "Zoom In/Out" +Zoom3rdPerson_description: "Moves the camera closer / further away when in third person view." + +MoveForward_name: "Move Forward" +MoveForward_description: "Can cancel out with Move Backward" + +MoveBackward_name: "Move Backward" +MoveBackward_description: "Can cancel out with Move Forward" + +MoveLeft_name: "Move Left" +MoveLeft_description: "Can cancel out with Move Right" + +MoveRight_name: "Move Right" +MoveRight_description: "Can cancel out with Move Left" + +Use_name: "Use" +Use_description: "Attack with a weapon or cast a spell depending on current stance" + +Run_name: "Run" +Run_description: "Hold to run/walk, depending on the Always Run setting" + +AlwaysRun_name: "Always Run" +AlwaysRun_description: "Toggle the Always Run setting" + +Jump_name: "Jump" +Jump_description: "Jump whenever you are on the ground" + +AutoMove_name: "Auto Run" +AutoMove_description: "Toggle continous forward movement" + +Sneak_name: "Sneak" +Sneak_description: "Hold to sneak, if the Toggle Sneak setting is off" + +ToggleSneak_name: "Toggle Sneak" +ToggleSneak_description: "Toggle sneak, if the Toggle Sneak setting is on" + +ToggleWeapon_name: "Ready Weapon" +ToggleWeapon_description: "Enter or leave the weapon stance" + +ToggleSpell_name: "Ready Magic" +ToggleSpell_description: "Enter or leave the magic stance" + +Inventory_name: "Inventory" +Inventory_description: "Open the inventory" + +Journal_name: "Journal" +Journal_description: "Open the journal" + +QuickKeysMenu_name: "QuickKeysMenu" +QuickKeysMenu_description: "Open the quick keys menu" + +SmoothMoveForward_name: "Smooth Move Forward" +SmoothMoveForward_description: "Forward movement adjusted for smooth Walk-Run transitions" + +SmoothMoveBackward_name: "Smooth Move Backward" +SmoothMoveBackward_description: "Backward movement adjusted for smooth Walk-Run transitions" + +SmoothMoveLeft_name: "Smooth Move Left" +SmoothMoveLeft_description: "Left movement adjusted for smooth Walk-Run transitions" + +SkmoothMoveRight_name: "SmoothMove Right" +SkmoothMoveRight_description: "Right movement adjusted for smooth Walk-Run transitions" diff --git a/files/data/l10n/OMWControls/fr.yaml b/files/data/l10n/OMWControls/fr.yaml index 6d58ee1794..dab9ddb8fc 100644 --- a/files/data/l10n/OMWControls/fr.yaml +++ b/files/data/l10n/OMWControls/fr.yaml @@ -14,3 +14,6 @@ toggleSneakDescription: | Une simple pression de la touche associée (Ctrl par défaut) active le mode discrétion, une seconde pression désactive le mode discrétion.\n\n Il n'est plus nécessaire de maintenir une touche appuyée pour que le mode discrétion soit actif.\n\n Certains joueurs ayant une utilisation intensive du mode discrétion considèrent qu'il est plus aisé de contrôler leur personnage ainsi. + +# smoothControllerMovement +# smoothControllerMovementDescription diff --git a/files/data/l10n/OMWControls/ru.yaml b/files/data/l10n/OMWControls/ru.yaml index 30bb15ee57..0ce3609e16 100644 --- a/files/data/l10n/OMWControls/ru.yaml +++ b/files/data/l10n/OMWControls/ru.yaml @@ -14,3 +14,5 @@ toggleSneakDescription: | чтобы красться, её достаточно нажать единожды для переключения положения, а не зажимать. Игрокам, которые много времени крадутся, может быть проще управлять персонажем, когда опция включена. +# smoothControllerMovement +# smoothControllerMovementDescription diff --git a/files/data/l10n/OMWControls/sv.yaml b/files/data/l10n/OMWControls/sv.yaml index 43565053c5..73fc5e18dc 100644 --- a/files/data/l10n/OMWControls/sv.yaml +++ b/files/data/l10n/OMWControls/sv.yaml @@ -13,3 +13,6 @@ toggleSneakDescription: | Denna inställningen gör att smygknappen (förinställt till ctrl) slår smygning på eller av vid ett knapptryck istället för att att kräva att knappen hålls nedtryckt för att smyga. Spelare som spenderar mycket tid med att smyga lär ha lättare att kontrollera rollfiguren med denna funktion aktiverad. + +# smoothControllerMovement +# smoothControllerMovementDescription diff --git a/files/data/scripts/omw/camera/camera.lua b/files/data/scripts/omw/camera/camera.lua index 38441f9e59..6c162f3a25 100644 --- a/files/data/scripts/omw/camera/camera.lua +++ b/files/data/scripts/omw/camera/camera.lua @@ -10,6 +10,24 @@ local I = require('openmw.interfaces') local Actor = require('openmw.types').Actor local Player = require('openmw.types').Player +input.registerAction { + key = 'TogglePOV', + l10n = 'OMWControls', + name = 'TogglePOV_name', + description = 'TogglePOV_description', + type = input.ACTION_TYPE.Boolean, + defaultValue = false, +} + +input.registerAction { + key = 'Zoom3rdPerson', + l10n = 'OMWControls', + name = 'Zoom3rdPerson_name', + description = 'Zoom3rdPerson_description', + type = input.ACTION_TYPE.Number, + defaultValue = 0, +} + local settings = require('scripts.omw.camera.settings').thirdPerson local head_bobbing = require('scripts.omw.camera.head_bobbing') local third_person = require('scripts.omw.camera.third_person') @@ -63,7 +81,7 @@ local previewTimer = 0 local function updatePOV(dt) local switchLimit = 0.25 - if input.isActionPressed(input.ACTION.TogglePOV) and Player.getControlSwitch(self, Player.CONTROL_SWITCH.ViewMode) then + if input.getBooleanActionValue('TogglePOV') and Player.getControlSwitch(self, Player.CONTROL_SWITCH.ViewMode) then previewTimer = previewTimer + dt if primaryMode == MODE.ThirdPerson or previewTimer >= switchLimit then third_person.standingPreview = false @@ -117,18 +135,19 @@ local maxDistance = 800 local function zoom(delta) if not Player.getControlSwitch(self, Player.CONTROL_SWITCH.ViewMode) or - not Player.getControlSwitch(self, Player.CONTROL_SWITCH.Controls) or - camera.getMode() == MODE.Static or next(noZoom) then + not Player.getControlSwitch(self, Player.CONTROL_SWITCH.Controls) or + camera.getMode() == MODE.Static or next(noZoom) then return end if camera.getMode() ~= MODE.FirstPerson then local obstacleDelta = third_person.preferredDistance - camera.getThirdPersonDistance() if delta > 0 and third_person.baseDistance == minDistance and - (camera.getMode() ~= MODE.Preview or third_person.standingPreview) and not next(noModeControl) then + (camera.getMode() ~= MODE.Preview or third_person.standingPreview) and not next(noModeControl) then primaryMode = MODE.FirstPerson camera.setMode(primaryMode) elseif delta > 0 or obstacleDelta < -delta then - third_person.baseDistance = util.clamp(third_person.baseDistance - delta - obstacleDelta, minDistance, maxDistance) + third_person.baseDistance = util.clamp(third_person.baseDistance - delta - obstacleDelta, minDistance, + maxDistance) end elseif delta < 0 and not next(noModeControl) then primaryMode = MODE.ThirdPerson @@ -137,21 +156,10 @@ local function zoom(delta) end end -local function applyControllerZoom(dt) - if input.isActionPressed(input.ACTION.TogglePOV) then - local triggerLeft = input.getAxisValue(input.CONTROLLER_AXIS.TriggerLeft) - local triggerRight = input.getAxisValue(input.CONTROLLER_AXIS.TriggerRight) - local controllerZoom = (triggerRight - triggerLeft) * 100 * dt - if controllerZoom ~= 0 then - zoom(controllerZoom) - end - end -end - local function updateStandingPreview() local mode = camera.getMode() if not previewIfStandStill or next(noStandingPreview) - or mode == MODE.FirstPerson or mode == MODE.Static or mode == MODE.Vanity then + or mode == MODE.FirstPerson or mode == MODE.Static or mode == MODE.Vanity then third_person.standingPreview = false return end @@ -184,7 +192,7 @@ local function updateIdleTimer(dt) if not input.isIdle() then idleTimer = 0 elseif self.controls.movement ~= 0 or self.controls.sideMovement ~= 0 or self.controls.jump or self.controls.use ~= 0 then - idleTimer = 0 -- also reset the timer in case of a scripted movement + idleTimer = 0 -- also reset the timer in case of a scripted movement else idleTimer = idleTimer + dt end @@ -205,7 +213,14 @@ local function onFrame(dt) updateStandingPreview() updateCrosshair() end - applyControllerZoom(dt) + + do + local Zoom3rdPerson = input.getNumberActionValue('Zoom3rdPerson') + if Zoom3rdPerson ~= 0 then + zoom(Zoom3rdPerson) + end + end + third_person.update(dt, smoothedSpeed) if not next(noHeadBobbing) then head_bobbing.update(dt, smoothedSpeed) end if slowViewChange then @@ -312,15 +327,6 @@ return { engineHandlers = { onUpdate = onUpdate, onFrame = onFrame, - onInputAction = function(action) - if core.isWorldPaused() or I.UI.getMode() then return end - if action == input.ACTION.ZoomIn then - zoom(10) - elseif action == input.ACTION.ZoomOut then - zoom(-10) - end - move360.onInputAction(action) - end, onTeleported = function() camera.instantTransition() end, @@ -329,7 +335,7 @@ return { if data and data.distance then third_person.baseDistance = data.distance end end, onSave = function() - return {version = 0, distance = third_person.baseDistance} + return { version = 0, distance = third_person.baseDistance } end, }, } diff --git a/files/data/scripts/omw/camera/move360.lua b/files/data/scripts/omw/camera/move360.lua index 18c30ae77b..a80019f3a0 100644 --- a/files/data/scripts/omw/camera/move360.lua +++ b/files/data/scripts/omw/camera/move360.lua @@ -30,6 +30,26 @@ local function turnOff() end end +local function processZoom3rdPerson() + if + not Player.getControlSwitch(self, Player.CONTROL_SWITCH.ViewMode) or + not Player.getControlSwitch(self, Player.CONTROL_SWITCH.Controls) or + input.getBooleanActionValue('TogglePOV') or + not I.Camera.isModeControlEnabled() + then + return + end + local Zoom3rdPerson = input.getNumberActionValue('Zoom3rdPerson') + if Zoom3rdPerson > 0 and camera.getMode() == MODE.Preview + and I.Camera.getBaseThirdPersonDistance() == 30 then + self.controls.yawChange = camera.getYaw() - self.rotation:getYaw() + camera.setMode(MODE.FirstPerson) + elseif Zoom3rdPerson < 0 and camera.getMode() == MODE.FirstPerson then + camera.setMode(MODE.Preview) + I.Camera.setBaseThirdPersonDistance(30) + end +end + function M.onFrame(dt) if core.isWorldPaused() then return end local newActive = M.enabled and Actor.getStance(self) == Actor.STANCE.Nothing @@ -39,9 +59,10 @@ function M.onFrame(dt) turnOff() end if not active then return end + processZoom3rdPerson() if camera.getMode() == MODE.Static then return end if camera.getMode() == MODE.ThirdPerson then camera.setMode(MODE.Preview) end - if camera.getMode() == MODE.Preview and not input.isActionPressed(input.ACTION.TogglePOV) then + if camera.getMode() == MODE.Preview and not input.getBooleanActionValue('TogglePOV') then camera.showCrosshair(camera.getFocalPreferredOffset():length() > 5) local move = util.vector2(self.controls.sideMovement, self.controls.movement) local yawDelta = camera.getYaw() - self.rotation:getYaw() @@ -59,22 +80,4 @@ function M.onFrame(dt) end end -function M.onInputAction(action) - if not active or core.isWorldPaused() or - not Player.getControlSwitch(self, Player.CONTROL_SWITCH.ViewMode) or - not Player.getControlSwitch(self, Player.CONTROL_SWITCH.Controls) or - input.isActionPressed(input.ACTION.TogglePOV) or - not I.Camera.isModeControlEnabled() then - return - end - if action == input.ACTION.ZoomIn and camera.getMode() == MODE.Preview - and I.Camera.getBaseThirdPersonDistance() == 30 then - self.controls.yawChange = camera.getYaw() - self.rotation:getYaw() - camera.setMode(MODE.FirstPerson) - elseif action == input.ACTION.ZoomOut and camera.getMode() == MODE.FirstPerson then - camera.setMode(MODE.Preview) - I.Camera.setBaseThirdPersonDistance(30) - end -end - return M diff --git a/files/data/scripts/omw/input/actionbindings.lua b/files/data/scripts/omw/input/actionbindings.lua new file mode 100644 index 0000000000..06ded80793 --- /dev/null +++ b/files/data/scripts/omw/input/actionbindings.lua @@ -0,0 +1,255 @@ +local core = require('openmw.core') +local input = require('openmw.input') +local util = require('openmw.util') +local async = require('openmw.async') +local storage = require('openmw.storage') +local ui = require('openmw.ui') + +local I = require('openmw.interfaces') + +local actionPressHandlers = {} +local function onActionPress(id, handler) + actionPressHandlers[id] = actionPressHandlers[id] or {} + table.insert(actionPressHandlers[id], handler) +end + +local function bindHold(key, actionId) + input.bindAction(key, async:callback(function() + return input.isActionPressed(actionId) + end), {}) +end + +local function bindMovement(key, actionId, axisId, direction) + input.bindAction(key, async:callback(function() + local actionActive = input.isActionPressed(actionId) + local axisActive = input.getAxisValue(axisId) * direction > 0 + return (actionActive or axisActive) and 1 or 0 + end), {}) +end + +local function bindTrigger(key, actionid) + onActionPress(actionid, function() + input.activateTrigger(key) + end) +end + +bindTrigger('AlwaysRun', input.ACTION.AlwaysRun) +bindTrigger('ToggleSneak', input.ACTION.Sneak) +bindTrigger('ToggleWeapon', input.ACTION.ToggleWeapon) +bindTrigger('ToggleSpell', input.ACTION.ToggleSpell) +bindTrigger('Jump', input.ACTION.Jump) +bindTrigger('AutoMove', input.ACTION.AutoMove) +bindTrigger('Inventory', input.ACTION.Inventory) +bindTrigger('Journal', input.ACTION.Journal) +bindTrigger('QuickKeysMenu', input.ACTION.QuickKeysMenu) + +bindHold('TogglePOV', input.ACTION.TogglePOV) +bindHold('Sneak', input.ACTION.Sneak) + +bindHold('Run', input.ACTION.Run) +input.bindAction('Run', async:callback(function(_, value) + local controllerInput = util.vector2( + input.getAxisValue(input.CONTROLLER_AXIS.MoveForwardBackward), + input.getAxisValue(input.CONTROLLER_AXIS.MoveLeftRight) + ):length2() + return value or controllerInput > 0.25 +end), {}) + +input.bindAction('Use', async:callback(function() + -- The value "0.6" shouldn't exceed the triggering threshold in BindingsManager::actionValueChanged. + -- TODO: Move more logic from BindingsManager to Lua and consider to make this threshold configurable. + return input.isActionPressed(input.ACTION.Use) or input.getAxisValue(input.CONTROLLER_AXIS.TriggerRight) >= 0.6 +end), {}) + +bindMovement('MoveBackward', input.ACTION.MoveBackward, input.CONTROLLER_AXIS.MoveForwardBackward, 1) +bindMovement('MoveForward', input.ACTION.MoveForward, input.CONTROLLER_AXIS.MoveForwardBackward, -1) +bindMovement('MoveRight', input.ACTION.MoveRight, input.CONTROLLER_AXIS.MoveLeftRight, 1) +bindMovement('MoveLeft', input.ACTION.MoveLeft, input.CONTROLLER_AXIS.MoveLeftRight, -1) + +do + local zoomInOut = 0 + onActionPress(input.ACTION.ZoomIn, function() + zoomInOut = zoomInOut + 1 + end) + onActionPress(input.ACTION.ZoomOut, function() + zoomInOut = zoomInOut - 1 + end) + input.bindAction('Zoom3rdPerson', async:callback(function(dt, _, togglePOV) + local Zoom3rdPerson = zoomInOut * 10 + if togglePOV then + local triggerLeft = input.getAxisValue(input.CONTROLLER_AXIS.TriggerLeft) + local triggerRight = input.getAxisValue(input.CONTROLLER_AXIS.TriggerRight) + local controllerZoom = (triggerRight - triggerLeft) * 100 * dt + Zoom3rdPerson = Zoom3rdPerson + controllerZoom + end + zoomInOut = 0 + return Zoom3rdPerson + end), { 'TogglePOV' }) +end + +local bindingSection = storage.playerSection('OMWInputBindings') + +local keyboardPresses = {} +local keybordHolds = {} +local boundActions = {} + +local function bindAction(action) + if boundActions[action] then return end + boundActions[action] = true + input.bindAction(action, async:callback(function() + if keybordHolds[action] then + for _, binding in pairs(keybordHolds[action]) do + if input.isKeyPressed(binding.code) then return true end + end + end + return false + end), {}) +end + +local function registerBinding(binding, id) + if not input.actions[binding.key] and not input.triggers[binding.key] then + print(string.format('Skipping binding for unknown action or trigger: "%s"', binding.key)) + return + end + if binding.type == 'keyboardPress' then + local bindings = keyboardPresses[binding.code] or {} + bindings[id] = binding + keyboardPresses[binding.code] = bindings + elseif binding.type == 'keyboardHold' then + local bindings = keybordHolds[binding.key] or {} + bindings[id] = binding + keybordHolds[binding.key] = bindings + bindAction(binding.key) + else + error('Unknown binding type "' .. binding.type .. '"') + end +end + +function clearBinding(id) + for _, boundTriggers in pairs(keyboardPresses) do + boundTriggers[id] = nil + end + for _, boundKeys in pairs(keybordHolds) do + boundKeys[id] = nil + end +end + +local function updateBinding(id, binding) + bindingSection:set(id, binding) + clearBinding(id) + if binding ~= nil then + registerBinding(binding, id) + end + return id +end + +local interfaceL10n = core.l10n('interface') + +I.Settings.registerRenderer('inputBinding', function(id, set, arg) + if type(id) ~= 'string' then error('inputBinding: must have a string default value') end + if not arg.type then error('inputBinding: type argument is required') end + if not arg.key then error('inputBinding: key argument is required') end + local info = input.actions[arg.key] or input.triggers[arg.key] + if not info then return {} end + + local l10n = core.l10n(info.key) + + local name = { + template = I.MWUI.templates.textNormal, + props = { + text = l10n(info.name), + }, + } + + local description = { + template = I.MWUI.templates.textNormal, + props = { + text = l10n(info.description), + }, + } + + local binding = bindingSection:get(id) + local label = binding and input.getKeyName(binding.code) or interfaceL10n('None') + + local recorder = { + template = I.MWUI.templates.textEditLine, + props = { + readOnly = true, + text = label, + }, + events = { + focusGain = async:callback(function() + if binding == nil then return end + updateBinding(id, nil) + set(id) + end), + keyPress = async:callback(function(key) + if binding ~= nil or key.code == input.KEY.Escape then return end + + local newBinding = { + code = key.code, + type = arg.type, + key = arg.key, + } + updateBinding(id, newBinding) + set(id) + end), + }, + } + + local row = { + type = ui.TYPE.Flex, + props = { + horizontal = true, + }, + content = ui.content { + name, + { props = { size = util.vector2(10, 0) } }, + recorder, + }, + } + local column = { + type = ui.TYPE.Flex, + content = ui.content { + row, + description, + }, + } + + return column +end) + +local initiated = false + +local function init() + for id, binding in pairs(bindingSection:asTable()) do + registerBinding(binding, id) + end +end + +return { + engineHandlers = { + onFrame = function() + if not initiated then + initiated = true + init() + end + end, + onInputAction = function(id) + if not actionPressHandlers[id] then + return + end + for _, handler in ipairs(actionPressHandlers[id]) do + handler() + end + end, + onKeyPress = function(e) + local bindings = keyboardPresses[e.code] + if bindings then + for _, binding in pairs(bindings) do + input.activateTrigger(binding.key) + end + end + end, + } +} diff --git a/files/data/scripts/omw/input/smoothmovement.lua b/files/data/scripts/omw/input/smoothmovement.lua new file mode 100644 index 0000000000..ebd322f25d --- /dev/null +++ b/files/data/scripts/omw/input/smoothmovement.lua @@ -0,0 +1,92 @@ +local input = require('openmw.input') +local util = require('openmw.util') +local async = require('openmw.async') +local storage = require('openmw.storage') +local types = require('openmw.types') +local self = require('openmw.self') + +local NPC = types.NPC + +local moveActions = { + 'MoveForward', + 'MoveBackward', + 'MoveLeft', + 'MoveRight' +} +for _, key in ipairs(moveActions) do + local smoothKey = 'Smooth' .. key + input.registerAction { + key = smoothKey, + l10n = 'OMWControls', + name = smoothKey .. '_name', + description = smoothKey .. '_description', + type = input.ACTION_TYPE.Range, + defaultValue = 0, + } +end + +local settings = storage.playerSection('SettingsOMWControls') + +local function shouldAlwaysRun(actor) + return actor.controls.sneak or not NPC.isOnGround(actor) or NPC.isSwimming(actor) +end + +local function remapToWalkRun(actor, inputMovement) + if shouldAlwaysRun(actor) then + return true, inputMovement + end + local normalizedInput, inputSpeed = inputMovement:normalize() + local switchPoint = 0.5 + if inputSpeed < switchPoint then + return false, inputMovement * 2 + else + local matchWalkingSpeed = NPC.getWalkSpeed(actor) / NPC.getRunSpeed(actor) + local runSpeedRatio = 2 * (inputSpeed - switchPoint) * (1 - matchWalkingSpeed) + matchWalkingSpeed + return true, normalizedInput * math.min(1, runSpeedRatio) + end +end + +local function computeSmoothMovement() + local controllerInput = util.vector2( + input.getAxisValue(input.CONTROLLER_AXIS.MoveForwardBackward), + input.getAxisValue(input.CONTROLLER_AXIS.MoveLeftRight) + ) + return remapToWalkRun(self, controllerInput) +end + +local function bindSmoothMove(key, axis, direction) + local smoothKey = 'Smooth' .. key + input.bindAction(smoothKey, async:callback(function() + local _, movement = computeSmoothMovement() + return math.max(direction * movement[axis], 0) + end), {}) + input.bindAction(key, async:callback(function(_, standardMovement, smoothMovement) + if not settings:get('smoothControllerMovement') then + return standardMovement + end + + if smoothMovement > 0 then + return smoothMovement + else + return standardMovement + end + end), { smoothKey }) +end + +bindSmoothMove('MoveForward', 'x', -1) +bindSmoothMove('MoveBackward', 'x', 1) +bindSmoothMove('MoveRight', 'y', 1) +bindSmoothMove('MoveLeft', 'y', -1) + +input.bindAction('Run', async:callback(function(_, run) + if not settings:get('smoothControllerMovement') then + return run + end + local smoothRun, movement = computeSmoothMovement() + if movement:length2() > 0 then + -- ignore always run + return smoothRun ~= settings:get('alwaysRun') + else + return run + end +end), {}) diff --git a/files/data/scripts/omw/playercontrols.lua b/files/data/scripts/omw/playercontrols.lua index 7b405180e8..d3f9ecea5f 100644 --- a/files/data/scripts/omw/playercontrols.lua +++ b/files/data/scripts/omw/playercontrols.lua @@ -1,12 +1,12 @@ local core = require('openmw.core') local input = require('openmw.input') local self = require('openmw.self') -local util = require('openmw.util') +local storage = require('openmw.storage') local ui = require('openmw.ui') +local async = require('openmw.async') local Actor = require('openmw.types').Actor local Player = require('openmw.types').Player -local storage = require('openmw.storage') local I = require('openmw.interfaces') local settingsGroup = 'SettingsOMWControls' @@ -16,16 +16,16 @@ local function boolSetting(key, default) key = key, renderer = 'checkbox', name = key, - description = key..'Description', + description = key .. 'Description', default = default, } end I.Settings.registerPage({ - key = 'OMWControls', - l10n = 'OMWControls', - name = 'ControlsPage', - description = 'ControlsPageDescription', + key = 'OMWControls', + l10n = 'OMWControls', + name = 'ControlsPage', + description = 'ControlsPageDescription', }) I.Settings.registerGroup({ @@ -36,171 +36,239 @@ I.Settings.registerGroup({ permanentStorage = true, settings = { boolSetting('alwaysRun', false), - boolSetting('toggleSneak', false), + boolSetting('toggleSneak', false), -- TODO: consider removing this setting when we have the advanced binding UI + boolSetting('smoothControllerMovement', true), }, }) -local settings = storage.playerSection(settingsGroup) +local settings = storage.playerSection('SettingsOMWControls') -local attemptJump = false -local startAttack = false -local autoMove = false -local movementControlsOverridden = false -local combatControlsOverridden = false -local uiControlsOverridden = false +do + local rangeActions = { + 'MoveForward', + 'MoveBackward', + 'MoveLeft', + 'MoveRight' + } + for _, key in ipairs(rangeActions) do + input.registerAction { + key = key, + l10n = 'OMWControls', + name = key .. '_name', + description = key .. '_description', + type = input.ACTION_TYPE.Range, + defaultValue = 0, + } + end -local function processMovement() - local controllerMovement = -input.getAxisValue(input.CONTROLLER_AXIS.MoveForwardBackward) - local controllerSideMovement = input.getAxisValue(input.CONTROLLER_AXIS.MoveLeftRight) - if controllerMovement ~= 0 or controllerSideMovement ~= 0 then - -- controller movement - if util.vector2(controllerMovement, controllerSideMovement):length2() < 0.25 - and not self.controls.sneak and Actor.isOnGround(self) and not Actor.isSwimming(self) then - self.controls.run = false - self.controls.movement = controllerMovement * 2 - self.controls.sideMovement = controllerSideMovement * 2 - else - self.controls.run = true - self.controls.movement = controllerMovement - self.controls.sideMovement = controllerSideMovement - end + local booleanActions = { + 'Use', + 'Run', + 'Sneak', + } + for _, key in ipairs(booleanActions) do + input.registerAction { + key = key, + l10n = 'OMWControls', + name = key .. '_name', + description = key .. '_description', + type = input.ACTION_TYPE.Boolean, + defaultValue = false, + } + end + + local triggers = { + 'Jump', + 'AutoMove', + 'ToggleWeapon', + 'ToggleSpell', + 'AlwaysRun', + 'ToggleSneak', + 'Inventory', + 'Journal', + 'QuickKeysMenu', + } + for _, key in ipairs(triggers) do + input.registerTrigger { + key = key, + l10n = 'OMWControls', + name = key .. '_name', + description = key .. '_description', + } + end +end + +local function checkNotWerewolf() + if Player.isWerewolf(self) then + ui.showMessage(core.getGMST('sWerewolfRefusal')) + return false else - -- keyboard movement - self.controls.movement = 0 - self.controls.sideMovement = 0 - if input.isActionPressed(input.ACTION.MoveLeft) then - self.controls.sideMovement = self.controls.sideMovement - 1 - end - if input.isActionPressed(input.ACTION.MoveRight) then - self.controls.sideMovement = self.controls.sideMovement + 1 - end - if input.isActionPressed(input.ACTION.MoveBackward) then - self.controls.movement = self.controls.movement - 1 - end - if input.isActionPressed(input.ACTION.MoveForward) then - self.controls.movement = self.controls.movement + 1 - end - self.controls.run = input.isActionPressed(input.ACTION.Run) ~= settings:get('alwaysRun') + return true end - if self.controls.movement ~= 0 or not Actor.canMove(self) then +end + +local function isJournalAllowed() + -- During chargen journal is not allowed until magic window is allowed + return I.UI.getWindowsForMode(I.UI.MODE.Interface)[I.UI.WINDOW.Magic] +end + +local movementControlsOverridden = false + +local autoMove = false +local function processMovement() + local movement = input.getRangeActionValue('MoveForward') - input.getRangeActionValue('MoveBackward') + local sideMovement = input.getRangeActionValue('MoveRight') - input.getRangeActionValue('MoveLeft') + local run = input.getBooleanActionValue('Run') ~= settings:get('alwaysRun') + + if movement ~= 0 or not Actor.canMove(self) then autoMove = false elseif autoMove then - self.controls.movement = 1 + movement = 1 end - self.controls.jump = attemptJump and Player.getControlSwitch(self, Player.CONTROL_SWITCH.Jumping) + + self.controls.movement = movement + self.controls.sideMovement = sideMovement + self.controls.run = run + if not settings:get('toggleSneak') then - self.controls.sneak = input.isActionPressed(input.ACTION.Sneak) + self.controls.sneak = input.getBooleanActionValue('Sneak') end end -local function processAttacking() - if startAttack then - self.controls.use = 1 - elseif Actor.stance(self) == Actor.STANCE.Spell then - self.controls.use = 0 - elseif input.getAxisValue(input.CONTROLLER_AXIS.TriggerRight) < 0.6 - and not input.isActionPressed(input.ACTION.Use) then - -- The value "0.6" shouldn't exceed the triggering threshold in BindingsManager::actionValueChanged. - -- TODO: Move more logic from BindingsManager to Lua and consider to make this threshold configurable. - self.controls.use = 0 +local function controlsAllowed() + return not core.isWorldPaused() + and Player.getControlSwitch(self, Player.CONTROL_SWITCH.Controls) + and not I.UI.getMode() +end + +local function movementAllowed() + return controlsAllowed() and not movementControlsOverridden +end + +input.registerTriggerHandler('Jump', async:callback(function() + if not movementAllowed() then return end + self.controls.jump = Player.getControlSwitch(self, Player.CONTROL_SWITCH.Jumping) +end)) + +input.registerTriggerHandler('ToggleSneak', async:callback(function() + if not movementAllowed() then return end + if settings:get('toggleSneak') then + self.controls.sneak = not self.controls.sneak end +end)) + +input.registerTriggerHandler('AlwaysRun', async:callback(function() + if not movementAllowed() then return end + settings:set('alwaysRun', not settings:get('alwaysRun')) +end)) + +input.registerTriggerHandler('AutoMove', async:callback(function() + if not movementAllowed() then return end + autoMove = not autoMove +end)) + +local combatControlsOverridden = false + +local function combatAllowed() + return controlsAllowed() and not combatControlsOverridden end -local function onFrame(dt) - local controlsAllowed = Player.getControlSwitch(self, Player.CONTROL_SWITCH.Controls) - and not core.isWorldPaused() and not I.UI.getMode() - if not movementControlsOverridden then - if controlsAllowed then - processMovement() - else - self.controls.movement = 0 - self.controls.sideMovement = 0 - self.controls.jump = false +input.registerTriggerHandler('ToggleSpell', async:callback(function() + if not combatAllowed() then return end + if Actor.stance(self) == Actor.STANCE.Spell then + Actor.setStance(self, Actor.STANCE.Nothing) + elseif Player.getControlSwitch(self, Player.CONTROL_SWITCH.Magic) then + if checkNotWerewolf() then + Actor.setStance(self, Actor.STANCE.Spell) end end - if controlsAllowed and not combatControlsOverridden then - processAttacking() +end)) + +input.registerTriggerHandler('ToggleWeapon', async:callback(function() + if not combatAllowed() then return end + if Actor.stance(self) == Actor.STANCE.Weapon then + Actor.setStance(self, Actor.STANCE.Nothing) + elseif Player.getControlSwitch(self, Player.CONTROL_SWITCH.Fighting) then + Actor.setStance(self, Actor.STANCE.Weapon) end - attemptJump = false - startAttack = false -end +end)) -local function checkNotWerewolf() - if Player.isWerewolf(self) then - ui.showMessage(core.getGMST('sWerewolfRefusal')) - return false +local startUse = false +input.registerActionHandler('Use', async:callback(function(value) + if value then startUse = true end +end)) +local function processAttacking() + if Actor.stance(self) == Actor.STANCE.Spell then + self.controls.use = startUse and 1 or 0 else - return true + self.controls.use = input.getBooleanActionValue('Use') and 1 or 0 end + startUse = false end -local function isJournalAllowed() - -- During chargen journal is not allowed until magic window is allowed - return I.UI.getWindowsForMode(I.UI.MODE.Interface)[I.UI.WINDOW.Magic] +local uiControlsOverridden = false + +input.registerTriggerHandler('ToggleWeapon', async:callback(function() + if not combatAllowed() then return end + if Actor.stance(self) == Actor.STANCE.Weapon then + Actor.setStance(self, Actor.STANCE.Nothing) + elseif Player.getControlSwitch(self, Player.CONTROL_SWITCH.Fighting) then + Actor.setStance(self, Actor.STANCE.Weapon) + end +end)) + + +local function uiAllowed() + return Player.getControlSwitch(self, Player.CONTROL_SWITCH.Controls) and not uiControlsOverridden end -local function onInputAction(action) - if not Player.getControlSwitch(self, Player.CONTROL_SWITCH.Controls) then - return - end - - if not uiControlsOverridden then - if action == input.ACTION.Inventory then - if I.UI.getMode() == nil then - I.UI.setMode(I.UI.MODE.Interface) - elseif I.UI.getMode() == I.UI.MODE.Interface or I.UI.getMode() == I.UI.MODE.Container then - I.UI.removeMode(I.UI.getMode()) - end - elseif action == input.ACTION.Journal then - if I.UI.getMode() == I.UI.MODE.Journal then - I.UI.removeMode(I.UI.MODE.Journal) - elseif isJournalAllowed() then - I.UI.addMode(I.UI.MODE.Journal) - end - elseif action == input.ACTION.QuickKeysMenu then - if I.UI.getMode() == I.UI.MODE.QuickKeysMenu then - I.UI.removeMode(I.UI.MODE.QuickKeysMenu) - elseif checkNotWerewolf() and Player.isCharGenFinished(self) then - I.UI.addMode(I.UI.MODE.QuickKeysMenu) - end - end +input.registerTriggerHandler('Inventory', async:callback(function() + if not uiAllowed() then return end + + if I.UI.getMode() == nil then + I.UI.setMode(I.UI.MODE.Interface) + elseif I.UI.getMode() == I.UI.MODE.Interface or I.UI.getMode() == I.UI.MODE.Container then + I.UI.removeMode(I.UI.getMode()) end +end)) + +input.registerTriggerHandler('Journal', async:callback(function() + if not uiAllowed() then return end - if core.isWorldPaused() or I.UI.getMode() then - return + if I.UI.getMode() == I.UI.MODE.Journal then + I.UI.removeMode(I.UI.MODE.Journal) + elseif isJournalAllowed() then + I.UI.addMode(I.UI.MODE.Journal) end +end)) - if action == input.ACTION.Jump then - attemptJump = true - elseif action == input.ACTION.Use then - startAttack = Actor.stance(self) ~= Actor.STANCE.Nothing - elseif action == input.ACTION.AutoMove and not movementControlsOverridden then - autoMove = not autoMove - elseif action == input.ACTION.AlwaysRun and not movementControlsOverridden then - settings:set('alwaysRun', not settings:get('alwaysRun')) - elseif action == input.ACTION.Sneak and not movementControlsOverridden then - if settings:get('toggleSneak') then - self.controls.sneak = not self.controls.sneak - end - elseif action == input.ACTION.ToggleSpell and not combatControlsOverridden then - if Actor.stance(self) == Actor.STANCE.Spell then - Actor.setStance(self, Actor.STANCE.Nothing) - elseif Player.getControlSwitch(self, Player.CONTROL_SWITCH.Magic) then - if checkNotWerewolf() then - Actor.setStance(self, Actor.STANCE.Spell) - end - end - elseif action == input.ACTION.ToggleWeapon and not combatControlsOverridden then - if Actor.stance(self) == Actor.STANCE.Weapon then - Actor.setStance(self, Actor.STANCE.Nothing) - elseif Player.getControlSwitch(self, Player.CONTROL_SWITCH.Fighting) then - Actor.setStance(self, Actor.STANCE.Weapon) - end +input.registerTriggerHandler('QuickKeysMenu', async:callback(function() + if not uiAllowed() then return end + + if I.UI.getMode() == I.UI.MODE.QuickKeysMenu then + I.UI.removeMode(I.UI.MODE.QuickKeysMenu) + elseif checkNotWerewolf() and Player.isCharGenFinished(self) then + I.UI.addMode(I.UI.MODE.QuickKeysMenu) + end +end)) + +local function onFrame(_) + if movementAllowed() then + processMovement() + elseif not movementControlsOverridden then + self.controls.movement = 0 + self.controls.sideMovement = 0 + self.controls.jump = false + end + if combatAllowed() then + processAttacking() end end local function onSave() - return {sneaking = self.controls.sneak} + return { + sneaking = self.controls.sneak + } end local function onLoad(data) @@ -211,7 +279,6 @@ end return { engineHandlers = { onFrame = onFrame, - onInputAction = onInputAction, onSave = onSave, onLoad = onLoad, }, @@ -242,4 +309,3 @@ return { overrideUiControls = function(v) uiControlsOverridden = v end, } } - diff --git a/files/lua_api/openmw/input.lua b/files/lua_api/openmw/input.lua index 4ca4e5af4e..563a4ab1f5 100644 --- a/files/lua_api/openmw/input.lua +++ b/files/lua_api/openmw/input.lua @@ -11,8 +11,7 @@ -- @return #boolean --- --- Is a specific control currently pressed. --- Input bindings can be changed ingame using Options/Controls menu. +-- (DEPRECATED, use getBooleanActionValue) Input bindings can be changed ingame using Options/Controls menu. -- @function [parent=#input] isActionPressed -- @param #number actionId One of @{openmw.input#ACTION} -- @return #boolean @@ -108,6 +107,7 @@ -- @field [parent=#input] #CONTROL_SWITCH CONTROL_SWITCH --- +-- (DEPRECATED, use actions with matching keys) -- @type ACTION -- @field [parent=#ACTION] #number GameMenu -- @field [parent=#ACTION] #number Screenshot @@ -153,7 +153,7 @@ -- @field [parent=#ACTION] #number TogglePostProcessorHUD --- --- Values that can be used with isActionPressed. +-- (DEPRECATED, use getBooleanActionValue) Values that can be used with isActionPressed. -- @field [parent=#input] #ACTION ACTION --- @@ -187,10 +187,10 @@ -- @field [parent=#CONTROLLER_AXIS] #number RightY Right stick vertical axis (from -1 to 1) -- @field [parent=#CONTROLLER_AXIS] #number TriggerLeft Left trigger (from 0 to 1) -- @field [parent=#CONTROLLER_AXIS] #number TriggerRight Right trigger (from 0 to 1) --- @field [parent=#CONTROLLER_AXIS] #number LookUpDown View direction vertical axis (RightY by default, can be mapped to another axis in Options/Controls menu) --- @field [parent=#CONTROLLER_AXIS] #number LookLeftRight View direction horizontal axis (RightX by default, can be mapped to another axis in Options/Controls menu) --- @field [parent=#CONTROLLER_AXIS] #number MoveForwardBackward Movement forward/backward (LeftY by default, can be mapped to another axis in Options/Controls menu) --- @field [parent=#CONTROLLER_AXIS] #number MoveLeftRight Side movement (LeftX by default, can be mapped to another axis in Options/Controls menu) +-- @field [parent=#CONTROLLER_AXIS] #number LookUpDown (DEPRECATED, use the LookUpDown action) View direction vertical axis (RightY by default, can be mapped to another axis in Options/Controls menu) +-- @field [parent=#CONTROLLER_AXIS] #number LookLeftRight (DEPRECATED, use the LookLeftRight action) View direction horizontal axis (RightX by default, can be mapped to another axis in Options/Controls menu) +-- @field [parent=#CONTROLLER_AXIS] #number MoveForwardBackward (DEPRECATED, use the MoveForwardBackward action) Movement forward/backward (LeftY by default, can be mapped to another axis in Options/Controls menu) +-- @field [parent=#CONTROLLER_AXIS] #number MoveLeftRight (DEPRECATED, use the MoveLeftRight action) Side movement (LeftX by default, can be mapped to another axis in Options/Controls menu) --- -- Values that can be used with getAxisValue. @@ -327,4 +327,105 @@ -- @field [parent=#TouchEvent] openmw.util#Vector2 position Relative position on the touch device (0 to 1 from top left corner), -- @field [parent=#TouchEvent] #number pressure Pressure of the finger. +--- +-- @type ActionType + +--- +-- @type ACTION_TYPE +-- @field #ActionType Boolean Input action with value of true or false +-- @field #ActionType Number Input action with a numeric value +-- @field #ActionType Range Input action with a numeric value between 0 and 1 (inclusive) + +--- +-- Values that can be used in registerAction +-- @field [parent=#input] #ACTION_TYPE ACTION_TYPE + +--- +-- @type ActionInfo +-- @field [parent=#Actioninfo] #string key +-- @field [parent=#Actioninfo] #ActionType type +-- @field [parent=#Actioninfo] #string l10n Localization context containing the name and description keys +-- @field [parent=#Actioninfo] #string name Localization key of the action's name +-- @field [parent=#Actioninfo] #string description Localization key of the action's description +-- @field [parent=#Actioninfo] defaultValue initial value of the action + +--- +-- Map of all currently registered actions +-- @field [parent=#input] #map<#string,#ActionInfo> actions + +--- +-- Registers a new input action. The key must be unique +-- @function [parent=#input] registerAction +-- @param #ActionInfo info + +--- +-- Provides a function computing the value of given input action. +-- The callback is called once a frame, after the values of dependency actions are resolved. +-- Throws an error if a cyclic action dependency is detected. +-- @function [parent=#input] bindAction +-- @param #string key +-- @param openmw.async#Callback callback returning the new value of the action, and taking as arguments: +-- frame time in seconds, +-- value of the function, +-- value of the first dependency action, +-- ... +-- @param #list<#string> dependencies +-- @usage +-- input.bindAction('Activate', async:callback(function(dt, use, sneak, run) +-- -- while sneaking, only activate things while holding the run binding +-- return use and (run or not sneak) +-- end), { 'Sneak', 'Run' }) + +--- +-- Registers a function to be called whenever the action's value changes +-- @function [parent=#input] registerActionHandler +-- @param #string key +-- @param openmw.async#Callback callback takes the new action value as the only argument + +--- +-- Returns the value of a Boolean action +-- @function [parent=#input] getBooleanActionValue +-- @param #string key +-- @return #boolean + +--- +-- Returns the value of a Number action +-- @function [parent=#input] getNumberActionValue +-- @param #string key +-- @return #number + +--- +-- Returns the value of a Range action +-- @function [parent=#input] getRangeActionValue +-- @param #string key +-- @return #number + +--- +-- @type TriggerInfo +-- @field [parent=#Actioninfo] #string key +-- @field [parent=#Actioninfo] #string l10n Localization context containing the name and description keys +-- @field [parent=#Actioninfo] #string name Localization key of the trigger's name +-- @field [parent=#Actioninfo] #string description Localization key of the trigger's description + +--- +-- Map of all currently registered triggers +-- @field [parent=#input] #map<#string,#TriggerInfo> triggers + +--- +-- Registers a new input trigger. The key must be unique +-- @function [parent=#input] registerTrigger +-- @param #TriggerInfo info + +--- +-- Registers a function to be called whenever the trigger activates +-- @function [parent=#input] registerTriggerHandler +-- @param #string key +-- @param openmw.async#Callback callback takes the new action value as the only argument + +--- +-- Activates the trigger with the given key +-- @function [parent=#input] activateTrigger +-- @param #string key + + return nil