resolve keyboard shortcuts through one action table instead of a switch

This commit is contained in:
2026-08-07 18:47:29 +02:00
parent f61f0bf761
commit 1f754de431
14 changed files with 953 additions and 88 deletions

View File

@@ -72,6 +72,7 @@ void BuildModeController::enterMode(BuildMode mode)
std::make_shared<BuilderModeExitedEvent>()); std::make_shared<BuilderModeExitedEvent>());
break; break;
case BuildMode::Blueprint: case BuildMode::Blueprint:
m_hoveredGhostIsTransfer = false;
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintModeExitedEvent>()); std::make_shared<BlueprintModeExitedEvent>());
break; break;
@@ -249,6 +250,16 @@ void BuildModeController::setBlueprintGhostTile(QPoint tile)
m_blueprintGhostTile = tile; m_blueprintGhostTile = tile;
} }
bool BuildModeController::isHoveredGhostTransfer() const
{
return m_hoveredGhostIsTransfer;
}
void BuildModeController::setHoveredGhostTransfer(bool transfer)
{
m_hoveredGhostIsTransfer = transfer;
}
const std::optional<BuildingId>& const std::optional<BuildingId>&
BuildModeController::getDeconstructHoverBuildingId() const BuildModeController::getDeconstructHoverBuildingId() const
{ {

View File

@@ -95,6 +95,15 @@ public:
QPoint getBlueprintGhostTile() const; QPoint getBlueprintGhostTile() const;
void setBlueprintGhostTile(QPoint tile); void setBlueprintGhostTile(QPoint tile);
// Whether the ghost under the cursor would hand its settings to the building
// already there rather than place anything (REQ-UI-BLUEPRINT-TRANSFER). Classifying
// it needs the factory state, so the caller resolves it and stores the answer here,
// as with setGhostValidity. Kept here rather than recomputed per reader so the
// click, the ghost's colour, and the controls panel cannot disagree about what the
// cursor is over.
bool isHoveredGhostTransfer() const;
void setHoveredGhostTransfer(bool transfer);
// --- deconstruct mode ----------------------------------------------------- // --- deconstruct mode -----------------------------------------------------
const std::optional<BuildingId>& getDeconstructHoverBuildingId() const; const std::optional<BuildingId>& getDeconstructHoverBuildingId() const;
void setDeconstructHoverBuildingId(std::optional<BuildingId> id); void setDeconstructHoverBuildingId(std::optional<BuildingId> id);
@@ -118,6 +127,7 @@ private:
Blueprint m_blueprint; Blueprint m_blueprint;
QPoint m_blueprintGhostTile; QPoint m_blueprintGhostTile;
bool m_hoveredGhostIsTransfer = false;
std::optional<BuildingId> m_deconstructHoverBuildingId; std::optional<BuildingId> m_deconstructHoverBuildingId;
}; };

View File

@@ -18,6 +18,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h ${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.h
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.h
PARENT_SCOPE PARENT_SCOPE
) )
@@ -33,6 +34,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.cpp
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -0,0 +1,263 @@
#include "ControlAction.h"
namespace
{
// A binding that a belt drag can take over. Availability is a property of the action,
// but a binding can be claimed by a different action while a gesture is in progress:
// right-click cancels the drag instead of leaving builder mode (REQ-BLD-BELT-DRAG), so
// ExitMode's right-click drops out and it is left with Q alone.
enum class BindingCondition
{
Always,
NotDraggingBelt
};
struct KeyBindingEntry
{
ControlAction action;
int key;
// Shown on the badge, and Ctrl additionally participates in matching. Every other
// modifier is display only -- see resolveKeyAction.
Qt::KeyboardModifiers modifiers;
};
struct MouseBindingEntry
{
ControlAction action;
MouseBinding binding;
BindingCondition condition;
};
// Resolution is first-match-wins over these tables, so an entry that must beat another
// on the same input is listed above it -- CancelBeltLine before ExitMode on the right
// mouse button. Everything else is disjoint by availability.
const KeyBindingEntry KEY_BINDINGS[] = {
{ControlAction::Move, Qt::Key_A, Qt::NoModifier},
{ControlAction::Move, Qt::Key_D, Qt::NoModifier},
{ControlAction::GameSpeed, Qt::Key_W, Qt::NoModifier},
{ControlAction::GameSpeed, Qt::Key_S, Qt::NoModifier},
{ControlAction::TogglePause, Qt::Key_Space, Qt::NoModifier},
{ControlAction::CopyTemporary, Qt::Key_C, Qt::NoModifier},
{ControlAction::CreateBlueprint, Qt::Key_C, Qt::ControlModifier},
{ControlAction::PasteTemporary, Qt::Key_V, Qt::NoModifier},
{ControlAction::OpenBlueprints, Qt::Key_V, Qt::ControlModifier},
// One binding, two badges: Shift picks the rotation direction and is read by the
// handler, the way a build hotkey's digit is (REQ-BLD-ROTATE). Both entries match
// the same press, and both resolve to the same action, so listing them twice costs
// nothing and is what puts "R" and "Shift+R" on the row.
{ControlAction::Rotate, Qt::Key_R, Qt::NoModifier},
{ControlAction::Rotate, Qt::Key_R, Qt::ShiftModifier},
{ControlAction::EnterDeconstruct, Qt::Key_Q, Qt::NoModifier},
{ControlAction::ExitMode, Qt::Key_Q, Qt::NoModifier},
{ControlAction::OpenMenu, Qt::Key_Escape, Qt::NoModifier},
};
const MouseBindingEntry MOUSE_BINDINGS[] = {
{ControlAction::Select, MouseBinding::LeftClick, BindingCondition::Always},
{ControlAction::Place, MouseBinding::LeftClick, BindingCondition::Always},
{ControlAction::ApplySettings, MouseBinding::LeftClick, BindingCondition::Always},
{ControlAction::ToggleDeconstruct, MouseBinding::LeftClick, BindingCondition::Always},
{ControlAction::SelectArea, MouseBinding::LeftDrag, BindingCondition::Always},
{ControlAction::PlaceBeltLine, MouseBinding::LeftDrag, BindingCondition::Always},
{ControlAction::DeconstructArea, MouseBinding::LeftDrag, BindingCondition::Always},
{ControlAction::AddToSelection, MouseBinding::CtrlLeftClick, BindingCondition::Always},
{ControlAction::AddAreaToSelection, MouseBinding::CtrlLeftDrag, BindingCondition::Always},
{ControlAction::CancelBeltLine, MouseBinding::RightClick, BindingCondition::Always},
{ControlAction::ExitMode, MouseBinding::RightClick, BindingCondition::NotDraggingBelt},
};
bool isConditionMet(BindingCondition condition, const ControlContext& context)
{
if (condition == BindingCondition::NotDraggingBelt) { return !context.draggingBelt; }
return true;
}
bool isPlacementMode(const ControlContext& context)
{
return context.mode == BuildMode::Builder || context.mode == BuildMode::Blueprint;
}
std::vector<ControlAction> filterAvailable(const std::vector<ControlAction>& actions,
const ControlContext& context)
{
std::vector<ControlAction> available;
for (ControlAction action : actions)
{
if (isControlActionAvailable(action, context)) { available.push_back(action); }
}
return available;
}
} // namespace
bool isControlActionAvailable(ControlAction action, const ControlContext& context)
{
switch (action)
{
case ControlAction::None:
return false;
case ControlAction::Move:
case ControlAction::GameSpeed:
case ControlAction::TogglePause:
case ControlAction::OpenBlueprints:
case ControlAction::OpenMenu:
return true;
// Does nothing until something has been captured with C, so it is not offered
// before then (REQ-UI-BLUEPRINT-TEMP, REQ-UI-CONTROLS-ACCURACY).
case ControlAction::PasteTemporary:
return context.temporaryBlueprintExists;
case ControlAction::Select:
case ControlAction::SelectArea:
case ControlAction::AddToSelection:
case ControlAction::AddAreaToSelection:
case ControlAction::EnterDeconstruct:
return context.mode == BuildMode::None;
// Both need something a blueprint can be made of; a selection of ships or debris
// leaves them inert (REQ-UI-HOTKEYS).
case ControlAction::CopyTemporary:
case ControlAction::CreateBlueprint:
return context.mode == BuildMode::None && context.placeableBuildingSelected;
// Place and ApplySettings are the same click; which one it is depends on whether
// the ghost under the cursor is a transfer target (REQ-UI-BLUEPRINT-TRANSFER).
case ControlAction::Place:
return isPlacementMode(context) && !context.hoveredGhostIsTransfer;
case ControlAction::ApplySettings:
return context.mode == BuildMode::Blueprint && context.hoveredGhostIsTransfer;
// The only building type placed by dragging (REQ-BLD-BELT-DRAG).
case ControlAction::PlaceBeltLine:
return context.mode == BuildMode::Builder
&& context.builderType == BuildingType::Belt;
case ControlAction::Rotate:
return isPlacementMode(context);
case ControlAction::CancelBeltLine:
return context.draggingBelt;
case ControlAction::ExitMode:
return context.mode != BuildMode::None;
case ControlAction::ToggleDeconstruct:
case ControlAction::DeconstructArea:
return context.mode == BuildMode::Deconstruct;
}
return false;
}
std::vector<ControlBinding> getControlActionBindings(ControlAction action,
const ControlContext& context)
{
std::vector<ControlBinding> bindings;
for (const MouseBindingEntry& entry : MOUSE_BINDINGS)
{
if (entry.action != action) { continue; }
if (!isConditionMet(entry.condition, context)) { continue; }
ControlBinding binding;
binding.isMouse = true;
binding.mouse = entry.binding;
bindings.push_back(binding);
}
for (const KeyBindingEntry& entry : KEY_BINDINGS)
{
if (entry.action != action) { continue; }
ControlBinding binding;
binding.key = entry.key;
binding.modifiers = entry.modifiers;
bindings.push_back(binding);
}
return bindings;
}
ControlContextKind getControlContextKind(const ControlContext& context)
{
switch (context.mode)
{
case BuildMode::Builder: return ControlContextKind::Build;
case BuildMode::Blueprint: return ControlContextKind::Blueprint;
case BuildMode::Deconstruct: return ControlContextKind::Deconstruct;
case BuildMode::None: break;
}
return context.selection == ControlSelection::None ? ControlContextKind::General
: ControlContextKind::Selection;
}
std::vector<ControlAction> getContextActions(const ControlContext& context)
{
// The candidates of each context, in the order REQ-UI-CONTROLS-CONTENT lists them,
// then filtered by availability.
//
// The General and Selection lists differ rather than one filtered list serving
// both, because the additive-selection rows are an omission and not an
// unavailability: Ctrl+click does work with nothing selected, it just picks the
// object like a plain click would. "Add / remove from selection" is a row that
// means nothing until there is a selection to add to, so it waits for one
// (REQ-UI-CONTROLS-ACCURACY permits omitting an available binding).
switch (getControlContextKind(context))
{
case ControlContextKind::Build:
case ControlContextKind::Blueprint:
return filterAvailable({ControlAction::Place, ControlAction::ApplySettings,
ControlAction::PlaceBeltLine, ControlAction::Rotate,
ControlAction::CancelBeltLine, ControlAction::ExitMode},
context);
case ControlContextKind::Deconstruct:
return filterAvailable({ControlAction::ToggleDeconstruct,
ControlAction::DeconstructArea, ControlAction::ExitMode},
context);
case ControlContextKind::Selection:
return filterAvailable({ControlAction::Select, ControlAction::SelectArea,
ControlAction::AddToSelection,
ControlAction::AddAreaToSelection,
ControlAction::EnterDeconstruct,
ControlAction::CopyTemporary,
ControlAction::CreateBlueprint},
context);
case ControlContextKind::General:
break;
}
return filterAvailable({ControlAction::Select, ControlAction::SelectArea,
ControlAction::EnterDeconstruct},
context);
}
std::vector<ControlAction> getAlwaysAvailableActions(const ControlContext& context)
{
return filterAvailable({ControlAction::Move, ControlAction::GameSpeed,
ControlAction::TogglePause, ControlAction::PasteTemporary,
ControlAction::OpenBlueprints, ControlAction::OpenMenu},
context);
}
ControlAction resolveKeyAction(int key, Qt::KeyboardModifiers modifiers,
const ControlContext& context)
{
// Ctrl distinguishes a chord from the bare key (Ctrl+C is not C); every other
// modifier is ignored, so Shift+A still pans and Shift+R still rotates. This is
// what the key handler has always done, and matching modifiers exactly instead
// would silently drop those presses.
const bool controlHeld = (modifiers & Qt::ControlModifier) != 0;
for (const KeyBindingEntry& entry : KEY_BINDINGS)
{
if (entry.key != key) { continue; }
const bool entryNeedsControl = (entry.modifiers & Qt::ControlModifier) != 0;
if (entryNeedsControl != controlHeld) { continue; }
if (isControlActionAvailable(entry.action, context)) { return entry.action; }
}
return ControlAction::None;
}
ControlAction resolveMouseAction(MouseBinding binding, const ControlContext& context)
{
for (const MouseBindingEntry& entry : MOUSE_BINDINGS)
{
if (entry.binding != binding) { continue; }
if (!isConditionMet(entry.condition, context)) { continue; }
if (isControlActionAvailable(entry.action, context)) { return entry.action; }
}
return ControlAction::None;
}

View File

@@ -0,0 +1,164 @@
#pragma once
#include <vector>
#include <Qt>
#include "BuildModeController.h"
#include "BuildingType.h"
// The single statement of what the player can do right now, and which input does it
// (REQ-UI-CONTROLS-CONTENT, REQ-UI-CONTROLS-ACCURACY).
//
// This file declares actions; it never performs them and it never names them. It knows
// an action's bindings and the situations in which it does something -- nothing about
// the simulation, the widgets, the events an action ends up firing, or the words shown
// to the player. Display text lives in ui/ControlActionText.h, which formats the
// bindings this hands it, so a badge is derived from the real binding rather than
// typed beside it.
//
// Three readers, all of them consuming this and none of them extending it:
//
// * ControlsPanel calls getContextActions()/getAlwaysAvailableActions() and draws them.
// * InputMapper calls resolveKeyAction() and fires the event the action stands for.
// * GameWorldView calls resolveMouseAction() and runs the branch it already ran.
//
// Two bindings are deliberately absent. Build hotkeys (REQ-UI-HOTKEYS) are advertised
// on the build buttons instead of in the panel, and InputMapper::getBuildHotkeyLabel
// already derives their badges from the same table the handler switches on, so they
// have no drift to fix. F3/F4 are development controls and appear nowhere
// (REQ-UI-CONTROLS-ACCURACY).
//
// When bindings become player-configurable, only the binding tables in the .cpp turn
// from hard-coded data into loaded data. The actions, the availability rules, the
// panel, and every handler are unaffected.
enum class ControlAction
{
None, // no action is bound to the queried input in the queried context
// Always available (REQ-UI-CONTROLS-CONTENT).
Move,
GameSpeed,
TogglePause,
PasteTemporary,
OpenBlueprints,
OpenMenu,
// No build mode active.
Select,
SelectArea,
AddToSelection,
AddAreaToSelection,
EnterDeconstruct,
// No build mode active, with something selected.
CopyTemporary,
CreateBlueprint,
// Builder and blueprint placement mode.
Place,
ApplySettings,
PlaceBeltLine,
Rotate,
CancelBeltLine,
ExitMode,
// Deconstruct mode.
ToggleDeconstruct,
DeconstructArea
};
// The mouse gestures that carry a binding. Each is a whole gesture rather than a raw
// event: a drag is one binding, not a press plus a release, because that is the unit
// the player and the panel both think in. Which events make up the gesture, and the
// state it runs on, stay with the widget that owns them.
enum class MouseBinding
{
LeftClick,
LeftDrag,
CtrlLeftClick,
CtrlLeftDrag,
RightClick
};
// Which selection category is held, mirroring REQ-UI-SELECTION-CATEGORIES without
// depending on SelectionController.
enum class ControlSelection
{
None,
Buildings,
FieldObjects
};
// Which card the panel is showing (REQ-UI-CONTROLS-CONTENT). Named rather than
// spelled, so the heading text stays a presentation concern.
enum class ControlContextKind
{
General,
Selection,
Build,
Blueprint,
Deconstruct
};
// Everything the availability rules are allowed to depend on, as a plain snapshot.
//
// Taking a snapshot rather than references to the live controllers is what keeps this
// testable without a world, and it is what stops an action from reaching into the
// simulation: if a rule needs a fact, the fact is named here and the caller supplies it.
struct ControlContext
{
BuildMode mode = BuildMode::None;
BuildingType builderType = BuildingType::Belt; // while mode == Builder
bool draggingBelt = false;
// A single-building blueprint whose ghost is over a configuration-transfer target,
// so clicking hands over settings rather than placing (REQ-UI-BLUEPRINT-TRANSFER).
bool hoveredGhostIsTransfer = false;
ControlSelection selection = ControlSelection::None;
int selectionCount = 0;
// At least one selected building is player-placeable, the condition under which
// C and Ctrl+C do anything (REQ-UI-HOTKEYS).
bool placeableBuildingSelected = false;
bool temporaryBlueprintExists = false;
};
// One input an action answers to, in structured form so the badge can be rendered from
// it. `modifiers` is what the badge shows; matching is looser than equality, see
// resolveKeyAction.
struct ControlBinding
{
bool isMouse = false;
MouseBinding mouse = MouseBinding::LeftClick;
int key = 0; // Qt::Key_*, when !isMouse
Qt::KeyboardModifiers modifiers = Qt::NoModifier;
};
// True when triggering the action in this context would do what its label says. The
// panel shows exactly the available actions, and the resolvers return only available
// ones, which is REQ-UI-CONTROLS-ACCURACY expressed as one function.
bool isControlActionAvailable(ControlAction action, const ControlContext& context);
// The inputs an action answers to, in the order the panel should badge them.
// Context-dependent because a binding can be taken over: while a belt drag is in
// progress the right mouse button cancels the drag, so ExitMode is left with its key
// binding alone (REQ-BLD-BELT-DRAG).
std::vector<ControlBinding> getControlActionBindings(ControlAction action,
const ControlContext& context);
// Which card is showing, and the rows it holds -- the context's own, then the block
// available everywhere (REQ-UI-CONTROLS-CARD). Both lists are already filtered to the
// available actions and ordered as REQ-UI-CONTROLS-CONTENT lists them.
ControlContextKind getControlContextKind(const ControlContext& context);
std::vector<ControlAction> getContextActions(const ControlContext& context);
std::vector<ControlAction> getAlwaysAvailableActions(const ControlContext& context);
// The action a key press or a mouse gesture triggers here, or None when the input is
// unbound in this context. Both return only actions that are available, so a caller can
// act on the result without re-checking the situation.
//
// Rotation direction is not part of the action: R and Shift+R are one Rotate, and the
// caller reads the modifier for the direction, exactly as the digit of a build hotkey
// is read from the key. An action with a parameter keeps the parameter at the handler.
ControlAction resolveKeyAction(int key, Qt::KeyboardModifiers modifiers,
const ControlContext& context);
ControlAction resolveMouseAction(MouseBinding binding, const ControlContext& context);

