move pan input into an InputMapper

This commit is contained in:
2026-08-05 19:50:23 +02:00
parent f6df95abb2
commit caa810f66d
7 changed files with 168 additions and 28 deletions

View File

@@ -16,6 +16,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/PanDirectionChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingTypeSelectedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingTypeSelectedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildHotkeyPressedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildHotkeyPressedEvent.h

View File

@@ -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;
};

View File

@@ -6,6 +6,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h ${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h
${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.h ${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.h
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h
${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.h
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
@@ -28,6 +29,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.cpp
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp
${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.cpp
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp

View File

@@ -207,8 +207,6 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
, m_debugDraw(false) , m_debugDraw(false)
, m_rng(std::random_device{}()) , m_rng(std::random_device{}())
, m_boxSelecting(false) , m_boxSelecting(false)
, m_scrollLeft(false)
, m_scrollRight(false)
, m_gameOverShown(false) , m_gameOverShown(false)
, m_schematicChoiceShown(false) , m_schematicChoiceShown(false)
{ {
@@ -366,15 +364,8 @@ void GameWorldView::onFrame()
// Apply held scroll // Apply held scroll
{ {
// Holding both keys cancels out, as it did when each key moved the view const bool viewMoved =
// independently. m_camera.advance(m_panDirection, elapsed, getScrollBounds());
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());
// While the view scrolls, the tile under a stationary cursor changes, // While the view scrolls, the tile under a stationary cursor changes,
// so refresh the box-select rectangle even though no mouse move fires. // so refresh the box-select rectangle even though no mouse move fires.
@@ -2319,6 +2310,10 @@ void GameWorldView::keyPressEvent(QKeyEvent* event)
return; 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 // Number-key build-mode hotkeys (REQ-UI-HOTKEYS). nativeVirtualKey gives the
// physical digit independent of keyboard layout and Shift (with Shift held, key() // 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. // 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()) switch (event->key())
{ {
case Qt::Key_A:
m_scrollLeft = true;
break;
case Qt::Key_D:
m_scrollRight = true;
break;
case Qt::Key_Space: case Qt::Key_Space:
if (m_gameSpeedMultiplier > 0.0) if (m_gameSpeedMultiplier > 0.0)
{ {
@@ -2422,8 +2411,7 @@ void GameWorldView::keyReleaseEvent(QKeyEvent* event)
QOpenGLWidget::keyReleaseEvent(event); QOpenGLWidget::keyReleaseEvent(event);
return; return;
} }
if (event->key() == Qt::Key_A) { m_scrollLeft = false; } if (m_inputMapper.handleKeyRelease(event)) { return; }
if (event->key() == Qt::Key_D) { m_scrollRight = false; }
// Releasing Shift discards the copied building settings (REQ-BLD-COPY-CONFIG). // Releasing Shift discards the copied building settings (REQ-BLD-COPY-CONFIG).
if (event->key() == Qt::Key_Shift) { m_copiedConfig.reset(); } if (event->key() == Qt::Key_Shift) { m_copiedConfig.reset(); }
QOpenGLWidget::keyReleaseEvent(event); QOpenGLWidget::keyReleaseEvent(event);
@@ -3070,8 +3058,9 @@ void GameWorldView::resetForNewGame()
m_copyConfigFlashes.clear(); m_copyConfigFlashes.clear();
m_boxSelecting = false; m_boxSelecting = false;
m_camera.reset(); m_camera.reset();
m_scrollLeft = false; // Drops any key still held across the restart, which also republishes the pan
m_scrollRight = false; // direction so m_panDirection follows.
m_inputMapper.releaseAll();
m_gameOverShown = false; m_gameOverShown = false;
m_winShown = false; m_winShown = false;
m_prevNonZeroSpeed = 1.0; m_prevNonZeroSpeed = 1.0;
@@ -3158,6 +3147,11 @@ void GameWorldView::handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent>
setGameSpeed(event->multiplier); setGameSpeed(event->multiplier);
} }
void GameWorldView::handleEvent(std::shared_ptr<const PanDirectionChangedEvent> event)
{
m_panDirection = event->direction;
}
void GameWorldView::handleEvent(std::shared_ptr<const CommandRequestedEvent> event) void GameWorldView::handleEvent(std::shared_ptr<const CommandRequestedEvent> event)
{ {
// Other widgets (MainWindow, SelectedBuildingPanel) request commands via this // Other widgets (MainWindow, SelectedBuildingPanel) request commands via this

View File

@@ -31,6 +31,8 @@
#include "EventHandler.h" #include "EventHandler.h"
#include "ExitBlueprintModeRequestedEvent.h" #include "ExitBlueprintModeRequestedEvent.h"
#include "ExitBuilderModeRequestedEvent.h" #include "ExitBuilderModeRequestedEvent.h"
#include "InputMapper.h"
#include "PanDirectionChangedEvent.h"
#include "DebugDrawToggledEvent.h" #include "DebugDrawToggledEvent.h"
#include "ArtifactCountChangedEvent.h" #include "ArtifactCountChangedEvent.h"
#include "BeamFiredEvent.h" #include "BeamFiredEvent.h"
@@ -80,6 +82,7 @@ class GameWorldView : public QOpenGLWidget,
BlueprintPlacementRequestedEvent, BlueprintPlacementRequestedEvent,
ExitBlueprintModeRequestedEvent, ExitBlueprintModeRequestedEvent,
SpeedChangeRequestedEvent, SpeedChangeRequestedEvent,
PanDirectionChangedEvent,
CommandRequestedEvent> CommandRequestedEvent>
{ {
Q_OBJECT Q_OBJECT
@@ -119,6 +122,7 @@ private:
void handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event) override; void handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> event) override; void handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event) override; void handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const PanDirectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const CommandRequestedEvent> event) override; void handleEvent(std::shared_ptr<const CommandRequestedEvent> event) override;
// Enqueue a sim command onto the CommandManager (the single mutation path). // Enqueue a sim command onto the CommandManager (the single mutation path).
@@ -392,13 +396,16 @@ private:
QPoint m_boxStartTile; QPoint m_boxStartTile;
QPoint m_boxCurrentTile; QPoint m_boxCurrentTile;
// Held-key state for the A / D pan controls (REQ-UI-SCROLL, REQ-UI-HOTKEYS), // Interprets this widget's key events into semantic actions and publishes them
// collapsed into a PanDirection for the camera each frame. Kept here rather // (REQ-UI-HOTKEYS). Owned here for now because this is the widget that holds
// than on the camera because it is key state, not view state: once controls // focus; everything it produces travels by event, so it can move to a
// are rebindable this becomes an input mapper publishing the direction, and // window-wide owner later without touching its consumers.
// the camera's interface does not change. InputMapper m_inputMapper;
bool m_scrollLeft;
bool m_scrollRight; // 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_gameOverShown;
bool m_winShown; bool m_winShown;
bool m_schematicChoiceShown; bool m_schematicChoiceShown;

70
src/ui/InputMapper.cpp Normal file
View File

@@ -0,0 +1,70 @@
#include "InputMapper.h"
#include <memory>
#include <QKeyEvent>
#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<PanDirectionChangedEvent>(direction));
}

44
src/ui/InputMapper.h Normal file
View File

@@ -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;
};