Данный материал взят с сайта old.shatalov.su и является его зеркалом

Создаём компьютерную игру. Создание игр на C++/DirectX

Есть вопросы?
Ошибка на сайте?
рус eng esp
Внимание! Данный сайт не обновляется. Новая версия: shatalov.su

Программа Клетки

Дата создания: 2010-01-24 23:06:52
Последний раз редактировалось: 2012-03-01 01:39:59

Cегодня мы будем рассматривать программу Клетки. Причём рассмотрим сразу две версии. Исходный код версии 0.1 можно скачать из раздела Листинги. Версию 0.2 вы должны будете воссоздать в упражнениях.,/p>

    Наши сегодняшние задачи:
  1. Научиться выбирать объекты в двухмерном пространстве.
  2. Улучшить навыки работы с двухмерной графикой.

Конечно же, неплохо было бы сразу научиться выбирать объекты в трёхмерном пространстве, но всё-таки мы пока освоимся с более простым двухмерным.

    В программе Клетки выводится только двухмерная графика. Давайте ещё раз вспомним, каким образом мы выводим двухмерную графику:
  1. Получение фонового буфера через метод IDirect3DDevice9::GetBackBuffer.
  2. Определение участка фонового буфера, в который будет "нарисована" картинка. В фоновый буфер можно копировать только прямоугольники. На данном шаге происходит заполнение полей структуры RECT.
  3. Замыкание выбранного нами участка, чтобы во время копирования нашей картинки, никто не мог получить доступ к этому участку. Буфер замыкается методом IDirect3DSurface9::LockRect.
  4. Копирование картинки в фоновый буфер функцией memcpy.
  5. Размыкание буфера - метод IDirect3DSurface9::UnlockRect.

Ещё одно небольшое замечание: чтобы картинка скопировалась правильно в фоновый буфер, форматы пикселей картинки и фонового буфера должны совпадать. Напоминаю, что мы используем четырёхканальный 32-битный цвет - X8R8G8B8.

Программа Клетки 0.1

В огромном количестве игр используются так называемые тайлы (tile - плитка, tiles - плитки): в подавляющем большинстве старых игр и в ряде современных (прежде всего в стратегиях). В тексте я буду использовать несколько обозначений: клетки, плитки, тайлы - это одно и то же. Плитки - это прямоугольные картинки, из которых состоит игровое поле. В некоторых играх (серия Heroes of Might and Magic) используются шестиугольники. В таких играх вместо обозначения тайлы используется слово гексы (от hexagon - шестиугольник). Есть игры, в которых вообще не используются тайлы - все шутеры.

Мы начнём рассмотрение игр именно с тайловых, так как их довольно просто программировать. В программе клетки выводится поле заданного размера. При щелчке на один из тайлов, меняется его цвет.

Упрощённый код программы:

// объявление глобальных переменных
WinMain()
{
  // инициализация окна
  // инициализация DirectX
  InitGraphics();
  while (1)
  {
    // ввод
    DrawInterface();
    DrawGraphics();
  }
}

    В программе три функции:
  1. InitGraphics. В функции происходит создание всех графических элементов программы. В версии 0.2 в этой же функции происходит загрузка картинок bmp.
  2. DrawInterface. Рисование элементов интерфейса: рисование кнопки закрытия и сетки вокруг плиток.
  3. DrawGraphics. Вывод на экран самой важной части программы - тайлов разных цветов.

Теперь приступим к подробному разбору кода.

Глобальные переменные

Прежде всего необходимо объявить несколько констант ответственных за клетки.

const int NUM_ROWS = 5;    // количество строк
const int NUM_COLUMNS = 5; // количество столбцов
const int TILE_SIZE = 90;  // размер тайла (и ширина, и высота)
const int OFFSET_X = 20;   // смещение поля клеток по x
const int OFFSET_Y = 30;   // смещение поля клеток по y

Надеюсь, здесь всё понятно. Мы используем квадратные тайлы (использовать другие мы и не будем), поэтому нам не нужно две отдельных переменных под ширину и высоту. OFFSET_X и OFFSET_Y - это смещение поля тайлов от начала координат (левый верхний угол). С помощью этих переменных мы можем передвигать в окне сразу все тайлы. Если непонятно предназначение этих переменных, скомпилируйте и запустите программу с разными значениями. Для хранения всех этих значений мы использовали константы. Т.е. во время выполнения программы мы не можем изменить, например, размер тайлов. Все значения связанные с тайлами можно менять только через код.

Следующая группа констант - цвета:

const int BLACK  = 0x00000000; // чёрный
const int WHITE  = 0x00ffffff; // белый
const int ORANGE = 0x00f86900; // оранжевый
const int GREEN  = 0x001ef938; // зелёный
const int GRAY_1 = 0x00d4d0c8; // три оттенка серого
const int GRAY_2 = 0X00808080;
const int GRAY_3 = 0x00404040;

Здесь мы создаём палитру цветов. Для вывода всей графики будут использованы только эти цвета.

D3DLOCKED_RECT lockedRect;
RECT winRect;   // прямоугольник окна
RECT mapRect;   // прямоугольник всех плиток
RECT closeRect; // прямоугольник кнопки закрытия
RECT tileRect;  // прямоугольник текущей клетки

Когда мы замыкаем какой-то прямоугольник в фоновом буфере, мы получаем на него указатель D3DLOCKED_RECT. В любой момент времени у нас будет замкнут только один участок фонового буфера, поэтому нам хватит одной переменной типа D3DLOCKED_RECT. После объявления lockedRect следуют объявления четырёх переменных типа RECT - в этих переменных будут заданы координаты замыкаемых участков буфера:

1. winRect - всё окно, эта переменная будет использоваться для вывода рамки вокруг окна.
2. mapRect содержит координаты всего поля плиток (игровой карты); переменная нужна для отрисовки сетки.
3. closeRect - прямоугольник кнопки закрытия.
4. tileRect - координаты отдельного тайла. Эта переменная меняет свои значения перед рисованием каждого тайла.

int map[NUM_ROWS*NUM_COLUMNS];         // массив клеток
int closeButton[10*10];                // кнопка закрытия окна
int tiles[2][TILE_SIZE*TILE_SIZE]; // типы клеток

map - массив клеток. Элементы массива могут принимать два значения. 0 - зелёная клетка. 1 - оранжевая.
closeButton - массив пикселей, где каждый пиксель занимает 4 байта. Т.е. это картинка. Мы могли бы загрузить её из отдельного bmp файла. Но картинка простая, и поэтому мы создадим её внутри программы.
tiles - типы тайлов. В данном массиве хранятся две картинки, которые мы, также как и кнопку закрытия, создадим внутри программы. Размер каждого тайла - TILE_SIZE*TILE_SIZE (ширину умножить на высоту) пикселей.

int top=0,left=0,right=0,bottom=0;
int i=0,j=0,k=0;

В первой строке происходит определение переменных, в которых мы будем хранить координаты отдельного тайла. Обратите внимание, что для этой же цели создавалась и переменная tileRect. В чём отличия: tileRect используется для вывода, а отдельные переменные - для проверки выбора данного тайла мышкой.

Во второй строке объявляются все счётчики программы. Это чтобы не писать лишнее слово (указание типа при инициализации счётчика), например, в цикле for.

До функции WinMain встречаются ещё несколько объявлений функций и указателей DirectX.

Функция InitGraphics

В данной функции происходит создание графики.

winRect.top = 0;
winRect.left = 0;
winRect.right = 499;
winRect.bottom = 499;
mapRect.top = OFFSET_Y;
mapRect.left = OFFSET_X;
mapRect.bottom = 499;
mapRect.right = 499;

Инициализация структурных переменных winRect и mapRect. Прямоугольник mapRect занимает площадь от OFFSET_X, OFFSET_Y и до конца окна. Хотя в нашем случае это и не играет никакой роли, но поля bottom и right должны выглядеть немножко по другому:

mapRect.bottom = OFFSET_Y+NUM_ROWS*TILE_SIZE+NUM_ROWS+1;
mapRect.right = OFFSET_x+NUM_COLUMNS*TILE_SIZE+NUM_COLUMNS+1;

Но, повторюсь ещё раз, в данном случае это не имеет значения.

Далее происходит заливка отдельных тайлов цветом:

for (i=0;i < TILE_SIZE;++i)
  for(j=0;j < TILE_SIZE;++j)
  {
    memcpy(reinterpret_cast(&tiles[0][j+TILE_SIZE*i]),
           &GREEN,4);
    memcpy(reinterpret_cast(&tiles[1][j+TILE_SIZE*i]),
           &ORANGE,4);
  }

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

Ещё раз хочу напомнить о представлении двухмерного массива одномерным. Смотрите, каждый тайл - одномерный массив из TILE_SIZE*TILE_SIZE пикселей. В этом одномерном массиве хранится прямоугольная картинка (в нашем случае - квадратная) шириной TILE_SIZE пикселей (и такой же высоты). Чтобы получить доступ к отдельному пикселю, используется два цикла. Внешний цикл "пробегает" по строкам, внутренний - по столбцам. Текущий элемент вычисляется по формуле: текущий столбец + текущая строка * кол-во элементов в строке. Мы уже обсуждали этот вопрос, но я хочу ещё раз заострить ваше внимание на нём. Это чрезвычайно важно - уметь представлять двухмерный массив в виде одномерного. Запомните формулу по которой происходит доступ к конкретному элементу массива.

Переходим к созданию кнопки закрытия.

closeRect.top = 7;
closeRect.left = 499-17;
closeRect.bottom = 17;
closeRect.right = 499-7;
for (i=0;i < 10;++i)
  for (j=0;j < 10;++j)
    memcpy(reinterpret_cast<char*>(&closeButton[j+i*10]),&WHITE,4);

for (i=0;i < 10;++i)
{
  memcpy(reinterpret_cast<char*>(&closeButton[i+i*10]),&BLACK,4);
  memcpy(reinterpret_cast<char*>(&closeButton[9+i*10-i]),&BLACK,4);
}

В полях переменной closeRect задаётся местоположение кнопки закрытия в окне программы. Далее картинка заполняется белым цветом точно таким же способом, как и тайлы.

В отдельном цикле двумя операторами рисуется крестик.

Функция DrawInterface

Сначала в этой функции рисуется (копируется в фоновый буфер) кнопка закрытия:

backBuffer->LockRect(&lockedRect,&closeRect,0);
for (i=0;i < 10;++i)
  memcpy(static_cast<char*>(lockedRect.pBits)+i*lockedRect.Pitch,
         reinterpret_cast<char*>(closeButton)+i*10*4,
         4*10);
backBuffer->UnlockRect();

Кнопка выводится построчна (10 пикселей или 40 байт за итерацию цикла). Обратите внимание, как вычисляется адрес байта в массиве closeButton. В предыдущей функции использовался другой способ. Рассмотрим оба способа вычисления адреса более подробно (получим доступ к двадцатому байту):

  1. Сначала вычисляется элемент массива (через операцию квадратных скобок), а потом берётся адрес: reinterpret_cast<char*>(&closeButton[5]);
  2. Сначала берётся указатель на конкретный тип, а потом происходит вычисление. Заметьте, так как в этом случае происходит приведение указателя к типу char*, то это нужно учитывать (умножение на 4). reinterpret_cast<char*>(closeButton)+5*4;

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

После этого рисуется рамка вокруг окна. В данном примере мы нарисуем рамку классического окна Windows. Толщина рамки - три пикселя:

backBuffer->LockRect(&lockedRect,&winRect,0);
for (i=0;i < 500;++i)
  memcpy(static_cast(lockedRect.pBits)
         +2*4+i*lockedRect.Pitch,&GRAY_1,4);
// Внимание! Здесь рисуется только одна линия.
// Оставшийся код в исходном файле к уроку
backBuffer->UnlockRect();

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

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

Функция DrawGraphics

В версии 0.1 между тайлами есть расстояние в один пиксель. Я специально сделал разрывы между тайлами, чтобы нарисовать сетку (заодно это немного усложнило вычисление координат :).

Горизонтальные и вертикальные линии сетки рисуются отдельно. При этом используется два вложенных цикла и одна операция memcpy. В первом аргументе функции memcpy используется довольно сложное смещение.

Код рисования сетки смотрите в исходном файле. Но! Будет лучше если вы удалите этот код и напишете его самостоятельно. Настоятельно рекомендую это сделать (очень полезное упражнение).

Далее идёт самая важная часть программы - вывод клеток. Для этого используется два вложенных цикла. В теле второго цикла сначала вычисляются координаты текущей клетки:

tileRect.top = OFFSET_Y+i*TILE_SIZE+i+1;
tileRect.left = OFFSET_X + j*TILE_SIZE+j+1;
tileRect.bottom = tileRect.top + TILE_SIZE;
tileRect.right = tileRect.left + TILE_SIZE;

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

Далее проверяется тип клетки. Рассмотрим только часть, когда выводится зелёная клетка:

if (map[j+i*NUM_ROWS] == 0)
{
  for (k = 0; k < TILE_SIZE;++k)
    memcpy(static_cast(lockedRect.pBits)+k*lockedRect.Pitch,
           reinterpret_cast(&tiles[1][k*TILE_SIZE]),
           TILE_SIZE*4);
}

За одну итерацию цикла выводится строка картинки.

Основной цикл

В функции WinMain до основного цикла вызывается функция InitGraphics и объявляется несколько переменных: координаты мыши и флаги нажатия левой/правой кнопок мыши.

В функции CreateWindow третий параметр (стиль окна) - WS_POPUP. Окна с таким стилем нельзя перемещать. Также нельзя менять их размер. Но мы выбрали этот стиль по другой причине - в нём отсутствует заголовок окна и рамка (именно поэтому мы рисовали её самостоятельно в DrawInterface).

Когда мы создаём окно размером 500 на 500 (или другое), то из-за заголовка и рамки в нашем распоряжении оказывается меньшая область. Т.е. заголовок и рамка входят в эти 500 пикселей. Кроме того, если присутствует заголовок, то при определённых событиях (events) в сообщении MSG могут находиться неверные координаты. Т.е. они верные, но при работе с ними нужно учитывать поправку на заголовок и рамку окна. Мы пойдём по пути наименьшего сопротивления и просто уберём заголовок. Но в этом случае нам нужно дать пользователям возможность закрывать окно. что мы и сделаем, создав кнопку closeButton.

В основном цикле происходит проверка ввода с мыши. Важное отличие программы Клетки от всех предыдущих программ - ввод с мышки осуществляется через сообщения WinAPI, а не через DirectInput:

if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
{
  if (msg.message == WM_QUIT)
    break;
  TranslateMessage(&msg);
  DispatchMessage(&msg);
  lmb = 0; rmb = 0;
  if (msg.message == WM_MOUSEMOVE)
  {
    x = LOWORD(msg.lParam);
    y = HIWORD(msg.lParam);
  }
  if (msg.wParam & MK_LBUTTON || msg.message == WM_LBUTTONDOWN)
    lmb = 1;
  if (msg.wParam & MK_RBUTTON || msg.message == WM_RBUTTONDOWN)
    rmb = 1;
}

Сообщения обрабатываются не в оконной процедуре, а здесь же - в основном цикле.

Нам нужно перехватывать три сообщения: WM_MOUSEMOVE, которое возникает при движении мыши; WM_LBUTTONDOWN и WM_RBUTTONDOWN, возникающие при нажатии левой/правой кнопок мыши.

Когда происходит движение мыши, мы снимаем координаты, которые хранятся в msg.lParam. LOWORD и HIWORD мы уже где-то обсуждали.

Далее проверяется нажатие кнопок мыши - должно выполняться одно из условий: или в поле wParam активна одна из кнопок мыши (в данном поле хранятся нажатые кнопки мыши для сообщения WM_MOUSEMOVE), или было послано сообщение WM_LBUTTONDOWN. В зависимости от нажатия кнопок мыши, переменным lmb (от left mouse button - левая кнопка мыши) или rmb (от right mouse button) присваивается единица.

Использование сообщения WM_MOUSEMOVE позволяет окрашивать тайлы при движении мыши.

После того как был получен ввод, проверяется нажатие кнопки закрытия:

if (x > closeRect.left && x < closeRect.right &&
    y > closeRect.top && y < closeRect.bottom && lmb==1)
{
  PostMessage(hWnd,WM_CLOSE,0,0);
}

Чтобы выполнилось тело ветвления, нужно чтобы координаты мыши находились внутри прямоугольника: курсор должен находиться правее левого края прямоугольника closeRect, левее правого края, ниже верхнего и выше нижнего. Кроме этого должна быть нажата левая кнопка мыши.

Вот этот код условия, он очень важен! Запомните его. Данный код позволяет проверить нахождение какой-либо точки внутри заданного прямоугольника. Для этого координаты точки сравниваются с границами прямоугольника. Функция PostMessage отправляет сообщение в очередь сообщений программы. Первый аргумент - описатель окна, второй - сообщение, третий - wParam, четвёртый - lParam. В данном случае, если была нажата кнопка закрытия, то в очередь сообщений посылается WM_CLOSE.

Далее проверяется нажатие кнопки мыши на каком-либо тайле. Для этого проверяются все тайлы (смотрите полный код):

