From 67da94217a271b0b76d42c75d1a78ee5ba942825 Mon Sep 17 00:00:00 2001 From: "Steinar H. Gunderson" Date: Mon, 29 May 2023 14:44:12 +0200 Subject: [PATCH] Add the beginnings of formation support. --- events.cpp | 83 ++++++++++++-- events.h | 10 +- formations.cpp | 80 ++++++++++++++ formations.h | 45 ++++++++ main.cpp | 70 +++++++++++- mainwindow.h | 7 +- mainwindow.ui | 294 ++++++++++++++++++++++++++++--------------------- meson.build | 2 +- 8 files changed, 446 insertions(+), 145 deletions(-) create mode 100644 formations.cpp create mode 100644 formations.h diff --git a/events.cpp b/events.cpp index e6ed579..bb915b2 100644 --- a/events.cpp +++ b/events.cpp @@ -24,7 +24,7 @@ QVariant EventsModel::headerData(int section, Qt::Orientation orientation, int r if (section == 0) { return "Time"; } else if (section == 1) { - return "Player"; + return "Who/what"; } else { return "Type"; } @@ -42,10 +42,15 @@ QVariant EventsModel::data(const QModelIndex &index, int role) const return QString::fromUtf8(format_timestamp(events[index.row()].t)); } else if (index.column() == 1) { optional player_id = events[index.row()].player_id; + optional formation_id = events[index.row()].formation_id; if (player_id) { auto p_it = players.find(*player_id); const Player &p = p_it->second; return QString::fromUtf8(p.name + " (" + p.number + ")"); + } else if (formation_id) { + auto f_it = formations.find(*formation_id); + const Formation &p = f_it->second; + return QString::fromUtf8(p.name); } else { return QVariant(); } @@ -90,8 +95,34 @@ void EventsModel::load_data() abort(); } + // Read the formations. + ret = sqlite3_prepare_v2(db, "SELECT formation, name FROM formation", -1, &stmt, 0); + if (ret != SQLITE_OK) { + fprintf(stderr, "SELECT prepare: %s\n", sqlite3_errmsg(db)); + abort(); + } + for ( ;; ) { + ret = sqlite3_step(stmt); + if (ret == SQLITE_ROW) { + Formation f; + f.formation_id = sqlite3_column_int(stmt, 0); + f.name = (const char *) sqlite3_column_text(stmt, 1); + formations[f.formation_id] = std::move(f); + } else if (ret == SQLITE_DONE) { + break; + } else { + fprintf(stderr, "SELECT step: %s\n", sqlite3_errmsg(db)); + abort(); + } + } + ret = sqlite3_finalize(stmt); + if (ret != SQLITE_OK) { + fprintf(stderr, "SELECT finalize: %s\n", sqlite3_errmsg(db)); + abort(); + } + // Read the events. - ret = sqlite3_prepare_v2(db, "SELECT event, t, player, type FROM event WHERE match=? ORDER BY t", -1, &stmt, 0); + ret = sqlite3_prepare_v2(db, "SELECT event, t, player, formation, type FROM event WHERE match=? ORDER BY t", -1, &stmt, 0); if (ret != SQLITE_OK) { fprintf(stderr, "SELECT prepare: %s\n", sqlite3_errmsg(db)); abort(); @@ -106,7 +137,10 @@ void EventsModel::load_data() if (sqlite3_column_type(stmt, 2) == SQLITE_INTEGER) { // Non-NULL. e.player_id = sqlite3_column_int(stmt, 2); } - e.type = (const char *)sqlite3_column_text(stmt, 3); + if (sqlite3_column_type(stmt, 3) == SQLITE_INTEGER) { // Non-NULL. + e.formation_id = sqlite3_column_int(stmt, 3); + } + e.type = (const char *)sqlite3_column_text(stmt, 4); events.push_back(std::move(e)); } else if (ret == SQLITE_DONE) { break; @@ -122,7 +156,7 @@ void EventsModel::load_data() } } -unsigned EventsModel::insert_event(uint64_t t, optional player_id, const string &type) +unsigned EventsModel::insert_event(uint64_t t, optional player_id, optional formation_id, const string &type) { auto it = lower_bound(events.begin(), events.end(), t, [](const Event &e, uint64_t t) { return e.t < t; }); @@ -139,7 +173,7 @@ unsigned EventsModel::insert_event(uint64_t t, optional player_id, const st // Insert the new row into the database. sqlite3_stmt *stmt; - int ret = sqlite3_prepare_v2(db, "INSERT INTO event (match, t, player, type) VALUES (?, ?, ?, ?)", -1, &stmt, 0); + int ret = sqlite3_prepare_v2(db, "INSERT INTO event (match, t, player, formation, type) VALUES (?, ?, ?, ?, ?)", -1, &stmt, 0); if (ret != SQLITE_OK) { fprintf(stderr, "INSERT prepare: %s\n", sqlite3_errmsg(db)); abort(); @@ -152,7 +186,12 @@ unsigned EventsModel::insert_event(uint64_t t, optional player_id, const st } else { sqlite3_bind_null(stmt, 3); } - sqlite3_bind_text(stmt, 4, type.data(), type.size(), SQLITE_STATIC); + if (formation_id) { + sqlite3_bind_int64(stmt, 4, *formation_id); + } else { + sqlite3_bind_null(stmt, 4); + } + sqlite3_bind_text(stmt, 5, type.data(), type.size(), SQLITE_STATIC); ret = sqlite3_step(stmt); if (ret == SQLITE_ROW) { @@ -385,16 +424,44 @@ void EventsModel::set_team_at(uint64_t t, const set &new_team) set old_team = get_team_at(backdate_point); for (int player_id : old_team) { if (!new_team.count(player_id)) { - insert_event(backdate_point, player_id, "out"); + insert_event(backdate_point, player_id, nullopt, "out"); } } for (int player_id : new_team) { if (!old_team.count(player_id)) { - insert_event(backdate_point, player_id, "in"); + insert_event(backdate_point, player_id, nullopt, "in"); } } } +void EventsModel::set_formation_at(uint64_t t, bool offense, unsigned formation) +{ + // If there's another goal/stoppage no more than 20 seconds ago, + // we assume that the formation started at that point (it just took + // the operator a bit of time to see it). If not, we assume we + // changed in the middle of a point. + uint64_t backdate_point = 0; + for (const Event &e : events) { + if (e.t > t) { + break; + } + if (e.type == "goal" || e.type == "their_goal" || e.type == "stoppage" || e.type == "reset" || e.type == "set_offense" || e.type == "set_defense" || e.type == "in" || e.type == "out") { + backdate_point = e.t + 1; + } + if (e.type == "formation_offense" || e.type == "formation_defense") { + backdate_point = 0; + } + } + if (backdate_point != 0 && t - backdate_point < 20000) { + t = backdate_point; + } + if (offense) { + insert_event(t, nullopt, formation, "formation_offense"); + } else { + insert_event(t, nullopt, formation, "formation_defense"); + } +} + vector EventsModel::sort_team(const set &team) const { vector ret(team.begin(), team.end()); diff --git a/events.h b/events.h index caf832a..545437b 100644 --- a/events.h +++ b/events.h @@ -25,7 +25,7 @@ public: QVariant headerData(int section, Qt::Orientation orientation, int role) const override; QVariant data(const QModelIndex &index, int role) const override; - unsigned insert_event(uint64_t t, std::optional player_id, const std::string &type = "unknown"); // Returns the row. + unsigned insert_event(uint64_t t, std::optional player_id, std::optional formation_id, const std::string &type = "unknown"); // Returns the row. void delete_event(unsigned row); void set_event_type(unsigned row, const std::string &type); uint64_t get_time(unsigned row) { return events[row].t; } @@ -48,6 +48,7 @@ public: std::set get_team_at(uint64_t t); void set_team_at(uint64_t, const std::set &new_team); std::vector sort_team(const std::set &team) const; // Ordered first by gender, then by number. + void set_formation_at(uint64_t t, bool offense, unsigned formation); private: struct Player { @@ -58,10 +59,17 @@ private: std::map players; std::map player_ordering; // From id to position. + struct Formation { + int formation_id; + std::string name; + }; + std::map formations; + struct Event { int event_id; uint64_t t; std::optional player_id; + std::optional formation_id; std::string type; }; std::vector events; diff --git a/formations.cpp b/formations.cpp new file mode 100644 index 0000000..fa2337b --- /dev/null +++ b/formations.cpp @@ -0,0 +1,80 @@ +#include +#include +#include +#include "formations.h" + +using namespace std; + +FormationsModel::FormationsModel(sqlite3 *db, bool offense) : db(db), offense(offense) +{ + load_data(); +} + +QVariant FormationsModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (role != Qt::DisplayRole) { + return QVariant(); + } + if (orientation == Qt::Horizontal) { + if (section == 0) { + return "Name"; + } else { + return QVariant(); + } + } else { + return ""; + } +} + +QVariant FormationsModel::data(const QModelIndex &index, int role) const +{ + if (role == Qt::TextAlignmentRole) { + return (Qt::AlignLeft | Qt::AlignVCenter).toInt(); + } + if (role != Qt::DisplayRole) { + return QVariant(); + } + if (index.column() == 0) { + if (index.row() == 0) { + return QString::fromUtf8("(None/unknown)"); + } else if (index.row() == formations.size() + 1) { + return QString::fromUtf8("Add new…"); + } else { + return QString::fromUtf8(formations[index.row() - 1].name); + } + } + return QVariant(); +} + +void FormationsModel::load_data() +{ + formations.clear(); + + // Read the formations. + sqlite3_stmt *stmt; + int ret = sqlite3_prepare_v2(db, "SELECT formation, name FROM formation WHERE offense=? ORDER BY name", -1, &stmt, 0); + if (ret != SQLITE_OK) { + fprintf(stderr, "SELECT prepare: %s\n", sqlite3_errmsg(db)); + abort(); + } + sqlite3_bind_int(stmt, 1, offense); + for ( ;; ) { + ret = sqlite3_step(stmt); + if (ret == SQLITE_ROW) { + Formation f; + f.formation_id = sqlite3_column_int(stmt, 0); + f.name = (const char *)sqlite3_column_text(stmt, 1); + formations.push_back(f); + } else if (ret == SQLITE_DONE) { + break; + } else { + fprintf(stderr, "SELECT step: %s\n", sqlite3_errmsg(db)); + abort(); + } + } + ret = sqlite3_finalize(stmt); + if (ret != SQLITE_OK) { + fprintf(stderr, "SELECT finalize: %s\n", sqlite3_errmsg(db)); + abort(); + } +} diff --git a/formations.h b/formations.h new file mode 100644 index 0000000..5eda723 --- /dev/null +++ b/formations.h @@ -0,0 +1,45 @@ +#ifndef _FORMATIONS_H +#define _FORMATIONS_H 1 + +#include +#include +#include +#include + +class FormationsModel : public QAbstractListModel +{ +public: + FormationsModel(sqlite3 *db, bool offense); + + int rowCount(const QModelIndex &parent) const override + { + return formations.size() + 2; + } + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + QVariant data(const QModelIndex &index, int role) const override; + + int get_formation_id(unsigned row) const { + if (row == 0) { + return 0; + } + if (row == formations.size() + 1) { + return -1; + } + return formations[row - 1].formation_id; + } + std::string get_formation_name_by_id(unsigned formation_id); + +private: + struct Formation { + int formation_id; + std::string name; + }; + std::vector formations; + + sqlite3 *db; + bool offense; + + void load_data(); +}; + +#endif // !defined(_FORMATIONS_H) diff --git a/main.cpp b/main.cpp index 4383f2e..d23c63e 100644 --- a/main.cpp +++ b/main.cpp @@ -15,6 +15,7 @@ #include "ui_mainwindow.h" #include "events.h" #include "players.h" +#include "formations.h" #include "json.h" using namespace std; @@ -33,7 +34,9 @@ string format_timestamp(uint64_t pos) return buf; } -MainWindow::MainWindow(EventsModel *events, PlayersModel *players) : events(events), players(players) +MainWindow::MainWindow(EventsModel *events, PlayersModel *players, + FormationsModel *offensive_formations, FormationsModel *defensive_formations) + : events(events), players(players), offensive_formations(offensive_formations), defensive_formations(defensive_formations) { video = new QMediaPlayer; //video->setSource(QUrl::fromLocalFile("/home/sesse/dev/stats/ultimate.mkv")); @@ -62,6 +65,20 @@ MainWindow::MainWindow(EventsModel *events, PlayersModel *players) : events(even ui->player_view->setColumnWidth(1, 20); ui->player_view->horizontalHeader()->setStretchLastSection(true); + auto formation_changed = [this](const QModelIndex ¤t, const QModelIndex &previous) { + update_action_buttons(video->position()); + }; + ui->offensive_formation_view->setModel(offensive_formations); + ui->defensive_formation_view->setModel(defensive_formations); + connect(ui->offensive_formation_view->selectionModel(), &QItemSelectionModel::currentRowChanged, formation_changed); + connect(ui->defensive_formation_view->selectionModel(), &QItemSelectionModel::currentRowChanged, formation_changed); + connect(ui->offensive_formation_view, &QListView::doubleClicked, [this](const QModelIndex &index) { + formation_double_clicked(true, index.row()); + }); + connect(ui->defensive_formation_view, &QListView::doubleClicked, [this](const QModelIndex &index) { + formation_double_clicked(false, index.row()); + }); + connect(video, &QMediaPlayer::positionChanged, [this](uint64_t pos) { position_changed(pos); }); @@ -217,9 +234,9 @@ void MainWindow::insert_player_event(int button_id) ui->event_view->selectionModel()->blockSignals(true); if (s.attack_state == EventsModel::Status::OFFENSE) { // TODO: Perhaps not if that player already did the last catch? - ui->event_view->selectRow(events->insert_event(t, player_id, "catch")); + ui->event_view->selectRow(events->insert_event(t, player_id, nullopt, "catch")); } else { - ui->event_view->selectRow(events->insert_event(t, player_id)); + ui->event_view->selectRow(events->insert_event(t, player_id, nullopt)); } ui->event_view->selectionModel()->blockSignals(false); @@ -231,7 +248,7 @@ void MainWindow::insert_noplayer_event(const string &type) uint64_t t = video->position(); ui->event_view->selectionModel()->blockSignals(true); - ui->event_view->selectRow(events->insert_event(t, nullopt, type)); + ui->event_view->selectRow(events->insert_event(t, nullopt, nullopt, type)); ui->event_view->selectionModel()->blockSignals(false); update_ui_from_time(t); @@ -334,6 +351,25 @@ void MainWindow::update_player_buttons(uint64_t t) void MainWindow::update_action_buttons(uint64_t t) { + { + QItemSelectionModel *select = ui->offensive_formation_view->selectionModel(); + if (select->hasSelection()) { + int row = select->selectedRows().front().row(); // Should only be one, due to our selection behavior. + ui->offensive_formation->setEnabled(offensive_formations->get_formation_id(row) != -1); + } else { + ui->offensive_formation->setEnabled(false); + } + } + { + QItemSelectionModel *select = ui->defensive_formation_view->selectionModel(); + if (select->hasSelection()) { + int row = select->selectedRows().front().row(); // Should only be one, due to our selection behavior. + ui->defensive_formation->setEnabled(defensive_formations->get_formation_id(row) != -1); + } else { + ui->defensive_formation->setEnabled(false); + } + } + EventsModel::Status s = events->get_status_at(t); bool has_selection = false; @@ -442,6 +478,23 @@ void MainWindow::update_action_buttons(uint64_t t) ui->their_pull->setEnabled(false); } +void MainWindow::formation_double_clicked(bool offense, unsigned row) +{ + FormationsModel *formations = offense ? offensive_formations : defensive_formations; + int id = formations->get_formation_id(row); + if (id == -1) { // “Add new” clicked. + bool ok; + QString new_formation_str = QInputDialog::getText(this, "New formation", "Choose name for new formation:", QLineEdit::Normal, "", &ok); + if (!ok || new_formation_str.isEmpty()) { + return; + } + + // FIXME insert + } else { + events->set_formation_at(video->position(), offense, id); + } +} + sqlite3 *open_db(const char *filename) { sqlite3 *db; @@ -460,7 +513,11 @@ sqlite3 *open_db(const char *filename) )", nullptr, nullptr, nullptr); // Ignore errors. sqlite3_exec(db, R"( - CREATE TABLE IF NOT EXISTS event (event INTEGER PRIMARY KEY, match INTEGER, t INTEGER, player INTEGER, type VARCHAR, FOREIGN KEY (player) REFERENCES player(player), FOREIGN KEY (match) REFERENCES match (match)); + CREATE TABLE IF NOT EXISTS formation (formation INTEGER PRIMARY KEY, name VARCHAR, offense BOOLEAN NOT NULL); + )", nullptr, nullptr, nullptr); // Ignore errors. + + sqlite3_exec(db, R"( + CREATE TABLE IF NOT EXISTS event (event INTEGER PRIMARY KEY, match INTEGER, t INTEGER, player INTEGER, type VARCHAR, formation INTEGER, FOREIGN KEY (player) REFERENCES player(player), FOREIGN KEY (match) REFERENCES match (match), FOREIGN KEY (formation) REFERENCES formation (formation)); )", nullptr, nullptr, nullptr); // Ignore errors. sqlite3_exec(db, "PRAGMA journal_mode=WAL", nullptr, nullptr, nullptr); // Ignore errors. @@ -580,7 +637,8 @@ int main(int argc, char *argv[]) return 0; } - MainWindow mainWindow(new EventsModel(db, match_id), new PlayersModel(db)); + MainWindow mainWindow(new EventsModel(db, match_id), new PlayersModel(db), + new FormationsModel(db, true), new FormationsModel(db, false)); mainWindow.resize(QSize(1280, 720)); mainWindow.show(); diff --git a/mainwindow.h b/mainwindow.h index 2d702c7..83ed909 100644 --- a/mainwindow.h +++ b/mainwindow.h @@ -7,13 +7,15 @@ class EventsModel; class PlayersModel; +class FormationsModel; class MainWindow : public QMainWindow { Q_OBJECT public: - MainWindow(EventsModel *events, PlayersModel *players); + MainWindow(EventsModel *events, PlayersModel *players, + FormationsModel *offensive_formations, FormationsModel *defensive_formations); private: void position_changed(uint64_t pos); @@ -23,6 +25,7 @@ private: void set_current_event_type(const std::string &type); void delete_current_event(); void make_substitution(); + void formation_double_clicked(bool offense, unsigned row); void update_ui_from_time(uint64_t t); void update_status(uint64_t t); @@ -32,6 +35,8 @@ private: Ui::MainWindow *ui; EventsModel *events; PlayersModel *players; + FormationsModel *offensive_formations; + FormationsModel *defensive_formations; bool seeking = false; bool playing = true; std::optional buffered_seek; diff --git a/mainwindow.ui b/mainwindow.ui index c672fd5..0c92265 100644 --- a/mainwindow.ui +++ b/mainwindow.ui @@ -16,128 +16,8 @@ - - - - - - 320 - 240 - - - - - - - - QAbstractItemView::SingleSelection - - - QAbstractItemView::SelectRows - - - - - - - - - 0:00:00.000 - - - Qt::AlignCenter - - - - - - - -10s (&K) - - - K - - - - - - - +10s (&L) - - - L - - - - - - - -2s (&←) - - - Left - - - - - - - +2s (&→) - - - Right - - - - - - - -1f (&,) - - - , - - - - - - - +1f (&.) - - - . - - - - - - - - 110 - 0 - - - - Pause (space) - - - Space - - - - - - - 0–0 | offense | 0 passes, 0 sec possession - - - Qt::AlignCenter - - - - - - + + @@ -246,12 +126,12 @@ - + - false + true - + Formation (&o) @@ -390,12 +270,12 @@ - + - false + true - + Formation (&o) O @@ -470,6 +350,164 @@ + + + + + 320 + 240 + + + + + + + + + + 0:00:00.000 + + + Qt::AlignCenter + + + + + + + -10s (&K) + + + K + + + + + + + +10s (&L) + + + L + + + + + + + -2s (&←) + + + Left + + + + + + + +2s (&→) + + + Right + + + + + + + -1f (&,) + + + , + + + + + + + +1f (&.) + + + . + + + + + + + + 110 + 0 + + + + Pause (space) + + + Space + + + + + + + 0–0 | offense | 0 passes, 0 sec possession + + + Qt::AlignCenter + + + + + + + + + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + + + + + + + Offensive formation + + + Qt::AlignCenter + + + + + + + + + + + + + + Defensive formation + + + Qt::AlignCenter + + + + + + + + + + diff --git a/meson.build b/meson.build index ae33315..02cb586 100644 --- a/meson.build +++ b/meson.build @@ -9,4 +9,4 @@ qt_files = qt6.preprocess( ui_files: ['mainwindow.ui'], dependencies: qt6deps) -executable('stats', ['main.cpp', 'events.cpp', 'players.cpp', 'json.cpp'], qt_files, dependencies: [qt6deps, sqlite3dep]) +executable('stats', ['main.cpp', 'events.cpp', 'players.cpp', 'formations.cpp', 'json.cpp'], qt_files, dependencies: [qt6deps, sqlite3dep]) -- 2.39.2