diff --git a/cockatrice/src/game_graphics/board/abstract_counter.cpp b/cockatrice/src/game_graphics/board/abstract_counter.cpp index a20fb1b3c..e63117e13 100644 --- a/cockatrice/src/game_graphics/board/abstract_counter.cpp +++ b/cockatrice/src/game_graphics/board/abstract_counter.cpp @@ -29,8 +29,9 @@ AbstractCounter::AbstractCounter(CounterState *state, { setAcceptHoverEvents(true); - connect(state, &CounterState::valueChanged, this, [this](int, int newValue) { + connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue) { value = newValue; + onValueChanged(oldValue, newValue); update(); }); @@ -228,3 +229,9 @@ void AbstractCounterDialog::changeValue(int diff) curValue += diff; setTextValue(QString::number(curValue)); } + +void AbstractCounter::onValueChanged(int /*oldValue*/, int /*newValue*/) +{ + // Default: no feedback. Subclasses such as PlayerCounter override this to + // flash the counter on meaningful changes (life gain/loss). +} diff --git a/cockatrice/src/game_graphics/board/abstract_counter.h b/cockatrice/src/game_graphics/board/abstract_counter.h index b319a722d..9ddcc6d58 100644 --- a/cockatrice/src/game_graphics/board/abstract_counter.h +++ b/cockatrice/src/game_graphics/board/abstract_counter.h @@ -35,6 +35,13 @@ protected: bool hovered = false; bool useNameForShortcut; + /** + * @brief Hook for subclasses that need per-value-change feedback (e.g. life-total flash). + * + * Called whenever the counter's value changes, before the item repaints. + */ + virtual void onValueChanged(int oldValue, int newValue); + void mousePressEvent(QGraphicsSceneMouseEvent *event) override; void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override; void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override; diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp index d443853ce..2831f3393 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp +++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp @@ -188,6 +188,11 @@ void PlayerGraphicsItem::onCounterAdded(CounterState *state) AbstractCounter *widget; if (state->getName() == "life") { widget = playerTarget->addCounter(state); + connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue) { + if (newValue < oldValue) { + tableZoneGraphicsItem->triggerDamageShimmer(); + } + }); } else { widget = new GeneralCounter(state, player, true, this); } diff --git a/cockatrice/src/game_graphics/player/player_target.cpp b/cockatrice/src/game_graphics/player/player_target.cpp index 567f3d44d..105a4a862 100644 --- a/cockatrice/src/game_graphics/player/player_target.cpp +++ b/cockatrice/src/game_graphics/player/player_target.cpp @@ -1,8 +1,11 @@ #include "player_target.h" +#include "../../client/settings/cache_settings.h" #include "../../game/player/player_logic.h" #include "../../interface/pixel_map_generator.h" +#include "../game_scene.h" +#include #include #include #include @@ -21,17 +24,24 @@ QRectF PlayerCounter::boundingRect() const void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) { - const int radius = 8; - const qreal border = 1; - QPainterPath path(QPointF(50 - border / 2, border / 2)); - path.lineTo(radius, border / 2); - path.arcTo(border / 2, border / 2, 2 * radius, 2 * radius, 90, 90); - path.lineTo(border / 2, 30 - border / 2); - path.lineTo(50 - border / 2, 30 - border / 2); - path.closeSubpath(); + const int radius = 15; + const qreal border = 1.5; + // The box is drawn with a border-wide stroke straddling the path, so the + // visible outline spans [inset, inset + border]. Fills that must not cover + // the outline (e.g. the life-change flash) use a path inset by `border`. + const auto makePath = [radius](qreal inset) { + QPainterPath path(QPointF(50 - inset, inset)); + path.lineTo(radius, inset); + path.arcTo(inset, inset, 2 * radius, 2 * radius, 90, 90); + path.lineTo(inset, 30 - inset); + path.lineTo(50 - inset, 30 - inset); + path.closeSubpath(); + return path; + }; + QPainterPath path = makePath(border / 2); QPen pen(QColor(100, 100, 100)); - pen.setWidth(border); + pen.setWidthF(border); painter->setPen(pen); painter->setBrush(hovered ? QColor(50, 50, 50, 160) : QColor(0, 0, 0, 160)); @@ -45,6 +55,48 @@ void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /* painter->setFont(font); painter->setPen(Qt::white); painter->drawText(translatedRect, Qt::AlignCenter, QString::number(value)); + + // Life-change flash: emerald on gain, red on loss, decaying over a few ticks. + if (flashAlpha > 0) { + painter->save(); + QColor flashColor = flashDelta > 0 ? QColor(52, 224, 122) : QColor(239, 68, 68); + flashColor.setAlphaF(0.45 * flashAlpha); + painter->setPen(Qt::NoPen); + painter->setBrush(flashColor); + painter->setOpacity(0.85); + painter->drawPath(makePath(border)); + painter->restore(); + } +} + +void PlayerCounter::onValueChanged(int oldValue, int newValue) +{ + flashDelta = newValue - oldValue; + if (flashDelta == 0) { + return; + } + + if (!SettingsCache::instance().userInterface().getLifeCounterAnimationsEnabled()) { + flashAlpha = 0.0; + return; + } + + flashAlpha = 1.0; + flashClock.start(); + if (scene()) { + static_cast(scene())->registerAnimationItem(this); + } +} + +bool PlayerCounter::animationEvent() +{ + flashAlpha = 1.0 - flashClock.elapsed() / flashDurationMs; + if (flashAlpha <= 0.0) { + flashAlpha = 0.0; + return false; + } + update(); + return true; } PlayerTarget::PlayerTarget(PlayerLogic *_owner, QGraphicsItem *parentItem) diff --git a/cockatrice/src/game_graphics/player/player_target.h b/cockatrice/src/game_graphics/player/player_target.h index 67e155660..af0e9c8b7 100644 --- a/cockatrice/src/game_graphics/player/player_target.h +++ b/cockatrice/src/game_graphics/player/player_target.h @@ -7,21 +7,34 @@ #ifndef PLAYERTARGET_H #define PLAYERTARGET_H +#include "../animated_item.h" #include "../board/abstract_counter.h" #include "../board/arrow_target.h" #include "../board/graphics_item_type.h" +#include #include class PlayerLogic; -class PlayerCounter : public AbstractCounter +class PlayerCounter : public AbstractCounter, public IAnimatedItem { Q_OBJECT +protected: + void onValueChanged(int oldValue, int newValue) override; + +private: + static constexpr qreal flashDurationMs = 450.0; + + QElapsedTimer flashClock; + qreal flashAlpha = 0.0; + int flashDelta = 0; + public: PlayerCounter(CounterState *state, PlayerLogic *player, QGraphicsItem *parent); QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; + bool animationEvent() override; }; class PlayerTarget : public ArrowTarget diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index 4ef01853f..306e2927e 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -8,6 +8,7 @@ #include "../board/arrow_item.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" +#include "../game_scene.h" #include "../z_values.h" #include @@ -47,6 +48,31 @@ void TableZone::updateBg() update(); } +void TableZone::triggerDamageShimmer() +{ + if (!SettingsCache::instance().userInterface().getBattlefieldFlashEnabled()) { + damageShimmerAlpha = 0.0; + return; + } + + damageShimmerAlpha = 1.0; + shimmerClock.start(); + if (scene()) { + static_cast(scene())->registerAnimationItem(this); + } +} + +bool TableZone::animationEvent() +{ + damageShimmerAlpha = 1.0 - shimmerClock.elapsed() / shimmerDurationMs; + if (damageShimmerAlpha <= 0.0) { + damageShimmerAlpha = 0.0; + return false; + } + update(); + return true; +} + QRectF TableZone::boundingRect() const { return QRectF(0, 0, width, height); @@ -77,6 +103,13 @@ void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*opti painter->fillRect(boundingRect(), FADE_MASK); } + // Decaying crimson wash from taking damage. + if (damageShimmerAlpha > 0.0) { + QColor shimmerColor(239, 68, 68); + shimmerColor.setAlphaF(0.22 * damageShimmerAlpha); + painter->fillRect(boundingRect(), shimmerColor); + } + paintLandDivider(painter); } diff --git a/cockatrice/src/game_graphics/zones/table_zone.h b/cockatrice/src/game_graphics/zones/table_zone.h index 0d7e58206..1836c96ff 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.h +++ b/cockatrice/src/game_graphics/zones/table_zone.h @@ -8,16 +8,19 @@ #define TABLEZONE_H #include "../../game/zones/table_zone_logic.h" +#include "../animated_item.h" #include "../board/abstract_card_item.h" #include "select_zone.h" +#include + /** * @brief TableZone is the grid based rect where CardItems may be placed. * * It is the main play zone and can be customized with background images. */ //! \todo Refactor methods to make more readable, extract logic to private methods (especially reorganizeCards()). -class TableZone : public SelectZone +class TableZone : public SelectZone, public IAnimatedItem { Q_OBJECT @@ -121,6 +124,16 @@ public: */ void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; + /** + Flashes the table surface after a player loses life. + + Wired up through the life counter so the battlefield glows when life drops. + */ + void triggerDamageShimmer(); + + /** @brief Decays the damage shimmer by one timer tick. */ + bool animationEvent() override; + /** Toggles the selected items as tapped. */ @@ -185,6 +198,11 @@ public: } private: + static constexpr qreal shimmerDurationMs = 450.0; + + QElapsedTimer shimmerClock; + qreal damageShimmerAlpha = 0.0; + void paintZoneOutline(QPainter *painter); void paintLandDivider(QPainter *painter); 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 fa6de81c2..3fa56dd48 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,15 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() connect(&tapAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::setTapAnimation); + lifeCounterAnimationsCheckBox.setChecked( + SettingsCache::instance().userInterface().getLifeCounterAnimationsEnabled()); + connect(&lifeCounterAnimationsCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setLifeCounterAnimationsEnabled); + + battlefieldFlashCheckBox.setChecked(SettingsCache::instance().userInterface().getBattlefieldFlashEnabled()); + connect(&battlefieldFlashCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setBattlefieldFlashEnabled); + connect(&enableAllAnimationsButton, &QPushButton::clicked, this, &UserInterfaceSettingsPage::enableAllAnimations); connect(&disableAllAnimationsButton, &QPushButton::clicked, this, &UserInterfaceSettingsPage::disableAllAnimations); @@ -123,6 +132,8 @@ 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); animationGroupBox = new QGroupBox; animationGroupBox->setLayout(animationGrid); @@ -276,11 +287,15 @@ void UserInterfaceSettingsPage::setNotificationEnabled(QT_STATE_CHANGED_T i) void UserInterfaceSettingsPage::enableAllAnimations() { tapAnimationCheckBox.setChecked(true); + lifeCounterAnimationsCheckBox.setChecked(true); + battlefieldFlashCheckBox.setChecked(true); } void UserInterfaceSettingsPage::disableAllAnimations() { tapAnimationCheckBox.setChecked(false); + lifeCounterAnimationsCheckBox.setChecked(false); + battlefieldFlashCheckBox.setChecked(false); } void UserInterfaceSettingsPage::updateCommanderSpellbookUiState() @@ -328,6 +343,8 @@ void UserInterfaceSettingsPage::retranslateUi() enableAllAnimationsButton.setText(tr("&Enable all animations")); disableAllAnimationsButton.setText(tr("&Disable all animations")); tapAnimationCheckBox.setText(tr("&Tap/untap animation")); + lifeCounterAnimationsCheckBox.setText(tr("Life counter flash")); + battlefieldFlashCheckBox.setText(tr("Battlefield flash on damage")); deckEditorGroupBox->setTitle(tr("Deck editor/storage settings")); openDeckInNewTabCheckBox.setText(tr("Open deck in new tab by default")); visualDeckStorageInGameCheckBox.setText(tr("Use visual deck storage in game lobby")); 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 f98e723b8..f18ab8ccf 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,8 @@ private: QPushButton enableAllAnimationsButton; QPushButton disableAllAnimationsButton; QCheckBox tapAnimationCheckBox; + QCheckBox lifeCounterAnimationsCheckBox; + QCheckBox battlefieldFlashCheckBox; QCheckBox openDeckInNewTabCheckBox; QLabel visualDeckStoragePromptForConversionLabel; QComboBox visualDeckStoragePromptForConversionSelector; diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h index ab2caa0d7..1f75d3d33 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h @@ -39,6 +39,8 @@ public: [[nodiscard]] virtual bool getShowStatusBar() const = 0; [[nodiscard]] virtual bool getShowShortcuts() const = 0; [[nodiscard]] virtual bool getShowGameSelectorFilterToolbar() const = 0; + [[nodiscard]] virtual bool getLifeCounterAnimationsEnabled() const = 0; + [[nodiscard]] virtual bool getBattlefieldFlashEnabled() const = 0; }; #endif // COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H diff --git a/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp index 29c57c57e..4dfc26417 100644 --- a/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp @@ -160,6 +160,16 @@ bool InterfaceSettings::getShowGameSelectorFilterToolbar() const return getValue("showGameSelectorFilterToolbar", QString(), QString(), true).toBool(); } +bool InterfaceSettings::getLifeCounterAnimationsEnabled() const +{ + return getValue("lifeCounterAnimationsEnabled", QString(), QString(), true).toBool(); +} + +bool InterfaceSettings::getBattlefieldFlashEnabled() const +{ + return getValue("battlefieldFlashEnabled", QString(), QString(), true).toBool(); +} + void InterfaceSettings::setUseTearOffMenus(bool _useTearOffMenus) { setValue(_useTearOffMenus, "useTearOffMenus"); @@ -326,3 +336,15 @@ void InterfaceSettings::setShowGameSelectorFilterToolbar(bool _showGameSelectorF setValue(_showGameSelectorFilterToolbar, "showGameSelectorFilterToolbar"); emit showGameSelectorFilterToolbarChanged(_showGameSelectorFilterToolbar); } + +void InterfaceSettings::setLifeCounterAnimationsEnabled(bool _lifeCounterAnimationsEnabled) +{ + setValue(_lifeCounterAnimationsEnabled, "lifeCounterAnimationsEnabled"); + emit lifeCounterAnimationsEnabledChanged(_lifeCounterAnimationsEnabled); +} + +void InterfaceSettings::setBattlefieldFlashEnabled(bool _battlefieldFlashEnabled) +{ + setValue(_battlefieldFlashEnabled, "battlefieldFlashEnabled"); + emit battlefieldFlashEnabledChanged(_battlefieldFlashEnabled); +} diff --git a/libcockatrice_settings/libcockatrice/settings/interface_settings.h b/libcockatrice_settings/libcockatrice/settings/interface_settings.h index 982976310..df254eb09 100644 --- a/libcockatrice_settings/libcockatrice/settings/interface_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.h @@ -42,6 +42,8 @@ public: [[nodiscard]] bool getShowStatusBar() const override; [[nodiscard]] bool getShowShortcuts() const override; [[nodiscard]] bool getShowGameSelectorFilterToolbar() const override; + [[nodiscard]] bool getLifeCounterAnimationsEnabled() const override; + [[nodiscard]] bool getBattlefieldFlashEnabled() const override; void setUseTearOffMenus(bool _useTearOffMenus); void setCardViewInitialRowsMax(int _cardViewInitialRowsMax); @@ -74,6 +76,8 @@ public: void setShowStatusBar(bool _showStatusBar); void setShowShortcuts(bool _showShortcuts); void setShowGameSelectorFilterToolbar(bool _showGameSelectorFilterToolbar); + void setLifeCounterAnimationsEnabled(bool _lifeCounterAnimationsEnabled); + void setBattlefieldFlashEnabled(bool _battlefieldFlashEnabled); signals: void useTearOffMenusChanged(bool state); @@ -85,6 +89,8 @@ signals: void tallyTypeChanged(int type); void showStatusBarChanged(bool state); void showGameSelectorFilterToolbarChanged(bool state); + void lifeCounterAnimationsEnabledChanged(bool state); + void battlefieldFlashEnabledChanged(bool state); public: explicit InterfaceSettings(const QString &settingPath, QObject *parent = nullptr);