stop hovering when the cursor points at no tile

A cursor resting on a panel or outside the window kept whatever it last
pointed at: the ghost, the tunnel preview, the deconstruct tint all stayed
put, because "not hovering" was not a state the build mode could hold. Make
both ghost tiles optional, clear the hover with them, and re-derive it when
the cursor comes back or a mode is entered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
This commit is contained in:
2026-08-17 15:19:22 +02:00
parent 9490a96e12
commit b2148f00ac
7 changed files with 167 additions and 31 deletions

View File

@@ -135,6 +135,17 @@ void BuildModeController::exitCurrentMode()
enterMode(BuildMode::None);
}
void BuildModeController::clearHover()
{
m_ghostTile.reset();
m_ghostValid = false;
m_tunnelGhostType = BuildingType::TunnelEntry;
m_tunnelPartnerTile.reset();
m_blueprintGhostTile.reset();
m_hoveredGhostIsTransfer = false;
m_deconstructHoverBuildingId.reset();
}
BuildingType BuildModeController::getBuilderType() const
{
return m_builderType;
@@ -150,7 +161,7 @@ BuildingType BuildModeController::getEffectiveBuilderType() const
return isTunnelMode() ? m_tunnelGhostType : m_builderType;
}
QPoint BuildModeController::getGhostTile() const
const std::optional<QPoint>& BuildModeController::getGhostTile() const
{
return m_ghostTile;
}
@@ -240,7 +251,7 @@ Blueprint& BuildModeController::getMutableBlueprint()
return m_blueprint;
}
QPoint BuildModeController::getBlueprintGhostTile() const
const std::optional<QPoint>& BuildModeController::getBlueprintGhostTile() const
{
return m_blueprintGhostTile;
}

View File

@@ -54,6 +54,15 @@ public:
// Backs out of whichever mode is active, if any (the Q key and right-click).
void exitCurrentMode();
// --- hover ----------------------------------------------------------------
// Drops everything that follows from a cursor pointing at the world — both
// ghost tiles, placement validity, the resolved tunnel end, the transfer flag,
// the deconstruct hover — for a cursor that points at no tile at all, because it
// rests on a panel or has left the window (REQ-BLD-GHOST). The active mode is
// untouched: the player is still building, just not over anything. A belt drag's
// path is untouched too, since a drag keeps hovering while the button is held.
void clearHover();
// --- builder mode ---------------------------------------------------------
// Only meaningful while isBuilderMode().
BuildingType getBuilderType() const;
@@ -64,7 +73,9 @@ public:
// tunnel mode, the plain builder type otherwise.
BuildingType getEffectiveBuilderType() const;
QPoint getGhostTile() const;
// Unset while the cursor points at no tile (clearHover), which is the one case
// where builder mode draws no ghost at all.
const std::optional<QPoint>& getGhostTile() const;
Rotation getGhostRotation() const;
bool isGhostValid() const;
void setGhostTile(QPoint tile);
@@ -92,7 +103,8 @@ public:
// Mutable so the caller can rotate the layout in place; rotating a blueprint
// needs building footprints from the config, which does not belong here.
Blueprint& getMutableBlueprint();
QPoint getBlueprintGhostTile() const;
// Unset for a cursor pointing at no tile, as for the builder ghost above.
const std::optional<QPoint>& getBlueprintGhostTile() const;
void setBlueprintGhostTile(QPoint tile);
// Whether the ghost under the cursor would hand its settings to the building
@@ -115,7 +127,7 @@ private:
BuildMode m_mode = BuildMode::None;
BuildingType m_builderType = BuildingType::Belt;
QPoint m_ghostTile;
std::optional<QPoint> m_ghostTile;
Rotation m_ghostRotation = Rotation::East;
bool m_ghostValid = false;
BuildingType m_tunnelGhostType = BuildingType::TunnelEntry;
@@ -125,9 +137,9 @@ private:
QPoint m_beltDragAnchor;
std::vector<BeltPathTile> m_beltDragPath;
Blueprint m_blueprint;
QPoint m_blueprintGhostTile;
bool m_hoveredGhostIsTransfer = false;
Blueprint m_blueprint;
std::optional<QPoint> m_blueprintGhostTile;
bool m_hoveredGhostIsTransfer = false;
std::optional<BuildingId> m_deconstructHoverBuildingId;
};

