diff --git a/src/lib/eventsystem/event/CMakeLists.txt b/src/lib/eventsystem/event/CMakeLists.txt index d6e5151..f82197f 100644 --- a/src/lib/eventsystem/event/CMakeLists.txt +++ b/src/lib/eventsystem/event/CMakeLists.txt @@ -16,6 +16,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h + ${CMAKE_CURRENT_SOURCE_DIR}/PanDirectionChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingTypeSelectedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildHotkeyPressedEvent.h diff --git a/src/lib/eventsystem/event/PanDirectionChangedEvent.h b/src/lib/eventsystem/event/PanDirectionChangedEvent.h new file mode 100644 index 0000000..082d754 --- /dev/null +++ b/src/lib/eventsystem/event/PanDirectionChangedEvent.h @@ -0,0 +1,22 @@ +#pragma once + +#include "Event.h" +#include "WorldCamera.h" + +// The direction the player is currently panning the view has changed +// (REQ-UI-SCROLL). Deliberately level-triggered: the payload is the complete +// current direction, including PanDirection::None when panning stops, rather than +// separate started/stopped events. A receiver that only ever saw edges would have +// to reconstruct the state and would be left panning forever if one edge went +// missing — which is exactly what happens when the widget loses focus mid-pan. +// +// Unlike the state-change events elsewhere in the UI, the receiver does cache this +// payload instead of re-reading the value from somewhere authoritative. That is +// correct here: input has no other source of truth to re-read from, so the +// publisher (InputMapper) is the authority and the payload is the value. +class PanDirectionChangedEvent : public Event +{ +public: + explicit PanDirectionChangedEvent(PanDirection direction) : direction(direction) {} + const PanDirection direction; +}; diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 820d28e..3a88f38 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -6,6 +6,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h ${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.h ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h + ${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.h ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h @@ -28,6 +29,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.cpp ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.cpp ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index bac5081..69dee28 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -207,8 +207,6 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config, , m_debugDraw(false) , m_rng(std::random_device{}()) , m_boxSelecting(false) - , m_scrollLeft(false) - , m_scrollRight(false) , m_gameOverShown(false) , m_schematicChoiceShown(false) { @@ -366,15 +364,8 @@ void GameWorldView::onFrame() // Apply held scroll { - // Holding both keys cancels out, as it did when each key moved the view - // independently. - PanDirection direction = PanDirection::None; - if (m_scrollLeft != m_scrollRight) - { - direction = m_scrollLeft ? PanDirection::Left : PanDirection::Right; - } - - const bool viewMoved = m_camera.advance(direction, elapsed, getScrollBounds()); + const bool viewMoved = + m_camera.advance(m_panDirection, elapsed, getScrollBounds()); // While the view scrolls, the tile under a stationary cursor changes, // so refresh the box-select rectangle even though no mouse move fires. @@ -2319,6 +2310,10 @@ void GameWorldView::keyPressEvent(QKeyEvent* event) return; } + // Keys the input mapper owns are turned into actions and published from there + // (REQ-UI-HOTKEYS); this widget reacts to those as an ordinary subscriber. + if (m_inputMapper.handleKeyPress(event)) { return; } + // Number-key build-mode hotkeys (REQ-UI-HOTKEYS). nativeVirtualKey gives the // physical digit independent of keyboard layout and Shift (with Shift held, key() // for the number row can arrive as Key_Exclam etc.). VK_1..VK_9 = 0x31..0x39. @@ -2359,12 +2354,6 @@ void GameWorldView::keyPressEvent(QKeyEvent* event) switch (event->key()) { - case Qt::Key_A: - m_scrollLeft = true; - break; - case Qt::Key_D: - m_scrollRight = true; - break; case Qt::Key_Space: if (m_gameSpeedMultiplier > 0.0) { @@ -2422,8 +2411,7 @@ void GameWorldView::keyReleaseEvent(QKeyEvent* event) QOpenGLWidget::keyReleaseEvent(event); return; } - if (event->key() == Qt::Key_A) { m_scrollLeft = false; } - if (event->key() == Qt::Key_D) { m_scrollRight = false; } + if (m_inputMapper.handleKeyRelease(event)) { return; } // Releasing Shift discards the copied building settings (REQ-BLD-COPY-CONFIG). if (event->key() == Qt::Key_Shift) { m_copiedConfig.reset(); } QOpenGLWidget::keyReleaseEvent(event); @@ -3070,8 +3058,9 @@ void GameWorldView::resetForNewGame() m_copyConfigFlashes.clear(); m_boxSelecting = false; m_camera.reset(); - m_scrollLeft = false; - m_scrollRight = false; + // Drops any key still held across the restart, which also republishes the pan + // direction so m_panDirection follows. + m_inputMapper.releaseAll(); m_gameOverShown = false; m_winShown = false; m_prevNonZeroSpeed = 1.0; @@ -3158,6 +3147,11 @@ void GameWorldView::handleEvent(std::shared_ptr setGameSpeed(event->multiplier); } +void GameWorldView::handleEvent(std::shared_ptr event) +{ + m_panDirection = event->direction; +} + void GameWorldView::handleEvent(std::shared_ptr event) { // Other widgets (MainWindow, SelectedBuildingPanel) request commands via this diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 33d7eb7..532add0 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -31,6 +31,8 @@ #include "EventHandler.h" #include "ExitBlueprintModeRequestedEvent.h" #include "ExitBuilderModeRequestedEvent.h" +#include "InputMapper.h" +#include "PanDirectionChangedEvent.h" #include "DebugDrawToggledEvent.h" #include "ArtifactCountChangedEvent.h" #include "BeamFiredEvent.h" @@ -80,6 +82,7 @@ class GameWorldView : public QOpenGLWidget, BlueprintPlacementRequestedEvent, ExitBlueprintModeRequestedEvent, SpeedChangeRequestedEvent, + PanDirectionChangedEvent, CommandRequestedEvent> { Q_OBJECT @@ -119,6 +122,7 @@ private: void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; // Enqueue a sim command onto the CommandManager (the single mutation path). @@ -392,13 +396,16 @@ private: QPoint m_boxStartTile; QPoint m_boxCurrentTile; - // Held-key state for the A / D pan controls (REQ-UI-SCROLL, REQ-UI-HOTKEYS), - // collapsed into a PanDirection for the camera each frame. Kept here rather - // than on the camera because it is key state, not view state: once controls - // are rebindable this becomes an input mapper publishing the direction, and - // the camera's interface does not change. - bool m_scrollLeft; - bool m_scrollRight; + // Interprets this widget's key events into semantic actions and publishes them + // (REQ-UI-HOTKEYS). Owned here for now because this is the widget that holds + // focus; everything it produces travels by event, so it can move to a + // window-wide owner later without touching its consumers. + InputMapper m_inputMapper; + + // Latest pan direction published by the input mapper (REQ-UI-SCROLL), fed to + // the camera each frame. Cached from the event payload rather than re-read: + // input has no other source of truth (see PanDirectionChangedEvent). + PanDirection m_panDirection = PanDirection::None; bool m_gameOverShown; bool m_winShown; bool m_schematicChoiceShown; diff --git a/src/ui/InputMapper.cpp b/src/ui/InputMapper.cpp new file mode 100644 index 0000000..b0beb4f --- /dev/null +++ b/src/ui/InputMapper.cpp @@ -0,0 +1,70 @@ +#include "InputMapper.h" + +#include + +#include + +#include "EventManager.h" +#include "PanDirectionChangedEvent.h" + +bool InputMapper::handleKeyPress(QKeyEvent* event) +{ + // Auto-repeat says nothing new about which keys are down, and a held action is + // already held. + if (event->isAutoRepeat()) { return false; } + + switch (event->key()) + { + case Qt::Key_A: + m_panLeftHeld = true; + updatePanDirection(); + return true; + case Qt::Key_D: + m_panRightHeld = true; + updatePanDirection(); + return true; + default: + return false; + } +} + +bool InputMapper::handleKeyRelease(QKeyEvent* event) +{ + if (event->isAutoRepeat()) { return false; } + + switch (event->key()) + { + case Qt::Key_A: + m_panLeftHeld = false; + updatePanDirection(); + return true; + case Qt::Key_D: + m_panRightHeld = false; + updatePanDirection(); + return true; + default: + return false; + } +} + +void InputMapper::releaseAll() +{ + m_panLeftHeld = false; + m_panRightHeld = false; + updatePanDirection(); +} + +void InputMapper::updatePanDirection() +{ + // Holding both keys cancels out rather than favouring one. + PanDirection direction = PanDirection::None; + if (m_panLeftHeld != m_panRightHeld) + { + direction = m_panLeftHeld ? PanDirection::Left : PanDirection::Right; + } + + if (direction == m_panDirection) { return; } + m_panDirection = direction; + EventManager::getInstance()->sendEventImmediately( + std::make_shared(direction)); +} diff --git a/src/ui/InputMapper.h b/src/ui/InputMapper.h new file mode 100644 index 0000000..1a5044e --- /dev/null +++ b/src/ui/InputMapper.h @@ -0,0 +1,44 @@ +#pragma once + +#include "WorldCamera.h" + +class QKeyEvent; + +// Turns raw key events into the game's semantic actions and publishes them +// (REQ-UI-HOTKEYS). Widgets react to the action, never to the key, so the two can +// be rebound independently later; the bindings themselves are still hard-coded +// here for now. +// +// Two output shapes, chosen by the nature of the action rather than by taste: +// +// * Discrete actions — one press, one thing happens — fire an event as they +// always have. +// * Continuous actions, currently just panning, are held state. They also travel +// as events, but level-triggered ones carrying the complete current value (see +// PanDirectionChangedEvent), so a receiver never has to reconstruct state from +// edges. +// +// Holding the state here rather than in the widgets is what makes releaseAll() +// possible: one call clears every held action at once, which is how a lost key-up +// (focus moving to a modal mid-pan) stops being a stuck input. +class InputMapper +{ +public: + // Both return true when the key was consumed; the caller passes anything else + // on to its base class so unrelated shortcuts keep working. + bool handleKeyPress(QKeyEvent* event); + bool handleKeyRelease(QKeyEvent* event); + + // Drops all held-key state, publishing the resulting change. Call when the + // receiving widget can no longer expect key-up events. + void releaseAll(); + +private: + // Recomputes the pan direction from the held keys and publishes it if it + // changed. Holding both keys cancels out. + void updatePanDirection(); + + bool m_panLeftHeld = false; + bool m_panRightHeld = false; + PanDirection m_panDirection = PanDirection::None; +};