[Game] Animate life counter manipulation (#7100)

* Add animations toggles for the life counter and battlefield

Per-effect toggles plus Enable/Disable-all buttons: a life-counter
flash on loss and a crimson battlefield shimmer on damage.


Took 24 seconds

* Animate life changes with a counter flash and battlefield shimmer

- Life loss/gain pulses the player life counter with a brief flash
- The battlefield table zone shimmers crimson on damage
- Respects the per-effect animation toggles

Both effects drive their decay from GameScene's shared animation timer
through the IAnimatedItem interface (QElapsedTimer based), instead of
owning per-item QTimers.

Took 8 minutes

* Revert unintentional cherry picks

Took 2 minutes

* Lambda to re-use path calculation.

Took 8 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-08-13 19:12:12 +02:00 committed by GitHub
parent 12a5b34e42
commit 9d26e07165
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 196 additions and 12 deletions

View file

@ -29,8 +29,9 @@ AbstractCounter::AbstractCounter(CounterState *state,
{ {
setAcceptHoverEvents(true); setAcceptHoverEvents(true);
connect(state, &CounterState::valueChanged, this, [this](int, int newValue) { connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue) {
value = newValue; value = newValue;
onValueChanged(oldValue, newValue);
update(); update();
}); });
@ -228,3 +229,9 @@ void AbstractCounterDialog::changeValue(int diff)
curValue += diff; curValue += diff;
setTextValue(QString::number(curValue)); 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).
}

View file

@ -35,6 +35,13 @@ protected:
bool hovered = false; bool hovered = false;
bool useNameForShortcut; 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 mousePressEvent(QGraphicsSceneMouseEvent *event) override;
void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override; void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override;
void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override; void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override;

View file

@ -188,6 +188,11 @@ void PlayerGraphicsItem::onCounterAdded(CounterState *state)
AbstractCounter *widget; AbstractCounter *widget;
if (state->getName() == "life") { if (state->getName() == "life") {
widget = playerTarget->addCounter(state); widget = playerTarget->addCounter(state);
connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue) {
if (newValue < oldValue) {
tableZoneGraphicsItem->triggerDamageShimmer();
}
});
} else { } else {
widget = new GeneralCounter(state, player, true, this); widget = new GeneralCounter(state, player, true, this);
} }

View file

@ -1,8 +1,11 @@
#include "player_target.h" #include "player_target.h"
#include "../../client/settings/cache_settings.h"
#include "../../game/player/player_logic.h" #include "../../game/player/player_logic.h"
#include "../../interface/pixel_map_generator.h" #include "../../interface/pixel_map_generator.h"
#include "../game_scene.h"
#include <QApplication>
#include <QDebug> #include <QDebug>
#include <QPainter> #include <QPainter>
#include <QPixmapCache> #include <QPixmapCache>
@ -21,17 +24,24 @@ QRectF PlayerCounter::boundingRect() const
void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/)
{ {
const int radius = 8; const int radius = 15;
const qreal border = 1; const qreal border = 1.5;
QPainterPath path(QPointF(50 - border / 2, border / 2)); // The box is drawn with a border-wide stroke straddling the path, so the
path.lineTo(radius, border / 2); // visible outline spans [inset, inset + border]. Fills that must not cover
path.arcTo(border / 2, border / 2, 2 * radius, 2 * radius, 90, 90); // the outline (e.g. the life-change flash) use a path inset by `border`.
path.lineTo(border / 2, 30 - border / 2); const auto makePath = [radius](qreal inset) {
path.lineTo(50 - border / 2, 30 - border / 2); QPainterPath path(QPointF(50 - inset, inset));
path.closeSubpath(); 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)); QPen pen(QColor(100, 100, 100));
pen.setWidth(border); pen.setWidthF(border);
painter->setPen(pen); painter->setPen(pen);
painter->setBrush(hovered ? QColor(50, 50, 50, 160) : QColor(0, 0, 0, 160)); 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->setFont(font);
painter->setPen(Qt::white); painter->setPen(Qt::white);
painter->drawText(translatedRect, Qt::AlignCenter, QString::number(value)); 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<GameScene *>(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) PlayerTarget::PlayerTarget(PlayerLogic *_owner, QGraphicsItem *parentItem)

View file

@ -7,21 +7,34 @@
#ifndef PLAYERTARGET_H #ifndef PLAYERTARGET_H
#define PLAYERTARGET_H #define PLAYERTARGET_H
#include "../animated_item.h"
#include "../board/abstract_counter.h" #include "../board/abstract_counter.h"
#include "../board/arrow_target.h" #include "../board/arrow_target.h"
#include "../board/graphics_item_type.h" #include "../board/graphics_item_type.h"
#include <QElapsedTimer>
#include <QPixmap> #include <QPixmap>
class PlayerLogic; class PlayerLogic;
class PlayerCounter : public AbstractCounter class PlayerCounter : public AbstractCounter, public IAnimatedItem
{ {
Q_OBJECT 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: public:
PlayerCounter(CounterState *state, PlayerLogic *player, QGraphicsItem *parent); PlayerCounter(CounterState *state, PlayerLogic *player, QGraphicsItem *parent);
QRectF boundingRect() const override; QRectF boundingRect() const override;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
bool animationEvent() override;
}; };
class PlayerTarget : public ArrowTarget class PlayerTarget : public ArrowTarget

View file

@ -8,6 +8,7 @@
#include "../board/arrow_item.h" #include "../board/arrow_item.h"
#include "../board/card_drag_item.h" #include "../board/card_drag_item.h"
#include "../board/card_item.h" #include "../board/card_item.h"
#include "../game_scene.h"
#include "../z_values.h" #include "../z_values.h"
#include <QGraphicsScene> #include <QGraphicsScene>
@ -47,6 +48,31 @@ void TableZone::updateBg()
update(); update();
} }
void TableZone::triggerDamageShimmer()
{
if (!SettingsCache::instance().userInterface().getBattlefieldFlashEnabled()) {
damageShimmerAlpha = 0.0;
return;
}
damageShimmerAlpha = 1.0;
shimmerClock.start();
if (scene()) {
static_cast<GameScene *>(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 QRectF TableZone::boundingRect() const
{ {
return QRectF(0, 0, width, height); return QRectF(0, 0, width, height);
@ -77,6 +103,13 @@ void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*opti
painter->fillRect(boundingRect(), FADE_MASK); 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); paintLandDivider(painter);
} }

View file

@ -8,16 +8,19 @@
#define TABLEZONE_H #define TABLEZONE_H
#include "../../game/zones/table_zone_logic.h" #include "../../game/zones/table_zone_logic.h"
#include "../animated_item.h"
#include "../board/abstract_card_item.h" #include "../board/abstract_card_item.h"
#include "select_zone.h" #include "select_zone.h"
#include <QElapsedTimer>
/** /**
* @brief TableZone is the grid based rect where CardItems may be placed. * @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. * 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()). //! \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 Q_OBJECT
@ -121,6 +124,16 @@ public:
*/ */
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; 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. Toggles the selected items as tapped.
*/ */
@ -185,6 +198,11 @@ public:
} }
private: private:
static constexpr qreal shimmerDurationMs = 450.0;
QElapsedTimer shimmerClock;
qreal damageShimmerAlpha = 0.0;
void paintZoneOutline(QPainter *painter); void paintZoneOutline(QPainter *painter);
void paintLandDivider(QPainter *painter); void paintLandDivider(QPainter *painter);

