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

@@ -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*/)