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..6a332716d 100644 --- a/cockatrice/src/game_graphics/board/abstract_counter.h +++ b/cockatrice/src/game_graphics/board/abstract_counter.h @@ -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; diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp index d443853ce..9b2e4d998 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp +++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp @@ -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); } diff --git a/cockatrice/src/game_graphics/player/player_target.cpp b/cockatrice/src/game_graphics/player/player_target.cpp index 567f3d44d..094cf6cf4 100644 --- a/cockatrice/src/game_graphics/player/player_target.cpp +++ b/cockatrice/src/game_graphics/player/player_target.cpp @@ -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 #include #include #include +#include #include #include @@ -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) diff --git a/cockatrice/src/game_graphics/player/player_target.h b/cockatrice/src/game_graphics/player/player_target.h index 67e155660..d38c7b100 100644 --- a/cockatrice/src/game_graphics/player/player_target.h +++ b/cockatrice/src/game_graphics/player/player_target.h @@ -14,10 +14,22 @@ #include 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; diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index 4ef01853f..22795a028 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -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); } diff --git a/cockatrice/src/game_graphics/zones/table_zone.h b/cockatrice/src/game_graphics/zones/table_zone.h index 0d7e58206..382085e18 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.h +++ b/cockatrice/src/game_graphics/zones/table_zone.h @@ -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 */ diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index 3c43c467d..8fbd62289 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -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; + } } } diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 07bf55b7a..4d37c57e4 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -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; diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 518a97bc6..485d43180 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -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"); +} diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index 861ab838b..eff5b065c 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -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 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: