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

@@ -123,7 +123,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- REQ-BLD-QUEUE: Placed buildings enter a construction queue and are built one at a time. Each building takes a duration defined in `buildings.toml [[building]].construction_time_seconds` to construct.
- REQ-BLD-ASTEROID-ONLY: Buildings can only be placed on asteroid tiles (per surface_mask; tiles marked `S` may extend into space).
- REQ-BLD-BUILDER-MODE: Clicking a build button activates builder mode for that building type. Builder mode is exited by right-clicking in the game world or clicking the same build button again. (Exception: while a belt drag placement is in progress, right-clicking cancels that drag instead of exiting, and builder mode stays active — REQ-BLD-BELT-DRAG.)
- REQ-BLD-GHOST: While in builder mode, a ghost of the building is rendered at the tile under the cursor, showing where it would be placed. The ghost follows the tile the cursor points at, which includes the view scrolling (REQ-UI-SCROLL) under a cursor that has not moved: the ghost then moves across the world with the scroll rather than sticking to the tile it was last placed on by a mouse move, exactly as a running selection box does (REQ-UI-MULTI-SELECT). The same holds for everything else the hovered position determines — placement validity (REQ-BLD-PLACE-VALID), the tunnel end being placed (REQ-BLD-TUNNEL-MODE), a belt drag's path (REQ-BLD-BELT-DRAG), the blueprint ghost (REQ-UI-BLUEPRINT-MODE), and the deconstruct hover (REQ-UI-DECONSTRUCT-BUTTON). A cursor that is not over the game world — resting on one of the floating panels, or outside the window — hovers nothing, so scrolling leaves that state untouched (REQ-UI-CONTROLS-PANEL). The ghost is drawn semi-transparently in the building type's own visuals — its `fill` and `outline` colors and `glyph` from `visuals.toml` — so that different building types are visually distinguishable in builder mode rather than all looking alike. When the current cursor position is invalid, the ghost instead uses the distinct "invalid" color (REQ-BLD-PLACE-VALID), which overrides the per-building coloring.
- REQ-BLD-GHOST: While in builder mode, a ghost of the building is rendered at the tile under the cursor, showing where it would be placed. The ghost follows the tile the cursor points at, which includes the view scrolling (REQ-UI-SCROLL) under a cursor that has not moved: the ghost then moves across the world with the scroll rather than sticking to the tile it was last placed on by a mouse move, exactly as a running selection box does (REQ-UI-MULTI-SELECT). The same holds for everything else the hovered position determines — placement validity (REQ-BLD-PLACE-VALID), the tunnel end being placed (REQ-BLD-TUNNEL-MODE), a belt drag's path (REQ-BLD-BELT-DRAG), the blueprint ghost (REQ-UI-BLUEPRINT-MODE), and the deconstruct hover (REQ-UI-DECONSTRUCT-BUTTON). A cursor that is not over the game world — resting on one of the floating panels (REQ-UI-CONTROLS-PANEL, REQ-UI-BUILD-BAR, REQ-UI-SELECTION-PANEL), or outside the window — points at no tile and therefore hovers nothing: no builder ghost and no blueprint ghost is drawn, no tunnel connection is previewed, and no building is tinted as the deconstruct hover, until the cursor returns to the world. The mode itself is unaffected — the player is still building, just not over anything — and the position the ghost had is not remembered: it is re-derived from wherever the cursor comes back. This applies whenever the cursor points elsewhere, including at the moment a mode is entered, so a mode entered from a build button (REQ-UI-BUILD-BAR) shows its ghost only once the cursor is over the world, while one entered by hotkey (REQ-UI-HOTKEYS) under a cursor already there shows it at once. The one exception is a gesture that holds the mouse button — a belt drag (REQ-BLD-BELT-DRAG) or a selection or deconstruct box (REQ-UI-MULTI-SELECT, REQ-BLD-DECONSTRUCT-BOX) — which goes on following the cursor across the panels and beyond the window until the button is released. The ghost is drawn semi-transparently in the building type's own visuals — its `fill` and `outline` colors and `glyph` from `visuals.toml` — so that different building types are visually distinguishable in builder mode rather than all looking alike. When the current cursor position is invalid, the ghost instead uses the distinct "invalid" color (REQ-BLD-PLACE-VALID), which overrides the per-building coloring.
- REQ-BLD-ROTATE: While in builder mode, pressing Shift+R rotates the ghost 90° clockwise and R rotates it 90° counter-clockwise. Rotation affects the direction of the output port.
- REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock. (For belts, placement is instead deferred to a drag gesture and happens on mouse release — REQ-BLD-BELT-DRAG.)
- REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, except as allowed by REQ-BLD-ROTATE-IN-PLACE (builder mode) or REQ-UI-BLUEPRINT-OVERLAP and REQ-UI-BLUEPRINT-TRANSFER (blueprint placement mode), and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color — overriding its per-building coloring (REQ-BLD-GHOST) — when the current cursor position fails any of these conditions.

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;