resolve keyboard shortcuts through one action table instead of a switch

The controls panel needs to say what each key does right now, and a panel that
keeps its own list of that is a list that goes stale. So the list moves into
lib/core/ControlAction.h: which actions exist, what each is bound to, and when
each does something. InputMapper stops deciding that and switches on the
resolved action instead, so the panel and the key handling cannot disagree
about what Q means -- there is only one place that says.

The table declares; it never performs. It holds no simulation access, fires no
events, and names nothing: display strings live in the ui target, which formats
the bindings this hands it, so a badge is rendered from the real binding rather
than typed beside it. What an action *does* stays exactly where it was.

ControlContext is the snapshot the rules read, which is what keeps this
testable without a world. Two of its facts come from BlueprintLibrary, which is
built after the world view and so arrives by setter; one comes from the new
hovered-transfer flag on BuildModeController, resolved once on mouse-move
through the same classifier the click and the ghost colour already use.

Behaviour is unchanged, deliberately. Ctrl still separates the chords and every
other modifier is still ignored, so Shift+A pans as before; matching modifiers
exactly would have silently swallowed those presses. Build hotkeys and F3/F4
stay outside the table -- the first are advertised on the build buttons and
already derive their badges from the handler's own table, the second are
development controls the panel must never offer.

The tests are the point of putting this in lib: every row's bindings must
resolve back to that row's action in that same context, which fails the moment
a shown row and its handler part ways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
This commit is contained in:
2026-08-07 17:20:44 +02:00
parent 02f2314588
commit 77bbd58d02
14 changed files with 953 additions and 88 deletions

View File

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

View File

@@ -95,6 +95,15 @@ public:
QPoint getBlueprintGhostTile() const;
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 -----------------------------------------------------
const std::optional<BuildingId>& getDeconstructHoverBuildingId() const;
void setDeconstructHoverBuildingId(std::optional<BuildingId> id);
@@ -118,6 +127,7 @@ private:
Blueprint m_blueprint;
QPoint m_blueprintGhostTile;
bool m_hoveredGhostIsTransfer = false;
std::optional<BuildingId> m_deconstructHoverBuildingId;
};

View File

@@ -18,6 +18,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.h
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.h
PARENT_SCOPE
)
@@ -33,6 +34,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.cpp
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
SelectionControllerTest.cpp
BuildModeControllerTest.cpp
ControlActionTest.cpp
BuildingTest.cpp
BuildingConfigTest.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);
}
bool BlueprintLibrary::getHasTemporaryBlueprint() const
{
return m_temporaryBlueprint.has_value();
}
void BlueprintLibrary::saveSelectionAs(const QString& name)
{
Blueprint blueprint = createBlueprintFromSelection();

View File

@@ -44,6 +44,11 @@ public:
// (REQ-UI-BLUEPRINT-CREATE).
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.
// Silently does nothing when nothing player-placeable is selected.
void saveSelectionAs(const QString& name);

View File

@@ -1,6 +1,7 @@
#include "GameWorldView.h"
#include "PlacementRules.h"
#include "FactoryQueries.h"
#include "BlueprintLibrary.h"
#include <algorithm>
#include <cctype>
@@ -1075,7 +1076,7 @@ void GameWorldView::keyPressEvent(QKeyEvent* event)
// Keys are turned into actions and published by the input mapper
// (REQ-UI-HOTKEYS); this widget reacts to those as an ordinary subscriber, so
// nothing is handled here directly.
if (m_inputMapper.handleKeyPress(event)) { return; }
if (m_inputMapper.handleKeyPress(event, getControlContext())) { return; }
QOpenGLWidget::keyPressEvent(event);
}
@@ -1087,7 +1088,7 @@ void GameWorldView::keyReleaseEvent(QKeyEvent* event)
QOpenGLWidget::keyReleaseEvent(event);
return;
}
if (m_inputMapper.handleKeyRelease(event)) { return; }
if (m_inputMapper.handleKeyRelease(event, getControlContext())) { return; }
QOpenGLWidget::keyReleaseEvent(event);
}
@@ -1101,6 +1102,43 @@ void GameWorldView::focusOutEvent(QFocusEvent* 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)
{
const WorldCoordinates coordinates = getCoordinates();
@@ -1263,6 +1301,21 @@ void GameWorldView::mouseMoveEvent(QMouseEvent* event)
else if (m_buildMode.isBlueprintMode())
{
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())
{

View File

@@ -64,6 +64,7 @@
struct Command;
struct ParsedReplay;
class BlueprintLibrary;
class ItemIconCache;
class ReplayPlayer;
class Simulation;
@@ -102,6 +103,17 @@ public:
void setGameSpeed(double multiplier);
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:
void initializeGL() override;
void paintGL() override;
@@ -280,6 +292,8 @@ private:
// between them (REQ-UI-SELECTION-CATEGORIES), including publishing the change
// events. This widget only resolves what was hit.
SelectionController m_selection;
// Not owned; set after construction, so null until MainWindow has built it.
const BlueprintLibrary* m_blueprintLibrary = nullptr;
bool m_boxSelecting;
QPoint m_boxStartTile;
QPoint m_boxCurrentTile;

View File

@@ -9,6 +9,7 @@
#include "BlueprintSelectionRequestedEvent.h"
#include "BuildHotkeyPressedEvent.h"
#include "BuildingType.h"
#include "ControlAction.h"
#include "DebugDrawToggleRequestedEvent.h"
#include "EscapeMenuRequestedEvent.h"
#include "EventManager.h"
@@ -77,7 +78,7 @@ QString InputMapper::getBuildHotkeyLabel(BuildingType type)
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
// already held.
@@ -86,6 +87,9 @@ bool InputMapper::handleKeyPress(QKeyEvent* event)
// 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.
// 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();
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,
// which binds bare A/D/W/S/R/Q/C/V and must not fire on a Ctrl chord -- bare C and V
// 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())
{
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;
}
}
// Development controls, deliberately outside the table so they are never offered
// to the player (REQ-UI-CONTROLS-ACCURACY).
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:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DebugDrawToggleRequestedEvent>());
return true;
case Qt::Key_Escape:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EscapeMenuRequestedEvent>());
return true;
case Qt::Key_F4:
EventManager::getInstance()->addEvent(
std::make_shared<TracePrintRequestedEvent>());
return true;
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;
}
}
bool InputMapper::handleKeyRelease(QKeyEvent* event)
bool InputMapper::handleKeyRelease(QKeyEvent* event, const ControlContext& context)
{
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;
}
if (event->key() == Qt::Key_A) { m_panLeftHeld = false; }
else { m_panRightHeld = false; }
updatePanDirection();
return true;
}
void InputMapper::releaseAll()

View File

@@ -3,14 +3,19 @@
#include <QString>
#include "BuildingType.h"
#include "ControlAction.h"
#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.
// be rebound independently later.
//
// 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:
//
@@ -34,9 +39,10 @@ public:
static QString getBuildHotkeyLabel(BuildingType type);
// 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);
// on to its base class so unrelated shortcuts keep working. `context` is the
// player's current situation, which decides what a key does (REQ-UI-CONTROLS-CONTENT).
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
// 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
// world view because loading blueprints.toml may put a message box on screen.
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
// its own, and hides itself while nothing is selected (REQ-UI-SELECTION-PANEL). Like