Animate life changes with a counter flash and battlefield shimmer

- Life loss pulses the player's life counter in the theme accent color
- The battlefield table zone shimmers crimson on damage
- Respects the global + per-effect animation toggles
This commit is contained in:
Lukas Brübach 2026-08-11 01:42:59 +02:00
parent c7ad813619
commit 33f8616328
11 changed files with 238 additions and 8 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,9 @@ protected:
bool hovered = false; bool hovered = false;
bool useNameForShortcut; bool useNameForShortcut;
// Hook for subclasses that need per-value-change feedback (e.g. life-total flash).
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,12 @@ 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);
// design-game: taking damage shimmers the player's table zone crimson.
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,11 +1,15 @@
#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 "../../interface/theme_manager.h"
#include <QApplication>
#include <QDebug> #include <QDebug>
#include <QPainter> #include <QPainter>
#include <QPixmapCache> #include <QPixmapCache>
#include <QTimer>
#include <QtMath> #include <QtMath>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h> #include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
@ -21,8 +25,10 @@ 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; // design-game: life totals live in a fully-rounded translucent HUD pill
const qreal border = 1; // with an accent (Mana Green) border and mono numerals.
const int radius = 15;
const qreal border = 1.5;
QPainterPath path(QPointF(50 - border / 2, border / 2)); QPainterPath path(QPointF(50 - border / 2, border / 2));
path.lineTo(radius, border / 2); path.lineTo(radius, border / 2);
path.arcTo(border / 2, border / 2, 2 * radius, 2 * radius, 90, 90); path.arcTo(border / 2, border / 2, 2 * radius, 2 * radius, 90, 90);
@ -30,21 +36,64 @@ void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*
path.lineTo(50 - border / 2, 30 - border / 2); path.lineTo(50 - border / 2, 30 - border / 2);
path.closeSubpath(); path.closeSubpath();
QPen pen(QColor(100, 100, 100)); QPen pen(qApp->palette().highlight().color());
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(18, 24, 38, 200) : QColor(11, 14, 20, 184));
painter->drawPath(path); painter->drawPath(path);
QRectF translatedRect = path.controlPointRect(); QRectF translatedRect = path.controlPointRect();
QSize translatedSize = translatedRect.size().toSize(); QSize translatedSize = translatedRect.size().toSize();
QFont font("Serif"); QFont font = themeManager->monoFont();
font.setWeight(QFont::Bold); font.setWeight(QFont::Bold);
font.setPixelSize(qMax(qRound(translatedSize.height() / 1.3), 9)); font.setPixelSize(qMax(qRound(translatedSize.height() / 1.3), 9));
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(path);
painter->restore();
}
}
void PlayerCounter::onValueChanged(int oldValue, int newValue)
{
flashDelta = newValue - oldValue;
if (flashDelta == 0) {
return;
}
if (!SettingsCache::instance().userInterface().getAnimationsEnabled() ||
!SettingsCache::instance().userInterface().getLifeCounterAnimationsEnabled()) {
flashAlpha = 0.0;
return;
}
flashAlpha = 1.0;
if (flashTimer == nullptr) {
flashTimer = new QTimer(this);
connect(flashTimer, &QTimer::timeout, this, &PlayerCounter::flashTick);
}
flashTimer->start(50);
}
void PlayerCounter::flashTick()
{
flashAlpha -= 0.12;
if (flashAlpha <= 0.0) {
flashAlpha = 0.0;
flashTimer->stop();
}
update();
} }
PlayerTarget::PlayerTarget(PlayerLogic *_owner, QGraphicsItem *parentItem) PlayerTarget::PlayerTarget(PlayerLogic *_owner, QGraphicsItem *parentItem)

View file

@ -14,10 +14,22 @@
#include <QPixmap> #include <QPixmap>
class PlayerLogic; class PlayerLogic;
class QTimer;
class PlayerCounter : public AbstractCounter class PlayerCounter : public AbstractCounter
{ {
Q_OBJECT Q_OBJECT
protected:
void onValueChanged(int oldValue, int newValue) override;
private:
QTimer *flashTimer = nullptr;
qreal flashAlpha = 0.0;
int flashDelta = 0;
private slots:
void flashTick();
public: public:
PlayerCounter(CounterState *state, PlayerLogic *player, QGraphicsItem *parent); PlayerCounter(CounterState *state, PlayerLogic *player, QGraphicsItem *parent);
QRectF boundingRect() const override; QRectF boundingRect() const override;

View file

@ -12,6 +12,7 @@
#include <QGraphicsScene> #include <QGraphicsScene>
#include <QPainter> #include <QPainter>
#include <QTimer>
#include <libcockatrice/card/card_info.h> #include <libcockatrice/card/card_info.h>
#include <libcockatrice/protocol/pb/command_move_card.pb.h> #include <libcockatrice/protocol/pb/command_move_card.pb.h>
#include <libcockatrice/protocol/pb/command_set_card_attr.pb.h> #include <libcockatrice/protocol/pb/command_set_card_attr.pb.h>
@ -47,6 +48,31 @@ void TableZone::updateBg()
update(); update();
} }
void TableZone::triggerDamageShimmer()
{
// design-game: damage pulses the affected zone with a brief crimson
// shimmer so a hit reads as an event, not a dialog.
if (!SettingsCache::instance().userInterface().getAnimationsEnabled() ||
!SettingsCache::instance().userInterface().getBattlefieldFlashEnabled()) {
damageShimmerAlpha = 0.0;
return;
}
damageShimmerAlpha = 1.0;
if (damageShimmerTimer == nullptr) {
damageShimmerTimer = new QTimer(this);
connect(damageShimmerTimer, &QTimer::timeout, this, [this] {
damageShimmerAlpha -= 0.12;
if (damageShimmerAlpha <= 0.0) {
damageShimmerAlpha = 0.0;
damageShimmerTimer->stop();
}
update();
});
}
damageShimmerTimer->start(50);
}
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

@ -11,6 +11,8 @@
#include "../board/abstract_card_item.h" #include "../board/abstract_card_item.h"
#include "select_zone.h" #include "select_zone.h"
class QTimer;
/** /**
* @brief TableZone is the grid based rect where CardItems may be placed. * @brief TableZone is the grid based rect where CardItems may be placed.
* *
@ -184,10 +186,21 @@ public:
update(); update();
} }
/**
Flashes the table surface after a player loses life.
Wired up through AbstractCounter so the life total on the battlefield
glows when it changes.
*/
void triggerDamageShimmer();
private: private:
void paintZoneOutline(QPainter *painter); void paintZoneOutline(QPainter *painter);
void paintLandDivider(QPainter *painter); void paintLandDivider(QPainter *painter);
QTimer *damageShimmerTimer = nullptr;
qreal damageShimmerAlpha = 0.0;
/* /*
Calculates card stack widths so mapping functions work properly Calculates card stack widths so mapping functions work properly
*/ */

View file

@ -7,7 +7,8 @@
bool ThemeConfig::isEmpty() const bool ThemeConfig::isEmpty() const
{ {
return colorScheme.isEmpty() && styleName.isEmpty(); return colorScheme.isEmpty() && styleName.isEmpty() && displayFont.isEmpty() && bodyFont.isEmpty() &&
cardTitleFont.isEmpty() && monoFont.isEmpty();
} }
QString ThemeConfig::toIni() const QString ThemeConfig::toIni() const
@ -17,6 +18,21 @@ QString ThemeConfig::toIni() const
out += QString("ColorScheme = %1\n").arg(colorScheme.isEmpty() ? "System" : colorScheme); out += QString("ColorScheme = %1\n").arg(colorScheme.isEmpty() ? "System" : colorScheme);
out += "\n[Style]\n"; out += "\n[Style]\n";
out += QString("Name = %1\n").arg(styleName.isEmpty() ? "Default" : styleName); out += QString("Name = %1\n").arg(styleName.isEmpty() ? "Default" : styleName);
if (!displayFont.isEmpty() || !bodyFont.isEmpty() || !cardTitleFont.isEmpty() || !monoFont.isEmpty()) {
out += "\n[Typography]\n";
if (!displayFont.isEmpty()) {
out += QString("DisplayFont = %1\n").arg(displayFont);
}
if (!bodyFont.isEmpty()) {
out += QString("BodyFont = %1\n").arg(bodyFont);
}
if (!cardTitleFont.isEmpty()) {
out += QString("CardTitleFont = %1\n").arg(cardTitleFont);
}
if (!monoFont.isEmpty()) {
out += QString("MonoFont = %1\n").arg(monoFont);
}
}
return out; return out;
} }
@ -65,6 +81,16 @@ ThemeConfig ThemeConfig::fromThemeDir(const QString &themeDirPath)
if (key.compare("Name", Qt::CaseInsensitive) == 0) { if (key.compare("Name", Qt::CaseInsensitive) == 0) {
cfg.styleName = value; cfg.styleName = value;
} }
} else if (currentSection.compare("Typography", Qt::CaseInsensitive) == 0) {
if (key.compare("DisplayFont", Qt::CaseInsensitive) == 0) {
cfg.displayFont = value;
} else if (key.compare("BodyFont", Qt::CaseInsensitive) == 0) {
cfg.bodyFont = value;
} else if (key.compare("CardTitleFont", Qt::CaseInsensitive) == 0) {
cfg.cardTitleFont = value;
} else if (key.compare("MonoFont", Qt::CaseInsensitive) == 0) {
cfg.monoFont = value;
}
} }
} }

View file