View File

@@ -16,6 +16,7 @@ add_files(
WorldCameraTest.cpp WorldCameraTest.cpp
SelectionControllerTest.cpp SelectionControllerTest.cpp
BuildModeControllerTest.cpp BuildModeControllerTest.cpp
ControlActionTest.cpp
BuildingTest.cpp BuildingTest.cpp
BuildingConfigTest.cpp BuildingConfigTest.cpp
ShipTest.cpp ShipTest.cpp

View File

@@ -0,0 +1,328 @@
#include "catch.hpp"
#include <algorithm>
#include <vector>
#include "BuildModeController.h"
#include "BuildingType.h"
#include "ControlAction.h"
// REQ-UI-CONTROLS-ACCURACY. The panel and the input handling read one table, and these
// tests are what makes that pay: the round-trip case below fails the moment a row is
// shown whose binding resolves elsewhere, which is the drift the whole design exists to
// prevent. Everything here works on action ids -- the display strings live in the ui
// target and are not what can silently go wrong.
namespace
{
ControlContext generalContext()
{
return ControlContext();
}
ControlContext selectionContext(bool placeable = true)
{
ControlContext context;
context.selection = ControlSelection::Buildings;
context.selectionCount = 3;
context.placeableBuildingSelected = placeable;
return context;
}
ControlContext builderContext(BuildingType type)
{
ControlContext context;
context.mode = BuildMode::Builder;
context.builderType = type;
return context;
}
ControlContext blueprintContext(bool transfer = false)
{
ControlContext context;
context.mode = BuildMode::Blueprint;
context.hoveredGhostIsTransfer = transfer;
return context;
}
ControlContext deconstructContext()
{
ControlContext context;
context.mode = BuildMode::Deconstruct;
return context;
}
// A spread wide enough that every availability rule and every binding condition is
// exercised by the whole-table properties below.
std::vector<ControlContext> allContexts()
{
ControlContext beltDragging = builderContext(BuildingType::Belt);
beltDragging.draggingBelt = true;
ControlContext generalWithBlueprint = generalContext();
generalWithBlueprint.temporaryBlueprintExists = true;
ControlContext fieldSelection = selectionContext(false);
fieldSelection.selection = ControlSelection::FieldObjects;
return {generalContext(),
generalWithBlueprint,
selectionContext(),
fieldSelection,
builderContext(BuildingType::Belt),
builderContext(BuildingType::Assembler),
beltDragging,
blueprintContext(false),
blueprintContext(true),
deconstructContext()};
}
std::vector<ControlAction> shownActions(const ControlContext& context)
{
std::vector<ControlAction> actions = getContextActions(context);
const std::vector<ControlAction> always = getAlwaysAvailableActions(context);
actions.insert(actions.end(), always.begin(), always.end());
return actions;
}
bool contains(const std::vector<ControlAction>& actions, ControlAction action)
{
return std::find(actions.begin(), actions.end(), action) != actions.end();
}
} // namespace
TEST_CASE("ControlAction: every shown row is available", "[controls]")
{
for (const ControlContext& context : allContexts())
{
for (ControlAction action : shownActions(context))
{
REQUIRE(isControlActionAvailable(action, context));
}
}
}
TEST_CASE("ControlAction: no row is shown twice", "[controls]")
{
for (const ControlContext& context : allContexts())
{
std::vector<ControlAction> actions = shownActions(context);
std::vector<ControlAction> unique = actions;
std::sort(unique.begin(), unique.end());
unique.erase(std::unique(unique.begin(), unique.end()), unique.end());
REQUIRE(unique.size() == actions.size());
}
}
// The core anti-drift property: a row's badges are rendered from its bindings, so every
// binding a row advertises must actually trigger that row's action in that same context.
TEST_CASE("ControlAction: every advertised binding resolves back to its own action",
"[controls]")
{
for (const ControlContext& context : allContexts())
{
for (ControlAction action : shownActions(context))
{
const std::vector<ControlBinding> bindings =
getControlActionBindings(action, context);
REQUIRE_FALSE(bindings.empty());
for (const ControlBinding& binding : bindings)
{
if (binding.isMouse)
{
REQUIRE(resolveMouseAction(binding.mouse, context) == action);
}
else
{
REQUIRE(resolveKeyAction(binding.key, binding.modifiers, context)
== action);
}
}
}
}
}
// The other direction: an input that resolves to something must resolve to an action
// that is actually available there, so no input can trigger a no-op. This is weaker
// than "must be a shown row" on purpose -- a context may omit an available binding
// (Ctrl+click in the General context), which REQ-UI-CONTROLS-ACCURACY permits; what it
// may never do is act on an unavailable one.
TEST_CASE("ControlAction: every resolvable input is available", "[controls]")
{
const std::vector<MouseBinding> mouseBindings = {
MouseBinding::LeftClick, MouseBinding::LeftDrag, MouseBinding::CtrlLeftClick,
MouseBinding::CtrlLeftDrag, MouseBinding::RightClick};
for (const ControlContext& context : allContexts())
{
for (MouseBinding binding : mouseBindings)
{
const ControlAction action = resolveMouseAction(binding, context);
if (action != ControlAction::None)
{
REQUIRE(isControlActionAvailable(action, context));
}
}
const std::vector<int> keys = {Qt::Key_A, Qt::Key_D, Qt::Key_W, Qt::Key_S,
Qt::Key_Space, Qt::Key_C, Qt::Key_V, Qt::Key_R,
Qt::Key_Q, Qt::Key_Escape};
for (int key : keys)
{
for (Qt::KeyboardModifiers modifiers :
{Qt::KeyboardModifiers(Qt::NoModifier),
Qt::KeyboardModifiers(Qt::ShiftModifier),
Qt::KeyboardModifiers(Qt::ControlModifier)})
{
const ControlAction action = resolveKeyAction(key, modifiers, context);
if (action != ControlAction::None)
{
REQUIRE(isControlActionAvailable(action, context));
}
}
}
}
}
TEST_CASE("ControlAction: contexts are named by mode and selection", "[controls]")
{
REQUIRE(getControlContextKind(generalContext()) == ControlContextKind::General);
REQUIRE(getControlContextKind(selectionContext()) == ControlContextKind::Selection);
REQUIRE(getControlContextKind(builderContext(BuildingType::Belt))
== ControlContextKind::Build);
REQUIRE(getControlContextKind(blueprintContext()) == ControlContextKind::Blueprint);
REQUIRE(getControlContextKind(deconstructContext())
== ControlContextKind::Deconstruct);
}
TEST_CASE("ControlAction: Q enters deconstruct mode, or leaves the active one",
"[controls]")
{
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, generalContext())
== ControlAction::EnterDeconstruct);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, selectionContext())
== ControlAction::EnterDeconstruct);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, builderContext(BuildingType::Belt))
== ControlAction::ExitMode);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, blueprintContext())
== ControlAction::ExitMode);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, deconstructContext())
== ControlAction::ExitMode);
}
TEST_CASE("ControlAction: Ctrl distinguishes the chords, other modifiers do not",
"[controls]")
{
const ControlContext context = selectionContext();
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::NoModifier, context)
== ControlAction::CopyTemporary);
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::ControlModifier, context)
== ControlAction::CreateBlueprint);
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::ControlModifier, context)
== ControlAction::OpenBlueprints);
// Shift+A must still pan, as it always has -- matching modifiers exactly instead of
// testing Ctrl alone would silently swallow the press.
REQUIRE(resolveKeyAction(Qt::Key_A, Qt::ShiftModifier, context) == ControlAction::Move);
REQUIRE(resolveKeyAction(Qt::Key_R, Qt::ShiftModifier, blueprintContext())
== ControlAction::Rotate);
}
TEST_CASE("ControlAction: a transfer target turns the click into Apply settings",
"[controls]")
{
const ControlContext plain = blueprintContext(false);
const ControlContext transfer = blueprintContext(true);
REQUIRE(resolveMouseAction(MouseBinding::LeftClick, plain) == ControlAction::Place);
REQUIRE(resolveMouseAction(MouseBinding::LeftClick, transfer)
== ControlAction::ApplySettings);
REQUIRE(contains(getContextActions(plain), ControlAction::Place));
REQUIRE_FALSE(contains(getContextActions(plain), ControlAction::ApplySettings));
REQUIRE(contains(getContextActions(transfer), ControlAction::ApplySettings));
REQUIRE_FALSE(contains(getContextActions(transfer), ControlAction::Place));
}
TEST_CASE("ControlAction: a belt drag takes the right mouse button from ExitMode",
"[controls]")
{
ControlContext dragging = builderContext(BuildingType::Belt);
dragging.draggingBelt = true;
const ControlContext idle = builderContext(BuildingType::Belt);
REQUIRE(resolveMouseAction(MouseBinding::RightClick, idle) == ControlAction::ExitMode);
REQUIRE(resolveMouseAction(MouseBinding::RightClick, dragging)
== ControlAction::CancelBeltLine);
// Q still leaves the mode outright while the drag runs, so ExitMode stays shown --
// with its right-click badge dropped, since the button now means something else.
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, dragging) == ControlAction::ExitMode);
const std::vector<ControlBinding> idleBindings =
getControlActionBindings(ControlAction::ExitMode, idle);
const std::vector<ControlBinding> dragBindings =
getControlActionBindings(ControlAction::ExitMode, dragging);
REQUIRE(idleBindings.size() == 2);
REQUIRE(dragBindings.size() == 1);
REQUIRE_FALSE(dragBindings.front().isMouse);
}
TEST_CASE("ControlAction: dragging a line is offered for belts only", "[controls]")
{
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag, builderContext(BuildingType::Belt))
== ControlAction::PlaceBeltLine);
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag,
builderContext(BuildingType::Assembler))
== ControlAction::None);
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag, blueprintContext())
== ControlAction::None);
}
TEST_CASE("ControlAction: blueprint keys are offered only where they do something",
"[controls]")
{
// V does nothing until C has captured something (REQ-UI-BLUEPRINT-TEMP).
ControlContext withBlueprint = generalContext();
withBlueprint.temporaryBlueprintExists = true;
REQUIRE_FALSE(contains(shownActions(generalContext()), ControlAction::PasteTemporary));
REQUIRE(contains(shownActions(withBlueprint), ControlAction::PasteTemporary));
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::NoModifier, generalContext())
== ControlAction::None);
// C and Ctrl+C need a selection holding something placeable (REQ-UI-HOTKEYS).
ControlContext fieldSelection = selectionContext(false);
fieldSelection.selection = ControlSelection::FieldObjects;
REQUIRE(contains(getContextActions(selectionContext()), ControlAction::CopyTemporary));
REQUIRE_FALSE(contains(getContextActions(fieldSelection), ControlAction::CopyTemporary));
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::NoModifier, fieldSelection)
== ControlAction::None);
// Ctrl+V opens the dialog whatever is going on (REQ-UI-HOTKEYS).
for (const ControlContext& context : allContexts())
{
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::ControlModifier, context)
== ControlAction::OpenBlueprints);
}
}
TEST_CASE("ControlAction: selection rows appear only once something is selected",
"[controls]")
{
const std::vector<ControlAction> general = getContextActions(generalContext());
const std::vector<ControlAction> selection = getContextActions(selectionContext());
REQUIRE(contains(general, ControlAction::Select));
REQUIRE(contains(general, ControlAction::EnterDeconstruct));
REQUIRE_FALSE(contains(general, ControlAction::AddToSelection));
REQUIRE_FALSE(contains(general, ControlAction::CreateBlueprint));
REQUIRE(contains(selection, ControlAction::AddToSelection));
REQUIRE(contains(selection, ControlAction::AddAreaToSelection));
REQUIRE(contains(selection, ControlAction::CreateBlueprint));
REQUIRE(contains(selection, ControlAction::EnterDeconstruct));
}

