Skip to content
Alexander Akulich edited this page Jun 29, 2018 · 92 revisions

Внимание! Выполнять задания строго последовательно!

Общая задача — дописать игровое приложение "Сапёр".

Список задач

Нулевое задание

  1. Реализовать отображение количества мин вокруг
  2. Сделать так, чтобы отображались только открытые клетки
  3. Закрашивать закрытые клетки серым прямоугольником
  4. Центрировать текст клетки
  5. Открытие соседних клеток
  6. Добавить возможность начать игру заново
  7. Генерация поля с учётом первой открытой клетки
  8. Мелкие доработки интерфейса
  9. Открытие всех клеток при открытии мины
  10. Отметки для клеток
  11. Ускоренное открытие клеток
  12. Field должен быть дочерним классом QObject
  13. Cell должен быть дочерним классом QObject
  14. Исправляем взаимодействие Cell<->Field
  15. Отображение количества оставшихся мин
  16. Обработка завершения игры
  17. Улучшаем внешний вид
  18. Добавление уровней сложности
  19. Открытие поля при победе
  20. QMLификация поля
  21. QMLификация клетки
  22. Создание QQuickView
  23. Начальная реализация main.qml
  24. Создание CellItem и взаимодействие с Cell
  25. Завершение реализации CellItem
  26. Добавление gameStateItem
  27. Поддержка изменения размера поля
  28. Начало новой игры из QML
  29. Автомасштабирование
  30. Таймер

Дополнительная информация для выполнения работы

1. Ключевое слово static

Ключевое слово static будем разбирать на следующем занятии. Всё же, для текущей практики вам нужно понять как минимум следующее:

1.1 Использование static при объявлении переменной функции

Для понимания и выполнения текущей практики достаточно знать, что переменная, объявленная как static, инициализируется при первом использовании, остаётся в памяти после выхода из функции и сохраняет своё последнее значение.

Пример:

#include <iostream>

void fun()
{
    static int a = 5;
    std::cout << a << std::endl;
    ++a;
}

int main(int argc, char *argv[])
{
    fun();
    fun();
    fun();

    return 0;
}

Вывод:

5
6
7

Ещё один пример:

void fun()
{
    static const int size = 10;
    std::cout << size << std::endl;
}

В данном случае компилятор может разместить переменную константу size в доступной только на чтение области данных, то есть программе не придётся её создавать при входе в функцию fun().

1.2 Использование static при объявлении полей и методов класса

Поля и методы класса, объявленные как static, не привязаны ни к какому объекту.

Значение статического поля можно получить как через объект класса, так и используя именя класса как пространство имён:

// В заголовочном файле:
class XmlData : public QObject
{
    Q_OBJECT
public:
    explicit CXmlData(QObject *parent = nullptr);
    ~CXmlData() override;

    static const int latestFormat;

    int format() const;

    bool load();
};

// Где-нибудь в реализации:
const int XmlData::latestFormat = 10;

void loadData() {
    XmlData data;
    data.load();

    if (data.format() != data.latestFormat) { // Доступ через объект
        qDebug() << "Format of the loaded data is outdated:" << data.format();
    }
}

void printLatestFormat()
{
    qDebug() << "Format:" << XmlData::latestFormat; // Доступ через имя класса
}

У статических методов нет указателя this и нет доступа к обычным полям.

Сокращённый, но реальный пример использования:

class CBinder
{
public:
    static CBinder *bindProperties(QObject *source, const QString &sourceProperty,
                                   QObject *target, const QString &targetProperty)
    {
        if (!source || !target) {
            return nullptr;
        }

        if (!source->hasProperty(sourceProperty)) {
            return nullptr;
        }
        
        if (!target->hasProperty(targetProperty)) {
            return nullptr;
        }

        return new CBinder(source, sourceProperty, target, targetProperty);
    }

protected:
    explicit CBinder(QObject *source, const QString &sourceProperty,
                     QObject *target, const QString &targetProperty);

};

int main(int argc, char *argv[])
{
    QObject *source = getSomeSource();
    QString sourceProperty = getSomeProperty();
    QObject *target = getSomeTarget();
    QString targetProperty = getSomeTargetProperty();
    
    // Ошибка компиляции: конструктор CBinder не доступен (не public)
    CBinder binder(source, sourceProperty, target, targetProperty);
    
    // То же самое
    CBinder *binder = new CBinder(source, sourceProperty, target, targetProperty);
    
    // А вот это - сработает.
    CBinder *binder = CBinder::bindProperties(source, sourceProperty, target, targetProperty);
    
    return 0;
}

Статические методы и статические поля можно использовать через объекты, но, для того, чтобы сразу дать понять, что происходит обращение к статическому члену класса и тем самым повысить читаемость кода, в 99.9% случаев лучше обращаться через имя класса, а не имя объекта.

1.2.1 QString

В классе QString есть метод static QString number(int n, int base = 10); Для того, чтобы получить строку str с целым числом 99, можно написать

QString str = QString::number(99);

Чтобы получить строку hexStr с шестнадцатиричным представлением целого числа 255, можно написать

QString str = QString::number(255, 16);

А вообще - смотрите справку (такая же справка доступна в Qt Creator по клавише F1).

2 Range-based for

В стандарте C++11 добавлена новая форма для оператора for, позволяющая перебирать элементы контейнера без итерационной переменной.

Синтаксис

for (ТипПеременной переменная : контейнер) {
    операторы, действия над переменной;
}

Примеры

for (Cell *cell : m_cells) {
    cell->open();
}

Сравнение с обычным синтаксисом for ()

for (int i = 0; i < m_cells.count(); ++i) {
    m_cells.at(i)->open();
}

3 foreach

foreach — это "искусственный" оператор Qt, который использовался для того же перебора элементов контейнера без итерационной переменной до появления стандарта C++11. Лучше использовать range-based for, но в уже написанном коде может попадаться использование foreach, поэтому будет неплохо ознакомиться и с его синтаксисом.

Синтаксис

foreach (ТипПеременной переменная, контейнер) {
    операторы, действия над переменной;
}

Примеры

foreach (Cell *cell, m_cells) {
    cell->open();
}

Сравнение с обычным for ()

QVector<int> ints;
for (int i = 0; i < ints.count(); ++i) {
    cout << ints[i] << endl;
}

то же самое

QVector<int> ints;
foreach (int number, ints) {
    cout << number << endl;
}

4 new/delete

Объекты, созданные с помощью оператора new, должны удаляться с помощью оператора delete. При этом оператор new сначала выделяет память под объект, потом передаёт управление конструктору. Оператор delete сначала вызывает деструктор объекта, затем освобождает память.

Пример:

class Sample
{
public:
    Sample(int x = 10)
    {
        m_x = x;
        std::cout << "Sample constructor called with argument " << x << std::endl;
    }

    ~Sample()
    {
        std::cout << "Sample destructor. " << m_x << std::endl;
    }

private:
    int m_x;
};

int main(int argc, char *argv[])
{
    // Выделяется память под Sample. Глядя на определение класса можно предположить,
    // что класс займёт 4 байта в памяти (у класса единственное поле типа int).
    // После выделения памяти вызывается конструктор Sample с аргументом 15.
    Sample *s1 = new Sample(15); 

    // Выделяется память и вызывается конструктор Sample с аргументом по-умолчанию (10).
    Sample *s2 = new Sample();

    // Вывод адрес участка памяти, выделенного под объект s1 типа Sample.
    std::cout << s1 << std::endl; 

    // Вызывается деструктор ~Sample() для объекта, на который указывает s1, затем освобождается память.
    delete s1;

    // Выведет тот же адрес участка памяти. (С указателем ничего не происходит!)
    std::cout << s1 << std::endl;

    // Вызывается деструктор ~Sample(), затем освобождается память.
    delete s2;

    return 0;
}

Второй пример:

void initialization()
{
    for (int y = 0; y < height; ++y) {
        for (int x = 0; x < width; ++x) {
            // Записываем в cell указатель на новый, только что созданный объект Cell.
            Cell *cell = new Cell(x, y); 
            m_cells.append(cell); // Добавляем в m_cell указатель на новый объект
        }
    }
}

void shutdown()
{
    for (Cell *cell : m_cells) { // Перебираем указатели, хранящиеся в m_cell
        delete cell; // Удаляем объект, расположенный по адресу, cell
    }
    m_cells.clear(); // Очищаем вектор указателей, которые теперь указывают на несуществующие объекты
}

5 Graphics View Framework

Описание: http://doc.qt.io/qt-5/graphicsview.html

Полное описание QGraphicsItem.

Неожиданно, нашлось описание на русском:

Нужные для выполнения задания функции QGraphicsItem:

  • setPos() - задаёт позицию элемента относительно родителя.
    • У CellItem родителя нет (мы сами так написали)
    • Текстовому элементу в качестве родительского мы передаём наш CellItem.
  • boundingRect() - определяет прямоугольник, описывающий область, в которой происходит отрисовка элемента.
    • В CellItem мы возвращаем прямоугольник, соответствующий размеру клетки
    • QGraphicsSimpleTextItem возвращает прямоугольник, описывающий заданный текст.

6 QObject, свойства, сигналы и слоты.

Документация:

Перевод документации:


Первая часть задания

0. Синхронизировать случайные числа

Функция qsrand(int) позволяет задать начальное число для генератора псевдослучайных чисел, который используется в qrand().

В начало метода Field::generate() добавьте строчку

    qsrand(10);

Теперь у всех будет генерироваться одинаковое поле, что упростит проверку работоспособности.

1. Реализовать отображение количества мин вокруг

Задача #14

QGraphicsSimpleTextItem::setText() принимает QString.

Ожидаемая статистика изменений:

 CellItem.cpp | 2 ++
 1 file changed, 2 insertions(+)

2. Сделать так, чтобы отображались только открытые клетки