View File

@@ -288,6 +288,66 @@ TEST_CASE("The effective builder type follows the resolved tunnel end", "[buildm
REQUIRE(controller.getTunnelPartnerTile() == QPoint(5, 5));
}
TEST_CASE("Clearing the hover drops everything the cursor pointed at", "[buildmode]")
{
// A cursor that leaves the world hovers nothing, so the ghost and its resolved
// tunnel end go with it, while the mode itself stays active (REQ-BLD-GHOST).
BuildModeController controller;
controller.enterBuilderMode(BuildingType::TunnelEntry);
controller.setGhostTile(QPoint(7, 2));
controller.setGhostValidity(true);
controller.setTunnelGhost(BuildingType::TunnelExit, QPoint(5, 2));
controller.clearHover();
REQUIRE(controller.isBuilderMode());
REQUIRE_FALSE(controller.getGhostTile().has_value());
REQUIRE_FALSE(controller.isGhostValid());
REQUIRE(controller.getEffectiveBuilderType() == BuildingType::TunnelEntry);
REQUIRE_FALSE(controller.getTunnelPartnerTile().has_value());
}
TEST_CASE("Clearing the hover keeps a belt drag's path", "[buildmode]")
{
// A drag holds the button and goes on hovering wherever the cursor travels, so
// nothing clears it short of releasing or cancelling (REQ-BLD-BELT-DRAG).
BuildModeController controller;
controller.enterBuilderMode(BuildingType::Belt);
controller.beginBeltDrag(QPoint(3, 4));
controller.setBeltDragPath({BeltPathTile{QPoint(3, 4), Rotation::East}});
controller.clearHover();
REQUIRE(controller.isDraggingBelt());
REQUIRE(controller.getBeltDragPath().size() == 1);
}
TEST_CASE("Clearing the hover drops the blueprint ghost and its transfer", "[buildmode]")
{
BuildModeController controller;
controller.enterBlueprintMode(makeBlueprint());
controller.setBlueprintGhostTile(QPoint(9, 9));
controller.setHoveredGhostTransfer(true);
controller.clearHover();
REQUIRE(controller.isBlueprintMode());
REQUIRE_FALSE(controller.getBlueprintGhostTile().has_value());
REQUIRE_FALSE(controller.isHoveredGhostTransfer());
}
TEST_CASE("Clearing the hover drops the deconstruct hover", "[buildmode]")
{
BuildModeController controller;
controller.toggleDeconstructMode();
controller.setDeconstructHoverBuildingId(BuildingId(4));
controller.clearHover();
REQUIRE(controller.isDeconstructMode());
REQUIRE_FALSE(controller.getDeconstructHoverBuildingId().has_value());
}
TEST_CASE("A non-tunnel builder ignores any resolved tunnel end", "[buildmode]")
{
BuildModeController controller;

View File

@@ -295,19 +295,12 @@ void GameWorldView::onFrame()
const bool viewMoved =
m_camera.advance(m_panDirection, elapsed, getScrollBounds());
// While the view scrolls, the world position under a stationary cursor
// changes, so the hover state is refreshed even though no mouse move fires.
if (viewMoved)
// Two things no mouse move reports: the world position under a stationary
// cursor changing as the view scrolls, and the cursor crossing onto a panel
// or out of the window, which ends the hover (REQ-BLD-GHOST).
if (viewMoved || isHoverLive() != m_hoverLive)
{
const QPoint cursorWidgetPos = mapFromGlobal(QCursor::pos());
// A running box drag holds the button and follows the cursor wherever it
// is, the floating panels included; hover state only tracks a cursor over
// the world, so scrolling past a cursor resting on a panel leaves the
// ghost where it was (REQ-UI-CONTROLS-PANEL).
if (m_boxSelecting || underMouse())
{
updateHoverAt(cursorWidgetPos);
}
refreshHover();
}
}
@@ -757,8 +750,10 @@ void GameWorldView::transferConfigTo(BuildingId id, const BlueprintBuilding& sou
void GameWorldView::updateTunnelGhost()
{
// The connection preview and entry/exit switch only apply at a valid placement
// (REQ-BLD-TUNNEL-MODE); at an invalid position the ghost stays a plain entry.
if (!m_buildMode.isGhostValid())
// (REQ-BLD-TUNNEL-MODE); at an invalid position, and where the cursor points at
// no tile at all, the ghost stays a plain entry.
const std::optional<QPoint>& ghostTile = m_buildMode.getGhostTile();
if (!ghostTile.has_value() || !m_buildMode.isGhostValid())
{
m_buildMode.setTunnelGhost(BuildingType::TunnelEntry, std::nullopt);
return;
@@ -768,7 +763,7 @@ void GameWorldView::updateTunnelGhost()
const TunnelLookup lookup = makeTunnelLookup(tunnels);
const TunnelCompletion completion =
resolveTunnelCompletion(lookup, m_buildMode.getGhostTile(),
resolveTunnelCompletion(lookup, *ghostTile,
m_buildMode.getGhostRotation(),
m_config->world.tunnelMaxDistance_tiles, m_cursorWorldPos);
m_buildMode.setTunnelGhost(completion.resolvedType, completion.partnerTile);
@@ -1335,8 +1330,35 @@ void GameWorldView::selectInBox(bool additive)
if (!additive) { m_selection.clearAll(); }
}
bool GameWorldView::isHoverLive() const
{
// underMouse() is false while the cursor sits on one of the floating panels,
// which are siblings of this widget rather than children, and while it is
// outside the window. A drag holding the button is the exception: it tracks the
// cursor wherever it goes until the button comes back up (REQ-UI-MULTI-SELECT,
// REQ-BLD-BELT-DRAG).
return underMouse() || m_boxSelecting || m_buildMode.isDraggingBelt();
}
void GameWorldView::refreshHover()
{
if (isHoverLive())
{
updateHoverAt(mapFromGlobal(QCursor::pos()));
}
else
{
m_buildMode.clearHover();
m_hoverLive = false;
}
}
void GameWorldView::updateHoverAt(QPoint cursorWidgetPos)
{
// Reached either from a mouse move, which only this widget receives, or from a
// hover refresh that has already established the cursor is on the world.
m_hoverLive = true;
const WorldCoordinates coordinates = getCoordinates();
const QPoint tile = coordinates.widgetToTile(cursorWidgetPos);
m_cursorWorldPos = coordinates.widgetToWorld(cursorWidgetPos);
@@ -1555,8 +1577,14 @@ void GameWorldView::rotateGhost(bool clockwise)
if (m_buildMode.isBuilderMode())
{
m_buildMode.rotateGhost(clockwise);
// The new facing is kept whatever the cursor is over; what it means for the
// world is only re-resolved while the cursor points at a tile (REQ-BLD-GHOST).
const std::optional<QPoint>& ghostTile = m_buildMode.getGhostTile();
if (!ghostTile.has_value()) { return; }
m_buildMode.setGhostValidity(
canPlaceBuildingHere(m_buildMode.getBuilderType(), m_buildMode.getGhostTile(),
canPlaceBuildingHere(m_buildMode.getBuilderType(), *ghostTile,
m_buildMode.getGhostRotation()));
// A new facing changes which tunnels the ghost could complete (REQ-BLD-TUNNEL-MODE).
if (m_buildMode.isTunnelMode()) { updateTunnelGhost(); }
@@ -1564,7 +1592,7 @@ void GameWorldView::rotateGhost(bool clockwise)
// without waiting for the next mouse move (REQ-BLD-BELT-DRAG).
if (m_buildMode.isDraggingBelt())
{
recomputeBeltDragPath(m_buildMode.getGhostTile());
recomputeBeltDragPath(*ghostTile);
}
}
else if (m_buildMode.isBlueprintMode())
@@ -1694,6 +1722,10 @@ void GameWorldView::handleEvent(std::shared_ptr<const BeamFiredEvent> event)
void GameWorldView::handleEvent(std::shared_ptr<const BuildingTypeSelectedEvent> event)
{
m_buildMode.enterBuilderMode(event->type);
// A mode entered by hotkey usually leaves the cursor exactly where it was, and no
// mouse move follows to place the ghost; entered from a build button it leaves the
// cursor on the bar, where there is nothing to hover (REQ-BLD-GHOST).
refreshHover();
}
void GameWorldView::handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEvent> /*event*/)
@@ -1704,11 +1736,13 @@ void GameWorldView::handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEv
void GameWorldView::handleEvent(std::shared_ptr<const DeconstructModeToggleRequestedEvent> /*event*/)
{
m_buildMode.toggleDeconstructMode();
refreshHover();
}
void GameWorldView::handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event)
{
m_buildMode.enterBlueprintMode(event->blueprint);
refreshHover();
}
void GameWorldView::handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> /*event*/)

View File

@@ -226,6 +226,15 @@ 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);
// Whether the cursor points at the game world at all: it does while it is over
// this widget, and while a belt or box drag holds the button, which goes on
// following the cursor onto the floating panels and past the window edge.
bool isHoverLive() const;
// Re-derives the hover from wherever the cursor is now, or drops it when the
// cursor points at nothing (REQ-BLD-GHOST). The entry point for everything a
// mouse move does not cover: a scrolling view, a cursor crossing onto a panel or
// out of the window, and a mode just entered under a cursor that has not moved.
void refreshHover();
// Re-resolves everything that follows from where the cursor points into the world:
// the ghost tile and its validity, the tunnel ends, a running belt or box drag, the
// deconstruct hover. Called for every mouse move, and once per frame while the view
@@ -320,6 +329,11 @@ private:
// end tile closest to the cursor when snapping to a building (REQ-BLD-BELT-DRAG)
// and to resolve the tunnel ghost sub-tile (REQ-BLD-TUNNEL-MODE).
QVector2D m_cursorWorldPos;
// Whether the hover state currently stands for a cursor pointing at the world,
// so that losing it is noticed once rather than every frame. Kept here rather
// than asked of Qt per reader: it has to agree with what was last written to the
// build mode controller, not with where the cursor happens to be mid-frame.
bool m_hoverLive = false;
bool m_debugDraw;

View File

@@ -897,13 +897,16 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor
/*showPortTargetGlyphs*/ true);
}
}
else
// A cursor that points at no tile — resting on a floating panel, or outside
// the window — hovers nothing, and builder mode then shows no ghost at all
// (REQ-BLD-GHOST).
else if (frame.buildMode.getGhostTile().has_value())
{
// In tunnel mode the ghost shows the position-resolved type (entry or
// exit) and, when it would complete an existing tunnel, the matched end
// and the tiles between it and the ghost are tinted green
// (REQ-BLD-TUNNEL-MODE).
const QPoint ghostTile = frame.buildMode.getGhostTile();
const QPoint ghostTile = *frame.buildMode.getGhostTile();
const std::optional<QPoint>& partnerTile = frame.buildMode.getTunnelPartnerTile();
if (frame.buildMode.isTunnelMode() && frame.buildMode.isGhostValid()
&& partnerTile.has_value())
@@ -931,14 +934,16 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor
}
}
// Blueprint placement ghost
if (frame.buildMode.isBlueprintMode())
// Blueprint placement ghost, drawn only while the cursor points at a tile, as for
// the builder ghost above (REQ-BLD-GHOST).
if (frame.buildMode.isBlueprintMode()
&& frame.buildMode.getBlueprintGhostTile().has_value())
{
// A single-building blueprint hit-tests the cursor for its transfer target; a
// constellation does not (REQ-UI-BLUEPRINT-TRANSFER). The stored building count,
// not the count after locked types are dropped, so the rule does not shift as the
// player unlocks things.
const QPoint cursorTile = frame.buildMode.getBlueprintGhostTile();
const QPoint cursorTile = *frame.buildMode.getBlueprintGhostTile();
const std::optional<QPoint> hoverTile =
frame.buildMode.getBlueprint().buildings.size() == 1
? std::make_optional(cursorTile) : std::nullopt;