make selection box sub-tile aware

This commit is contained in:
2026-08-14 22:44:18 +02:00
parent 1cf7c264c1
commit a1c567715e
13 changed files with 232 additions and 125 deletions

View File

@@ -295,16 +295,11 @@ void GameWorldView::onFrame()
const bool viewMoved =
m_camera.advance(m_panDirection, elapsed, getScrollBounds());
// While the view scrolls, the tile under a stationary cursor changes,
// so refresh the box-select rectangle even though no mouse move fires.
// While the view scrolls, the world position under a stationary cursor
// changes, so refresh the box even though no mouse move fires.
if (m_boxSelecting && viewMoved)
{
m_boxCurrentTile =
getCoordinates().widgetToTile(mapFromGlobal(QCursor::pos()));
// The cursor has not travelled a pixel, so the drag threshold above never
// trips; but the scroll has grown the box past the tile it started on,
// which has to become visible.
if (m_boxCurrentTile != m_boxStartTile) { m_boxDragMoved = true; }
updateBoxDrag(mapFromGlobal(QCursor::pos()));
}
}
@@ -425,9 +420,14 @@ void GameWorldView::paintGL()
WorldRenderFrame GameWorldView::makeRenderFrame() const
{
return WorldRenderFrame{m_selection, m_buildMode, m_activeBeams,
m_boxSelecting, m_boxDragMoved, m_boxStartTile,
m_boxCurrentTile, m_debugDraw};
// A box only reaches the renderer once the gesture reads as a drag: below the
// threshold there is nothing to draw and nothing the box marks that hovering does
// not mark already (REQ-UI-MULTI-SELECT).
std::optional<QRectF> boxWorldRect;
if (m_boxSelecting && m_boxDragMoved) { boxWorldRect = getBoxWorldRect(); }
return WorldRenderFrame{m_selection, m_buildMode, m_activeBeams, boxWorldRect,
m_debugDraw};
}
// ---------------------------------------------------------------------------
@@ -1227,11 +1227,10 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
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;
m_boxStartPos = event->pos();
m_boxDragMoved = false;
m_boxSelecting = true;
m_boxStartWorld = coordinates.widgetToWorld(event->pos());
m_boxCurrentWorld = m_boxStartWorld;
m_boxDragMoved = false;
break;
case ControlAction::Select:
@@ -1244,9 +1243,8 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
// selectAtPoint has already cleared the selection unless Ctrl is
// preserving it for an additive drag.
m_boxSelecting = true;
m_boxStartTile = tile;
m_boxCurrentTile = tile;
m_boxStartPos = event->pos();
m_boxStartWorld = coordinates.widgetToWorld(event->pos());
m_boxCurrentWorld = m_boxStartWorld;
m_boxDragMoved = false;
}
break;
@@ -1300,8 +1298,10 @@ void GameWorldView::selectInBox(bool additive)
// mode: a Ctrl box adds and never deselects, where a Ctrl click toggles.
const SelectionMode mode = additive ? SelectionMode::Add : SelectionMode::Replace;
const QRectF worldBox = getBoxWorldRect();
const std::vector<BuildingId> boxIds =
buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile);
buildingsInBox(m_sim->getFactoryState(), worldBox);
if (!boxIds.empty())
{
publishSelectionAnchor(mode, boxIds, {}, {});
@@ -1309,10 +1309,8 @@ void GameWorldView::selectInBox(bool additive)
return;
}
const std::vector<entt::entity> boxActors =
actorsInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
const std::vector<entt::entity> boxDebris =
debrisInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
const std::vector<entt::entity> boxActors = actorsInBox(m_sim->getAdmin(), worldBox);
const std::vector<entt::entity> boxDebris = debrisInBox(m_sim->getAdmin(), worldBox);
if (!boxActors.empty() || !boxDebris.empty())
{
publishSelectionAnchor(mode, {}, boxActors, boxDebris);
@@ -1324,6 +1322,35 @@ void GameWorldView::selectInBox(bool additive)
if (!additive) { m_selection.clearAll(); }
}
void GameWorldView::updateBoxDrag(QPoint cursorWidgetPos)
{
const WorldCoordinates coordinates = getCoordinates();
m_boxCurrentWorld = coordinates.widgetToWorld(cursorWidgetPos);
// Measured against where the anchor sits on screen right now, not against where
// the button went down: a view that scrolls under a held button moves the anchor
// away from a motionless cursor, and that is a drag as much as moving the mouse
// is (REQ-UI-MULTI-SELECT).
const QPointF anchorWidgetPos = coordinates.worldToWidget(m_boxStartWorld);
const qreal travel_px = std::abs(cursorWidgetPos.x() - anchorWidgetPos.x())
+ std::abs(cursorWidgetPos.y() - anchorWidgetPos.y());
if (travel_px >= kBoxDragThresholdPixels) { m_boxDragMoved = true; }
}
QRectF GameWorldView::getBoxWorldRect() const
{
if (!m_boxDragMoved)
{
// Still a click: the rectangle it spans has no area and would cover nothing,
// so the box is the whole tile the button went down on instead — what the
// click points at (REQ-UI-MULTI-SELECT, REQ-BLD-DECONSTRUCT-CLICK).
return QRectF(std::floor(m_boxStartWorld.x()), std::floor(m_boxStartWorld.y()),
1.0, 1.0);
}
return QRectF(QPointF(m_boxStartWorld.x(), m_boxStartWorld.y()),
QPointF(m_boxCurrentWorld.x(), m_boxCurrentWorld.y())).normalized();
}
void GameWorldView::publishSelectionAnchor(SelectionMode mode,
const std::vector<BuildingId>& buildings,
const std::vector<entt::entity>& actors,
@@ -1359,14 +1386,6 @@ void GameWorldView::mouseMoveEvent(QMouseEvent* event)
const QPoint tile = coordinates.widgetToTile(event->pos());
m_cursorWorldPos = coordinates.widgetToWorld(event->pos());
// A press is a drag once the cursor has travelled far enough from it; below that
// it stays a click and shows no rectangle (REQ-UI-MULTI-SELECT).
if (m_boxSelecting
&& (event->pos() - m_boxStartPos).manhattanLength() >= kBoxDragThresholdPixels)
{
m_boxDragMoved = true;
}
if (m_buildMode.isBuilderMode())
{
m_buildMode.setGhostTile(tile);
@@ -1410,11 +1429,11 @@ void GameWorldView::mouseMoveEvent(QMouseEvent* event)
else if (m_buildMode.isDeconstructMode())
{
m_buildMode.setDeconstructHoverBuildingId(buildingAtTile(tile));
if (m_boxSelecting) { m_boxCurrentTile = tile; }
if (m_boxSelecting) { updateBoxDrag(event->pos()); }
}
else if (m_boxSelecting)
{
m_boxCurrentTile = tile;
updateBoxDrag(event->pos());
}
}
@@ -1435,7 +1454,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
m_boxSelecting = false;
const std::vector<BuildingId> boxIds =
buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile);
buildingsInBox(m_sim->getFactoryState(), getBoxWorldRect());
const bool controlHeld = (event->modifiers() & Qt::ControlModifier) != 0;
const ControlAction dragAction =

