-
Notifications
You must be signed in to change notification settings - Fork 7
Task
Внимание! Читать строго последовательно!
Общая задача — дописать игровое приложение "Сапёр".
- Реализовать отображение количества мин вокруг
- Сделать так, чтобы отображались только открытые клетки
- Закрашивать закрытые клетки серым прямоугольником
- Центрировать текст клетки
- Открытие соседних клеток
- Добавить возможность начать игру заново
- Генерация поля с учётом первой открытой клетки
- Мелкие доработки интерфейса
- Открытие всех клеток при открытии мины
- Отметки для клеток
- Ускоренное открытие клеток
- Field должен быть дочерним классом QObject
- Cell должен быть дочерним классом QObject
- Исправляем взаимодействие Cell<->Field
- Отображение количества оставшихся мин
- Обработка завершения игры
- Улучшаем внешний вид
- Добавление уровней сложности
- Открытие поля при победе
Ключевое слово 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().
Поля и методы класса, объявленные как static, не привязаны ни к какому объекту.
Значение статического поля можно получить как через объект класса, так и используя именя класса как пространство имён:
// В заголовочном файле:
class XmlData : public QObject
{
Q_OBJECT
public:
explicit CXmlData(QObject *parent = 0);
~CXmlData();
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 0;
}
if (!source->haveProperty(sourceProperty)) {
return 0;
}
if (!target->haveProperty(targetProperty)) {
return 0;
}
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% случаев лучше обращаться через имя класса, а не имя объекта.
В классе 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).
В стандарте C++11 добавлена новая форма для оператора for, позволяющая перебирать элементы контейнера без итерационной переменной.
for (ТипПеременной переменная : контейнер) {
операторы, действия над переменной;
}
for (Cell *cell : m_cells) {
cell->open();
}
for (int i = 0; i < m_cells.count(); ++i) {
m_cells.at(i)->open();
}
foreach — это "искусственный" оператор Qt, который использовался для того же перебора элементов контейнера без итерационной переменной до появления стандарта C++11. Лучше использовать range-based for, но в уже написанном коде может попадаться использование foreach, поэтому будет неплохо ознакомиться и с его синтаксисом.
foreach (ТипПеременной переменная, контейнер) {
операторы, действия над переменной;
}
foreach (Cell *cell, m_cells) {
cell->open();
}
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;
}
Объекты, созданные с помощью оператора 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(), затем освобождается память.
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(); // Очищаем вектор указателей, которые теперь указывают на несуществующие объекты
}
Описание: http://doc.qt.io/qt-5/graphicsview.html
Полное описание QGraphicsItem.
Неожиданно, нашлось описание на русском:
- http://doc.crossplatform.ru/qt/4.7.x/qgraphicsitem.html#details
- http://doc.crossplatform.ru/qt/4.7.x/graphicsview.html
Нужные для выполнения задания функции QGraphicsItem:
- setPos() - задаёт позицию элемента относительно родителя.
- У CellItem родителя нет (мы сами так написали)
- Текстовому элементу в качестве родительского мы передаём наш CellItem.
- boundingRect() - определяет прямоугольник, описывающий область, в которой происходит отрисовка элемента.
- В CellItem мы возвращаем прямоугольник, соответствующий размеру клетки
- QGraphicsSimpleTextItem возвращает прямоугольник, описывающий заданный текст.
Документация:
Перевод документации:
- http://doc.crossplatform.ru/qt/4.7.x/signalsandslots.html
- http://doc.crossplatform.ru/qt/4.7.x/properties.html
Функция qsrand(int) позволяет задать начальное число для генератора псевдослучайных чисел, который используется в qrand().
В начало метода Field::generate() добавьте строчку
qsrand(10);
Теперь у всех будет генерироваться одинаковое поле, что упростит проверку работоспособности.
Задача #14
QGraphicsSimpleTextItem::setText() принимает QString.
Ожидаемая статистика изменений:
CellItem.cpp | 2 ++
1 file changed, 2 insertions(+)
Задача #15
Ожидаемая статистика изменений:
CellItem.cpp | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
Задача #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);
}
}
Задача #17
Смотрите описание методов QGraphicsItem
Ожидаемая статистика изменений:
CellItem.cpp | 1 +
1 file changed, 1 insertion(+)
Задача #6
Сейчас клетки можно открывать сколько угодно раз (то есть тело функции open() полностью выполняется при каждом вызове). Для того, чтобы при открытие соседних клеток не вызывало бесконечный цикл (и не "пробивало" стек), нужно в начале метода open() проверять, была ли данная клетка уже открыта. Итого: добавляем в начало Cell::open(): если клетка открыта — выходить из функции.
Ожидаемая статистика изменений:
Cell.cpp | 4 ++++
1 file changed, 4 insertions(+)
При открытии клетки: если рядом с клеткой нет мин — открывать все соседние клетки.
Ожидаемая статистика изменений:
Cell.cpp | 6 ++++++
1 file changed, 6 insertions(+)
Задача #7
Читаем, как добавлять на форму меню и пункты меню.
Добавляем меню "Game" с пунктами "New game" и "Exit".
В списке действий делаем двойной щелчок на действии "New game". В открывшемся окне меняем имя объекта (object name) на actionNewGame (просто мне так больше нравится) и задаём комбинацию клавиш (shortcut) Ctrl+N. Аналогично для действия Exit задаём комбинацию клавиш Ctrl+Q.
В списке действий делаем нажимаем правую кнопку на действии "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().
Запускаем и пробуем начать новую игру через пункт меню.
Радуемся, что всё работает.Исправляем некорректную отрисовку количества мин после запуска новой игры.
Есть два способа:
- задавать пустой текст, если текст не нужен. Метод setText(QString()).
- скрывать текстовый элемент, если текст не нужен и отображать, когда нужен. Методы hide(), show(), setVisible(bool).
Задача #3
Мы не можем предугадать, какую клетку откроет игрок, но мы можем отложить генерацию поля до открытия первой клетки. План такой: при начале новой игры поле сбрасывается (Field::prepare()). При открытии клетки происходит проверка, сгенерированно ли поле. Если да — всё как обычно, если нет — запускаем генерацию поля с координатами первой открываемой клетки.
Добавляем в класс Field поле bool m_generated
и метод bool isGenerated() const
, возвращающий значение этого поля.
Сбрасываем значение в false при подготовке поля (функция Field::prepare()) и выставляем в true при генерации (метод Field::generate()).
- В метод Cell::open() добавляем проверку поля. Если поле не сгенерированно, вызываем
m_field->generate()
, дальше всё как обычно. - Убираем генерацию поля из MainWindow::newGame().
- Добавляем в метод
Field::generate()
параметры x и y. - Изменяем Cell::open() так, чтобы при открытии передавались координаты клетки
- В методе генерации проверяем, чтобы координаты клетки, которую мы хотим заминировать, не совпадали с переданными координатами.
Действительно, этого мало. Если при открытии первой клетки мы узнаем, что в соседних клетках четыре мины, то нам всё так же придётся играть в угадайку. Для нормальной игры нужно сделать так, чтобы после открытия первой клетки мы смогли начать просчитывать расположение мин. То есть, нужно открыть сразу несколько клеток. Для этого:
- Получаем указатель на клетку, расположенную по переданным координатам:
Cell *banned = cellAt(x, y);
- Получаем вектор соседних клеток:
QVector<Cell*> bannedCells = banned->getNeighbors();
- Добавляем центральную клетку в вектор:
bannedCells.append(banned);
. - Теперь достаточно проверить, содержит ли вектор bannedCells клетку, которую мы хотим заминировать (метод
QVector::contains()
). Если содержит, значит мы попали в центральную клетку или одну из соседних и нам нужно пропустить минирование и продолжить генерацию. (проверка по координатам больше не нужна).
Задача #19
В данном случае можно обойтись изменением формы. В редакторе форм в инспекторе объектов (справа наверху) выбираем объект MainWindow и в редакторе свойств (справа посередине или внизу) задаём свойству windowTitle значение Mines.
Задача #20
Вариант первый: добавить в MainWindow слот, вызываемый по срабатыванию действия Exit (как в задании 6.2). В реализации слота вызывать метод close() текущего объекта (окна).
Вариант второй: открыть редактор сигналов и слотов (вторая вкладка внизу, рядом с редактором действий). Добавить соединение actionExit, сигнал triggered() с объектом MainWindow, слот close().
Во втором варианте писать код вообще не приходится.
Задача #21
См. задание 3.
Ожидаемая статистика изменений:
CellItem.cpp | 1 +
1 file changed, 1 insertion(+)
Допустим, у нас есть картинка, шириной в 19 пикселей. Если нам нужно отобразить её в области, шириной в 38 пикселей — всё хорошо, просто каждый пиксель отрисуется по два раза. Если же нам нужно будет отобразить её в области, шириной в 40 клеток, то какие-то два столбца придётся отобразить три раза вместо двух. Для отображения в области шириной в 44 клетки, нам придётся из 19 столбцов оригинального изображения удвоить 13 столбцов и утроить ещё 6 столбцов.
Как это относится к нашей программе?
Допустим, у нас размер поля - 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) {
Задача #19
- Добавим в класс Field метод lose(), в реализации которого просто переберём все клетки и откроем их.
- В методе Cell::open() в случае открытия клетки с миной вызываем метод из первого пункта.
Предложенная реализация подверженна одной проблеме, проявление которой зависит от расположения вызова lose() в open().
Задача #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(-)
Задача #10
В класс Cell предлагаю добавить метод tryToOpenAround(), который будет считать количество восклицательных знаков в соседних клетках. Если это количество равно числу мин вокруг, то открывать все соседние клетки без восклицательного знака.
В CellItem при нажатии на левую кнопку мыши вызывать новый метод, если клетка уже открыта (в ином случае - открывать клетку).
Ожидаемая статистика изменений:
Cell.cpp | 19 +++++++++++++++++++
Cell.hpp | 1 +
CellItem.cpp | 6 +++++-
3 files changed, 25 insertions(+), 1 deletion(-)
Для оставшихся заданий нам нужно будет получать от Field информацию о состоянии игры, например о том, когда она была запущена и о том, когда прекращена из-за победы или поражения. Мы могли бы поступить так же, как с клеткой — просто передать Field указатель на MainWindow. Тогда поле вызывало бы методы окна для сообщения о своих изменениях. Это - неправильно с точки зрения архитектуры. Как вы могли убедиться на примере второй практики, лучше сразу планировать архитектуру, чем потом переписывать проект. Я бы обязательно провёл вас по всем граблям, какие только можно встретить, но в условиях ограниченного времени нам придётся сразу (или почти сразу) выбирать правильные направления.
Прежде чем приступить к заданию, прочитайте материал из шестого раздела дополнительной информация для выполнения работы.
Итак, для того, чтобы сделать Field наследником QObject:
- Перед определением класса Field подключаем заголовочный файл QObject.
- В первой строчке определения класса указываем, что он открыто наследуется от QObject.
- В начало определения (после фигурной скобки и перед секцией public) нужно добавляем макрос Q_OBJECT. Этот макрос добавит в наш класс дополнительные функции для поддержки мета-объектов.
- В файл реализации Field добавляем вызов родительского конструктора. (Аналогично тому, как в конструкторе CellItem вызывается конструктор базового класса QGraphicsItem).
Пробуем собрать проект.
Если будет ошибка undefined reference to 'vtable for Field'
, попробуйте выбрать "Запустить qmake" в меню сборки или контекстном меню проекта.
Ожидаемая статистика изменений:
Field.cpp | 3 ++-
Field.hpp | 4 +++-
2 files changed, 5 insertions(+), 2 deletions(-)
Сделайте самостоятельно.
Ожидаемая статистика изменений:
Cell.cpp | 3 ++-
Cell.hpp | 4 +++-
2 files changed, 5 insertions(+), 2 deletions(-)
Сейчас у нас Field управляет Cell, но и Cell вызывает методы Field.
Для упрощения программы сделаем так, чтобы иерархия управления была строго однонаправленной.
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(-)
Вместо того, чтобы в клетке говорить полю "сгенерируйся!", мы будем в клетке сообщать "клетка x, y открыта", а в поле отслеживать все такие сообщения и запускать генерацию при необходимости.
- Добавляем в Cell сигнал opened(int x, int y) и испускаем этот сигнал вместо запроса на генерацию поля.
- Добавляем в Field слот, который будет вызываться при открытии клетки.
- В слоте Field::onCellOpened(int x, int y) генерируем поле, если оно не сгенерированно.
Коммит полностью:
commit d590604a46bb823615c1a42e78a3b460d54c7944
Author: Alexandr Akulich <[email protected]>
Date: Wed Mar 16 13:02:04 2016 +0500
Cell, Field: Refactored 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(-)
Вместо вызова m_field->lose() в методе открытия клетки можно в слоте Field::onCellOpened() узнавать (методом cellAt()), какая клетка открылась и вызывать lose(), если в клетке есть мина.
Удаляем все оставшиеся упоминания Field из класса Cell, исправляем вызов конструктора в Field.
Ожидаемая статистика изменений:
Cell.cpp | 6 +-----
Cell.hpp | 6 +-----
Field.cpp | 2 +-
3 files changed, 3 insertions(+), 11 deletions(-)
Задача #9
- Откроем форму MainWindow
- Добавим QLabel над graphicsView.
- Теперь добавим второй QLabel справа от первого. После этого layout будет состоять из двух строк и двух столбцов.
- Перенесём правую границу graphicsView вправо так, чтобы этот виджет занял всю строчку.
- В левом верхнем label поменяем текст на "Mines:".
- Изменим горизонтальное положение текста (свойство alignment) на AlignRight
- Щелчком выделяем правый label и в инспекторе объектов (справа наверху) меняем его имя на minesLabel. Также имя можно поменять в свойствах — это первое свойство, objectName.
- Сохраняем и закрываем форму.
Для улучшения читаемости кода для отметок вместо чисел можно использовать перечисление (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;
}
}
Внимание: Если вы всё написали правильно, то для успешной компиляции вам понадобится изменить ещё одну строчку. Сделайте это самостоятельно.
Воспользуйтесь новым перечислением в CellItem. Класс является одновременно и пространством имён, поэтому для доступа к константам нужно написать префикс Cell::. Например:
switch (m_cell->mark()) {
case Cell::MarkNothing:
m_text->setText("");
break;
Добавьте в класс Cell сигнал void markChanged(Mark newMark);
и добавьте его испускание в места изменения m_mark.
Ожидаемая статистика изменений:
Cell.cpp | 4 ++++
Cell.hpp | 1 +
2 files changed, 5 insertions(+)
(Из четырёх добавленных в Cell.cpp строк две — пустые.)
В данном случае свойство — совокупность поля, метода чтения и сигнала об изменении, без макроса Q_PROPERTY. Хотя макрос пригодится для реализации qml интерфейса.
Метод чтения выглядит так:
int numberOfMines() const { return m_numberOfMines; }
Остальное напишите сами.
Нужно добавить защищённый слот void onCellMarkChanged(). Тут есть тонкость: с одной стороны, клетка испускает сигнал с параметром — кодом отметки и мы могли бы считать (суммировать) все появившиеся MarkFlagged. С другой стороны, у нас возникнет проблема с подсчётом убранных отметок. Нам нельзя полагаться на такие детали реализации, как следование MarkQuestioned после MarkFlagged, иначе при изменении порядка у нас будет трудноуловимая ошибка. Надёжнее будет игнорировать параметр сигнала и просто каждый раз вычислять количество флагов перебирая все клетки заново. Вот это и напишем в реализации слота.
Да, не стоит забывать о подключении сигнала Cell::markChanged() каждой клетки к нашему новому слоту в методе Field::setSize().
Добавляем в класс MainWindow защищённый слот void onFieldNumberOfFlagsChanged(int number);
для обработки количества флагов.
В реализации предлагаю воспользоваться возможностью QString форматировать строки.
Реализация слота должна выглядеть (примерно) так:
ui->minesLabel->setText(QString("%1/%2").arg(number).arg(m_field->numberOfMines()));
Как это работает:
- В конструктор QString передаётся строка для форматирования.
- У результата (то есть объекта QString со строкой "%1/%2") вызывается метод .arg(), который создаёт новую строку и записывает в неё предыдущую строку с заменой % с наименьшим номером на то, что передано arg() в качестве аргумента.
Самостоятельно подключите сигнал numberOfFlagsChanged поля к слоту onFieldNumberOfFlagsChanged.
Запустите приложение и убедитесь, что счётчик работает (первое число равно количеству восклицательных знаков).
Победа определяется как открытие всех клеток без мин. Добавим счётчик int m_numberOfOpenedCells
. Не забываем сбрасывать счётчик в Field::prepare() и увеличивать его в onCellOpened().
Добавим в Field приватный метод win() (метод lose() у нас уже есть). Пока что реализацию win() оставим пустой.
Реализуем окончание игры: если открылась клетка с миной — вызываем lose(), если открытых клеток стало == m_cells.count() - m_numberOfMines
, то вызываем win().
Для проверки можно использовать вывод в консоль. Для вывода вместо std::cout и прочих способов воспользуемся функцией qDebug(), которая возвращает объект с перегруженными операторами ввода для вывода отладочной информации. Пример использования:
#include <QDebug>
void Field::win()
{
qDebug() << "win!";
}
Подключение заголовочного файла разместите в начале файла cpp.
--- 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.
Реализуем простейшую реакцию на окончание игры. Для этого добавим защищённый или приватный слот onFieldStateChanged(). В реализации слота пишем проверку текущего состояния. Если игра окончена, то будем отображать в центре сцены текст "Game over".
Для реализации:
- Добавим в MainWindow поле QGraphicsSimpleTextItem *m_gameStateText. Последнее напоминание: для того, чтобы использовать указатель на тип, нужно объявить, что это за тип. В данном случае нужно объявить, что тип
QGraphicsSimpleTextItem — это класс. Для этого в начале заголовочного файла MainWindow.hpp примерно в 10-12 строчку добавляем объявление
class QGraphicsSimpleTextItem;
- В конструктор MainWindow:
- Добавляем создание m_gameStateText.
- Аналогично тому, как это сделано в конструкторе CellItem, задаём размер шрифта, например, 32 пикселя.
- Задаём порядок отрисовки, чтобы текст отображался поверх других элементов:
m_gameStateText->setZValue(2);
. По-умолчанию все элементы добавляются с z = 0 и отображаются в порядке добавления (то есть последний добавленный элемент будет отображаться поверх всех с таким же z). - Добавляем элемент на сцену.
m_scene->addItem(m_gameStateText);
- Слот 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 у вас появилась ошибка компиляции — читаем текст ошибки, понимаем, как плохо давать короткие имена переменным и исправляем ошибку.
Для возможности сглаживания (задание 8.4) boundingRect() элементов может быть чуть больше, чем размер элементов. Это может привести к небольшим "колебаниям" сцены из-за увеличения её размера и перемасштабирования вида (view) при открытии клеток, расположенных на границе сцены.
Добавим прямоугольник, изображающий игровое поле как для общего улучшения внешнего вида, так и для избавления от описанных неприятных дёрганий при открытии клеток.
Обойдёмся без создания класса, просто добавим m_fieldItem типа QGraphicsRectItem.
Для того, чтобы отобразить поле нужного размера, нам понадобится узнать размеры клеток. Читаем про использование static при объявлении полей и методов класса и делаем cellSize константным статическим полем класса CellItem.
Добавляем статическую переменную со значением отступа клеток от границ поля:
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)
Раз уж у нас появляется элемент, отображающий поле, то будет логично разместить клетки на этом элементе. Это позволит нам произвольно позиционировать поле (например, чтобы добавить отображение какой-либо дополнительной информации прямо внутри 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 = 0);
static const int cellSize;
// QGraphicsItem interface
Обратите внимание, что у аргумента parent есть значение по-умолчанию. Это позволяет создавать объекты как с родителем, так и без.
Перед добавлением клеток на сцену зададим полю прямоугольник:
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);
.
После последнего изменения клетки отображаются не в центре поля, а сверху/слева. Исправляем самостоятельно.
Кстати, setPos() в конструкторе CellItem больше не нужен.
Добавляем в класс 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);
}
}
Задача #11
Что у нас сейчас происходит в 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() четырёх строчек, одна из которых — пустая.
Добавляем метод 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: Implemented 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);
Добавляем меню Difficulty с пунктами из #11.
Для каждого из трёх действий добавляем слоты в MainWindow и реализуем их следующим образом:
- Вызываем resizeField() с размерами поля, соответствующими сложности
- Задаём количество мин
- Запускаем новую игру
- Обновляем масштаб поля (вызываем
updateSceneScale();
).
- Добавляем в Cell поле bool m_exploded.
- Изменяем функцию открытия так, чтобы она выставляла флаг m_exploded, если в открываемой клетке есть мина.
- Добавляем функцию reveal(), которая просто выставляет флаг "открытости" клетки. То есть:
@@ -53,6 +58,11 @@ void Cell::open()
}
}
+void Cell::reveal()
+{
+ m_open = true;
+}
+
void Cell::tryToOpenAround()
{
В метод win() добавляем раскрытие всех клеток (методом reveal()).
Изменим метод отрисовки так, чтобы при отображении открытой клетки с миной отображался красный прямоугольник, если мина взорвалась (m_cell->isExploded()
) и зелёный, если взрыва не было.
Прежде чем приступить к заданию, повторите материал из шестого раздела
Пока можно сделать все свойства доступными только для чтения, то есть достаточно добавить следующие объявления:
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()).
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();
qmlRegisterType - шаблонная функция (то есть принимает тип в угловых скобках). Первый аргумент — строка для импорта в QML. Второй и третий — мажорная и минорная версии компонента. Четвёртый аргумент — название типа в QML.
Вот так регистрируются обычные Qt'ные типы. (uri = "QtQuick").
Добавляем свойства haveMine, isOpen, exploded, minesAround и mark.
Для того, чтобы использовать перечисление State, нужно зарегистрировать его тип в мета-объектной системе. Для этого сразу после объявления перечисления добавляем макрос Q_ENUM с именем типа.
Сигналы реализуем самостоятельно.
Делаем самостоятельно, по аналогии с 20.2.
Текущий код не скомпилируется, потому что регистрация компонента подразумевает, что его можно создать (то есть написать в 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(bool isOpen READ isOpen NOTIFY opened)
+ Q_PROPERTY(bool exploded READ isExploded NOTIFY explodedChanged)
+ Q_PROPERTY(int minesAround READ minesAround NOTIFY minesAroundChanged)
+ 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: