Программирование логической игры с использованием DirectX. Крестики-нолики.

Владимир | | C++.

Не так давно я начал изучать библиотеку DirectX, и, естественно, мне тут же захотелось написать свою собственную игрушку. Информации на эту тему масса, начиная от описания базовых алгоритмов, и заканчивая полностью готовыми играми. С выбором темы игры я долго не мучился. Варианты типа DOOM4 и Elder Scroll's 5:-) я отбросил сразу. Хотелось написать что-то по быстрому, и, в тоже время, полностью самостоятельно (без использования готовых движков, моделей и т.п.). Поэтому выбор пал на простую логическую игрушку — крестики-нолики. Кроме простоты реализации эта игра обладает ещё одним очень важным достоинством — никому не нужно рассказывать правила 🙂 .

О программе
Описание программы
Заключение
Скачать

О программе

Эта программа существенно отличается от большинства примеров, размещённых на сайте. Во-первых, она написана на С++ с помощью Visual Studio.NET.
Во-вторых, в ней используются две библиотеки: API Windows и DirectX9. Соответственно, для сборки проекта должен быть установлен DirectX SDK девятой версии.
API Windows используется только для создания окна и обработки сообщений мыши. DirectX — для создания интерфейса пользователя (крестики, нолики, сетка, меню и т.п.).
Кроме того, хочу сразу предупредить, это одна из моих первых программ, написанная с использованием DirectX. Поэтому не стоит использовать ее как образец для ваших собственных разработок, особенно, если они крупные (например, движок). С другой стороны, работает она правильно (именно так, как задумывалось), а не совсем оптимальный код практически не заметен в играх такого масштаба:-). Так что, выбор за вами.
В-третьих, для работы программы кроме файла xzgame.exe необходимы файлы моделей и текстур (если вы используете инсталлятор, то они установятся автоматически).
В-четвертых, я сделал инсталлятор с помощью программы Inno Setup. Конфигурационный файл называется dist.iss.


Описание программы

Программу можно условно разделить на две части:

  • первая — хранит информацию о расположении крестиков и ноликов, выполняет расчёт очередного хода;
  • вторая — рисует игровое поле, выполняет обработку команд пользователя и т.п.

Так как игра достаточно простая, то всю работу, связанную с хранением информации об игре и выбором очередных ходов, выполняет всего один класс — XZField. Его структура показана на рис.1.
Нажмите чтобы увеличить рисунок

Рис.1. Класс XZField.

Пользоваться им очень просто. С помощью метода setElement можно добавить крестик или нолик в заданную ячейку. Метод getElement возвращает элемент, расположенный в заданной ячейке. Выбор следующего хода осуществляется методом findNextMove. В качестве параметров этому методу передаются ссылки на переменные, в которых нужно сохранить координаты выбранного хода. Если новый ход был найден, метод возвращает true, в противном случае — false.

Пример:

XZField f; . . . int row, col; //переменные, в которых сохраняются координаты следующего хода
//ищем следующий ход
if(f.findNextMove(row, col)) {
    f.setElement(f.ZERO, row, col); //устанавливаем элемент
}

Метод isGameOver позволяет определить закончена ли игра, и кто выиграл. Если игра закончена, возвращает true, в противном случае — false. В параметре winner сохраняются данные о победителе (крестики, нолики или ничья).

Более подробное описание работы методов класса можно почитать в комментариях к исходному коду класса.

Вторая часть программы значительно объёмнее. Она отвечает за прорисовку всех элементов игры (создание окна, меню, крестики, нолики и т.д.), взаимодействие с пользователем (обработка сообщений мыши).

Таким образом, нам нужно несколько объектов с различными свойствами и назначением:

  • объекты игры (крестик, нолик, сетка, прямоугольник выделения) — каждый из них является 3D моделью и хранится в файле с расширением .х в папке models (cross.x, zero.x, grid.x, selectionPlane.x). Все эти модели должны быть загружены перед началом игры, также необходим метод для прорисовки (желательно один);
  • меню — представляет собой квадрат с текстурой, на которой нарисованы изображения кнопок, текстовые сообщения и пр.. Текстуры должны загружаться из файлов при загрузке программы. Нужны методы для изменения текстур и прорисовки меню;
  • искусственный интеллект (AI) — это уже рассмотренный класс XZField;
  • создание окна и обработка ввода пользователя — нужно создать обычное окно, создать объект и устройство Direct3D, установить обработчики сообщений мыши, также нужен метод для перерисовки окна;
  • сообщения об ошибках — даже в самых простых программах, могут возникнуть десятки различных ошибок, поэтому необходим общий подход к их обработке.