View File

@@ -39,6 +39,11 @@ bool BlueprintLibrary::getCanCaptureSelection() const
return selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds); return selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds);
} }
bool BlueprintLibrary::getHasTemporaryBlueprint() const
{
return m_temporaryBlueprint.has_value();
}
void BlueprintLibrary::saveSelectionAs(const QString& name) void BlueprintLibrary::saveSelectionAs(const QString& name)
{ {
Blueprint blueprint = createBlueprintFromSelection(); Blueprint blueprint = createBlueprintFromSelection();

View File

@@ -44,6 +44,11 @@ public:
// (REQ-UI-BLUEPRINT-CREATE). // (REQ-UI-BLUEPRINT-CREATE).
bool getCanCaptureSelection() const; bool getCanCaptureSelection() const;
// True once C has captured something -- the condition under which V does anything
// (REQ-UI-BLUEPRINT-TEMP), and so the condition under which the controls panel
// offers it (REQ-UI-CONTROLS-ACCURACY).
bool getHasTemporaryBlueprint() const;
// Captures the current selection under the given name and appends it to the list. // Captures the current selection under the given name and appends it to the list.
// Silently does nothing when nothing player-placeable is selected. // Silently does nothing when nothing player-placeable is selected.
void saveSelectionAs(const QString& name); void saveSelectionAs(const QString& name);

View File

@@ -1,6 +1,7 @@
#include "GameWorldView.h" #include "GameWorldView.h"
#include "PlacementRules.h" #include "PlacementRules.h"
#include "FactoryQueries.h" #include "FactoryQueries.h"
#include "BlueprintLibrary.h"
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -1075,7 +1076,7 @@ void GameWorldView::keyPressEvent(QKeyEvent* event)
// Keys are turned into actions and published by the input mapper // Keys are turned into actions and published by the input mapper
// (REQ-UI-HOTKEYS); this widget reacts to those as an ordinary subscriber, so // (REQ-UI-HOTKEYS); this widget reacts to those as an ordinary subscriber, so
// nothing is handled here directly. // nothing is handled here directly.
if (m_inputMapper.handleKeyPress(event)) { return; } if (m_inputMapper.handleKeyPress(event, getControlContext())) { return; }
QOpenGLWidget::keyPressEvent(event); QOpenGLWidget::keyPressEvent(event);
} }
@@ -1087,7 +1088,7 @@ void GameWorldView::keyReleaseEvent(QKeyEvent* event)
QOpenGLWidget::keyReleaseEvent(event); QOpenGLWidget::keyReleaseEvent(event);
return; return;
} }
if (m_inputMapper.handleKeyRelease(event)) { return; } if (m_inputMapper.handleKeyRelease(event, getControlContext())) { return; }
QOpenGLWidget::keyReleaseEvent(event); QOpenGLWidget::keyReleaseEvent(event);
} }
@@ -1101,6 +1102,43 @@ void GameWorldView::focusOutEvent(QFocusEvent* event)
QOpenGLWidget::focusOutEvent(event); QOpenGLWidget::focusOutEvent(event);
} }
void GameWorldView::setBlueprintLibrary(const BlueprintLibrary* library)
{
m_blueprintLibrary = library;
}
ControlContext GameWorldView::getControlContext() const
{
ControlContext context;
context.mode = m_buildMode.getMode();
context.draggingBelt = m_buildMode.isDraggingBelt();
context.hoveredGhostIsTransfer = m_buildMode.isHoveredGhostTransfer();
if (m_buildMode.isBuilderMode()) { context.builderType = m_buildMode.getBuilderType(); }
// Buildings win over field objects, so the two are never both non-empty
// (REQ-UI-SELECTION-CATEGORIES).
const std::vector<BuildingId>& buildings = m_selection.getSelectedBuildings();
const std::vector<entt::entity>& actors = m_selection.getSelectedActors();
const std::vector<entt::entity>& debris = m_selection.getSelectedDebris();
if (!buildings.empty())
{
context.selection = ControlSelection::Buildings;
context.selectionCount = static_cast<int>(buildings.size());
}
else if (!actors.empty() || !debris.empty())
{
context.selection = ControlSelection::FieldObjects;
context.selectionCount = static_cast<int>(actors.size() + debris.size());
}
if (m_blueprintLibrary)
{
context.placeableBuildingSelected = m_blueprintLibrary->getCanCaptureSelection();
context.temporaryBlueprintExists = m_blueprintLibrary->getHasTemporaryBlueprint();
}
return context;
}
void GameWorldView::mousePressEvent(QMouseEvent* event) void GameWorldView::mousePressEvent(QMouseEvent* event)
{ {
const WorldCoordinates coordinates = getCoordinates(); const WorldCoordinates coordinates = getCoordinates();
@@ -1263,6 +1301,21 @@ void GameWorldView::mouseMoveEvent(QMouseEvent* event)
else if (m_buildMode.isBlueprintMode()) else if (m_buildMode.isBlueprintMode())
{ {
m_buildMode.setBlueprintGhostTile(tile); m_buildMode.setBlueprintGhostTile(tile);
// Resolved here, once, through the same classifier the click and the ghost's
// colour use, and stored on the mode: the controls panel says "Apply settings"
// exactly when clicking would transfer (REQ-UI-BLUEPRINT-TRANSFER). Only a
// single-building blueprint hit-tests the cursor, so only it can be a hovered
// transfer target.
const std::vector<BlueprintBuilding>& buildings =
m_buildMode.getBlueprint().buildings;
bool transfer = false;
if (buildings.size() == 1)
{
transfer = resolveBlueprintGhostHere(buildings.front(), tile).action
== BlueprintGhostAction::Transfer;
}
m_buildMode.setHoveredGhostTransfer(transfer);
} }
else if (m_buildMode.isDeconstructMode()) else if (m_buildMode.isDeconstructMode())
{ {

View File

@@ -64,6 +64,7 @@
struct Command; struct Command;
struct ParsedReplay; struct ParsedReplay;
class BlueprintLibrary;
class ItemIconCache; class ItemIconCache;
class ReplayPlayer; class ReplayPlayer;
class Simulation; class Simulation;
@@ -102,6 +103,17 @@ public:
void setGameSpeed(double multiplier); void setGameSpeed(double multiplier);
void resetForNewGame(); void resetForNewGame();
// The blueprint library is constructed after this widget, so it arrives by setter.
// Not owned; supplies the two facts about blueprints that the control context needs
// (REQ-UI-CONTROLS-CONTENT).
void setBlueprintLibrary(const BlueprintLibrary* library);
// The player's current situation, as the one snapshot every reader of the control
// table works from: this widget's own mouse dispatch, the input mapper, and the
// controls panel (REQ-UI-CONTROLS-CONTENT). Built here because this widget owns the
// build mode and the selection.
ControlContext getControlContext() const;
protected: protected:
void initializeGL() override; void initializeGL() override;
void paintGL() override; void paintGL() override;
@@ -280,6 +292,8 @@ private:
// between them (REQ-UI-SELECTION-CATEGORIES), including publishing the change // between them (REQ-UI-SELECTION-CATEGORIES), including publishing the change
// events. This widget only resolves what was hit. // events. This widget only resolves what was hit.
SelectionController m_selection; SelectionController m_selection;
// Not owned; set after construction, so null until MainWindow has built it.
const BlueprintLibrary* m_blueprintLibrary = nullptr;
bool m_boxSelecting; bool m_boxSelecting;
QPoint m_boxStartTile; QPoint m_boxStartTile;
QPoint m_boxCurrentTile; QPoint m_boxCurrentTile;

View File

@@ -9,6 +9,7 @@
#include "BlueprintSelectionRequestedEvent.h" #include "BlueprintSelectionRequestedEvent.h"
#include "BuildHotkeyPressedEvent.h" #include "BuildHotkeyPressedEvent.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "ControlAction.h"
#include "DebugDrawToggleRequestedEvent.h" #include "DebugDrawToggleRequestedEvent.h"
#include "EscapeMenuRequestedEvent.h" #include "EscapeMenuRequestedEvent.h"
#include "EventManager.h" #include "EventManager.h"
@@ -77,7 +78,7 @@ QString InputMapper::getBuildHotkeyLabel(BuildingType type)
return QString(); return QString();
} }
bool InputMapper::handleKeyPress(QKeyEvent* event) bool InputMapper::handleKeyPress(QKeyEvent* event, const ControlContext& context)
{ {
// Auto-repeat says nothing new about which keys are down, and a held action is // Auto-repeat says nothing new about which keys are down, and a held action is
// already held. // already held.
@@ -86,6 +87,9 @@ bool InputMapper::handleKeyPress(QKeyEvent* event)
// 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.
// Not part of the ControlAction table: these are advertised on the build buttons
// rather than in the controls panel, and getBuildHotkeyLabel already reads the
// same binding table this does (REQ-UI-CONTROLS-ACCURACY).
const quint32 virtualKey = event->nativeVirtualKey(); const quint32 virtualKey = event->nativeVirtualKey();
if (virtualKey >= 0x31 && virtualKey <= 0x39) if (virtualKey >= 0x31 && virtualKey <= 0x39)
{ {
@@ -100,107 +104,102 @@ bool InputMapper::handleKeyPress(QKeyEvent* event)
} }
} }
// Blueprint chords (REQ-UI-HOTKEYS). Checked ahead of the plain-key switch below, // Development controls, deliberately outside the table so they are never offered
// which binds bare A/D/W/S/R/Q/C/V and must not fire on a Ctrl chord -- bare C and V // to the player (REQ-UI-CONTROLS-ACCURACY).
// are the temporary-blueprint counterparts of these two (REQ-UI-BLUEPRINT-TEMP). Both
// requests are decided by MainWindow, the only widget that can pause the game and dim
// the window for a modal.
if ((event->modifiers() & Qt::ControlModifier) != 0)
{
switch (event->key()) switch (event->key())
{ {
case Qt::Key_C:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintSaveRequestedEvent>());
return true;
case Qt::Key_V:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintSelectionRequestedEvent>());
return true;
default:
break;
}
}
switch (event->key())
{
case Qt::Key_A:
m_panLeftHeld = true;
updatePanDirection();
return true;
case Qt::Key_D:
m_panRightHeld = true;
updatePanDirection();
return true;
case Qt::Key_Space:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<PauseToggleRequestedEvent>());
return true;
case Qt::Key_W:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SpeedStepRequestedEvent>(+1));
return true;
case Qt::Key_S:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SpeedStepRequestedEvent>(-1));
return true;
case Qt::Key_R:
// Shift reverses the rotation direction (REQ-BLD-ROTATE).
EventManager::getInstance()->sendEventImmediately(
std::make_shared<GhostRotationRequestedEvent>(
(event->modifiers() & Qt::ShiftModifier) != 0));
return true;
case Qt::Key_Q:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ModeCancelRequestedEvent>());
return true;
case Qt::Key_C:
// Capture a temporary blueprint from the current selection (REQ-UI-BLUEPRINT-TEMP).
// The BlueprintLibrary owns the selection and blueprint-capture logic; it decides
// whether anything placeable is selected and drives placement mode from there.
EventManager::getInstance()->sendEventImmediately(
std::make_shared<TemporaryBlueprintCaptureRequestedEvent>());
return true;
case Qt::Key_V:
// Re-enter placement mode for the temporary blueprint captured with C, if there is
// one (REQ-UI-BLUEPRINT-TEMP). The library holds it; nothing is captured here.
EventManager::getInstance()->sendEventImmediately(
std::make_shared<TemporaryBlueprintPlaceRequestedEvent>());
return true;
case Qt::Key_F3: case Qt::Key_F3:
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<DebugDrawToggleRequestedEvent>()); std::make_shared<DebugDrawToggleRequestedEvent>());
return true; return true;
case Qt::Key_Escape:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EscapeMenuRequestedEvent>());
return true;
case Qt::Key_F4: case Qt::Key_F4:
EventManager::getInstance()->addEvent( EventManager::getInstance()->addEvent(
std::make_shared<TracePrintRequestedEvent>()); std::make_shared<TracePrintRequestedEvent>());
return true; return true;
default: default:
break;
}
// Everything else: the key names an action, the action names an event. Which key
// is bound to what, and whether it does anything in this situation, are both the
// table's business -- this switch only knows what each action means
// (REQ-UI-HOTKEYS, REQ-UI-CONTROLS-ACCURACY).
switch (resolveKeyAction(event->key(), event->modifiers(), context))
{
case ControlAction::Move:
// A parameter of the action rather than an action of its own: the table binds
// both keys to Move and the direction is read off the key here, as the rotation
// direction and the build hotkey's digit are.
if (event->key() == Qt::Key_A) { m_panLeftHeld = true; }
else { m_panRightHeld = true; }
updatePanDirection();
return true;
case ControlAction::GameSpeed:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SpeedStepRequestedEvent>(event->key() == Qt::Key_W ? +1 : -1));
return true;
case ControlAction::TogglePause:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<PauseToggleRequestedEvent>());
return true;
case ControlAction::Rotate:
// Shift reverses the rotation direction (REQ-BLD-ROTATE).
EventManager::getInstance()->sendEventImmediately(
std::make_shared<GhostRotationRequestedEvent>(
(event->modifiers() & Qt::ShiftModifier) != 0));
return true;
case ControlAction::EnterDeconstruct:
case ControlAction::ExitMode:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ModeCancelRequestedEvent>());
return true;
case ControlAction::CopyTemporary:
// The BlueprintLibrary owns the selection and blueprint-capture logic; it drives
// placement mode from there (REQ-UI-BLUEPRINT-TEMP).
EventManager::getInstance()->sendEventImmediately(
std::make_shared<TemporaryBlueprintCaptureRequestedEvent>());
return true;
case ControlAction::PasteTemporary:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<TemporaryBlueprintPlaceRequestedEvent>());
return true;
case ControlAction::CreateBlueprint:
// Decided by MainWindow, the only widget that can pause the game and dim the
// window for a modal.
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintSaveRequestedEvent>());
return true;
case ControlAction::OpenBlueprints:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintSelectionRequestedEvent>());
return true;
case ControlAction::OpenMenu:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EscapeMenuRequestedEvent>());
return true;
default:
// Either nothing is bound to the key here, or what is bound is a mouse gesture
// the view handles. Unconsumed, so ordinary Qt shortcuts keep working.
return false; return false;
} }
} }
bool InputMapper::handleKeyRelease(QKeyEvent* event) bool InputMapper::handleKeyRelease(QKeyEvent* event, const ControlContext& context)
{ {
if (event->isAutoRepeat()) { return false; } if (event->isAutoRepeat()) { return false; }
switch (event->key()) // Only held actions have a release worth acting on. Resolved through the table
// rather than matched against A and D directly, so the keys stay rebindable in one
// place rather than two.
if (resolveKeyAction(event->key(), event->modifiers(), context) != ControlAction::Move)
{ {
case Qt::Key_A:
m_panLeftHeld = false;
updatePanDirection();
return true;
case Qt::Key_D:
m_panRightHeld = false;
updatePanDirection();
return true;
default:
return false; return false;
} }
if (event->key() == Qt::Key_A) { m_panLeftHeld = false; }
else { m_panRightHeld = false; }
updatePanDirection();
return true;
} }
void InputMapper::releaseAll() void InputMapper::releaseAll()