@ -11,6 +11,13 @@ struct ThemeConfig
QString colorScheme; QString colorScheme;
QString styleName; QString styleName;
// Optional per-theme font families (see design-game.md). Empty = use the
// application default / the classic "Serif" hint for card titles.
QString displayFont;
QString bodyFont;
QString cardTitleFont;
QString monoFont;
bool isEmpty() const; bool isEmpty() const;
QString toIni() const; QString toIni() const;

View file

@ -99,6 +99,8 @@ ThemeManager::ThemeManager(QObject *parent) : QObject(parent)
} }
// Capture the untouched application palette before any theme is applied. // Capture the untouched application palette before any theme is applied.
defaultPalette = qApp->palette(); defaultPalette = qApp->palette();
// Capture the untouched application font before any theme is applied.
defaultFont = qApp->font();
ensureThemeDirectoryExists(); ensureThemeDirectoryExists();
#if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)) #if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0))
connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, [this] { connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, [this] {
@ -352,6 +354,16 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName,
// widget gets polished against the stale colours, requiring a second apply // widget gets polished against the stale colours, requiring a second apply
// to fully resolve. Setting palette first means setStyle's repolish cascade // to fully resolve. Setting palette first means setStyle's repolish cascade
// already sees the correct colours. // already sees the correct colours.
// The body font is applied on the same pass, before the repolish, so
// widgets are polished with the theme's typeface.
currentThemeConfig = themeCfg;
if (!themeCfg.bodyFont.isEmpty()) {
QFont bodyFont(themeCfg.bodyFont);
bodyFont.setStyleHint(QFont::SansSerif);
qApp->setFont(bodyFont);
} else {
qApp->setFont(defaultFont);
}
qApp->setPalette(base); qApp->setPalette(base);
qApp->setStyle(style); qApp->setStyle(style);
@ -475,3 +487,50 @@ QBrush ThemeManager::getExtraBgBrush(Role role, int zoneId)
return brushCache.value(zoneId); return brushCache.value(zoneId);
} }
QFont ThemeManager::cardTitleFont() const
{
QFont font;
if (currentThemeConfig.cardTitleFont.isEmpty()) {
// Classic behaviour: the platform serif, echoing printed cardstock.
font.setFamily(QStringLiteral("Serif"));
font.setStyleHint(QFont::Serif);
} else {
font.setFamily(currentThemeConfig.cardTitleFont);
}
return font;
}
QFont ThemeManager::monoFont() const
{
QFont font;
if (currentThemeConfig.monoFont.isEmpty()) {
// Classic behaviour: serif numerals on counters and the card back.
font.setFamily(QStringLiteral("Serif"));
font.setStyleHint(QFont::Serif);
} else {
font.setFamily(currentThemeConfig.monoFont);
font.setStyleHint(QFont::Monospace);
}
return font;
}
QFont ThemeManager::displayFont() const
{
QFont font;
if (currentThemeConfig.displayFont.isEmpty()) {
font.setFamily(QStringLiteral("sans-serif"));
font.setStyleHint(QFont::SansSerif);
} else {
font.setFamily(currentThemeConfig.displayFont);
}
return font;
}
QString ThemeManager::monoFontFamily() const
{
if (!currentThemeConfig.monoFont.isEmpty()) {
return currentThemeConfig.monoFont;
}
return QStringLiteral("monospace");
}

View file

@ -11,6 +11,7 @@
#include <QBrush> #include <QBrush>
#include <QDir> #include <QDir>
#include <QFont>
#include <QLoggingCategory> #include <QLoggingCategory>
#include <QMap> #include <QMap>
#include <QObject> #include <QObject>
@ -47,6 +48,12 @@ private:
// palette is applied. Used as the base when a theme supplies no palette, so // palette is applied. Used as the base when a theme supplies no palette, so
// switching away from a custom palette restores the original colours. // switching away from a custom palette restores the original colours.
QPalette defaultPalette; QPalette defaultPalette;
// Pristine application font captured at startup, restored when the active
// theme declares no body font of its own.
QFont defaultFont;
// ThemeConfig of the currently applied theme, used to resolve per-theme
// typography tokens.
ThemeConfig currentThemeConfig;
QString currentThemePath; QString currentThemePath;
std::array<QBrush, Role::MaxRole + 1> brushes; std::array<QBrush, Role::MaxRole + 1> brushes;
QStringMap availableThemes; QStringMap availableThemes;
@ -92,6 +99,14 @@ public:
QBrush &getBgBrush(Role zone); QBrush &getBgBrush(Role zone);
QBrush getExtraBgBrush(Role zone, int zoneId = 0); QBrush getExtraBgBrush(Role zone, int zoneId = 0);
// Per-theme typography helpers (see design-game.md). Falls back to the
// classic app defaults when the active theme declares no fonts, so
// non-CockatriceNG themes keep their current look.
QFont cardTitleFont() const;
QFont monoFont() const;
QFont displayFont() const;
QString monoFontFamily() const;
protected slots: protected slots:
void themeChangedSlot(); void themeChangedSlot();
signals: signals: