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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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*/)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user