На рис.2 изображена диаграмма классов игры. На ней прямоугольниками изображены классы, а стрелками показаны связи между ними. Разберём подробнее, что это все означает и как работает.
Нажмите чтобы увеличить изображение

Рис.2. Диаграмма классов игры

Итак, в этой игре я использовал следующие классы:

  • BaseWindowClass
  • XZGame
  • ExceptionBase
  • XZField
  • NewGameMenu
  • XZMesh

Теперь посмотрим, что они делают, и как связаны друг с другом.

Класс BaseWindowClass используется для создания пустого окна. Использовать его напрямую нельзя, т.к. он содержит абстрактный метод Render(). Поэтому нам нужно создать дочерний класс, в котором этот метод будет выполнять рендеринг объектов игры. Но об этом чуть позже, а сейчас рассмотрим как пользоваться остальными методами класса BaseWindowClass.

Конструктор — ничего не делает.
Create — создает окно. В качестве параметров можно указать:

  • hInstance — идентификатор приложения;
  • szTitle — название приложения;
  • iStyle — стиль окна;
  • iWidth — длина окна;
  • iHeight — ширина окна.

Это обычные параметры, которые указываются при создании оконных приложений, поэтому я не буду их здесь подробно объяснять.
WndProc — это оконная процедура. Вызывается операционной системой, для передачи сообщений окну.

Run — этот метод запускает цикл обработки сообщений. В каждом цикле есть вызов метода Render().

MessageHandler — в этом методе выполняется обработка сообщений, отправленных окну. Написан обработчик сообщения WM_DESTROY, которое приложение получает, когда пользователь закрывает приложение. Обработка этого сообщения является одинаковой для большинства приложений (если пользователь нажал кнопку «закрыть», значит надо завершать работу), поэтому я написал обработчик в базовом классе. Вы, наверное, заметили, что этот метод объявлен виртуальным. Зачем нам это нужно? В первую очередь давайте посмотрим, кто вызывает этот метод. Сообщение окну передаёт операционная система с помощью оконной процедуры, которая в свою очередь вызывает обработчик. Теперь представим, что нам нужно обрабатывать другие сообщения (например, перемещения мыши). Можно, конечно добавить соответствующие обработчики прямо в текст метода, т.е. каждый раз менять код базового класса, а это не правильно. Гораздо удобнее создать в производном классе такой же метод (MessageHandler), и добавить обработчики в него. Т.е. у нас будет два метода MessageHandler, один — в базовом классе, другой — в производном. При этом нам нужно, чтобы оконная процедура вызывала метод производного класса (из него, при необходимости, мы можем вызвать метод базового класса). Так вот, объявление метода виртуальным гарантирует, что мы получим именно такое поведение. Тем, кто хочет разобраться подробнее с этим моментом, я советую почитать какую-нибудь книжку по объектно-ориентированному программированию.

Render — объявлен абстрактным. Это значит, что в производном классе нам придется написать его реализацию. Т.е. объявить точно такой же метод, и написать код, который он будет выполнять (в принципе, этот метод можно оставить пустым, если вы не хотите ничего рисовать).

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

Работу этого класса в качестве движка мы рассмотрим далее, а сейчас я только покажу какие методы класса BaseWindowClass мы переопределяем и какие добавляем. В первую очередь нам нужен метод initD3DAndGameObjects(). Он выполняет инициализацию DirectX и всех объектов игры. Далее мы метод setMatricesAndRenderStates() в котором настраиваем устройство Direct3D и матрицы (мира, вида и проекционную). Затем мы переопределяем метод MessageHandler, в котором у нас содержатся обработчики сообщений. И последний метод — Render(). В нем мы будем рисовать объекты игры (крестики, нолики, игровое поле, меню).

Теперь посмотрим как пользоваться этим классом. Все очень просто. В функции WinMain нам нужно написать примерно такой код:

//создаем экземпляр класса
XZGame XZGame game;
//создаем окно
game.Create(hInst, "Крестики-нолики 1.0", WS_OVERLAPPEDWINDOW | WS_VISIBLE, 640, 480); //инициализируем Direct3D и объекты игры
game.initD3DAndGameObjects();
//устанавливаем матрицы и настраиваем параметры отображения сцены game.setMatricesAndRenderStates();
//входим в цикл обработки сообщений
game.Run();

