[Game] Smooth board hover-zoom with fade helpers

This commit is contained in:
Lukas Brübach 2026-10-14 19:20:37 +02:00 • committed by Lukas Brübach
parent 92f05fd363
commit 63d64d6578
4 changed files with 145 additions and 4 deletions

View file

@ -21,6 +21,7 @@
#include <QFont> #include <QFont>
#include <QGraphicsScene> #include <QGraphicsScene>
#include <QGraphicsSceneMouseEvent> #include <QGraphicsSceneMouseEvent>
#include <QGraphicsView>
#include <QHash> #include <QHash>
#include <QLineF> #include <QLineF>
#include <QList> #include <QList>
@ -29,6 +30,7 @@
#include <QPen> #include <QPen>
#include <QPixmap> #include <QPixmap>
#include <QPointF> #include <QPointF>
#include <QPropertyAnimation>
#include <QRectF> #include <QRectF>
#include <QTimer> #include <QTimer>
#include <QTransform> #include <QTransform>
@ -42,6 +44,13 @@
class QSizeF; class QSizeF;
// Width the hovered card smooth-zooms to: exactly the hover panel picture size, so
// all four corners coincide when the panel swaps in over it.
static constexpr qreal HOVER_ZOOM_WIDTH = 270.0;
static constexpr int HOVER_ZOOM_IN_MS = 220;
static constexpr int HOVER_ZOOM_OUT_MS = 140;
static constexpr int FADE_DURATION_MS = 140;
AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id) AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id)
: ArrowTarget(_owner, parent), state(new CardState(this, nullptr, cardRef, _id)), tapAngle(0), : ArrowTarget(_owner, parent), state(new CardState(this, nullptr, cardRef, _id)), tapAngle(0),
bgColor(Qt::transparent), isHovered(false), realZValue(0) bgColor(Qt::transparent), isHovered(false), realZValue(0)
@ -54,12 +63,36 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef
connect(state, &CardState::cardInfoChanged, this, &AbstractCardItem::onCardInfoChanged); connect(state, &CardState::cardInfoChanged, this, &AbstractCardItem::onCardInfoChanged);
connect(state, &CardState::cardRefChanged, this, &AbstractCardItem::onCardRefChanged); connect(state, &CardState::cardRefChanged, this, &AbstractCardItem::onCardRefChanged);
connect(state, &CardState::tappedChanged, this, &AbstractCardItem::onTappedChanged); connect(state, &CardState::tappedChanged, this, &AbstractCardItem::onTappedChanged);
connect(state, &CardState::facedownChanged, this, [this] { update(); }); connect(state, &CardState::facedownChanged, this, [this](bool faceDown) {
update();
emit faceDownChanged(faceDown);
});
connect(state, &CardState::colorChanged, this, [this] { connect(state, &CardState::colorChanged, this, [this] {
cacheBgColor(); cacheBgColor();
update(); update();
}); });
hoverZoom = new QPropertyAnimation(this, "cardScale", this);
hoverZoom->setEasingCurve(QEasingCurve::InOutCubic);
// Settling the unhover waits for the shrink-out to finish: the stacking override,
// the clip escape and the ancestor lift all stay put while the card is still large,
// so siblings and the clip container cannot cut through the shrinking card.
connect(hoverZoom, &QPropertyAnimation::finished, this, [this] {
if (!isHovered) {
setZValue(realZValue);
// Final repaint at the settled footprint: with no scale delta the animation
// emits no per-frame updates, and zones would keep a stale clip hole behind.
if (scene()) {
scene()->update(sceneBoundingRect());
}
emit hoverShrinkFinished(this);
}
});
opacityAnim = new QPropertyAnimation(this, "cardOpacity", this);
opacityAnim->setDuration(FADE_DURATION_MS);
opacityAnim->setEasingCurve(QEasingCurve::OutCubic);
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this, connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this,
[this] { update(); }); [this] { update(); });
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
@ -262,9 +295,38 @@ void AbstractCardItem::setHovered(bool _hovered)
} }
isHovered = _hovered; isHovered = _hovered;
setZValue(_hovered ? ZValues::HOVERED_CARD : realZValue); // The stacking override is one-directional: it applies the moment the hover starts
setScale(_hovered && SettingsCache::instance().cardsDisplay().getScaleCards() ? 1.1 : 1); // and only drops once the shrink-out finishes (see the hoverZoom finished handler).
setTransformOriginPoint(_hovered ? CardDimensions::WIDTH_HALF_F : 0, _hovered ? CardDimensions::HEIGHT_HALF_F : 0); // Restoring it here would let siblings paint over the still-enlarged card.
if (_hovered) {
setZValue(ZValues::HOVERED_CARD);
}
// Scale about the center in both directions: at scale 1 the origin is moot,
// and keeping it centered avoids drift while shrinking back down.
setTransformOriginPoint(CardDimensions::WIDTH_HALF_F, CardDimensions::HEIGHT_HALF_F);
// Smooth-zoom up to the hover picture size (instead of the old 1.1 snap) so the
// hover panel can then swap in over the zoomed card with matching corners.
// The target is constant ON-SCREEN pixels: divide out the board view zoom so
// fullscreen (or zoomed) boards don't overshoot while the fixed-size panel matches.
qreal target = 1.0;
if (_hovered && SettingsCache::instance().cardsDisplay().getScaleCards()) {
const qreal w = boundingRect().width();
qreal viewScale = 1.0;
if (scene() && !scene()->views().isEmpty()) {
if (const auto *view = scene()->views().first()) {
viewScale = qAbs(view->transform().m11());
}
}
if (w > 0.0 && viewScale > 0.0) {
target = qBound(1.0, HOVER_ZOOM_WIDTH / (w * viewScale), 6.0);
}
}
hoverZoom->stop();
hoverZoom->setStartValue(scale());
hoverZoom->setEndValue(target);
hoverZoom->setDuration(_hovered ? HOVER_ZOOM_IN_MS : HOVER_ZOOM_OUT_MS);
hoverZoom->start();
update(); update();
} }
@ -273,6 +335,59 @@ void AbstractCardItem::setColor(const QString &_color)
state->setColor(_color); state->setColor(_color);
} }
qreal AbstractCardItem::getCardScale() const
{
return scale();
}
void AbstractCardItem::setCardScale(qreal _scale)
{
if (qFuzzyCompare(_scale, scale())) {
return;
}
// The scale animates every frame: keep the scene index in step or hover
// picking tests a stale rect and the hover oscillates on/off by itself.
// Repaint the union of the old and new footprints, not just the card: zones
// dodge the zoomed card via a clip hole computed at paint time, and without
// a repaint they keep a stale hole — covering the card mid-animation and
// leaving a black rect behind once the hover ends.
const QRectF oldFootprint = sceneBoundingRect();
prepareGeometryChange();
setScale(_scale);
if (scene()) {
scene()->update(oldFootprint.united(sceneBoundingRect()));
} else {
update();
}
}
qreal AbstractCardItem::getCardOpacity() const
{
return opacity();
}
void AbstractCardItem::setCardOpacity(qreal _opacity)
{
setOpacity(_opacity);
update();
}
void AbstractCardItem::fadeCardOut()
{
opacityAnim->stop();
opacityAnim->setStartValue(opacity());
opacityAnim->setEndValue(0.0);
opacityAnim->start();
}
void AbstractCardItem::fadeCardIn()
{
opacityAnim->stop();
opacityAnim->setStartValue(opacity());
opacityAnim->setEndValue(1.0);
opacityAnim->start();
}
void AbstractCardItem::cacheBgColor() void AbstractCardItem::cacheBgColor()
{ {
QChar colorChar; QChar colorChar;
@ -310,6 +425,7 @@ void AbstractCardItem::cacheBgColor()
void AbstractCardItem::onTappedChanged(bool newTapped, bool canAnimate) void AbstractCardItem::onTappedChanged(bool newTapped, bool canAnimate)
{ {
emit tappedChanged(newTapped);
if (SettingsCache::instance().cardsDisplay().getTapAnimation() && canAnimate) { if (SettingsCache::instance().cardsDisplay().getTapAnimation() && canAnimate) {
static_cast<GameScene *>(scene())->registerAnimationItem(this); static_cast<GameScene *>(scene())->registerAnimationItem(this);
} else { } else {

View file

@ -23,10 +23,13 @@ class PlayerLogic;
class CardInfo; class CardInfo;
class QPainter; class QPainter;
class QPoint; class QPoint;
class QPropertyAnimation;
class AbstractCardItem : public ArrowTarget, public IAnimatedItem class AbstractCardItem : public ArrowTarget, public IAnimatedItem
{ {
Q_OBJECT Q_OBJECT
Q_PROPERTY(qreal cardScale READ getCardScale WRITE setCardScale)
Q_PROPERTY(qreal cardOpacity READ getCardOpacity WRITE setCardOpacity)
protected: protected:
CardState *state; CardState *state;
int tapAngle; int tapAngle;
@ -37,6 +40,8 @@ private:
qreal realZValue; qreal realZValue;
bool pendingSelectionCollapse; bool pendingSelectionCollapse;
int selectionCollapseGeneration = 0; ///< Guards the deferred collapse timer against stale firings int selectionCollapseGeneration = 0; ///< Guards the deferred collapse timer against stale firings
QPropertyAnimation *hoverZoom = nullptr;
QPropertyAnimation *opacityAnim = nullptr;
private slots: private slots:
void pixmapUpdated(); void pixmapUpdated();
void onCardInfoChanged(); void onCardInfoChanged();
@ -57,6 +62,10 @@ signals:
void playSelectedFaceDown(AbstractCardItem *card); void playSelectedFaceDown(AbstractCardItem *card);
void hideSelected(AbstractCardItem *card); void hideSelected(AbstractCardItem *card);
void selectionChanged(AbstractCardItem *card, bool selected); void selectionChanged(AbstractCardItem *card, bool selected);
void tappedChanged(bool tapped);
void faceDownChanged(bool faceDown);
/** @brief Emitted when the hover shrink-out animation runs to completion while unhovered. */
void hoverShrinkFinished(AbstractCardItem *card);
public: public:
enum enum
@ -116,6 +125,13 @@ public:
} }
void setRealZValue(qreal _zValue); void setRealZValue(qreal _zValue);
void setHovered(bool _hovered); void setHovered(bool _hovered);
qreal getCardScale() const;
void setCardScale(qreal scale);
qreal getCardOpacity() const;
void setCardOpacity(qreal opacity);
/** @brief Fades the card out under the hover panel, or back in when it closes. */
void fadeCardOut();
void fadeCardIn();
bool getIsHovered() const bool getIsHovered() const
{ {
return isHovered; return isHovered;

View file

@ -117,6 +117,11 @@ void CardItem::deleteLater()
void CardItem::setZone(CardZoneLogic *_zone) void CardItem::setZone(CardZoneLogic *_zone)
{ {
state->setZone(_zone); state->setZone(_zone);
// A card faded out under the open hover panel must come back as soon as it changes
// zones; otherwise it stays invisible at its destination until the panel closes.
if (opacity() < 1.0) {
fadeCardIn();
}
} }
void CardItem::retranslateUi() void CardItem::retranslateUi()

View file

@ -34,6 +34,10 @@ constexpr qreal OVERLAY_BASE = ZValueLayerManager::OVERLAY_BASE;
// Overlay layer Z-values for items that should appear above normal cards // Overlay layer Z-values for items that should appear above normal cards
constexpr qreal HOVERED_CARD = ZValueLayerManager::overlayZValue(1.0); constexpr qreal HOVERED_CARD = ZValueLayerManager::overlayZValue(1.0);
// Lifts the hovered card's whole player area above sibling areas (and their zone
// decorations) while hovering. One level only: Qt compares subtrees at their highest
// differing ancestor, so the top ancestor decides. Below arrows/views/drags on purpose.
constexpr qreal HOVERED_AREA = ZValueLayerManager::overlayZValue(2.0);
constexpr qreal ARROWS = ZValueLayerManager::overlayZValue(3.0); constexpr qreal ARROWS = ZValueLayerManager::overlayZValue(3.0);
constexpr qreal ZONE_VIEW_WIDGET = ZValueLayerManager::overlayZValue(4.0); constexpr qreal ZONE_VIEW_WIDGET = ZValueLayerManager::overlayZValue(4.0);
constexpr qreal DRAG_ITEM = ZValueLayerManager::overlayZValue(5.0); constexpr qreal DRAG_ITEM = ZValueLayerManager::overlayZValue(5.0);