Cockatrice/cockatrice/src/game_graphics/board/abstract_card_item.cpp
Bryce! 3d8c729517
[Client] Preview token edits live and preserve custom drafts (#7737)
* Preview token edits live and preserve custom drafts

* Restore the upstream reveal-random-hand-cards action

The rebase onto upstream/master took the PR side of player_actions.cpp and
dropped PlayerActions::actRevealRandomHandCards, which upstream added after
the PR merge base. Re-apply the face-down P/T fix on the current file so the
branch keeps both changes.

* Draw the token preview P/T as the hover-panel pill

The preview painted the name and P/T unconditionally: the name duplicated the
artwork, and the raw P/T text was drawn even when it matched the printed value,
which looked wrong at non-default dialog sizes.

- Suppress the card name over a token picture via a small AbstractCardItem hook;
  cards without art still show it.
- Draw only a P/T that differs from the printed getPowTough() as the #7700
  hover-panel pill: dark rounded scrim, bold white text, anchored on the printed
  P/T box and clamped inside the card.
- Use designated CardRef initializers in the new code.

The face-up draft still previews while face-down creation is selected.

---------

Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com>
2026-11-09 12:59:53 +01:00

596 lines
21 KiB
C++

#include "abstract_card_item.h"
#include "../../client/settings/cache_settings.h"
#include "../../interface/card_localization.h"
#include "../../interface/card_picture_loader/card_picture_loader.h"
#include "../../interface/theme_manager.h"
#include "../card_dimensions.h"
#include "../game_scene.h"
#include "../z_values.h"
#include "abstract_graphics_item.h"
#include "libcockatrice/card/card_info.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/utility/card_ref.h"
#include <QAbstractAnimation>
#include <QApplication>
#include <QBrush>
#include <QChar>
#include <QCursor>
#include <QFlags>
#include <QFont>
#include <QGraphicsScene>
#include <QGraphicsSceneMouseEvent>
#include <QGraphicsView>
#include <QHash>
#include <QLineF>
#include <QList>
#include <QPainter>
#include <QPainterPath>
#include <QPen>
#include <QPixmap>
#include <QPointF>
#include <QPropertyAnimation>
#include <QRectF>
#include <QTimer>
#include <QTransform>
#include <QVariant>
#include <QtGlobal>
#include <algorithm>
#include <libcockatrice/settings/appearance_settings.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/debug_settings.h>
#include <qnamespace.h>
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;
// The pre-panel hover enlargement. This stays the hover behavior when the smooth zoom is
// turned off (see setHoverHighlighted).
static constexpr qreal LEGACY_HOVER_SCALE = 1.1;
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)
: ArrowTarget(_owner, parent), state(new CardState(this, nullptr, cardRef, _id)), tapAngle(0),
bgColor(Qt::transparent), isHovered(false), isHoverZoomed(false), realZValue(0)
{
setCursor(Qt::OpenHandCursor);
setFlag(ItemIsSelectable);
setCacheMode(DeviceCoordinateCache);
connect(state, &CardState::cardPixmapUpdated, this, &AbstractCardItem::pixmapUpdated);
connect(state, &CardState::cardInfoChanged, this, &AbstractCardItem::onCardInfoChanged);
connect(state, &CardState::cardRefChanged, this, &AbstractCardItem::onCardRefChanged);
connect(state, &CardState::tappedChanged, this, &AbstractCardItem::onTappedChanged);
connect(state, &CardState::facedownChanged, this, [this](bool faceDown) {
update();
emit faceDownChanged(faceDown);
});
connect(state, &CardState::colorChanged, this, [this] {
cacheBgColor();
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 (!isHoverZoomed) {
// The cursor may still rest on the card (e.g. wheel-up reverts the zoom),
// in which case the highlight keeps it raised.
setZValue(isHovered ? ZValues::HOVERED_CARD : 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,
[this] { update(); });
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
[this] { update(); });
refreshCardInfo();
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this,
[this](bool _roundCardCorners) {
Q_UNUSED(_roundCardCorners);
prepareGeometryChange();
update();
});
}
AbstractCardItem::~AbstractCardItem()
{
emit deleteCardInfoPopup(state->getCardRef().name);
}
QRectF AbstractCardItem::boundingRect() const
{
return QRectF(0, 0, CardDimensions::WIDTH_F, CardDimensions::HEIGHT_F);
}
QPainterPath AbstractCardItem::shape() const
{
QPainterPath shape;
qreal cardCornerRadius =
SettingsCache::instance().cardsDisplay().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0;
shape.addRoundedRect(boundingRect(), cardCornerRadius, cardCornerRadius);
return shape;
}
void AbstractCardItem::pixmapUpdated()
{
update();
emit sigPixmapUpdated();
}
void AbstractCardItem::onCardInfoChanged()
{
cacheBgColor();
update();
}
void AbstractCardItem::onCardRefChanged(const CardRef &oldCardRef, const CardRef & /*newCardRef*/)
{
emit deleteCardInfoPopup(oldCardRef.name);
}
void AbstractCardItem::refreshCardInfo()
{
state->refreshCardInfo();
}
/**
* Convenience method to get the CardInfo of the exactCard
* @return A const reference to the CardInfo, or an empty CardInfo if card was null
*/
const CardInfo &AbstractCardItem::getCardInfo() const
{
return state->getCardInfo();
}
void AbstractCardItem::setRealZValue(qreal _zValue)
{
realZValue = _zValue;
// During hover, zValue is overridden to HOVERED_CARD. Layout operations
// like reorganizeCards() call setRealZValue() on all cards including the
// hovered one — skip setZValue() here to avoid clobbering the override.
if (!isHovered && !isHoverZoomed) {
setZValue(_zValue);
}
}
QSizeF AbstractCardItem::getTranslatedSize(QPainter *painter) const
{
return QSizeF(painter->combinedTransform().map(QLineF(0, 0, boundingRect().width(), 0)).length(),
painter->combinedTransform().map(QLineF(0, 0, 0, boundingRect().height())).length());
}
bool AbstractCardItem::shouldPaintNameOverPicture() const
{
return SettingsCache::instance().cardsDisplay().getDisplayCardNames();
}
void AbstractCardItem::transformPainter(QPainter *painter, const QSizeF &translatedSize, int angle)
{
const int MAX_FONT_SIZE = SettingsCache::instance().appearance().getMaxFontSize();
const int fontSize = std::max(9, MAX_FONT_SIZE);
QRectF totalBoundingRect = painter->combinedTransform().mapRect(boundingRect());
int scale = resetPainterTransform(painter);
painter->translate(totalBoundingRect.width() / 2, totalBoundingRect.height() / 2);
painter->rotate(angle);
painter->translate(-translatedSize.width() / 2, -translatedSize.height() / 2);
QFont f = themeManager->appFont(AppFont::CardText);
f.setPixelSize(fontSize * scale);
painter->setFont(f);
}
void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedSize, int angle)
{
qreal scaleFactor = translatedSize.width() / boundingRect().width();
QPixmap translatedPixmap;
bool paintImage = true;
if (state->getFaceDown() || state->getCardRef().name.isEmpty()) {
// never reveal card color, always paint the card back
CardPictureLoader::getCardBackPixmap(translatedPixmap, translatedSize.toSize());
} else {
// don't even spend time trying to load the picture if our size is too small
if (translatedSize.width() > 10) {
CardPictureLoader::getPixmap(translatedPixmap, state->getCard(), translatedSize.toSize());
if (translatedPixmap.isNull()) {
paintImage = false;
}
} else {
paintImage = false;
}
}
painter->save();
if (paintImage) {
painter->save();
painter->setClipPath(shape());
painter->drawPixmap(boundingRect(), translatedPixmap, QRectF({0, 0}, translatedPixmap.size()));
painter->restore();
} else {
painter->setBrush(bgColor);
painter->drawPath(shape());
}
if (translatedPixmap.isNull() || state->getFaceDown() || shouldPaintNameOverPicture()) {
painter->save();
transformPainter(painter, translatedSize, angle);
painter->setPen(Qt::white);
painter->setBackground(Qt::black);
painter->setBackgroundMode(Qt::OpaqueMode);
QString nameStr;
if (state->getFaceDown()) {
nameStr = "# " + QString::number(state->getId());
} else {
QString prefix = "";
if (SettingsCache::instance().debug().getShowCardId()) {
prefix = "#" + QString::number(state->getId()) + " ";
}
nameStr = prefix + CardLocalization::displayName(getCardInfo());
}
painter->drawText(QRectF(3 * scaleFactor, 3 * scaleFactor, translatedSize.width() - 6 * scaleFactor,
translatedSize.height() - 6 * scaleFactor),
Qt::AlignTop | Qt::AlignLeft | Qt::TextWrapAnywhere, nameStr);
painter->restore();
}
painter->restore();
}
void AbstractCardItem::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/)
{
painter->save();
QSizeF translatedSize = getTranslatedSize(painter);
paintPicture(painter, translatedSize, tapAngle);
painter->setRenderHint(QPainter::Antialiasing, false);
if (isSelected() || isHovered || isHoverZoomed) {
QPen pen;
if (isHovered || isHoverZoomed) {
pen.setColor(Qt::yellow);
}
if (isSelected()) {
pen.setColor(Qt::red);
}
pen.setWidth(0); // Cosmetic pen
painter->setPen(pen);
painter->drawPath(shape());
}
painter->restore();
}
void AbstractCardItem::setHoverZoomed(bool _hoverZoomed)
{
if (isHoverZoomed == _hoverZoomed) {
return;
}
if (_hoverZoomed) {
processHoverEvent();
} else {
// Mark the hovered card's current scene footprint dirty so overlapped
// sibling zones (e.g. StackZone) repaint after the card moves away.
if (scene()) {
const QRectF footprint = sceneBoundingRect();
scene()->update(footprint);
// The hole disappears the moment the card is no longer hovered, even though the
// card has not changed size yet: tell the zones so they repaint it shut.
emit hoverFootprintChanged(footprint, footprint);
}
}
isHoverZoomed = _hoverZoomed;
// The stacking override is one-directional: it applies the moment the hover starts
// and only drops once the shrink-out finishes (see the hoverZoom finished handler).
// Restoring it here would let siblings paint over the still-enlarged card.
if (_hoverZoomed) {
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 (_hoverZoomed && 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(_hoverZoomed ? HOVER_ZOOM_IN_MS : HOVER_ZOOM_OUT_MS);
hoverZoom->start();
update();
}
void AbstractCardItem::setHovered(bool _hovered)
{
if (isHovered == _hovered) {
return;
}
if (_hovered) {
// The card-info widget listens for this: hovering still updates it right away.
processHoverEvent();
}
isHovered = _hovered;
if (_hovered) {
setZValue(ZValues::HOVERED_CARD);
}
// Fallback to the historical hover scale when the smooth hover zoom is turned off and
// the panel is not driven by hover: contact alone must still enlarge the card to the
// old 1.1x while scaleCards is on.
auto &display = SettingsCache::instance().cardsDisplay();
const bool legacyHoverScale = display.getScaleCards() &&
display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerOff &&
display.getHoverPanelTrigger() != CardsDisplaySettings::HoverPanelTriggerHover;
if (legacyHoverScale) {
// Centered like the smooth zoom, so growing and shrinking never drift.
setTransformOriginPoint(CardDimensions::WIDTH_HALF_F, CardDimensions::HEIGHT_HALF_F);
hoverZoom->stop();
hoverZoom->setStartValue(scale());
hoverZoom->setEndValue(_hovered ? LEGACY_HOVER_SCALE : 1.0);
hoverZoom->setDuration(_hovered ? HOVER_ZOOM_IN_MS : HOVER_ZOOM_OUT_MS);
hoverZoom->start();
} else if (!_hovered && !isHoverZoomed && hoverZoom->state() != QAbstractAnimation::Running) {
// A zoomed card keeps its raised z until the shrink-out finishes (its finished
// handler restores it); a highlight-only card has nothing to unwind.
setZValue(realZValue);
}
update();
}
void AbstractCardItem::setColor(const QString &_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);
const QRectF newFootprint = sceneBoundingRect();
if (scene()) {
scene()->update(oldFootprint.united(newFootprint));
// Marking the scene region dirty is not enough for cached items: a zone that cuts a
// hover hole out of itself keeps the hole from its last repaint, which then shows as
// an uncovered black rectangle under the zoom.
emit hoverFootprintChanged(oldFootprint, newFootprint);
} 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()
{
QChar colorChar;
const QString cardColor = state->getColor();
if (cardColor.isEmpty()) {
colorChar = state->getCardInfo().getColorChar();
} else {
colorChar = cardColor.at(0);
}
switch (colorChar.toLower().toLatin1()) {
case 'b':
bgColor = QColor(0, 0, 0);
break;
case 'u':
bgColor = QColor(0, 140, 180);
break;
case 'w':
bgColor = QColor(255, 250, 140);
break;
case 'r':
bgColor = QColor(230, 0, 0);
break;
case 'g':
bgColor = QColor(0, 160, 0);
break;
case 'm':
bgColor = QColor(250, 190, 30);
break;
default:
bgColor = QColor(230, 230, 230);
break;
}
}
void AbstractCardItem::onTappedChanged(bool newTapped, bool canAnimate)
{
emit tappedChanged(newTapped);
if (SettingsCache::instance().cardsDisplay().getTapAnimation() && canAnimate) {
static_cast<GameScene *>(scene())->registerAnimationItem(this);
} else {
tapAngle = newTapped ? 90 : 0;
setTransform(QTransform()
.translate(CardDimensions::WIDTH_HALF_F, CardDimensions::HEIGHT_HALF_F)
.rotate(tapAngle)
.translate(-CardDimensions::WIDTH_HALF_F, -CardDimensions::HEIGHT_HALF_F));
update();
}
}
bool AbstractCardItem::animationEvent()
{
return false;
}
void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
{
cancelPendingSelectionCollapse();
if (scene() != nullptr) {
// A new press starts a new gesture: no card may collapse the selection out from under it afterwards.
for (QGraphicsItem *selectedItem : scene()->selectedItems()) {
auto *selectedCard = qgraphicsitem_cast<AbstractCardItem *>(selectedItem);
if (selectedCard != nullptr && selectedCard != this) {
selectedCard->cancelPendingSelectionCollapse();
}
}
}
if ((event->modifiers() & Qt::AltModifier) && event->button() == Qt::LeftButton) {
emit cardShiftClicked(state->getCardRef().name);
} else if ((event->modifiers() & Qt::ControlModifier)) {
setSelected(!isSelected());
} else if (!isSelected()) {
scene()->clearSelection();
setSelected(true);
} else if (event->button() == Qt::LeftButton && scene()->selectedItems().count() > 1) {
// Part of a multi-selection; collapse only if this turns out to be a click rather than a drag,
// so that dragging the whole selection keeps it intact.
pendingSelectionCollapse = true;
}
if (event->button() == Qt::LeftButton) {
setCursor(Qt::ClosedHandCursor);
} else if (event->button() == Qt::MiddleButton) {
emit showCardInfoPopup(event->screenPos(), state->getCardRef());
}
event->accept();
}
void AbstractCardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
{
if (pendingSelectionCollapse) {
pendingSelectionCollapse = false;
bool dragged = (event->screenPos() - event->buttonDownScreenPos(event->button())).manhattanLength() >=
2 * QApplication::startDragDistance();
if (!dragged) {
if (doubleClickActsOnSelection()) {
// A double-click may still arrive and must act on the whole multi-selection, so collapse only
// once the double-click window has passed without one. The generation guard (and the receiver
// context) drops the collapse if the item is gone, deselected, or a double-click cancelled it;
// the count guard drops it if the selection changed meanwhile (rubber band, ctrl-toggle).
const int generation = ++selectionCollapseGeneration;
const int armedSelectionCount = scene()->selectedItems().count();
QTimer::singleShot(QApplication::doubleClickInterval(), this, [this, generation, armedSelectionCount] {
if (generation == selectionCollapseGeneration && scene() != nullptr && isSelected() &&
scene()->selectedItems().count() == armedSelectionCount && armedSelectionCount > 1) {
scene()->clearSelection();
setSelected(true);
}
});
} else {
scene()->clearSelection();
setSelected(true);
}
}
}
if (event->button() == Qt::MiddleButton) {
emit deleteCardInfoPopup(state->getCardRef().name);
}
// This function ensures the parent function doesn't mess around with our selection.
event->accept();
}
void AbstractCardItem::cancelPendingSelectionCollapse()
{
pendingSelectionCollapse = false;
++selectionCollapseGeneration;
}
bool AbstractCardItem::doubleClickActsOnSelection() const
{
return false;
}
void AbstractCardItem::processHoverEvent()
{
emit hovered(this);
}
QVariant AbstractCardItem::itemChange(QGraphicsItem::GraphicsItemChange change, const QVariant &value)
{
if (change == ItemSelectedHasChanged) {
update();
return value;
} else {
return ArrowTarget::itemChange(change, value);
}
}