В результате будет запущена наша игрушка.
Следующий класс — ExceptionBase. Это сервисный класс. Он используется всеми классами для передачи сообщений об ошибках. Такой подход позволяет создать общую для всей программы систему уведомления об ошибках.
Пользоваться им очень просто. Если возникла ошибка, мы создаём объект этого класса с указанием описания ошибки, и генерируем исключение. Например:

ExceptionBase error(111, "Какая-то ошибка");
throw error;

Здесь, 111 — код ошибки, «Какая-то ошибка» — описание ошибки.
Перехват исключений выполняется как обычно:

try {
    //код программы
}
//обработка исключений типа ExceptionBase
catch(ExceptionBase err) {
    MessageBox(NULL, (const char*)err, "Ошибка", MB_ICONERROR);
}
//обработка исключений других типов
catch(…) { }

Тут все просто, но я хочу обратить ваше внимание на оператор (const char*)err. Он выполняет преобразование объекта типа ExceptionBase к типу const char*. Используя такое преобразование можно легко получить строку, содержащую описание ошибки. Есть также оператор приведения к типу int, который возвращает код ошибки.
Класс XZMesh.

Как вам известно, практически все объекты в играх представляют собой меши, т.е. наборы связанных между собой вершин. Для их создания удобнее всего воспользоваться каким-нибудь 3D редактором. Например, я пользуюсь Blender'ом (полностью бесплатная программа, причем очень не большого размера). Вы можете воспользоваться любым другим редактором в зависимости от ваших предпочтений, знаний, навыков и т.п., главное чтобы вы могли в нем нарисовать то, что вам хочется:-).
И ещё один важный момент по поводу редакторов. После того, как вы нарисовали красивый объект, вам нужно будет загрузить его в вашу программу, и тут возникает интересный момент. Каждый редактор использует свой собственный формат для хранения файлов, поэтому убедитесь, что у вас есть возможность импортировать файлы в .х формат (это единственный формат, который поддерживается библиотекой DirectX). Для Blender'а я использовал DirectX8ExporterMod, для 3Dmax'a и Maja конвертер есть в DirectX SDK. Впрочем, если вы занимаетесь созданием движка или просто большим проектом, то можно создать загрузчик файлов для нужного формата (процедура очень трудоёмкая, но обычно себя оправдывает: увеличивается скорость загрузки, можно загружать дополнительную информацию и т.д.).
Но вернёмся к нашему классу. Он очень простой (практически всю работу выполняет DirectX). При вызове конструктора нужно указать имя файла, в котором находится mesh. Для того, чтобы нарисовать mesh вызываем метод render(). Например так:

XZMesh cross = new XZMesh(pD3DDevice, "modelscross.x");
...
cross.render();

И это все. Никаких наворотов, только то, что необходимо.
Следующий класс NewGameMenu. Он создаёт меню, которое видит игрок перед началом и после окончания игры. Итак, что представляет собой меню? Это просто прямоугольник, на который «натянута» текстура с изображение меню. Тут все просто, но сколько нам нужно текстур? Давайте считать. Существует 4 возможных варианта:

  • игрок только что запустил программу, и мы предлагаем начать игру;
  • игрок выиграл предыдущую партию, мы выводим соответствующее сообщение и предлагаем сыграть ещё раз;
  • игрок проиграл предыдущую игру;
  • предыдущая игра закончилась вничью.

