Мы уже практически завершили создание нашей игры, по пути обсудив немало тем, начиная с обработки возникающих событий до программирования GDI-графики. Некоторые темы, которых мы касались, выходят за рамки создания игр. Создание приложений MFC является одной из таких тем, т.к. немногие игры написаны с использованием технологии MFC, но при этом список прикладных программ, которые её используют, очень и очень большой. На этом уроке мы рассмотрим реализацию функционала «Отмена/Повтор» действий игрока. «Отмена/Повтор» является важной функцией для большинства приложений.
Реализация функционала «Отмена/Повтор»
Я называю эту функцию стеком «Отмена/Повтор» из-за того, что в её основе лежит принцип «Абстрактных типов данных», к которым относится и стек. Стек — это набор объектов, который похож на стопку тарелок на столе. Единственный способ добраться до нижних тарелок — снять верхние. Чтобы добавить тарелку в стопку, вам нужно поместить её поверх других. Таким образом реализуется принцип LIFO (сокр. от «Last In, First Out» = «Последним Пришёл, Первым Ушёл»).
Когда вы выполняете любое действие в игре, оно сразу же помещается на вершину стека, откуда его можно потом как отменить, так и восстановить (повторить). Способ, которым мы собираемся это всё реализовать заключается в том, чтобы сохранить копию старого объекта игрового поля в стеке «Отмена». Когда мы отменяем ход, текущая игровая доска помещается в стек «Повтор», а верхний объект из стека «Отмена» становится текущим.
Нам нужно будет создать конструктор глубокого копирования. Для этого добавьте прототип функции конструктора копирования прямо между конструктором по умолчанию и деструктором в SameGameBoard.h:
1 2 3 4 5 6 7 8 |
// Конструктор по умолчанию CSameGameBoard(void); // Конструктор глубокого копирования CSameGameBoard(const CSameGameBoard& board); // Деструктор ~CSameGameBoard(void); |
Мы используем конструктор глубокого копирования, потому что у нас есть указатель на некоторую область динамически выделенной памяти. Это означает, что мы не можем просто скопировать указатель, нам нужно динамически выделить новый блок памяти, а затем скопировать содержимое прошлого блока памяти в новый. Ниже описывается добавление конструктора копирования в исходный файл для игрового поля SameGameBoard.cpp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
CSameGameBoard::CSameGameBoard(const CSameGameBoard& board) { // Копирование всех элементов класса m_nColumns = board.m_nColumns; m_nRows = board.m_nRows; m_nHeight = board.m_nHeight; m_nWidth = board.m_nWidth; m_nRemaining = board.m_nRemaining; m_nColors = board.m_nColors; // Копирование цветовых элементов for ( int i = 0; i < 8; i++ ) m_arrColors[i] = board.m_arrColors[i]; m_arrBoard = NULL; // Создание нового игрового поля CreateBoard(); // Копирование содержимого игрового поля for(int row = 0; row < m_nRows; row++) for(int col = 0; col < m_nColumns; col++) m_arrBoard[row][col] = board.m_arrBoard[row][col]; } |
Код конструктора копирования очень простой:
сначала мы копируем переменные-члены класса;
затем указателю на доску присваиваем значение NULL
;
после этого вызываем функцию CreateBoard(), которая создает новый двумерный массив игрового поля того же размера, что и исходный (т.к. мы уже установили количество строк и столбцов перед вызовом функции).
последними идут 2 цикла for, которые копируют все цвета блоков со старой доски на новую.
Большая часть работы будет выполняться самим Document, т.к. именно он будет создавать цепочку действий «Отмена/Повтор» и хранить оба типа стека. Для этого мы воспользуемся библиотекой STL. Она содержит класс стека, который очень прост в использовании.
Мы передаем классу тип переменной (указатель на SameGameBoard), а он предоставляет нам несколько простых функций для работы со стеком:
функция push() — добавляет новый элемент в стек;
функция pop() — удаляет самый последний элемент из стека;
функция top() — возвращает элемент, находящийся на вершине стека;
функция empty() — определяет, является ли стек пустым.
Ниже расположен полный исходный код заголовочного файла SameGameDoc.h со всеми изменениями:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
#pragma once #include "SameGameBoard.h" #include <stack> class CSameGameDoc : public CDocument { protected: CSameGameDoc() noexcept; DECLARE_DYNCREATE(CSameGameDoc) // Операции public: // Функции доступа к игровой доске COLORREF GetBoardSpace(int row, int col) { return m_board.GetBoardSpace(row, col); } void SetupBoard(void) { m_board.SetupBoard(); } int GetWidth(void) { return m_board.GetWidth(); } void SetWidth(int nWidth) { m_board.SetWidth(nWidth); } int GetHeight(void) { return m_board.GetHeight(); } void SetHeight(int nHeight) { m_board.SetHeight(nHeight); } int GetColumns(void) { return m_board.GetColumns(); } void SetColumns(int nColumns) { m_board.SetColumns(nColumns); } int GetRows(void) { return m_board.GetRows(); } void SetRows(int nRows) { m_board.SetRows(nRows); } void DeleteBoard(void) { m_board.DeleteBoard(); } bool IsGameOver() { return m_board.IsGameOver(); } int DeleteBlocks(int row, int col) { return m_board.DeleteBlocks(row, col); } int GetRemainingCount() { return m_board.GetRemainingCount(); } int GetNumColors() { return m_board.GetNumColors(); } void SetNumColors(int nColors); // Переопределения public: virtual BOOL OnNewDocument(); virtual void Serialize(CArchive& ar); #ifdef SHARED_HANDLERS virtual void InitializeSearchContent(); virtual void OnDrawThumbnail(CDC& dc, LPRECT lprcBounds); #endif // Реализация public: virtual ~CSameGameDoc(); #ifdef _DEBUG virtual void AssertValid() const; virtual void Dump(CDumpContext& dc) const; #endif protected: // Функции очистки стеков «Отмена/Повтор» void ClearUndo(); void ClearRedo(); // Экземпляр класса игровой доски. Теперь мы сделали его указателем на класс CSameGameBoard* m_board; // Стек "Отмена" std::stack<CSameGameBoard*> m_undo; // Стек "Повтор" std::stack<CSameGameBoard*> m_redo; // Генерация функции сообщений protected: DECLARE_MESSAGE_MAP() #ifdef SHARED_HANDLERS // Вспомогательная функция, задающая содержимое поиска для обработчика поиска void SetSearchContent(const CString& value); #endif }; |
Прежде всего нам нужно подключить заголовочный файл стека. Поскольку мы собираемся изменить переменную m_board
на указатель, то нам придется перейти от использования оператора прямой принадлежности «точка» к оператору непрямой принадлежности «стрелка» (или к разыменованию указателя в каждой функции Document). Далее мы помещаем реализацию функции DeleteBlocks() в исходный файл.
Затем мы добавляем шесть новых функций, четыре из которых будут располагаться в public-разделе класса, а две другие — в protected-разделе. public-функции разделены на две группы: функции UndoLast() и RedoLast() выполняют «Отмена/Повтор», в то время как функции CanUndo() и CanRedo() являются обычными тестами, которые мы будем использовать для включения и отключения параметров меню, когда они недоступны. protected-функции являются простыми вспомогательными функциями для очистки и освобождения памяти, связанной с обоими типами стеков. В конце мы добавляем два объявления стеков «Отмена/Повтор».
Файл SameGameDoc.cpp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
#include "stdafx.h" #ifndef SHARED_HANDLERS #include "SameGame.h" #endif #include "SameGameDoc.h" #include <propkey.h> #ifdef _DEBUG #define new DEBUG_NEW #endif // CSameGameDoc IMPLEMENT_DYNCREATE(CSameGameDoc, CDocument) BEGIN_MESSAGE_MAP(CSameGameDoc, CDocument) END_MESSAGE_MAP() // Создание/уничтожение CSameGameDoc CSameGameDoc::CSameGameDoc()noexcept { // Здесь всегда должна быть игровая доска m_board = new CSameGameBoard(); } CSameGameDoc::~CSameGameDoc() { // Удаляем текущую игровую доску delete m_board; // Удаляем всё из стека «Отмена» ClearUndo(); // Удаляем всё из стека «Повтор» ClearRedo(); } BOOL CSameGameDoc::OnNewDocument() { if (!CDocument::OnNewDocument()) return FALSE; // Устанавливаем (или сбрасываем) игровую доску m_board->SetupBoard(); // Очистка стеков «Отмена/Повтор» ClearUndo(); ClearRedo(); return TRUE; } void CSameGameDoc::SetNumColors(int nColors) { // Сначала задаем количество цветов m_board->SetNumColors(nColors); // А затем устанавливаем параметры игровой доски m_board->SetupBoard(); } int CSameGameDoc::DeleteBlocks(int row, int col) { // Сохранение текущего состояния доски в стеке «Отмена» m_undo.push(new CSameGameBoard(*m_board)); // Очищаем стек «Повтор» ClearRedo(); // Затем удаляем блоки int blocks = m_board->DeleteBlocks(row, col); // Очищаем стек «Отмена» в конце игры if(m_board->IsGameOver()) ClearUndo(); // Возвращаем количество блоков return blocks; } void CSameGameDoc::UndoLast() { // Смотрим, есть ли у нас что-нибудь в стеке «Отмена» if(m_undo.empty()) return; // Помещаем текущую игровую доску в стек «Повтор» m_redo.push(m_board); // Назначаем верхний элемент стека «Отмена» текущим m_board = m_undo.top(); m_undo.pop(); } bool CSameGameDoc::CanUndo() { // Убеждаемся, что у нас есть возможность выполнить отмену действия return !m_undo.empty(); } void CSameGameDoc::RedoLast() { // Смотрим, есть ли у нас что-нибудь в стеке «Повтор» if(m_redo.empty()) return; // Помещаем текущую игровую доску в стек «Отмена» m_undo.push(m_board); // Назначаем верхний элемент стека «Повтор» текущим m_board = m_redo.top(); m_redo.pop(); } bool CSameGameDoc::CanRedo() { // Убеждаемся, сможем ли мы выполнить повтор действия (не пуст ли стек) return !m_redo.empty(); } void CSameGameDoc::ClearUndo() { // Очищаем стек «Отмена» while(!m_undo.empty()) { delete m_undo.top(); m_undo.pop(); } } void CSameGameDoc::ClearRedo() { // Очищаем стек «Повтор» while(!m_redo.empty()) { delete m_redo.top(); m_redo.pop(); } } |
Мы предполагаем, что всегда будет действительная игровая доска, на которую ссылается указатель m_board
, поэтому она должна создаваться в конструкторе, а удаляться в деструкторе. Как только объект будет удален в деструкторе, то мы также должны удалить все другие игровые доски, которые были сохранены, вызвав функции Clear…() для очистки стеков «Отмена/Повтор».
Затем мы добавили изменения в функцию OnNewDocument(), которые очищают стеки «Отмена/Повтор», чтобы новая игра начиналась со свежего набора стеков. Последнее изменение в этом файле — это перемещение функции DeleteBlocks() из заголовочного файла в исходный файл .cpp.
Прежде чем мы удалим какие-либо блоки или изменим расположение элементов на игровом поле, нам нужно сохранить копию текущей игровой доски в стеке «Отмена». Выполняется это с помощью конструктора копирования, который мы написали. Как только мы выполним какое-либо действие, нам нужно сразу же очистить стек «Повтор», потому что все данные, которые были в нем, больше не действительны. После того, как эти два стека будут обновлены, мы готовы приступить к фактическому удалению блоков. Далее, как только игра закончится, мы должны очистить стек «Отмена», потому что игра в своей фазе достигла своего окончательного состояния. Очистка стека ставит точку в текущей игровой партии, т.к. не позволяет игроку вернуться на несколько шагов назад и по-другому «сыграть прошлые ходы». В самом конце мы возвращаем количество блоков, которые были удалены.
Функции UndoLast() и RedoLast() похожи друг на друга. Они выполняют действия в обратном порядке. Сначала мы должны убедиться, что у нас есть возможность отменить или повторить действие. Для этого можно было бы использовать функцию CanUndo() или CanRedo(), но из соображений эффективности мы используем STL-функцию empty().
Поэтому, если у нас есть действие, для которого мы можем выполнить «Отмена/Повтор», мы берем текущую игровую доску и помещаем её в соответствующий противоположный стек: в стек «Повтор», если отменяем действие, и в стек «Отмена» — если повторяем действие. Затем указатель текущей игровой доски мы устанавливаем на доску, расположенную на вершине стека «Отмена» или стека «Повтор», и достаем её оттуда. Функции CanUndo() и CanRedo() нужны нам для того, чтобы определить, можем ли мы отменить/повторить действие.
Последние две функции, добавленные в класс Document, нужны для очистки и освобождения памяти, используемой различными стеками. Мы просто перебираем все указатели в стеке, удаляя объект, а затем выталкиваем указатель из стека. Это гарантирует то, что вся память будет освобождена.
Сейчас нам нужно внести изменения во View. Эти изменения являются добавлением простых обработчиков событий для параметров меню «Отмена» и «Повтор». Сначала мы создаем их с помощью кнопки "События"
(молния) на панели «Свойства» файла CSameGameView.h. Мы хотим добавить обработчики ON_COMMAND
и ON_UPDATE_COMMAND_UI
. Обработчик ON_UPDATE_COMMAND_UI
позволяет нам отключить параметры меню, когда нет действий для выполнения «Отмена/Повтор». После добавления всех 4 обработчиков событий следующий код будет автоматически добавлен в заголовочный файл SameGameView.h:
1 2 3 4 5 6 7 |
// Функции для меню «Отмена/Повтор» afx_msg void OnEditUndo(); afx_msg void On32771(); // Функции для обновления меню «Отмена/Повтор» afx_msg void OnUpdateEditUndo(CCmdUI *pCmdUI); afx_msg void OnUpdate32771 (CCmdUI *pCmdUI); |
Примечание: При попытке изменить ID функции On32771() и OnUpdate32771() на ID_EDIT_REDO
, MS Visual Studio (2017/2019) выдавал ошибку, поэтому пришлось оставить всё, как есть.
Эти прототипы функций похожи на обработчиков событий меню, которые мы рассматривали на предыдущих уроках, поэтому я не буду вдаваться в подробности. Теперь давайте посмотрим на исходный файл. В карте сообщений вы найдете четыре новые строки, которые настраивают обработчики событий, связывая события, идентификаторы и функции. Опять же, мы уже видели это раньше:
1 2 3 4 |
ON_COMMAND(ID_32771, &CSameGameView::On32771) ON_COMMAND(ID_EDIT_UNDO, &CSameGameView::OnEditUndo) ON_UPDATE_COMMAND_UI(ID_EDIT_UNDO, &CSameGameView::OnUpdateEditUndo) ON_UPDATE_COMMAND_UI(ID_32771, &CSameGameView::OnUpdate32771) |
Получаем указатель на Document, вызываем функцию в Document и, наконец, перерисовываем View. Сделаем это для реализации действий «Отмена/Повтор»:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
void CSameGameView::OnEditUndo() { // Получаем указатель на Document CSameGameDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); if(!pDoc) return; pDoc->UndoLast(); // Перерисовываем View Invalidate(); UpdateWindow(); } void CSameGameView::On32771() { // Получаем указатель на Document CSameGameDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); if(!pDoc) return; pDoc->RedoLast(); // Перерисовываем View Invalidate(); UpdateWindow(); } |
Теперь очередь дошла и до обработчиков событий для ON_UPDATE_COMMAND_UI
. Новой здесь является функция Enable(), которая указывает на то, следует ли включить или отключить опцию меню на основе результата, который возвращает функция CanUndo() или CanRedo():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
void CSameGameView::OnUpdateEditUndo(CCmdUI *pCmdUI) { // Сначала получаем указатель на Document CSameGameDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); if(!pDoc) return; // Включаем опцию, если она доступна pCmdUI->Enable(pDoc->CanUndo()); } void CSameGameView::OnUpdate32771 (CCmdUI *pCmdUI) { // Сначала получаем указатель на Document CSameGameDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); if(!pDoc) return; // Включаем опцию, если она доступна pCmdUI->Enable(pDoc->CanRedo()); } |
Таким образом, наши стеки «Отмена/Повтор» теперь полностью функционируют. Вы сами можете проверить их работу, запустив игру. Сделайте действие и проверьте пункт меню «Отменить». Он должен быть включен. Нажмите на него, и вы увидите начальную игровую доску. Проверьте пункт меню «Повтор». Он будет включен. Нажмите на него, и ваше действие будет восстановлено. Вот примерно следующим образом должна выглядеть ваша игра сейчас:
Акселераторы
Попробуйте нажать Ctrl+Z
после того, как вы сделали несколько ходов. Вы увидите, что отмена действия работает также и по комбинации клавиш. Теперь попробуйте нажать Ctrl+Y
для «повтора» действия. Сработало? Нет? Мы можем это исправить. Обратите внимание, в опции меню для повтора мы указали пользователю, что Ctrl+Y
отправит ON_COMMAND
в ID_32771
. Вот это и называется акселератором.
Чтобы получить доступ к акселераторам, откройте «Редактор ресурсов» из меню "Вид" > "Другие окна"
или нажмите сочетание клавиш Ctrl+Shift+E
. Затем откройте опцию акселератора в SameGame.rc и дважды щелкните по IDR_MAINFRAME
, чтобы вызвать редактор акселератора. На следующем скриншоте мы добавили акселератор для команды «Повтор»:
Чтобы добавить свой собственный акселератор, нажмите на пустую строку после последнего акселератора в столбце ID — это вызовет выпадающее меню, которое позволит выбрать ID_EDIT_32771
(идентификатор опции меню для команды повтора). Присвойте ему ключ Y
и модификатор Ctrl
(Ctrl+Y
). Теперь скомпилируйте свою игру и запустите её. Мы только что добавили комбинацию клавиш, которая теперь отправляет ON_COMMAND
в ID_EDIT_32771
.
Заключение
Это было еще то путешествие! Мы с нуля сделали полноценную игру, рассмотрели множество тем, связанных с разработкой игр и работой с платформой Windows. Создание игр — это круто, и я надеюсь, что вы тоже получили удовольствие от процесса и увидели, что в создании игр нет чего-то сверхъестественного. Я думаю, что вы вдохновлены на самостоятельное продвижение и воплощение своих идей.
Есть еще много опций, которые мы могли бы добавить в эту игру, включая ведение счета и отслеживание максимальных результатов по баллам или сохранение параметров в реестре, чтобы в следующий раз, когда вы будете открывать игру, программа запомнила, что вы играете на уровне с 7 цветами и блоками 10×10 на игровом поле 40×40. Можно было бы также реализовать функционал подсказок, которые подсказывали бы следующие возможные ходы. Попробуйте сами реализовать некоторые из этих идей.
Удачи!
GitHub / Исходный код — Урок №9: Финальные штрихи в создании игры «SameGame» на C++/MFC
Спасибо большое за курс! Эх как же далеко ушли технологии программирования от моих ДВК-ашных уровней вообще мало чего осталось… Теперь мало выучить язык и даже знание функция библиотек поможет только отчасти.
Важно уметь пользоваться IDE почти на уровне вслепую…
Здравствуйте, можете подсказать как сделать ведение счета?
Спасибо за уроки. А c большим количеством документов MFC на много сложней?
Файл SameGameDoc.h не отредактирован до конца, нет 4 прототипов новых функций в public, а в имеющихся не отредактирован синтаксис с "точки" на "стрелку".
Большое спасибо за курс, познавательно и доходчиво =)
Будет очень круто, если будет рассмотрен, ещё какой-нибудь проект с MFC. Спасибо за уроки.
Пожалуйста. Насчет еще одного проекта с MFC — я думаю, что в ближайшем будущем можно рассмотреть что-нибудь еще 🙂
Добрый вечер! А будут ли уроки по WinApi?
А что конкретно из WinApi интересует? 🙂