View File

@@ -226,6 +226,17 @@ private:
// which is the only case that goes on to start a box drag.
bool selectAtPoint(QPoint tile, QVector2D worldPos, bool additive);
void selectInBox(bool additive);
// Moves the running box drag's far corner to the world position under
// `cursorWidgetPos` and, once the cursor sits far enough from where the anchor is
// drawn, promotes the gesture from a click to a drag. Every corner update goes
// through here, including the ones a scrolling view causes under a cursor that
// has not moved (REQ-UI-MULTI-SELECT).
void updateBoxDrag(QPoint cursorWidgetPos);
// The box the drag currently spans, in world coordinates and normalized: the
// rectangle between its two corners once it reads as a drag, and the whole tile
// the button went down on before that (REQ-UI-MULTI-SELECT). Both what is drawn
// and what is selected come from here, so they can never disagree.
QRectF getBoxWorldRect() const;
// Publishes where on the screen the selection about to be made sits, so the
// selection panel can be placed beside it (REQ-UI-SELECTION-PANEL). Called with
// what is about to be selected, immediately before selecting it, and publishes
@@ -313,16 +324,16 @@ private:
// 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;
// Where the button went down, in widget pixels; the origin the drag threshold
// below measures from. Pixels, not tiles: the threshold separates a click from a
// drag, which is a hand-steadiness question and not a tile-sized one.
QPoint m_boxStartPos;
// Whether the cursor has moved far enough from m_boxStartPos for this to read as
// a drag. Until it has, the rectangle is not drawn (REQ-UI-MULTI-SELECT): a plain
// click would otherwise flash a one-tile rectangle. Sticky for the rest of the
// drag, so coming back to the press position does not hide the rectangle again.
// The drag's two corners in world coordinates, unsnapped: where the button went
// down and where the cursor is now (REQ-UI-MULTI-SELECT). World rather than
// widget coordinates so the anchor keeps the spot in the world it was placed on
// when the view scrolls under a held button.
QVector2D m_boxStartWorld;
QVector2D m_boxCurrentWorld;
// Whether the cursor has moved far enough from the anchor for this to read as a
// drag. Until it has, the rectangle is not drawn and the box resolves as the
// whole anchor tile (REQ-UI-MULTI-SELECT). Sticky for the rest of the drag, so
// coming back to the press position does not hide the rectangle again.
bool m_boxDragMoved;
// Interprets this widget's key events into semantic actions and publishes them