Кроме этого я решил реализовать подсветку кнопок меню, т.е. нужно по одной дополнительной текстуре для каждой кнопки (каждое меню в игре имеет две кнопки). Таким образом, получается, что нужно по 3 текстуры на каждый вариант меню. Итого, 4 * 3 = 12 текстур.
Посмотрим, как пользоваться нашим классом.
При создании меню нам нужно указать положение центра меню на экране, передать указатель на массив с именами текстур, количество текстур в массиве, длину и ширину меню, прямоугольник в котором нужно нарисовать меню.
Для прорисовки меню, как вы догадались, используется метод render().
Два дополнительных метода класса — checkSelectedButtons и setCurrentMenu используются вместе. Зачем они нужны? Помните, я писал, что мы будем подсвечивать кнопки меню, так вот, чтобы включить подсветку мы должны проверить, где находится курсор мыши, и установить соответствующую текстуру. Метод checkSelectedButtons в качестве параметра принимает координаты курсора мыши, и возвращает номер выбранной кнопки, а метод setCurrentMenu устанавливает нужное меню. Таким образом, если вызывать эти методы при в обработчике события WM_MOUSEMOVE (возникает при перемещении курсора мыши), то мы получим обычное меню: навели курсор на кнопку, она подсветилась, убрали — подсветка исчезла.
Приводить пример использования этого класса я не буду, т.к. он очень тесно связан с остальным кодом проекта (обработкой сообщений, установкой матриц преобразований и т.п.), поэтому будет лучше, если вы посмотрите исходники программы, и почитаете комментарии.
Теперь, как я и обещал, вернёмся к классу XZGame, и рассмотрим, как он осуществляет управление ходом игры (т.е. его работу в качестве движка).
В первую очередь посмотрим на рис.3. На нем изображена диаграмма состояний класса, на которой изображены все возможные варианты хода игры (ситуации, вроде нажатия на кнопку «Reset» или удара молотком по системному блоку 🙂 здесь не учитываются).
Нажмите чтобы увеличить изображение

Рис.3. Диаграмма состояний класса

Давайте разберем все по порядку. У нас есть шесть основных состояний игры, каждому из которых соответствует своя константа в перечислении states (см. исходный код):

  • NEW_GAME_MENU — начало новой игры, пользователь только что запустил программу;
  • NEW_GAME_MENU_WIN — начало новой игры, пользователь выиграл предыдущую игру;
  • NEW_GAME_MENU_LOST — начало новой игры, пользователь проиграл предыдущую игру;
  • NEW_GAME_MENU_DRAW — начало новой игры, предыдущая игра закончилась вничью;
  • PLAYER_MOVE — ожидание хода игрока;
  • COMP_MOVE — ожидание хода компьютера (ждем, пока метод findNextMove класса XZField выберет ход).

Итак, игрок только что запустил программу.

Объект класса XZGame находится в состоянии NEW_GAME_MENU, и, соответственно, на экране отображается меню с предложением начать игру. Если игрок нажимает кнопку «нет» — игра завершается (не понятно — зачем вообще он ее запускал? От скуки щелкал по всем ярлыкам подряд:-)). А вот если нажата кнопка «да» — начинаем играть. В первую очередь, программа выбирает, кто первым будет ходить (случайным образом, с помощью функции rand()). Далее, в зависимости от результатов предыдущего этапа, мы попадаем в состояние PLAYER_MOVE или COMP_MOVE. В состоянии PLAYER_MOVE мы просто ждем, пока игрок сделает ход (кстати, игрок всегда играет ноликами). А в состоянии COMP_MOVE мы ждем результатов метода findNextMove.

Затем мы проверяем состояние игры (завершена, не завершена, если завершена, то с каким результатом). Если игра не была завершена, то мы переходим либо в состояние PLAYER_MOVE, либо COMP_MOVE, в зависимости от того, кто ходил перед этим. Если игра завершена, то в зависимости от ее результатов, мы переходим в одно из трех состояний: NEW_GAME_MENU_WIN (если победил игрок), NEW_GAME_MENU_LOST (если победил компьютер), NEW_GAME_MENU_DRAW (если игра закончилась вничью). Эти состояния очень похожи. Все три рисуют меню с предложением начать новую игру. Разница только в текстурах, которые используются для меню (в верхней части текстуры нарисована строка с результатами игры). Дальше все просто, если игрок нажмет кнопку «да» — переходим в состояние розыгрыша первого хода, и процесс повторяется, если игрок нажал кнопку «нет» — завершаем работу.


Заключение

Это очень простая игра, но, тем не менее, здесь используются те же приемы, что и в больших проектах. Проблема с большими проектами в том, что в них, как говорится, «за лесом деревьев не видно» (особенно, если вы только начали изучать DirectX и принципы создания игр). Конечно, моя игрушка написана далеко не идеально. К моменту окончания работы над ней, я видел кучу недостатков, и, если бы я делал ее заново, то возможно она получилась бы лучше и красивее. Но игра работает. В нее можно играть. А это все-таки положительный результат.

В общем, надеюсь, эта статья вам поможет.


Скачать:

игру крестики-нолики (setup.exe — 578кБ);

исходный код (xzgame.zip — 475кБ).

Постовой

Продвижение сайта сделает ваш товар ближе к потребителю