From 404b0cdf2862db7ca6f47d786eb7a9d3f293036d Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:44:17 +0200 Subject: [PATCH] [Game] Animate Arrows (#7099) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add an arrow draw animation setting - New arrowDrawAnimation cards-display setting, default on - The arrow draw animation checkbox joins the animation settings group - Visual Deck Storage selection animation checkbox moves next to the other animation checkboxes, and the enable/disable-all buttons now cover it and the arrow animation Took 2 minutes Took 21 minutes Took 7 minutes Took 11 minutes Took 20 seconds * Animate arrows drawing from start to target - The arrow stroke reveals itself along the arc with an eased timing, followed by a short light sheen that sweeps down the shaft - The arrow head pops in once the reveal reaches it, then the whole arrow fades from its initial glow - Decay is driven by GameScene's shared animation timer through the IAnimatedItem interface (QElapsedTimer based), respecting the arrowDrawAnimation setting - GameScene adds the arrow item to the scene before starting its animation so the item is registered against a valid scene Took 6 minutes Took 1 minute * Defer animation start so arrows don't start halfway materialized Took 13 minutes * Don't draw tip/shaft outline Took 12 minutes --------- Co-authored-by: Lukas BrĂ¼bach --- .../src/game_graphics/board/arrow_item.cpp | 174 +++++++++++++++++- .../src/game_graphics/board/arrow_item.h | 25 ++- cockatrice/src/game_graphics/game_scene.cpp | 1 + .../user_interface_settings_page.cpp | 12 +- .../user_interface_settings_page.h | 1 + ...nterface_cards_display_settings_provider.h | 1 + .../settings/cards_display_settings.cpp | 10 + .../settings/cards_display_settings.h | 2 + tests/settings/settings_defaults_test.cpp | 6 + 9 files changed, 219 insertions(+), 13 deletions(-) diff --git a/cockatrice/src/game_graphics/board/arrow_item.cpp b/cockatrice/src/game_graphics/board/arrow_item.cpp index ce8967bb5..af63d047d 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.cpp +++ b/cockatrice/src/game_graphics/board/arrow_item.cpp @@ -4,12 +4,14 @@ #include "../../client/settings/cache_settings.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" +#include "../game_scene.h" #include "../player/player_target.h" #include "../z_values.h" #include "../zones/card_zone.h" #include "card_item.h" #include +#include #include #include #include @@ -18,10 +20,27 @@ #include #include #include +#include #include #include #include +namespace +{ +constexpr qreal kMinStrokeDurationMs = 200.0; +constexpr qreal kMaxStrokeDurationMs = 450.0; +constexpr qreal kMsPerPixel = 0.8; +constexpr qreal kGlowFadeDurationMs = 120.0; +constexpr qreal kSheenHalfWidth = 14.0; + +/// @brief Ease-out cubic, for a natural "slow in / slow out" reveal. +qreal easeOutCubic(qreal t) +{ + const qreal inverse = 1.0 - t; + return 1.0 - inverse * inverse * inverse; +} +} // namespace + ArrowItem::ArrowItem(QSharedPointer _data, ArrowTarget *_startItem, ArrowTarget *_targetItem) : data(std::move(_data)), startItem(_startItem), targetItem(_targetItem) { @@ -47,6 +66,13 @@ ArrowItem::ArrowItem(QSharedPointer _data, ArrowTarget *_startI } } +ArrowItem::~ArrowItem() +{ + if (auto *scene = qobject_cast(this->scene())) { + scene->unregisterAnimationItem(this); + } +} + void ArrowItem::onTargetDestroyed() { emit requestDeletion(data->creatorId, data->id); @@ -91,16 +117,21 @@ void ArrowItem::updatePath(const QPointF &endPoint) prepareGeometryChange(); if (lineLength < 30) { path = QPainterPath(); + bodyPath = QPainterPath(); + headPath = QPainterPath(); + shaftOutlinePath = QPainterPath(); + centerLine = QPainterPath(); + headBaseFraction = 1.0; } else { QPointF c(lineLength / 2, qTan(phi * M_PI / 180) * lineLength); - QPainterPath centerLine; + centerLine = QPainterPath(); centerLine.moveTo(0, 0); centerLine.quadTo(c, QPointF(lineLength, 0)); - double percentage = 1 - headLength / lineLength; - QPointF arrowBodyEndPoint = centerLine.pointAtPercent(percentage); - QLineF testLine(arrowBodyEndPoint, centerLine.pointAtPercent(percentage + 0.001)); + headBaseFraction = 1 - headLength / lineLength; + QPointF arrowBodyEndPoint = centerLine.pointAtPercent(headBaseFraction); + QLineF testLine(arrowBodyEndPoint, centerLine.pointAtPercent(headBaseFraction + 0.001)); qreal alpha = testLine.angle() - 90; QPointF endPoint1 = arrowBodyEndPoint + arrowWidth / 2 * QPointF(qCos(alpha * M_PI / 180), -qSin(alpha * M_PI / 180)); @@ -111,20 +142,89 @@ void ArrowItem::updatePath(const QPointF &endPoint) QPointF point2 = endPoint2 + (headWidth - arrowWidth) / 2 * QPointF(-qCos(alpha * M_PI / 180), qSin(alpha * M_PI / 180)); - path = QPainterPath(-arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180))); + QPointF start1 = -arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180)); + QPointF start2 = arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180)); + + path = QPainterPath(start1); path.quadTo(c, endPoint1); path.lineTo(point1); path.lineTo(QPointF(lineLength, 0)); path.lineTo(point2); path.lineTo(endPoint2); - path.quadTo(c, arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180))); - path.lineTo(-arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180))); + path.quadTo(c, start2); + path.lineTo(start1); + + bodyPath = QPainterPath(start1); + bodyPath.quadTo(c, endPoint1); + bodyPath.lineTo(endPoint2); + bodyPath.quadTo(c, start2); + bodyPath.lineTo(start1); + + headPath = QPainterPath(endPoint1); + headPath.lineTo(point1); + headPath.lineTo(QPointF(lineLength, 0)); + headPath.lineTo(point2); + headPath.lineTo(endPoint2); + + shaftOutlinePath = QPainterPath(start1); + shaftOutlinePath.quadTo(c, endPoint1); + shaftOutlinePath.moveTo(endPoint2); + shaftOutlinePath.quadTo(c, start2); + shaftOutlinePath.lineTo(start1); } setPos(startPoint); setTransform(QTransform().rotate(-line.angle())); } +void ArrowItem::startDrawAnimation() +{ + if (!SettingsCache::instance().cardsDisplay().getArrowDrawAnimation() || centerLine.isEmpty()) { + return; + } + + strokeDurationMs = qBound(kMinStrokeDurationMs, centerLine.length() * kMsPerPixel, kMaxStrokeDurationMs); + glowFadeDurationMs = kGlowFadeDurationMs; + // The clock is started on the first animationEvent() tick so that t=0 + // corresponds to the first rendered frame. Starting it here would count + // the time spent before the item's first paint (event-loop delays, bursts + // of arrows created together), making the arrow appear already partway + // drawn when it first shows up. + animationStarted = false; + drawProgress = 0.0; + glowAlpha = 1.0; + update(); + if (auto *scene = qobject_cast(this->scene())) { + scene->registerAnimationItem(this); + } +} + +bool ArrowItem::animationEvent() +{ + if (!animationStarted) { + animationClock.start(); + animationStarted = true; + } + + const qint64 elapsed = animationClock.elapsed(); + if (elapsed >= strokeDurationMs + glowFadeDurationMs) { + drawProgress = 1.0; + glowAlpha = 0.0; + update(); + return false; + } + + if (elapsed < strokeDurationMs) { + drawProgress = easeOutCubic(qBound(0.0, elapsed / strokeDurationMs, 1.0)); + glowAlpha = 1.0; + } else { + drawProgress = 1.0; + glowAlpha = 1.0 - (elapsed - strokeDurationMs) / glowFadeDurationMs; + } + update(); + return true; +} + void ArrowItem::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) { QColor paintColor(data->color); @@ -133,8 +233,66 @@ void ArrowItem::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*opti } else { paintColor.setAlpha(150); } + + painter->save(); + const QPen outlinePen = painter->pen(); painter->setBrush(paintColor); - painter->drawPath(path); + + const auto drawShaft = [this, painter, &outlinePen, paintColor]() { + painter->setPen(Qt::NoPen); + painter->drawPath(bodyPath); + painter->setPen(outlinePen); + painter->setBrush(Qt::NoBrush); + painter->drawPath(shaftOutlinePath); + painter->setBrush(paintColor); + }; + + if (drawProgress >= 1.0 || path.isEmpty()) { + painter->drawPath(path); + } else if (drawProgress < headBaseFraction) { + // The reveal edge and the sheen share the same arc-length parameterization, + // so the stroke stays exactly in sync with the trailing sheen. + const qreal revealX = centerLine.pointAtPercent(drawProgress).x(); + QPainterPath clip; + clip.addRect(QRectF(-glowExtent, path.boundingRect().top() - glowExtent, revealX + glowExtent, + path.boundingRect().height() + 2 * glowExtent)); + painter->setClipPath(clip); + drawShaft(); + } else { + // Once the reveal reaches the head base, pop the whole head in with a fade + // instead of slicing the triangle into a growing stub. + drawShaft(); + const qreal headFadeIn = (drawProgress - headBaseFraction) / (1.0 - headBaseFraction); + painter->setOpacity(headFadeIn); + painter->setPen(Qt::NoPen); + painter->drawPath(headPath); + painter->setPen(outlinePen); + painter->setBrush(Qt::NoBrush); + painter->drawPath(headPath); + painter->setOpacity(1.0); + painter->setBrush(paintColor); + } + + if (glowAlpha > 0.0 && !centerLine.isEmpty()) { + // Sweep a bright band across the arrow. Clipping to the + // silhouette keeps it flat against the shaft so it reads as a light reflection. + const qreal anticipation = qMin(1.0, drawProgress / 0.08); + const QPointF sweep = centerLine.pointAtPercent(qMin(drawProgress, 1.0)); + QLinearGradient sheen(sweep.x() - kSheenHalfWidth, 0.0, sweep.x() + kSheenHalfWidth, 0.0); + sheen.setColorAt(0.0, QColor(paintColor.red(), paintColor.green(), paintColor.blue(), 0)); + sheen.setColorAt(0.5, QColor(255, 255, 255, 200)); + sheen.setColorAt(1.0, QColor(paintColor.red(), paintColor.green(), paintColor.blue(), 0)); + painter->save(); + painter->setPen(Qt::NoPen); + painter->setClipPath(path); + painter->setBrush(sheen); + painter->setOpacity(glowAlpha * anticipation); + painter->drawRect(QRectF(sweep.x() - kSheenHalfWidth - glowExtent, path.boundingRect().top() - glowExtent, + (kSheenHalfWidth + glowExtent) * 2.0, + path.boundingRect().height() + glowExtent * 2.0)); + painter->restore(); + } + painter->restore(); } void ArrowItem::mousePressEvent(QGraphicsSceneMouseEvent *event) diff --git a/cockatrice/src/game_graphics/board/arrow_item.h b/cockatrice/src/game_graphics/board/arrow_item.h index 1c306e065..21f991b77 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.h +++ b/cockatrice/src/game_graphics/board/arrow_item.h @@ -2,9 +2,12 @@ #define ARROWITEM_H #include "../../game/board/arrow_data.h" +#include "../animated_item.h" #include "arrow_target.h" +#include #include +#include #include #include @@ -12,7 +15,7 @@ class CardItem; class QGraphicsSceneMouseEvent; class PlayerLogic; -class ArrowItem : public QObject, public QGraphicsItem +class ArrowItem : public QObject, public QGraphicsItem, public IAnimatedItem { Q_OBJECT Q_INTERFACES(QGraphicsItem) @@ -21,6 +24,19 @@ signals: private: QPainterPath path; + QPainterPath bodyPath; + QPainterPath headPath; + QPainterPath shaftOutlinePath; + QPainterPath centerLine; + qreal headBaseFraction = 1.0; + QElapsedTimer animationClock; + qreal strokeDurationMs = 0; + qreal glowFadeDurationMs = 0; + qreal drawProgress = 1.0; + qreal glowAlpha = 0.0; + bool animationStarted = false; + + static constexpr qreal glowExtent = 12.0; protected: QSharedPointer data; @@ -33,16 +49,19 @@ protected: public: ArrowItem(QSharedPointer _data, ArrowTarget *_startItem, ArrowTarget *_targetItem); + ~ArrowItem() override; void onTargetDestroyed(); void delArrow(); void updatePath(); void updatePath(const QPointF &endPoint); + void startDrawAnimation(); + bool animationEvent() override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; [[nodiscard]] QRectF boundingRect() const override { - return path.boundingRect(); + return path.boundingRect().adjusted(-glowExtent, -glowExtent, glowExtent, glowExtent); } [[nodiscard]] QPainterPath shape() const override { @@ -106,4 +125,4 @@ protected: void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; }; -#endif \ No newline at end of file +#endif diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 25c5fbcf0..4d3144ad4 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -502,6 +502,7 @@ void GameScene::addArrow(QSharedPointer data) auto *arrow = new ArrowItem(data, startCard, targetItem); addItem(arrow); + arrow->startDrawAnimation(); arrowRegistry.insert(data, arrow); connect(arrow, &ArrowItem::requestDeletion, this, &GameScene::requestArrowDeletion); } diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp index 3fa56dd48..a20d31652 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp @@ -116,6 +116,10 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() connect(&tapAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::setTapAnimation); + arrowDrawAnimationCheckBox.setChecked(SettingsCache::instance().cardsDisplay().getArrowDrawAnimation()); + connect(&arrowDrawAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setArrowDrawAnimation); + lifeCounterAnimationsCheckBox.setChecked( SettingsCache::instance().userInterface().getLifeCounterAnimationsEnabled()); connect(&lifeCounterAnimationsCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), @@ -132,8 +136,9 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() animationGrid->addWidget(&enableAllAnimationsButton, 0, 0); animationGrid->addWidget(&disableAllAnimationsButton, 0, 1); animationGrid->addWidget(&tapAnimationCheckBox, 1, 0); - animationGrid->addWidget(&lifeCounterAnimationsCheckBox, 2, 0); - animationGrid->addWidget(&battlefieldFlashCheckBox, 3, 0); + animationGrid->addWidget(&arrowDrawAnimationCheckBox, 2, 0); + animationGrid->addWidget(&lifeCounterAnimationsCheckBox, 3, 0); + animationGrid->addWidget(&battlefieldFlashCheckBox, 4, 0); animationGroupBox = new QGroupBox; animationGroupBox->setLayout(animationGrid); @@ -287,6 +292,7 @@ void UserInterfaceSettingsPage::setNotificationEnabled(QT_STATE_CHANGED_T i) void UserInterfaceSettingsPage::enableAllAnimations() { tapAnimationCheckBox.setChecked(true); + arrowDrawAnimationCheckBox.setChecked(true); lifeCounterAnimationsCheckBox.setChecked(true); battlefieldFlashCheckBox.setChecked(true); } @@ -294,6 +300,7 @@ void UserInterfaceSettingsPage::enableAllAnimations() void UserInterfaceSettingsPage::disableAllAnimations() { tapAnimationCheckBox.setChecked(false); + arrowDrawAnimationCheckBox.setChecked(false); lifeCounterAnimationsCheckBox.setChecked(false); battlefieldFlashCheckBox.setChecked(false); } @@ -343,6 +350,7 @@ void UserInterfaceSettingsPage::retranslateUi() enableAllAnimationsButton.setText(tr("&Enable all animations")); disableAllAnimationsButton.setText(tr("&Disable all animations")); tapAnimationCheckBox.setText(tr("&Tap/untap animation")); + arrowDrawAnimationCheckBox.setText(tr("&Arrow draw animation")); lifeCounterAnimationsCheckBox.setText(tr("Life counter flash")); battlefieldFlashCheckBox.setText(tr("Battlefield flash on damage")); deckEditorGroupBox->setTitle(tr("Deck editor/storage settings")); diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h index f18ab8ccf..2b9eba72c 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h @@ -40,6 +40,7 @@ private: QPushButton enableAllAnimationsButton; QPushButton disableAllAnimationsButton; QCheckBox tapAnimationCheckBox; + QCheckBox arrowDrawAnimationCheckBox; QCheckBox lifeCounterAnimationsCheckBox; QCheckBox battlefieldFlashCheckBox; QCheckBox openDeckInNewTabCheckBox; diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h index 3ee3d2aef..3f2cbbe8e 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h @@ -15,6 +15,7 @@ public: [[nodiscard]] virtual bool getIncludeRebalancedCards() const = 0; [[nodiscard]] virtual bool getPrintingSelectorNavigationButtonsVisible() const = 0; [[nodiscard]] virtual bool getTapAnimation() const = 0; + [[nodiscard]] virtual bool getArrowDrawAnimation() const = 0; [[nodiscard]] virtual bool getAutoRotateSidewaysLayoutCards() const = 0; [[nodiscard]] virtual bool getScaleCards() const = 0; [[nodiscard]] virtual int getStackCardOverlapPercent() const = 0; diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp index 6ec1af962..f528a7c4b 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp @@ -50,6 +50,11 @@ bool CardsDisplaySettings::getTapAnimation() const return getValue("tapAnimation", QString(), QString(), true).toBool(); } +bool CardsDisplaySettings::getArrowDrawAnimation() const +{ + return getValue("arrowDrawAnimation", QString(), QString(), true).toBool(); +} + bool CardsDisplaySettings::getAutoRotateSidewaysLayoutCards() const { return getValue("autoRotateSidewaysLayoutCards", QString(), QString(), true).toBool(); @@ -159,6 +164,11 @@ void CardsDisplaySettings::setTapAnimation(bool _tapAnimation) setValue(_tapAnimation, "tapAnimation"); } +void CardsDisplaySettings::setArrowDrawAnimation(bool _arrowDrawAnimation) +{ + setValue(_arrowDrawAnimation, "arrowDrawAnimation"); +} + void CardsDisplaySettings::setAutoRotateSidewaysLayoutCards(bool _autoRotateSidewaysLayoutCards) { setValue(_autoRotateSidewaysLayoutCards, "autoRotateSidewaysLayoutCards"); diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h index 15a3e3ff4..dbafa32ae 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h @@ -20,6 +20,7 @@ public: [[nodiscard]] bool getIncludeRebalancedCards() const override; [[nodiscard]] bool getPrintingSelectorNavigationButtonsVisible() const override; [[nodiscard]] bool getTapAnimation() const override; + [[nodiscard]] bool getArrowDrawAnimation() const override; [[nodiscard]] bool getAutoRotateSidewaysLayoutCards() const override; [[nodiscard]] bool getScaleCards() const override; [[nodiscard]] int getStackCardOverlapPercent() const override; @@ -40,6 +41,7 @@ public: void setIncludeRebalancedCards(bool _includeRebalancedCards); void setPrintingSelectorNavigationButtonsVisible(bool _navigationButtonsVisible); void setTapAnimation(bool _tapAnimation); + void setArrowDrawAnimation(bool _arrowDrawAnimation); void setAutoRotateSidewaysLayoutCards(bool _autoRotateSidewaysLayoutCards); void setCardScaling(bool _scaleCards); void setStackCardOverlapPercent(int _verticalCardOverlapPercent); diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index dfdad4780..041a60d6f 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -476,6 +476,12 @@ TEST_F(SettingsDefaultsTest, CardsDisplay_SampleHandSize_Default) ASSERT_EQ(s.getSampleHandSize(), 7); } +TEST_F(SettingsDefaultsTest, CardsDisplay_ArrowDrawAnimation_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getArrowDrawAnimation(), true); +} + // --- VisualDeckStorageSettings --- TEST_F(SettingsDefaultsTest, VisualDeckStorage_SortingOrder_Default)