[Game] Wire hover zoom/panel triggers through the scene

This commit is contained in:
Lukas Brübach 2026-10-14 19:20:44 +02:00 • committed by Lukas Brübach
parent b2f0528fc7
commit abbab61e4f
6 changed files with 609 additions and 13 deletions

View file

@ -1,6 +1,7 @@
#include "game_scene.h"
#include "../client/settings/cache_settings.h"
#include "../interface/widgets/cards/card_hover_widget.h"
#include "../interface/theme_manager.h"
#include "animated_item.h"
#include "board/abstract_card_item.h"
@ -11,16 +12,20 @@
#include "player/menu/player_menu.h"
#include "player/player_graphics_item.h"
#include "player/player_target.h"
#include "z_values.h"
#include "zones/card_zone.h"
#include "zones/select_zone.h"
#include "zones/view_zone.h"
#include "zones/view_zone_widget.h"
#include <QBasicTimer>
#include <QCursor>
#include <QDebug>
#include <QEvent>
#include <QGraphicsItem>
#include <QGraphicsObject>
#include <QGraphicsSceneMouseEvent>
#include <QGraphicsSceneWheelEvent>
#include <QGraphicsView>
#include <QListIterator>
#include <QMenu>
@ -29,9 +34,11 @@
#include <QObject>
#include <QPoint>
#include <QRectF>
#include <QScrollBar>
#include <QSet>
#include <QSharedPointer>
#include <QtMath>
#include <QTimer>
#include <QtGlobal>
#include <algorithm>
#include <functional>
#include <libcockatrice/game/abstract_game.h>
@ -43,6 +50,7 @@
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/zones/card_zone_logic.h>
#include <libcockatrice/game/zones/view_zone_logic.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/zone_names.h>
#include <numeric>
@ -75,6 +83,22 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent)
}
});
// Card hover popup (cardHoverWidget is created lazily on first show)
hoverActivateTimer = new QTimer(this);
hoverActivateTimer->setSingleShot(true);
hoverActivateTimer->setInterval(500);
connect(hoverActivateTimer, &QTimer::timeout, this, &GameScene::onHoverActivateTimeout);
hoverPanelTimer = new QTimer(this);
hoverPanelTimer->setSingleShot(true);
hoverPanelTimer->setInterval(200);
connect(hoverPanelTimer, &QTimer::timeout, this, &GameScene::onHoverPanelTimeout);
hoverGraceTimer = new QTimer(this);
hoverGraceTimer->setSingleShot(true);
hoverGraceTimer->setInterval(200);
connect(hoverGraceTimer, &QTimer::timeout, this, &GameScene::onHoverGraceTimeout);
rearrange();
}
@ -93,6 +117,9 @@ GameScene::~GameScene()
delete animationTimer;
animationTimer = nullptr;
delete hoverActivateTimer;
delete hoverPanelTimer;
delete hoverGraceTimer;
// Delete all ArrowItems before QGraphicsScene's base destructor runs.
// QGraphicsScene::~QGraphicsScene() destroys items in arbitrary order.
@ -239,6 +266,21 @@ void GameScene::onCardRightClicked(AbstractCardItem *abstractCard, QPoint screen
return;
}
// The zoom and panel triggers are independently bindable: a right-click fires
// whichever phases listen to it. Once right-click drives either phase the context
// menu stays suppressed — popping it would steal the flow (and the panel already
// carries every action when it is the panel trigger).
auto &display = SettingsCache::instance().cardsDisplay();
const bool zoomFired = display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerRightClick;
const bool panelFired = display.getHoverPanelTrigger() == CardsDisplaySettings::HoverPanelTriggerRightClick;
if (zoomFired || panelFired) {
fireTriggers(card, zoomFired, panelFired);
return;
}
// Hide the hover popup when the context menu opens
hideCardHoverPopup();
setActiveCard(card);
if (auto *menu = view->getPlayerMenu()->updateCardMenu(card->getState())) {
@ -690,7 +732,6 @@ void GameScene::requestArrowDeletion(int playerId, int arrowId)
emit arrowDeletionRequested(playerId, arrowId);
}
}
void GameScene::onCardZoneChanged(CardState *card, bool sameZone)
{
auto *item = qobject_cast<CardItem *>(card->parent());
@ -710,6 +751,11 @@ void GameScene::onCardZoneChanged(CardState *card, bool sameZone)
for (auto *arrow : toDelete) {
deleteArrow(arrow->getCreatorId(), arrow->getId());
}
// The hover panel stays open on a moved card: follow it instead of stranding.
if (!sameZone && cardHoverWidget && cardHoverWidget->getCardItem() == item) {
positionCardHoverPopup();
}
}
void GameScene::onCardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner)
@ -746,27 +792,129 @@ void GameScene::clearArrowsForPlayerLocally(int playerId)
}
}
}
// ---------- Hover Handling ----------
CardItem *GameScene::getHoveredCard() const
{
return hoveredCard;
}
void GameScene::updateHover(const QPointF &scenePos)
{
auto itemList = items(scenePos, Qt::IntersectsItemBoundingRect, Qt::DescendingOrder, getViewTransform());
updateHoveredCard(pickCardAt(scenePos));
}
CardItem *GameScene::pickCardAt(const QPointF &scenePos)
{
auto itemList = items(scenePos, Qt::IntersectsItemBoundingRect, Qt::DescendingOrder, getViewTransform());
CardZone *zone = findTopmostZone(itemList);
CardItem *topCard = zone ? findTopmostCardInZone(itemList, zone) : nullptr;
updateHoveredCard(topCard);
if (!topCard && hoveredCard) {
const QRectF guard = hoveredCard->sceneBoundingRect().adjusted(-8, -8, 8, 8);
if (guard.contains(scenePos)) {
// The zoomed card overflows onto neighboring zones, where naive topmost-zone
// picking attributes the cursor to the zone underneath. While the cursor is
// still over the zoomed footprint (with hysteresis for edge jiggle), keep it.
// A genuinely different topmost card still takes over.
return hoveredCard;
}
}
return topCard;
}
void GameScene::updateHoveredCard(CardItem *newCard)
{
// Safety: if the popup's card was destroyed, hide the popup immediately
if (cardHoverWidget && cardHoverWidget->isVisible() && !cardHoverWidget->hasCard()) {
hideCardHoverPopup();
}
// The hovered card may have been destroyed without ending the hover: its QPointer
// nulled itself, but the ancestor lift is still outstanding. Restore it now.
if (!hoveredCard && !hoverLiftedAncestors.isEmpty()) {
restoreHoverLift();
}
// Hovering alone never starts anything anymore: the trigger (dwell, click, wheel,
// shortcut) resolves first, then the zoom follows. This keeps the feature calm.
bool wasHoveringCard = (hoveredCard != nullptr);
if (hoveredCard && (newCard != hoveredCard)) {
endCardHover(hoveredCard);
hoveredCard = nullptr;
}
if (newCard && (newCard != hoveredCard)) {
beginCardHover(newCard);
auto &display = SettingsCache::instance().cardsDisplay();
const int zoomTrigger = display.getHoverZoomTrigger();
const int panelTrigger = display.getHoverPanelTrigger();
const bool doubleConfirm = display.getHoverPanelDoubleConfirm() && zoomTrigger == panelTrigger;
if (newCard) {
// Cancel any pending grace timer (mouse is back on a card)
hoverGracePending = false;
hoverGraceTimer->stop();
if (newCard == hoveredCard) {
// Steady state: zoom already up. Refresh an open panel, otherwise nothing —
// in particular this must not re-fire an armed double-confirm.
if (cardHoverWidget && cardHoverWidget->isVisible() && cardHoverWidget->getCardItem() == newCard) {
cardHoverWidget->refreshDisplay();
}
return;
}
if (cardHoverWidget && cardHoverWidget->isVisible() && 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;
confirmArmedCard.clear();
hoverPanelTarget = newCard;
hoverPanelTimer->start(display.getHoverPanelDelayMs());
return;
}
if (zoomTrigger != CardsDisplaySettings::HoverPanelTriggerHover &&
panelTrigger != CardsDisplaySettings::HoverPanelTriggerHover) {
// Mouse contact alone drives neither phase in this config.
return;
}
if (doubleConfirm && confirmArmedCard == newCard) {
// Repeat hover on an armed card: zoom and show at once, no second dwell.
beginCardHover(newCard);
hoveredCard = newCard;
confirmArmedCard.clear();
showCardHoverPopup(newCard);
return;
}
if (newCard != hoverPopupTarget) {
// First contact (or a new card): dwell, then resolve on timeout.
hoverPopupTarget = newCard;
hoverActivateTimer->start(display.getHoverZoomDelayMs());
}
} else {
// No card hovered: drop any pending dwell/panel timers (kills stale shows) and start
// the grace timer only when the mouse just left a card with the panel open.
hoverPopupTarget = nullptr;
hoverPanelTarget = nullptr;
hoverActivateTimer->stop();
hoverPanelTimer->stop();
// Non-hover zoom arms only make sense while approaching the card: leaving
// re-arms the zoom for the next explicit trigger instead of showing the panel.
// (Hover-trigger arms survive the leave so re-entering completes the repeat.)
if (zoomTrigger != CardsDisplaySettings::HoverPanelTriggerHover) {
confirmArmedCard.clear();
}
if (wasHoveringCard && cardHoverWidget && cardHoverWidget->isVisible() &&
!cardHoverWidget->isMouseOverWidget() && !hoverGracePending) {
hoverGracePending = true;
hoverGraceTimer->start();
}
}
hoveredCard = newCard;
}
void GameScene::beginCardHover(CardItem *card)
@ -775,16 +923,317 @@ void GameScene::beginCardHover(CardItem *card)
if (auto *zone = SelectZone::findOwningSelectZone(card)) {
zone->escapeClipForHover(card);
}
// Lift the whole ancestor chain above their siblings: the zoomed card overflows its
// own zone, and Qt orders whole subtrees level by level, so no single z-value wins
// every comparison on its own. Harmless elsewhere (areas and zones tile without
// overlapping; arrows, zone views and drag items keep their higher layers).
// Restore first: if a previous hover never ended (its card was destroyed mid-hover),
// its chain is still outstanding and must land before a new one is recorded.
restoreHoverLift();
QGraphicsItem *ancestor = card->parentItem();
while (ancestor) {
// The chain is QGraphicsObject-derived in practice; a plain QGraphicsItem
// ancestor cannot be guarded, so it keeps its z-value instead of risking an
// unrestorable lift.
if (QGraphicsObject *ancestorObject = ancestor->toGraphicsObject()) {
hoverLiftedAncestors.append({QPointer<QGraphicsObject>(ancestorObject), ancestor->zValue()});
ancestor->setZValue(ZValues::HOVERED_AREA);
}
ancestor = ancestor->parentItem();
}
liftOwner = card;
connect(card, &AbstractCardItem::hoverShrinkFinished, this, &GameScene::onHoverShrinkFinished,
Qt::UniqueConnection);
}
void GameScene::endCardHover(CardItem *card)
{
if (auto *zone = SelectZone::findOwningSelectZone(card)) {
zone->restoreClipAfterHover(card);
}
// Only the zoom-out starts here: the z-value, the clip escape and the ancestor
// lift stay put until the shrink finishes (onHoverShrinkFinished), so siblings and
// the clip container cannot cut through the still-enlarged card.
card->setHovered(false);
}
void GameScene::onHoverShrinkFinished(AbstractCardItem *card)
{
auto *cardItem = qobject_cast<CardItem *>(card);
if (!cardItem) {
return;
}
// Per-card and idempotent: always settle, even if a newer hover owns the lift.
if (auto *zone = SelectZone::findOwningSelectZone(cardItem)) {
zone->restoreClipAfterHover(cardItem);
}
if (liftOwner != cardItem) {
// A newer hover already owns the lift (or nothing does): leave it alone.
return;
}
restoreHoverLift();
}
void GameScene::restoreHoverLift()
{
for (const auto &[lifted, zValue] : hoverLiftedAncestors) {
if (lifted) {
lifted->setZValue(zValue);
}
}
hoverLiftedAncestors.clear();
liftOwner.clear();
}
// ---------- Card Hover Popup ----------
void GameScene::showCardHoverPopup(CardItem *card)
{
if (!card || !card->getOwner()) {
return;
}
PlayerGraphicsItem *playerView = playerViews.value(card->getOwner()->getPlayerInfo()->getId());
if (!playerView) {
return;
}
hoverActivateTimer->stop();
hoverPanelTimer->stop();
hoverGracePending = false;
hoverGraceTimer->stop();
hoverPopupTarget = card;
hoverPanelTarget = nullptr;
confirmArmedCard.clear();
if (!cardHoverWidget) {
auto *viewport = views().isEmpty() ? nullptr : views().first()->viewport();
cardHoverWidget = new CardHoverWidget(viewport);
connect(cardHoverWidget, &CardHoverWidget::hoverEntered, this, &GameScene::onHoverWidgetEntered);
connect(cardHoverWidget, &CardHoverWidget::hoverLeft, this, &GameScene::onHoverWidgetLeft);
// Keep the popup anchored to its card while the view scrolls, zooms, or resizes
if (!views().isEmpty()) {
auto *view = views().first();
connect(view->horizontalScrollBar(), &QScrollBar::valueChanged, this, &GameScene::positionCardHoverPopup);
connect(view->verticalScrollBar(), &QScrollBar::valueChanged, this, &GameScene::positionCardHoverPopup);
if (viewport) {
viewport->installEventFilter(this);
}
}
}
// Crossfade: the opaque panel picture sits exactly over the zoomed card, so fade
// the board card out underneath it (masks any residual size/corner mismatch) and
// fade any previously shown card back in.
CardItem *oldCard = cardHoverWidget->hasCard() ? cardHoverWidget->getCardItem() : nullptr;
cardHoverWidget->setCard(card, playerView);
positionCardHoverPopup();
cardHoverWidget->showAnimated();
if (oldCard && oldCard != card) {
oldCard->fadeCardIn();
}
card->fadeCardOut();
}
void GameScene::hideCardHoverPopup()
{
// Hiding is terminal: drop all pending hover timing with it, or a dwell/panel
// timer that resolves afterwards pops the panel back up (e.g. over the context
// menu that hideCardHoverPopup was just called for).
hoverActivateTimer->stop();
hoverPanelTimer->stop();
hoverGracePending = false;
hoverGraceTimer->stop();
if (cardHoverWidget) {
// The panel is gone: whatever card it covered must come back.
if (CardItem *coveredCard = cardHoverWidget->getCardItem()) {
coveredCard->fadeCardIn();
}
if (cardHoverWidget->isVisible()) {
cardHoverWidget->hideAnimated();
}
}
hoverPopupTarget = nullptr;
hoverPanelTarget = nullptr;
}
void GameScene::positionCardHoverPopup()
{
if (!cardHoverWidget || !cardHoverWidget->hasCard()) {
return;
}
auto *viewport = views().isEmpty() ? nullptr : views().first()->viewport();
if (!viewport) {
return;
}
QRectF sceneRect = cardHoverWidget->getCardItem()->sceneBoundingRect();
QTransform vpTransform = getViewportTransform();
cardHoverWidget->positionNextTo(sceneRect, vpTransform, viewport);
}
void GameScene::onHoverActivateTimeout()
{
// Dwell resolved: the hover drives whichever phases listen to it. The cursor must
// still be over the pending card (rect test, not a fresh pick, so pick flakiness
// cannot veto it); otherwise this was a stale timer.
CardItem *target = hoverPopupTarget;
hoverPopupTarget = nullptr;
if (!target || views().isEmpty()) {
return;
}
auto *view = views().first();
const QPointF cursorScene = view->mapToScene(view->viewport()->mapFromGlobal(QCursor::pos()));
if (!target->sceneBoundingRect().contains(cursorScene)) {
return;
}
auto &display = SettingsCache::instance().cardsDisplay();
const bool zoomPhase = display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerHover;
const bool panelPhase = display.getHoverPanelTrigger() == CardsDisplaySettings::HoverPanelTriggerHover;
const bool effectiveDouble =
display.getHoverPanelDoubleConfirm() && display.getHoverZoomTrigger() == display.getHoverPanelTrigger();
if (hoveredCard && hoveredCard != target) {
endCardHover(hoveredCard);
}
if (zoomPhase) {
if (hoveredCard != target) {
beginCardHover(target);
hoveredCard = target;
}
if (effectiveDouble) {
confirmArmedCard = target;
}
}
if (panelPhase && !effectiveDouble) {
hoverPanelTarget = target;
hoverPanelTimer->start(display.getHoverPanelDelayMs());
}
}
void GameScene::onHoverPanelTimeout()
{
// Panel delay resolved: show, but only if the target card is still the one under
// the cursor (same cheap rect check — no dependence on pick stability).
CardItem *target = hoverPanelTarget;
hoverPanelTarget = nullptr;
if (!target || views().isEmpty()) {
return;
}
auto *view = views().first();
const QPointF cursorScene = view->mapToScene(view->viewport()->mapFromGlobal(QCursor::pos()));
if (!target->sceneBoundingRect().contains(cursorScene)) {
return;
}
ensureZoomed(target);
showCardHoverPopup(target);
}
void GameScene::onHoverGraceTimeout()
{
hoverGracePending = false;
if (cardHoverWidget && cardHoverWidget->isVisible() && cardHoverWidget->isMouseOverWidget()) {
return;
}
hideCardHoverPopup();
}
void GameScene::summonHoverPanel()
{
// Summon for whatever is under the cursor right now; hovering is not required.
if (views().isEmpty()) {
return;
}
auto *view = views().first();
const QPointF cursorScene = view->mapToScene(view->viewport()->mapFromGlobal(QCursor::pos()));
if (CardItem *card = pickCardAt(cursorScene)) {
auto &display = SettingsCache::instance().cardsDisplay();
fireTriggers(card, display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerShortcut,
display.getHoverPanelTrigger() == CardsDisplaySettings::HoverPanelTriggerShortcut);
}
}
void GameScene::ensureZoomed(CardItem *card)
{
if (hoveredCard != card) {
if (hoveredCard) {
endCardHover(hoveredCard);
}
beginCardHover(card);
hoveredCard = card;
}
}
void GameScene::fireTriggers(CardItem *card, bool zoomFired, bool panelFired)
{
if (!card || !card->getOwner()) {
return;
}
if (cardHoverWidget && cardHoverWidget->isVisible() && cardHoverWidget->getCardItem() == card) {
cardHoverWidget->refreshDisplay();
return;
}
// Explicit triggers preempt any pending hover timing.
hoverPopupTarget = nullptr;
hoverPanelTarget = nullptr;
hoverActivateTimer->stop();
hoverPanelTimer->stop();
auto &display = SettingsCache::instance().cardsDisplay();
const bool effectiveDouble =
display.getHoverPanelDoubleConfirm() && display.getHoverZoomTrigger() == display.getHoverPanelTrigger();
if (zoomFired && panelFired) {
// Both phases on one action: one-step zooms and shows at once (the crossfade
// covers it); double-confirm arms on first fire and shows on repeat.
if (effectiveDouble) {
if (confirmArmedCard == card) {
confirmArmedCard.clear();
ensureZoomed(card);
showCardHoverPopup(card);
} else {
confirmArmedCard = card;
ensureZoomed(card);
}
} else {
ensureZoomed(card);
showCardHoverPopup(card);
}
return;
}
if (zoomFired) {
ensureZoomed(card);
}
if (panelFired) {
ensureZoomed(card);
showCardHoverPopup(card);
}
}
void GameScene::onHoverWidgetEntered()
{
hoverGracePending = false;
hoverGraceTimer->stop();
}
void GameScene::onHoverWidgetLeft()
{
if (!hoveredCard && cardHoverWidget && cardHoverWidget->isVisible() && !hoverGracePending) {
hoverGracePending = true;
hoverGraceTimer->start();
}
}
bool GameScene::eventFilter(QObject *watched, QEvent *event)
{
if (event->type() == QEvent::Resize) {
positionCardHoverPopup();
}
return QGraphicsScene::eventFilter(watched, event);
}
CardZone *GameScene::findTopmostZone(const QList<QGraphicsItem *> &items)
{
for (QGraphicsItem *item : items) {
@ -940,7 +1389,50 @@ bool GameScene::event(QEvent *event)
{
if (event->type() == QEvent::GraphicsSceneMouseMove) {
updateHover(static_cast<QGraphicsSceneMouseEvent *>(event)->scenePos());
} else if (event->type() == QEvent::GraphicsSceneWheel) {
// Wheel trigger: wheeling over a card fires whichever phases listen to it
// instead of scrolling. Fully consumed here: forwarding to the base dispatcher
// as well would double-deliver it to items under the cursor (e.g. zone views,
// which scroll on wheel). Scrolling up instead reverts an active zoom/panel —
// backing out of the gesture undoes it; with nothing to revert the event falls
// through to normal scrolling.
auto &display = SettingsCache::instance().cardsDisplay();
const bool zoomFired = display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerWheel;
const bool panelFired = display.getHoverPanelTrigger() == CardsDisplaySettings::HoverPanelTriggerWheel;
if (zoomFired || panelFired) {
auto *wheelEvent = static_cast<QGraphicsSceneWheelEvent *>(event);
// QGraphicsSceneWheelEvent carries delta()/pixelDelta(), not angleDelta().
const int wheelUp = !wheelEvent->pixelDelta().isNull()
? wheelEvent->pixelDelta().y()
: (wheelEvent->orientation() == Qt::Vertical ? wheelEvent->delta() : 0);
if (wheelUp > 0) {
if ((cardHoverWidget && cardHoverWidget->isVisible()) || hoveredCard) {
hoverPopupTarget = nullptr;
hoverPanelTarget = nullptr;
confirmArmedCard.clear();
hideCardHoverPopup();
if (hoveredCard) {
endCardHover(hoveredCard);
hoveredCard = nullptr;
}
event->accept();
return true;
}
} else if (CardItem *card = pickCardAt(wheelEvent->scenePos())) {
fireTriggers(card, zoomFired, panelFired);
event->accept();
return true;
}
}
} else if (event->type() == QEvent::Leave) {
// The hover panel opens directly over the zoomed card under the cursor, which the
// scene observes as a Leave. The cursor never actually left: if it is over the open
// panel, keep the hover (dropping it here shrinks the card from under the panel and
// starves the popup logic, so the menu never shows). A genuine leave of the window
// misses the panel and still ends the hover as before.
if (cardHoverWidget && cardHoverWidget->isVisible() && cardHoverWidget->isMouseOverWidget()) {
return QGraphicsScene::event(event);
}
updateHoveredCard(nullptr);
}

View file

@ -11,6 +11,7 @@
#include <QLoggingCategory>
#include <QMap>
#include <QMetaObject>
#include <QPair>
#include <QPointF>
#include <QPointer>
#include <QSize>
@ -30,6 +31,7 @@
class CardZoneLogic;
class IAnimatedItem;
class QGraphicsItem;
class QGraphicsObject;
class QPoint;
struct ArrowData;
template <class T> class QSharedPointer;
@ -48,6 +50,8 @@ class ServerInfo_Card;
class PhasesToolbar;
class QBasicTimer;
class QObject;
class QTimer;
class CardHoverWidget;
/**
* @class GameScene
@ -75,14 +79,36 @@ private:
QSize viewSize; ///< Current view size
QPointer<CardItem> hoveredCard; ///< Currently hovered card
QPointer<CardItem> activeCard; ///< Card the currently open context menu targets
QBasicTimer *animationTimer; ///< Timer for scene animations
QHash<QObject *, IAnimatedItem *> animatedItems; ///< Items currently animating
/// Card armed for double-confirm panel summon (first trigger zooms, repeat shows).
QPointer<CardItem> confirmArmedCard;
/// Ancestor chain of the hovered card with pre-hover Z-values. Lifted while hovering
/// because Qt orders subtrees level by level: the card z alone cannot beat a sibling
/// zone, and the top ancestor alone cannot beat same-area sibling zones either — every
/// level from the zone up must win its own comparison. Guarded pointers: if the card
/// is destroyed mid-hover (or an ancestor goes away first), the guards null out and
/// the next restore skips them instead of touching freed memory. An orphaned lift is
/// invisible — areas and zones tile without overlapping and all UI layers still win —
/// and any outstanding lift is restored by the next hover transition.
QList<QPair<QPointer<QGraphicsObject>, qreal>> hoverLiftedAncestors;
/// Card whose ancestor chain is currently lifted (cleared together with the chain).
QPointer<CardItem> liftOwner;
QBasicTimer *animationTimer; ///< Timer for scene animations
QHash<QObject *, IAnimatedItem *> animatedItems; ///< Items currently animating
QHash<QObject *, QMetaObject::Connection>
animationItemConnections; ///< destroyed->removeAnimatedItem handles per animated item
int playerRotation; ///< Rotation offset for player layout
bool rearranging = false; ///< Guard against re-entrant rearrange
bool needsReArrange = false; ///< Pending rearrange requested during a pass
// Card hover popup
QPointer<CardHoverWidget> cardHoverWidget; ///< The hover popup widget
QTimer *hoverActivateTimer; ///< Dwell delay before the hover zoom kicks in
QTimer *hoverPanelTimer; ///< Delay after the zoom before the tab panel shows
QTimer *hoverGraceTimer; ///< Grace period before hiding popup (200ms)
QPointer<CardItem> hoverPopupTarget; ///< Card the popup is intended for
QPointer<CardItem> hoverPanelTarget; ///< Card the panel timer will show
bool hoverGracePending = false; ///< Grace timer armed (mouse left a card or the popup)
/**
* @brief Updates which card is currently hovered based on scene coordinates.
* @param scenePos Scene position of the cursor.
@ -96,6 +122,15 @@ private:
void beginCardHover(CardItem *card);
/** @brief Deactivates hover state and restores the card to its clip container. */
void endCardHover(CardItem *card);
/** @brief Restores every lifted ancestor to its pre-hover Z-value and drops the chain. */
void restoreHoverLift();
/** @brief Settles a finished shrink-out: clip container and ancestor lift restore. */
void onHoverShrinkFinished(AbstractCardItem *card);
// Card hover popup methods
void showCardHoverPopup(CardItem *card);
void hideCardHoverPopup();
void positionCardHoverPopup();
public:
/**
@ -227,6 +262,21 @@ public:
/** @brief Updates hovered card highlighting. */
void updateHoveredCard(CardItem *newCard);
/** @brief Topmost card at a scene position, without side effects. */
CardItem *pickCardAt(const QPointF &scenePos);
/**
* @brief Fires the zoom and/or panel trigger for a card (one explicit user action).
* When both fire on one action, double-confirm arms on first fire and shows on repeat.
*/
void fireTriggers(CardItem *card, bool zoomFired, bool panelFired);
/** @brief Starts the hover zoom for a card without touching panel state. */
void ensureZoomed(CardItem *card);
/** @brief The currently hovered card, if any (used by zones to dodge it while painting). */
CardItem *getHoveredCard() const;
/**
* @brief Registers an item for animation updates with the shared scene timer.
*
@ -293,10 +343,22 @@ public slots:
*/
void onCardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner);
// Card hover popup slots
void onHoverActivateTimeout();
void onHoverPanelTimeout();
void onHoverGraceTimeout();
void onHoverWidgetEntered();
void onHoverWidgetLeft();
/** @brief Summons the hover panel for the currently hovered card, if any. */
void summonHoverPanel();
protected:
/** @brief Handles hover updates. */
bool event(QEvent *event) override;
/** @brief Repositions the hover popup when the viewport resizes. */
bool eventFilter(QObject *watched, QEvent *event) override;
/** @brief Handles animation timer updates. */
void timerEvent(QTimerEvent *event) override;

View file

@ -87,6 +87,10 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par
connect(aCloseMostRecentZoneView, &QAction::triggered, scene, &GameScene::closeMostRecentZoneView);
addAction(aCloseMostRecentZoneView);
aShowHoverPanel = new QAction(this);
connect(aShowHoverPanel, &QAction::triggered, scene, &GameScene::summonHoverPanel);
addAction(aShowHoverPanel);
connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this,
&GameView::refreshShortcuts);
refreshShortcuts();
@ -246,6 +250,7 @@ void GameView::refreshShortcuts()
{
aCloseMostRecentZoneView->setShortcuts(
SettingsCache::instance().shortcuts().getShortcut("Player/aCloseMostRecentZoneView"));
aShowHoverPanel->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("Player/aShowHoverPanel"));
}
void GameView::clearTallyLabels()

