Создание движка для игры "Варяг" - часть 1.
главная страница статьи файлы о сайте ссылки
Создание движка для игры "Варяг" - часть 1.

Георгий Мошкин
tmtlib@narod.ru

Часть 1 - Часть 2 - Часть 3 - Часть 4 - Часть 5 - Часть 6 - Часть 7

РАССА (Viverricula indica) или малая цивета, для получения мускуса интродуцирована на острова Сокотра, Филиппины, Мадагаскар. Ее естественный ареал приурочен к Юго-Восточной Азии от Индии и Южного Китая до Цейлона и Малайского архипелага. Этот нарядно окрашенный зверек мельче предыдущих видов (масса —2—4 кг), лишен стоячей гривы, уши у него взаимно сближены. Расса настолько наземный вид, что, даже спасаясь от преследования, предпочитает скрыться в зарослях, чем забраться на дерево. Питается она разнообразной животной и растительной пищей, включая падаль. Часто уничтожает больных животных. Размножается круглый год, принося по 2—5 детенышей.
Источник: http://www.floranimal.ru/pages/animal/r/102.html

Как-то раз мне пришло письмо от Димы с предложением сделать игру про древнюю Русь. В своём письме он предлагал мне посетить его сайт http://varyag.h12.ru/ и ознакомиться с готовыми спрайтами для игры. По словам Димы проект был давно заброшен, а графика осталась не востребована. Поэтому идея сотрудничества заключалась в том, чтобы я спрограммировал какой-нибудь движок на основе предложенных спрайтов.

Я решил сделать движок с открытым исходным кодом (open source), а для разработки игры использовать язык программирования Delphi. В качестве графического API будет OpenGL. В этой статье я буду последовательно описывать процесс разработки движка для игры "Варяг" - стратегии в реальном времени (RTS). Также будут даны некоторые пояснения для новичков.

Итак, что же будет представлять собой игра "Варяг"? Мне она видится типичной RTS наподобие WarCraft 2. Как известно, процесс создания стратегических игр, да и любых игр вообще достаточно прост. Главное - это не ничего не усложнять. Для начала посмотрим что за спрайты мне прислал Дима по электронной почте.

К письму был прикреплён winrar-овский архив sp.rar, в котором было несколько спрайтов в формате PNG. Просмотр с помощью программы IrfanView показал, что там содержатся следующие спрайты: курсор мыши в виде указывающей руки (цветной и серый), кадры анимации трёх лучников (один одет в стиле "Иван", второй с накинутым балахоном и ещё какой-то), спрайты летящей стрелы, бревенчатый домик, вышка на деревянных ножках, шатёр, ферма с крышей из сена, некоторые спрайты для меню управления юнитами в стиле WarCraft, кадры анимации юнита с мечом и щитом, кадры анимации крестьянина с мешком золота на спине, кадры анимации крестьянина с топориком, деревянная постройка для кузнеца с наковальней и двумя колёсами от повозки при входе, спрайты травы, деревьев и торчащих пней, деревянная вышка из брёвен и большой дом из брёвен. Здесь можно сказать только одно: Дима проделал большую работу над созданием этих спрайтов, поэтому в основном остаётся сделать только игровой движок.

Как на основе этих спрайтов сделать движок для стратегической игры? Всё очень просто. В своих многочисленных статьях и заметках я уже писал о последовательности создании любых компьютерных игр. Вспомните мою статью "Как сделать 2d игру": самым первым делом нужно научиться выводить точку на экран.

Для вывода точки на экран нам понадобится какая-то "основа". Эту основу часто наывают framework (фреймворк), или просто "заготовка". По сути дела эта заготовка представляет собой исходный код, который после компиляции открывает чёрное окно OpenGL. Как только у нас появится такая "основа", то мы сможем на это чёрное окно вывести белую точку.

Для простоты за основу возмём исходники framework.zip (9kb). В этом файле я поместил заготовки исходников для написания игры. Если вы внимательно посмотрите эти исходники, то найдёте в файле unit1.pas процедуру glDraw(). В эту процедуру мы будем добавлять команды для вывода точки на экран. Посмотрим внимательно на эту процедуру:

procedure TForm1.glDraw(); // главная процедура рисования в OPENGL окне

begin

glClear(GL_COLOR_BUFFER_BIT or GL_DEPTH_BUFFER_BIT); // очищаем экран
glLoadIdentity();
// заполняем матрицу единичной диагональной

// здесь добавим команды для вывода на экран

SwapBuffers(DC);

end;

Эта процедура будет периодически запускаться во время работы программы. Каждый раз при запуске этой процедуры стирается картинка и z-buffer, находящиеся в заднем буфере экрана (процедура glClear), обнуляются произведённые ранее повороты и перемещения (процедура glLoadIdentity), происходит отрисовка с цены (между glLoadIdentity и SwapBuffers), передний и задний буфер меняются местами (SwapBuffers). Такая схема с применением переднего и заднего буферов необходима для предотвращения мерцания при перерисовке кадров и носит название "Double Buffering". Смысл этой схемы прост: перерисовка идёт в невидимом (заднем) буфере, а на экран отображается только передний буфер. Когда задний буфер готов к отображению, он замещает передний.

Вернёмся к проблеме вывода точки на экран. Наш фреймворк (framework) для работы с OpenGL инициализирует 2D режим. Для этого используется несколько OpenGL-евских процедур вместе с glOrtho, которые вызываются из процедуры FormResize:

procedure TForm1.FormResize(Sender: TObject);

begin

glViewport(0, 0, Panel3.Width, Panel3.Height);

// 2D режим

glMatrixMode(GL_PROJECTION);
glPushMatrix();

glLoadIdentity();

glOrtho(0, Panel3.Width, 0, Panel3.Height , -100, 100);

glMatrixMode(GL_MODELVIEW);
glPushMatrix();

glLoadIdentity();


glDraw();

end;

Данная процедура срабатывает при создании OpenGL окна и при изменении его размеров. Для вывода графики используется поверхность Panel3, поэтому устанавливается размер ViewPort-а, равный по ширине (Panel3.Width) и высоте (Panel3. Height) этой поверхности. Далее устанавливается режим ортогонального проецирования с помощью процедуры glOrtho.

Здесь не нужно особо во что-то вникать. Сейчас главное заключается в следующем: у нас есть готовый фреймворк для вывода 2D графики на экран компьютера с помощью OpenGL. Также мы знаем кудадобавлять OpenGL-евские команды для того, чтобы на экране что-то появилось.

Попробуем запустить наш фреймворк. Для этого откроем файл varyag.dpr из Delphi 6 и нажмём кнопку F9. После запуска вы увидите чёрное окно программы, на которое ничего не выводится. Окно программы состоит из трёх панелей: панель 1 - крупный заколовок окна, панель 2 - прямоугольное поле с левой стороны, панель 3 - чёрный прямоугольник для вывода графики с помощью OpenGL.

Середина поверхности имеет координату x=Panel3.Width/2 и y=Panel3.Height/2. Для вывода точки на экран в этой координате воспользуемся процедурой glVertex2f:

procedure TForm1.glDraw(); // главная процедура рисования в OPENGL окне

begin

glClear(GL_COLOR_BUFFER_BIT or GL_DEPTH_BUFFER_BIT); // очищаем экран
glLoadIdentity();
// заполняем матрицу единичной диагональной

glBegin(GL_POINTS);
glVertex2f( Panel3.Width / 2 , Panel3.Height / 2 );
glEnd;

SwapBuffers(DC);

end;

Любые вызовы процедур glVertex2f должны находится между glBegin и glEnd. В скобках у процедуры glBegin указывается тип примитива. Основные типы примитивов следующие:

  • GL_POINTS - точки
  • GL_LINES - линии
  • GL_TRIANGLES - треугольники
  • GL_QUADS - четырёхугольники

Есть и другие типы примитивов, подробнее о которых вы можете узнать из других источников (книги и интернет сайты по тематике OpenGL).

На основе типа примитива, который мы укажем процедуре glBegin, OpenGL будет соответствующим образом интерпретировать данные, поступающие от glVertex2f. Например, чтобы нарисовать треугольник, достаточно написать следующий код:

glBegin(GL_TRIANGLES);
glVertex2f(5,5);
glVertex2f(100,5);
glVertex2f(100,100);
glEnd;

