From 1748be57fbd04d2afe4dc0329f7f9dbe8f8d1b64 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Wed, 5 Aug 2026 13:35:23 +0200 Subject: [PATCH] move pan input into an InputMapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of pulling key handling out of GameWorldView. The widget no longer holds A/D key state; the mapper owns it and publishes the resulting direction as PanDirectionChangedEvent, which the view consumes like any other event. The event is level-triggered on purpose — the payload is the complete current direction, PanDirection::None included — so a receiver never reconstructs state from edges and cannot be left panning by a missing key-up. It is also the one place in the UI where caching an event payload is right rather than wrong: input has no other authority to re-read from, so the mapper is the source of truth. Both points are written down on the event, since they look like violations of the surrounding conventions otherwise. Holding the state in one object is what makes releaseAll() possible; restart uses it, and it is what a focusOutEvent will call to fix the stuck-pan bug in a follow-up. Bindings stay hard-coded. Only the ownership moved, so behaviour is unchanged, including both keys held cancelling out. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K --- src/lib/eventsystem/event/CMakeLists.txt | 1 + .../event/PanDirectionChangedEvent.h | 22 ++++++ src/ui/CMakeLists.txt | 2 + src/ui/GameWorldView.cpp | 36 ++++------ src/ui/GameWorldView.h | 21 ++++-- src/ui/InputMapper.cpp | 70 +++++++++++++++++++ src/ui/InputMapper.h | 44 ++++++++++++ 7 files changed, 168 insertions(+), 28 deletions(-) create mode 100644 src/lib/eventsystem/event/PanDirectionChangedEvent.h create mode 100644 src/ui/InputMapper.cpp create mode 100644 src/ui/InputMapper.h 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; +};