#include "game_scene.h" #include "../client/settings/cache_settings.h" #include "../interface/theme_manager.h" #include "../interface/widgets/cards/card_hover_widget.h" #include "animated_item.h" #include "board/abstract_card_item.h" #include "board/arrow_item.h" #include "board/arrow_registry.h" #include "board/card_item.h" #include "phases_toolbar.h" #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/table_zone.h" #include "zones/view_zone.h" #include "zones/view_zone_widget.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include class ArrowTarget; /** * @brief Constructs the GameScene. * @param _phasesToolbar Toolbar widget for phases. * @param parent Optional parent QObject. * * Initializes the animation timer, adds the phases toolbar to the scene, * and connects to settings changes for multi-column layout. * Finally, calls rearrange() to layout players initially. */ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent) : QGraphicsScene(parent), phasesToolbar(_phasesToolbar), viewSize(QSize()), playerRotation(0) { animationTimer = new QBasicTimer; addItem(phasesToolbar); connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::minPlayersForMultiColumnLayoutChanged, this, &GameScene::rearrange); // CardText items (P/T boxes, counters, player names) read the role at // paint time and cache their rendering, so touch every item to invalidate // the caches and pick up the new family on font changes. connect(themeManager, &ThemeManager::fontsChanged, this, [this] { for (auto *item : items()) { item->update(); } }); // 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(); } GameScene::~GameScene() { // Sever all destroyed->removeAnimatedItem connections before the members below // are destroyed: the base QGraphicsScene destructor destroys the remaining items, // and their destroyed() signals must not reach slots that reference members that // no longer exist. The connection handle overload is used because the string-based // disconnect(nullptr, nullptr, this, nullptr) is invalid (the sender must never be // nullptr) and would otherwise fail to sever these pointer-to-member connections. for (auto it = animationItemConnections.constBegin(); it != animationItemConnections.constEnd(); ++it) { QObject::disconnect(*it); } animationItemConnections.clear(); 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. // If a PlayerTarget is destroyed before an ArrowItem pointing to it, // ArrowItem::onTargetDestroyed fires and emits on the partially-destroyed // GameScene, causing a segfault. for (auto *item : items()) { if (auto *arrow = qgraphicsitem_cast(item)) { delete arrow; } } // DO NOT call clearViews() here // clearViews calls close() on the zoneViews, which sends signals; sending signals in destructors leads to segfaults // deleteLater() deletes the zoneView without allowing it to send signals for (const auto &zoneView : zoneViews) { zoneView->deleteLater(); } } /** * @brief Updates localized text in all zone views. */ void GameScene::retranslateUi() { for (ZoneViewWidget *view : zoneViews) { view->retranslateUi(); } } QList GameScene::selectedCardStates() const { QList selectedStates; for (auto *card : selectedCards()) { selectedStates.append(card->getState()); } return selectedStates; } QList GameScene::selectedCards() const { QList selectedCards; for (auto item : selectedItems()) { if (auto card = qgraphicsitem_cast(item)) { selectedCards.append(card); } } return selectedCards; } /** * @brief Selects every card of @p zone for which @p filter holds. * @param filter A predicate to filter which cards are selected. */ static void selectCardsInZone( const CardZoneLogic *zone, std::function filter = [](const CardItem *) { return true; }) { if (!zone) { return; } for (auto *cardState : zone->getCards()) { auto *cardItem = cardState == nullptr ? nullptr : qobject_cast(cardState->parent()); if (cardItem && filter(cardItem)) { cardItem->setSelected(true); } } } void GameScene::selectAllInZone(const CardItem *card) { if (!card) { return; } selectCardsInZone(card->getZone()); } void GameScene::selectSameRow(const CardItem *card) { if (!card) { return; } auto isSameRow = [card](const CardItem *cardItem) { return qAbs(card->scenePos().y() - cardItem->scenePos().y()) < 50; }; selectCardsInZone(card->getZone(), isSameRow); } void GameScene::selectSameColumn(const CardItem *card) { if (!card) { return; } auto isSameColumn = [card](const CardItem *cardItem) { return cardItem->x() == card->x(); }; selectCardsInZone(card->getZone(), isSameColumn); } CardItem *GameScene::getActiveCard() const { return activeCard; } void GameScene::setActiveCard(CardItem *card) { activeCard = card; } void GameScene::onCardSelectionChanged(AbstractCardItem *abstractCard, bool selected) { CardItem *card = qobject_cast(abstractCard); if (!card || !card->getOwner()) { return; } auto *owner = card->getOwner(); if (selected) { owner->requestCardMenuUpdate(card->getState()); return; } if (selectedItems().isEmpty()) { setActiveCard(nullptr); owner->requestCardMenuUpdate(nullptr); } } void GameScene::onCardRightClicked(AbstractCardItem *abstractCard, QPoint screenPos) { auto *card = qobject_cast(abstractCard); if (!card) { return; } if (!card->getOwner()) { return; } auto *view = playerViews.value(card->getOwner()->getPlayerInfo()->getId()); if (!view) { return; } // The zoom and panel triggers are independently bindable: a right-click fires // whichever phases listen to it. When the panel is the trigger it carries every // action itself, so the context menu stays suppressed; when right-click only zooms, // the menu still opens, since nothing else would summon it. 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); if (panelFired) { return; } } // Hide the hover popup when the context menu opens hideCardHoverPopup(); setActiveCard(card); if (auto *menu = view->getPlayerMenu()->updateCardMenu(card->getState())) { menu->popup(screenPos); } } void GameScene::playSelected(AbstractCardItem *card) { if (!card) { return; } if (!card->getOwner()) { return; } card->getOwner()->getPlayerActions()->actPlay(selectedCardStates()); } void GameScene::playSelectedFaceDown(AbstractCardItem *card) { if (!card) { return; } if (!card->getOwner()) { return; } card->getOwner()->getPlayerActions()->actPlayFacedown(selectedCardStates()); } void GameScene::hideSelected(AbstractCardItem *card) { if (!card) { return; } if (!card->getOwner()) { return; } card->getOwner()->getPlayerActions()->actHide(selectedCardStates()); } /** * @brief Adds a player to the scene and stores their graphics item. * @param player Player to add. * * Connects to the player's sizeChanged signal to recompute layout on resize. */ void GameScene::addPlayer(PlayerLogic *player) { qCInfo(GameScenePlayerAdditionRemovalLog) << "GameScene::addPlayer name=" << player->getPlayerInfo()->getName(); auto *view = new PlayerGraphicsItem(player); playerViews.insert(player->getPlayerInfo()->getId(), view); addItem(view); connect(view, &PlayerGraphicsItem::sizeChanged, this, &GameScene::rearrange); connect(player, &PlayerLogic::concededChanged, this, [this](int id, bool conceded) { if (conceded) { clearArrowsForPlayer(id); } rearrange(); }); connect(player, &PlayerLogic::requestZoneViewToggle, this, &GameScene::toggleZoneView); connect(player, &PlayerLogic::requestRevealedZoneView, this, &GameScene::addRevealedZoneView); connect(player, &PlayerLogic::arrowDeleted, this, &GameScene::deleteArrow); connect(player, &PlayerLogic::arrowCreateRequested, this, &GameScene::addArrow); connect(player, &PlayerLogic::arrowDeleteRequested, this, &GameScene::requestArrowDeletion); connect(player, &PlayerLogic::arrowsClearedLocally, this, [this, id = player->getPlayerInfo()->getId()]() { clearArrowsForPlayerLocally(id); }); connect(player->getPlayerEventHandler(), &PlayerEventHandler::cardZoneChanged, this, &GameScene::onCardZoneChanged); connect(player->getPlayerEventHandler(), &PlayerEventHandler::cardViewRefreshRequested, this, &GameScene::onCardViewRefreshRequested); rearrange(); } /** * @brief Removes a player from the scene. * @param player Player to remove. * * Closes any zone views associated with the player and recomputes layout. */ void GameScene::removePlayer(PlayerLogic *player) { qCInfo(GameScenePlayerAdditionRemovalLog) << "GameScene::removePlayer name=" << player->getPlayerInfo()->getName(); clearArrowsForPlayer(player->getPlayerInfo()->getId()); // Closing a view removes it from zoneViews synchronously, so iterate over a // copy: otherwise a player with several open views (e.g. library and hand) // only has the first one closed here and the remaining views are left // pointing at a player that is about to be deleted. const QList zoneViewCopy = zoneViews; for (ZoneViewWidget *zone : zoneViewCopy) { if (zone->getPlayer() == player) { zone->close(); } } auto *view = playerViews.take(player->getPlayerInfo()->getId()); removeItem(view); view->deleteLater(); rearrange(); } /** * @brief Adjusts the global rotation offset for player layout. * @param rotationAdjustment Number of positions to rotate. * * Recomputes player layout after applying rotation. */ void GameScene::adjustPlayerRotation(int rotationAdjustment) { playerRotation += rotationAdjustment; rearrange(); } /** * @brief Recomputes the layout of players and the scene size. * * Steps: * 1. Collect active players who haven't conceded. * 2. Rotate player list based on first local player and rotation offset. * 3. Determine number of columns. * 4. Compute scene size and layout. * 5. Update toolbar height and scene rectangle. * 6. Adjust columns and player positions to match view size. */ void GameScene::rearrange() { if (rearranging) { needsReArrange = true; return; } rearranging = true; do { needsReArrange = false; int firstPlayerIndex = 0; auto playersPlaying = collectActivePlayers(firstPlayerIndex); playersPlaying = rotatePlayers(playersPlaying, firstPlayerIndex); int columns = determineColumnCount(playersPlaying.size()); QSizeF sceneSize = computeSceneSizeAndPlayerLayout(playersPlaying, columns); phasesToolbar->setHeight(sceneSize.height()); setSceneRect(0, 0, sceneSize.width(), sceneSize.height()); processViewSizeChange(viewSize); } while (needsReArrange); rearranging = false; } // ---------- View Size ---------- /** * @brief Handles view resize and redistributes player positions. * @param newSize New view size. * * Steps: * 1. Compute minimum width per column from player items. * 2. Determine new scene width respecting aspect ratio. * 3. Resize columns and reposition players proportionally. */ void GameScene::processViewSizeChange(const QSize &newSize) { viewSize = newSize; QList minWidthByColumn = calculateMinWidthByColumn(); qreal minWidth = std::accumulate(minWidthByColumn.begin(), minWidthByColumn.end(), phasesToolbar->getWidth()); qreal newWidth = calculateNewSceneWidth(newSize, minWidth); setSceneRect(0, 0, newWidth, sceneRect().height()); resizeColumnsAndPlayers(minWidthByColumn, newWidth); } // ---------- Player Layout Helpers ---------- /** * @brief Collects all active (non-conceded) players. * @param firstPlayerIndex Output index of first local player. * @return List of active players. * * Used to determine rotation and layout order. */ QList GameScene::collectActivePlayers(int &firstPlayerIndex) const { QList activePlayers; firstPlayerIndex = 0; bool firstPlayerFound = false; for (auto *pgItem : playerViews.values()) { PlayerLogic *p = pgItem->getLogic(); if (p && !p->getConceded()) { activePlayers.append(p); if (!firstPlayerFound && p->getPlayerInfo()->getLocal()) { firstPlayerIndex = activePlayers.size() - 1; firstPlayerFound = true; } } } // Lay out the table in seat order rather than id order. Players missing // from the tracked order keep id order at the end. QList seatOrder; for (auto *pgItem : playerViews.values()) { if (PlayerLogic *p = pgItem->getLogic()) { seatOrder = p->getGame()->getGameState()->getSeatOrder(); break; } } if (!seatOrder.isEmpty()) { std::sort(activePlayers.begin(), activePlayers.end(), [&](PlayerLogic *a, PlayerLogic *b) { int indexA = seatOrder.indexOf(a->getPlayerInfo()->getId()); int indexB = seatOrder.indexOf(b->getPlayerInfo()->getId()); if (indexA == -1) { indexA = seatOrder.size() + a->getPlayerInfo()->getId(); } if (indexB == -1) { indexB = seatOrder.size() + b->getPlayerInfo()->getId(); } return indexA < indexB; }); // Recompute the local-player index after sorting. firstPlayerIndex = 0; for (int i = 0; i < activePlayers.size(); ++i) { if (activePlayers[i]->getPlayerInfo()->getLocal()) { firstPlayerIndex = i; break; } } } return activePlayers; } /** * @brief Rotates the list of players for layout. * @param players Original list of players. * @param firstPlayerIndex Index of first local player. * @return Rotated list. * * Applies rotation offset and ensures the list wraps correctly. */ QList GameScene::rotatePlayers(const QList &activePlayers, int firstPlayerIndex) const { QList rotated = activePlayers; if (!rotated.isEmpty()) { int totalRotation = firstPlayerIndex + playerRotation; while (totalRotation < 0) { totalRotation += rotated.size(); } for (int i = 0; i < totalRotation; ++i) { rotated.append(rotated.takeFirst()); } } return rotated; } int GameScene::determineColumnCount(int playerCount) { return playerCount < SettingsCache::instance().userInterface().getMinPlayersForMultiColumnLayout() ? 1 : 2; } /** * @brief Computes layout positions and scene size based on players and columns. * @param playersPlaying List of active players. * @param columns Number of columns to split into. * @return Calculated scene size. * * Logic: * - Determine rows per column (rounding up). * - Calculate column widths based on widest player item. * - Accumulate scene width and height. * - Position players in columns with spacing. * - Mirror graphics for visual balance. */ QSizeF GameScene::computeSceneSizeAndPlayerLayout(const QList &playersPlaying, int columns) { playersByColumn.clear(); int rows = qCeil((qreal)playersPlaying.size() / columns); qreal sceneHeight = 0, sceneWidth = -playerAreaSpacing; QList columnWidth; QListIterator playersIter(playersPlaying); for (int col = 0; col < columns; ++col) { playersByColumn.append(QList()); columnWidth.append(0); qreal thisColumnHeight = -playerAreaSpacing; int rowsInColumn = rows - (playersPlaying.size() % columns) * col; // Adjust rows for uneven columns for (int j = 0; j < rowsInColumn; ++j) { PlayerLogic *player = playersIter.next(); if (col == 0) { playersByColumn[col].prepend(playerViews.value(player->getPlayerInfo()->getId())); } else { playersByColumn[col].append(playerViews.value(player->getPlayerInfo()->getId())); } auto *pgItem = playerViews.value(player->getPlayerInfo()->getId()); thisColumnHeight += pgItem->boundingRect().height() + playerAreaSpacing; columnWidth[col] = std::max(columnWidth[col], (int)pgItem->boundingRect().width()); } sceneHeight = std::max(sceneHeight, thisColumnHeight); sceneWidth += columnWidth[col] + playerAreaSpacing; } qreal phasesWidth = phasesToolbar->getWidth(); sceneWidth += phasesWidth; // Position players horizontally and vertically qreal x = phasesWidth; for (int col = 0; col < columns; ++col) { qreal y = 0; for (int row = 0; row < playersByColumn[col].size(); ++row) { PlayerGraphicsItem *player = playersByColumn[col][row]; player->setPos(x, y); player->setMirrored(row != rows - 1); // Mirror all except bottom-most y += player->boundingRect().height() + playerAreaSpacing; } x += columnWidth[col] + playerAreaSpacing; } return QSizeF(sceneWidth, sceneHeight); } /** * @brief Computes the minimum width for each column based on player minimum widths. * @return List of minimum widths per column. */ QList GameScene::calculateMinWidthByColumn() const { QList minWidthByColumn; for (const auto &col : playersByColumn) { qreal maxWidth = 0; for (PlayerGraphicsItem *player : col) { maxWidth = std::max(maxWidth, player->getMinimumWidth()); } minWidthByColumn.append(maxWidth); } return minWidthByColumn; } /** * @brief Calculates new scene width considering window aspect ratio. * @param newSize View size. * @param minWidth Minimum width needed to fit all players. * @return Scene width respecting window and content. */ qreal GameScene::calculateNewSceneWidth(const QSize &newSize, qreal minWidth) const { qreal newRatio = (qreal)newSize.width() / newSize.height(); qreal minRatio = minWidth / sceneRect().height(); if (minRatio > newRatio) { return minWidth; // Table dominates width } else { return newRatio * sceneRect().height(); // Window ratio dominates } } /** * @brief Resizes columns and distributes extra width to players. * @param minWidthByColumn Minimum widths per column. * @param newWidth Total scene width. * * Extra width is distributed evenly across columns. Each player item is * notified to adjust internal layout for the new column width. */ void GameScene::resizeColumnsAndPlayers(const QList &minWidthByColumn, qreal newWidth) { qreal minWidth = std::accumulate(minWidthByColumn.begin(), minWidthByColumn.end(), phasesToolbar->getWidth()); qreal extraWidthPerColumn = (newWidth - minWidth) / playersByColumn.size(); qreal newx = phasesToolbar->getWidth(); // Snapshot the columns: resizing a player's table can synchronously trigger // GameScene::rearrange (table width -> sizeChanged -> updateBoundingRect -> // sizeChanged -> rearrange), and rearrange rebuilds playersByColumn. Iterating // the live container across that re-entrant call would use invalidated iterators. const QList> columns = playersByColumn; for (int col = 0; col < columns.size(); ++col) { for (PlayerGraphicsItem *player : columns[col]) { player->processSceneSizeChange(minWidthByColumn[col] + extraWidthPerColumn); player->setPos(newx, player->y()); } newx += minWidthByColumn[col] + extraWidthPerColumn; } } void GameScene::addArrow(QSharedPointer data) { auto *startView = playerViews.value(data->startPlayerId); auto *targetView = playerViews.value(data->targetPlayerId); if (!startView || !targetView) { return; } PlayerLogic *startLogic = startView->getLogic(); auto *startZone = startLogic->getZones().value(data->startZone); if (!startZone) { return; } CardState *startCardState = startZone->getCard(data->startCardId); if (!startCardState) { return; } CardItem *startCard = qobject_cast(startCardState->parent()); if (!startCard) { return; } ArrowTarget *targetItem = nullptr; if (data->isPlayerTargeted()) { targetItem = targetView->getPlayerTarget(); } else { auto *zone = targetView->getLogic()->getZones().value(data->targetZone); if (zone) { CardState *targetCardState = zone->getCard(data->targetCardId); targetItem = targetCardState == nullptr ? nullptr : qobject_cast(targetCardState->parent()); } } if (!targetItem) { return; } auto *arrow = new ArrowItem(data, startCard, targetItem); addItem(arrow); arrow->startDrawAnimation(); arrowRegistry.insert(data, arrow); connect(arrow, &ArrowItem::requestDeletion, this, &GameScene::requestArrowDeletion); } void GameScene::deleteArrow(int playerId, int arrowId) { if (auto *arrow = arrowRegistry.take(playerId, arrowId)) { arrow->delArrow(); } } void GameScene::requestArrowDeletion(int playerId, int arrowId) { if (arrowRegistry.contains(playerId, arrowId)) { emit arrowDeletionRequested(playerId, arrowId); } } void GameScene::onCardZoneChanged(CardState *card, bool sameZone) { auto *item = qobject_cast(card->parent()); if (!item) { return; } QList toDelete; for (auto *arrow : arrowRegistry.all()) { if (arrow->getStartItem() == item || arrow->getTargetItem() == item) { if (sameZone) { arrow->updatePath(); } else { toDelete.append(arrow); } } } 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) { auto *item = qobject_cast(card->parent()); if (!item) { return; } if (zoneChanged) { item->deleteCardInfoPopup(); } item->deleteDragItem(); if (zoneChanged) { item->setBeingPointedAt(false); item->setHoverZoomed(false); } if (newOwner) { item->setOwner(newOwner); } } void GameScene::clearArrowsForPlayer(int playerId) { for (int arrowId : arrowRegistry.idsForPlayer(playerId)) { emit requestArrowDeletion(playerId, arrowId); } } void GameScene::clearArrowsForPlayerLocally(int playerId) { for (int arrowId : arrowRegistry.idsForPlayer(playerId)) { if (auto *arrow = arrowRegistry.take(playerId, arrowId)) { arrow->delArrow(); } } } // ---------- Hover Handling ---------- CardItem *GameScene::getHoverZoomedCard() const { return hoverZoomedCard; } void GameScene::updateHover(const QPointF &scenePos) { 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; if (!topCard && hoverZoomedCard) { const QRectF guard = hoverZoomedCard->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 hoverZoomedCard; } } 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 (!hoverZoomedCard && !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 = (hoverZoomedCard != nullptr); if (hoverZoomedCard && (newCard != hoverZoomedCard)) { endHoverZoom(hoverZoomedCard); hoverZoomedCard = nullptr; } // The hover outline and the card info follow the cursor on contact: only the board // zoom and the panel wait for their trigger. A highlight-only card has no zoom to // unwind, so it is dropped directly instead of through endCardHover(). if (hoveredCard && hoveredCard != newCard) { hoveredCard->setHovered(false); } hoveredCard = newCard; if (newCard) { newCard->setHovered(true); } 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 == hoverZoomedCard) { // 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->isClosing() && cardHoverWidget->getCardItem() == newCard) { // Panel open but zoom dropped (e.g. survived a Leave) — zoom back and refresh. beginHoverZoom(newCard); hoverZoomedCard = newCard; cardHoverWidget->refreshDisplay(); return; } 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(); } 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. beginHoverZoom(newCard); hoverZoomedCard = 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(); } } } void GameScene::beginHoverZoom(CardItem *card) { card->setHoverZoomed(true); 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::onHoverZoomShrinkFinished, Qt::UniqueConnection); connect(card, &AbstractCardItem::hoverFootprintChanged, this, &GameScene::onHoverZoomFootprintChanged, Qt::UniqueConnection); } void GameScene::endHoverZoom(CardItem *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->setHoverZoomed(false); } void GameScene::onHoverZoomShrinkFinished(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::onHoverZoomFootprintChanged(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(item->toGraphicsObject())) { zone->update(); } } } 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); connect(cardHoverWidget, &CardHoverWidget::dismissRequested, this, &GameScene::hideCardHoverPopup); // 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 (hoverZoomedCard && hoverZoomedCard != target) { endHoverZoom(hoverZoomedCard); } if (zoomPhase) { if (hoverZoomedCard != target) { beginHoverZoom(target); hoverZoomedCard = 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; } ensureHoverZoomed(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::ensureHoverZoomed(CardItem *card) { if (hoverZoomedCard != card) { if (hoverZoomedCard) { endHoverZoom(hoverZoomedCard); } beginHoverZoom(card); hoverZoomedCard = card; } } void GameScene::fireTriggers(CardItem *card, bool zoomFired, bool panelFired) { if (!card || !card->getOwner()) { return; } if (cardHoverWidget && cardHoverWidget->isVisible() && !cardHoverWidget->isClosing() && 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(); ensureHoverZoomed(card); showCardHoverPopup(card); } else { confirmArmedCard = card; ensureHoverZoomed(card); } } else { ensureHoverZoomed(card); showCardHoverPopup(card); } return; } if (zoomFired) { ensureHoverZoomed(card); } if (panelFired) { ensureHoverZoomed(card); showCardHoverPopup(card); } } void GameScene::onHoverWidgetEntered() { hoverGracePending = false; hoverGraceTimer->stop(); } void GameScene::onHoverWidgetLeft() { if (!hoverZoomedCard && 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) { if (auto *zone = qgraphicsitem_cast(item)) { return zone; } } return nullptr; } CardItem *GameScene::findTopmostCardInZone(const QList &items, CardZone *zone) { CardItem *maxZCard = nullptr; qreal maxZ = -1; for (QGraphicsItem *item : items) { CardItem *card = qgraphicsitem_cast(item); if (!card) { continue; } if (card->getAttachedTo()) { if (card->getAttachedTo()->getZone() != zone->getLogic()) { continue; } } else if (card->getZone() != zone->getLogic()) { continue; } if (card->getRealZValue() > maxZ) { maxZ = card->getRealZValue(); maxZCard = card; } } return maxZCard; } // ---------- Zone Views ---------- /** * @brief Toggles a zone view for a player. * @param player Player owning the zone. * @param zoneName Name of the zone. * @param numberCards Number of cards visible in the view. * @param isReversed Whether the zone view is reversed. * * If an identical view exists, it is closed. Otherwise, a new ZoneViewWidget is created * and positioned based on zone type. */ void GameScene::toggleZoneView(PlayerLogic *player, const QString &zoneName, int numberCards, bool isReversed) { // Closing a view removes it from zoneViews synchronously, so iterate over a // copy to make sure every already-open matching view is closed. const QList zoneViewCopy = zoneViews; for (auto *view : zoneViewCopy) { ZoneViewZone *temp = view->getZone(); if (temp->getLogic()->getName() == zoneName && temp->getLogic()->getPlayer() == player && qobject_cast(temp->getLogic())->getNumberCards() == numberCards) { view->close(); } } ZoneViewWidget *item = new ZoneViewWidget(player, player->getZones().value(zoneName), numberCards, false, false, {}, isReversed); zoneViews.append(item); connect(item, &ZoneViewWidget::closePressed, this, &GameScene::removeZoneView); addItem(item); if (zoneName == ZoneNames::GRAVE) { item->setPos(360, 100); } else if (zoneName == ZoneNames::EXILE) { item->setPos(380, 120); } else { item->setPos(340, 80); } } /** * @brief Adds a revealed zone view (for shown cards). * @param player Owning player. * @param zone Zone logic. * @param cardList List of cards to show. * @param withWritePermission Whether edits are allowed. */ void GameScene::addRevealedZoneView(PlayerLogic *player, CardZoneLogic *zone, const QList &cardList, bool withWritePermission) { ZoneViewWidget *item = new ZoneViewWidget(player, zone, -2, true, withWritePermission, cardList); zoneViews.append(item); connect(item, &ZoneViewWidget::closePressed, this, &GameScene::removeZoneView); addItem(item); item->setPos(600, 80); } /** * @brief Removes a zone view widget from the scene. * @param item Zone view to remove. */ void GameScene::removeZoneView(ZoneViewWidget *item) { zoneViews.removeOne(item); removeItem(item); } /** * @brief Closes all zone views. */ void GameScene::clearViews() { while (!zoneViews.isEmpty()) { zoneViews.first()->close(); } } void GameScene::emptyActiveViews() { for (auto zoneView : zoneViews) { auto logic = qobject_cast(zoneView->getZone()->getLogic()); if (!logic->getRevealZone()) { logic->clearCards(); } } } /** * @brief Closes the most recently added zone view. */ void GameScene::closeMostRecentZoneView() { if (!zoneViews.isEmpty()) { zoneViews.last()->close(); } } // ---------- View Transforms ---------- QTransform GameScene::getViewTransform() const { return views().at(0)->transform(); } QTransform GameScene::getViewportTransform() const { return views().at(0)->viewportTransform(); } // ---------- Event Handling ---------- 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()) || hoverZoomedCard) { hoverPopupTarget = nullptr; hoverPanelTarget = nullptr; confirmArmedCard.clear(); hideCardHoverPopup(); if (hoverZoomedCard) { endHoverZoom(hoverZoomedCard); hoverZoomedCard = 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); } return QGraphicsScene::event(event); } void GameScene::timerEvent(QTimerEvent * /*event*/) { QMutableHashIterator i(animatedItems); while (i.hasNext()) { i.next(); if (!i.value()->animationEvent()) { i.remove(); } } if (animatedItems.isEmpty()) { animationTimer->stop(); } } void GameScene::registerAnimationItem(IAnimatedItem *item) { auto *object = dynamic_cast(item); if (!object) { return; } // Guard against duplicate connections using the connection map, not // animatedItems: the animation timer removes entries from animatedItems when an // animation completes, but the destroyed->removeAnimatedItem connection must // persist until the object is destroyed. Relying on animatedItems here would let // a re-registered item (e.g. a life counter that flashes repeatedly) accumulate // duplicate destroyed connections, the older ones of which would survive teardown. if (!animationItemConnections.contains(object)) { animationItemConnections.insert(object, connect(object, &QObject::destroyed, this, &GameScene::removeAnimatedItem)); } animatedItems.insert(object, item); if (animationTimer && !animationTimer->isActive()) { animationTimer->start(10, this); } } void GameScene::unregisterAnimationItem(IAnimatedItem *item) { animatedItems.remove(dynamic_cast(item)); if (animationTimer && animatedItems.isEmpty()) { animationTimer->stop(); } } void GameScene::removeAnimatedItem(QObject *item) { animatedItems.remove(item); animationItemConnections.remove(item); if (animationTimer && animatedItems.isEmpty()) { animationTimer->stop(); } } // ---------- Rubber Band ---------- void GameScene::startRubberBand(const QPointF &selectionOrigin) { emit sigStartRubberBand(selectionOrigin); } void GameScene::resizeRubberBand(const QPointF &cursorPoint, int selectedCount) { emit sigResizeRubberBand(cursorPoint, selectedCount); } void GameScene::stopRubberBand() { emit sigStopRubberBand(); }