[Game] Repaint cached zones when the hover zoom moves

`TableZone` cuts the hovered card out of its own paint so zone washes,
indicators, dimming and the land divider never draw over the zoom. It is a
`DeviceCoordinateCache` item, so the card changing geometry never dirtied it
and the stale hole stayed transparent, uncovering the black viewport as soon
as the zoom crossed a zone edge.

- Emit `hoverFootprintChanged(old, new)` from `setCardScale()` and once from
  `setHovered(false)`.
- Repaint every overlapping `TableZone` on that signal.
- Clip with the card's painted outline instead of its bounding rectangle, and
  treat a zoom covering the whole zone as "paint nothing".
This commit is contained in:
Lukas Brübach 2026-11-01 03:58:59 +01:00 • committed by Lukas Brübach
parent abbab61e4f
commit 692edb30dd
6 changed files with 97 additions and 29 deletions

View file

@ -290,7 +290,11 @@ void AbstractCardItem::setHovered(bool _hovered)
// Mark the hovered card's current scene footprint dirty so overlapped
// sibling zones (e.g. StackZone) repaint after the card moves away.
if (scene()) {
scene()->update(sceneBoundingRect());
const QRectF footprint = sceneBoundingRect();
scene()->update(footprint);
// The hole disappears the moment the card is no longer hovered, even though the
// card has not changed size yet: tell the zones so they repaint it shut.
emit hoverFootprintChanged(footprint, footprint);
}
}
@ -354,8 +358,13 @@ void AbstractCardItem::setCardScale(qreal _scale)
const QRectF oldFootprint = sceneBoundingRect();
prepareGeometryChange();
setScale(_scale);
const QRectF newFootprint = sceneBoundingRect();
if (scene()) {
scene()->update(oldFootprint.united(sceneBoundingRect()));
scene()->update(oldFootprint.united(newFootprint));
// Marking the scene region dirty is not enough for cached items: a zone that cuts a
// hover hole out of itself keeps the hole from its last repaint, which then shows as
// an uncovered black rectangle under the zoom.
emit hoverFootprintChanged(oldFootprint, newFootprint);
} else {
update();
}

View file

@ -66,6 +66,15 @@ signals:
void faceDownChanged(bool faceDown);
/** @brief Emitted when the hover shrink-out animation runs to completion while unhovered. */
void hoverShrinkFinished(AbstractCardItem *card);
/**
* @brief Emitted when the hover zoom changes this card's scene footprint.
*
* Zones (and anything else that dodges the zoomed card) draw from the live footprint at
* paint time but are cached: they need this to invalidate, or a stale hole is left behind.
* Also emitted once when the hover ends, with both rects equal, because the hole
* disappears without the card changing size.
*/
void hoverFootprintChanged(const QRectF &oldFootprint, const QRectF &newFootprint);
public:
enum

View file