View file

@ -116,6 +116,15 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
connect(&tapAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(), connect(&tapAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(),
&CardsDisplaySettings::setTapAnimation); &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(&enableAllAnimationsButton, &QPushButton::clicked, this, &UserInterfaceSettingsPage::enableAllAnimations);
connect(&disableAllAnimationsButton, &QPushButton::clicked, this, &UserInterfaceSettingsPage::disableAllAnimations); connect(&disableAllAnimationsButton, &QPushButton::clicked, this, &UserInterfaceSettingsPage::disableAllAnimations);
@ -123,6 +132,8 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
animationGrid->addWidget(&enableAllAnimationsButton, 0, 0); animationGrid->addWidget(&enableAllAnimationsButton, 0, 0);
animationGrid->addWidget(&disableAllAnimationsButton, 0, 1); animationGrid->addWidget(&disableAllAnimationsButton, 0, 1);
animationGrid->addWidget(&tapAnimationCheckBox, 1, 0); animationGrid->addWidget(&tapAnimationCheckBox, 1, 0);
animationGrid->addWidget(&lifeCounterAnimationsCheckBox, 2, 0);
animationGrid->addWidget(&battlefieldFlashCheckBox, 3, 0);
animationGroupBox = new QGroupBox; animationGroupBox = new QGroupBox;
animationGroupBox->setLayout(animationGrid); animationGroupBox->setLayout(animationGrid);
@ -276,11 +287,15 @@ void UserInterfaceSettingsPage::setNotificationEnabled(QT_STATE_CHANGED_T i)
void UserInterfaceSettingsPage::enableAllAnimations() void UserInterfaceSettingsPage::enableAllAnimations()
{ {
tapAnimationCheckBox.setChecked(true); tapAnimationCheckBox.setChecked(true);
lifeCounterAnimationsCheckBox.setChecked(true);
battlefieldFlashCheckBox.setChecked(true);
} }
void UserInterfaceSettingsPage::disableAllAnimations() void UserInterfaceSettingsPage::disableAllAnimations()
{ {
tapAnimationCheckBox.setChecked(false); tapAnimationCheckBox.setChecked(false);
lifeCounterAnimationsCheckBox.setChecked(false);
battlefieldFlashCheckBox.setChecked(false);
} }
void UserInterfaceSettingsPage::updateCommanderSpellbookUiState() void UserInterfaceSettingsPage::updateCommanderSpellbookUiState()
@ -328,6 +343,8 @@ void UserInterfaceSettingsPage::retranslateUi()
enableAllAnimationsButton.setText(tr("&Enable all animations")); enableAllAnimationsButton.setText(tr("&Enable all animations"));
disableAllAnimationsButton.setText(tr("&Disable all animations")); disableAllAnimationsButton.setText(tr("&Disable all animations"));
tapAnimationCheckBox.setText(tr("&Tap/untap animation")); 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")); deckEditorGroupBox->setTitle(tr("Deck editor/storage settings"));
openDeckInNewTabCheckBox.setText(tr("Open deck in new tab by default")); openDeckInNewTabCheckBox.setText(tr("Open deck in new tab by default"));
visualDeckStorageInGameCheckBox.setText(tr("Use visual deck storage in game lobby")); visualDeckStorageInGameCheckBox.setText(tr("Use visual deck storage in game lobby"));

View file

@ -40,6 +40,8 @@ private:
QPushButton enableAllAnimationsButton; QPushButton enableAllAnimationsButton;
QPushButton disableAllAnimationsButton; QPushButton disableAllAnimationsButton;
QCheckBox tapAnimationCheckBox; QCheckBox tapAnimationCheckBox;
QCheckBox lifeCounterAnimationsCheckBox;
QCheckBox battlefieldFlashCheckBox;
QCheckBox openDeckInNewTabCheckBox; QCheckBox openDeckInNewTabCheckBox;
QLabel visualDeckStoragePromptForConversionLabel; QLabel visualDeckStoragePromptForConversionLabel;
QComboBox visualDeckStoragePromptForConversionSelector; QComboBox visualDeckStoragePromptForConversionSelector;

View file

@ -39,6 +39,8 @@ public:
[[nodiscard]] virtual bool getShowStatusBar() const = 0; [[nodiscard]] virtual bool getShowStatusBar() const = 0;
[[nodiscard]] virtual bool getShowShortcuts() const = 0; [[nodiscard]] virtual bool getShowShortcuts() const = 0;
[[nodiscard]] virtual bool getShowGameSelectorFilterToolbar() 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 #endif // COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H

View file

@ -160,6 +160,16 @@ bool InterfaceSettings::getShowGameSelectorFilterToolbar() const
return getValue("showGameSelectorFilterToolbar", QString(), QString(), true).toBool(); 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) void InterfaceSettings::setUseTearOffMenus(bool _useTearOffMenus)
{ {
setValue(_useTearOffMenus, "useTearOffMenus"); setValue(_useTearOffMenus, "useTearOffMenus");
@ -326,3 +336,15 @@ void InterfaceSettings::setShowGameSelectorFilterToolbar(bool _showGameSelectorF
setValue(_showGameSelectorFilterToolbar, "showGameSelectorFilterToolbar"); setValue(_showGameSelectorFilterToolbar, "showGameSelectorFilterToolbar");
emit showGameSelectorFilterToolbarChanged(_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);
}

View file

@ -42,6 +42,8 @@ public:
[[nodiscard]] bool getShowStatusBar() const override; [[nodiscard]] bool getShowStatusBar() const override;
[[nodiscard]] bool getShowShortcuts() const override; [[nodiscard]] bool getShowShortcuts() const override;
[[nodiscard]] bool getShowGameSelectorFilterToolbar() const override; [[nodiscard]] bool getShowGameSelectorFilterToolbar() const override;
[[nodiscard]] bool getLifeCounterAnimationsEnabled() const override;
[[nodiscard]] bool getBattlefieldFlashEnabled() const override;
void setUseTearOffMenus(bool _useTearOffMenus); void setUseTearOffMenus(bool _useTearOffMenus);
void setCardViewInitialRowsMax(int _cardViewInitialRowsMax); void setCardViewInitialRowsMax(int _cardViewInitialRowsMax);
@ -74,6 +76,8 @@ public:
void setShowStatusBar(bool _showStatusBar); void setShowStatusBar(bool _showStatusBar);
void setShowShortcuts(bool _showShortcuts); void setShowShortcuts(bool _showShortcuts);
void setShowGameSelectorFilterToolbar(bool _showGameSelectorFilterToolbar); void setShowGameSelectorFilterToolbar(bool _showGameSelectorFilterToolbar);
void setLifeCounterAnimationsEnabled(bool _lifeCounterAnimationsEnabled);
void setBattlefieldFlashEnabled(bool _battlefieldFlashEnabled);
signals: signals:
void useTearOffMenusChanged(bool state); void useTearOffMenusChanged(bool state);
@ -85,6 +89,8 @@ signals:
void tallyTypeChanged(int type); void tallyTypeChanged(int type);
void showStatusBarChanged(bool state); void showStatusBarChanged(bool state);
void showGameSelectorFilterToolbarChanged(bool state); void showGameSelectorFilterToolbarChanged(bool state);
void lifeCounterAnimationsEnabledChanged(bool state);
void battlefieldFlashEnabledChanged(bool state);
public: public:
explicit InterfaceSettings(const QString &settingPath, QObject *parent = nullptr); explicit InterfaceSettings(const QString &settingPath, QObject *parent = nullptr);