View File

@@ -3,14 +3,19 @@
#include <QString> #include <QString>
#include "BuildingType.h" #include "BuildingType.h"
#include "ControlAction.h"
#include "WorldCamera.h" #include "WorldCamera.h"
class QKeyEvent; class QKeyEvent;
// Turns raw key events into the game's semantic actions and publishes them // 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 // (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 // be rebound independently later.
// here for now. //
// Which key means what is not decided here: the caller hands in a ControlContext and
// ControlAction.h resolves the press against it, so this file only knows what each
// action means once resolved. That is what keeps the controls panel and the key
// handling from drifting apart -- both read the one table (REQ-UI-CONTROLS-ACCURACY).
// //
// Two output shapes, chosen by the nature of the action rather than by taste: // Two output shapes, chosen by the nature of the action rather than by taste:
// //
@@ -34,9 +39,10 @@ public:
static QString getBuildHotkeyLabel(BuildingType type); static QString getBuildHotkeyLabel(BuildingType type);
// Both return true when the key was consumed; the caller passes anything else // Both return true when the key was consumed; the caller passes anything else
// on to its base class so unrelated shortcuts keep working. // on to its base class so unrelated shortcuts keep working. `context` is the
bool handleKeyPress(QKeyEvent* event); // player's current situation, which decides what a key does (REQ-UI-CONTROLS-CONTENT).
bool handleKeyRelease(QKeyEvent* event); bool handleKeyPress(QKeyEvent* event, const ControlContext& context);
bool handleKeyRelease(QKeyEvent* event, const ControlContext& context);
// Drops all held-key state, publishing the resulting change. Call when the // Drops all held-key state, publishing the resulting change. Call when the
// receiving widget can no longer expect key-up events. // receiving widget can no longer expect key-up events.

View File

@@ -77,6 +77,10 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
// because only it can pause the game and raise the dim overlay. Built after the // because only it can pause the game and raise the dim overlay. Built after the
// world view because loading blueprints.toml may put a message box on screen. // world view because loading blueprints.toml may put a message box on screen.
m_blueprintLibrary = std::make_unique<BlueprintLibrary>(sim, &sim->getConfig(), this); m_blueprintLibrary = std::make_unique<BlueprintLibrary>(sim, &sim->getConfig(), this);
// Two facts about blueprints decide what the world view offers the player
// (REQ-UI-CONTROLS-CONTENT); the library is built after the view, so it is handed
// over here rather than passed to the constructor.
m_gameWorldView->setBlueprintLibrary(m_blueprintLibrary.get());
// Floats over the game world at its right edge rather than occupying a column of // Floats over the game world at its right edge rather than occupying a column of
// its own, and hides itself while nothing is selected (REQ-UI-SELECTION-PANEL). Like // its own, and hides itself while nothing is selected (REQ-UI-SELECTION-PANEL). Like