@ -15,6 +15,7 @@
#include "z_values.h"
#include "zones/card_zone.h"
#include "zones/select_zone.h"
#include "zones/table_zone.h"
#include "zones/view_zone.h"
#include "zones/view_zone_widget.h"
@ -860,23 +861,20 @@ void GameScene::updateHoveredCard(CardItem *newCard)
}
return;
}
if (cardHoverWidget && cardHoverWidget->isVisible() && cardHoverWidget->getCardItem() == newCard) {
if (cardHoverWidget && cardHoverWidget->isVisible() && !cardHoverWidget->isClosing() &&
cardHoverWidget->getCardItem() == newCard) {
// Panel open but zoom dropped (e.g. survived a Leave) — zoom back and refresh.
beginCardHover(newCard);
hoveredCard = newCard;
cardHoverWidget->refreshDisplay();
return;
}
if (cardHoverWidget && cardHoverWidget->isVisible()) {
// Panel open elsewhere — move the zoom at once so it never strands, but let
// the panel follow after the configured delay instead of strobing open on
// every card swept across.
beginCardHover(newCard);
hoveredCard = newCard;
if (cardHoverWidget && cardHoverWidget->isVisible() && !cardHoverWidget->isClosing()) {
// A different card is hovered while the panel is up: close the old panel
// first, then fall through to first contact so the new card follows the
// normal dwell instead of the old panel trailing the cursor.
hideCardHoverPopup();
confirmArmedCard.clear();
hoverPanelTarget = newCard;
hoverPanelTimer->start(display.getHoverPanelDelayMs());
return;
}
if (zoomTrigger != CardsDisplaySettings::HoverPanelTriggerHover &&
panelTrigger != CardsDisplaySettings::HoverPanelTriggerHover) {
@ -944,6 +942,8 @@ void GameScene::beginCardHover(CardItem *card)
liftOwner = card;
connect(card, &AbstractCardItem::hoverShrinkFinished, this, &GameScene::onHoverShrinkFinished,
Qt::UniqueConnection);
connect(card, &AbstractCardItem::hoverFootprintChanged, this, &GameScene::onHoverFootprintChanged,
Qt::UniqueConnection);
}
void GameScene::endCardHover(CardItem *card)
@ -971,6 +971,25 @@ void GameScene::onHoverShrinkFinished(AbstractCardItem *card)
restoreHoverLift();
}
void GameScene::onHoverFootprintChanged(const QRectF &oldFootprint, const QRectF &newFootprint)
{
const QRectF dirty = oldFootprint.united(newFootprint);
if (dirty.isEmpty()) {
return;
}
// TableZone cuts the hovered card out of its own paint, but it is cached: the card
// changing size does not dirty the zone, so the previous hole would stay transparent in
// the cache — an uncovered black rectangle under the zoom whenever the card grows across
// a zone boundary. Nudge only the zones the footprint touches; the scene repaint itself
// is already scheduled by the card.
const auto overlapping = items(dirty, Qt::IntersectsItemBoundingRect);
for (QGraphicsItem *item : overlapping) {
if (auto *zone = qobject_cast<TableZone *>(item->toGraphicsObject())) {
zone->update();
}
}
}
void GameScene::restoreHoverLift()
{
for (const auto &[lifted, zValue] : hoverLiftedAncestors) {
@ -1170,7 +1189,8 @@ void GameScene::fireTriggers(CardItem *card, bool zoomFired, bool panelFired)
return;
}
if (cardHoverWidget && cardHoverWidget->isVisible() && cardHoverWidget->getCardItem() == card) {
if (cardHoverWidget && cardHoverWidget->isVisible() && !cardHoverWidget->isClosing() &&
cardHoverWidget->getCardItem() == card) {
cardHoverWidget->refreshDisplay();
return;
}

View file

@ -14,6 +14,7 @@
#include <QPair>
#include <QPointF>
#include <QPointer>
#include <QRectF>
#include <QSize>
#include <QSizeF>
#include <QString>
@ -126,6 +127,14 @@ private:
void restoreHoverLift();
/** @brief Settles a finished shrink-out: clip container and ancestor lift restore. */
void onHoverShrinkFinished(AbstractCardItem *card);
/**
* @brief Repaints the zones that dodge the zoomed card when its footprint moves.
*
* Those zones cut the hovered card out of their paint, but they are cached: another
* item's geometry change does not dirty them, so without this the previous hole stays
* transparent in the cache and a black rectangle shows under the zoom.
*/
void onHoverFootprintChanged(const QRectF &oldFootprint, const QRectF &newFootprint);
// Card hover popup methods
void showCardHoverPopup(CardItem *card);

View file

@ -19,6 +19,7 @@
#include <QList>
#include <QListIterator>
#include <QPainter>
#include <QPainterPath>
#include <QRectF>
#include <QSharedPointer>
#include <QString>
@ -34,6 +35,7 @@
#include <libcockatrice/game/zones/table_zone_logic.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/zone_names.h>
#include <optional>
#include <string>
namespace google
@ -121,10 +123,14 @@ void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*opti
// The hovered (zoomed) card may spill over this zone: cut it out of every layer
// below so washes, indicators, dimming and the land divider never paint over it,
// whatever the stacking order is.
const QRect hoverHole = hoveredCardHole();
const bool hasHole = !hoverHole.isNull();
if (hasHole) {
painter->setClipRegion(QRegion(boundingRect().toRect()) - QRegion(hoverHole));
const std::optional<QPainterPath> hoverHole = hoveredCardHole();
const bool clipped = hoverHole.has_value();
if (clipped && hoverHole->isEmpty()) {
// The zoom covers this zone entirely: there is nothing left of it to paint.
return;
}
if (clipped) {
painter->setClipPath(*hoverHole);
}
if (playmatActive) {
@ -152,31 +158,38 @@ void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*opti
paintLandDivider(painter);
if (hasHole) {
if (clipped) {
painter->setClipping(false);
}
}
/**
* @brief The hovered card's footprint in zone coordinates, or null when it does not
* overlap this zone. Lets every paint layer dodge the zoomed card instead of painting
* over it.
* @brief This zone's paint area minus the hovered card's painted outline, in zone coordinates.
*
* @c std::nullopt when no card is hovered or it does not overlap this zone; an *empty* path when
* the zoom covers this zone entirely, which means nothing of it is left to paint. Lets every
* paint layer dodge the zoomed card instead of painting over it.
*/
QRect TableZone::hoveredCardHole() const
std::optional<QPainterPath> TableZone::hoveredCardHole() const
{
auto *scene = qobject_cast<GameScene *>(this->scene());
if (!scene) {
return {};
return std::nullopt;
}
CardItem *hovered = scene->getHoveredCard();
if (!hovered) {
return {};
return std::nullopt;
}
QRect hole = mapRectFromScene(hovered->sceneBoundingRect()).toRect();
if (!hole.intersects(boundingRect().toRect())) {
return {};
// Cut out the card's painted outline, not its bounding rectangle: the rectangle's corners
// are never covered by the artwork (rounded corners, antialiased edges), so they would stay
// black around the zoom.
const QPainterPath cardOutline = sceneTransform().inverted().map(hovered->sceneTransform().map(hovered->shape()));
if (!cardOutline.boundingRect().intersects(boundingRect())) {
return std::nullopt;
}
return hole & boundingRect().toRect();
QPainterPath zoneArea;
zoneArea.addRect(boundingRect());
return zoneArea.subtracted(cardOutline);
}
void TableZone::onPlaymatChanged(bool active)

View file

@ -18,6 +18,7 @@
#include <QPointF>
#include <QtGlobal>
#include <libcockatrice/game/zones/table_zone_logic.h>
#include <optional>
#include <qtmetamacros.h>
class CardDragItem;
@ -26,6 +27,7 @@ class CardZoneLogic;
class QColor;
class QGraphicsItem;
class QPainter;
class QPainterPath;
template <typename T> class QList;
/**
@ -221,7 +223,13 @@ private:
void paintZoneOutline(QPainter *painter);
void paintLandDivider(QPainter *painter);
[[nodiscard]] QRect hoveredCardHole() const;
/**
* @brief This zone's paint area minus the hovered card's painted outline, in zone coordinates.
*
* @c std::nullopt when no card is hovered or it does not overlap this zone; an *empty* path when
* the hovered card covers this zone entirely, in which case nothing of the zone is visible.
*/
[[nodiscard]] std::optional<QPainterPath> hoveredCardHole() const;
/*
Calculates card stack widths so mapping functions work properly