View file

@ -29,6 +29,7 @@ class GameView : public QGraphicsView
Q_OBJECT
private:
QAction *aCloseMostRecentZoneView;
QAction *aShowHoverPanel;
QRubberBand *rubberBand;
QLabel *dragCountLabel;
QLabel *totalCountLabel;

View file

@ -118,6 +118,15 @@ bool TableZone::isInverted() const
void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/)
{
// 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));
}
if (playmatActive) {
// Subtle overlay to distinguish table zone from stack zone
painter->fillRect(boundingRect(), QColor(0, 0, 0, 60));
@ -142,6 +151,32 @@ void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*opti
}
paintLandDivider(painter);
if (hasHole) {
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.
*/
QRect TableZone::hoveredCardHole() const
{
auto *scene = qobject_cast<GameScene *>(this->scene());
if (!scene) {
return {};
}
CardItem *hovered = scene->getHoveredCard();
if (!hovered) {
return {};
}
QRect hole = mapRectFromScene(hovered->sceneBoundingRect()).toRect();
if (!hole.intersects(boundingRect().toRect())) {
return {};
}
return hole & boundingRect().toRect();
}
void TableZone::onPlaymatChanged(bool active)

View file

@ -221,6 +221,7 @@ private:
void paintZoneOutline(QPainter *painter);
void paintLandDivider(QPainter *painter);
[[nodiscard]] QRect hoveredCardHole() const;
/*
Calculates card stack widths so mapping functions work properly