Compare commits
3 Commits
02f2314588
...
fe69921b8e
| Author | SHA1 | Date | |
|---|---|---|---|
| fe69921b8e | |||
| 3e79b09fec | |||
| 77bbd58d02 |
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
263
src/lib/core/ControlAction.cpp
Normal file
263
src/lib/core/ControlAction.cpp
Normal 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;
|
||||
}
|
||||
164
src/lib/core/ControlAction.h
Normal file
164
src/lib/core/ControlAction.h
Normal 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);
|
||||
@@ -16,6 +16,7 @@ add_files(
|
||||
WorldCameraTest.cpp
|
||||
SelectionControllerTest.cpp
|
||||
BuildModeControllerTest.cpp
|
||||
ControlActionTest.cpp
|
||||
BuildingTest.cpp
|
||||
BuildingConfigTest.cpp
|
||||
ShipTest.cpp
|
||||
|
||||
328
src/test/ControlActionTest.cpp
Normal file
328
src/test/ControlActionTest.cpp
Normal 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));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -14,6 +14,8 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ControlsPanel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ControlActionText.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
|
||||
@@ -45,6 +47,8 @@ SET(SRCS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ControlsPanel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ControlActionText.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp
|
||||
|
||||
92
src/ui/ControlActionText.cpp
Normal file
92
src/ui/ControlActionText.cpp
Normal file
@@ -0,0 +1,92 @@
|
||||
#include "ControlActionText.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QKeySequence>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// tr() for a file of free functions. Q_DECLARE_TR_FUNCTIONS expands with access
|
||||
// specifiers, so it needs a class rather than a namespace; this struct exists only to
|
||||
// carry it and give the strings a single lupdate context.
|
||||
struct Strings
|
||||
{
|
||||
Q_DECLARE_TR_FUNCTIONS(ControlActionText)
|
||||
};
|
||||
|
||||
QString getMouseBadge(MouseBinding binding)
|
||||
{
|
||||
switch (binding)
|
||||
{
|
||||
case MouseBinding::LeftClick: return Strings::tr("LMB");
|
||||
case MouseBinding::LeftDrag: return Strings::tr("LMB drag");
|
||||
case MouseBinding::CtrlLeftClick: return Strings::tr("Ctrl+LMB");
|
||||
case MouseBinding::CtrlLeftDrag: return Strings::tr("Ctrl+LMB drag");
|
||||
case MouseBinding::RightClick: return Strings::tr("RMB");
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString getControlBindingBadge(const ControlBinding& binding)
|
||||
{
|
||||
if (binding.isMouse) { return getMouseBadge(binding.mouse); }
|
||||
|
||||
// QKeySequence spells the modifiers and the key together and localizes them, which
|
||||
// is what makes this stay correct once keys are rebindable: the chip is generated
|
||||
// from the binding rather than typed next to it.
|
||||
return QKeySequence(binding.key | static_cast<int>(binding.modifiers))
|
||||
.toString(QKeySequence::NativeText);
|
||||
}
|
||||
|
||||
QString getControlActionLabel(ControlAction action, const ControlContext& context)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case ControlAction::None: return QString();
|
||||
case ControlAction::Move: return Strings::tr("Move");
|
||||
case ControlAction::GameSpeed: return Strings::tr("Game speed");
|
||||
case ControlAction::TogglePause: return Strings::tr("Toggle pause");
|
||||
case ControlAction::PasteTemporary: return Strings::tr("Paste last");
|
||||
case ControlAction::OpenBlueprints: return Strings::tr("Blueprints");
|
||||
case ControlAction::OpenMenu: return Strings::tr("Menu");
|
||||
// A click on empty space clears and a click on an object replaces; naming only the
|
||||
// first would be a half-truth once something is selected.
|
||||
case ControlAction::Select:
|
||||
return context.selection == ControlSelection::None
|
||||
? Strings::tr("Select")
|
||||
: Strings::tr("Select / clear selection");
|
||||
case ControlAction::SelectArea: return Strings::tr("Select area");
|
||||
case ControlAction::AddToSelection: return Strings::tr("Add / remove from selection");
|
||||
case ControlAction::AddAreaToSelection: return Strings::tr("Add area to selection");
|
||||
case ControlAction::EnterDeconstruct: return Strings::tr("Deconstruct mode");
|
||||
case ControlAction::CopyTemporary: return Strings::tr("Copy to temporary blueprint");
|
||||
case ControlAction::CreateBlueprint: return Strings::tr("Create blueprint");
|
||||
case ControlAction::Place: return Strings::tr("Place");
|
||||
case ControlAction::ApplySettings: return Strings::tr("Apply settings");
|
||||
case ControlAction::PlaceBeltLine: return Strings::tr("Place belt line");
|
||||
case ControlAction::Rotate: return Strings::tr("Rotate");
|
||||
case ControlAction::CancelBeltLine: return Strings::tr("Cancel belt line");
|
||||
case ControlAction::ExitMode:
|
||||
return context.mode == BuildMode::Deconstruct
|
||||
? Strings::tr("Exit deconstruct mode")
|
||||
: Strings::tr("Exit placement");
|
||||
case ControlAction::ToggleDeconstruct: return Strings::tr("Toggle deconstruct");
|
||||
case ControlAction::DeconstructArea: return Strings::tr("Deconstruct area");
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString getControlContextName(ControlContextKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case ControlContextKind::General: return Strings::tr("GENERAL");
|
||||
case ControlContextKind::Selection: return Strings::tr("SELECTION");
|
||||
case ControlContextKind::Build: return Strings::tr("BUILD MODE");
|
||||
case ControlContextKind::Blueprint: return Strings::tr("BLUEPRINT MODE");
|
||||
case ControlContextKind::Deconstruct: return Strings::tr("DECONSTRUCT MODE");
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
26
src/ui/ControlActionText.h
Normal file
26
src/ui/ControlActionText.h
Normal file
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
#include "ControlAction.h"
|
||||
|
||||
// What the player is shown for the actions and bindings declared in ControlAction.h
|
||||
// (REQ-UI-CONTROLS-CONTENT). Kept out of lib/core deliberately: that file decides what
|
||||
// is available and what triggers it, this one decides what it is called, and only this
|
||||
// half is presentation.
|
||||
//
|
||||
// A badge is rendered from the binding it belongs to rather than written beside it, so
|
||||
// what the panel shows on a chip is what the resolver actually matches. When bindings
|
||||
// become player-configurable, this is the only place that has to learn to spell a
|
||||
// rebound key.
|
||||
|
||||
// The chip text for one binding: "Ctrl+V", "LMB drag", "RMB".
|
||||
QString getControlBindingBadge(const ControlBinding& binding);
|
||||
|
||||
// What the action is called in this context. A few actions are named for their
|
||||
// situation rather than their implementation -- one exit action reads "Exit placement"
|
||||
// in a placement mode and "Exit deconstruct mode" in deconstruct mode.
|
||||
QString getControlActionLabel(ControlAction action, const ControlContext& context);
|
||||
|
||||
// The heading, in the upper case the card shows it in (REQ-UI-CONTROLS-CARD).
|
||||
QString getControlContextName(ControlContextKind kind);
|
||||
239
src/ui/ControlsPanel.cpp
Normal file
239
src/ui/ControlsPanel.cpp
Normal file
@@ -0,0 +1,239 @@
|
||||
#include "ControlsPanel.h"
|
||||
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QMouseEvent>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "ControlActionText.h"
|
||||
#include "GameWorldView.h"
|
||||
#include "selection/SelectionNames.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
const int kMarginPx = 8; // between the band's edge and the panel
|
||||
const int kCardMarginPx = 8; // inside the panel, around its content
|
||||
const int kRefreshMs = 50; // see the class comment on why this polls
|
||||
|
||||
// Separates the heading's name from its detail, e.g. "BUILD MODE * Assembler".
|
||||
const QChar kHeadingSeparator(0x00B7); // U+00B7 MIDDLE DOT
|
||||
|
||||
// One row: the chips for the bindings, then what the action is called. Built as a plain
|
||||
// widget rather than a class of its own -- it holds no state and answers no questions.
|
||||
QWidget* makeRow(ControlAction action, const ControlContext& context, QWidget* parent)
|
||||
{
|
||||
QWidget* row = new QWidget(parent);
|
||||
QHBoxLayout* layout = new QHBoxLayout(row);
|
||||
layout->setContentsMargins(0, 2, 0, 2);
|
||||
layout->setSpacing(4);
|
||||
|
||||
// Every badge is rendered from the binding the resolver matches, so a chip cannot
|
||||
// claim a key that does nothing (REQ-UI-CONTROLS-ACCURACY).
|
||||
const std::vector<ControlBinding> bindings = getControlActionBindings(action, context);
|
||||
for (const ControlBinding& binding : bindings)
|
||||
{
|
||||
QLabel* badge = new QLabel(getControlBindingBadge(binding), row);
|
||||
badge->setObjectName(QStringLiteral("controlBadge"));
|
||||
layout->addWidget(badge);
|
||||
}
|
||||
|
||||
QLabel* label = new QLabel(getControlActionLabel(action, context), row);
|
||||
label->setObjectName(action == ControlAction::ExitMode
|
||||
? QStringLiteral("controlLabelExit")
|
||||
: QStringLiteral("controlLabel"));
|
||||
layout->addSpacing(4);
|
||||
layout->addWidget(label);
|
||||
layout->addStretch(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ControlsPanel::ControlsPanel(const GameWorldView* view, QWidget* parent)
|
||||
: QWidget(parent)
|
||||
, m_view(view)
|
||||
{
|
||||
// Floats over the rendered world, so it brings its own background to stay legible
|
||||
// over any world content (REQ-UI-CONTROLS-PANEL). Palette colors match the build
|
||||
// button bar's and the selection panel's chrome; like them this is widget chrome
|
||||
// rather than world rendering, so it is deliberately not a visuals.toml color. The
|
||||
// class scoped selector keeps the border on the panel rather than cascading onto
|
||||
// its children.
|
||||
setAttribute(Qt::WA_StyledBackground, true);
|
||||
setStyleSheet(QStringLiteral(
|
||||
"ControlsPanel { background-color: palette(window);"
|
||||
" border: 1px solid palette(mid); border-radius: 4px; }"
|
||||
"QLabel#controlHeading { font-weight: bold; letter-spacing: 1px;"
|
||||
" color: palette(text); }"
|
||||
"QLabel#controlBadge { border: 1px solid palette(mid); border-radius: 3px;"
|
||||
" padding: 1px 5px; font-family: monospace; color: palette(text); }"
|
||||
"QLabel#controlLabel { color: palette(text); }"
|
||||
// The row that leaves the mode reads differently from the ones that act within
|
||||
// it (REQ-UI-CONTROLS-CARD).
|
||||
"QLabel#controlLabelExit { color: palette(bright-text); }"
|
||||
"QLabel#controlCaption { color: palette(mid); font-size: 10px;"
|
||||
" letter-spacing: 1px; }"));
|
||||
|
||||
QVBoxLayout* outerLayout = new QVBoxLayout(this);
|
||||
outerLayout->setContentsMargins(kCardMarginPx, kCardMarginPx,
|
||||
kCardMarginPx, kCardMarginPx);
|
||||
outerLayout->setSpacing(4);
|
||||
|
||||
m_heading = new QLabel(this);
|
||||
m_heading->setObjectName(QStringLiteral("controlHeading"));
|
||||
m_heading->setCursor(Qt::PointingHandCursor);
|
||||
outerLayout->addWidget(m_heading);
|
||||
|
||||
m_rows = new QWidget(this);
|
||||
m_rowsLayout = new QVBoxLayout(m_rows);
|
||||
m_rowsLayout->setContentsMargins(0, 0, 0, 0);
|
||||
m_rowsLayout->setSpacing(0);
|
||||
outerLayout->addWidget(m_rows);
|
||||
|
||||
// Polling rather than subscribing; see the class comment.
|
||||
m_refreshTimer = new QTimer(this);
|
||||
connect(m_refreshTimer, &QTimer::timeout, this, &ControlsPanel::refresh);
|
||||
m_refreshTimer->start(kRefreshMs);
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
void ControlsPanel::anchorTo(const QRect& bandRect)
|
||||
{
|
||||
m_bandRect = bandRect;
|
||||
refit();
|
||||
}
|
||||
|
||||
void ControlsPanel::mousePressEvent(QMouseEvent* event)
|
||||
{
|
||||
// Only the heading toggles; a click anywhere else is swallowed so it never reaches
|
||||
// the world beneath (REQ-UI-CONTROLS-PANEL).
|
||||
if (m_heading->geometry().contains(event->pos()))
|
||||
{
|
||||
m_collapsed = !m_collapsed;
|
||||
m_rows->setVisible(!m_collapsed);
|
||||
refit();
|
||||
}
|
||||
event->accept();
|
||||
}
|
||||
|
||||
void ControlsPanel::refresh()
|
||||
{
|
||||
if (!m_view) { return; }
|
||||
|
||||
const ControlContext context = m_view->getControlContext();
|
||||
|
||||
const QString heading = getHeadingText(context);
|
||||
const std::vector<ControlAction> contextActions = getContextActions(context);
|
||||
const std::vector<ControlAction> alwaysActions = getAlwaysAvailableActions(context);
|
||||
|
||||
if (heading == m_shownHeading && contextActions == m_shownContextActions
|
||||
&& alwaysActions == m_shownAlwaysActions)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_shownHeading = heading;
|
||||
m_shownContextActions = contextActions;
|
||||
m_shownAlwaysActions = alwaysActions;
|
||||
rebuild(context);
|
||||
}
|
||||
|
||||
void ControlsPanel::rebuild(const ControlContext& context)
|
||||
{
|
||||
m_heading->setText(m_shownHeading);
|
||||
|
||||
// Rows are rebuilt wholesale rather than reconciled: a context change replaces
|
||||
// nearly all of them, and the panel redraws only when something actually changed.
|
||||
while (QLayoutItem* item = m_rowsLayout->takeAt(0))
|
||||
{
|
||||
delete item->widget();
|
||||
delete item;
|
||||
}
|
||||
|
||||
for (ControlAction action : m_shownContextActions)
|
||||
{
|
||||
m_rowsLayout->addWidget(makeRow(action, context, m_rows));
|
||||
}
|
||||
|
||||
// The always-available block sits under a divider, except in the General context,
|
||||
// where those rows and the context's own are the same kind of thing to a player
|
||||
// with nothing selected and no mode active (REQ-UI-CONTROLS-CARD).
|
||||
if (!m_shownAlwaysActions.empty()
|
||||
&& getControlContextKind(context) != ControlContextKind::General)
|
||||
{
|
||||
QFrame* divider = new QFrame(m_rows);
|
||||
divider->setFrameShape(QFrame::HLine);
|
||||
divider->setFrameShadow(QFrame::Plain);
|
||||
m_rowsLayout->addSpacing(6);
|
||||
m_rowsLayout->addWidget(divider);
|
||||
|
||||
QLabel* caption = new QLabel(tr("ALWAYS AVAILABLE"), m_rows);
|
||||
caption->setObjectName(QStringLiteral("controlCaption"));
|
||||
m_rowsLayout->addWidget(caption);
|
||||
}
|
||||
|
||||
for (ControlAction action : m_shownAlwaysActions)
|
||||
{
|
||||
m_rowsLayout->addWidget(makeRow(action, context, m_rows));
|
||||
}
|
||||
|
||||
m_rows->setVisible(!m_collapsed);
|
||||
refit();
|
||||
}
|
||||
|
||||
QString ControlsPanel::getHeadingText(const ControlContext& context) const
|
||||
{
|
||||
const ControlContextKind kind = getControlContextKind(context);
|
||||
const QString name = getControlContextName(kind);
|
||||
|
||||
QString detail;
|
||||
switch (kind)
|
||||
{
|
||||
case ControlContextKind::Build:
|
||||
detail = getBuildingTypeName(context.builderType);
|
||||
break;
|
||||
case ControlContextKind::Blueprint:
|
||||
{
|
||||
// The temporary blueprint is never named (REQ-UI-BLUEPRINT-TEMP), so it is
|
||||
// labelled by what it is rather than left blank.
|
||||
const QString blueprintName = m_view->getActiveBlueprintName();
|
||||
detail = blueprintName.isEmpty() ? tr("Temporary") : blueprintName;
|
||||
break;
|
||||
}
|
||||
case ControlContextKind::Selection:
|
||||
detail = context.selection == ControlSelection::Buildings
|
||||
? tr("%n building(s)", "", context.selectionCount)
|
||||
: tr("%n object(s)", "", context.selectionCount);
|
||||
break;
|
||||
case ControlContextKind::General:
|
||||
case ControlContextKind::Deconstruct:
|
||||
break;
|
||||
}
|
||||
|
||||
if (detail.isEmpty()) { return name; }
|
||||
return name + QStringLiteral(" ") + kHeadingSeparator + QStringLiteral(" ") + detail;
|
||||
}
|
||||
|
||||
void ControlsPanel::refit()
|
||||
{
|
||||
if (m_bandRect.isNull()) { return; }
|
||||
|
||||
// The layout drops hidden widgets from its size hint, but only once it has been
|
||||
// re-run: collapsing hides the rows before Qt would get around to it on its own.
|
||||
layout()->activate();
|
||||
|
||||
const QRect band = m_bandRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx);
|
||||
if (band.width() <= 0 || band.height() <= 0) { return; }
|
||||
|
||||
const QSize hint = sizeHint();
|
||||
const int widthPx = qMin(hint.width(), band.width());
|
||||
const int heightPx = qMin(hint.height(), band.height());
|
||||
|
||||
// Left edge of the band, sitting on its bottom edge, so the panel grows upward as
|
||||
// rows are added (REQ-UI-CONTROLS-PANEL).
|
||||
setGeometry(band.left(), band.bottom() - heightPx + 1, widthPx, heightPx);
|
||||
}
|
||||
83
src/ui/ControlsPanel.h
Normal file
83
src/ui/ControlsPanel.h
Normal file
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <QRect>
|
||||
#include <QString>
|
||||
#include <QWidget>
|
||||
|
||||
#include "ControlAction.h"
|
||||
|
||||
class GameWorldView;
|
||||
class QLabel;
|
||||
class QTimer;
|
||||
class QVBoxLayout;
|
||||
|
||||
// Shows the controls available in the player's current situation
|
||||
// (REQ-UI-CONTROLS-PANEL). The panel decides nothing: which rows apply is
|
||||
// ControlAction.h's answer, the same one the key handling and the world view's mouse
|
||||
// dispatch act on, so what is shown and what happens cannot part company
|
||||
// (REQ-UI-CONTROLS-ACCURACY).
|
||||
//
|
||||
// It floats over the game world at the left edge, bottom-aligned within the band its
|
||||
// owner hands it, and collapses to its heading when the heading is clicked.
|
||||
//
|
||||
// Refreshed on a timer rather than by subscribing to events: two of the things that
|
||||
// change a row -- a belt drag starting, the ghost moving over a transfer target --
|
||||
// happen on mouse movement and publish nothing, and they still have to be reflected
|
||||
// while the game is paused, so there is no tick to hang it on either. The rebuild is
|
||||
// skipped unless the resolved content actually differs, which is a vector of enums to
|
||||
// compare.
|
||||
class ControlsPanel : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
// Neither pointer is owned; both must outlive this widget. The view is the source
|
||||
// of the control context, being the widget that owns the build mode and the
|
||||
// selection.
|
||||
ControlsPanel(const GameWorldView* view, QWidget* parent = nullptr);
|
||||
|
||||
// Confines the panel to the given band of the game world view: it left-aligns
|
||||
// within it and sits on its bottom edge. The band is the world view less the strip
|
||||
// the build button bar occupies, so the two never overlap and the bar never has to
|
||||
// move (REQ-UI-CONTROLS-PANEL, REQ-UI-BUILD-BAR).
|
||||
void anchorTo(const QRect& bandRect);
|
||||
|
||||
protected:
|
||||
// Clicking the heading collapses and expands the panel (REQ-UI-CONTROLS-PANEL).
|
||||
void mousePressEvent(QMouseEvent* event) override;
|
||||
|
||||
private:
|
||||
// Re-resolves the context and rebuilds only if the rows or the heading changed.
|
||||
void refresh();
|
||||
// Replaces the rows with those of the current context.
|
||||
void rebuild(const ControlContext& context);
|
||||
// The heading's "<name> * <detail>" text for the context, detail omitted when the
|
||||
// context has none.
|
||||
QString getHeadingText(const ControlContext& context) const;
|
||||
// Re-fits the panel to its content within the anchored band.
|
||||
void refit();
|
||||
|
||||
const GameWorldView* m_view;
|
||||
|
||||
QLabel* m_heading;
|
||||
QWidget* m_rows;
|
||||
QVBoxLayout* m_rowsLayout;
|
||||
QTimer* m_refreshTimer;
|
||||
|
||||
// What is currently drawn, so a refresh that resolves to the same thing does
|
||||
// nothing. The always-available block is kept separately because the divider
|
||||
// between the two is part of what is drawn.
|
||||
QString m_shownHeading;
|
||||
std::vector<ControlAction> m_shownContextActions;
|
||||
std::vector<ControlAction> m_shownAlwaysActions;
|
||||
|
||||
// Survives context changes and simulation restarts; presentation only, never a
|
||||
// command (REQ-UI-CONTROLS-PANEL).
|
||||
bool m_collapsed = false;
|
||||
|
||||
// The band the panel confines itself to, in the coordinates of its parent; null
|
||||
// until the owner has anchored it for the first time.
|
||||
QRect m_bandRect;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "GameWorldView.h"
|
||||
#include "PlacementRules.h"
|
||||
#include "FactoryQueries.h"
|
||||
#include "BlueprintLibrary.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
@@ -73,6 +74,23 @@
|
||||
namespace
|
||||
{
|
||||
|
||||
// The action a left-button gesture triggers, with the Ctrl variant falling back to the
|
||||
// plain one wherever nothing is bound to it. That fallback is what keeps Ctrl+click
|
||||
// placing a building in builder mode and Ctrl+drag deconstructing an area: the modifier
|
||||
// only means something where an action claims it (REQ-UI-CONTROLS-CONTENT).
|
||||
ControlAction resolveLeftGesture(bool controlHeld, bool isDrag,
|
||||
const ControlContext& context)
|
||||
{
|
||||
if (controlHeld)
|
||||
{
|
||||
const ControlAction action = resolveMouseAction(
|
||||
isDrag ? MouseBinding::CtrlLeftDrag : MouseBinding::CtrlLeftClick, context);
|
||||
if (action != ControlAction::None) { return action; }
|
||||
}
|
||||
return resolveMouseAction(isDrag ? MouseBinding::LeftDrag : MouseBinding::LeftClick,
|
||||
context);
|
||||
}
|
||||
|
||||
// Keep only the filter entries whose item type is currently unlocked
|
||||
// (REQ-LOCK-UI-BLUEPRINT). An empty result means "accept all".
|
||||
std::vector<ItemType> filterUnlockedItems(const std::vector<ItemType>& filter,
|
||||
@@ -1075,7 +1093,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 +1105,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,36 +1119,93 @@ 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;
|
||||
}
|
||||
|
||||
QString GameWorldView::getActiveBlueprintName() const
|
||||
{
|
||||
if (!m_buildMode.isBlueprintMode()) { return QString(); }
|
||||
return m_buildMode.getBlueprint().name;
|
||||
}
|
||||
|
||||
void GameWorldView::mousePressEvent(QMouseEvent* event)
|
||||
{
|
||||
const WorldCoordinates coordinates = getCoordinates();
|
||||
const ControlContext context = getControlContext();
|
||||
|
||||
if (event->button() != Qt::LeftButton)
|
||||
{
|
||||
if (event->button() == Qt::RightButton)
|
||||
{
|
||||
if (m_buildMode.isBuilderMode() && m_buildMode.isDraggingBelt())
|
||||
switch (resolveMouseAction(MouseBinding::RightClick, context))
|
||||
{
|
||||
// Cancel the in-progress belt drag without placing anything;
|
||||
// stay in belt builder mode (REQ-BLD-BELT-DRAG).
|
||||
case ControlAction::CancelBeltLine:
|
||||
// Drop the in-progress path without placing anything; belt builder
|
||||
// mode stays active (REQ-BLD-BELT-DRAG).
|
||||
m_buildMode.cancelBeltDrag();
|
||||
}
|
||||
else if (m_buildMode.getMode() != BuildMode::None)
|
||||
{
|
||||
break;
|
||||
case ControlAction::ExitMode:
|
||||
m_buildMode.exitCurrentMode();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const QPoint tile = coordinates.widgetToTile(event->pos());
|
||||
const bool controlHeld = (event->modifiers() & Qt::ControlModifier) != 0;
|
||||
|
||||
if (m_buildMode.isBuilderMode())
|
||||
// A press begins the click gesture; whether it turns out to be a drag is settled on
|
||||
// release, where the drag binding is resolved instead.
|
||||
switch (resolveLeftGesture(controlHeld, /*isDrag*/ false, context))
|
||||
{
|
||||
if (m_buildMode.getBuilderType() == BuildingType::Belt)
|
||||
case ControlAction::Place:
|
||||
case ControlAction::ApplySettings:
|
||||
if (m_buildMode.isBlueprintMode())
|
||||
{
|
||||
// Deferred placement: start the drag and show the path ghost; nothing
|
||||
// is placed until release (REQ-BLD-BELT-DRAG).
|
||||
placeBlueprintAtTile(tile);
|
||||
}
|
||||
else if (m_buildMode.getBuilderType() == BuildingType::Belt)
|
||||
{
|
||||
// Belts place by dragging, so the press only anchors the path and shows its
|
||||
// ghost; nothing is placed until release, and a plain click is the one-tile
|
||||
// case (REQ-BLD-BELT-DRAG).
|
||||
m_buildMode.beginBeltDrag(tile);
|
||||
m_cursorWorldPos = coordinates.widgetToWorld(event->pos());
|
||||
recomputeBeltDragPath(tile);
|
||||
@@ -1139,27 +1214,22 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
|
||||
{
|
||||
placeAtTile(tile);
|
||||
}
|
||||
}
|
||||
else if (m_buildMode.isBlueprintMode())
|
||||
{
|
||||
placeBlueprintAtTile(tile);
|
||||
}
|
||||
else if (m_buildMode.isDeconstructMode())
|
||||
{
|
||||
break;
|
||||
|
||||
case ControlAction::ToggleDeconstruct:
|
||||
// Start a deconstruct box drag; a plain click resolves as a 1x1 box on
|
||||
// release (REQ-BLD-DECONSTRUCT-CLICK, REQ-BLD-DECONSTRUCT-BOX).
|
||||
m_boxSelecting = true;
|
||||
m_boxStartTile = tile;
|
||||
m_boxCurrentTile = tile;
|
||||
}
|
||||
else
|
||||
{
|
||||
const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0;
|
||||
break;
|
||||
|
||||
case ControlAction::Select:
|
||||
case ControlAction::AddToSelection:
|
||||
// Only a click that hit nothing starts a box drag. Starting one on a hit
|
||||
// would re-resolve the same object as a 1x1 box on release and undo the
|
||||
// click: a Ctrl+click would toggle the building off, then straight back on.
|
||||
if (!selectAtPoint(tile, coordinates.widgetToWorld(event->pos()), ctrl))
|
||||
if (!selectAtPoint(tile, coordinates.widgetToWorld(event->pos()), controlHeld))
|
||||
{
|
||||
// selectAtPoint has already cleared the selection unless Ctrl is
|
||||
// preserving it for an additive drag.
|
||||
@@ -1167,6 +1237,10 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
|
||||
m_boxStartTile = tile;
|
||||
m_boxCurrentTile = tile;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1263,6 +1337,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())
|
||||
{
|
||||
@@ -1294,7 +1383,11 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
||||
const std::vector<BuildingId> boxIds =
|
||||
buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile);
|
||||
|
||||
if (m_buildMode.isDeconstructMode())
|
||||
const bool controlHeld = (event->modifiers() & Qt::ControlModifier) != 0;
|
||||
const ControlAction dragAction =
|
||||
resolveLeftGesture(controlHeld, /*isDrag*/ true, getControlContext());
|
||||
|
||||
if (dragAction == ControlAction::DeconstructArea)
|
||||
{
|
||||
const FactoryState& factory = m_sim->getFactoryState();
|
||||
|
||||
@@ -1356,7 +1449,9 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
||||
return;
|
||||
}
|
||||
|
||||
selectInBox((event->modifiers() & Qt::ControlModifier) != 0);
|
||||
// A Ctrl box adds and never deselects, where a plain one replaces
|
||||
// (REQ-UI-MULTI-SELECT).
|
||||
selectInBox(dragAction == ControlAction::AddAreaToSelection);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
|
||||
struct Command;
|
||||
struct ParsedReplay;
|
||||
class BlueprintLibrary;
|
||||
class ItemIconCache;
|
||||
class ReplayPlayer;
|
||||
class Simulation;
|
||||
@@ -102,6 +103,21 @@ 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;
|
||||
|
||||
// Name of the blueprint being placed, for the controls panel's heading. Empty while
|
||||
// no blueprint is active and for the unnamed temporary one (REQ-UI-BLUEPRINT-TEMP).
|
||||
QString getActiveBlueprintName() const;
|
||||
|
||||
protected:
|
||||
void initializeGL() override;
|
||||
void paintGL() override;
|
||||
@@ -280,6 +296,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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
#include "SchematicChoiceDialog.h"
|
||||
#include "HeaderBar.h"
|
||||
#include "SelectionPanel.h"
|
||||
#include "ControlsPanel.h"
|
||||
#include "ShipLayoutBlueprintSerializer.h"
|
||||
#include "ShipLayoutDialog.h"
|
||||
#include "BuildingIconCache.h"
|
||||
@@ -77,6 +78,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
|
||||
@@ -87,6 +92,11 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
|
||||
m_itemIcons.get(), m_buildingIcons.get(),
|
||||
this);
|
||||
|
||||
// Floats at the world view's opposite edge from the selection panel and reads the
|
||||
// world view for the player's current situation (REQ-UI-CONTROLS-PANEL). Built
|
||||
// after the view for the same stacking reason as the panels above it.
|
||||
m_controlsPanel = new ControlsPanel(m_gameWorldView, this);
|
||||
|
||||
// Created last so it stacks above the other children; covers the whole window and
|
||||
// dims the game behind modal dialogs/menus (REQ-UI-MODAL-DIM).
|
||||
m_dimOverlay = new ModalDimOverlay(m_visuals.overlays.modalDim, this);
|
||||
@@ -169,6 +179,10 @@ void MainWindow::layoutPanels()
|
||||
// move for it (REQ-UI-SELECTION-PANEL, REQ-UI-BUILD-BAR).
|
||||
m_selectionPanel->anchorTo(
|
||||
worldRect.adjusted(0, 0, 0, -m_buildButtonBar->getStripHeightPx()));
|
||||
// Same band, opposite edge: left and bottom-aligned, so it clears the bar's strip
|
||||
// too and never meets the selection panel (REQ-UI-CONTROLS-PANEL).
|
||||
m_controlsPanel->anchorTo(
|
||||
worldRect.adjusted(0, 0, 0, -m_buildButtonBar->getStripHeightPx()));
|
||||
m_dimOverlay->setGeometry(0, 0, totalW, totalH);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class Simulation;
|
||||
class GameWorldView;
|
||||
class HeaderBar;
|
||||
class SelectionPanel;
|
||||
class ControlsPanel;
|
||||
class BuildButtonBar;
|
||||
class BlueprintLibrary;
|
||||
class BuildingIconCache;
|
||||
@@ -99,6 +100,7 @@ private:
|
||||
GameWorldView* m_gameWorldView;
|
||||
HeaderBar* m_headerBar;
|
||||
SelectionPanel* m_selectionPanel;
|
||||
ControlsPanel* m_controlsPanel;
|
||||
BuildButtonBar* m_buildButtonBar;
|
||||
// The saved blueprints themselves; they have no widget of their own any more and
|
||||
// are reached through the two modal dialogs (REQ-UI-BLUEPRINT-DIALOG).
|
||||
|
||||
Reference in New Issue
Block a user