diff --git a/CMakeLists.txt b/CMakeLists.txt index f314e16..cae31be 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,6 +15,7 @@ endif() add_library(Caffeine src/core/Timer.cpp src/core/GameLoop.cpp + src/input/InputManager.cpp ) target_include_directories(Caffeine PUBLIC diff --git a/src/input/InputManager.cpp b/src/input/InputManager.cpp new file mode 100644 index 0000000..f391230 --- /dev/null +++ b/src/input/InputManager.cpp @@ -0,0 +1,249 @@ +#include "InputManager.hpp" + +namespace Caffeine::Input { + +InputManager::InputManager() { + m_keyState.fill(false); + m_mouseState.fill(false); + m_gamepadButtonState.fill(false); + m_gamepadAxisState.fill(0.0f); + m_prevKeyState.fill(false); + m_prevMouseState.fill(false); + m_prevGamepadButtonState.fill(false); + setupDefaultBindings(); +} + +void InputManager::beginFrame() { + m_prevKeyState = m_keyState; + m_prevMouseState = m_mouseState; + m_prevGamepadButtonState = m_gamepadButtonState; + m_prevMousePos = m_mousePos; + m_prevActionStates = m_actionStates; +} + +void InputManager::endFrame() { + for (usize i = 0; i < static_cast(Action::Count); ++i) { + auto action = static_cast(i); + const auto& bindings = m_actionBindings[i]; + + bool active = false; + for (u8 b = 0; b < bindings.count; ++b) { + if (isBindingActive(bindings.bindings[b])) { + active = true; + break; + } + } + + bool wasActive = m_prevActionStates[i].pressed; + + ActionState& state = m_actionStates[i]; + state.pressed = active; + state.justPressed = active && !wasActive; + state.justReleased = !active && wasActive; + + if (state.justPressed) fireActionPressed(action); + if (state.justReleased) fireActionReleased(action); + } +} + +void InputManager::injectKeyDown(Key key) { + auto idx = static_cast(key); + if (idx < m_keyState.size()) m_keyState[idx] = true; +} + +void InputManager::injectKeyUp(Key key) { + auto idx = static_cast(key); + if (idx < m_keyState.size()) m_keyState[idx] = false; +} + +void InputManager::injectMouseButtonDown(MouseButton button) { + auto idx = static_cast(button); + if (idx < m_mouseState.size()) m_mouseState[idx] = true; +} + +void InputManager::injectMouseButtonUp(MouseButton button) { + auto idx = static_cast(button); + if (idx < m_mouseState.size()) m_mouseState[idx] = false; +} + +void InputManager::injectMouseMove(f32 x, f32 y) { + m_mousePos = Vec2(x, y); +} + +void InputManager::injectGamepadButtonDown(GamepadButton button) { + auto idx = static_cast(button); + if (idx < m_gamepadButtonState.size()) m_gamepadButtonState[idx] = true; +} + +void InputManager::injectGamepadButtonUp(GamepadButton button) { + auto idx = static_cast(button); + if (idx < m_gamepadButtonState.size()) m_gamepadButtonState[idx] = false; +} + +void InputManager::injectGamepadAxis(GamepadAxis axis, f32 value) { + auto idx = static_cast(axis); + if (idx < m_gamepadAxisState.size()) m_gamepadAxisState[idx] = value; +} + +ActionState InputManager::actionState(Action action) const { + auto idx = static_cast(action); + if (idx < m_actionStates.size()) return m_actionStates[idx]; + return {}; +} + +AxisState InputManager::axisState(Axis axis) const { + auto idx = static_cast(axis); + if (idx >= static_cast(Axis::Count)) return {}; + + const auto& pair = m_axisBindings[idx]; + if (!pair.bound) return {}; + + f32 value = 0.0f; + + if (pair.negative.type == BindingType::GamepadAxis && + pair.positive.type == BindingType::GamepadAxis && + pair.negative == pair.positive) { + auto axisIdx = static_cast(pair.negative.gamepadAxis); + if (axisIdx < m_gamepadAxisState.size()) { + value = m_gamepadAxisState[axisIdx]; + } + } else { + f32 neg = isBindingActive(pair.negative) ? -1.0f : 0.0f; + f32 pos = isBindingActive(pair.positive) ? 1.0f : 0.0f; + value = neg + pos; + } + + f32 prevValue = 0.0f; + if (pair.negative.type == BindingType::GamepadAxis && + pair.positive.type == BindingType::GamepadAxis && + pair.negative == pair.positive) { + prevValue = value; + } else { + f32 prevNeg = wasBindingActive(pair.negative) ? -1.0f : 0.0f; + f32 prevPos = wasBindingActive(pair.positive) ? 1.0f : 0.0f; + prevValue = prevNeg + prevPos; + } + + AxisState state; + state.value = value; + state.delta = value - prevValue; + return state; +} + +Vec2 InputManager::mousePosition() const { + return m_mousePos; +} + +Vec2 InputManager::mouseDelta() const { + return m_mousePos - m_prevMousePos; +} + +void InputManager::bind(Action action, Binding binding) { + auto idx = static_cast(action); + if (idx >= static_cast(Action::Count)) return; + + auto& ab = m_actionBindings[idx]; + if (ab.count >= MAX_BINDINGS_PER_ACTION) return; + ab.bindings[ab.count] = binding; + ++ab.count; +} + +void InputManager::clearBindings(Action action) { + auto idx = static_cast(action); + if (idx >= static_cast(Action::Count)) return; + m_actionBindings[idx].count = 0; +} + +void InputManager::clearAllBindings() { + for (usize i = 0; i < static_cast(Action::Count); ++i) { + m_actionBindings[i].count = 0; + } + for (usize i = 0; i < static_cast(Axis::Count); ++i) { + m_axisBindings[i].bound = false; + } +} + +void InputManager::resetToDefaults() { + clearAllBindings(); + setupDefaultBindings(); +} + +void InputManager::bindAxis(Axis axis, Binding negative, Binding positive) { + auto idx = static_cast(axis); + if (idx >= static_cast(Axis::Count)) return; + m_axisBindings[idx].negative = negative; + m_axisBindings[idx].positive = positive; + m_axisBindings[idx].bound = true; +} + +void InputManager::setCallbackHandler(IInputCallbacks* handler) { + m_callbackHandler = handler; +} + +void InputManager::setupDefaultBindings() { + bind(Action::MoveUp, Binding::fromKey(Key::W)); + bind(Action::MoveDown, Binding::fromKey(Key::S)); + bind(Action::MoveLeft, Binding::fromKey(Key::A)); + bind(Action::MoveRight, Binding::fromKey(Key::D)); + bind(Action::Jump, Binding::fromKey(Key::Space)); + bind(Action::Pause, Binding::fromKey(Key::Escape)); + + bind(Action::MoveUp, Binding::fromKey(Key::Up)); + bind(Action::MoveDown, Binding::fromKey(Key::Down)); + bind(Action::MoveLeft, Binding::fromKey(Key::Left)); + bind(Action::MoveRight, Binding::fromKey(Key::Right)); +} + +bool InputManager::isBindingActive(const Binding& binding) const { + switch (binding.type) { + case BindingType::Key: { + auto idx = static_cast(binding.key); + return idx < m_keyState.size() && m_keyState[idx]; + } + case BindingType::MouseButton: { + auto idx = static_cast(binding.mouseButton); + return idx < m_mouseState.size() && m_mouseState[idx]; + } + case BindingType::GamepadButton: { + auto idx = static_cast(binding.gamepadButton); + return idx < m_gamepadButtonState.size() && m_gamepadButtonState[idx]; + } + case BindingType::GamepadAxis: { + auto idx = static_cast(binding.gamepadAxis); + return idx < m_gamepadAxisState.size() && m_gamepadAxisState[idx] > 0.5f; + } + } + return false; +} + +bool InputManager::wasBindingActive(const Binding& binding) const { + switch (binding.type) { + case BindingType::Key: { + auto idx = static_cast(binding.key); + return idx < m_prevKeyState.size() && m_prevKeyState[idx]; + } + case BindingType::MouseButton: { + auto idx = static_cast(binding.mouseButton); + return idx < m_prevMouseState.size() && m_prevMouseState[idx]; + } + case BindingType::GamepadButton: { + auto idx = static_cast(binding.gamepadButton); + return idx < m_prevGamepadButtonState.size() && m_prevGamepadButtonState[idx]; + } + case BindingType::GamepadAxis: + return false; + } + return false; +} + +void InputManager::fireActionPressed(Action action) { + if (onActionPressed) onActionPressed(action); + if (m_callbackHandler) m_callbackHandler->onActionPressed(action); +} + +void InputManager::fireActionReleased(Action action) { + if (onActionReleased) onActionReleased(action); + if (m_callbackHandler) m_callbackHandler->onActionReleased(action); +} + +} // namespace Caffeine::Input diff --git a/src/input/InputManager.hpp b/src/input/InputManager.hpp new file mode 100644 index 0000000..8eccc11 --- /dev/null +++ b/src/input/InputManager.hpp @@ -0,0 +1,258 @@ +// ============================================================================ +// @file InputManager.hpp +// @brief Action-based input system with remappable bindings (RF2.9) +// @note Part of input/ module - no SDL3 dependency at compile time +// ============================================================================ +#pragma once + +#include "../core/Types.hpp" +#include "../math/Vec2.hpp" +#include "../containers/HashMap.hpp" +#include "../containers/Vector.hpp" + +#include +#include + +namespace Caffeine::Input { + +// ============================================================================ +// Enums +// ============================================================================ + +enum class Action : u8 { + MoveUp = 0, + MoveDown, + MoveLeft, + MoveRight, + Jump, + Attack, + Interact, + Pause, + Count +}; + +enum class Axis : u8 { + MoveX = 0, + MoveY, + LookX, + LookY, + Count +}; + +// ============================================================================ +// Key / Button codes (SDL3-compatible values, no SDL3 header dependency) +// ============================================================================ + +enum class Key : u16 { + Unknown = 0, + A = 4, B, C, D, E, F, G, H, I, J, K, L, M, + N, O, P, Q, R, S, T, U, V, W, X, Y, Z, + Num1 = 30, Num2, Num3, Num4, Num5, Num6, Num7, Num8, Num9, Num0, + Return = 40, Escape, Backspace, Tab, Space, + Up = 82, Down, Left = 80, Right, + LShift = 225, RShift, LCtrl = 224, RCtrl, + LAlt = 226, RAlt, + KeyCount = 512 +}; + +enum class MouseButton : u8 { + Left = 1, + Middle, + Right, + X1, + X2, + Count +}; + +enum class GamepadButton : u8 { + A = 0, B, X, Y, + LeftBumper, RightBumper, + Back, Start, Guide, + LeftStick, RightStick, + DPadUp, DPadDown, DPadLeft, DPadRight, + Count +}; + +enum class GamepadAxis : u8 { + LeftX = 0, + LeftY, + RightX, + RightY, + TriggerLeft, + TriggerRight, + Count +}; + +// ============================================================================ +// State structs +// ============================================================================ + +struct ActionState { + bool pressed = false; + bool justPressed = false; + bool justReleased = false; +}; + +struct AxisState { + f32 value = 0.0f; + f32 delta = 0.0f; +}; + +// ============================================================================ +// Binding - what physical input maps to a logical action/axis +// ============================================================================ + +enum class BindingType : u8 { + Key, + MouseButton, + GamepadButton, + GamepadAxis +}; + +struct Binding { + BindingType type; + union { + Key key; + MouseButton mouseButton; + GamepadButton gamepadButton; + GamepadAxis gamepadAxis; + }; + + static Binding fromKey(Key k) { + Binding b{}; + b.type = BindingType::Key; + b.key = k; + return b; + } + + static Binding fromMouseButton(MouseButton mb) { + Binding b{}; + b.type = BindingType::MouseButton; + b.mouseButton = mb; + return b; + } + + static Binding fromGamepadButton(GamepadButton gb) { + Binding b{}; + b.type = BindingType::GamepadButton; + b.gamepadButton = gb; + return b; + } + + static Binding fromGamepadAxis(GamepadAxis ga) { + Binding b{}; + b.type = BindingType::GamepadAxis; + b.gamepadAxis = ga; + return b; + } + + bool operator==(const Binding& other) const { + if (type != other.type) return false; + switch (type) { + case BindingType::Key: return key == other.key; + case BindingType::MouseButton: return mouseButton == other.mouseButton; + case BindingType::GamepadButton: return gamepadButton == other.gamepadButton; + case BindingType::GamepadAxis: return gamepadAxis == other.gamepadAxis; + } + return false; + } +}; + +// ============================================================================ +// InputManager +// ============================================================================ + +static constexpr usize MAX_BINDINGS_PER_ACTION = 4; + +class InputManager { +public: + InputManager(); + + // --- Frame lifecycle (called by GameLoop) --- + void beginFrame(); + void endFrame(); + + // --- Event injection (testable without SDL3) --- + void injectKeyDown(Key key); + void injectKeyUp(Key key); + void injectMouseButtonDown(MouseButton button); + void injectMouseButtonUp(MouseButton button); + void injectMouseMove(f32 x, f32 y); + void injectGamepadButtonDown(GamepadButton button); + void injectGamepadButtonUp(GamepadButton button); + void injectGamepadAxis(GamepadAxis axis, f32 value); + + // --- Query state --- + ActionState actionState(Action action) const; + AxisState axisState(Axis axis) const; + Vec2 mousePosition() const; + Vec2 mouseDelta() const; + + // --- Binding management --- + void bind(Action action, Binding binding); + void clearBindings(Action action); + void clearAllBindings(); + void resetToDefaults(); + + // --- Axis binding --- + void bindAxis(Axis axis, Binding negativeBinding, Binding positiveBinding); + + // --- Callbacks (std::function interface) --- + std::function onActionPressed; + std::function onActionReleased; + + // --- Virtual callback interface --- + class IInputCallbacks { + public: + virtual ~IInputCallbacks() = default; + virtual void onActionPressed(Action action) = 0; + virtual void onActionReleased(Action action) = 0; + }; + void setCallbackHandler(IInputCallbacks* handler); + +private: + void setupDefaultBindings(); + bool isBindingActive(const Binding& binding) const; + bool wasBindingActive(const Binding& binding) const; + void fireActionPressed(Action action); + void fireActionReleased(Action action); + + // --- Raw input state --- + std::array(Key::KeyCount)> m_keyState{}; + std::array(MouseButton::Count)> m_mouseState{}; + std::array(GamepadButton::Count)> m_gamepadButtonState{}; + std::array(GamepadAxis::Count)> m_gamepadAxisState{}; + + // --- Previous frame raw state (for justPressed/justReleased) --- + std::array(Key::KeyCount)> m_prevKeyState{}; + std::array(MouseButton::Count)> m_prevMouseState{}; + std::array(GamepadButton::Count)> m_prevGamepadButtonState{}; + + // --- Action bindings: Action -> up to MAX_BINDINGS_PER_ACTION bindings --- + struct ActionBindings { + std::array bindings{}; + u8 count = 0; + }; + std::array(Action::Count)> m_actionBindings{}; + + // --- Axis bindings: Axis -> negative/positive binding pair --- + struct AxisBindingPair { + Binding negative; + Binding positive; + bool bound = false; + }; + std::array(Axis::Count)> m_axisBindings{}; + + // --- Cached action states --- + std::array(Action::Count)> m_actionStates{}; + std::array(Action::Count)> m_prevActionStates{}; + + // --- Mouse --- + Vec2 m_mousePos{0.0f, 0.0f}; + Vec2 m_prevMousePos{0.0f, 0.0f}; + + // --- Callback handler --- + IInputCallbacks* m_callbackHandler = nullptr; +}; + +} // namespace Caffeine::Input diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1fdae69..47c1920 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,6 +7,7 @@ add_executable(CaffeineTest test_core.cpp test_timer.cpp test_gameloop.cpp + test_input.cpp ) target_link_libraries(CaffeineTest PRIVATE Caffeine) diff --git a/tests/test_input.cpp b/tests/test_input.cpp new file mode 100644 index 0000000..b9ceadb --- /dev/null +++ b/tests/test_input.cpp @@ -0,0 +1,560 @@ +#include "catch.hpp" +#include "../src/input/InputManager.hpp" + +using namespace Caffeine; +using namespace Caffeine::Input; + +// ============================================================================ +// ActionState defaults +// ============================================================================ + +TEST_CASE("ActionState - Default is all false", "[input][action]") { + InputManager mgr; + auto state = mgr.actionState(Action::Jump); + REQUIRE(state.pressed == false); + REQUIRE(state.justPressed == false); + REQUIRE(state.justReleased == false); +} + +// ============================================================================ +// Key press -> action state transitions +// ============================================================================ + +TEST_CASE("ActionState - justPressed on first frame of key down", "[input][action]") { + InputManager mgr; + mgr.bind(Action::Jump, Binding::fromKey(Key::Space)); + + mgr.beginFrame(); + mgr.injectKeyDown(Key::Space); + mgr.endFrame(); + + auto state = mgr.actionState(Action::Jump); + REQUIRE(state.pressed == true); + REQUIRE(state.justPressed == true); + REQUIRE(state.justReleased == false); +} + +TEST_CASE("ActionState - pressed but not justPressed on second frame", "[input][action]") { + InputManager mgr; + mgr.bind(Action::Jump, Binding::fromKey(Key::Space)); + + // Frame 1: press + mgr.beginFrame(); + mgr.injectKeyDown(Key::Space); + mgr.endFrame(); + + // Frame 2: still held + mgr.beginFrame(); + mgr.endFrame(); + + auto state = mgr.actionState(Action::Jump); + REQUIRE(state.pressed == true); + REQUIRE(state.justPressed == false); + REQUIRE(state.justReleased == false); +} + +TEST_CASE("ActionState - justReleased on frame after key up", "[input][action]") { + InputManager mgr; + mgr.bind(Action::Jump, Binding::fromKey(Key::Space)); + + // Frame 1: press + mgr.beginFrame(); + mgr.injectKeyDown(Key::Space); + mgr.endFrame(); + + // Frame 2: release + mgr.beginFrame(); + mgr.injectKeyUp(Key::Space); + mgr.endFrame(); + + auto state = mgr.actionState(Action::Jump); + REQUIRE(state.pressed == false); + REQUIRE(state.justPressed == false); + REQUIRE(state.justReleased == true); +} + +TEST_CASE("ActionState - all false after release frame passes", "[input][action]") { + InputManager mgr; + mgr.bind(Action::Jump, Binding::fromKey(Key::Space)); + + // Frame 1: press + mgr.beginFrame(); + mgr.injectKeyDown(Key::Space); + mgr.endFrame(); + + // Frame 2: release + mgr.beginFrame(); + mgr.injectKeyUp(Key::Space); + mgr.endFrame(); + + // Frame 3: idle + mgr.beginFrame(); + mgr.endFrame(); + + auto state = mgr.actionState(Action::Jump); + REQUIRE(state.pressed == false); + REQUIRE(state.justPressed == false); + REQUIRE(state.justReleased == false); +} + +// ============================================================================ +// Multiple bindings per action +// ============================================================================ + +TEST_CASE("Action - Multiple bindings, either activates", "[input][binding]") { + InputManager mgr; + mgr.bind(Action::Jump, Binding::fromKey(Key::Space)); + mgr.bind(Action::Jump, Binding::fromKey(Key::W)); + + mgr.beginFrame(); + mgr.injectKeyDown(Key::W); + mgr.endFrame(); + + auto state = mgr.actionState(Action::Jump); + REQUIRE(state.pressed == true); + REQUIRE(state.justPressed == true); +} + +TEST_CASE("Action - Multiple bindings, both held, release one stays pressed", "[input][binding]") { + InputManager mgr; + mgr.bind(Action::Jump, Binding::fromKey(Key::Space)); + mgr.bind(Action::Jump, Binding::fromKey(Key::W)); + + // Frame 1: both down + mgr.beginFrame(); + mgr.injectKeyDown(Key::Space); + mgr.injectKeyDown(Key::W); + mgr.endFrame(); + + // Frame 2: release one + mgr.beginFrame(); + mgr.injectKeyUp(Key::W); + mgr.endFrame(); + + auto state = mgr.actionState(Action::Jump); + REQUIRE(state.pressed == true); + REQUIRE(state.justReleased == false); +} + +// ============================================================================ +// Mouse button bindings +// ============================================================================ + +TEST_CASE("Action - Mouse button binding", "[input][mouse]") { + InputManager mgr; + mgr.bind(Action::Attack, Binding::fromMouseButton(MouseButton::Left)); + + mgr.beginFrame(); + mgr.injectMouseButtonDown(MouseButton::Left); + mgr.endFrame(); + + auto state = mgr.actionState(Action::Attack); + REQUIRE(state.pressed == true); + REQUIRE(state.justPressed == true); +} + +TEST_CASE("Mouse button release triggers justReleased", "[input][mouse]") { + InputManager mgr; + mgr.bind(Action::Attack, Binding::fromMouseButton(MouseButton::Left)); + + mgr.beginFrame(); + mgr.injectMouseButtonDown(MouseButton::Left); + mgr.endFrame(); + + mgr.beginFrame(); + mgr.injectMouseButtonUp(MouseButton::Left); + mgr.endFrame(); + + auto state = mgr.actionState(Action::Attack); + REQUIRE(state.pressed == false); + REQUIRE(state.justReleased == true); +} + +// ============================================================================ +// Gamepad button bindings +// ============================================================================ + +TEST_CASE("Action - Gamepad button binding", "[input][gamepad]") { + InputManager mgr; + mgr.bind(Action::Jump, Binding::fromGamepadButton(GamepadButton::A)); + + mgr.beginFrame(); + mgr.injectGamepadButtonDown(GamepadButton::A); + mgr.endFrame(); + + auto state = mgr.actionState(Action::Jump); + REQUIRE(state.pressed == true); + REQUIRE(state.justPressed == true); +} + +// ============================================================================ +// Mouse position and delta +// ============================================================================ + +TEST_CASE("Mouse position tracks injectMouseMove", "[input][mouse]") { + InputManager mgr; + + mgr.beginFrame(); + mgr.injectMouseMove(100.0f, 200.0f); + mgr.endFrame(); + + Vec2 pos = mgr.mousePosition(); + REQUIRE(pos.x == Approx(100.0f)); + REQUIRE(pos.y == Approx(200.0f)); +} + +TEST_CASE("Mouse delta is difference between frames", "[input][mouse]") { + InputManager mgr; + + mgr.beginFrame(); + mgr.injectMouseMove(100.0f, 200.0f); + mgr.endFrame(); + + mgr.beginFrame(); + mgr.injectMouseMove(150.0f, 220.0f); + mgr.endFrame(); + + Vec2 delta = mgr.mouseDelta(); + REQUIRE(delta.x == Approx(50.0f)); + REQUIRE(delta.y == Approx(20.0f)); +} + +// ============================================================================ +// Axis state (digital keys -> axis value) +// ============================================================================ + +TEST_CASE("AxisState - Default is zero", "[input][axis]") { + InputManager mgr; + auto state = mgr.axisState(Axis::MoveX); + REQUIRE(state.value == Approx(0.0f)); + REQUIRE(state.delta == Approx(0.0f)); +} + +TEST_CASE("AxisState - Positive binding gives +1.0", "[input][axis]") { + InputManager mgr; + mgr.bindAxis(Axis::MoveX, + Binding::fromKey(Key::A), // negative + Binding::fromKey(Key::D)); // positive + + mgr.beginFrame(); + mgr.injectKeyDown(Key::D); + mgr.endFrame(); + + auto state = mgr.axisState(Axis::MoveX); + REQUIRE(state.value == Approx(1.0f)); +} + +TEST_CASE("AxisState - Negative binding gives -1.0", "[input][axis]") { + InputManager mgr; + mgr.bindAxis(Axis::MoveX, + Binding::fromKey(Key::A), + Binding::fromKey(Key::D)); + + mgr.beginFrame(); + mgr.injectKeyDown(Key::A); + mgr.endFrame(); + + auto state = mgr.axisState(Axis::MoveX); + REQUIRE(state.value == Approx(-1.0f)); +} + +TEST_CASE("AxisState - Both keys cancel to 0.0", "[input][axis]") { + InputManager mgr; + mgr.bindAxis(Axis::MoveX, + Binding::fromKey(Key::A), + Binding::fromKey(Key::D)); + + mgr.beginFrame(); + mgr.injectKeyDown(Key::A); + mgr.injectKeyDown(Key::D); + mgr.endFrame(); + + auto state = mgr.axisState(Axis::MoveX); + REQUIRE(state.value == Approx(0.0f)); +} + +TEST_CASE("AxisState - Gamepad axis value pass-through", "[input][axis]") { + InputManager mgr; + mgr.bindAxis(Axis::LookX, + Binding::fromGamepadAxis(GamepadAxis::RightX), + Binding::fromGamepadAxis(GamepadAxis::RightX)); + + mgr.beginFrame(); + mgr.injectGamepadAxis(GamepadAxis::RightX, 0.75f); + mgr.endFrame(); + + auto state = mgr.axisState(Axis::LookX); + REQUIRE(state.value == Approx(0.75f)); +} + +TEST_CASE("AxisState - Delta tracks change between frames", "[input][axis]") { + InputManager mgr; + mgr.bindAxis(Axis::MoveX, + Binding::fromKey(Key::A), + Binding::fromKey(Key::D)); + + // Frame 1: idle + mgr.beginFrame(); + mgr.endFrame(); + + // Frame 2: press D -> value goes from 0 to 1 + mgr.beginFrame(); + mgr.injectKeyDown(Key::D); + mgr.endFrame(); + + auto state = mgr.axisState(Axis::MoveX); + REQUIRE(state.delta == Approx(1.0f)); +} + +// ============================================================================ +// Binding management +// ============================================================================ + +TEST_CASE("clearBindings removes action bindings", "[input][binding]") { + InputManager mgr; + mgr.bind(Action::Jump, Binding::fromKey(Key::Space)); + mgr.clearBindings(Action::Jump); + + mgr.beginFrame(); + mgr.injectKeyDown(Key::Space); + mgr.endFrame(); + + auto state = mgr.actionState(Action::Jump); + REQUIRE(state.pressed == false); +} + +TEST_CASE("clearAllBindings removes all action bindings", "[input][binding]") { + InputManager mgr; + mgr.bind(Action::Jump, Binding::fromKey(Key::Space)); + mgr.bind(Action::Attack, Binding::fromMouseButton(MouseButton::Left)); + mgr.clearAllBindings(); + + mgr.beginFrame(); + mgr.injectKeyDown(Key::Space); + mgr.injectMouseButtonDown(MouseButton::Left); + mgr.endFrame(); + + REQUIRE(mgr.actionState(Action::Jump).pressed == false); + REQUIRE(mgr.actionState(Action::Attack).pressed == false); +} + +TEST_CASE("resetToDefaults restores WASD + arrow defaults", "[input][binding]") { + InputManager mgr; + mgr.clearAllBindings(); + mgr.resetToDefaults(); + + mgr.beginFrame(); + mgr.injectKeyDown(Key::W); + mgr.endFrame(); + + REQUIRE(mgr.actionState(Action::MoveUp).pressed == true); +} + +TEST_CASE("bind respects MAX_BINDINGS_PER_ACTION limit", "[input][binding]") { + InputManager mgr; + mgr.clearBindings(Action::Jump); + + mgr.bind(Action::Jump, Binding::fromKey(Key::Space)); + mgr.bind(Action::Jump, Binding::fromKey(Key::W)); + mgr.bind(Action::Jump, Binding::fromKey(Key::Up)); + mgr.bind(Action::Jump, Binding::fromKey(Key::Return)); + // 5th binding should be silently ignored (MAX=4) + mgr.bind(Action::Jump, Binding::fromKey(Key::A)); + + mgr.beginFrame(); + mgr.injectKeyDown(Key::A); + mgr.endFrame(); + + REQUIRE(mgr.actionState(Action::Jump).pressed == false); +} + +// ============================================================================ +// Callbacks (std::function) +// ============================================================================ + +TEST_CASE("onActionPressed callback fires on justPressed", "[input][callback]") { + InputManager mgr; + mgr.bind(Action::Jump, Binding::fromKey(Key::Space)); + + Action firedAction = Action::Count; + mgr.onActionPressed = [&](Action a) { firedAction = a; }; + + mgr.beginFrame(); + mgr.injectKeyDown(Key::Space); + mgr.endFrame(); + + REQUIRE(firedAction == Action::Jump); +} + +TEST_CASE("onActionReleased callback fires on justReleased", "[input][callback]") { + InputManager mgr; + mgr.bind(Action::Jump, Binding::fromKey(Key::Space)); + + Action firedAction = Action::Count; + mgr.onActionReleased = [&](Action a) { firedAction = a; }; + + mgr.beginFrame(); + mgr.injectKeyDown(Key::Space); + mgr.endFrame(); + + mgr.beginFrame(); + mgr.injectKeyUp(Key::Space); + mgr.endFrame(); + + REQUIRE(firedAction == Action::Jump); +} + +// ============================================================================ +// Callbacks (virtual interface) +// ============================================================================ + +class TestInputHandler : public InputManager::IInputCallbacks { +public: + Action lastPressed = Action::Count; + Action lastReleased = Action::Count; + int pressCount = 0; + int releaseCount = 0; + + void onActionPressed(Action action) override { + lastPressed = action; + ++pressCount; + } + void onActionReleased(Action action) override { + lastReleased = action; + ++releaseCount; + } +}; + +TEST_CASE("IInputCallbacks - onActionPressed fires via interface", "[input][callback]") { + InputManager mgr; + mgr.bind(Action::Attack, Binding::fromKey(Key::E)); + + TestInputHandler handler; + mgr.setCallbackHandler(&handler); + + mgr.beginFrame(); + mgr.injectKeyDown(Key::E); + mgr.endFrame(); + + REQUIRE(handler.lastPressed == Action::Attack); + REQUIRE(handler.pressCount == 1); +} + +TEST_CASE("IInputCallbacks - onActionReleased fires via interface", "[input][callback]") { + InputManager mgr; + mgr.bind(Action::Attack, Binding::fromKey(Key::E)); + + TestInputHandler handler; + mgr.setCallbackHandler(&handler); + + mgr.beginFrame(); + mgr.injectKeyDown(Key::E); + mgr.endFrame(); + + mgr.beginFrame(); + mgr.injectKeyUp(Key::E); + mgr.endFrame(); + + REQUIRE(handler.lastReleased == Action::Attack); + REQUIRE(handler.releaseCount == 1); +} + +TEST_CASE("Callbacks do not fire when no state transition", "[input][callback]") { + InputManager mgr; + mgr.bind(Action::Jump, Binding::fromKey(Key::Space)); + + TestInputHandler handler; + mgr.setCallbackHandler(&handler); + + // Frame 1: press + mgr.beginFrame(); + mgr.injectKeyDown(Key::Space); + mgr.endFrame(); + REQUIRE(handler.pressCount == 1); + + // Frame 2: still held - no new press callback + mgr.beginFrame(); + mgr.endFrame(); + REQUIRE(handler.pressCount == 1); +} + +// ============================================================================ +// Independent actions don't interfere +// ============================================================================ + +TEST_CASE("Different actions are independent", "[input][action]") { + InputManager mgr; + mgr.bind(Action::Jump, Binding::fromKey(Key::Space)); + mgr.bind(Action::Attack, Binding::fromKey(Key::E)); + + mgr.beginFrame(); + mgr.injectKeyDown(Key::Space); + mgr.endFrame(); + + REQUIRE(mgr.actionState(Action::Jump).pressed == true); + REQUIRE(mgr.actionState(Action::Attack).pressed == false); +} + +// ============================================================================ +// Default bindings exist after construction +// ============================================================================ + +TEST_CASE("Default bindings - MoveUp bound to W", "[input][defaults]") { + InputManager mgr; + + mgr.beginFrame(); + mgr.injectKeyDown(Key::W); + mgr.endFrame(); + + REQUIRE(mgr.actionState(Action::MoveUp).pressed == true); +} + +TEST_CASE("Default bindings - MoveDown bound to S", "[input][defaults]") { + InputManager mgr; + + mgr.beginFrame(); + mgr.injectKeyDown(Key::S); + mgr.endFrame(); + + REQUIRE(mgr.actionState(Action::MoveDown).pressed == true); +} + +TEST_CASE("Default bindings - MoveLeft bound to A", "[input][defaults]") { + InputManager mgr; + + mgr.beginFrame(); + mgr.injectKeyDown(Key::A); + mgr.endFrame(); + + REQUIRE(mgr.actionState(Action::MoveLeft).pressed == true); +} + +TEST_CASE("Default bindings - MoveRight bound to D", "[input][defaults]") { + InputManager mgr; + + mgr.beginFrame(); + mgr.injectKeyDown(Key::D); + mgr.endFrame(); + + REQUIRE(mgr.actionState(Action::MoveRight).pressed == true); +} + +TEST_CASE("Default bindings - Jump bound to Space", "[input][defaults]") { + InputManager mgr; + + mgr.beginFrame(); + mgr.injectKeyDown(Key::Space); + mgr.endFrame(); + + REQUIRE(mgr.actionState(Action::Jump).pressed == true); +} + +TEST_CASE("Default bindings - Pause bound to Escape", "[input][defaults]") { + InputManager mgr; + + mgr.beginFrame(); + mgr.injectKeyDown(Key::Escape); + mgr.endFrame(); + + REQUIRE(mgr.actionState(Action::Pause).pressed == true); +}