Заменив константу GL_TRIANGLES на GL_POINTS, вместо треугольника увидим три вершины:

glBegin(GL_POINTS);
glVertex2f(5,5);
glVertex2f(100,5);
glVertex2f(100,100);
glEnd;

Данные, передаваемые с помощью процедуры glVertex2f, в обоих случаях были одинаковыми. Стоит отметить, что нарисовать незакрашенный треугольник по трём точкам с помощью константы GL_LINES не удастся. Мы увидим только отрезок с вершинами (5,5) и (100,5). Это связано с тем, что в блоке GL_LINES количество точек должно быть чётным. Поэтому код для рисования незакрашенного треугольника должен состоять из шести операторов glVertex2f (по два на каждую сторону треугольника). Код для вывода незакрашенного треугольника на экран будет выглядеть следующим образом:

glBegin(GL_LINES);

glVertex2f(5,5);
// первый отрезок
glVertex2f(100,5);

glVertex2f(100,5);
// второй отрезок
glVertex2f(100,100);

glVertex2f(100,100);
// третий отрезок
glVertex2f(5,5);

glEnd;

Таким образом, для GL_POINTS нужна минимум одна точка, для GL_LINES - минимум две точки, для GL_TRIANGLES - минимум три точки, а для GL_QUADS - минимум четыре точки. Если вы выводите пять треугольников в блоке glBegin(GL_TRIANGLES), то это будет 3*5=15 точек. Если 100 четырёхугольников, то glVertex2f будет вызван из блока glBegin(GL_QUADS) 400 раз (100*4=400 точек).

Эти пояснения даны для тех, кто мало знаком с OpenGL. Особо долго вникать в это не следует. Стоит лишь отметить три вещи:

  1. Координаты вершин передаются в OpenGL с помощью процедуры glVertex2f.
  2. Тип примитива указывается в скобках после процедуры glBegin
  3. Процедуры glVertex2f всегда должны находиться между glBegin() и glEnd.

Посмотрим повнимательнее на спрайты, с которыми нам предстоит работать. Ниже показана часть содержимого файла "peasant with gold.png". В одном файле находятся сразу несколько спрайтов с разными кадрами и углом поворота юнита. Размеры файла по ширине и высоте составляют 360 на 936. При этом в строке 5 спрайтов, а всего строк 13. Отсюда следует, что размер спрайта по ширине составляет 360 / 5 = 72 пикселя. По высоте тоже 72 пикселя (936 / 13 = 72). Итак, спрайты живых юнитов имеют размер 72 x 72.

Стоит отметить, что в таком виде загружать текстуру в OpenGL нельзя. Дело в том, что OpenGL поддерживает только текстуры, у которых длина и ширина получаются возведением числа 2 в некоторую степень (0, 1, 2 и т.д.). Отсюда следует, что текстуры могут иметь размеры 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024 и т.п..

То есть можно загружать 1x1024, 16x16, 512x256 и т.п.. Но при загрузке 50x50 или 936x936 могут возникнуть проблемы, так как эти числа не являются числом 2, возведённым в целую степень. Программа при этом может работать с ошибками.

Поэтому мы дополним текстуру пустыми белыми областями справа и снизу так, чтобы её размеры увеличились от 360 x 936 до 512 x 1024. Обратите внимание, что я не делаю так называемый resize (ресайзинг), а лишь увеличиваю пустое место. Весь процесс можно представить следующим образом: на пустую картинку размером 512 x 1024 я помещаю картинку размером 360 x 936. При этом размещаю её таким образом, чтобы у этих картинок совпал левый верхний угол. Результат будем записывать в формат BMP, так как для него легко найти хороший загрузчик текстур в OpenGL.

