mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-28 00:42:19 -07:00
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:
parent
c7ad813619
commit
33f8616328
11 changed files with 238 additions and 8 deletions
|
|
@ -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).
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ protected:
|
|||
bool hovered = false;
|
||||
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 hoverEnterEvent(QGraphicsSceneHoverEvent *event) override;
|
||||
void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override;
|
||||
|
|
|
|||
|
|
@ -188,6 +188,12 @@ void PlayerGraphicsItem::onCounterAdded(CounterState *state)
|
|||
AbstractCounter *widget;
|
||||
if (state->getName() == "life") {
|
||||
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 {
|
||||
widget = new GeneralCounter(state, player, true, this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
#include "player_target.h"
|
||||
|
||||
#include "../../client/settings/cache_settings.h"
|
||||
#include "../../game/player/player_logic.h"
|
||||
#include "../../interface/pixel_map_generator.h"
|
||||
#include "../../interface/theme_manager.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QDebug>
|
||||
#include <QPainter>
|
||||
#include <QPixmapCache>
|
||||
#include <QTimer>
|
||||
#include <QtMath>
|
||||
#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*/)
|
||||
{
|
||||
const int radius = 8;
|
||||
const qreal border = 1;
|
||||
// design-game: life totals live in a fully-rounded translucent HUD pill
|
||||
// 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));
|
||||
path.lineTo(radius, border / 2);
|
||||
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.closeSubpath();
|
||||
|
||||
QPen pen(QColor(100, 100, 100));
|
||||
pen.setWidth(border);
|
||||
QPen pen(qApp->palette().highlight().color());
|
||||
pen.setWidthF(border);
|
||||
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);
|
||||
|
||||
QRectF translatedRect = path.controlPointRect();
|
||||
QSize translatedSize = translatedRect.size().toSize();
|
||||
QFont font("Serif");
|
||||
QFont font = themeManager->monoFont();
|
||||
font.setWeight(QFont::Bold);
|
||||
font.setPixelSize(qMax(qRound(translatedSize.height() / 1.3), 9));
|
||||
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(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)
|
||||
|
|
|
|||
|
|
@ -14,10 +14,22 @@
|
|||
#include <QPixmap>
|
||||
|
||||
class PlayerLogic;
|
||||
class QTimer;
|
||||
|
||||
class PlayerCounter : public AbstractCounter
|
||||
{
|
||||
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:
|
||||
PlayerCounter(CounterState *state, PlayerLogic *player, QGraphicsItem *parent);
|
||||
QRectF boundingRect() const override;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
#include <QGraphicsScene>
|
||||
#include <QPainter>
|
||||
#include <QTimer>
|
||||
#include <libcockatrice/card/card_info.h>
|
||||
#include <libcockatrice/protocol/pb/command_move_card.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_set_card_attr.pb.h>
|
||||
|
|
@ -47,6 +48,31 @@ void TableZone::updateBg()
|
|||
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
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
#include "../board/abstract_card_item.h"
|
||||
#include "select_zone.h"
|
||||
|
||||
class QTimer;
|
||||
|
||||
/**
|
||||
* @brief TableZone is the grid based rect where CardItems may be placed.
|
||||
*
|
||||
|
|
@ -184,10 +186,21 @@ public:
|
|||
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:
|
||||
void paintZoneOutline(QPainter *painter);
|
||||
void paintLandDivider(QPainter *painter);
|
||||
|
||||
QTimer *damageShimmerTimer = nullptr;
|
||||
qreal damageShimmerAlpha = 0.0;
|
||||
|
||||
/*
|
||||
Calculates card stack widths so mapping functions work properly
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
|
||||
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
|
||||
|
|
@ -17,6 +18,21 @@ QString ThemeConfig::toIni() const
|
|||
out += QString("ColorScheme = %1\n").arg(colorScheme.isEmpty() ? "System" : colorScheme);
|
||||
out += "\n[Style]\n";
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +81,16 @@ ThemeConfig ThemeConfig::fromThemeDir(const QString &themeDirPath)
|
|||
if (key.compare("Name", Qt::CaseInsensitive) == 0) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ struct ThemeConfig
|
|||
QString colorScheme;
|
||||
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;
|
||||
QString toIni() const;
|
||||
|
||||
|
|
|
|||
|
|
@ -99,6 +99,8 @@ ThemeManager::ThemeManager(QObject *parent) : QObject(parent)
|
|||
}
|
||||
// Capture the untouched application palette before any theme is applied.
|
||||
defaultPalette = qApp->palette();
|
||||
// Capture the untouched application font before any theme is applied.
|
||||
defaultFont = qApp->font();
|
||||
ensureThemeDirectoryExists();
|
||||
#if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0))
|
||||
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
|
||||
// to fully resolve. Setting palette first means setStyle's repolish cascade
|
||||
// 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->setStyle(style);
|
||||
|
||||
|
|
@ -475,3 +487,50 @@ QBrush ThemeManager::getExtraBgBrush(Role role, int 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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
#include <QBrush>
|
||||
#include <QDir>
|
||||
#include <QFont>
|
||||
#include <QLoggingCategory>
|
||||
#include <QMap>
|
||||
#include <QObject>
|
||||
|
|
@ -47,6 +48,12 @@ private:
|
|||
// palette is applied. Used as the base when a theme supplies no palette, so
|
||||
// switching away from a custom palette restores the original colours.
|
||||
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;
|
||||
std::array<QBrush, Role::MaxRole + 1> brushes;
|
||||
QStringMap availableThemes;
|
||||
|
|
@ -92,6 +99,14 @@ public:
|
|||
|
||||
QBrush &getBgBrush(Role zone);
|
||||
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:
|
||||
void themeChangedSlot();
|
||||
signals:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue