diff --git a/files.cmake b/files.cmake index 7c5524e2b6..ee5673320e 100644 --- a/files.cmake +++ b/files.cmake @@ -1474,6 +1474,8 @@ set(DUSK_FILES src/dusk/ui/editor.hpp src/dusk/ui/event.cpp src/dusk/ui/event.hpp + src/dusk/ui/input.cpp + src/dusk/ui/input.hpp src/dusk/ui/nav_types.hpp src/dusk/ui/number_button.cpp src/dusk/ui/number_button.hpp diff --git a/src/dusk/ui/document.cpp b/src/dusk/ui/document.cpp index b733284694..683d495d41 100644 --- a/src/dusk/ui/document.cpp +++ b/src/dusk/ui/document.cpp @@ -17,6 +17,17 @@ Rml::ElementDocument* load_document(const Rml::String& source) { } // namespace Document::Document(const Rml::String& source) : mDocument(load_document(source)) { + // Block keydown events while hidden (except for Menu) + listen( + Rml::EventId::Keydown, + [this](Rml::Event& event) { + const auto cmd = map_nav_event(event); + if (cmd != NavCommand::Menu && !visible()) { + event.StopPropagation(); + } + }, + true); + listen(Rml::EventId::Keydown, [this](Rml::Event& event) { const auto cmd = map_nav_event(event); if (cmd != NavCommand::None && handle_nav_command(event, cmd)) { @@ -76,7 +87,18 @@ bool Document::can_destroy() const { return *mDocument->GetProperty(Rml::PropertyId::Visibility) == Rml::Style::Visibility::Hidden; } +bool Document::visible() const { + if (mDocument == nullptr) { + return false; + } + return *mDocument->GetProperty(Rml::PropertyId::Visibility) == Rml::Style::Visibility::Visible; +} + bool Document::handle_nav_command(Rml::Event& event, NavCommand cmd) { + if (cmd == NavCommand::Menu) { + toggle_top_document(); + return true; + } return false; } diff --git a/src/dusk/ui/document.hpp b/src/dusk/ui/document.hpp index d27d20c59d..f9670abbbd 100644 --- a/src/dusk/ui/document.hpp +++ b/src/dusk/ui/document.hpp @@ -17,6 +17,7 @@ public: virtual void hide(); virtual void update(); virtual bool focus(); + virtual bool visible() const; void listen(Rml::Element* element, Rml::EventId event, ScopedEventListener::Callback callback, bool capture = false); @@ -33,4 +34,4 @@ protected: std::vector > mListeners; }; -} // namespace dusk::ui \ No newline at end of file +} // namespace dusk::ui diff --git a/src/dusk/ui/input.cpp b/src/dusk/ui/input.cpp new file mode 100644 index 0000000000..8ed17bc877 --- /dev/null +++ b/src/dusk/ui/input.cpp @@ -0,0 +1,610 @@ +#include "input.hpp" + +#include "ui.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +namespace dusk::ui { +namespace { + +constexpr double kGamepadRepeatInitialDelay = 0.32; +constexpr double kGamepadRepeatStartInterval = 0.12; +constexpr double kGamepadRepeatMinInterval = 0.045; +constexpr double kGamepadRepeatRampDuration = 1.0; +constexpr double kGamepadMenuChordGraceDuration = 0.12; +constexpr Sint16 kGamepadAxisPressThreshold = 16384; +constexpr Sint16 kGamepadAxisReleaseThreshold = 12000; +constexpr int kGamepadAxisDirectionCount = SDL_GAMEPAD_AXIS_COUNT * 2; + +struct GamepadRepeatState { + Rml::Input::KeyIdentifier key = Rml::Input::KI_UNKNOWN; + double pressedAt = 0.0; + double nextRepeatAt = 0.0; + bool held = false; + bool repeatable = false; + bool pending = false; +}; + +bool sPadInputBlocked = false; +std::array sGamepadButtonRepeats; +std::array sGamepadAxisRepeats; +std::array sPadHoldMasks; +std::array sMenuChordConsumed; + +double now_seconds() noexcept { + return static_cast(SDL_GetTicksNS()) / 1000000000.0; +} + +bool is_menu_chord_part(PADButton button) noexcept { + return button == PAD_TRIGGER_R || button == PAD_BUTTON_START; +} + +bool has_menu_chord_part_held(u32 port) noexcept { + if (port >= sPadHoldMasks.size()) { + return false; + } + + const u32 held = sPadHoldMasks[port]; + return (held & (PAD_TRIGGER_R | PAD_BUTTON_START)) != 0; +} + +bool should_block_pad_for_menu_chord() noexcept { + for (u32 port = 0; port < sPadHoldMasks.size(); ++port) { + if (sMenuChordConsumed[port] && has_menu_chord_part_held(port)) { + return true; + } + } + return false; +} + +PADButton pad_button_from_axis(PADAxis axis) noexcept { + switch (axis) { + case PAD_AXIS_TRIGGER_R: + return PAD_TRIGGER_R; + case PAD_AXIS_TRIGGER_L: + return PAD_TRIGGER_L; + default: + return 0; + } +} + +void set_pad_button_held(u32 port, PADButton button, bool held) noexcept { + if (port >= sPadHoldMasks.size() || button == 0) { + return; + } + + if (held) { + sPadHoldMasks[port] |= button; + } else { + sPadHoldMasks[port] &= ~button; + } +} + +bool is_menu_chord(u32 port) noexcept { + if (port >= sPadHoldMasks.size()) { + return false; + } + + const u32 held = sPadHoldMasks[port]; + return (held & PAD_TRIGGER_R) != 0 && (held & PAD_BUTTON_START) != 0; +} + +bool any_menu_chord() noexcept { + return std::any_of(sPadHoldMasks.begin(), sPadHoldMasks.end(), + [](u32 held) { return (held & PAD_TRIGGER_R) != 0 && (held & PAD_BUTTON_START) != 0; }); +} + +Rml::Input::KeyIdentifier map_pad_button(PADButton button) noexcept { + switch (button) { + case PAD_BUTTON_UP: + return Rml::Input::KI_UP; + case PAD_BUTTON_DOWN: + return Rml::Input::KI_DOWN; + case PAD_BUTTON_LEFT: + return Rml::Input::KI_LEFT; + case PAD_BUTTON_RIGHT: + return Rml::Input::KI_RIGHT; + case PAD_BUTTON_B: + return Rml::Input::KI_ESCAPE; + case PAD_BUTTON_A: + return Rml::Input::KI_RETURN; + case PAD_TRIGGER_R: + return Rml::Input::KI_NEXT; + case PAD_TRIGGER_L: + return Rml::Input::KI_PRIOR; + default: + return Rml::Input::KI_UNKNOWN; + } +} + +Rml::Input::KeyIdentifier map_pad_axis(PADAxis axis) noexcept { + switch (axis) { + case PAD_AXIS_LEFT_X_POS: + return Rml::Input::KI_RIGHT; + case PAD_AXIS_LEFT_X_NEG: + return Rml::Input::KI_LEFT; + case PAD_AXIS_LEFT_Y_POS: + return Rml::Input::KI_UP; + case PAD_AXIS_LEFT_Y_NEG: + return Rml::Input::KI_DOWN; + case PAD_AXIS_TRIGGER_R: + return Rml::Input::KI_NEXT; + case PAD_AXIS_TRIGGER_L: + return Rml::Input::KI_PRIOR; + default: + return Rml::Input::KI_UNKNOWN; + } +} + +Rml::Input::KeyIdentifier map_raw_gamepad_button(SDL_GamepadButton button) noexcept { + switch (button) { + case SDL_GAMEPAD_BUTTON_DPAD_UP: + return Rml::Input::KI_UP; + case SDL_GAMEPAD_BUTTON_DPAD_DOWN: + return Rml::Input::KI_DOWN; + case SDL_GAMEPAD_BUTTON_DPAD_LEFT: + return Rml::Input::KI_LEFT; + case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: + return Rml::Input::KI_RIGHT; + case SDL_GAMEPAD_BUTTON_EAST: + return Rml::Input::KI_ESCAPE; + case SDL_GAMEPAD_BUTTON_SOUTH: + return Rml::Input::KI_RETURN; + case SDL_GAMEPAD_BUTTON_BACK: + return Rml::Input::KI_PAUSE; + case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER: + return Rml::Input::KI_NEXT; + case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER: + return Rml::Input::KI_PRIOR; + default: + return Rml::Input::KI_UNKNOWN; + } +} + +Rml::Input::KeyIdentifier map_raw_button_alias(SDL_GamepadButton button) noexcept { + switch (button) { + case SDL_GAMEPAD_BUTTON_BACK: + return Rml::Input::KI_PAUSE; + case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER: + return Rml::Input::KI_NEXT; + case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER: + return Rml::Input::KI_PRIOR; + default: + return Rml::Input::KI_UNKNOWN; + } +} + +Rml::Input::KeyIdentifier map_raw_gamepad_axis(SDL_GamepadAxis axis, PADAxisSign sign) noexcept { + switch (axis) { + case SDL_GAMEPAD_AXIS_LEFTX: + return sign == AXIS_SIGN_POSITIVE ? Rml::Input::KI_RIGHT : Rml::Input::KI_LEFT; + case SDL_GAMEPAD_AXIS_LEFTY: + return sign == AXIS_SIGN_NEGATIVE ? Rml::Input::KI_UP : Rml::Input::KI_DOWN; + case SDL_GAMEPAD_AXIS_RIGHT_TRIGGER: + return sign == AXIS_SIGN_POSITIVE ? Rml::Input::KI_NEXT : Rml::Input::KI_UNKNOWN; + case SDL_GAMEPAD_AXIS_LEFT_TRIGGER: + return sign == AXIS_SIGN_POSITIVE ? Rml::Input::KI_PRIOR : Rml::Input::KI_UNKNOWN; + default: + return Rml::Input::KI_UNKNOWN; + } +} + +bool find_event_port(SDL_JoystickID instance, u32& port) noexcept { + for (u32 candidate = 0; candidate < PAD_MAX_CONTROLLERS; ++candidate) { + const s32 index = PADGetIndexForPort(candidate); + if (index < 0) { + continue; + } + + SDL_Gamepad* gamepad = PADGetSDLGamepadForIndex(static_cast(index)); + if (gamepad == nullptr) { + continue; + } + + SDL_Joystick* joystick = SDL_GetGamepadJoystick(gamepad); + if (joystick != nullptr && SDL_GetJoystickID(joystick) == instance) { + port = candidate; + return true; + } + } + return false; +} + +bool find_mapped_pad_button(u32 port, SDL_GamepadButton nativeButton, PADButton& button) noexcept { + u32 buttonCount = 0; + PADButtonMapping* buttons = PADGetButtonMappings(port, &buttonCount); + if (buttons != nullptr) { + for (u32 i = 0; i < buttonCount; ++i) { + if (buttons[i].nativeButton == static_cast(nativeButton)) { + button = buttons[i].padButton; + return true; + } + } + } + + u32 axisCount = 0; + PADAxisMapping* axes = PADGetAxisMappings(port, &axisCount); + if (axes != nullptr) { + for (u32 i = 0; i < axisCount; ++i) { + if (axes[i].nativeButton == nativeButton) { + button = pad_button_from_axis(axes[i].padAxis); + return button != 0; + } + } + } + + return false; +} + +bool find_mapped_pad_axis( + u32 port, SDL_GamepadAxis nativeAxis, PADAxisSign sign, PADAxis& axis) noexcept { + u32 buttonCount = 0; + PADGetButtonMappings(port, &buttonCount); + + u32 axisCount = 0; + PADAxisMapping* axes = PADGetAxisMappings(port, &axisCount); + if (axes == nullptr) { + return false; + } + + for (u32 i = 0; i < axisCount; ++i) { + const PADSignedNativeAxis mappedAxis = axes[i].nativeAxis; + if (mappedAxis.nativeAxis == nativeAxis && mappedAxis.sign == sign) { + axis = axes[i].padAxis; + return true; + } + } + + return false; +} + +bool find_event_pad_button( + const SDL_GamepadButtonEvent& event, u32& port, PADButton& button) noexcept { + return find_event_port(event.which, port) && + find_mapped_pad_button(port, static_cast(event.button), button); +} + +Rml::Input::KeyIdentifier map_gamepad_button(const SDL_GamepadButtonEvent& event) noexcept { + const auto nativeButton = static_cast(event.button); + if (nativeButton == SDL_GAMEPAD_BUTTON_BACK) { + return Rml::Input::KI_PAUSE; + } + + u32 port = 0; + if (!find_event_port(event.which, port)) { + return map_raw_gamepad_button(nativeButton); + } + + PADButton button = 0; + if (find_mapped_pad_button(port, nativeButton, button)) { + const auto key = map_pad_button(button); + return key == Rml::Input::KI_UNKNOWN ? map_raw_button_alias(nativeButton) : key; + } + + return map_raw_button_alias(nativeButton); +} + +Rml::Input::KeyIdentifier map_gamepad_axis( + const SDL_GamepadAxisEvent& event, PADAxisSign sign) noexcept { + u32 port = 0; + if (!find_event_port(event.which, port)) { + return map_raw_gamepad_axis(static_cast(event.axis), sign); + } + + PADAxis axis = 0; + if (find_mapped_pad_axis(port, static_cast(event.axis), sign, axis)) { + return map_pad_axis(axis); + } + + return Rml::Input::KI_UNKNOWN; +} + +bool is_repeatable_key(Rml::Input::KeyIdentifier key) noexcept { + switch (key) { + case Rml::Input::KI_UP: + case Rml::Input::KI_DOWN: + case Rml::Input::KI_LEFT: + case Rml::Input::KI_RIGHT: + case Rml::Input::KI_NEXT: + case Rml::Input::KI_PRIOR: + return true; + default: + return false; + } +} + +double repeat_interval(double heldFor) noexcept { + const double ramp = std::clamp(heldFor / kGamepadRepeatRampDuration, 0.0, 1.0); + return kGamepadRepeatStartInterval + + (kGamepadRepeatMinInterval - kGamepadRepeatStartInterval) * ramp; +} + +GamepadRepeatState* button_repeat_state(SDL_GamepadButton button) noexcept { + const auto index = static_cast(button); + if (index < 0 || index >= static_cast(sGamepadButtonRepeats.size())) { + return nullptr; + } + return &sGamepadButtonRepeats[index]; +} + +GamepadRepeatState* axis_repeat_state(SDL_GamepadAxis axis, PADAxisSign sign) noexcept { + const auto axisIndex = static_cast(axis); + if (axisIndex < 0 || axisIndex >= SDL_GAMEPAD_AXIS_COUNT) { + return nullptr; + } + + const int directionOffset = sign == AXIS_SIGN_POSITIVE ? 0 : 1; + return &sGamepadAxisRepeats[axisIndex * 2 + directionOffset]; +} + +void clear_gamepad_repeats() noexcept { + for (auto& repeat : sGamepadButtonRepeats) { + repeat = {}; + } + for (auto& repeat : sGamepadAxisRepeats) { + repeat = {}; + } + sPadHoldMasks.fill(0); + sMenuChordConsumed.fill(false); +} + +void begin_gamepad_key(GamepadRepeatState& repeat, Rml::Input::KeyIdentifier key) noexcept { + if (repeat.held) { + return; + } + + const double now = now_seconds(); + repeat.key = key; + repeat.pressedAt = now; + repeat.held = true; + repeat.repeatable = is_repeatable_key(key); + repeat.nextRepeatAt = repeat.repeatable ? now + kGamepadRepeatInitialDelay : 0.0; + repeat.pending = false; +} + +void begin_pending_gamepad_key(GamepadRepeatState& repeat, Rml::Input::KeyIdentifier key) noexcept { + if (repeat.held) { + return; + } + + const double now = now_seconds(); + repeat.key = key; + repeat.pressedAt = now; + repeat.held = true; + repeat.repeatable = is_repeatable_key(key); + repeat.nextRepeatAt = 0.0; + repeat.pending = true; +} + +void consume_menu_chord(u32 port, Rml::Context& context) noexcept { + if (port < sMenuChordConsumed.size()) { + sMenuChordConsumed[port] = true; + } + + auto cancel_next = [&context](GamepadRepeatState& repeat) { + if (!repeat.held || repeat.key != Rml::Input::KI_NEXT) { + return; + } + if (!repeat.pending) { + context.ProcessKeyUp(repeat.key, 0); + } + repeat = {}; + }; + + for (auto& repeat : sGamepadButtonRepeats) { + cancel_next(repeat); + } + for (auto& repeat : sGamepadAxisRepeats) { + cancel_next(repeat); + } +} + +void update_menu_chord_release(u32 port) noexcept { + if (port >= sMenuChordConsumed.size() || has_menu_chord_part_held(port)) { + return; + } + + sMenuChordConsumed[port] = false; +} + +bool should_defer_menu_chord_part(PADButton button, Rml::Input::KeyIdentifier key) noexcept { + return button == PAD_TRIGGER_R && key == Rml::Input::KI_NEXT; +} + +void process_axis_direction( + Rml::Context& context, const SDL_GamepadAxisEvent& event, PADAxisSign sign) noexcept { + GamepadRepeatState* repeat = axis_repeat_state(static_cast(event.axis), sign); + if (repeat == nullptr) { + return; + } + + const bool active = sign == AXIS_SIGN_POSITIVE ? event.value >= kGamepadAxisPressThreshold : + event.value <= -kGamepadAxisPressThreshold; + const bool released = sign == AXIS_SIGN_POSITIVE ? event.value <= kGamepadAxisReleaseThreshold : + event.value >= -kGamepadAxisReleaseThreshold; + + u32 port = 0; + PADAxis padAxis = 0; + const bool hasPadAxis = + find_event_port(event.which, port) && + find_mapped_pad_axis(port, static_cast(event.axis), sign, padAxis); + const PADButton heldPadButton = hasPadAxis ? pad_button_from_axis(padAxis) : 0; + + if (repeat->held) { + if (released) { + if (!repeat->pending) { + context.ProcessKeyUp(repeat->key, 0); + } + set_pad_button_held(port, heldPadButton, false); + *repeat = {}; + update_menu_chord_release(port); + } + return; + } + + if (!active) { + return; + } + + set_pad_button_held(port, heldPadButton, true); + const bool chorded = heldPadButton == PAD_TRIGGER_R && is_menu_chord(port); + if (chorded) { + consume_menu_chord(port, context); + } + const auto key = chorded ? Rml::Input::KI_PAUSE : map_gamepad_axis(event, sign); + if (key == Rml::Input::KI_UNKNOWN) { + return; + } + + if (!chorded && should_defer_menu_chord_part(heldPadButton, key)) { + begin_pending_gamepad_key(*repeat, key); + return; + } + + begin_gamepad_key(*repeat, key); + context.ProcessMouseLeave(); + context.ProcessKeyDown(key, 0); +} + +} // namespace + +void sync_input_block() noexcept { + const bool shouldBlock = any_document_visible() || should_block_pad_for_menu_chord(); + if (sPadInputBlocked == shouldBlock) { + return; + } + + PADBlockInput(shouldBlock); + sPadInputBlocked = shouldBlock; +} + +void release_input_block() noexcept { + if (!sPadInputBlocked) { + return; + } + + PADBlockInput(false); + sPadInputBlocked = false; +} + +void reset_input_state() noexcept { + clear_gamepad_repeats(); +} + +void handle_event(const SDL_Event& event) noexcept { + if (event.type == SDL_EVENT_GAMEPAD_REMOVED || event.type == SDL_EVENT_WINDOW_FOCUS_LOST) { + reset_input_state(); + sync_input_block(); + return; + } + if (event.type != SDL_EVENT_GAMEPAD_BUTTON_DOWN && event.type != SDL_EVENT_GAMEPAD_BUTTON_UP && + event.type != SDL_EVENT_GAMEPAD_AXIS_MOTION) + { + return; + } + + auto* context = aurora::rmlui::get_context(); + if (context == nullptr) { + return; + } + + if (event.type == SDL_EVENT_GAMEPAD_AXIS_MOTION) { + process_axis_direction(*context, event.gaxis, AXIS_SIGN_POSITIVE); + process_axis_direction(*context, event.gaxis, AXIS_SIGN_NEGATIVE); + sync_input_block(); + return; + } + + auto* repeat = button_repeat_state(static_cast(event.gbutton.button)); + u32 port = 0; + PADButton button = 0; + const bool hasPadButton = find_event_pad_button(event.gbutton, port, button); + if (event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN) { + set_pad_button_held(port, button, true); + const bool chorded = hasPadButton && is_menu_chord_part(button) && is_menu_chord(port); + if (chorded) { + consume_menu_chord(port, *context); + } + const auto key = chorded ? Rml::Input::KI_PAUSE : map_gamepad_button(event.gbutton); + if (key != Rml::Input::KI_UNKNOWN) { + bool deferred = false; + if (repeat != nullptr) { + if (!chorded && should_defer_menu_chord_part(button, key)) { + begin_pending_gamepad_key(*repeat, key); + deferred = true; + } else { + begin_gamepad_key(*repeat, key); + } + } + if (!deferred) { + context->ProcessMouseLeave(); + context->ProcessKeyDown(key, 0); + } + } + } else { + const auto key = repeat != nullptr && repeat->held ? repeat->key : Rml::Input::KI_UNKNOWN; + const bool wasPending = repeat != nullptr && repeat->pending; + set_pad_button_held(port, button, false); + update_menu_chord_release(port); + if (key != Rml::Input::KI_UNKNOWN) { + if (repeat != nullptr) { + *repeat = {}; + } + if (!wasPending) { + context->ProcessKeyUp(key, 0); + } + } + } + sync_input_block(); +} + +void update_input() noexcept { + auto* context = aurora::rmlui::get_context(); + if (context != nullptr) { + const double now = now_seconds(); + auto process_repeats = [context, now](auto& repeats) { + for (auto& repeat : repeats) { + if (!repeat.held) { + continue; + } + + if (repeat.pending) { + if (now < repeat.pressedAt + kGamepadMenuChordGraceDuration) { + continue; + } + + repeat.pending = false; + repeat.pressedAt = now; + repeat.nextRepeatAt = + repeat.repeatable ? now + kGamepadRepeatInitialDelay : 0.0; + context->ProcessMouseLeave(); + context->ProcessKeyDown(repeat.key, 0); + continue; + } + + if (!repeat.repeatable || now < repeat.nextRepeatAt || + (repeat.key == Rml::Input::KI_NEXT && any_menu_chord())) + { + continue; + } + + context->ProcessKeyDown(repeat.key, 0); + const double heldFor = now - repeat.pressedAt; + repeat.nextRepeatAt = now + repeat_interval(heldFor); + } + }; + process_repeats(sGamepadButtonRepeats); + process_repeats(sGamepadAxisRepeats); + } else { + reset_input_state(); + } +} + +} // namespace dusk::ui diff --git a/src/dusk/ui/input.hpp b/src/dusk/ui/input.hpp new file mode 100644 index 0000000000..b83c9ab3d2 --- /dev/null +++ b/src/dusk/ui/input.hpp @@ -0,0 +1,10 @@ +#pragma once + +namespace dusk::ui { + +void update_input() noexcept; +void reset_input_state() noexcept; +void sync_input_block() noexcept; +void release_input_block() noexcept; + +} // namespace dusk::ui diff --git a/src/dusk/ui/nav_types.hpp b/src/dusk/ui/nav_types.hpp index 7e485b9e8f..1f423a2bf3 100644 --- a/src/dusk/ui/nav_types.hpp +++ b/src/dusk/ui/nav_types.hpp @@ -12,6 +12,7 @@ enum class NavCommand { Previous, // L1 Confirm, // A Cancel, // B + Menu, // Back/Minus, or R + Start }; } // namespace dusk::ui diff --git a/src/dusk/ui/overlay.cpp b/src/dusk/ui/overlay.cpp index 811e909ab2..9661b4226d 100644 --- a/src/dusk/ui/overlay.cpp +++ b/src/dusk/ui/overlay.cpp @@ -60,9 +60,8 @@ void set_value(GraphicsOption option, int value) { getSettings().game.shadowResolutionMultiplier.setValue(value); break; case GraphicsOption::BloomMode: - getSettings().game.bloomMode.setValue( - static_cast(std::clamp(value, static_cast(BloomMode::Off), - static_cast(BloomMode::Dusk)))); + getSettings().game.bloomMode.setValue(static_cast(std::clamp( + value, static_cast(BloomMode::Off), static_cast(BloomMode::Dusk)))); break; case GraphicsOption::BloomMultiplier: getSettings().game.bloomMultiplier.setValue(std::clamp(value, 0, 100) / 100.0f); @@ -209,10 +208,12 @@ Overlay::Overlay(OverlayProps props) if (auto* footer = mDocument->GetElementById("footer")) { auto& returnButton = add_component