View File

@@ -984,9 +984,9 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor
// Deconstruct tint: while dragging a deconstruct box, tint every covered
// building/site (REQ-BLD-DECONSTRUCT-BOX); otherwise tint the hovered one.
if (frame.buildMode.isDeconstructMode() && frame.isBoxSelecting)
if (frame.buildMode.isDeconstructMode() && frame.boxWorldRect.has_value())
{
for (BuildingId id : buildingsInBox(m_sim.getFactoryState(), frame.boxStartTile, frame.boxCurrentTile))
for (BuildingId id : buildingsInBox(m_sim.getFactoryState(), *frame.boxWorldRect))
{
const Building* b = findBuilding(m_sim.getFactoryState(), id);
if (b && b->type == BuildingType::Hq) { continue; }
@@ -1019,16 +1019,14 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor
}
}
// Box-select rectangle. Not drawn until the drag has moved far enough to read as
// one, so a plain click does not flash a rectangle (REQ-UI-MULTI-SELECT).
if (frame.isBoxSelecting && frame.isBoxDragMoved)
// Box-select rectangle, drawn from the world rectangle itself and unsnapped, so
// the outline sits where the mouse went rather than on the tile grid
// (REQ-UI-MULTI-SELECT).
if (frame.boxWorldRect.has_value())
{
const QPoint tl(std::min(frame.boxStartTile.x(), frame.boxCurrentTile.x()),
std::min(frame.boxStartTile.y(), frame.boxCurrentTile.y()));
const QPoint br(std::max(frame.boxStartTile.x(), frame.boxCurrentTile.x()) + 1,
std::max(frame.boxStartTile.y(), frame.boxCurrentTile.y()) + 1);
const QRectF selRect(coordinates.tileToWidget(tl),
coordinates.tileToWidget(br));
const QRectF selRect(
coordinates.worldToWidget(QVector2D(frame.boxWorldRect->topLeft())),
coordinates.worldToWidget(QVector2D(frame.boxWorldRect->bottomRight())));
// In deconstruct mode the box marks buildings for demolition, so it is
// drawn in the deconstruct red instead of the selection color; the
// tint's alpha governs only the fills it tints, never this outline

View File

@@ -54,12 +54,11 @@ struct WorldRenderFrame
const SelectionController& selection;
const BuildModeController& buildMode;
const std::vector<ActiveBeam>& beams;
bool isBoxSelecting;
// Whether that box drag has passed the movement threshold that tells it apart
// from a click; until it has, the rectangle is not drawn (REQ-UI-MULTI-SELECT).
bool isBoxDragMoved;
QPoint boxStartTile;
QPoint boxCurrentTile;
// The box being dragged, in world coordinates and normalized, or nullopt when no
// drag is in progress — a press that has not passed the movement threshold is
// still a click and offers none (REQ-UI-MULTI-SELECT). It is the same rectangle
// the view selects by, so what is drawn and what is selected cannot disagree.
std::optional<QRectF> boxWorldRect;
bool isDebugDrawEnabled;
};