Посмотрим один из способов проведения этой манипуляции. Мы можем взять бесплатный графический редактор i.Mage (http://www.memecode.com/image.php), загрузить файл "peasant with gold.png" и выполнить последовательно следующие действия:

  • File -> New/Properties -> 24bit -> Ok
  • File -> New/Properties -> Убрать галочку "Maintain aspect ratio" -> Выбрать Crop Image ->
    -> Ввести X: 512, Y: 1024 -> Ok
  • File -> Save As -> Ввести имя "peasant with gold.bmp" -> Нажать "Сохранить"

Теперь вместо файла "peasant with gold.png" с размерами 360 x 936 мы получили файл "peasant with gold.bmp" с размерами 512 x 1024. Дополнительно к этому мы изменили цветовое разрешение картинки с 8bit до 24bit. Справа показано меню "New/Properties" программы i.Mage.

В файле "summer2.png" находятся травка и всякие деревья, а также пенёчки от срубленных крестьянами деревьев. Детальный осмотр этих каряг показал, что размеры картинки по ширине составляют 512 пикселей, а по высоте - 160 пикселей. Размеры тайла (tile) составляют 32 x 32 пикселя. В одной строке помещается 16 тайлов (512 / 32 = 16). Всего 5 строк (160 / 32 = 5).

С этим файлом мы поступаем аналогичным образом, подбирая ближайшие большие значения из ряда чисел 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, ... По ширине изменений не требуется, так как 512 - это число из ряда, совместимого с OpenGL. А вот по высоте необходимо добавить пустого места. 128<160, поэтому выбираем следующее значение - 256. Таким образом, файл "summer2.png" из 512 x 160 переделываем в 512 x 256 и записываем в 24-битный "summer2.bmp". Аналогичные манипуляции проделываем со всеми графическими файлами. В итоге все файлы у нас готовы к загрузке в любой OpenGL программе.

Небольшое отступление. Вообще необходимость в данных манипуляциях связана лишь с тем, что мы делаем игру уже на основе готовых спрайтов. А у готовых спрайтов есть некоторые особенности:

  • спрайты нескольких кадров одного юнита записаны в один графический файл
  • графические файлы имеют размеры по длине и ширине, не подходящие для загрузки в OpenGL

То есть требования к движку предъявлены исходя из особенностей спрайтов (сначала сделали спрайты, а потом движок). Обычно всё наоборот: у движка есть свои особенности, и требования предъявляются к спрайтам (сначала сделали движок, а потом спрайты).

Вернёмся к разработке движка для RTS "Варяг". Напомню вам о том, что было сделано выше. Во-первых, мы взяли framework для работы с OpenGL и определили, как вывести точку на экран. Во-вторых, привели графические файлы со спрайтами к такому виду, что при их загрузке и использовании не возникнет ошибок OpenGL.

Для загрузки графических файлов BMP в OpenGL лучше всего воспользоваться готовой библиотекой (что-нибудь простое и удобное). Например, библиотеку Textures.pas или BMP.pas, которая используется в демках на сайте http://www.sulaco.co.za/. Зайдём на этот сайт и скачаем какую-нибудь OpenGL демку с исходниками. Например, bouncingball.zip - "Bouncing Ball". Распаковав этот архив мы найдём исходники демки с прыгающим шариком. Среди файлов с исходниками есть нужная нам библиотека BMP.pas для загрузки 24-битных файлов в формате BMP. Для начала это самый лучший выбор, так как у этой библиотеки простые исходники и вы без труда разберётесь в том, как загружать текстуры в OpenGL. В комментариях к исходникам библиотеки дан пример использования. Все текстуры в OpenGL имеют идентификатор типа GLUINT. Поэтому загрузка текстуры будет выглядеть примерно так:

uses BMP;
var MoyaTextura: GLUINT;
begin
LoadTexture('girl.bmp', MoyaTextura);
end;

Как видите, загрузка текстур в OpenGL с помощью библиотеки BMP.pas очень проста: мы добавили BMP в раздел uses, задали переменную для хранения идентификатора текстуры MoyaTextura типа GLUINT. И вызвали процедуру загрузки текстуры LoadTexture.

Стоит отметить, что данная библиотека может загружать только 24-битные BMP файлы, поэтому выше мы сконвертировали 8-битные PNG файлы в 24-битные BMP с помощью программы i.Mage. Также мы изменили размер текстур таким образом, чтобы их размер по ширине и высоте был из ряда 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024 и т.д. Наши текстуры могут иметь размеры 1x1, 2x2, 4x4, 8x8, 16x16, 32x32 и т.д.. Но ширина не обязательно равна высоте. Мы можем загружать BMP файлы с размерами 1x512, 1024x8, 16x32 и т.п. Главное - это чтобы числа были из написанного выше ряда. А как вы помните, этот ряд образуется возведением числа 2 в степень 0, 1, 2,3, 4, 5 и т.д.

Итак, мы подготовили всё необходимое для загрузки текстур: у нас есть framework для инициализации OpenGL окна, есть библиотека BMP.pas для загрузки 24-битных BMP файлов и текстуры в формате BMP. Приступим к написанию кода для загрузки текстур. Создадим главный модуль rtsmain.pas и добавим его в USES к нашего framework. В Delphi нажимаем File -> New -> Unit. Затем сохраняем под именем rtsmain.pas. Для этого нажимаем File -> Save As... -> вводим rtsmain.pas -> Сохранить. Содержимое нового модуля будет выглядеть следующим образом:

unit rtsmain;

interface

implementation

end.

Затем выбираем для отображения модуль Unit1 (unit1.pas) и вписываем в раздел USES имя rtsmain. Чтобы проверить, не допустили ли мы при этом синтаксических ошибок, нажмём клавишу F9. Если всё было проделано правильно, то появится окно запущенного OpenGL framework, на котором видна чёрная панель. На чёрной панели будут отображены примитивы (точки, треугольники и т.п.), для которых вы добавляли OpenGL команды в процедуру TForm1.glDraw(). Если вы не добавляли команды к содержимому процедуры glDraw, то на панели не будет никаких изображений.

Продолжим написание кода для загрузки текстур. Перейдём в окно модуля rtsmain и добавим в раздел USES имя BMP. Как только мы добавим это имя в раздел USES модуля rtsmain, у нас появится возможность использовать процедуру LoadTexture. Модуль rtsmain будет выглядеть так:

unit rtsmain;

interface

uses BMP;

implementation

end.

Также добавим в раздел uses модуля rtsmain имя "opengl":

unit rtsmain;

interface

uses BMP, opengl;

implementation

end.

Прежде чем продолжить написание загрузки текстур остановимся немного подробнее на принципе работы нашего framework, да и вообще любых программ для Windows. В статье "Всё о создании компьютерных игр" я уже рассматривал основную идею многозадачности в операционной системе Microsoft Windows. Там была дана упрощенная модель для быстрого понимания.

Если вы внимательно посмотрите на форму нашей программы, то заметите маленький квадратик с изображением часов. Это таймер, который периодически вызывает процедуру TimerDraw. Вы можете в этом убедиться самостоятельно, если посмотрите свойства таймера. Для этого сделайте один клик на картинке таймера и с помощью клавиши F11 выйдете на окно "Object Inspector". Далее выберете вкладку "Events". Как видите, у таймера Timer1 есть только одно событие, которое имеет название OnTimer. Данное событие вызывается каждые N миллисекунд. Интервал N можно установить на вкладке properties в окне "Object Inspector" (Timer1 -> Object Inspector -> Interval -> ...). Событию OnTimer соответствует вызов процедуры TimerDraw. Вот как выглядит эта процедура:

procedure TForm1.TimerDraw(Sender: TObject);
begin
glDraw();
end;

Эта процедура вызывается по событию OnTimer каждые N миллисекунд. Из исходного кода процедуры TimerDraw, приведённого выше, видно, что каждые N миллисекунд вызывается процедура TimerDraw, из которой запускается процедура glDraw.

Таким образом, каждые N миллисекунд (Timer1 -> Object Inspector -> Properties -> Interval) вызывается главная процедура рисования в OpenGL окне. Этим обеспечивается работа "вечного" цикла (главного цикла) нашей программы. Только вместо операторов for или while используется событие OnTimer.

Поэтому нужно чётко понимать, что все внутренние переменные процедуры glDraw не сохраняют своё значение между кадрами. Например, такая запись будет неправильной:

procedure TForm1.glDraw(); // главная процедура рисования в OPENGL окне

var abc : integer;

begin

glClear(GL_COLOR_BUFFER_BIT or GL_DEPTH_BUFFER_BIT); // очищаем экран
glLoadIdentity();
// заполняем матрицу единичной диагональной

// здесь добавим команды для вывода на экран

abc:=abc+1;

SwapBuffers(DC);

end;

Переменная abc не будет принимать значения 0,1,2,3,4,5... так как при достижении end значение abc теряется. Из-за этого abc будет принимать значения 1,1,1,1,1 и т.д.. Чтобы передавать значения между последовательными вызовами процедуры glDraw, необходимо выносить их за пределы данной процедуры. Правильная реализация передачи данных между кадрами будет выглядеть так:

var abc : integer;

procedure TForm1.glDraw(); // главная процедура рисования в OPENGL окне

begin

glClear(GL_COLOR_BUFFER_BIT or GL_DEPTH_BUFFER_BIT); // очищаем экран
glLoadIdentity();
// заполняем матрицу единичной диагональной

// здесь добавим команды для вывода на экран

abc:=abc+1;

SwapBuffers(DC);

end;

Здесь программа будет работать правильно, так как переменная abc стала "глобальной" и существует вне зависимости от процедуры glDraw. Поэтому при работе главного цикла программы значение abc не пропадает.

Если вы посмотрите на исходники OpenGL фреймворков без использования VCL (визуальных компонент), то в них явно можно найти главный цикл программы. Например, основа (фреймворк) демок на сайтах http://nehe.gamedev.net и http://www.sulaco.co.za/ имеет главный цикл, который задаётся явно с помщью while:

// Main message loop:
while not finished do
begin
if (PeekMessage(msg, 0, 0, 0, PM_REMOVE)) then
// Check if there is a message for this window
begin
if (msg.message = WM_QUIT) then
// If WM_QUIT message received then we are done
finished := True
else
begin
// Else translate and dispatch the message to this window
TranslateMessage(msg);
DispatchMessage(msg);
end;
end
else
begin

glDraw(); // Draw the scene
SwapBuffers(h_DC);
// Display the scene

if (keys[VK_ESCAPE]) then // If user pressed ESC then set finised TRUE
finished := True
else
ProcessKeys;
// Check for any other key Pressed
end;
end;
glKillWnd(FALSE);
Result := msg.wParam;
end;

Это главный цикл программы. В этом цикле осуществляется обработка сообщений, поступающих от Windows. Обратите внимание, что здесь процедура glDraw вызывается из цикла.

Если вы посмотрите исходники визуальных компонент, которые поставляются вместе с Delphi, то обнаружите подобный цикл. А так как мы используем фреймворк, основанный на VCL библиотеках (визуальные компоненты), то нам не нужно задавать этот цикл. Благодаря использованию готовых VCL компонент мы избавляемся от необходимости писать громоздкий код на WinAPI (Windows Advanced Programming Interface) по инициализации окна и обработке сообщений от Windows.

Более глубоко рассмотреть этот вопрос вы можете позже, а сейчас главное чётко понять что мы имеем. Во-первых, у нас есть фреймворк (framework), который позволяет использовать OpenGL. Чтобы выводить на экран точки, линии, треугольники и другие фигуры нам достаточно добавить соответствующие команды в процедуру glDraw нашего framework-а. Также у нас есть несколько текстур в формате BMP. Для загрузки текстур мы подключили модуль BMP.pas к нашему фреймворку. Для этого мы добавили его в раздел USES нашего фреймворка. Основной исходный код игры мы решили поместить в модуль rtsmain.pas.

Для загрузки текстуры дополним модуль rtsmain процедурой Zagruz, а для вывода на экран - процедурой Kvadrat. Пока что это будет предварительный вариант, чтобы посмотреть как всё работает:

unit rtsmain;

interface

uses BMP,OpenGL;

procedure Zagruz;
procedure Kvadrat;

var test:GLUINT;

implementation

procedure Zagruz;
begin
LoadTexture('peasant with gold.bmp',test);
end;

procedure Kvadrat;
begin
glBegin(GL_QUADS);
glTexCoord2f(0,0);glVertex2f(0,0);
glTexCoord2f(1,0);glVertex2f(71,0);
glTexCoord2f(1,1);glVertex2f(71,71);
glTexCoord2f(0,1);glVertex2f(0,71);
glEnd;
end;

end.

Файлы к статье: dev002src.rar - исходники (144kb), dev002exe.rar - исходники + exe (329kb).

Часть 1 - Часть 2 - Часть 3 - Часть 4 - Часть 5 - Часть 6 - Часть 7