top = OFFSET_Y+i*TILE_SIZE+i+1;
left = OFFSET_X + j*TILE_SIZE+j+1;
bottom = top + TILE_SIZE;
right = left + TILE_SIZE;
if (lmb == 1)
  if (y >= top && y <= bottom &&
      x >= left && x <= right)
  {
    map[j+i*NUM_ROWS] = 1;
  }

Первые четыре строки мы уже видели в функции DrawGraphics. Только там заполнялись не отдельные переменные, а поля структурной переменной tileRect. Здесь тоже можно было бы использовать tileRect, но без неё условия можно записать короче.

Далее проверяется нажатие левой кнопки мыши. В данном случае проверка осуществляется в отдельном ветвлении. Можно (и нужно) было бы воспользоваться операцией &&, как для проверки кнопки закрытия. Просто в программе Клетки я хотел показать как можно больше примеров кода.

Теперь просмотрите полный код. Программа получилась довольно простой. Самое главное разобраться со смещениями адресов в аргументах функции memcpy. Ну а теперь упражнения!

Версия 0.2 и упражнения

1. Начнём с поиска ошибки в программе. :) Простой способ найти ошибку: сделать NUM_ROWS на единицу меньше NUM_COLUMNS и в запущенной программе окрасить тайлы в порядке их вывода.

2. Всё-таки удалите часть кода программы и напишите самостоятельно код вывода: рамки, сетки и плиток.

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

4. Код второй версии мы разбирать не будем. Вы должны сделать её самостоятельно. Я лишь остановлюсь на некоторых моментах. Сама по себе программа не сложная. Если постараетесь, управитесь меньше чем за два часа. Картинки к программе находятся в одном архиве с исполняемым файлом.

В программе я использовал массивы с заранее заданным размером (как в версии 0.1 массив tiles). Это позволяет не выделять память на ходу. Но для этого нужно заранее знать размеры всех графических элементов:

Интерфейс - 500*500.
Кнопка меню - 44*23.
Меню - 302*202.
Две кнопки в левом верхнем углу - 25*24.

Обратите внимание, что в некоторых кнопках на уголках - белый цвет. При копировании изображения в фоновый буфер, нужно провять цвет пикселя, например, так: menu[j+i*202] != 0x00ffffff Так как необходимо проверять каждый пиксель, то и копировать изображение придётся по пикселям, а не по строкам.

Дополнительные цвета:

const int GREEN = 0x00477f34; // зелёный
const int GRAY = 0x00afafaf;  // серый
const int BLUE = 0x0000c6ff;  // голубой

В программе я использовался простой конечный автомат с двумя состояниями: RUN (ээхгм... можно перевести как в процессе) и MENU. Также для большего удобства было написано несколько дополнительных функций.

По поводу сетки. В версии 0.2 между клетками нет расстояния (в 0.1 - один пиксель). При выводе сетки нужно это учитывать.

Для контроля отрисовки сетки и текстур я использовал два флага. На выборе этих элементов стоит остановиться подробнее. Каждая из кнопок в левом верхнем углу может включать и выключать свой режим. Поэтому нужно предусмотреть случай, когда пользователь медленно нажимает на кнопку. В таком случае кнопка может изменить свой режим несколько раз. В своей версии я ограничил повторное нажатие кнопки 300 миллисекундами. Т.е. две кнопки в левом верхнем углу можно нажимать не чаще 0,3 секунды. Если пользователь нажимает на них чаще (или нажимает кнопку мыши и держит), то ничего не будет происходить в течение 0,3 после смены режима. Я реализовал подобную функциональность выделив каждой кнопке свой таймер.

Для вывода сетки была создана отдельная функция. Так легче включать/выключать сетку.

И последнее. Благодаря одному из читателей была выявлена одна проблема. При работе с двухмерной графикой не выводится текст. Решение: вызывать метод DrawText между вызовами BeginScene и EndScene.

Заключение

Самое важное в сегодняшнем выпуске:
1. Умение работать со смещениями адресов для вычисления точной позиции пикселя в окне.
2. Столкновение прямоугольника и точки. Это добавило в программу возможность выбора отдельных тайлов (в нашем случае они перекрашивались в другой цвет) и пунктов меню.

Ещё хотелось бы отметить количество fps. По сравнению с прошлым выпуском, падение более чем в десять раз: было 7000, стало 300. Дело в том, что операции замыкания/размыкания фонового буфера очень длительные. В будущем мы откажемся от прямого доступа к фоновому буфера. А пока что будем пользоваться этим способом, так как он самый простой.

На сегодня всё. До скорой встречи.