diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 0b2192399..2706d9865 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -81,6 +81,7 @@ set(cockatrice_SOURCES src/game/game_scene.cpp src/game/game_state.cpp src/game/game_view.cpp + src/game/keyboard_card_navigator.cpp src/game/hand_counter.cpp src/game/log/message_log_widget.cpp src/game/phase.cpp diff --git a/cockatrice/src/game/board/abstract_card_item.cpp b/cockatrice/src/game/board/abstract_card_item.cpp index 86b3e27c8..3b19c8efa 100644 --- a/cockatrice/src/game/board/abstract_card_item.cpp +++ b/cockatrice/src/game/board/abstract_card_item.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +20,7 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef { setCursor(Qt::OpenHandCursor); setFlag(ItemIsSelectable); + setFlag(ItemIsFocusable); setCacheMode(DeviceCoordinateCache); connect(&SettingsCache::instance(), &SettingsCache::displayCardNamesChanged, this, [this] { update(); }); @@ -347,3 +349,20 @@ QVariant AbstractCardItem::itemChange(QGraphicsItem::GraphicsItemChange change, return ArrowTarget::itemChange(change, value); } } + +void AbstractCardItem::keyPressEvent(QKeyEvent *event) +{ + if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) { + if (event->modifiers() & Qt::AltModifier) { + emit cardShiftClicked(cardRef.name); + } else if (event->modifiers() & Qt::ControlModifier) { + setSelected(!isSelected()); + } else if (!isSelected() && isHovered) { + scene()->clearSelection(); + setSelected(true); + } + event->accept(); + } else { + ArrowTarget::keyPressEvent(event); + } +} diff --git a/cockatrice/src/game/board/abstract_card_item.h b/cockatrice/src/game/board/abstract_card_item.h index ed545e1ab..93921d874 100644 --- a/cockatrice/src/game/board/abstract_card_item.h +++ b/cockatrice/src/game/board/abstract_card_item.h @@ -127,6 +127,7 @@ protected: void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; QVariant itemChange(QGraphicsItem::GraphicsItemChange change, const QVariant &value) override; void cacheBgColor(); + void keyPressEvent(QKeyEvent *event) override; }; #endif diff --git a/cockatrice/src/game/board/arrow_item.cpp b/cockatrice/src/game/board/arrow_item.cpp index 430477d76..58de20459 100644 --- a/cockatrice/src/game/board/arrow_item.cpp +++ b/cockatrice/src/game/board/arrow_item.cpp @@ -228,52 +228,9 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) if (targetItem && targetItem != startItem) { CardItem *startCard = qgraphicsitem_cast(startItem); - // For now, we can safely assume that the start item is always a card. - // The target item can be a player as well. - if (!startCard) { - delArrow(); - return; + if (startCard) { + ArrowItem::sendCreateArrowCommand(player, startCard, targetItem, color, deleteInPhase); } - - CardZoneLogic *startZone = startCard->getZone(); - - Command_CreateArrow cmd; - cmd.mutable_arrow_color()->CopyFrom(convertQColorToColor(color)); - cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId()); - cmd.set_start_zone(startZone->getName().toStdString()); - cmd.set_start_card_id(startCard->getId()); - - if (auto *targetCard = qgraphicsitem_cast(targetItem)) { - CardZoneLogic *targetZone = targetCard->getZone(); - cmd.set_target_player_id(targetZone->getPlayer()->getPlayerInfo()->getId()); - cmd.set_target_zone(targetZone->getName().toStdString()); - cmd.set_target_card_id(targetCard->getId()); - } else if (auto *targetPlayer = qgraphicsitem_cast(targetItem)) { - cmd.set_target_player_id(targetPlayer->getOwner()->getPlayerInfo()->getId()); - } else { - delArrow(); - return; - } - - // if the card is in hand then we will move the card to stack or table as part of drawing the arrow - if (startZone->getName() == ZoneNames::HAND) { - startCard->playCard(false); - CardInfoPtr ci = startCard->getCard().getCardPtr(); - bool playToStack = SettingsCache::instance().getPlayToStack(); - if (ci && ((!playToStack && ci->getUiAttributes().tableRow == 3) || - (playToStack && ci->getUiAttributes().tableRow != 0 && - startCard->getZone()->getName() != ZoneNames::STACK))) { - cmd.set_start_zone(ZoneNames::STACK); - } else { - cmd.set_start_zone(playToStack ? ZoneNames::STACK : ZoneNames::TABLE); - } - } - - if (deleteInPhase != 0) { - cmd.set_delete_in_phase(deleteInPhase); - } - - player->getPlayerActions()->sendGameCommand(cmd); } delArrow(); @@ -282,6 +239,55 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) } } +void ArrowItem::sendCreateArrowCommand(PlayerLogic *player, + CardItem *startCard, + ArrowTarget *targetItem, + const QColor &color, + int deleteInPhase) +{ + if (!startCard || !targetItem || !player) { + return; + } + + CardZoneLogic *startZone = startCard->getZone(); + + Command_CreateArrow cmd; + cmd.mutable_arrow_color()->CopyFrom(convertQColorToColor(color)); + cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId()); + cmd.set_start_zone(startZone->getName().toStdString()); + cmd.set_start_card_id(startCard->getId()); + + if (auto *targetCard = qgraphicsitem_cast(targetItem)) { + CardZoneLogic *targetZone = targetCard->getZone(); + cmd.set_target_player_id(targetZone->getPlayer()->getPlayerInfo()->getId()); + cmd.set_target_zone(targetZone->getName().toStdString()); + cmd.set_target_card_id(targetCard->getId()); + } else if (auto *targetPlayer = qgraphicsitem_cast(targetItem)) { + cmd.set_target_player_id(targetPlayer->getOwner()->getPlayerInfo()->getId()); + } else { + return; + } + + // if the card is in hand then we will move the card to stack or table as part of drawing the arrow + if (startZone->getName() == ZoneNames::HAND) { + startCard->playCard(false); + CardInfoPtr ci = startCard->getCard().getCardPtr(); + bool playToStack = SettingsCache::instance().getPlayToStack(); + if (ci && ((!playToStack && ci->getUiAttributes().tableRow == 3) || + (playToStack && ci->getUiAttributes().tableRow != 0 && + startCard->getZone()->getName() != ZoneNames::STACK))) { + cmd.set_start_zone(ZoneNames::STACK); + } else { + cmd.set_start_zone(playToStack ? ZoneNames::STACK : ZoneNames::TABLE); + } + } + + if (deleteInPhase != 0) { + cmd.set_delete_in_phase(deleteInPhase); + } + + player->getPlayerActions()->sendGameCommand(cmd); +} // ArrowAttachItem ArrowAttachItem::ArrowAttachItem(ArrowTarget *_startItem) : ArrowItem(_startItem->getOwner(), -1, _startItem, nullptr, Qt::green) diff --git a/cockatrice/src/game/board/arrow_item.h b/cockatrice/src/game/board/arrow_item.h index 7dc0f9477..9a7fa36ab 100644 --- a/cockatrice/src/game/board/arrow_item.h +++ b/cockatrice/src/game/board/arrow_item.h @@ -77,6 +77,11 @@ public: } void delArrow(); + static void sendCreateArrowCommand(PlayerLogic *player, + CardItem *startCard, + ArrowTarget *targetItem, + const QColor &color, + int deleteInPhase = 0); }; class ArrowDragItem : public ArrowItem diff --git a/cockatrice/src/game/board/card_item.cpp b/cockatrice/src/game/board/card_item.cpp index a08194540..fa4e97093 100644 --- a/cockatrice/src/game/board/card_item.cpp +++ b/cockatrice/src/game/board/card_item.cpp @@ -5,6 +5,7 @@ #include "../../game_graphics/zones/view_zone.h" #include "../../interface/widgets/tabs/tab_game.h" #include "../game_scene.h" +#include "../keyboard_card_navigator.h" #include "../phase.h" #include "../player/player_actions.h" #include "../player/player_logic.h" @@ -14,7 +15,10 @@ #include <../../client/settings/card_counter_settings.h> #include +#include #include +#include +#include #include #include #include @@ -500,6 +504,71 @@ void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) event->accept(); } +void CardItem::keyPressEvent(QKeyEvent *event) +{ + + auto *gameScene = static_cast(scene()); + KeyboardCardNavigator *navigator = gameScene ? gameScene->getCardNavigator() : nullptr; + + if (event->key() == Qt::Key_Escape && navigator && navigator->isArrowModeActiveVar()) { + navigator->cancelArrowMode(); + event->accept(); + qWarning() << "Arrow mode cancelled from CardItem with id:" << id; + return; + } + + if (event->key() == Qt::Key_A && (event->modifiers() & Qt::ShiftModifier) && getIsHovered()) { + qWarning() << "Starting arrow mode from CardItem with id:" << id; + if (navigator) { + qWarning() << "Navigator found, starting arrow mode."; + scene()->clearSelection(); + setSelected(true); + navigator->startArrowMode(this); + event->accept(); + return; + } + } + + if ((event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) && navigator && + navigator->isArrowModeActiveVar() && getIsHovered()) { + + qWarning() << "Finalizing arrow mode from CardItem with id:" << id; + navigator->createArrow(this); + navigator->cancelArrowMode(); + event->accept(); + return; + } + + if ((event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) && isSelected() && + SettingsCache::instance().getDoubleClickToPlay()) { + handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier)); + event->accept(); + } else if (event->key() == Qt::Key_Space && getIsHovered()) { + if (QWidget *popup = QApplication::activePopupWidget()) { + popup->close(); + event->accept(); + return; + } + + if (owner != nullptr) { + scene()->clearSelection(); + setSelected(true); + owner->getGame()->setActiveCard(this); + if (QMenu *cardMenu = owner->getPlayerMenu()->updateCardMenu(this)) { + QPointF scenePos = sceneBoundingRect().center(); + if (!scene()->views().isEmpty()) { + QGraphicsView *view = scene()->views().first(); + QPoint screenPos = view->mapToGlobal(view->mapFromScene(scenePos)); + cardMenu->popup(screenPos); + } + } + } + event->accept(); + } else { + AbstractCardItem::keyPressEvent(event); + } +} + bool CardItem::animationEvent() { int rotation = ROTATION_DEGREES_PER_FRAME; diff --git a/cockatrice/src/game/board/card_item.h b/cockatrice/src/game/board/card_item.h index 87f9667de..5ee603e88 100644 --- a/cockatrice/src/game/board/card_item.h +++ b/cockatrice/src/game/board/card_item.h @@ -169,6 +169,7 @@ protected: void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; QVariant itemChange(GraphicsItemChange change, const QVariant &value) override; }; diff --git a/cockatrice/src/game/game_scene.cpp b/cockatrice/src/game/game_scene.cpp index 1b4f0d461..ee831fc4d 100644 --- a/cockatrice/src/game/game_scene.cpp +++ b/cockatrice/src/game/game_scene.cpp @@ -5,6 +5,7 @@ #include "../game_graphics/zones/view_zone.h" #include "../game_graphics/zones/view_zone_widget.h" #include "board/card_item.h" +#include "keyboard_card_navigator.h" #include "phases_toolbar.h" #include "player/player_graphics_item.h" #include "player/player_logic.h" @@ -28,9 +29,11 @@ * Finally, calls rearrange() to layout players initially. */ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent) - : QGraphicsScene(parent), phasesToolbar(_phasesToolbar), viewSize(QSize()), playerRotation(0) + : QGraphicsScene(parent), phasesToolbar(_phasesToolbar), viewSize(QSize()), playerRotation(0), + cardNavigator(nullptr) { animationTimer = new QBasicTimer; + cardNavigator = new KeyboardCardNavigator(nullptr); addItem(phasesToolbar); connect(&SettingsCache::instance(), &SettingsCache::minPlayersForMultiColumnLayoutChanged, this, &GameScene::rearrange); @@ -41,6 +44,7 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent) GameScene::~GameScene() { delete animationTimer; + delete cardNavigator; // DO NOT call clearViews() here // clearViews calls close() on the zoneViews, which sends signals; sending signals in destructors leads to segfaults @@ -460,6 +464,46 @@ void GameScene::onCardZoneChanged(CardItem *card, bool sameZone) } } +void GameScene::handleLeftArrow() +{ + if (cardNavigator) { + QKeyEvent event(QEvent::KeyPress, Qt::Key_Left, Qt::NoModifier); + cardNavigator->switchCardInZone(&event); + } +} + +void GameScene::handleRightArrow() +{ + if (cardNavigator) { + QKeyEvent event(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier); + cardNavigator->switchCardInZone(&event); + } +} + +void GameScene::handleUpArrow() +{ + if (cardNavigator) { + QKeyEvent event(QEvent::KeyPress, Qt::Key_Up, Qt::NoModifier); + cardNavigator->switchZone(&event); + } +} + +void GameScene::handleDownArrow() +{ + if (cardNavigator) { + QKeyEvent event(QEvent::KeyPress, Qt::Key_Down, Qt::NoModifier); + cardNavigator->switchZone(&event); + } +} + +void GameScene::setActivePlayer(PlayerLogic *player) +{ + if (cardNavigator) { + cardNavigator->setPlayer(player); + cardNavigator->setCurrentZone(player->getHandZone()); + } +} + // ---------- Hover Handling ---------- void GameScene::updateHover(const QPointF &scenePos) diff --git a/cockatrice/src/game/game_scene.h b/cockatrice/src/game/game_scene.h index 1551c8365..b33b6cce5 100644 --- a/cockatrice/src/game/game_scene.h +++ b/cockatrice/src/game/game_scene.h @@ -23,6 +23,7 @@ class CardItem; class ServerInfo_Card; class PhasesToolbar; class QBasicTimer; +class KeyboardCardNavigator; /** * @class GameScene @@ -52,6 +53,7 @@ private: QBasicTimer *animationTimer; ///< Timer for card animations QSet cardsToAnimate; ///< Cards currently animating int playerRotation; ///< Rotation offset for player layout + KeyboardCardNavigator *cardNavigator; ///< Handles keyboard-based card navigation /** * @brief Updates which card is currently hovered based on scene coordinates. @@ -171,6 +173,12 @@ public: /** @brief Updates hovered card highlighting. */ void updateHoveredCard(CardItem *newCard); + /** @brief Gets the keyboard card navigator. */ + KeyboardCardNavigator *getCardNavigator() const + { + return cardNavigator; + } + /** @brief Registers a card for animation updates. */ void registerAnimationItem(AbstractCardItem *card); @@ -206,6 +214,17 @@ public slots: void deleteArrow(int arrowId); void clearArrowsForPlayer(int playerId); + /** @brief Handles left arrow key for card navigation. */ + void handleLeftArrow(); + /** @brief Handles right arrow key for card navigation. */ + void handleRightArrow(); + /** @brief Handles up arrow key for zone navigation. */ + void handleUpArrow(); + /** @brief Handles down arrow key for zone navigation. */ + void handleDownArrow(); + /** @brief Sets the active player for keyboard navigation. */ + void setActivePlayer(PlayerLogic *player); + /// Queues up arrow deletion but doesn't directly modify the scene void requestArrowDeletion(int arrowId); void requestClearArrowsForPlayer(int playerId); diff --git a/cockatrice/src/game/keyboard_card_navigator.cpp b/cockatrice/src/game/keyboard_card_navigator.cpp new file mode 100644 index 000000000..055b522b3 --- /dev/null +++ b/cockatrice/src/game/keyboard_card_navigator.cpp @@ -0,0 +1,256 @@ +#include "keyboard_card_navigator.h" + +#include "../../client/settings/cache_settings.h" +#include "board/arrow_item.h" +#include "board/card_item.h" +#include "player/player_logic.h" + +#include +#include +KeyboardCardNavigator::KeyboardCardNavigator(PlayerLogic *player) + : currentZone(nullptr), hoveredCardIndex(-1), isArrowModeActive(false), arrowOriginCard(nullptr), + previewArrow(nullptr), playerLogic(player) +{ +} + +int KeyboardCardNavigator::getHoveredIndex() +{ + return hoveredCardIndex; +} + +CardZoneLogic *KeyboardCardNavigator::getCurrentZone() +{ + return currentZone; +} + +void KeyboardCardNavigator::setCurrentZone(CardZoneLogic *zone) +{ + currentZone = zone; +} + +void KeyboardCardNavigator::switchCardInZone(QKeyEvent *event) +{ + if (!playerLogic) { + return; + } + if (!currentZone) { + return; + } + if (QApplication::activePopupWidget()) { + return; + } + + const CardList &zoneCards = currentZone->getCards(); + if (zoneCards.isEmpty()) { + // if the current zone is empty, try to force a zone change. + QKeyEvent event(QEvent::KeyPress, Qt::Key_Up, Qt::NoModifier); + KeyboardCardNavigator::switchZone(&event); + return; + } + event->accept(); + + // Check if this is an arrow key we care about + int keyCode = event->key(); + if (keyCode != Qt::Key_Right && keyCode != Qt::Key_Left) { + return; + } + + // Calculate new index + int newIndex = hoveredCardIndex; + bool isInitial = (hoveredCardIndex < 0); + + if (isInitial) { + // If the first key pressed is the right, spawn the cursor at the first card + // Otherwise, spawn at the last card. + newIndex = keyCode == Qt::Key_Right ? 0 : zoneCards.size() - 1; + } else { + if (hoveredCardIndex >= zoneCards.size()) { + hoveredCardIndex = 0; + newIndex = 0; + } + + if (keyCode == Qt::Key_Right) { + newIndex = (hoveredCardIndex + 1) % zoneCards.size(); + } else { + newIndex = (hoveredCardIndex - 1 + zoneCards.size()) % zoneCards.size(); + } + } + + KeyboardCardNavigator::changeHoverCard(hoveredCardIndex, false); + + hoveredCardIndex = newIndex; + KeyboardCardNavigator::changeHoverCard(newIndex, true); +} + +void KeyboardCardNavigator::changeHoverCard(int cardIndex, bool hover) +{ + const CardList &zoneCards = currentZone->getCards(); + if (cardIndex >= 0 && cardIndex < zoneCards.size()) { + CardItem *card = zoneCards[cardIndex]; + if (card) { + card->setHovered(hover); + card->setFocus(); + // Force update of new card's area + if (card->scene()) { + card->scene()->update(card->sceneBoundingRect()); + } + if (isArrowModeActive && hover) { + createTempArrow(card); + } + } + } +} + +void KeyboardCardNavigator::setPlayer(PlayerLogic *player) +{ + playerLogic = player; +} + +void KeyboardCardNavigator::setHoveredCardIndex(int index) +{ + hoveredCardIndex = index; +} + +void KeyboardCardNavigator::unhoverCard() +{ + if (!playerLogic || !currentZone) { + return; + } + + changeHoverCard(hoveredCardIndex, false); +} + +void KeyboardCardNavigator::createTempArrow(CardItem *targetCard) +{ + if (!isArrowModeActive || !arrowOriginCard || !targetCard || !playerLogic) { + return; + } + + if (previewArrow) { + delete previewArrow; + previewArrow = nullptr; + } + + previewArrow = new ArrowItem(playerLogic, -1, arrowOriginCard, targetCard, Qt::red); + if (arrowOriginCard->scene()) { + arrowOriginCard->scene()->addItem(previewArrow); + } +} +void KeyboardCardNavigator::createArrow(CardItem *targetCard) +{ + if (!isArrowModeActive || !arrowOriginCard || !targetCard || !playerLogic) { + return; + } + + if (previewArrow) { + delete previewArrow; + previewArrow = nullptr; + } + isArrowModeActive = false; + + if (arrowOriginCard == targetCard) { + arrowOriginCard = nullptr; + return; + } + + ArrowItem::sendCreateArrowCommand(playerLogic, arrowOriginCard, targetCard, Qt::red); + + arrowOriginCard = nullptr; +} + +void KeyboardCardNavigator::startArrowMode(CardItem *originCard) +{ + if (!originCard || !originCard->scene() || !playerLogic) { + return; + } + + isArrowModeActive = true; + arrowOriginCard = originCard; + + createTempArrow(originCard); +} + +void KeyboardCardNavigator::cancelArrowMode() +{ + if (previewArrow) { + delete previewArrow; + previewArrow = nullptr; + } + isArrowModeActive = false; + arrowOriginCard = nullptr; +} + +CardZoneLogic * +KeyboardCardNavigator::findZoneWithCards(QList &zonesList, int currentZoneIndex, bool upperZone) +{ + CardZoneLogic *newZone; + int newZoneIndex = currentZoneIndex; + do { + // Calculate new zone index + if (upperZone) { + newZoneIndex = (newZoneIndex + 1) % zonesList.size(); + } else { + newZoneIndex = (newZoneIndex - 1 + zonesList.size()) % zonesList.size(); + } + newZone = zonesList[newZoneIndex]; + // Prevent switching zone if the others are empty + } while (newZoneIndex != currentZoneIndex && newZone->getCards().size() == 0); + return newZone; +} + +void KeyboardCardNavigator::switchZone(QKeyEvent *event) +{ + if (!playerLogic) { + return; + } + + if (QApplication::activePopupWidget()) { + return; + } + + int keyCode = event->key(); + if (keyCode != Qt::Key_Up && keyCode != Qt::Key_Down) { + return; + } + + event->accept(); + // Build list with only the zones of interest + QList zonesList; + + TableZoneLogic *tableZone = playerLogic->getTableZone(); + StackZoneLogic *stackZone = playerLogic->getStackZone(); + HandZoneLogic *handZone = playerLogic->getHandZone(); + + if (tableZone) { + zonesList.append(tableZone); + } + if (stackZone) { + zonesList.append(stackZone); + } + if (handZone) { + zonesList.append(handZone); + } + + if (zonesList.isEmpty()) { + return; + } + + int currentZoneIndex = zonesList.indexOf(currentZone); + if (currentZoneIndex < 0) { + + currentZoneIndex = 0; + } + + CardZoneLogic *newZone = + KeyboardCardNavigator::findZoneWithCards(zonesList, currentZoneIndex, keyCode == Qt::Key_Up); + + if (currentZone != newZone) { + + changeHoverCard(hoveredCardIndex, false); + setCurrentZone(newZone); + // Reset card index since we're in a new zone + hoveredCardIndex = 0; + // The new zone has to have cards, hover and select the first one + changeHoverCard(hoveredCardIndex, true); + } +} diff --git a/cockatrice/src/game/keyboard_card_navigator.h b/cockatrice/src/game/keyboard_card_navigator.h new file mode 100644 index 000000000..c420ee85e --- /dev/null +++ b/cockatrice/src/game/keyboard_card_navigator.h @@ -0,0 +1,61 @@ +/** + * @file keyboard_card_navigator.h + * @ingroup GameInput + * @brief Handles keyboard navigation for selecting cards using arrow keys. + * + * Allows players to navigate through hand cards using directional arrow keys. + * Spatially-aware: finds adjacent cards based on screen position. + */ + +#ifndef KEYBOARD_CARD_NAVIGATOR_H +#define KEYBOARD_CARD_NAVIGATOR_H + +#include "zones/card_zone_logic.h" + +class PlayerLogic; +class CardItem; +class QKeyEvent; +class ArrowItem; + +class KeyboardCardNavigator +{ +private: + CardZoneLogic *currentZone; + int hoveredCardIndex = -1; + bool isArrowModeActive; + CardItem *arrowOriginCard; + ArrowItem *previewArrow; + PlayerLogic *playerLogic; + /** + * @brief Gets hand cards sorted by visual position. + * @return List of cards sorted by visual order (left-to-right for horizontal, top-to-bottom for vertical). + */ + CardList getVisuallyOrderedHandCards() const; + +public: + KeyboardCardNavigator(PlayerLogic *player = nullptr); + int getHoveredIndex(); + CardZoneLogic *getCurrentZone(); + void setCurrentZone(CardZoneLogic *zone); + void switchCardInZone(QKeyEvent *event); + void switchZone(QKeyEvent *event); + void setPlayer(PlayerLogic *player); + /** + * @brief Validates and resets the hovered card if needed. + * Call this when hand composition changes. + */ + void setHoveredCardIndex(int index); + void unhoverCard(); + void changeHoverCard(int cardIndex, bool hover); + void createArrow(CardItem *targetCard); + void createTempArrow(CardItem *targetCard); + void startArrowMode(CardItem *originCard); + void cancelArrowMode(); + CardZoneLogic *findZoneWithCards(QList &zonesList, int currentZoneIndex, bool upperZone); + bool isArrowModeActiveVar() const + { + return isArrowModeActive; + } +}; + +#endif diff --git a/cockatrice/src/game/player/player_logic.cpp b/cockatrice/src/game/player/player_logic.cpp index 67c6e9519..200e3936c 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/cockatrice/src/game/player/player_logic.cpp @@ -11,6 +11,7 @@ #include "../board/card_list.h" #include "../board/counter_general.h" #include "../game_scene.h" +#include "../keyboard_card_navigator.h" #include "player_actions.h" #include "player_target.h" @@ -323,6 +324,9 @@ bool PlayerLogic::clearCardsToDelete() void PlayerLogic::setActive(bool _active) { active = _active; + if (_active == true) { + hoverFirstCardInHand(); + } emit activeChanged(active); } @@ -348,3 +352,20 @@ void PlayerLogic::setGameStarted() } setConceded(false); } + +void PlayerLogic::hoverFirstCardInHand() +{ + HandZoneLogic *handZone = getHandZone(); + if (!handZone) { + return; + } + const CardList &handCards = handZone->getCards(); + if (!handCards.isEmpty()) { + CardItem *firstCard = handCards.at(0); + if (firstCard) { + firstCard->setHovered(true); + getGameScene()->getCardNavigator()->unhoverCard(); + getGameScene()->getCardNavigator()->setHoveredCardIndex(0); + } + } +} diff --git a/cockatrice/src/game/player/player_logic.h b/cockatrice/src/game/player/player_logic.h index 20d7597b4..f5c558859 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/cockatrice/src/game/player/player_logic.h @@ -229,6 +229,8 @@ public: void setZoneId(int _zoneId); + void hoverFirstCardInHand(); + private: AbstractGame *game; PlayerInfo *playerInfo; diff --git a/cockatrice/src/interface/key_signals.cpp b/cockatrice/src/interface/key_signals.cpp index a78997cc2..1b8995491 100644 --- a/cockatrice/src/interface/key_signals.cpp +++ b/cockatrice/src/interface/key_signals.cpp @@ -1,5 +1,6 @@ #include "key_signals.h" +#include #include bool KeySignals::eventFilter(QObject * /*object*/, QEvent *event) @@ -26,15 +27,35 @@ bool KeySignals::eventFilter(QObject * /*object*/, QEvent *event) case Qt::Key_Right: if (kevent->modifiers() & Qt::ShiftModifier) { emit onShiftRight(); + } else { + emit onRightArrow(); + return true; } break; case Qt::Key_Left: if (kevent->modifiers() & Qt::ShiftModifier) { emit onShiftLeft(); + } else { + emit onLeftArrow(); + return true; } break; + case Qt::Key_Up: + // This check exists so up and down arrows can still navigate all menu boxes while having + // normal browsing functionality in game + if (qApp->activePopupWidget() != nullptr || qApp->activeModalWidget() != nullptr) { + return false; + } + emit onUpArrow(); + return true; + case Qt::Key_Down: + if (qApp->activePopupWidget() != nullptr || qApp->activeModalWidget() != nullptr) { + return false; + } + emit onDownArrow(); + return true; case Qt::Key_Delete: case Qt::Key_Backspace: emit onDelete(); diff --git a/cockatrice/src/interface/key_signals.h b/cockatrice/src/interface/key_signals.h index 30dcee0ba..1e44381c1 100644 --- a/cockatrice/src/interface/key_signals.h +++ b/cockatrice/src/interface/key_signals.h @@ -28,6 +28,10 @@ signals: void onCtrlAltRBracket(); void onShiftS(); void onCtrlC(); + void onLeftArrow(); + void onRightArrow(); + void onUpArrow(); + void onDownArrow(); protected: bool eventFilter(QObject *, QEvent *event) override; diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 9fc123a8c..6902319fe 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -14,6 +14,7 @@ #include "../game/player/player_logic.h" #include "../game/replay.h" #include "../interface/card_picture_loader/card_picture_loader.h" +#include "../interface/key_signals.h" #include "../interface/widgets/cards/card_info_frame_widget.h" #include "../interface/widgets/dialogs/dlg_create_game.h" #include "../interface/widgets/server/user/user_list_manager.h" @@ -863,6 +864,7 @@ PlayerLogic *TabGame::setActivePlayer(int id) } playerListWidget->setActivePlayer(id); + scene->setActivePlayer(player); QMapIterator i(game->getPlayerManager()->getPlayers()); while (i.hasNext()) { i.next(); @@ -1153,6 +1155,14 @@ void TabGame::createPlayAreaWidget(bool bReplay) connect(scene, &GameScene::arrowDeletionRequested, game->getGameEventHandler(), &GameEventHandler::handleArrowDeletion); connect(game->getGameEventHandler(), &GameEventHandler::arrowDeleted, scene, &GameScene::deleteArrow); + + keySignals = new KeySignals(); + qApp->installEventFilter(keySignals); + connect(keySignals, &KeySignals::onLeftArrow, scene, &GameScene::handleLeftArrow); + connect(keySignals, &KeySignals::onRightArrow, scene, &GameScene::handleRightArrow); + connect(keySignals, &KeySignals::onUpArrow, scene, &GameScene::handleUpArrow); + connect(keySignals, &KeySignals::onDownArrow, scene, &GameScene::handleDownArrow); + gameView = new GameView(scene); auto gamePlayAreaVBox = new QVBoxLayout; diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.h b/cockatrice/src/interface/widgets/tabs/tab_game.h index 7f9392034..043fe429d 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -51,6 +51,7 @@ class GameReplay; class LineEditCompleter; class QDockWidget; class QStackedWidget; +class KeySignals; class TabGame : public Tab { @@ -73,6 +74,7 @@ private: PhasesToolbar *phasesToolbar; GameScene *scene; GameView *gameView; + KeySignals *keySignals; QMap deckViewContainers; QVBoxLayout *deckViewContainerLayout; QWidget *gamePlayAreaWidget, *deckViewContainerWidget; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 00eba288e..bcaf3831e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -75,6 +75,7 @@ target_link_libraries( ) add_subdirectory(card_zone_algorithms) +add_subdirectory(keyboard_navigator_tests) add_subdirectory(carddatabase) add_subdirectory(loading_from_clipboard) add_subdirectory(movecard_tests) diff --git a/tests/keyboard_navigator_tests/CMakeLists.txt b/tests/keyboard_navigator_tests/CMakeLists.txt new file mode 100644 index 000000000..2c0a7f632 --- /dev/null +++ b/tests/keyboard_navigator_tests/CMakeLists.txt @@ -0,0 +1,32 @@ +add_executable(keyboard_navigator_test keyboard_card_navigator_test.cpp keyboard_navigator_test_stubs.cpp) + +target_compile_options(keyboard_navigator_test PRIVATE --coverage) +target_link_options(keyboard_navigator_test PRIVATE --coverage) + +target_include_directories( + keyboard_navigator_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src ${CMAKE_SOURCE_DIR}/cockatrice/src/game + ${CMAKE_SOURCE_DIR} ${CMAKE_SOURCE_DIR}/libcockatrice_interfaces +) + +target_link_libraries( + keyboard_navigator_test + PRIVATE Threads::Threads + PRIVATE ${GTEST_BOTH_LIBRARIES} + PRIVATE ${TEST_QT_MODULES} + PRIVATE libcockatrice_settings + PRIVATE libcockatrice_interfaces + PRIVATE libcockatrice_protocol + PRIVATE libcockatrice_card + PRIVATE libcockatrice_deck_list + PRIVATE libcockatrice_models + PRIVATE libcockatrice_rng + PRIVATE libcockatrice_network + PRIVATE libcockatrice_utility + PRIVATE Qt6::Widgets +) + +add_test(NAME keyboard_navigator_test COMMAND keyboard_navigator_test) + +if(NOT GTEST_FOUND) + add_dependencies(keyboard_navigator_test gtest) +endif() diff --git a/tests/keyboard_navigator_tests/keyboard_card_navigator_test.cpp b/tests/keyboard_navigator_tests/keyboard_card_navigator_test.cpp new file mode 100644 index 000000000..29d0c08b2 --- /dev/null +++ b/tests/keyboard_navigator_tests/keyboard_card_navigator_test.cpp @@ -0,0 +1,229 @@ +#include "game/keyboard_card_navigator.h" + +#include +#include +#include +#include +#include +#include + +// Some tests require us to get the zones out of a player, so instead of changing code we didn't make, we +// decided to use this dirty trick. +#define private public +#include "game/player/player_logic.h" +#undef private + +#include "game/board/abstract_card_item.h" +#include "game/board/arrow_item.h" +#include "game/board/card_item.h" +#include "game/board/card_list.h" +#include "game/keyboard_card_navigator.cpp" +#include "game/zones/card_zone_logic.h" +#include "game/zones/hand_zone_logic.h" +#include "game/zones/stack_zone_logic.h" +#include "game/zones/table_zone_logic.h" +#include "keyboard_navigator_test_fakes.h" + +class KeyboardCardNavigatorTest : public ::testing::Test +{ +protected: + KeyboardCardNavigator *navigator; + FakeHandZoneLogic *handZone; + FakeTableZoneLogic *tableZone; + FakeStackZoneLogic *stackZone; + PlayerLogic *player; + + void SetUp() override + { + handZone = new FakeHandZoneLogic("hand"); + tableZone = new FakeTableZoneLogic("table"); + stackZone = new FakeStackZoneLogic("stack"); + // Player is mocked like this, so we fill the zones for the tests. + player = (PlayerLogic *)malloc(sizeof(PlayerLogic)); + new (&player->zones) QMap(); + player->zones.insert("table", tableZone); + player->zones.insert("stack", stackZone); + player->zones.insert("hand", handZone); + navigator = new KeyboardCardNavigator(player); + } + + void TearDown() override + { + delete navigator; + free(player); + delete handZone; + delete tableZone; + delete stackZone; + } +}; + +/* This test verifies the behaviour of spawning the cursor: + When pressing the left arrow, it goes to the first card. + When pressing the right arrow, it goes to the last card of the zone. */ +TEST_F(KeyboardCardNavigatorTest, LeftRightArrowTest) +{ + // Set an arbitrary amount of cards for the zone + handZone->setDummyCardCount(5); + + // Set the default index + navigator->setHoveredCardIndex(-1); + + navigator->setCurrentZone(handZone); + + // Simulate a key press and a card switch + QKeyEvent eRight(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier); + navigator->switchCardInZone(&eRight); + + // Verify the first card is selected + EXPECT_EQ(navigator->getHoveredIndex(), 0); + + // Reset the index + navigator->setHoveredCardIndex(-1); + + // Simulate a key press and a card switch + QKeyEvent eLeft(QEvent::KeyPress, Qt::Key_Left, Qt::NoModifier); + navigator->switchCardInZone(&eLeft); + + // Check if the last card was selected + EXPECT_EQ(navigator->getHoveredIndex(), 4); +} + +/* This test verifies the moving behaviour of the cursor. */ +TEST_F(KeyboardCardNavigatorTest, NormalSwitchCards) +{ + // Set an arbitrary amount of cards for the zone + handZone->setDummyCardCount(5); + navigator->setCurrentZone(handZone); + // Select the second card + navigator->setHoveredCardIndex(1); + + // If right is pressed, go to index + 1 + QKeyEvent eRight(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier); + navigator->switchCardInZone(&eRight); + EXPECT_EQ(navigator->getHoveredIndex(), 2); + + // If left is pressed, go to index - 1 + QKeyEvent eLeft(QEvent::KeyPress, Qt::Key_Left, Qt::NoModifier); + navigator->switchCardInZone(&eLeft); + EXPECT_EQ(navigator->getHoveredIndex(), 1); +} + +/* This test verifies the edge case moving behaviour of the cursor. + If we are on the first card, and left is pressed, we should go to the + last card, and vice-versa */ +TEST_F(KeyboardCardNavigatorTest, ZoneLoopsTest) +{ + // Set an arbitrary amount of cards for the zone + tableZone->setDummyCardCount(2); + + // Select the first card + navigator->setCurrentZone(tableZone); + navigator->setHoveredCardIndex(0); + + // If we press left, it wraps around to the end, and the zone doesn't change + QKeyEvent eLeft(QEvent::KeyPress, Qt::Key_Left, Qt::NoModifier); + navigator->switchCardInZone(&eLeft); + EXPECT_EQ(navigator->getHoveredIndex(), 1); + EXPECT_EQ(navigator->getCurrentZone(), tableZone); + + // If we press right, it wraps around to the start, and the zone doesn't change + QKeyEvent eRight(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier); + navigator->switchCardInZone(&eRight); + EXPECT_EQ(navigator->getHoveredIndex(), 0); + EXPECT_EQ(navigator->getCurrentZone(), tableZone); +} + +/* This test verifies the switching of zones if the zone we are currently on is empty. */ +TEST_F(KeyboardCardNavigatorTest, EmptyZoneLoopTest) +{ + QList zonesList; + zonesList.append(tableZone); + zonesList.append(stackZone); + zonesList.append(handZone); + + tableZone->setDummyCardCount(2); + stackZone->setDummyCardCount(0); // empty + handZone->setDummyCardCount(2); + + // Simulate key up when switching zones + CardZoneLogic *newZone = navigator->findZoneWithCards(zonesList, 0, true); + // The result should be zone 2 + EXPECT_EQ(newZone, handZone); + + // Simulate key down when switching zones + newZone = navigator->findZoneWithCards(zonesList, 2, false); + // The result should be zone 2 + EXPECT_EQ(newZone, tableZone); +} + +/* This test verifies the switching of zones with the up and down keys. */ +TEST_F(KeyboardCardNavigatorTest, SwitchZoneTest) +{ + tableZone->setDummyCardCount(2); + stackZone->setDummyCardCount(2); + handZone->setDummyCardCount(2); + + navigator->setCurrentZone(tableZone); + navigator->setHoveredCardIndex(0); + + // Simulate key up when switching zones + QKeyEvent eUp(QEvent::KeyPress, Qt::Key_Up, Qt::NoModifier); + navigator->switchZone(&eUp); + // The result should be zone 1 and the card selected is the first one + EXPECT_EQ(navigator->getCurrentZone(), stackZone); + EXPECT_EQ(navigator->getHoveredIndex(), 0); + + // Simulate key down when switching zones + QKeyEvent eDown(QEvent::KeyPress, Qt::Key_Down, Qt::NoModifier); + navigator->switchZone(&eDown); + // The result should be zone 0 and the card selected is the first one + EXPECT_EQ(navigator->getCurrentZone(), tableZone); + EXPECT_EQ(navigator->getHoveredIndex(), 0); +} + +/* This test verifies the case where every zone is empty. */ +TEST_F(KeyboardCardNavigatorTest, EmptyZoneSwitchZones) +{ + QList zonesList; + zonesList.append(tableZone); + zonesList.append(stackZone); + zonesList.append(handZone); + + tableZone->setDummyCardCount(0); + stackZone->setDummyCardCount(0); + handZone->setDummyCardCount(0); + + // The expected zone is the starting one + CardZoneLogic *newZone = navigator->findZoneWithCards(zonesList, 0, true); + EXPECT_EQ(newZone, tableZone); +} + +/* This test verifies the behaviour when someone moves a card with the mouse and the + keyboard is used after a zone becomes empty. */ +TEST_F(KeyboardCardNavigatorTest, ZoneEmptySwitchTest) +{ + tableZone->setDummyCardCount(1); + stackZone->setDummyCardCount(0); + handZone->setDummyCardCount(1); + + // Set a card as hovered in the table zone + navigator->setCurrentZone(tableZone); + navigator->setHoveredCardIndex(0); + + // Simulate the user deleting/moving all cards out of the table zone + tableZone->setDummyCardCount(0); + + // Now the user presses an arrow key in the empty zone + QKeyEvent eRight(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier); + navigator->switchCardInZone(&eRight); + + // The expected zone is the next one with cards + EXPECT_EQ(navigator->getCurrentZone(), handZone); + EXPECT_EQ(navigator->getHoveredIndex(), 0); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/keyboard_navigator_tests/keyboard_navigator_test_fakes.h b/tests/keyboard_navigator_tests/keyboard_navigator_test_fakes.h new file mode 100644 index 000000000..f1c60e40d --- /dev/null +++ b/tests/keyboard_navigator_tests/keyboard_navigator_test_fakes.h @@ -0,0 +1,67 @@ +#ifndef KEYBOARD_NAVIGATOR_TEST_FAKES_H +#define KEYBOARD_NAVIGATOR_TEST_FAKES_H + +#include "game/board/card_item.h" +#include "game/zones/card_zone_logic.h" +#include "game/zones/hand_zone_logic.h" +#include "game/zones/stack_zone_logic.h" +#include "game/zones/table_zone_logic.h" + +// Define safe macro replacements for the visual tests +#define setFocus() zValue() +#define getVisuallyOrderedHandCards() getCards() + +namespace QApplicationMock +{ +extern bool hasPopup; +} + +class FakeCardZoneLogic : public CardZoneLogic +{ +public: + QString name; + FakeCardZoneLogic(const QString &n) : CardZoneLogic(nullptr, n, false, false, true, nullptr), name(n) + { + } + void addCardImpl(CardItem *, int, int) override + { + } + void setDummyCardCount(int count) + { + cards.clear(); + for (int i = 0; i < count; i++) { + cards.insert(i, (CardItem *)nullptr); + } + } + const QString getName() const + { + return name; + } +}; + +#define DECLARE_FAKE_ZONE(FakeName, TargetName) \ + class FakeName : public FakeCardZoneLogic \ + { \ + public: \ + FakeName(const QString &n) : FakeCardZoneLogic(n) \ + { \ + } \ + const QMetaObject *metaObject() const override \ + { \ + return &TargetName::staticMetaObject; \ + } \ + void *qt_metacast(const char *clname) override \ + { \ + if (!clname) \ + return nullptr; \ + if (!strcmp(clname, #TargetName)) \ + return static_cast(this); \ + return FakeCardZoneLogic::qt_metacast(clname); \ + } \ + }; + +DECLARE_FAKE_ZONE(FakeTableZoneLogic, TableZoneLogic) +DECLARE_FAKE_ZONE(FakeStackZoneLogic, StackZoneLogic) +DECLARE_FAKE_ZONE(FakeHandZoneLogic, HandZoneLogic) + +#endif // KEYBOARD_NAVIGATOR_TEST_FAKES_H diff --git a/tests/keyboard_navigator_tests/keyboard_navigator_test_stubs.cpp b/tests/keyboard_navigator_tests/keyboard_navigator_test_stubs.cpp new file mode 100644 index 000000000..276cc7316 --- /dev/null +++ b/tests/keyboard_navigator_tests/keyboard_navigator_test_stubs.cpp @@ -0,0 +1,106 @@ +#include "game/board/abstract_card_item.h" +#include "game/board/arrow_item.h" +#include "game/board/card_item.h" +#include "game/board/card_list.h" +#include "game/player/player_logic.h" +#include "game/zones/card_zone_logic.h" +#include "game/zones/hand_zone_logic.h" +#include "game/zones/stack_zone_logic.h" +#include "game/zones/table_zone_logic.h" + +#include +#include +#include +#include + +// Stubs for AbstractCardItem +void AbstractCardItem::setHovered(bool) +{ +} + +// Stubs for ArrowItem +ArrowItem::ArrowItem(PlayerLogic *, int, ArrowTarget *, ArrowTarget *, QColor const &) + : QObject(nullptr), QGraphicsItem(nullptr) +{ +} +void ArrowItem::sendCreateArrowCommand(PlayerLogic *, CardItem *, ArrowTarget *, QColor const &, int) +{ +} +void ArrowItem::paint(QPainter *, QStyleOptionGraphicsItem const *, QWidget *) +{ +} +void ArrowItem::mousePressEvent(QGraphicsSceneMouseEvent *) +{ +} +const QMetaObject *ArrowItem::metaObject() const +{ + return nullptr; +} +void *ArrowItem::qt_metacast(const char *) +{ + return nullptr; +} +int ArrowItem::qt_metacall(QMetaObject::Call, int, void **) +{ + return 0; +} + +// Stubs for QMetaObject +const QMetaObject TableZoneLogic::staticMetaObject = { + {&QObject::staticMetaObject, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}}; +const QMetaObject StackZoneLogic::staticMetaObject = { + {&QObject::staticMetaObject, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}}; +const QMetaObject HandZoneLogic::staticMetaObject = { + {&QObject::staticMetaObject, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}}; +const QMetaObject PlayerLogic::staticMetaObject = { + {&QObject::staticMetaObject, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}}; +const QMetaObject CardZoneLogic::staticMetaObject = { + {&QObject::staticMetaObject, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}}; + +// Stubs for CardList +CardList::CardList(bool) : QList() +{ +} + +// Stubs for CardZoneLogic +CardZoneLogic::CardZoneLogic(PlayerLogic *, const QString &, bool, bool, bool _contentsKnown, QObject *) + : QObject(nullptr), cards(_contentsKnown) +{ +} +CardItem *CardZoneLogic::getCard(int) +{ + return nullptr; +} +CardItem *CardZoneLogic::takeCard(int, int, bool) +{ + return nullptr; +} +void CardZoneLogic::addCardImpl(CardItem *, int, int) +{ +} +QString CardZoneLogic::getTranslatedName(bool, GrammaticalCase) const +{ + return QString(); +} +const QMetaObject *CardZoneLogic::metaObject() const +{ + return nullptr; +} +void *CardZoneLogic::qt_metacast(const char *) +{ + return nullptr; +} +int CardZoneLogic::qt_metacall(QMetaObject::Call, int, void **) +{ + return 0; +} + +// QApplication Mock implementation +namespace QApplicationMock +{ +bool hasPopup = false; +} +QWidget *QApplication::activePopupWidget() +{ + return QApplicationMock::hasPopup ? (QWidget *)1 : nullptr; +}