Задача #15

Ожидаемая статистика изменений:

 CellItem.cpp | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

3. Закрашивать закрытые клетки серым прямоугольником

Задача #16

Изменения:

diff --git a/CellItem.cpp b/CellItem.cpp
index 3192f04..d57672c 100644
--- a/CellItem.cpp
+++ b/CellItem.cpp
@@ -35,12 +35,15 @@ void CellItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option,
 
     painter->drawRect(0, 0, cellSize, cellSize);
 
+    static const int border = 4;
     if (m_cell->isOpen()) {
         if (m_cell->haveMine()) {
             m_text->setText("+");
         } else if (m_cell->minesAround() > 0) {
             m_text->setText(QString::number(m_cell->minesAround()));
         }
+    } else {
+        painter->fillRect(border, border, cellSize - border * 2, cellSize - border * 2, Qt::lightGray);
     }
 }

4. Центрировать текст клетки.

Задача #17

Смотрите описание методов QGraphicsItem

Ожидаемая статистика изменений:

 CellItem.cpp | 1 +
 1 file changed, 1 insertion(+)

5. Открытие соседних клеток.

Задача #6

Предварительная задача

Сейчас клетки можно открывать сколько угодно раз (то есть тело функции open() полностью выполняется при каждом вызове). Для того, чтобы при открытие соседних клеток не вызывало бесконечный цикл (и не "пробивало" стек), нужно в начале метода open() проверять, была ли данная клетка уже открыта. Итого: добавляем в начало Cell::open(): если клетка открыта — выходить из функции.

Ожидаемая статистика изменений:

 Cell.cpp | 4 ++++
 1 file changed, 4 insertions(+)

Основная задача

При открытии клетки: если рядом с клеткой нет мин — открывать все соседние клетки.

Ожидаемая статистика изменений:

 Cell.cpp | 6 ++++++
 1 file changed, 6 insertions(+)

Вторая часть задания

6. Добавить возможность начать игру заново

Задача #7

6.1 Добавляем действия

Читаем, как добавлять на форму меню и пункты меню.

Добавляем меню "Game" с пунктами "New game" и "Exit".

В списке действий делаем двойной щелчок на действии "New game". В открывшемся окне меняем имя объекта (object name) на actionNewGame (просто мне так больше нравится) и задаём комбинацию клавиш (shortcut) Ctrl+N. Аналогично для действия Exit задаём комбинацию клавиш Ctrl+Q.

6.2 Добавляем начальную реализацию перезапуска игры

В списке действий делаем нажимаем правую кнопку на действии "New game" и выбираем пункт "перейти к слоту" (Go to slot). По-умолчанию выбран сигнал triggered(), он нам и нужен. ОК. Открылся редактор кода с методом on_actionNewGame_triggered.

Добавляем в MainWindow публичный метод void newGame() и вызываем его в слоте on_actionNewGame_triggered(). (да, мы ещё не разбирались, что такое сигналы и слоты. Пока достаточно знать, что это особые методы, которые можно связывать друг с другом).

Добавляем в Cell публичный метод reset(), который будет сбрасывать m_haveMine и m_open в false. Добавляем в Field публичный метод prepare(), который будет обходить все клетки и вызывать у них метод reset().

В методе MainWindow::newGame() вызываем у поля сначала метод prepare(), а потом метод generate(). Qt пытается по максимуму экономить ресурсы, потому перерисовка сцены происходит только при изменении сцены. В нашей реализации класс Cell никак не сообщает CellItem'у о том, что у него изменились значения open и haveMine, поэтому в этом же методе MainWindow::newGame() нужно добавить принудительную перерисовку сцены:m_scene->update().

Последний штрих — начинать новую игру при запуске приложения. Для этого добавим вызов newGame() в конец конструктора MainWindow().

Запускаем и пробуем начать новую игру через пункт меню.

6.3 Отрисовка количества мин в соседних клетках

Радуемся, что всё работает.Исправляем некорректную отрисовку количества мин после запуска новой игры. Есть два способа:

  • задавать пустой текст, если текст не нужен. Метод setText(QString()).
  • скрывать текстовый элемент, если текст не нужен и отображать, когда нужен. Методы hide(), show(), setVisible(bool).

7. Генерация поля с учётом первой открытой клетки

Задача #3

Мы не можем предугадать, какую клетку откроет игрок, но мы можем отложить генерацию поля до открытия первой клетки. План такой: при начале новой игры поле сбрасывается (Field::prepare()). При открытии клетки происходит проверка, сгенерированно ли поле. Если да — всё как обычно, если нет — запускаем генерацию поля с координатами первой открываемой клетки.

7.1 Добавляем свойство поля generated

Добавляем в класс Field поле bool m_generated и метод bool isGenerated() const, возвращающий значение этого поля. Сбрасываем значение в false при подготовке поля (функция Field::prepare()) и выставляем в true при генерации (метод Field::generate()).

7.2 Модифицируем код открытия клетки.

  • В метод Cell::open() добавляем проверку поля. Если поле не сгенерированно, вызываем m_field->generate(), дальше всё как обычно.
  • Убираем генерацию поля из MainWindow::newGame().

7.3 Предусмотрительная генерация поля

  • Добавляем в метод Field::generate() параметры x и y.
  • Изменяем Cell::open() так, чтобы при открытии передавались координаты клетки
  • В методе генерации проверяем, чтобы координаты клетки, которую мы хотим заминировать, не совпадали с переданными координатами.

7.4 Этого мало.

Действительно, этого мало. Если при открытии первой клетки мы узнаем, что в соседних клетках четыре мины, то нам всё так же придётся играть в угадайку. Для нормальной игры нужно сделать так, чтобы после открытия первой клетки мы смогли начать просчитывать расположение мин. То есть, нужно открыть сразу несколько клеток. Для этого:

  • Получаем указатель на клетку, расположенную по переданным координатам: Cell *banned = cellAt(x, y);
  • Получаем вектор соседних клеток: QVector<Cell*> bannedCells = banned->getNeighbors();
  • Добавляем центральную клетку в вектор: bannedCells.append(banned);.
  • Теперь достаточно проверить, содержит ли вектор bannedCells клетку, которую мы хотим заминировать (метод QVector::contains()). Если содержит, значит мы попали в центральную клетку или одну из соседних и нам нужно пропустить минирование и продолжить генерацию. (проверка по координатам больше не нужна).

8 Мелкие доработки интерфейса

8.1 Исправить заголовок окна на Mines

Задача #19

В данном случае можно обойтись изменением формы. В редакторе форм в инспекторе объектов (справа наверху) выбираем объект MainWindow и в редакторе свойств (справа посередине или внизу) задаём свойству windowTitle значение Mines.

8.2 Выход из игры

Задача #20

Вариант первый: добавить в MainWindow слот, вызываемый по срабатыванию действия Exit (как в задании 6.2). В реализации слота вызывать метод close() текущего объекта (окна).

Вариант второй: открыть редактор сигналов и слотов (вторая вкладка внизу, рядом с редактором действий). Добавить соединение actionExit, сигнал triggered() с объектом MainWindow, слот close().

Во втором варианте писать код вообще не приходится.

8.3 Отображать красный квадрат в открытых клетках с миной

Задача #21

См. задание 3.

Ожидаемая статистика изменений:

 CellItem.cpp | 1 +
 1 file changed, 1 insertion(+)

8.4 Сглаживание линий, часть 2.

Допустим, у нас есть картинка, шириной в 19 пикселей. Если нам нужно отобразить её в области, шириной в 38 пикселей — всё хорошо, просто каждый пиксель отрисуется по два раза. Если же нам нужно будет отобразить её в области, шириной в 40 клеток, то какие-то два столбца придётся отобразить три раза вместо двух. Для отображения в области шириной в 44 клетки, нам придётся из 19 столбцов оригинального изображения удвоить 13 столбцов и утроить ещё 6 столбцов. Fractional scaling

Как это относится к нашей программе?

Допустим, у нас размер поля - 8х8 клеток. Размер клетки - 32х32 точки. Размер окна - 635x618 точек, размер области отображения (QGraphicsView) 617x542 точки.

В таком случае для вписывания сцены в окно, будет применяться масштабирование с коэффициентом примерно 2,1171875. Это значит, что примерно один из девяти столбцов окажется чуть шире остальных. Часть линий окажутся чуть толще остальных и это будет заметно.

По-умолчанию отрисовка настроена на максимальную производительность, но в нашем случае для нормального вида клеток нужно применять дополнительное сглаживание (возможно, вы встречались с настройками MSAA в играх). Примените следующие изменения для включения сглаживания и установки коэффициента сглаживания в 4x.

diff --git a/MainWindow.cpp b/MainWindow.cpp
index 3bfb767..b75b0d3 100644
--- a/MainWindow.cpp
+++ b/MainWindow.cpp
@@ -20,8 +20,12 @@ MainWindow::MainWindow(QWidget *parent) :
     m_field->setSize(8, 8);
     m_field->setNumberOfMines(10);
 
+    QGLFormat f = QGLFormat::defaultFormat();
+    f.setSampleBuffers(true);
+    f.setSamples(4);
+
+    ui->graphicsView->setViewport(new QGLWidget(f));
     ui->graphicsView->setScene(m_scene);
-    ui->graphicsView->setViewport(new QGLWidget());
 
     for (int y = 0; y < m_field->height(); ++y) {
         for (int x = 0; x < m_field->width(); ++x) {

9. Открытие всех клеток при открытии мины.

Задача #19

  1. Добавим в класс Field метод lose(), в реализации которого просто переберём все клетки и откроем их.
  2. В методе Cell::open() в случае открытия клетки с миной вызываем метод из первого пункта.

Предложенная реализация подверженна одной проблеме, проявление которой зависит от расположения вызова lose() в open().


Третья часть задания

10. Отметки для клеток

Задача #4

Предлагаю добавить в класс Cell методы int mark() const; void toggleMark(); и поле int m_mark;

Реализацию этих методов и модификацию CellItem делаем самостоятельно.

Ожидаемая статистика изменений:

 Cell.cpp     | 10 ++++++++++
 Cell.hpp     |  5 +++++
 CellItem.cpp | 15 ++++++++++++++-
 3 files changed, 29 insertions(+), 1 deletion(-)

11. Ускоренное открытие клеток

Задача #10

В класс Cell предлагаю добавить метод tryToOpenAround(), который будет считать количество восклицательных знаков в соседних клетках. Если это количество равно числу мин вокруг, то открывать все соседние клетки без восклицательного знака.

В CellItem при нажатии на левую кнопку мыши вызывать новый метод, если клетка уже открыта (в ином случае - открывать клетку).

Ожидаемая статистика изменений:

 Cell.cpp     | 19 +++++++++++++++++++
 Cell.hpp     |  1 +
 CellItem.cpp |  6 +++++-
 3 files changed, 25 insertions(+), 1 deletion(-)

12. Field должен быть дочерним классом QObject

Для оставшихся заданий нам нужно будет получать от Field информацию о состоянии игры, например о том, когда она была запущена и о том, когда прекращена (из-за победы или поражения). Мы могли бы поступить так же, как с клеткой — просто передать Field указатель на MainWindow. Тогда поле вызывало бы методы окна для сообщения о своих изменениях. Это - неправильно с точки зрения архитектуры. Как вы могли убедиться на примере второй практики, лучше сразу планировать архитектуру, чем потом переписывать проект. Я бы обязательно провёл вас по всем граблям, какие только можно встретить, но в условиях ограниченного времени нам придётся сразу (или почти сразу) выбирать правильные направления.

Прежде чем приступить к заданию, прочитайте материал из шестого раздела дополнительной информация для выполнения работы.

Итак, для того, чтобы сделать Field наследником QObject:

  1. Перед определением класса Field подключаем заголовочный файл QObject.
  2. В первой строчке определения класса указываем, что он открыто наследуется от QObject.
  3. В начало определения (после фигурной скобки и перед секцией public) нужно добавляем макрос Q_OBJECT. Этот макрос добавит в наш класс дополнительные функции для поддержки мета-объектов.
  4. В файл реализации Field добавляем вызов родительского конструктора. (Аналогично тому, как в конструкторе CellItem вызывается конструктор базового класса QGraphicsItem).

Пробуем собрать проект. Если будет ошибка undefined reference to 'vtable for Field', попробуйте выбрать "Запустить qmake" в меню сборки или контекстном меню проекта.

Ожидаемая статистика изменений:

 Field.cpp | 3 ++-
 Field.hpp | 4 +++-
 2 files changed, 5 insertions(+), 2 deletions(-)

13. Cell должен быть дочерним классом QObject

Сделайте самостоятельно.

Ожидаемая статистика изменений:

 Cell.cpp | 3 ++-
 Cell.hpp | 4 +++-
 2 files changed, 5 insertions(+), 2 deletions(-)

14. Исправляем взаимодействие Cell<->Field

Сейчас у нас Field управляет Cell, но и Cell вызывает методы Field.

Для упрощения программы сделаем так, чтобы иерархия управления была строго однонаправленной.

14.1 Убираем из Cell вызовы m_field->cellAt().

cellAt() у нас вызывается только для получения вектора соседей. Тут есть ещё один небольшой недостаток: после генерации поля вектор соседей не меняется, но у нас он каждый раз составляется заново. tryToOpenAround() вообще составляет вектор соседей два раза.

Вместо динамического составления вектора, добавим в Cell метод задания соседей setNeighbors(const QVector<Cell*> &neighbors) и поле QVector<Cell*> m_neighbors.

Код нахождения соседей перенесём из Cell в Field:

  • maybeAddCell() переносится просто так
  • Реализация getNeighbors() переносится в цикл в Field::prepare(). В этом цикле после сброса клетки находим её соседей и задаём их этой клетке методом setNeighbors(). Обратите внимание, что когда метод setNeighbors был в классе Cell, он имел доступ ко всем полям и методам, а теперь придётся использовать публичные методы (такие, как cell->x() и cell->y()).

Реализацию Cell::getNeighbors() можно переписать как простой возврат поля m_neighbors.

Ожидаемая статистика изменений:

 Cell.cpp  | 18 ++++--------------
 Cell.hpp  |  3 +++
 Field.cpp | 17 +++++++++++++++++
 3 files changed, 24 insertions(+), 14 deletions(-)

14.2 Убираем из Cell вызовы Field::isGenerated() и Field::generate().

Вместо того, чтобы в клетке говорить полю "сгенерируйся!", мы будем в клетке сообщать "клетка x, y открыта", а в поле отслеживать все такие сообщения и запускать генерацию при необходимости.

  1. Добавляем в Cell сигнал opened(int x, int y) и испускаем этот сигнал вместо запроса на генерацию поля.
  2. Добавляем в Field слот, который будет вызываться при открытии клетки.
  3. В слоте Field::onCellOpened(int x, int y) генерируем поле, если оно не сгенерированно.

Коммит полностью:

commit d590604a46bb823615c1a42e78a3b460d54c7944
Author: Alexandr Akulich <[email protected]>
Date:   Wed Mar 16 13:02:04 2016 +0500

    Cell, Field: Refactor Field::generate() invocation

diff --git a/Cell.cpp b/Cell.cpp
index 5e59ca5..6925cc4 100644
--- a/Cell.cpp
+++ b/Cell.cpp
@@ -46,9 +46,7 @@ void Cell::open()
 
     m_open = true;
 
-    if (!m_field->isGenerated()) {
-        m_field->generate(x(), y());
-    }
+    emit opened(x(), y());
 
     if (minesAround() == 0) {
         for (Cell *cell : getNeighbors()) {
diff --git a/Cell.hpp b/Cell.hpp
index 7430b86..488977f 100644
--- a/Cell.hpp
+++ b/Cell.hpp
@@ -32,6 +32,9 @@ public:
     QVector<Cell*> getNeighbors() const;
     void setNeighbors(const QVector<Cell*> &neighbors);
 
+signals:
+    void opened(int x, int y);
+
 private:
     Field *m_field;
 
diff --git a/Field.cpp b/Field.cpp
index a2e2431..fd15472 100644
--- a/Field.cpp
+++ b/Field.cpp
@@ -20,7 +20,9 @@ void Field::setSize(int width, int height)
 
     for (int y = 0; y < height; ++y) {
         for (int x = 0; x < width; ++x) {
-            m_cells.append(new Cell(this, x, y));
+            Cell *cell = new Cell(this, x, y);
+            connect(cell, SIGNAL(opened(int,int)), this, SLOT(onCellOpened(int,int)));
+            m_cells.append(cell);
         }
     }
 }
@@ -93,3 +95,10 @@ Cell *Field::cellAt(int x, int y) const
 
     return m_cells.at(x + y * m_width);
 }
+
+void Field::onCellOpened(int x, int y)
+{
+    if (!isGenerated()) {
+        generate(x, y);
+    }
+}
diff --git a/Field.hpp b/Field.hpp
index 44a7639..2c35693 100644
--- a/Field.hpp
+++ b/Field.hpp
@@ -25,6 +25,9 @@ public:
 
     Cell *cellAt(int x, int y) const;
 
+protected slots:
+    void onCellOpened(int x, int y);
+
 private:
     QVector<Cell*> m_cells;

Статистика коммита:

 Cell.cpp  |  4 +---
 Cell.hpp  |  3 +++
 Field.cpp | 11 ++++++++++-
 Field.hpp |  3 +++
 4 files changed, 17 insertions(+), 4 deletions(-)

14.3 Вызов Field::lose()

Вместо вызова m_field->lose() в методе открытия клетки можно в слоте Field::onCellOpened() узнавать (методом cellAt()), какая клетка открылась и вызывать lose(), если в клетке есть мина.

14.4 Cleanup

Удаляем все оставшиеся упоминания Field из класса Cell, исправляем вызов конструктора в Field.

Ожидаемая статистика изменений:

 Cell.cpp  | 6 +-----
 Cell.hpp  | 6 +-----
 Field.cpp | 2 +-
 3 files changed, 3 insertions(+), 11 deletions(-)

15. Отображение количества оставшихся мин

Задача #9

15.1 Ui.

  1. Откроем форму MainWindow
  2. Добавим QLabel над graphicsView.
  3. Теперь добавим второй QLabel справа от первого. После этого layout будет состоять из двух строк и двух столбцов.
  4. Перенесём правую границу graphicsView вправо так, чтобы этот виджет занял всю строчку.
  5. В левом верхнем label поменяем текст на "Mines:".
  6. Изменим горизонтальное положение текста (свойство alignment) на AlignRight
  7. Щелчком выделяем правый label и в инспекторе объектов (справа наверху) меняем его имя на minesLabel. Также имя можно поменять в свойствах — это первое свойство, objectName.
  8. Сохраняем и закрываем форму.

15.2 Улучшение кода Cell

Для улучшения читаемости кода для отметок вместо чисел можно использовать перечисление (enum).

Добавим в Cell перечисление Mark:

 public:
+    enum Mark {
+        MarkNothing,
+        MarkFlagged,
+        MarkQuestioned
+    };
+
     Cell(int x, int y);

Теперь вместо запоминания, что 0 означает отсутствие метки, 1 означает восклицательный знак и 2 означает знак вопроса, можно использовать имена из enum. Константы в enum автоматически нумеруюутся по возрастанию от нуля, поэтому значения констант MarkNothing, MarkFlagged и MarkQuestioned совпадают с теми, которые мы уже использовали.

Перечисление — это тип. Нам следует использовать этот тип Mark везде, где мы использовали int для обозначения отметки.

@@ -24,7 +30,7 @@ public:
     void open();
     void tryToOpenAround();
 
-    int mark() const { return m_mark; }
+    Mark mark() const { return m_mark; }
     void toggleMark();
 
     QVector<Cell*> getNeighbors() const;
@@ -42,7 +48,7 @@ private:
     bool m_haveMine;
     bool m_open;
 
-    int m_mark;
+    Mark m_mark;

Функцию toggleMark() придётся переписать, потому что к типу "перечисление" нельзя присваивать обычные числа (и, соответственно, результаты арифметических операций).

 void Cell::toggleMark()
 {
-    if (m_mark == 2) {
-        m_mark = 0;
-    } else {
-        ++m_mark;
+    switch (m_mark) {
+    case MarkNothing:
+        m_mark = MarkFlagged;
+        break;
+    case MarkFlagged:
+        m_mark = MarkQuestioned;
+        break;
+    case MarkQuestioned:
+        m_mark = MarkNothing;
+        break;
     }
 }

Внимание: Если вы всё написали правильно, то для успешной компиляции вам понадобится изменить ещё одну строчку. Сделайте это самостоятельно.

15.3 Улучшение кода CellItem.

Воспользуйтесь новым перечислением в CellItem. Класс является одновременно и пространством имён, поэтому для доступа к константам нужно написать префикс Cell::. Например:

        switch (m_cell->mark()) {
        case Cell::MarkNothing:
            m_text->setText("");
            break;

15.4 Добавим сигнал об изменении метки.

Добавьте в класс Cell сигнал void markChanged(Mark newMark); и добавьте его испускание в места изменения m_mark.

Ожидаемая статистика изменений:

 Cell.cpp | 4 ++++
 Cell.hpp | 1 +
 2 files changed, 5 insertions(+)

(Из четырёх добавленных в Cell.cpp строк две — пустые.)

15.5 Статистика мин в Field.

15.5.1 Добавить в Field свойство int numberOfMines.

В данном случае свойство — совокупность поля, метода чтения и сигнала об изменении, без макроса Q_PROPERTY. Хотя макрос пригодится для реализации qml интерфейса.

Метод чтения выглядит так:

int numberOfMines() const { return m_numberOfMines; }

Остальное напишите сами.

15.5.2 protected (private) Field::onCellMarkChanged().

Нужно добавить защищённый слот void onCellMarkChanged(). Тут есть тонкость: с одной стороны, клетка испускает сигнал с параметром — кодом отметки и мы могли бы считать (суммировать) все появившиеся MarkFlagged. С другой стороны, у нас возникнет проблема с подсчётом убранных отметок. Нам нельзя полагаться на такие детали реализации, как следование MarkQuestioned после MarkFlagged, иначе при изменении порядка у нас будет трудноуловимая ошибка. Надёжнее будет игнорировать параметр сигнала и просто каждый раз вычислять количество флагов перебирая все клетки заново. Вот это и напишем в реализации слота.

Да, не стоит забывать о подключении сигнала Cell::markChanged() каждой клетки к нашему новому слоту в методе Field::setSize().

15.5.3 MainWindow::onFieldNumberOfFlagsChanged().

Добавляем в класс MainWindow защищённый слот void onFieldNumberOfFlagsChanged(int number); для обработки количества флагов. В реализации предлагаю воспользоваться возможностью QString форматировать строки. Реализация слота должна выглядеть (примерно) так:

ui->minesLabel->setText(QString("%1/%2").arg(number).arg(m_field->numberOfMines()));

Как это работает:

  1. В конструктор QString передаётся строка для форматирования.
  2. У результата (то есть объекта QString со строкой "%1/%2") вызывается метод .arg(), который создаёт новую строку и записывает в неё предыдущую строку с заменой % с наименьшим номером на то, что передано arg() в качестве аргумента.

Самостоятельно подключите сигнал numberOfFlagsChanged поля к слоту onFieldNumberOfFlagsChanged.

Запустите приложение и убедитесь, что счётчик работает (первое число равно количеству восклицательных знаков).

16. Обработка завершения игры

16.1 Счётчик открытых клеток

Победа определяется как открытие всех клеток без мин. Добавим счётчик int m_numberOfOpenedCells. Не забываем сбрасывать счётчик в Field::prepare() и увеличивать его в onCellOpened().

16.2 Победа, поражение

Добавим в Field приватный метод win() (метод lose() у нас уже есть). Пока что реализацию win() оставим пустой.

Реализуем окончание игры: если открылась клетка с миной — вызываем lose(), если открытых клеток стало == m_cells.count() - m_numberOfMines, то вызываем win().

Для проверки можно использовать вывод в консоль. Для вывода вместо std::cout и прочих способов воспользуемся функцией qDebug(), которая возвращает объект с перегруженными операторами ввода для вывода отладочной информации. Пример использования:

#include <QDebug>

void Field::win()
{
    qDebug() << "win!";
}

Подключение заголовочного файла разместите в начале файла cpp.

16.3 Состояние игры

--- a/Field.hpp
+++ b/Field.hpp
@@ -10,8 +10,15 @@ class Field : public QObject
 {
     Q_OBJECT
 public:
+    enum State {
+        StateIdle,
+        StateStarted,
+        StateEnded
+    };
+
     Field();

Добавим публичный метод для получения текущего состояния, сигнал об изменении состояния и закрытый (private) метод установки состояния (конечно, нам также понадобится приватное поле State m_state).

Напишем (самостоятельно) реализацию методов чтения и записи.

Теперь:

  • Инициализируем в конструкторе начальное значение m_state в StateIdle (без использования setState(), потому что этот метод проверяет текущее значение m_state (, которого ещё нет) и испускает сигнал (который из конструктора испускать нет смысла, потому что к нему ещё никто не мог подключиться).
  • При подготовке поля делаем setState(StateIdle);
  • При генерации — setState(StateStarted);
  • При победе и поражении сразу задаём состояние StateEnded.

16.4 MainWindow

Реализуем простейшую реакцию на окончание игры. Для этого добавим защищённый или приватный слот onFieldStateChanged(). В реализации слота пишем проверку текущего состояния. Если игра окончена, то будем отображать в центре сцены текст "Game over".

Для реализации:

  1. Добавим в MainWindow поле QGraphicsSimpleTextItem *m_gameStateText. Последнее напоминание: для того, чтобы использовать указатель на тип, нужно объявить, что это за тип. В данном случае нужно объявить, что тип QGraphicsSimpleTextItem — это класс. Для этого в начале заголовочного файла MainWindow.hpp примерно в 10-12 строчку добавляем объявление class QGraphicsSimpleTextItem;
  2. В конструктор MainWindow:
  3. Добавляем создание m_gameStateText.
  4. Аналогично тому, как это сделано в конструкторе CellItem, задаём размер шрифта, например, 32 пикселя.
  5. Задаём порядок отрисовки, чтобы текст отображался поверх других элементов: m_gameStateText->setZValue(2);. По-умолчанию все элементы добавляются с z = 0 и отображаются в порядке добавления (то есть последний добавленный элемент будет отображаться поверх всех с таким же z).
  6. Добавляем элемент на сцену. m_scene->addItem(m_gameStateText);
  7. Слот onFieldStateChanged():
void MainWindow::onFieldStateChanged()
{
    if (m_field->state() == Field::StateEnded) {
        m_gameStateText->setText("Game over");
        m_gameStateText->setPos((m_scene->width() - m_gameStateText->boundingRect().width()) / 2,
                               (m_scene->height() - m_gameStateText->boundingRect().height()) / 2);
        m_gameStateText->setVisible(true);
    } else {
        m_gameStateText->setVisible(false);
    }
}

Если после пункта 2.2 у вас появилась ошибка компиляции — читаем текст ошибки, понимаем, как плохо давать короткие имена переменным и исправляем ошибку.

17. Улучшаем внешний вид

Для возможности сглаживания (задание 8.4) boundingRect() элементов может быть чуть больше, чем размер элементов. Это может привести к небольшим "колебаниям" сцены из-за увеличения её размера и перемасштабирования вида (view) при открытии клеток, расположенных на границе сцены.

Добавим прямоугольник, изображающий игровое поле как для общего улучшения внешнего вида, так и для избавления от описанных неприятных дёрганий при открытии клеток.

17.1 FieldItem

Обойдёмся без создания класса, просто добавим m_fieldItem типа QGraphicsRectItem.

17.1.1 CellItem::cellSize

Для того, чтобы отобразить поле нужного размера, нам понадобится узнать размеры клеток. Читаем про использование static при объявлении полей и методов класса и делаем cellSize константным статическим полем класса CellItem.

17.1.2 fieldBorderWidth

Добавляем статическую переменную со значением отступа клеток от границ поля:

diff --git a/MainWindow.cpp b/MainWindow.cpp
index db4538b..e2a0f09 100644
--- a/MainWindow.cpp
+++ b/MainWindow.cpp
@@ -9,6 +9,8 @@
 #include <QGraphicsScene>
 #include <QTimer>
 
+static const int fieldBorderWidth = CellItem::cellSize / 2;
+
 MainWindow::MainWindow(QWidget *parent) :
     QMainWindow(parent),
     ui(new Ui::MainWindow)
17.1.3 Исправление конструктора CellItem

Раз уж у нас появляется элемент, отображающий поле, то будет логично разместить клетки на этом элементе. Это позволит нам произвольно позиционировать поле (например, чтобы добавить отображение какой-либо дополнительной информации прямо внутри QGraphicsView).

Для размещения одних элементов внутри других обычно используется передача указателя на родительский элемент в конструктор дочернего элемента. Это предусмотрено в QGraphicsItem, но мы до сих пор игнорировали это в CellItem.

Добавим в конструктор CellItem аргумент QGraphicsItem *parent и передадим этот аргумент конструктору QGraphicsItem().

diff --git a/CellItem.cpp b/CellItem.cpp
index 6aadcc8..028c581 100644
--- a/CellItem.cpp
+++ b/CellItem.cpp
@@ -8,8 +8,8 @@
 
 const int CellItem::cellSize = 32;
 
-CellItem::CellItem(Cell *cell) :
-    QGraphicsItem()
+CellItem::CellItem(Cell *cell, QGraphicsItem *parent) :
+    QGraphicsItem(parent)
 {
     m_cell = cell;
     m_text = new QGraphicsSimpleTextItem(this);
diff --git a/CellItem.hpp b/CellItem.hpp
index ea0a33d..8e44b8b 100644
--- a/CellItem.hpp
+++ b/CellItem.hpp
@@ -9,7 +9,7 @@ class QGraphicsSimpleTextItem;
 class CellItem : public QGraphicsItem
 {
 public:
-    CellItem(Cell *cell);
+    CellItem(Cell *cell, QGraphicsItem *parent = nullptr);
     static const int cellSize;
 
     // QGraphicsItem interface

Обратите внимание, что у аргумента parent есть значение по-умолчанию. Это позволяет создавать объекты как с родителем, так и без.

17.1.4 Добавление графического элемента поля.

Перед добавлением клеток на сцену зададим полю прямоугольник:

    m_fieldItem->setRect(0, 0, m_field->width() * CellItem::cellSize + fieldBorderWidth * 2,
                         m_field->height() * CellItem::cellSize + fieldBorderWidth * 2);

Добавление клеток придётся переписать так, чтобы 1) m_fieldItem был родительским элементом для клеток и 2) клетки правильно позиционировались в элементе поля.

     for (int y = 0; y < m_field->height(); ++y) {
         for (int x = 0; x < m_field->width(); ++x) {
+            CellItem *newItem = new CellItem(m_field->cellAt(x, y), m_fieldItem);
+            newItem->setPos(x * CellItem::cellSize, y * CellItem::cellSize);
         }
     }

В конце конструктора не забываем добавить элемент поля на сцену: m_scene->addItem(m_fieldItem);.

17.1.5 Исправление позиционирования клеток.

После последнего изменения клетки отображаются не в центре поля, а сверху/слева. Исправляем самостоятельно.

17.1.6 Cleanup

Кстати, setPos() в конструкторе CellItem больше не нужен.

17.2 Прямоугольник под текст с описанием игрового состояния

Добавляем в класс MainWindow поле QGraphicsRectItem *m_gameStateRect. В конструкторе создаём объект и задаём его z = 1 (так, что он будет отображаться поверх поля с z == 0, но под текстом с z == 2). Делаем прямоугольник прозрачным: вызываем у прямоугольника метод setOpacity() с параметром, например, 0.7. По-умолчанию прямоугольник рисуется прозрачным. Поменяем кисть, которой происходит закрашивание, на любую цветную. Я сделал так: m_gameStateRect->setBrush(Qt::lightGray);. Можно задать любой цвет (например m_gameStateRect->setBrush(QColor(200, 100, 100)); и любую кисть, например линейный градиент:

    QLinearGradient gradient(0, 0, 200, m_gameStateRect->rect().height());
    gradient.setColorAt(0, QColor(00, 100, 0));
    gradient.setColorAt(1, QColor(200, 0, 150));
    m_gameStateRect->setBrush(gradient);

Не забываем добавить элемент на сцену.

В слоте MainWindow::onFieldStateChanged() дописываем код размещения прямоугольника, например такой:

void MainWindow::onFieldStateChanged()
{
    if (m_field->state() == Field::StateEnded) {
        m_gameStateText->setText("Game over");
        m_gameStateText->setPos((m_fieldItem->boundingRect().width() - m_gameStateText->boundingRect().width()) / 2,
                               (m_fieldItem->boundingRect().height() - m_gameStateText->boundingRect().height()) / 2);

        int rectHeight = m_fieldItem->boundingRect().height() * 0.3;

        m_gameStateRect->setRect(0, (m_fieldItem->boundingRect().height() - rectHeight) / 2, m_field->width() * CellItem::cellSize + fieldBorderWidth * 2, rectHeight);
        m_gameStateText->setVisible(true);
        m_gameStateRect->setVisible(true);
    } else {
        m_gameStateText->setVisible(false);
        m_gameStateRect->setVisible(false);
    }
}

18. Добавление уровней сложности

Задача #11

18.1 Field::setSize().

Что у нас сейчас происходит в setSize(int width, int height)?

void Field::setSize(int width, int height)
{
    // Полям m_width и m_height задаются значения,
    // соответствующие входным параметрам.
    m_width = width;
    m_height = height;

    // Начинается цикл с переменной y, изменяющейся от 0 до height
    for (int y = 0; y < height; ++y) {
        // В этом цикле организуется вложенный цикл с переменной x от 0 до width.
        for (int x = 0; x < width; ++x) {
            // В переменную cell типа указатель на объект Cell
            // записывается адрес нового объекта Cell, конструктору
            // которого передаются переменные x и y.
            Cell *cell = new Cell(x, y);

            // Сигнал opened() нового объекта подключается
            // к слоту onCellOpened() текущего объекта.
            connect(cell, SIGNAL(opened(int,int)), this, SLOT(onCellOpened(int,int)));

            // Аналогично.
            connect(cell, SIGNAL(markChanged(Mark)), this, SLOT(onCellMarkChanged()));

            // В вектор указателей m_cells добавляется указатель на созданный объект.
            m_cells.append(cell);
        }
    }
}

Проблема повторного использования setSize() заключается в том, что этот метод не удаляет предыдущие клетки, то есть после первого вызова setSize(8, 8) в m_cells будет 8x8=64 указателя на Cell. После второго вызова setSize(8, 8) в векторе станет 128 таких указателей.

Таким образом, для правильного изменения размера поля нужно удалять старые клетки (для того, чтобы освободить память) и очистить вектор m_cells, для того чтобы 1) не вызвать ошибку обращением по (теперь уже) некорректному адресу, 2) старые указатели не мешали работе метода cellAt().

Читаем описание операторов new и delete и делаем самостоятельно. Решение заключается в добавлении в Field::setSize() четырёх строчек, одна из которых — пустая.

18.2 MainWindow::resizeField().

Добавляем метод void MainWindow::resizeField(int width, int height).

Подобно тому, как мы очищали Field от старых Cell, нам нужно очистить сцену от CellItem, привязанных к старым клеткам.

До того, как мы добавили m_fieldItem и ещё два элемента, мы могли просто вызвать у сцены метод clear(), но теперь нам нужно удалить не все элементы, а только клетки. Мы переписали код так, что у нас клетки стали дочерними элементами поля (m_fieldItem). Это позволяет нам удалить клетки простым перебором и удалением дочерних элементов m_fieldItem.

После удаления CellItem'ов мы можем смело изменять размер поля. Смело — потому что CellItem'ы сохраняли указатели на клетки и обращение по этим указателям для перерисовки клетки привело бы к ошибке, а так — нет клеток — нет проблемы.

После изменения размера поля можно пересоздать элементы, как это было в конструкторе MainWindow и обновить размер m_fieldItem'a.

Последний штрих — использовать новый метод в конструкторе MainWindow вместо старого кода.

Коммит целиком:

commit c77fef4ec297abfe387ad50b15a321880cf0a9c4
Author: Alexandr Akulich <[email protected]>
Date:   Tue Mar 22 17:48:49 2016 +0500

    MainWindow: Implement resizeField() method

diff --git a/MainWindow.cpp b/MainWindow.cpp
index 286c4af..4abc1f9 100644
--- a/MainWindow.cpp
+++ b/MainWindow.cpp
@@ -39,9 +39,6 @@ MainWindow::MainWindow(QWidget *parent) :
     connect(m_field, SIGNAL(numberOfFlagsChanged(int)), this, SLOT(onFieldNumberOfFlagsChanged(int)));
     connect(m_field, SIGNAL(stateChanged()), this, SLOT(onFieldStateChanged()));
 
-    m_field->setSize(8, 8);
-    m_field->setNumberOfMines(10);
-
     QGLFormat f = QGLFormat::defaultFormat();
     f.setSampleBuffers(false);
     f.setSamples(4);
@@ -49,14 +46,8 @@ MainWindow::MainWindow(QWidget *parent) :
     ui->graphicsView->setViewport(new QGLWidget(f));
     ui->graphicsView->setScene(m_scene);
 
-    m_fieldItem->setRect(0, 0, m_field->width() * CellItem::cellSize + fieldBorderWidth * 2,
-                         m_field->height() * CellItem::cellSize + fieldBorderWidth * 2);
-    for (int y = 0; y < m_field->height(); ++y) {
-        for (int x = 0; x < m_field->width(); ++x) {
-            CellItem *newItem = new CellItem(m_field->cellAt(x, y), m_fieldItem);
-            newItem->setPos(x * CellItem::cellSize + fieldBorderWidth, y * CellItem::cellSize + fieldBorderWidth);
-        }
-    }
+    resizeField(8, 8);
+    m_field->setNumberOfMines(10);
 
     m_scene->addItem(m_fieldItem);
 
@@ -74,6 +65,25 @@ void MainWindow::newGame()
     m_scene->update();
 }
 
+void MainWindow::resizeField(int width, int height)
+{
+    for (QGraphicsItem *cell : m_fieldItem->childItems()) {
+        delete cell;
+    }
+
+    m_field->setSize(width, height);
+    m_fieldItem->setRect(0, 0,
+                         width * CellItem::cellSize + fieldBorderWidth * 2,
+                         height * CellItem::cellSize + fieldBorderWidth * 2);
+    for (int y = 0; y < height; ++y) {
+        for (int x = 0; x < width; ++x) {
+            CellItem *newItem = new CellItem(m_field->cellAt(x, y), m_fieldItem);
+            newItem->setPos(x * CellItem::cellSize + fieldBorderWidth,
+                            y * CellItem::cellSize + fieldBorderWidth);
+        }
+    }
+}
+
 void MainWindow::resizeEvent(QResizeEvent *event)
 {
     QTimer::singleShot(0, this, SLOT(updateSceneScale()));
diff --git a/MainWindow.hpp b/MainWindow.hpp
index 407e0c9..539ddce 100644
--- a/MainWindow.hpp
+++ b/MainWindow.hpp
@@ -21,6 +21,7 @@ public:
     ~MainWindow();
 
     void newGame();
+    void resizeField(int width, int height);
 
 protected:
     void resizeEvent(QResizeEvent *event);

18.3 Действия для изменения сложности

Добавляем меню Difficulty с пунктами из #11.

18.4 Действия для изменения сложности

Для каждого из трёх действий добавляем слоты в MainWindow и реализуем их следующим образом:

  • Вызываем resizeField() с размерами поля, соответствующими сложности
  • Задаём количество мин
  • Запускаем новую игру
  • Обновляем масштаб поля (вызываем updateSceneScale();).

18.5 updateSceneScale()

В документации QGraphicsScene написано: sceneRect() will return the largest bounding rect of all items on the scene since the scene was created (i.e., a rectangle that grows when items are added to or moved in the scene, but never shrinks).

Поэтому обновление масштаба работает неправильно при уменьшении количества клеток. Что же, будем задавать sceneRect вручную. Для подсчёта настоящих размеров сцены можно воспользоваться методом QGraphicsScene::itemsBoundingRect().

Применяем патч с помощью git am:

From c2fee20df95f2f2780c739392efed31ba4bf7abd Mon Sep 17 00:00:00 2001
From: Alexandr Akulich <[email protected]>
Date: Thu, 28 Apr 2016 12:39:37 +0500
Subject: [PATCH] MainWindow: Fix scene scale

Add missing scene rect adjustment.
---
 MainWindow.cpp | 1 +
 1 file changed, 1 insertion(+)

diff --git a/MainWindow.cpp b/MainWindow.cpp
index 5eb8717..a5ba9e0 100644
--- a/MainWindow.cpp
+++ b/MainWindow.cpp
@@ -42,5 +42,6 @@ void MainWindow::resizeEvent(QResizeEvent *event)
 
 void MainWindow::updateSceneScale()
 {
+    m_scene->setSceneRect(m_scene->itemsBoundingRect());
     ui->graphicsView->fitInView(m_scene->sceneRect(), Qt::KeepAspectRatio);
 }
-- 
2.7.4

19 Открытие поля при победе

19.1 Модификация Cell

  • Добавляем в Cell поле bool m_exploded.
  • Изменяем функцию открытия так, чтобы она выставляла флаг m_exploded, если в открываемой клетке есть мина.
  • Добавляем функцию reveal(), которая просто выставляет флаг "открытости" клетки. То есть:
@@ -53,6 +58,11 @@ void Cell::open()
     }
 }
 
+void Cell::reveal()
+{
+    m_open = true;
+}
+
 void Cell::tryToOpenAround()
 {

19.2 Модификация Field

В метод win() добавляем раскрытие всех клеток (методом reveal()).

19.3 Модификация CellItem

Изменим метод отрисовки так, чтобы при отображении открытой клетки с миной отображался красный прямоугольник, если мина взорвалась (m_cell->isExploded()) и зелёный, если взрыва не было.


Четвёртая часть (QML)

20 QMLификация поля

Прежде чем приступить к заданию, повторите материал из шестого раздела

20.1 Расширение мета-объектного интерфейса Field свойствами width, height.

Пока можно сделать все свойства доступными только для чтения, то есть достаточно добавить следующие объявления:

diff --git a/Field.hpp b/Field.hpp
index bba3711..2c3f054 100644
--- a/Field.hpp
+++ b/Field.hpp
@@ -9,20 +9,23 @@ class Cell;
 class Field : public QObject
 {
     Q_OBJECT
+    Q_PROPERTY(int width READ width NOTIFY widthChanged)
+    Q_PROPERTY(int height READ height NOTIFY heightChanged)
 public:

Ширина и высота могут изменяться (пока только методом setSize()), поэтому нужно добавить сигналы, уведомляющие об изменении значений свойств:

 signals:
     void numberOfFlagsChanged(int number);
     void stateChanged();
 
+    void widthChanged(int newWidth);
+    void heightChanged(int newHeight);
+
 protected slots:
     void onCellOpened(int x, int y);
     void onCellMarkChanged();

Испускание сигналов добавляем самостоятельно (в метод Field::setSize()).

20.2 Регистрация QML типа

Для использования QML и, в частности, QtQuick, подключаем к проекту модуль "quick":

+++ b/Mines.pro
@@ -4,7 +4,7 @@

-QT       += core gui opengl
+QT       += core gui opengl quick

Для регистрации типов используем шаблонную функцию qmlRegisterType(). "Шаблонность" функции говорит о том, что она "принимает" тип в угловых скобках. Первый аргумент — строка для импорта в QML. Второй и третий — мажорная и минорная версии компонента. Четвёртый аргумент — название типа в QML.

Вот так регистрируются обычные Qt'ные типы. (uri = "QtQuick").

Мы делаем так:

diff --git a/main.cpp b/main.cpp
index 1886701..7fd05d7 100644
--- a/main.cpp
+++ b/main.cpp
@@ -1,11 +1,32 @@
 #include "MainWindow.hpp"
 #include <QApplication>
 
+#include <QtQml>
+
+#include "Field.hpp"
+
 int main(int argc, char *argv[])
 {
     QApplication a(argc, argv);
+
+    qmlRegisterType<Field>("GameComponents", 1, 0, "Field");
+
     MainWindow w;
     w.show();

21 QMLификация клетки

21.1 Добавляем свойства

Добавляем свойства haveMine, isOpen, exploded, minesAround и mark.

Для того, чтобы использовать перечисление State, нужно зарегистрировать его тип в мета-объектной системе. Для этого сразу после объявления перечисления добавляем макрос Q_ENUM с именем типа.

Замечание насчёт сигналов: сигнал void opened(int x, int y); не подходит для уведомления об изменении свойства isOpen, потому что этот сигнал испускается только при открытии клетки, а нам нужно сообщать и о закрытии клетки (и сбросе других свойств) в методе Cell::reset(). Поэтому добавляем сигнал isOpenChanged.

Реализацию пишем самостоятельно.

21.2 Регистрация QML типа.

Делаем самостоятельно, по аналогии с 20.2.

21.3 Исправляем объявление конструктора Cell

Текущий код не скомпилируется, потому что регистрация компонента подразумевает, что его можно создать (то есть написать в QML Cell { id: cell }), но в нашем случае нельзя "просто так" создать Cell, потому что конструктор принимает два обязательных параметра — x и y.

Исправляем ситуацию добавлением значений по-умолчанию, например: int x = 0, int y = 0.

Часть изменений, применяемых к началу заголовочного файла:

diff --git a/Cell.hpp b/Cell.hpp
index f3977f3..8a268e5 100644
--- a/Cell.hpp
+++ b/Cell.hpp
@@ -7,14 +7,20 @@
 class Cell : public QObject
 {
     Q_OBJECT
+    Q_PROPERTY(bool haveMine READ haveMine NOTIFY haveMineChanged)
+    Q_PROPERTY(Mark mark READ mark NOTIFY markChanged)
 public:
     enum Mark {
         MarkNothing,
         MarkFlagged,
         MarkQuestioned
     };
+    Q_ENUM(Mark)
 
-    Cell(int x, int y);
+    Cell(int x = 0, int y = 0);
 
     void reset();
 
@@ -28,19 +34,24 @@ public:

22 Создание QQuickView

QML — это язык, сфера применения которого не ограничивается графическими приложениями. Для реализации интерфейса мы пользуемся qml-компонентами, которые называются QtQuick. Именно QtQuick позволяет создавать окна и отображать в них различные QtQuick элементы, такие как Rectangle, Text и другие.

Итак, создаём экземпляр QQuickView и подгружаем в него начальный qml файл.

22.1 Создаём и отображаем окно

Подходящий класс называется QQuickView. Создаём и отображаем окно в main.cpp так же, как это сделано для MainWindow:

diff --git a/main.cpp b/main.cpp
index e0fe10a..6518b74 100644
--- a/main.cpp
+++ b/main.cpp
@@ -1,6 +1,7 @@
 #include "MainWindow.hpp"
 #include <QApplication>
 
+#include <QQuickView>
 #include <QtQml>
 
 #include "Field.hpp"
@@ -16,5 +17,8 @@ int main(int argc, char *argv[])
     MainWindow w;
     w.show();
 
+    QQuickView view;
+    view.show();
+
     return a.exec();
 }

22.2 Добавляем main.qml

QuickView обычно работает с файлами qml, то есть обращается к файлам в процессе работы — при инициализации и создании компонентов. Для того, чтобы qml-файл всегда был доступен при запуске приложения, мы воспользуемся технологией Qt Resource Collection. Технология простая, разберём её на лету:

  1. Добавляем в проект файл qrc (Qt Resource File). Называем файл просто "resources".
  2. Добавляем файл QML (Qt Quick 2), называем файл main и при на последней странице мастера добавления файлов выбираем: добавить в проект — "resources.qrc Prefix: /".
  3. В main.cpp дописываем указание файла для QQuickView:
diff --git a/main.cpp b/main.cpp
index 6518b74..7a63ac4 100644
--- a/main.cpp
+++ b/main.cpp
@@ -2,6 +2,7 @@
 #include <QApplication>
 
 #include <QQuickView>
 #include <QtQml>
 
 #include "Field.hpp"
@@ -18,6 +19,11 @@ int main(int argc, char *argv[])
     w.show();
 
     QQuickView view;
+    view.setSource(QUrl("qrc:///main.qml"));
     view.show();

Как это работает: resources.qrc — это просто файл со списком файлов, которые при компиляции будут встроены в секцию данных исполняемого файла. Для доступа к таким файлам в Qt используется схема qrc://.

В указании Url исходного файла "qrc:///main.qml"qrc:// - это схема, /main.qml - это путь и имя файла.

23 Начальная реализация main.qml

Начальная реализация будет состоять из двух частей:

  • пробросим в qml поле из MainWindow
  • напишем начальный код main.qml, который просто отобразит поле из клеток.

23.1 MainWindow::field().

Пусть поле пока останется в MainWindow, просто сделаем так, чтобы мы могли до него добраться из main.cpp. Для этого добавим в класс окна геттер

    Field *field() const { return m_field; }

23.2 Context Property

QML-код выполняется в определённом окружении, называемом контекстом. В этом контексте можно определять символы (переменные и функции), которые будут доступны во всех элементах.

В main.cpp подключаем заголовочный файл QQmlContext и задаём контексту нашего QuickView свойство, содержащее указатель на поле из MainWindow.

Метод QQmlContext::setContextProperty() принимает два аргумента: имя свойства и его значение.

diff --git a/main.cpp b/main.cpp
index 6518b74..f840803 100644
--- a/main.cpp
+++ b/main.cpp
@@ -2,6 +2,7 @@
 #include <QApplication>
 
 #include <QQuickView>
+#include <QQmlContext>
 #include <QtQml>
 
 #include "Field.hpp"
@@ -18,6 +19,8 @@ int main(int argc, char *argv[])
     w.show();
 
     QQuickView view;
+    view.rootContext()->setContextProperty("field", w.field());
+    view.setSource(QUrl("qrc:///main.qml"));
     view.show();
 
     return a.exec();

23.3 main.qml

Прежде всего — добавляем импорт наших игровых классов:

import GameComponents 1.0

После этого — изменяем размер корневого элемента, например, на 800x600. QQuickView по-умолчанию настроен на изменение размеров окна под размеры корневого элемента.

Теперь нам понадобятся следующие элементы:

  • Rectangle — отображает прямоугольник. Можно настроить цвет, рамку, радиус закругления и т.д.
  • Grid — располагает все дочерние элементы по сетке. Можно указать количество строк и/или столбцов.
  • Repeater — создаёт несколько экземпляров дочерних элементов на основании модели. Моделью может быть настоящая модель (ListModel), список строк (string list), список объектов или просто число. Нас интересует последний вариант. К каждому созданному элементу прикрепляется свойство index с его порядковым номером.

Как теперь сделать поле? Создаём Grid, внутри которого размещаем Repeater с моделью = ширина поля * высоту поля. Внутри Repeater добавим прямоугольник 64x64 с чёрной рамкой, шириной в две точки. Указываем Grid количество столбцов, равное ширине поля.

Вот, что должно получится:

import QtQuick 2.0
import GameComponents 1.0

Rectangle {
    width: 800
    height: 600

    Grid {
        columns: field.width
        Repeater {
            model: field.width * field.height

            Rectangle {
                width: 64
                height: 64
                border.color: "black"
                border.width: 2
            }
        }
    }
}

24 Создание CellItem и взаимодействие с Cell

24.1 Извлечение CellItem

Нажимаем Alt+Enter на Rectangle в Repeater и выбираем первый пункт — извлечение компонента в отдельный файл. Назовём файл CellItem. Файл автоматически добавится в проект и в список ресурсов, а его имя теперь является типом QML, доступным в main.qml.

24.2 Field::cellAt()

Для связи визуальной и логической частей клетки воспользуемся методом Field::cellAt(). Прежде всего, отмечаем метод cellAt() как доступный для вызова из qml, для этого добавляем макрос Q_INVOKABLE перед объявлением cellAt():

diff --git a/Field.hpp b/Field.hpp
index a1bb733..b6a4c8b 100644
--- a/Field.hpp
+++ b/Field.hpp
@@ -34,7 +34,7 @@ public:
     int width() const { return m_width; }
     int height() const { return m_height; }
 
-    Cell *cellAt(int x, int y) const;
+    Q_INVOKABLE Cell *cellAt(int x, int y) const;
 
 signals:
     void numberOfFlagsChanged(int number);

Этот макрос добавит в мета-объектную систему информацию о том, что у класса Field есть метод cellAt().

24.3 Cell x и y из index

Добавим в CellItem свойство, которое будет содержать указатель на соответствующую логическую клетку.

Для объявления свойства в QML элементе нужно написать ключевое слово property, потом тип свойства, его имя и, опционально, его значение через двоеточие. Можно не указывать никакое значение (потому что мы всё-равно будем его перезаписывать настоящим значением из поля), либо указать null.

diff --git a/CellItem.qml b/CellItem.qml
index 67657aa..616b442 100644
--- a/CellItem.qml
+++ b/CellItem.qml
@@ -6,4 +6,6 @@ Rectangle {
     height: 64
     border.color: "black"
     border.width: 2
+
+    property Cell cell: null
 }

Теперь в main.qml зададим графической клетке её логическую клетку:

diff --git a/main.qml b/main.qml
index 62b80f0..7fec21d 100644
--- a/main.qml
+++ b/main.qml
@@ -11,6 +11,7 @@ Rectangle {
             model: field.width * field.height
 
             CellItem {
+                cell: field.cellAt(index % field.width, index / field.width)
             }
         }
     }

Всё, готово. Исходя из индекса мы нашли координаты клетки и задали значение свойства результату вызова Field::cellAt() с найденными координатами.

24.4 Взаимодействие Cell -> CellItem

Добавим серый прямоугольник, который будет отображаться, если клетка закрыта.

diff --git a/CellItem.qml b/CellItem.qml
index 616b442..61ab7b9 100644
--- a/CellItem.qml
+++ b/CellItem.qml
@@ -8,4 +8,11 @@ Rectangle {
     border.width: 2
 
     property Cell cell: null
+
+    Rectangle {
+        color: "#c0c0c0"
+        visible: !cell.isOpen
+        anchors.fill: parent
+        anchors.margins: 2
+    }
 }

24.5 Взаимодействие CellItem -> Cell

24.5.1 C++

В qml доступны только методы, информация о которох есть в мета-объектной системе, то есть только методы, отмеченные Q_INVOKABLE и публичные слоты.

Слот — это метод-действие, изменяющее состояние объекта, обычно являющееся реакцией на какое-то событие (сигнал) и не возвращающее никаких значений. Мы сделали cellAt() вызываемым (invokable), потому что он (этот метод) не изменяет объект, поэтому не подходит семантически.

Открытие клетки вполне подходит определению действия, так что сделаем метод Cell::open() публичным слотом:

diff --git a/Cell.hpp b/Cell.hpp
index dba35fd..410ca0c 100644
--- a/Cell.hpp
+++ b/Cell.hpp
@@ -34,7 +34,6 @@ public:
 
     bool isOpen() const { return m_open; }
     bool isExploded() { return m_exploded; }
-    void open();
     void reveal();
     void tryToOpenAround();
 
@@ -44,6 +43,9 @@ public:
     QVector<Cell*> getNeighbors() const;
     void setNeighbors(const QVector<Cell*> &neighbors);
 
+public slots:
+    void open();
+
 signals:

Кстати, чтобы «два раза не вставать», стоит подумать, какие ещё методы соответствуют публично-доступным действиям, то есть могут вызываться из графического интерфейса и так же перенести их в секцию слотов.

24.5.2 QML

Добавим элемент MouseArea для открытия клетки по щелчку мышкой.

Объект MouseArea наследуется от Item, поэтому имеет все знакомые настройки для позиционирования. Сделаем так, чтобы элемент заполнил всю клетку. Далее, воспользуемся сигналом clicked() для открытия клетки:

commit d218261754faa7eca862a9d66b97c095f17216ff
Author: Alexandr Akulich <[email protected]>
Date:   Fri Mar 25 20:06:30 2016 +0500

    CellItem: Add MouseArea to open the cell on click

diff --git a/CellItem.qml b/CellItem.qml
index 61ab7b9..21bd854 100644
--- a/CellItem.qml
+++ b/CellItem.qml
@@ -15,4 +15,9 @@ Rectangle {
         anchors.fill: parent
         anchors.margins: 2
     }
+
+    MouseArea {
+        anchors.fill: parent
+        onClicked: cell.open()
+    }
 }

25 Завершение реализации CellItem

Добавим всё, что реализованно в классе CellItem для QGraphicsView рендера:

  • Отметки (! и ?)
  • Переключение отметок правой кнопкой
  • Ускоренное открытие
  • Зелёный квадрат при отображении не взорвавшихся мин
  • Красный квадрат при отображении взорванных мин

Примечание: у MouseArea есть свойство acceptedButtons.

26 Добавление gameStateItem

26.1 C++

В определении класса Field добавляем свойство state типа State. Не забываем, что для использования перечисления, его нужно зарегистрировать макросом Q_ENUM.

26.2 QML

В main.qml добавляем:

  • Прямоугольник цвета "#c0c0c0" с opacity 0.7
  • Текст с описанием состояния игры (Game over)

27 Поддержка изменения размера поля

Изменение размера поля «стопорится» на том, что в CellItem мы используем указатели на объекты Cell. Как и в случае с изменением размера поля, отображаемого с помощью QGraphicsView, мы будем уничтожать все клетки на время изменения размера. Для этого:

  1. В Field добавим свойство bool resetInProgress.
  2. Добавим задание этого свойство в true в начале Field::setSize() и обратно в false в конце.
  3. В qml поменяем модель Repeater'a на условие — если поле в процессе сброса, то 0 элементов, иначе width * height.

28 Начало новой игры из QML

28.1 Field::startNewGame()

Переименовываем Field::prepare() в startNewGame() и делаем его публичным слотом.

28.2 QML/Main Keys

Для запуска новой игры в qml будем использовать ту же комбинацию клавиш, что и раньше. Для работы с клавиатурой нам нужно:

  • Обновить импорт QtQuick до 2.5 (достаточно 2.2, но давайте импортировать самую новую версию)
  • Разрешить фокус элемента, в котором мы будем работать с клавиатурой.
  • Использовать обработчик сигнала Keys.onPressed для обработки нажатий
  • Использовать StandardKey для сравнения нажатых клавиш с комбинацией, принятой на данной платформе для создания чего-то нового.

Я предлагаю взаимодействовать с клавиатурой прямо в корневом элементе файла main.qml.

С обновлением импорта всё понятно.

Для разрешения захвата фокуса воспользуемся свойством focus.

Насчёт StandardKey читаем документацию (перевода нет).

Читаем документацию QML-типа KeyEvent и делаем сами. Для невнимательных даю подсказку, что в конце страницы документации есть пример.

28.3 Выход из игры

Попробуем реализовать выход из игры по нажатию стандартной комбинации клавиш QKeySequence::StandardKey::Quit.

Давайте посмотрим, как это должно работать.

if (event.matches(StandardKey.Quit)) {

event — объект типа QKeyEvent.

matches — метод QKeyEvent::matches()

StandardKey.Quit — константа QKeySequence::StandardKey::Quit

Этот метод вызывает QKeySequence::keyBindings(), который вызывает QGuiApplicationPrivate::platformTheme()->keyBindings(key).

Метод platformTheme() возвращает разные объекты на разных платформах, благодаря чему на каждой платформе используется "родное" оформление и "родные" комбинации клавиш. В Windows (в зависимости от версии) платформа может обеспечиваться плагином "windows" QWindowsTheme, либо "winrt" QWinRTTheme.

Ни в одном из этих классов метод keyBindings() не переопределён, значит используется метод базового класса QPlatformTheme::keyBindings().

(На моей платформе используется класс KdePlatformTheme с переопределённым keyBindings().)

Итак, список горячих клавиш для стандартного действия осуществляется в методе QPlatformTheme::keyBindings

Метод перебирает все комбинации, указанные в QPlatformThemePrivate::keyBindings и выбирает из них комбинации с подходящей платформой. Далее, если комбинация имеет приоритет больше нуля, то она добавляется в начало результирующего списка, иначе — в конец.

Я вижу только одно определение для Quit:

{QKeySequence::Quit, 0, Qt::CTRL | Qt::Key_Q, KB_X11 | KB_Gnome | KB_KDE | KB_Mac},

(http://code.woboq.org/qt5/qtbase/src/gui/kernel/qplatformtheme.cpp.html#324)

В последнем элементе отсутстствует KB_Win или KB_All, значит список действительно будет пустой. И это отражено в документации.

Почему так? Потому что в Windows не определена стандартная комбинация выхода из приложения. Точнее, в официальной документации: https://support.microsoft.com/en-us/kb/126449 написана какая-то глупость, будто бы выход из приложения происходит нажатием комбинации Alt+F4, но это же очевидно не работает для многооконных приложений.

P.S.: Кстати, у Qt есть зеркало на github, просто на https://code.woboq.org удобнее читать код, потому что там используется подсветка из IDE KDevelop. Если интересно узнать историю изменений, то лучше пользоваться гитхабом. Например, код QPlatformTheme::keyBindings(): https://github.com/qtproject/qtbase/blob/dev/src/gui/kernel/qplatformtheme.cpp#L605

Используя git можно посмотреть историю изменений файла. Если есть локальная копия, то можно сделать

git log -p src/gui/kernel/qplatformtheme.cpp

На github можно воспользоваться кнопкой History: https://github.com/qtproject/qtbase/commits/dev/src/gui/kernel/qplatformtheme.cpp

Чтобы узнать, кто является последним автором строчки, можно сделать git blame.

На github можно воспользоваться кнопкой Blame: https://github.com/qtproject/qtbase/blame/dev/src/gui/kernel/qplatformtheme.cpp#L605

29 Автомасштабирование

Автоматическое масштабирование клеток под размер окна с сохранением пропорций (то есть клетки должны оставаться квадратными)

29.1 QQuickView, ResizeMode

В main.cpp нужно задать resizeMode объекта QQuickView в SizeRootToObjectView.

Если непонятно — читайте документацию, пользуйтесь поисковыми системами.

29.2 QML/CellItem

  • Добавляем свойство int size, которое будет определять размер клеток. По-умолчанию = 64.
  • Меняем width и hight, которые теперь равны size.
  • Меняем размер шрифта для текста. size * 0.8 смотрится неплохо.
  • Меняем отступы серого/зелёного/красного прямоугольников на size * 0.14, но не меньше двух. (size * 0.14) < 2 ? 2 : size * 0.14 Значение 0.14 подобрал экспериментально.

29.3 QML/Main

Для того, чтобы вписать поле в окно, нам нужно знать соотношение сторон поля и соотношение сторон окна.

В качестве области для клеток мы будем использовать не всё поле, а поле с отступом. Поэтому, для того, чтобы упростить вычисления соотношения сторон, добавим Item, который будет вписываться в окно с отступом. Теперь:

  • Считаем соотношение сторон окна.
  • Считаем соотношение сторон поля.
  • Вычисляем коэффициент изменения высоты: если мы вписываем клетки по высоте (то есть у нас остаётся запас по горизонтали), то высота клетки будет равна просто высоте поля, разделённой на количество клеток (коэффициент к высоте = 1). Если мы вписываем клетки по ширине, то есть ограничением является высота, то мы должны умножить высоту клетки на соотношение соотношения окна к полю.
  • Задаём размер клеток равный высоте окна, разделённой на высоту клетки и умноженной на коэффициент. Итого:
commit 5dc33a500431b955d89e76ec6effa468cbd59c6d
Author: Alexandr Akulich <[email protected]>
Date:   Tue Apr 5 11:54:24 2016 +0500

    QML/Main: Implement cells auto scale to fit view

diff --git a/main.qml b/main.qml
index af696a0..4727e8a 100644
--- a/main.qml
+++ b/main.qml
@@ -2,6 +2,7 @@ import QtQuick 2.0
 import GameComponents 1.0
 
 Rectangle {
+    id: window
     width: 800
     height: 600
 
@@ -12,15 +13,28 @@ Rectangle {
         radius: 10
         anchors.fill: parent
 
-        Grid {
-            id: fieldItem
-            anchors.centerIn: parent
-            columns: field.width
-            Repeater {
-                model: field.width * field.height
+        Item {
+            id: cellContainer
+            anchors.fill: parent
+            anchors.margins: 10
+            property real windowProportion: width / height
+            property real fieldProportion: field.width * 1.0 / field.height
+            property real fixupFactor: windowProportion > fieldProportion ? 1 : windowProportion / fieldProportion
+            property int preferredSize: height / field.height * fixupFactor
+
+            Grid {
+                id: fieldItem
+                anchors.centerIn: parent
+
+                columns: field.width
+                Repeater {
+                    id: cellRepeater
+                    model: field.width * field.height
 
-                CellItem {
-                    cell: field.cellAt(index % field.width, index / field.width)
+                    CellItem {
+                        cell: field.cellAt(index % field.width, index / field.width)
+                        size: cellContainer.preferredSize
+                    }
                 }
             }
         }

30 Таймер

Таймер должен запускаться при генерации поля и останавливаться в конце игры.

30.1 Подготовка UI

Добавляем на поле Item-панель. Делаем так, чтобы панель занимала 1/10 верхнюю часть поля.

         anchors.fill: parent
 
+        Item {
+            id: panel
+            width: parent.width
+            height: parent.height * 0.1
+        }

         Item {
             id: cellContainer
-            anchors.fill: parent
+            anchors.top: panel.bottom
+            anchors.left: parent.left
+            anchors.right: parent.right
+            anchors.bottom: parent.bottom
             anchors.margins: 10

Добавляем в центр панели текстовый элемент. Размер шрифта задаём равным 60% от высоты панели. В качестве текста задаём "Time: " + time.

Добавляем свойство time типа string со значением "00:00".

30.2 Реализация таймера

Добавляем элемент типа Timer. Задаём интервал срабатывания 1000 мс, делаем так, чтобы таймер срабатывал многократно (repeat: true) и работал только когда состояние поля равно StateStarted.

Добавляем свойства int seconds и int minutes.

Добавляем обработчики сигналов. По сигналу triggered: проверяем количество секунд. Если секунд === 59, то увеличиваем количество минут на одну и обнуляем секунды. Иначе — увеличиваем количество секунд на единицу.

+        onTriggered: {
+            if (seconds === 59) {
+                minutes += 1;
+                seconds = 0;
+            }
+            seconds = seconds + 1
+        }

Самостоятельно добавляем обработчик сигнала изменения свойства running. Если таймер запустился, то обнуляем количество минут и секунд.

30.3 Подключение таймера к UI

Изменяем свойство time: если состояние игры === StateIdle, отображаем "00:00", иначе отображаем минуты : секунды.

30.4 Исправления.

  1. Убедитесь, что у вас в минуте 60 секунд
  2. Сделайте так, чтобы минуты и секунды отображались двузначными числами.

В качестве подсказки покажу одну строчку из своих изменений:

+property string minutes: gameTimer.minutes < 10 ? "0" + gameTimer.minutes : gameTimer.minutes
Clone this wiki locally