From 1f754de43166342433a26f2342cb9b6f0e27cd0d Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Fri, 7 Aug 2026 18:47:29 +0200 Subject: [PATCH] resolve keyboard shortcuts through one action table instead of a switch --- src/lib/core/BuildModeController.cpp | 11 + src/lib/core/BuildModeController.h | 10 + src/lib/core/CMakeLists.txt | 2 + src/lib/core/ControlAction.cpp | 263 +++++++++++++++++++++ src/lib/core/ControlAction.h | 164 ++++++++++++++ src/test/CMakeLists.txt | 1 + src/test/ControlActionTest.cpp | 328 +++++++++++++++++++++++++++ src/ui/BlueprintLibrary.cpp | 5 + src/ui/BlueprintLibrary.h | 5 + src/ui/GameWorldView.cpp | 57 ++++- src/ui/GameWorldView.h | 14 ++ src/ui/InputMapper.cpp | 161 +++++++------ src/ui/InputMapper.h | 16 +- src/ui/MainWindow.cpp | 4 + 14 files changed, 953 insertions(+), 88 deletions(-) create mode 100644 src/lib/core/ControlAction.cpp create mode 100644 src/lib/core/ControlAction.h create mode 100644 src/test/ControlActionTest.cpp diff --git a/src/lib/core/BuildModeController.cpp b/src/lib/core/BuildModeController.cpp index 56d9be1..9e70f46 100644 --- a/src/lib/core/BuildModeController.cpp +++ b/src/lib/core/BuildModeController.cpp @@ -72,6 +72,7 @@ void BuildModeController::enterMode(BuildMode mode) std::make_shared()); break; case BuildMode::Blueprint: + m_hoveredGhostIsTransfer = false; EventManager::getInstance()->sendEventImmediately( std::make_shared()); 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& BuildModeController::getDeconstructHoverBuildingId() const { diff --git a/src/lib/core/BuildModeController.h b/src/lib/core/BuildModeController.h index 5bc5a6d..86f62cf 100644 --- a/src/lib/core/BuildModeController.h +++ b/src/lib/core/BuildModeController.h @@ -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& getDeconstructHoverBuildingId() const; void setDeconstructHoverBuildingId(std::optional id); @@ -118,6 +127,7 @@ private: Blueprint m_blueprint; QPoint m_blueprintGhostTile; + bool m_hoveredGhostIsTransfer = false; std::optional m_deconstructHoverBuildingId; }; diff --git a/src/lib/core/CMakeLists.txt b/src/lib/core/CMakeLists.txt index f4db9e6..8526bc2 100644 --- a/src/lib/core/CMakeLists.txt +++ b/src/lib/core/CMakeLists.txt @@ -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 ) diff --git a/src/lib/core/ControlAction.cpp b/src/lib/core/ControlAction.cpp new file mode 100644 index 0000000..d1f1832 --- /dev/null +++ b/src/lib/core/ControlAction.cpp @@ -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 filterAvailable(const std::vector& actions, + const ControlContext& context) +{ + std::vector 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 getControlActionBindings(ControlAction action, + const ControlContext& context) +{ + std::vector 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 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 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; +} diff --git a/src/lib/core/ControlAction.h b/src/lib/core/ControlAction.h new file mode 100644 index 0000000..d2bec18 --- /dev/null +++ b/src/lib/core/ControlAction.h @@ -0,0 +1,164 @@ +#pragma once + +#include + +#include + +#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 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 getContextActions(const ControlContext& context); +std::vector 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); diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index a22afb8..2c8d4bb 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -16,6 +16,7 @@ add_files( WorldCameraTest.cpp SelectionControllerTest.cpp BuildModeControllerTest.cpp + ControlActionTest.cpp BuildingTest.cpp BuildingConfigTest.cpp ShipTest.cpp diff --git a/src/test/ControlActionTest.cpp b/src/test/ControlActionTest.cpp new file mode 100644 index 0000000..dd818f1 --- /dev/null +++ b/src/test/ControlActionTest.cpp @@ -0,0 +1,328 @@ +#include "catch.hpp" + +#include +#include + +#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 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 shownActions(const ControlContext& context) +{ + std::vector actions = getContextActions(context); + const std::vector always = getAlwaysAvailableActions(context); + actions.insert(actions.end(), always.begin(), always.end()); + return actions; +} + +bool contains(const std::vector& 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 actions = shownActions(context); + std::vector 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 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 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 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 idleBindings = + getControlActionBindings(ControlAction::ExitMode, idle); + const std::vector 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 general = getContextActions(generalContext()); + const std::vector 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)); +} diff --git a/src/ui/BlueprintLibrary.cpp b/src/ui/BlueprintLibrary.cpp index 2eb8b97..4cda158 100644 --- a/src/ui/BlueprintLibrary.cpp +++ b/src/ui/BlueprintLibrary.cpp @@ -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(); diff --git a/src/ui/BlueprintLibrary.h b/src/ui/BlueprintLibrary.h index cd9c94f..384f0cc 100644 --- a/src/ui/BlueprintLibrary.h +++ b/src/ui/BlueprintLibrary.h @@ -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); diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 5d2382f..222ec00 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -1,6 +1,7 @@ #include "GameWorldView.h" #include "PlacementRules.h" #include "FactoryQueries.h" +#include "BlueprintLibrary.h" #include #include @@ -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& buildings = m_selection.getSelectedBuildings(); + const std::vector& actors = m_selection.getSelectedActors(); + const std::vector& debris = m_selection.getSelectedDebris(); + if (!buildings.empty()) + { + context.selection = ControlSelection::Buildings; + context.selectionCount = static_cast(buildings.size()); + } + else if (!actors.empty() || !debris.empty()) + { + context.selection = ControlSelection::FieldObjects; + context.selectionCount = static_cast(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& 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()) { diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 6190727..8828891 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -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; diff --git a/src/ui/InputMapper.cpp b/src/ui/InputMapper.cpp index 2f2c771..1b936c3 100644 --- a/src/ui/InputMapper.cpp +++ b/src/ui/InputMapper.cpp @@ -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()); - return true; - case Qt::Key_V: - EventManager::getInstance()->sendEventImmediately( - std::make_shared()); - 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()); - return true; - case Qt::Key_W: - EventManager::getInstance()->sendEventImmediately( - std::make_shared(+1)); - return true; - case Qt::Key_S: - EventManager::getInstance()->sendEventImmediately( - std::make_shared(-1)); - return true; - case Qt::Key_R: - // Shift reverses the rotation direction (REQ-BLD-ROTATE). - EventManager::getInstance()->sendEventImmediately( - std::make_shared( - (event->modifiers() & Qt::ShiftModifier) != 0)); - return true; - case Qt::Key_Q: - EventManager::getInstance()->sendEventImmediately( - std::make_shared()); - 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()); - 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()); - return true; case Qt::Key_F3: EventManager::getInstance()->sendEventImmediately( std::make_shared()); return true; - case Qt::Key_Escape: - EventManager::getInstance()->sendEventImmediately( - std::make_shared()); - return true; case Qt::Key_F4: EventManager::getInstance()->addEvent( std::make_shared()); 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(event->key() == Qt::Key_W ? +1 : -1)); + return true; + case ControlAction::TogglePause: + EventManager::getInstance()->sendEventImmediately( + std::make_shared()); + return true; + case ControlAction::Rotate: + // Shift reverses the rotation direction (REQ-BLD-ROTATE). + EventManager::getInstance()->sendEventImmediately( + std::make_shared( + (event->modifiers() & Qt::ShiftModifier) != 0)); + return true; + case ControlAction::EnterDeconstruct: + case ControlAction::ExitMode: + EventManager::getInstance()->sendEventImmediately( + std::make_shared()); + 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()); + return true; + case ControlAction::PasteTemporary: + EventManager::getInstance()->sendEventImmediately( + std::make_shared()); + 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()); + return true; + case ControlAction::OpenBlueprints: + EventManager::getInstance()->sendEventImmediately( + std::make_shared()); + return true; + case ControlAction::OpenMenu: + EventManager::getInstance()->sendEventImmediately( + std::make_shared()); + 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() diff --git a/src/ui/InputMapper.h b/src/ui/InputMapper.h index eb46b4d..03f714a 100644 --- a/src/ui/InputMapper.h +++ b/src/ui/InputMapper.h @@ -3,14 +3,19 @@ #include #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. diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index a307d78..e16096a 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -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(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