From abbab61e4fa4345932404ad7493b50d019a40a79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Wed, 14 Oct 2026 19:20:44 +0200 Subject: [PATCH] [Game] Wire hover zoom/panel triggers through the scene --- cockatrice/src/game_graphics/game_scene.cpp | 514 +++++++++++++++++- cockatrice/src/game_graphics/game_scene.h | 66 ++- cockatrice/src/game_graphics/game_view.cpp | 5 + cockatrice/src/game_graphics/game_view.h | 1 + .../src/game_graphics/zones/table_zone.cpp | 35 ++ .../src/game_graphics/zones/table_zone.h | 1 + 6 files changed, 609 insertions(+), 13 deletions(-) diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 128899a13..65299fb83 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -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 +#include #include #include #include +#include #include +#include #include #include #include @@ -29,9 +34,11 @@ #include #include #include +#include #include #include -#include +#include +#include #include #include #include @@ -43,6 +50,7 @@ #include #include #include +#include #include #include #include @@ -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(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(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(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 &items) { for (QGraphicsItem *item : items) { @@ -940,7 +1389,50 @@ bool GameScene::event(QEvent *event) { if (event->type() == QEvent::GraphicsSceneMouseMove) { updateHover(static_cast(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(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); } diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index 0d4db70a8..54185e4b3 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,7 @@ class CardZoneLogic; class IAnimatedItem; class QGraphicsItem; +class QGraphicsObject; class QPoint; struct ArrowData; template 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 hoveredCard; ///< Currently hovered card QPointer activeCard; ///< Card the currently open context menu targets - QBasicTimer *animationTimer; ///< Timer for scene animations - QHash animatedItems; ///< Items currently animating + /// Card armed for double-confirm panel summon (first trigger zooms, repeat shows). + QPointer 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, qreal>> hoverLiftedAncestors; + /// Card whose ancestor chain is currently lifted (cleared together with the chain). + QPointer liftOwner; + QBasicTimer *animationTimer; ///< Timer for scene animations + QHash animatedItems; ///< Items currently animating QHash 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; ///< 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 hoverPopupTarget; ///< Card the popup is intended for + QPointer 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; diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp index 7a5aa073a..59f374435 100644 --- a/cockatrice/src/game_graphics/game_view.cpp +++ b/cockatrice/src/game_graphics/game_view.cpp @@ -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() diff --git a/cockatrice/src/game_graphics/game_view.h b/cockatrice/src/game_graphics/game_view.h index 99a73a0a8..52c06ebcb 100644 --- a/cockatrice/src/game_graphics/game_view.h +++ b/cockatrice/src/game_graphics/game_view.h @@ -29,6 +29,7 @@ class GameView : public QGraphicsView Q_OBJECT private: QAction *aCloseMostRecentZoneView; + QAction *aShowHoverPanel; QRubberBand *rubberBand; QLabel *dragCountLabel; QLabel *totalCountLabel; diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index 0dffab353..c16d7ba87 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -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(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) diff --git a/cockatrice/src/game_graphics/zones/table_zone.h b/cockatrice/src/game_graphics/zones/table_zone.h index 8aa4936ad..7fbe57236 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.h +++ b/cockatrice/src/game_graphics/zones/table_zone.h @@ -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