mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 01:23:24 -08:00
Compare commits
3 commits
3954d0d386
...
733d2a2739
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
733d2a2739 | ||
|
|
bd4bc62dfd | ||
|
|
93f4575048 |
10 changed files with 1316 additions and 2 deletions
|
|
@ -249,6 +249,8 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/playmat/playmat_settings_dialog.cpp
|
||||
src/interface/widgets/quick_settings/settings_button_widget.cpp
|
||||
src/interface/widgets/quick_settings/settings_popup_widget.cpp
|
||||
src/interface/widgets/replay/replay_event_index.cpp
|
||||
src/interface/widgets/replay/replay_event_sidebar.cpp
|
||||
src/interface/widgets/replay/replay_manager.cpp
|
||||
src/interface/widgets/replay/replay_quick_settings_widget.cpp
|
||||
src/interface/widgets/replay/replay_timeline_widget.cpp
|
||||
|
|
|
|||
876
cockatrice/src/interface/widgets/replay/replay_event_index.cpp
Normal file
876
cockatrice/src/interface/widgets/replay/replay_event_index.cpp
Normal file
|
|
@ -0,0 +1,876 @@
|
|||
#include "replay_event_index.h"
|
||||
|
||||
#include "libcockatrice/protocol/get_pb_extension.h"
|
||||
#include "libcockatrice/protocol/pb/card_attributes.pb.h"
|
||||
#include "libcockatrice/protocol/pb/context_concede.pb.h"
|
||||
#include "libcockatrice/protocol/pb/context_connection_state_changed.pb.h"
|
||||
#include "libcockatrice/protocol/pb/context_deck_select.pb.h"
|
||||
#include "libcockatrice/protocol/pb/context_mulligan.pb.h"
|
||||
#include "libcockatrice/protocol/pb/context_ready_start.pb.h"
|
||||
#include "libcockatrice/protocol/pb/context_set_sideboard_lock.pb.h"
|
||||
#include "libcockatrice/protocol/pb/context_undo_draw.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_attach_card.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_change_zone_properties.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_create_counter.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_create_token.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_del_counter.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_destroy_card.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_draw_cards.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_dump_zone.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_flip_card.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_game_closed.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_game_host_changed.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_game_log_notice.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_game_say.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_game_state_changed.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_join.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_kicked.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_leave.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_move_card.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_player_properties_changed.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_reveal_cards.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_reverse_turn.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_roll_die.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_set_active_phase.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_set_active_player.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_set_card_attr.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_set_card_counter.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_set_counter.pb.h"
|
||||
#include "libcockatrice/protocol/pb/event_shuffle.pb.h"
|
||||
#include "libcockatrice/protocol/pb/game_event.pb.h"
|
||||
#include "libcockatrice/protocol/pb/game_event_container.pb.h"
|
||||
#include "libcockatrice/protocol/pb/game_event_context.pb.h"
|
||||
#include "libcockatrice/protocol/pb/game_replay.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_card.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_counter.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_player.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_playerproperties.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_zone.pb.h"
|
||||
#include "libcockatrice/utility/zone_names.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QHash>
|
||||
#include <QStringList>
|
||||
#include <QtGlobal>
|
||||
#include <algorithm>
|
||||
#include <libcockatrice/game/phase.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
struct PendingDetail
|
||||
{
|
||||
QString text;
|
||||
QString badge;
|
||||
ReplayEventKind kind = ReplayEventKind::Action;
|
||||
QString actor;
|
||||
};
|
||||
|
||||
QString formatTime(int timeMs)
|
||||
{
|
||||
const int totalSeconds = qMax(0, timeMs) / 1000;
|
||||
const int hours = totalSeconds / 3600;
|
||||
const int minutes = (totalSeconds / 60) % 60;
|
||||
const int seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return QStringLiteral("%1:%2:%3")
|
||||
.arg(hours)
|
||||
.arg(minutes, 2, 10, QLatin1Char('0'))
|
||||
.arg(seconds, 2, 10, QLatin1Char('0'));
|
||||
}
|
||||
|
||||
return QStringLiteral("%1:%2").arg(minutes, 2, 10, QLatin1Char('0')).arg(seconds, 2, 10, QLatin1Char('0'));
|
||||
}
|
||||
|
||||
class ReplayEventIndexBuilder
|
||||
{
|
||||
Q_DECLARE_TR_FUNCTIONS(ReplayEventIndex)
|
||||
|
||||
public:
|
||||
ReplayEventIndexBuilder(const GameReplay *replay, const QList<int> &timeline) : replay(replay), timeline(timeline)
|
||||
{
|
||||
}
|
||||
|
||||
QList<ReplayEventEntry> build()
|
||||
{
|
||||
QList<ReplayEventEntry> entries;
|
||||
|
||||
for (int i = 0; i < replay->event_list_size(); ++i) {
|
||||
const GameEventContainer &container = replay->event_list(i);
|
||||
const QList<PendingDetail> details = describeContainer(container);
|
||||
if (details.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ReplayEventEntry entry;
|
||||
entry.eventIndex = i;
|
||||
entry.timeMs = timeline.value(i, static_cast<int>(container.seconds_elapsed()) * 1000);
|
||||
entry.timeLabel = formatTime(entry.timeMs);
|
||||
entry.kind = details.first().kind;
|
||||
entry.badge = details.first().badge;
|
||||
if (details.size() > 1) {
|
||||
entry.badge += QStringLiteral(" +%1").arg(details.size() - 1);
|
||||
}
|
||||
|
||||
const QString sharedActor = details.first().actor;
|
||||
const bool mixedActors = std::any_of(details.cbegin(), details.cend(), [&sharedActor](const auto &detail) {
|
||||
return detail.actor != sharedActor;
|
||||
});
|
||||
|
||||
QStringList detailTexts;
|
||||
for (const PendingDetail &detail : details) {
|
||||
if (mixedActors && !detail.actor.isEmpty()) {
|
||||
detailTexts.append(tr("%1: %2").arg(detail.actor, detail.text));
|
||||
} else {
|
||||
detailTexts.append(detail.text);
|
||||
}
|
||||
}
|
||||
entry.actor = mixedActors ? QString() : sharedActor;
|
||||
entry.detail = detailTexts.join(QStringLiteral(" • "));
|
||||
|
||||
entries.append(entry);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private:
|
||||
const GameReplay *replay;
|
||||
const QList<int> &timeline;
|
||||
|
||||
QHash<int, QString> playerNames;
|
||||
QHash<int, bool> spectators;
|
||||
QHash<QString, QString> cardNames;
|
||||
QHash<QString, QString> counterNames;
|
||||
|
||||
int activePlayerId = -1;
|
||||
bool gameStarted = false;
|
||||
bool sawGameState = false;
|
||||
|
||||
static QString cardKey(int playerId, const QString &zone, int cardId)
|
||||
{
|
||||
return QStringLiteral("%1/%2/%3").arg(playerId).arg(zone).arg(cardId);
|
||||
}
|
||||
|
||||
static QString counterKey(int playerId, int counterId)
|
||||
{
|
||||
return QStringLiteral("%1/%2").arg(playerId).arg(counterId);
|
||||
}
|
||||
|
||||
QString playerName(int playerId) const
|
||||
{
|
||||
if (playerId < 0) {
|
||||
return tr("Game");
|
||||
}
|
||||
return playerNames.value(playerId, tr("Player %1").arg(playerId));
|
||||
}
|
||||
|
||||
QString actorName(int playerId) const
|
||||
{
|
||||
if (playerId < 0) {
|
||||
return QString();
|
||||
}
|
||||
return playerNames.value(playerId, tr("Player %1").arg(playerId));
|
||||
}
|
||||
|
||||
bool isSpectator(int playerId) const
|
||||
{
|
||||
return spectators.value(playerId, false);
|
||||
}
|
||||
|
||||
void rememberPlayer(const ServerInfo_PlayerProperties &properties)
|
||||
{
|
||||
const int playerId = properties.player_id();
|
||||
if (playerId < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QString name = QString::fromStdString(properties.user_info().name());
|
||||
if (!name.isEmpty()) {
|
||||
playerNames.insert(playerId, name);
|
||||
}
|
||||
spectators.insert(playerId, properties.spectator());
|
||||
}
|
||||
|
||||
void rememberCard(int playerId, const QString &zone, int cardId, const QString &name)
|
||||
{
|
||||
if (cardId >= 0 && !name.isEmpty()) {
|
||||
cardNames.insert(cardKey(playerId, zone, cardId), name);
|
||||
}
|
||||
}
|
||||
|
||||
void forgetCard(int playerId, const QString &zone, int cardId)
|
||||
{
|
||||
if (cardId >= 0) {
|
||||
cardNames.remove(cardKey(playerId, zone, cardId));
|
||||
}
|
||||
}
|
||||
|
||||
QString cardName(int playerId, const QString &zone, int cardId) const
|
||||
{
|
||||
if (cardId < 0) {
|
||||
return QString();
|
||||
}
|
||||
return cardNames.value(cardKey(playerId, zone, cardId));
|
||||
}
|
||||
|
||||
void rememberCounter(int playerId, const ServerInfo_Counter &counter)
|
||||
{
|
||||
const QString name = QString::fromStdString(counter.name());
|
||||
if (!name.isEmpty()) {
|
||||
counterNames.insert(counterKey(playerId, counter.id()), name);
|
||||
}
|
||||
}
|
||||
|
||||
QString counterName(int playerId, int counterId) const
|
||||
{
|
||||
return counterNames.value(counterKey(playerId, counterId));
|
||||
}
|
||||
|
||||
QString zoneName(const QString &zone) const
|
||||
{
|
||||
if (zone == ZoneNames::TABLE) {
|
||||
return tr("battlefield");
|
||||
}
|
||||
if (zone == ZoneNames::GRAVE) {
|
||||
return tr("graveyard");
|
||||
}
|
||||
if (zone == ZoneNames::EXILE) {
|
||||
return tr("exile");
|
||||
}
|
||||
if (zone == ZoneNames::HAND) {
|
||||
return tr("hand");
|
||||
}
|
||||
if (zone == ZoneNames::DECK) {
|
||||
return tr("library");
|
||||
}
|
||||
if (zone == ZoneNames::SIDEBOARD) {
|
||||
return tr("sideboard");
|
||||
}
|
||||
if (zone == ZoneNames::STACK) {
|
||||
return tr("stack");
|
||||
}
|
||||
return zone;
|
||||
}
|
||||
|
||||
QString zonePhrase(int playerId, const QString &zone, int actorId) const
|
||||
{
|
||||
if (zone == ZoneNames::TABLE) {
|
||||
return tr("the battlefield");
|
||||
}
|
||||
if (zone == ZoneNames::EXILE) {
|
||||
return tr("exile");
|
||||
}
|
||||
if (zone == ZoneNames::STACK) {
|
||||
return tr("the stack");
|
||||
}
|
||||
|
||||
const QString name = zoneName(zone);
|
||||
if (playerId < 0 || playerId == actorId) {
|
||||
return tr("their %1").arg(name);
|
||||
}
|
||||
return tr("%1's %2").arg(playerName(playerId), name);
|
||||
}
|
||||
|
||||
QString formatCardList(const QStringList &names, int total) const
|
||||
{
|
||||
// Keep repeats: each entry is a drawn/revealed card, so the list length must agree with the count.
|
||||
QStringList listedNames;
|
||||
for (const QString &name : names) {
|
||||
if (!name.isEmpty()) {
|
||||
listedNames.append(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (listedNames.isEmpty()) {
|
||||
if (total <= 0) {
|
||||
return tr("no cards");
|
||||
}
|
||||
return total == 1 ? tr("1 card") : tr("%1 cards").arg(total);
|
||||
}
|
||||
|
||||
if (total < listedNames.size()) {
|
||||
total = listedNames.size();
|
||||
}
|
||||
|
||||
if (listedNames.size() > 3) {
|
||||
return tr("%1 and %2 more").arg(listedNames.mid(0, 3).join(QStringLiteral(", "))).arg(total - 3);
|
||||
}
|
||||
if (total > listedNames.size()) {
|
||||
return tr("%1 and %2 more").arg(listedNames.join(QStringLiteral(", "))).arg(total - listedNames.size());
|
||||
}
|
||||
|
||||
return listedNames.join(QStringLiteral(", "));
|
||||
}
|
||||
|
||||
void updateGameState(const Event_GameStateChanged &event)
|
||||
{
|
||||
for (int i = 0; i < event.player_list_size(); ++i) {
|
||||
const ServerInfo_Player &player = event.player_list(i);
|
||||
const int playerId = player.properties().player_id();
|
||||
rememberPlayer(player.properties());
|
||||
|
||||
for (int j = 0; j < player.zone_list_size(); ++j) {
|
||||
const ServerInfo_Zone &zone = player.zone_list(j);
|
||||
for (int k = 0; k < zone.card_list_size(); ++k) {
|
||||
const ServerInfo_Card &card = zone.card_list(k);
|
||||
rememberCard(playerId, QString::fromStdString(zone.name()), card.id(),
|
||||
QString::fromStdString(card.name()));
|
||||
}
|
||||
}
|
||||
|
||||
for (int j = 0; j < player.counter_list_size(); ++j) {
|
||||
rememberCounter(playerId, player.counter_list(j));
|
||||
}
|
||||
}
|
||||
|
||||
if (event.has_active_player_id()) {
|
||||
activePlayerId = event.active_player_id();
|
||||
}
|
||||
if (event.has_game_started()) {
|
||||
gameStarted = event.game_started();
|
||||
}
|
||||
}
|
||||
|
||||
QString describeMoveCard(const Event_MoveCard &event, int actorId, const GameEventContext &context)
|
||||
{
|
||||
const int startPlayerId = event.start_player_id() >= 0 ? event.start_player_id() : actorId;
|
||||
const int targetPlayerId = event.target_player_id() >= 0 ? event.target_player_id() : startPlayerId;
|
||||
const QString startZone = QString::fromStdString(event.start_zone());
|
||||
const QString targetZone = event.has_target_zone() ? QString::fromStdString(event.target_zone()) : startZone;
|
||||
|
||||
QString name;
|
||||
if (event.has_card_name()) {
|
||||
name = QString::fromStdString(event.card_name());
|
||||
}
|
||||
if (name.isEmpty()) {
|
||||
name = cardName(startPlayerId, startZone, event.card_id());
|
||||
}
|
||||
|
||||
const bool knownName = !name.isEmpty();
|
||||
if (name.isEmpty()) {
|
||||
name = event.face_down() ? tr("a face-down card") : tr("a card");
|
||||
}
|
||||
|
||||
const int newCardId = event.has_new_card_id() ? event.new_card_id() : event.card_id();
|
||||
forgetCard(startPlayerId, startZone, event.card_id());
|
||||
if (knownName) {
|
||||
rememberCard(targetPlayerId, targetZone, newCardId, name);
|
||||
}
|
||||
|
||||
if (startZone == targetZone) {
|
||||
return tr("moved %1 within %2.").arg(name, zonePhrase(targetPlayerId, targetZone, actorId));
|
||||
}
|
||||
if (context.HasExtension(Context_UndoDraw::ext)) {
|
||||
return tr("returned %1 to %2.").arg(name, zonePhrase(targetPlayerId, targetZone, actorId));
|
||||
}
|
||||
|
||||
return tr("moved %1 from %2 to %3.")
|
||||
.arg(name, zonePhrase(startPlayerId, startZone, actorId), zonePhrase(targetPlayerId, targetZone, actorId));
|
||||
}
|
||||
|
||||
QString describeSetCardAttribute(const Event_SetCardAttr &event, int actorId)
|
||||
{
|
||||
const QString zone = QString::fromStdString(event.zone_name());
|
||||
QString name;
|
||||
if (event.has_card_id()) {
|
||||
name = cardName(actorId, zone, event.card_id());
|
||||
}
|
||||
if (name.isEmpty()) {
|
||||
name = event.has_card_id() ? tr("a card") : tr("all cards");
|
||||
}
|
||||
|
||||
const QString value = QString::fromStdString(event.attr_value());
|
||||
switch (event.attribute()) {
|
||||
case AttrTapped:
|
||||
return value == QLatin1String("1") ? tr("tapped %1.").arg(name) : tr("untapped %1.").arg(name);
|
||||
case AttrAttacking:
|
||||
return value == QLatin1String("1") ? tr("%1 is now attacking.").arg(name)
|
||||
: tr("%1 is no longer attacking.").arg(name);
|
||||
case AttrFaceDown:
|
||||
return value == QLatin1String("1") ? tr("turned %1 face down.").arg(name)
|
||||
: tr("turned %1 face up.").arg(name);
|
||||
case AttrColor:
|
||||
return tr("changed %1's color to %2.").arg(name, value);
|
||||
case AttrPT:
|
||||
return tr("set %1's power/toughness to %2.").arg(name, value);
|
||||
case AttrAnnotation:
|
||||
return value.isEmpty() ? tr("cleared %1's annotation.").arg(name)
|
||||
: tr("annotated %1: %2").arg(name, value);
|
||||
case AttrDoesntUntap:
|
||||
return value == QLatin1String("1") ? tr("%1 will no longer untap.").arg(name)
|
||||
: tr("%1 will untap normally again.").arg(name);
|
||||
}
|
||||
|
||||
return tr("changed %1.").arg(name);
|
||||
}
|
||||
|
||||
QString badgeForCardAttribute(CardAttribute attribute) const
|
||||
{
|
||||
switch (attribute) {
|
||||
case AttrTapped:
|
||||
return tr("TAP");
|
||||
case AttrAttacking:
|
||||
return tr("ATTACK");
|
||||
case AttrFaceDown:
|
||||
return tr("FLIP");
|
||||
case AttrColor:
|
||||
return tr("COLOR");
|
||||
case AttrPT:
|
||||
return tr("PT");
|
||||
case AttrAnnotation:
|
||||
return tr("NOTE");
|
||||
case AttrDoesntUntap:
|
||||
return tr("TAP");
|
||||
}
|
||||
return tr("CARD");
|
||||
}
|
||||
|
||||
QString describeLeaveReason(Event_Leave::LeaveReason reason) const
|
||||
{
|
||||
switch (reason) {
|
||||
case Event_Leave::USER_KICKED:
|
||||
return tr("kicked by game host or moderator");
|
||||
case Event_Leave::USER_LEFT:
|
||||
return tr("left the game");
|
||||
case Event_Leave::USER_DISCONNECTED:
|
||||
return tr("disconnected");
|
||||
case Event_Leave::OTHER:
|
||||
default:
|
||||
return tr("reason unknown");
|
||||
}
|
||||
}
|
||||
|
||||
void addDetail(QList<PendingDetail> &details,
|
||||
const QString &text,
|
||||
const QString &badge,
|
||||
ReplayEventKind kind,
|
||||
int actorId,
|
||||
bool forcedByJudge) const
|
||||
{
|
||||
if (text.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QString finalText = forcedByJudge ? tr("%1 (judge)").arg(text) : text;
|
||||
details.append(PendingDetail{.text = finalText, .badge = badge, .kind = kind, .actor = actorName(actorId)});
|
||||
}
|
||||
|
||||
QList<PendingDetail> describeContainer(const GameEventContainer &container)
|
||||
{
|
||||
QList<PendingDetail> details;
|
||||
const GameEventContext &context = container.context();
|
||||
const bool forcedByJudge = container.has_forced_by_judge();
|
||||
const auto contextType = static_cast<GameEventContext::ContextType>(getPbExtension(context));
|
||||
|
||||
for (int i = 0; i < container.event_list_size(); ++i) {
|
||||
const GameEvent &event = container.event_list(i);
|
||||
const int eventPlayerId = event.player_id();
|
||||
const auto eventType = static_cast<GameEvent::GameEventType>(getPbExtension(event));
|
||||
|
||||
switch (eventType) {
|
||||
case GameEvent::GAME_STATE_CHANGED: {
|
||||
const Event_GameStateChanged &state = event.GetExtension(Event_GameStateChanged::ext);
|
||||
const bool wasStarted = gameStarted;
|
||||
updateGameState(state);
|
||||
if (state.game_started() && !wasStarted) {
|
||||
addDetail(details, tr("Game started."), tr("GAME"), ReplayEventKind::System, -1, forcedByJudge);
|
||||
} else if (!sawGameState) {
|
||||
addDetail(details, tr("Game state loaded."), tr("GAME"), ReplayEventKind::System, -1,
|
||||
forcedByJudge);
|
||||
}
|
||||
sawGameState = true;
|
||||
break;
|
||||
}
|
||||
case GameEvent::PLAYER_PROPERTIES_CHANGED: {
|
||||
const Event_PlayerPropertiesChanged &propertiesEvent =
|
||||
event.GetExtension(Event_PlayerPropertiesChanged::ext);
|
||||
const bool hasProperties = propertiesEvent.has_player_properties();
|
||||
const ServerInfo_PlayerProperties &properties = propertiesEvent.player_properties();
|
||||
// The server only fills in the field that changed, so player_id is absent from these events
|
||||
// unless they are about a specific player; fall back to the event's own player id.
|
||||
const bool describesPlayer = hasProperties && properties.has_player_id();
|
||||
const int playerId = describesPlayer ? properties.player_id() : eventPlayerId;
|
||||
if (describesPlayer) {
|
||||
rememberPlayer(properties);
|
||||
}
|
||||
|
||||
switch (contextType) {
|
||||
case GameEventContext::READY_START:
|
||||
addDetail(details,
|
||||
properties.ready_start() ? tr("is ready to start.") : tr("is no longer ready."),
|
||||
tr("READY"), ReplayEventKind::System, playerId, forcedByJudge);
|
||||
break;
|
||||
case GameEventContext::CONCEDE:
|
||||
addDetail(details, tr("conceded the game."), tr("CONCEDE"), ReplayEventKind::System,
|
||||
playerId, forcedByJudge);
|
||||
break;
|
||||
case GameEventContext::UNCONCEDE:
|
||||
addDetail(details, tr("unconceded the game."), tr("CONCEDE"), ReplayEventKind::System,
|
||||
playerId, forcedByJudge);
|
||||
break;
|
||||
case GameEventContext::DECK_SELECT: {
|
||||
const Context_DeckSelect &deckSelect = context.GetExtension(Context_DeckSelect::ext);
|
||||
const int sideboardSize = deckSelect.sideboard_size();
|
||||
addDetail(details,
|
||||
sideboardSize >= 0
|
||||
? tr("selected a deck with %1 sideboard cards.").arg(sideboardSize)
|
||||
: tr("selected a deck."),
|
||||
tr("DECK"), ReplayEventKind::System, playerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEventContext::MULLIGAN: {
|
||||
const Context_Mulligan &mulligan = context.GetExtension(Context_Mulligan::ext);
|
||||
addDetail(details,
|
||||
mulligan.number() > 0 ? tr("took a mulligan (%1).").arg(mulligan.number())
|
||||
: tr("took a mulligan."),
|
||||
tr("MULLIGAN"), ReplayEventKind::System, playerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEventContext::SET_SIDEBOARD_LOCK:
|
||||
addDetail(details,
|
||||
properties.sideboard_locked() ? tr("locked their sideboard.")
|
||||
: tr("unlocked their sideboard."),
|
||||
tr("SIDEBOARD"), ReplayEventKind::System, playerId, forcedByJudge);
|
||||
break;
|
||||
case GameEventContext::CONNECTION_STATE_CHANGED:
|
||||
addDetail(details,
|
||||
properties.ping_seconds() != -1 ? tr("reconnected to the game.")
|
||||
: tr("lost connection to the game."),
|
||||
tr("CONNECTION"), ReplayEventKind::System, playerId, forcedByJudge);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GameEvent::JOIN: {
|
||||
const Event_Join &join = event.GetExtension(Event_Join::ext);
|
||||
if (join.has_player_properties()) {
|
||||
rememberPlayer(join.player_properties());
|
||||
}
|
||||
const int playerId = join.player_properties().player_id() >= 0
|
||||
? join.player_properties().player_id()
|
||||
: eventPlayerId;
|
||||
addDetail(details, isSpectator(playerId) ? tr("joined as a spectator.") : tr("joined the game."),
|
||||
tr("JOIN"), ReplayEventKind::System, playerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::LEAVE: {
|
||||
const Event_Leave &leave = event.GetExtension(Event_Leave::ext);
|
||||
const QString reason = describeLeaveReason(leave.reason());
|
||||
if (eventPlayerId < 0) {
|
||||
addDetail(details, tr("A player left the game (%1).").arg(reason), tr("LEAVE"),
|
||||
ReplayEventKind::System, -1, forcedByJudge);
|
||||
} else {
|
||||
addDetail(details,
|
||||
isSpectator(eventPlayerId) ? tr("stopped spectating (%1).").arg(reason)
|
||||
: tr("left the game (%1).").arg(reason),
|
||||
tr("LEAVE"), ReplayEventKind::System, eventPlayerId, forcedByJudge);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GameEvent::KICKED:
|
||||
addDetail(details,
|
||||
eventPlayerId >= 0 ? tr("was kicked from the game.")
|
||||
: tr("A player was kicked from the game."),
|
||||
tr("KICK"), ReplayEventKind::System, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
case GameEvent::GAME_CLOSED:
|
||||
addDetail(details, tr("Game closed."), tr("GAME"), ReplayEventKind::System, -1, forcedByJudge);
|
||||
break;
|
||||
case GameEvent::GAME_HOST_CHANGED:
|
||||
addDetail(details, eventPlayerId >= 0 ? tr("is now the game host.") : tr("The game host changed."),
|
||||
tr("HOST"), ReplayEventKind::System, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
case GameEvent::GAME_SAY: {
|
||||
const Event_GameSay &say = event.GetExtension(Event_GameSay::ext);
|
||||
addDetail(details, QString::fromStdString(say.message()), tr("CHAT"), ReplayEventKind::Chat,
|
||||
eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::SET_ACTIVE_PLAYER: {
|
||||
const Event_SetActivePlayer &setActivePlayer = event.GetExtension(Event_SetActivePlayer::ext);
|
||||
activePlayerId = setActivePlayer.active_player_id();
|
||||
if (activePlayerId >= 0) {
|
||||
addDetail(details, tr("begins their turn."), tr("TURN"), ReplayEventKind::Turn, activePlayerId,
|
||||
forcedByJudge);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GameEvent::SET_ACTIVE_PHASE: {
|
||||
const Event_SetActivePhase &setActivePhase = event.GetExtension(Event_SetActivePhase::ext);
|
||||
if (setActivePhase.phase() >= 0) {
|
||||
const QString phaseName = Phases::getPhase(setActivePhase.phase()).getName();
|
||||
addDetail(details, tr("entered the %1 phase.").arg(phaseName), tr("PHASE"),
|
||||
ReplayEventKind::Phase, activePlayerId >= 0 ? activePlayerId : eventPlayerId,
|
||||
forcedByJudge);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GameEvent::REVERSE_TURN: {
|
||||
const Event_ReverseTurn &reverseTurn = event.GetExtension(Event_ReverseTurn::ext);
|
||||
addDetail(details,
|
||||
reverseTurn.reversed() ? tr("reversed the turn order.") : tr("restored the turn order."),
|
||||
tr("TURN"), ReplayEventKind::Turn, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::MOVE_CARD: {
|
||||
const Event_MoveCard &moveCard = event.GetExtension(Event_MoveCard::ext);
|
||||
const QString text = describeMoveCard(moveCard, eventPlayerId, context);
|
||||
const QString startZone = QString::fromStdString(moveCard.start_zone());
|
||||
const QString targetZone = QString::fromStdString(moveCard.target_zone());
|
||||
QString badge = tr("MOVE");
|
||||
if (context.HasExtension(Context_UndoDraw::ext)) {
|
||||
badge = tr("UNDO");
|
||||
} else if (startZone == ZoneNames::STACK) {
|
||||
badge = tr("RESOLVE");
|
||||
} else if (startZone == ZoneNames::HAND && targetZone == ZoneNames::TABLE) {
|
||||
badge = tr("PLAY");
|
||||
}
|
||||
addDetail(details, text, badge, ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::DRAW_CARDS: {
|
||||
const Event_DrawCards &drawCards = event.GetExtension(Event_DrawCards::ext);
|
||||
QStringList names;
|
||||
for (int j = 0; j < drawCards.cards_size(); ++j) {
|
||||
const ServerInfo_Card &card = drawCards.cards(j);
|
||||
const QString name = QString::fromStdString(card.name());
|
||||
names.append(name);
|
||||
rememberCard(eventPlayerId, ZoneNames::HAND, card.id(), name);
|
||||
}
|
||||
if (drawCards.number() > 0) {
|
||||
addDetail(details, tr("drew %1.").arg(formatCardList(names, drawCards.number())), tr("DRAW"),
|
||||
ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GameEvent::REVEAL_CARDS: {
|
||||
const Event_RevealCards &revealCards = event.GetExtension(Event_RevealCards::ext);
|
||||
const QString zone = QString::fromStdString(revealCards.zone_name());
|
||||
QStringList names;
|
||||
for (int j = 0; j < revealCards.cards_size(); ++j) {
|
||||
const ServerInfo_Card &card = revealCards.cards(j);
|
||||
const QString name = QString::fromStdString(card.name());
|
||||
names.append(name);
|
||||
rememberCard(eventPlayerId, zone, card.id(), name);
|
||||
}
|
||||
const int total =
|
||||
revealCards.number_of_cards() > 0 ? revealCards.number_of_cards() : revealCards.cards_size();
|
||||
QString text =
|
||||
tr("revealed %1 from %2.")
|
||||
.arg(formatCardList(names, total), zonePhrase(eventPlayerId, zone, eventPlayerId));
|
||||
if (revealCards.other_player_id() >= 0 && revealCards.other_player_id() != eventPlayerId) {
|
||||
text = tr("revealed %1 from %2 to %3.")
|
||||
.arg(formatCardList(names, total), zonePhrase(eventPlayerId, zone, eventPlayerId),
|
||||
playerName(revealCards.other_player_id()));
|
||||
}
|
||||
addDetail(details, text, tr("REVEAL"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::SHUFFLE: {
|
||||
const Event_Shuffle &shuffle = event.GetExtension(Event_Shuffle::ext);
|
||||
addDetail(
|
||||
details,
|
||||
tr("shuffled %1.")
|
||||
.arg(zonePhrase(eventPlayerId, QString::fromStdString(shuffle.zone_name()), eventPlayerId)),
|
||||
tr("SHUFFLE"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::ROLL_DIE: {
|
||||
const Event_RollDie &rollDie = event.GetExtension(Event_RollDie::ext);
|
||||
QStringList rolls;
|
||||
for (int j = 0; j < rollDie.values_size(); ++j) {
|
||||
rolls.append(QString::number(rollDie.values(j)));
|
||||
}
|
||||
if (rolls.isEmpty() && rollDie.value() > 0) {
|
||||
rolls.append(QString::number(rollDie.value()));
|
||||
}
|
||||
const QString sides = rollDie.sides() > 0 ? QString::number(rollDie.sides()) : QStringLiteral("?");
|
||||
addDetail(details, tr("rolled a d%1: %2.").arg(sides, rolls.join(QStringLiteral(", "))), tr("DICE"),
|
||||
ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::FLIP_CARD: {
|
||||
const Event_FlipCard &flipCard = event.GetExtension(Event_FlipCard::ext);
|
||||
const QString zone = QString::fromStdString(flipCard.zone_name());
|
||||
if (flipCard.has_card_name()) {
|
||||
rememberCard(eventPlayerId, zone, flipCard.card_id(),
|
||||
QString::fromStdString(flipCard.card_name()));
|
||||
}
|
||||
QString name = flipCard.has_card_name() ? QString::fromStdString(flipCard.card_name())
|
||||
: cardName(eventPlayerId, zone, flipCard.card_id());
|
||||
if (name.isEmpty()) {
|
||||
name = tr("a card");
|
||||
}
|
||||
addDetail(details,
|
||||
flipCard.face_down() ? tr("flipped %1 face down.").arg(name)
|
||||
: tr("flipped %1 face up.").arg(name),
|
||||
tr("FLIP"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::DESTROY_CARD: {
|
||||
const Event_DestroyCard &destroyCard = event.GetExtension(Event_DestroyCard::ext);
|
||||
const QString zone = QString::fromStdString(destroyCard.zone_name());
|
||||
const QString name = cardName(eventPlayerId, zone, destroyCard.card_id());
|
||||
forgetCard(eventPlayerId, zone, destroyCard.card_id());
|
||||
addDetail(details, tr("destroyed %1.").arg(name.isEmpty() ? tr("a card") : name), tr("DESTROY"),
|
||||
ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::ATTACH_CARD: {
|
||||
const Event_AttachCard &attachCard = event.GetExtension(Event_AttachCard::ext);
|
||||
const QString startZone = QString::fromStdString(attachCard.start_zone());
|
||||
const QString source = cardName(eventPlayerId, startZone, attachCard.card_id());
|
||||
const QString sourceText = source.isEmpty() ? tr("a card") : source;
|
||||
// An unattach event carries no target at all, so the target fields default to player 0 / no zone
|
||||
// / card 0; only a present target_player_id means the card was attached to something.
|
||||
if (!attachCard.has_target_player_id()) {
|
||||
addDetail(details, tr("unattached %1.").arg(sourceText), tr("ATTACH"), ReplayEventKind::Action,
|
||||
eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
const QString targetZone = QString::fromStdString(attachCard.target_zone());
|
||||
const int targetPlayerId =
|
||||
attachCard.target_player_id() >= 0 ? attachCard.target_player_id() : eventPlayerId;
|
||||
const QString target = cardName(targetPlayerId, targetZone, attachCard.target_card_id());
|
||||
addDetail(details,
|
||||
tr("attached %1 to %2.").arg(sourceText, target.isEmpty() ? tr("a card") : target),
|
||||
tr("ATTACH"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::CREATE_TOKEN: {
|
||||
const Event_CreateToken &createToken = event.GetExtension(Event_CreateToken::ext);
|
||||
const QString name = QString::fromStdString(createToken.card_name());
|
||||
rememberCard(eventPlayerId, QString::fromStdString(createToken.zone_name()), createToken.card_id(),
|
||||
name);
|
||||
const QString pt = QString::fromStdString(createToken.pt());
|
||||
addDetail(
|
||||
details,
|
||||
tr("created a token: %1%2.")
|
||||
.arg(name.isEmpty() ? tr("token") : name, pt.isEmpty() ? QString() : tr(" (%1)").arg(pt)),
|
||||
tr("TOKEN"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::SET_CARD_ATTR: {
|
||||
const Event_SetCardAttr &setCardAttr = event.GetExtension(Event_SetCardAttr::ext);
|
||||
addDetail(details, describeSetCardAttribute(setCardAttr, eventPlayerId),
|
||||
badgeForCardAttribute(setCardAttr.attribute()), ReplayEventKind::Action, eventPlayerId,
|
||||
forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::SET_CARD_COUNTER: {
|
||||
const Event_SetCardCounter &setCardCounter = event.GetExtension(Event_SetCardCounter::ext);
|
||||
const QString name = cardName(eventPlayerId, QString::fromStdString(setCardCounter.zone_name()),
|
||||
setCardCounter.card_id());
|
||||
addDetail(
|
||||
details,
|
||||
tr("set a counter on %1 to %2.")
|
||||
.arg(name.isEmpty() ? tr("a card") : name, QString::number(setCardCounter.counter_value())),
|
||||
tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::CREATE_COUNTER: {
|
||||
const Event_CreateCounter &createCounter = event.GetExtension(Event_CreateCounter::ext);
|
||||
const ServerInfo_Counter &counter = createCounter.counter_info();
|
||||
rememberCounter(eventPlayerId, counter);
|
||||
const QString name = QString::fromStdString(counter.name());
|
||||
addDetail(details,
|
||||
tr("created the %1 counter (%2).")
|
||||
.arg(name.isEmpty() ? tr("new") : name, QString::number(counter.count())),
|
||||
tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::SET_COUNTER: {
|
||||
const Event_SetCounter &setCounter = event.GetExtension(Event_SetCounter::ext);
|
||||
const QString name = counterName(eventPlayerId, setCounter.counter_id());
|
||||
addDetail(details,
|
||||
tr("set %1 to %2.")
|
||||
.arg(name.isEmpty() ? tr("a counter") : tr("the %1 counter").arg(name),
|
||||
QString::number(setCounter.value())),
|
||||
tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::DEL_COUNTER: {
|
||||
const Event_DelCounter &delCounter = event.GetExtension(Event_DelCounter::ext);
|
||||
const QString name = counterName(eventPlayerId, delCounter.counter_id());
|
||||
counterNames.remove(counterKey(eventPlayerId, delCounter.counter_id()));
|
||||
addDetail(details,
|
||||
tr("removed %1.").arg(name.isEmpty() ? tr("a counter") : tr("the %1 counter").arg(name)),
|
||||
tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::DUMP_ZONE: {
|
||||
const Event_DumpZone &dumpZone = event.GetExtension(Event_DumpZone::ext);
|
||||
const QString zoneOwnerPhrase = zonePhrase(
|
||||
dumpZone.zone_owner_id(), QString::fromStdString(dumpZone.zone_name()), eventPlayerId);
|
||||
if (dumpZone.number_cards() < 0) {
|
||||
addDetail(details, tr("looked at %1.").arg(zoneOwnerPhrase), tr("LOOK"),
|
||||
ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
} else {
|
||||
addDetail(details,
|
||||
tr("looked at %1 from %2.")
|
||||
.arg(formatCardList(QStringList(), dumpZone.number_cards()), zoneOwnerPhrase),
|
||||
tr("LOOK"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GameEvent::CHANGE_ZONE_PROPERTIES: {
|
||||
const Event_ChangeZoneProperties &changeZone = event.GetExtension(Event_ChangeZoneProperties::ext);
|
||||
const QString zone = QString::fromStdString(changeZone.zone_name());
|
||||
QStringList changes;
|
||||
if (changeZone.always_reveal_top_card()) {
|
||||
changes.append(tr("top card revealed to all"));
|
||||
}
|
||||
if (changeZone.always_look_at_top_card()) {
|
||||
changes.append(tr("top card visible to its owner"));
|
||||
}
|
||||
addDetail(
|
||||
details,
|
||||
changes.isEmpty()
|
||||
? tr("changed the properties of %1.").arg(zonePhrase(eventPlayerId, zone, eventPlayerId))
|
||||
: tr("changed %1: %2.")
|
||||
.arg(zonePhrase(eventPlayerId, zone, eventPlayerId),
|
||||
changes.join(QStringLiteral(", "))),
|
||||
tr("ZONE"), ReplayEventKind::Action, eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
case GameEvent::GAME_LOG_NOTICE: {
|
||||
const Event_GameLogNotice ¬ice = event.GetExtension(Event_GameLogNotice::ext);
|
||||
switch (notice.notice_type()) {
|
||||
case Event_GameLogNotice::UNDO_DRAW_FAILED:
|
||||
addDetail(details, tr("failed to undo a draw."), tr("NOTICE"), ReplayEventKind::System,
|
||||
eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
default:
|
||||
addDetail(details, tr("A game notice was logged."), tr("NOTICE"), ReplayEventKind::System,
|
||||
-1, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GameEvent::CREATE_ARROW:
|
||||
case GameEvent::DELETE_ARROW:
|
||||
// Arrows are transient visual annotations and would make the history very noisy.
|
||||
break;
|
||||
default:
|
||||
addDetail(details, tr("Unrecognized game event."), tr("EVENT"), ReplayEventKind::Action,
|
||||
eventPlayerId, forcedByJudge);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
QList<ReplayEventEntry> buildReplayEventEntries(const GameReplay *replay, const QList<int> &timeline)
|
||||
{
|
||||
if (!replay) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return ReplayEventIndexBuilder(replay, timeline).build();
|
||||
}
|
||||
43
cockatrice/src/interface/widgets/replay/replay_event_index.h
Normal file
43
cockatrice/src/interface/widgets/replay/replay_event_index.h
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#ifndef COCKATRICE_REPLAY_EVENT_INDEX_H
|
||||
#define COCKATRICE_REPLAY_EVENT_INDEX_H
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
class GameReplay;
|
||||
|
||||
enum class ReplayEventKind
|
||||
{
|
||||
Action,
|
||||
Turn,
|
||||
Phase,
|
||||
Chat,
|
||||
System
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief One user-visible entry in the replay history sidebar.
|
||||
*
|
||||
* The event index refers to the corresponding GameEventContainer in the replay. It is used to seek the replay to that
|
||||
* container.
|
||||
*/
|
||||
struct ReplayEventEntry
|
||||
{
|
||||
int eventIndex = -1;
|
||||
int timeMs = 0;
|
||||
QString timeLabel;
|
||||
QString actor;
|
||||
QString badge;
|
||||
QString detail;
|
||||
ReplayEventKind kind = ReplayEventKind::Action;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Builds a compact, human-readable index of the events in a replay.
|
||||
*
|
||||
* The returned entries copy all the data they need, so the caller does not need to keep the replay object alive after
|
||||
* this function returns.
|
||||
*/
|
||||
QList<ReplayEventEntry> buildReplayEventEntries(const GameReplay *replay, const QList<int> &timeline);
|
||||
|
||||
#endif // COCKATRICE_REPLAY_EVENT_INDEX_H
|
||||
284
cockatrice/src/interface/widgets/replay/replay_event_sidebar.cpp
Normal file
284
cockatrice/src/interface/widgets/replay/replay_event_sidebar.cpp
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
#include "replay_event_sidebar.h"
|
||||
|
||||
#include "replay_event_index.h"
|
||||
|
||||
#include <QAbstractItemView>
|
||||
#include <QFontMetrics>
|
||||
#include <QFrame>
|
||||
#include <QLabel>
|
||||
#include <QListWidget>
|
||||
#include <QListWidgetItem>
|
||||
#include <QPainter>
|
||||
#include <QPalette>
|
||||
#include <QStyle>
|
||||
#include <QStyledItemDelegate>
|
||||
#include <QVBoxLayout>
|
||||
#include <algorithm>
|
||||
#include <libcockatrice/utility/qt_utils.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
enum ItemDataRole
|
||||
{
|
||||
EventIndexRole = Qt::UserRole + 1,
|
||||
TimeLabelRole,
|
||||
KindRole,
|
||||
ActorRole,
|
||||
BadgeRole,
|
||||
DetailRole
|
||||
};
|
||||
|
||||
QColor accentForKind(ReplayEventKind kind, const QPalette &palette)
|
||||
{
|
||||
switch (kind) {
|
||||
case ReplayEventKind::Turn:
|
||||
return QColor(0x4c, 0xaf, 0x50);
|
||||
case ReplayEventKind::Phase:
|
||||
return QColor(0x42, 0xa5, 0xf5);
|
||||
case ReplayEventKind::Chat:
|
||||
return QColor(0xff, 0xb3, 0x00);
|
||||
case ReplayEventKind::System:
|
||||
return palette.mid().color();
|
||||
case ReplayEventKind::Action:
|
||||
default:
|
||||
return palette.highlight().color();
|
||||
}
|
||||
}
|
||||
|
||||
class ReplayEventDelegate : public QStyledItemDelegate
|
||||
{
|
||||
public:
|
||||
explicit ReplayEventDelegate(QObject *parent) : QStyledItemDelegate(parent)
|
||||
{
|
||||
}
|
||||
|
||||
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override
|
||||
{
|
||||
painter->save();
|
||||
painter->setRenderHint(QPainter::Antialiasing, true);
|
||||
|
||||
const QPalette &palette = option.palette;
|
||||
const bool selected = option.state.testFlag(QStyle::State_Selected);
|
||||
const bool hovered = option.state.testFlag(QStyle::State_MouseOver);
|
||||
const auto kind = static_cast<ReplayEventKind>(index.data(KindRole).toInt());
|
||||
const QColor accent = accentForKind(kind, palette);
|
||||
|
||||
const QRect card = option.rect.adjusted(4, 2, -4, -2);
|
||||
QColor cardBackground = selected ? palette.highlight().color() : palette.base().color();
|
||||
if (!selected && hovered) {
|
||||
cardBackground = palette.alternateBase().color();
|
||||
}
|
||||
|
||||
painter->setPen(Qt::NoPen);
|
||||
painter->setBrush(cardBackground);
|
||||
painter->drawRoundedRect(card, 6, 6);
|
||||
|
||||
QColor borderColor = selected ? palette.highlightedText().color() : palette.mid().color();
|
||||
borderColor.setAlpha(selected ? 100 : 130);
|
||||
painter->setBrush(Qt::NoBrush);
|
||||
painter->setPen(QPen(borderColor, 1));
|
||||
painter->drawRoundedRect(card.adjusted(0, 0, -1, -1), 6, 6);
|
||||
|
||||
QColor accentColor = accent;
|
||||
if (selected) {
|
||||
accentColor = palette.highlightedText().color();
|
||||
accentColor.setAlpha(210);
|
||||
}
|
||||
painter->setPen(Qt::NoPen);
|
||||
painter->setBrush(accentColor);
|
||||
painter->drawRoundedRect(QRect(card.left() + 7, card.top() + 7, 3, card.height() - 14), 1.5, 1.5);
|
||||
|
||||
const QColor textColor = selected ? palette.highlightedText().color() : palette.text().color();
|
||||
QColor mutedColor = textColor;
|
||||
mutedColor.setAlpha(170);
|
||||
|
||||
QFont timeFont = option.font;
|
||||
timeFont.setBold(true);
|
||||
timeFont.setPointSizeF(qMax(7.0, timeFont.pointSizeF() * 0.9));
|
||||
|
||||
QFont actorFont = option.font;
|
||||
actorFont.setBold(true);
|
||||
|
||||
QFont badgeFont = option.font;
|
||||
badgeFont.setBold(true);
|
||||
badgeFont.setPointSizeF(qMax(6.5, badgeFont.pointSizeF() * 0.72));
|
||||
|
||||
QFont detailFont = option.font;
|
||||
detailFont.setPointSizeF(qMax(7.0, detailFont.pointSizeF() * 0.95));
|
||||
|
||||
const QFontMetrics timeMetrics(timeFont);
|
||||
const QFontMetrics actorMetrics(actorFont);
|
||||
const QFontMetrics badgeMetrics(badgeFont);
|
||||
const QFontMetrics detailMetrics(detailFont);
|
||||
|
||||
const int contentLeft = card.left() + 15;
|
||||
const int contentRight = card.right() - 10;
|
||||
const int topRowTop = card.top() + 6;
|
||||
const int topRowHeight = qMax(timeMetrics.height(), badgeMetrics.height()) + 2;
|
||||
|
||||
const QString timeText = index.data(TimeLabelRole).toString();
|
||||
const QString actorText = index.data(ActorRole).toString();
|
||||
const QString badgeText = index.data(BadgeRole).toString();
|
||||
const QString detailText = index.data(DetailRole).toString();
|
||||
|
||||
const int timeWidth = timeMetrics.horizontalAdvance(timeText);
|
||||
painter->setFont(timeFont);
|
||||
painter->setPen(mutedColor);
|
||||
painter->drawText(QRect(contentLeft, topRowTop, timeWidth + 2, topRowHeight), Qt::AlignLeft | Qt::AlignVCenter,
|
||||
timeText);
|
||||
|
||||
int actorRight = contentRight;
|
||||
if (!badgeText.isEmpty()) {
|
||||
const int badgeWidth = badgeMetrics.horizontalAdvance(badgeText) + 10;
|
||||
const int badgeHeight = badgeMetrics.height() + 2;
|
||||
const QRect badgeRect(contentRight - badgeWidth, topRowTop + (topRowHeight - badgeHeight) / 2, badgeWidth,
|
||||
badgeHeight);
|
||||
|
||||
QColor badgeBackground = accentColor;
|
||||
badgeBackground.setAlpha(selected ? 45 : 32);
|
||||
painter->setPen(Qt::NoPen);
|
||||
painter->setBrush(badgeBackground);
|
||||
painter->drawRoundedRect(badgeRect, 3, 3);
|
||||
|
||||
painter->setFont(badgeFont);
|
||||
painter->setPen(accentColor);
|
||||
painter->drawText(badgeRect, Qt::AlignCenter, badgeText);
|
||||
|
||||
actorRight = badgeRect.left() - 8;
|
||||
}
|
||||
|
||||
const int actorLeft = contentLeft + timeWidth + 10;
|
||||
if (actorRight > actorLeft) {
|
||||
const QString elidedActor =
|
||||
actorMetrics.elidedText(actorText, Qt::ElideRight, qMax(0, actorRight - actorLeft));
|
||||
painter->setFont(actorFont);
|
||||
painter->setPen(textColor);
|
||||
painter->drawText(QRect(actorLeft, topRowTop, actorRight - actorLeft, topRowHeight),
|
||||
Qt::AlignLeft | Qt::AlignVCenter, elidedActor);
|
||||
}
|
||||
|
||||
const int detailTop = topRowTop + topRowHeight + 2;
|
||||
const int detailHeight = qMax(detailMetrics.height(), card.bottom() - detailTop);
|
||||
const QString elidedDetail =
|
||||
detailMetrics.elidedText(detailText, Qt::ElideRight, qMax(0, contentRight - contentLeft));
|
||||
painter->setFont(detailFont);
|
||||
painter->setPen(textColor);
|
||||
painter->drawText(QRect(contentLeft, detailTop, contentRight - contentLeft, detailHeight),
|
||||
Qt::AlignLeft | Qt::AlignVCenter, elidedDetail);
|
||||
|
||||
painter->restore();
|
||||
}
|
||||
|
||||
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex & /*index*/) const override
|
||||
{
|
||||
const QFontMetrics metrics(option.font);
|
||||
return {0, qMax(58, metrics.height() * 3 + 10)};
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
ReplayEventSidebar::ReplayEventSidebar(QWidget *parent, const GameReplay *replay, const QList<int> &timeline)
|
||||
: QWidget(parent), eventList(new QListWidget(this)), emptyLabel(new QLabel(this))
|
||||
{
|
||||
setObjectName("replayEventSidebar");
|
||||
|
||||
eventList->setObjectName("replayEventList");
|
||||
eventList->setItemDelegate(new ReplayEventDelegate(eventList));
|
||||
eventList->setFrameShape(QFrame::NoFrame);
|
||||
eventList->setFocusPolicy(Qt::NoFocus);
|
||||
eventList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
eventList->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
eventList->setMouseTracking(true);
|
||||
eventList->setUniformItemSizes(true);
|
||||
eventList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
|
||||
|
||||
emptyLabel->setAlignment(Qt::AlignCenter);
|
||||
emptyLabel->setWordWrap(true);
|
||||
emptyLabel->setMargin(16);
|
||||
emptyLabel->setForegroundRole(QPalette::PlaceholderText);
|
||||
|
||||
auto *layout = QtUtils::makeLayout<QVBoxLayout>(this);
|
||||
layout->addWidget(eventList);
|
||||
layout->addWidget(emptyLabel);
|
||||
|
||||
const QList<ReplayEventEntry> entries = buildReplayEventEntries(replay, timeline);
|
||||
eventIndices.reserve(entries.size());
|
||||
|
||||
for (const ReplayEventEntry &entry : entries) {
|
||||
auto *item = new QListWidgetItem(eventList);
|
||||
item->setData(EventIndexRole, entry.eventIndex);
|
||||
item->setData(TimeLabelRole, entry.timeLabel);
|
||||
item->setData(KindRole, static_cast<int>(entry.kind));
|
||||
item->setData(ActorRole, entry.actor);
|
||||
item->setData(BadgeRole, entry.badge);
|
||||
item->setData(DetailRole, entry.detail);
|
||||
const QString tooltip = entry.actor.isEmpty()
|
||||
? tr("%1\n%2").arg(entry.timeLabel, entry.detail)
|
||||
: tr("%1 · %2\n%3").arg(entry.timeLabel, entry.actor, entry.detail);
|
||||
item->setToolTip(tooltip);
|
||||
item->setData(Qt::AccessibleTextRole, entry.actor.isEmpty()
|
||||
? tr("%1: %2").arg(entry.timeLabel, entry.detail)
|
||||
: tr("%1, %2: %3").arg(entry.timeLabel, entry.actor, entry.detail));
|
||||
eventIndices.append(entry.eventIndex);
|
||||
}
|
||||
|
||||
const bool hasEvents = !entries.isEmpty();
|
||||
eventList->setVisible(hasEvents);
|
||||
emptyLabel->setVisible(!hasEvents);
|
||||
emptyLabel->setText(tr("No game events in this replay."));
|
||||
|
||||
connect(eventList, &QListWidget::itemClicked, this, [this](QListWidgetItem *item) {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentRow(eventList->row(item));
|
||||
emit eventSelected(item->data(EventIndexRole).toInt());
|
||||
});
|
||||
|
||||
if (hasEvents) {
|
||||
setCurrentRow(-1);
|
||||
}
|
||||
}
|
||||
|
||||
void ReplayEventSidebar::setCurrentEvent(int eventIndex)
|
||||
{
|
||||
if (eventIndices.isEmpty()) {
|
||||
setCurrentRow(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
// The rows hold a subset of the replay's events, in playback order, so the last row at or before eventIndex is the
|
||||
// one the game state has reached. An eventIndex of -1 (nothing replayed yet) selects no row.
|
||||
const auto it = std::upper_bound(eventIndices.cbegin(), eventIndices.cend(), eventIndex);
|
||||
const int row = static_cast<int>(it - eventIndices.cbegin()) - 1;
|
||||
setCurrentRow(row);
|
||||
}
|
||||
|
||||
void ReplayEventSidebar::setCurrentRow(int row)
|
||||
{
|
||||
if (row < 0 || row >= eventList->count()) {
|
||||
row = -1;
|
||||
}
|
||||
|
||||
if (row >= 0) {
|
||||
// The view's current index also moves on mouse press and can be deselected by a ctrl-click,
|
||||
// so the cached row alone cannot tell whether the view is already in the desired state.
|
||||
const QListWidgetItem *item = eventList->item(row);
|
||||
if (row == eventList->currentRow() && item && item->isSelected()) {
|
||||
return;
|
||||
}
|
||||
} else if (row == currentRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentRow = row;
|
||||
eventList->setCurrentRow(currentRow);
|
||||
|
||||
if (currentRow >= 0) {
|
||||
QListWidgetItem *item = eventList->item(currentRow);
|
||||
if (!eventList->viewport()->rect().contains(eventList->visualItemRect(item))) {
|
||||
eventList->scrollToItem(item, QAbstractItemView::PositionAtCenter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
#ifndef COCKATRICE_REPLAY_EVENT_SIDEBAR_H
|
||||
#define COCKATRICE_REPLAY_EVENT_SIDEBAR_H
|
||||
|
||||
#include <QList>
|
||||
#include <QWidget>
|
||||
#include <qtmetamacros.h>
|
||||
|
||||
class GameReplay;
|
||||
class QLabel;
|
||||
class QListWidget;
|
||||
|
||||
/**
|
||||
* @brief A scrollable list of replay events that can be used to seek the replay.
|
||||
*
|
||||
* The sidebar copies the event data it needs at construction time. It does not keep a reference to the replay or to
|
||||
* the replay manager.
|
||||
*/
|
||||
class ReplayEventSidebar : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
/**
|
||||
* @param parent The parent widget
|
||||
* @param replay The replay to build the event list from
|
||||
* @param timeline Event index to time in milliseconds, as used by ReplayManager
|
||||
*/
|
||||
ReplayEventSidebar(QWidget *parent, const GameReplay *replay, const QList<int> &timeline);
|
||||
|
||||
public slots:
|
||||
/**
|
||||
* @brief Highlights the row of the event the game state has reached.
|
||||
*
|
||||
* @param eventIndex Index into the replay's event list, or -1 when nothing has been replayed yet
|
||||
*/
|
||||
void setCurrentEvent(int eventIndex);
|
||||
|
||||
signals:
|
||||
void eventSelected(int eventIndex);
|
||||
|
||||
private:
|
||||
void setCurrentRow(int row);
|
||||
|
||||
QListWidget *eventList;
|
||||
QLabel *emptyLabel;
|
||||
QList<int> eventIndices; ///< Replay event index of each row, ascending
|
||||
int currentRow = -1;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_REPLAY_EVENT_SIDEBAR_H
|
||||
|
|
@ -66,6 +66,16 @@ public:
|
|||
return replayTimeline;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Index of the last event the game state has processed.
|
||||
*
|
||||
* @return The event index, or -1 while no event has been replayed yet
|
||||
*/
|
||||
[[nodiscard]] int getLastProcessedEventIndex() const
|
||||
{
|
||||
return currentEvent - 1;
|
||||
}
|
||||
|
||||
void setTimeScaleFactor(qreal _timeScaleFactor);
|
||||
void setSkipEmptySections(bool value);
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@
|
|||
|
||||
class GameReplay;
|
||||
|
||||
int ReplayWidget::getLastProcessedEventIndex() const
|
||||
{
|
||||
return replayManager->getLastProcessedEventIndex();
|
||||
}
|
||||
|
||||
ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay)
|
||||
: QWidget(parent), replayPlayButton(nullptr), replayFastForwardButton(nullptr), aReplaySkipForward(nullptr),
|
||||
aReplaySkipBackward(nullptr), aReplaySkipForwardBig(nullptr), aReplaySkipBackwardBig(nullptr)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ public:
|
|||
|
||||
[[nodiscard]] const QList<int> &getReplayTimeline() const;
|
||||
|
||||
/**
|
||||
* @brief Index of the last replay event the game state has processed, or -1 before the first event.
|
||||
*/
|
||||
[[nodiscard]] int getLastProcessedEventIndex() const;
|
||||
|
||||
public slots:
|
||||
void seekToEvent(int eventIndex);
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
#include "../interface/widgets/utility/line_edit_completer.h"
|
||||
#include "../main.h"
|
||||
#include "../menus/tearoff_menu.h"
|
||||
#include "../replay/replay_event_sidebar.h"
|
||||
#include "../replay/replay_widget.h"
|
||||
#include "../utility/visibility_change_listener.h"
|
||||
#include "card/card_completer_proxy_model.h"
|
||||
|
|
@ -97,7 +98,7 @@ class Message;
|
|||
} // namespace google
|
||||
|
||||
TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay)
|
||||
: Tab(_tabSupervisor), sayLabel(nullptr), sayEdit(nullptr)
|
||||
: Tab(_tabSupervisor), sayLabel(nullptr), sayEdit(nullptr), replayDock(nullptr), historyDock(nullptr)
|
||||
{
|
||||
// THIS CTOR IS USED ON REPLAY
|
||||
game = new Replay(this, _replay, tabSupervisor->getIsLocalGame(), &SettingsCache::instance().userInterface(),
|
||||
|
|
@ -113,6 +114,7 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay)
|
|||
addDockWidget(Qt::RightDockWidgetArea, cardInfoDock);
|
||||
addDockWidget(Qt::RightDockWidgetArea, playerListDock);
|
||||
addDockWidget(Qt::RightDockWidgetArea, messageLayoutDock);
|
||||
addDockWidget(Qt::LeftDockWidgetArea, historyDock);
|
||||
addDockWidget(Qt::BottomDockWidgetArea, replayDock);
|
||||
|
||||
mainWidget = new QStackedWidget(this);
|
||||
|
|
@ -154,6 +156,7 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor,
|
|||
createPlayAreaWidget();
|
||||
createDeckViewContainerWidget();
|
||||
replayDock = nullptr;
|
||||
historyDock = nullptr;
|
||||
|
||||
addDockWidget(Qt::RightDockWidgetArea, cardInfoDock);
|
||||
addDockWidget(Qt::RightDockWidgetArea, playerListDock);
|
||||
|
|
@ -345,6 +348,9 @@ void TabGame::retranslateUi()
|
|||
}
|
||||
cardInfoDock->setWindowTitle(tr("Card Info") + (cardInfoDock->isWindow() ? tabText : QString()));
|
||||
messageLayoutDock->setWindowTitle(tr("Messages") + (messageLayoutDock->isWindow() ? tabText : QString()));
|
||||
if (historyDock) {
|
||||
historyDock->setWindowTitle(tr("Game History") + (historyDock->isWindow() ? tabText : QString()));
|
||||
}
|
||||
if (replayDock) {
|
||||
replayDock->setWindowTitle(tr("Replay Timeline") + (replayDock->isWindow() ? tabText : QString()));
|
||||
}
|
||||
|
|
@ -418,6 +424,9 @@ void TabGame::retranslateUi()
|
|||
dockToActions[messageLayoutDock].menu->setTitle(tr("Messages"));
|
||||
dockToActions[playerListDock].menu->setTitle(tr("Player List"));
|
||||
|
||||
if (historyDock) {
|
||||
dockToActions[historyDock].menu->setTitle(tr("Game History"));
|
||||
}
|
||||
if (replayDock) {
|
||||
dockToActions[replayDock].menu->setTitle(tr("Replay Timeline"));
|
||||
}
|
||||
|
|
@ -1192,6 +1201,10 @@ void TabGame::createViewMenuItems()
|
|||
registerDockWidget(viewMenu, messageLayoutDock, {250, 200});
|
||||
registerDockWidget(viewMenu, playerListDock, {250, 50});
|
||||
|
||||
if (historyDock) {
|
||||
registerDockWidget(viewMenu, historyDock, {280, 400});
|
||||
}
|
||||
|
||||
if (replayDock) {
|
||||
registerDockWidget(viewMenu, replayDock, {900, 100});
|
||||
}
|
||||
|
|
@ -1261,6 +1274,12 @@ void TabGame::actResetLayout()
|
|||
addDockWidget(Qt::RightDockWidgetArea, playerListDock);
|
||||
addDockWidget(Qt::RightDockWidgetArea, messageLayoutDock);
|
||||
|
||||
if (historyDock) {
|
||||
historyDock->setVisible(true);
|
||||
historyDock->setFloating(false);
|
||||
addDockWidget(Qt::LeftDockWidgetArea, historyDock);
|
||||
}
|
||||
|
||||
if (replayDock) {
|
||||
replayDock->setVisible(true);
|
||||
replayDock->setFloating(false);
|
||||
|
|
@ -1269,6 +1288,9 @@ void TabGame::actResetLayout()
|
|||
cardInfoDock->resize(250, 360);
|
||||
messageLayoutDock->resize(250, 200);
|
||||
playerListDock->resize(250, 50);
|
||||
if (historyDock) {
|
||||
historyDock->resize(280, 400);
|
||||
}
|
||||
replayDock->resize(900, 100);
|
||||
} else {
|
||||
cardInfoDock->resize(250, 360);
|
||||
|
|
@ -1322,6 +1344,22 @@ void TabGame::createReplayDock(GameReplay *replay)
|
|||
[this](const auto &event, auto options) {
|
||||
game->getGameEventHandler()->processGameEventContainer(event, nullptr, options);
|
||||
});
|
||||
|
||||
auto *historySidebar = new ReplayEventSidebar(nullptr, replay, replayWidget->getReplayTimeline());
|
||||
historyDock = new QDockWidget(this);
|
||||
historyDock->setObjectName("historyDock");
|
||||
historyDock->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetFloatable |
|
||||
QDockWidget::DockWidgetMovable);
|
||||
historyDock->setWidget(historySidebar);
|
||||
historyDock->setFloating(false);
|
||||
|
||||
connect(historySidebar, &ReplayEventSidebar::eventSelected, replayWidget, &ReplayWidget::seekToEvent);
|
||||
// The sidebar highlights events rather than times, so ask the manager which event the game state has reached.
|
||||
connect(replayWidget, &ReplayWidget::timeChanged, historySidebar,
|
||||
[this, historySidebar] { historySidebar->setCurrentEvent(replayWidget->getLastProcessedEventIndex()); });
|
||||
|
||||
// No event has been replayed yet when the sidebar is connected, so apply the initial state explicitly.
|
||||
historySidebar->setCurrentEvent(replayWidget->getLastProcessedEventIndex());
|
||||
}
|
||||
|
||||
void TabGame::createDeckViewContainerWidget(bool bReplay)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ class AbstractClient;
|
|||
class GameView;
|
||||
class GameScene;
|
||||
class ReplayWidget;
|
||||
class ReplayEventSidebar;
|
||||
class CardInfoFrameWidget;
|
||||
class QLabel;
|
||||
class QPushButton;
|
||||
|
|
@ -89,7 +90,7 @@ private:
|
|||
QMap<int, TabbedDeckViewContainer *> deckViewContainers;
|
||||
QVBoxLayout *deckViewContainerLayout;
|
||||
QWidget *gamePlayAreaWidget, *deckViewContainerWidget;
|
||||
QDockWidget *cardInfoDock, *messageLayoutDock, *playerListDock, *replayDock;
|
||||
QDockWidget *cardInfoDock, *messageLayoutDock, *playerListDock, *replayDock, *historyDock;
|
||||
QAction *playersSeparator;
|
||||
QMenu *gameMenu, *viewMenu;
|
||||
TearOffMenu *phasesMenu;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue