Пишем 2D RPG roguelike на C++ и Ogre3D

24.07.2012

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

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

О журнале.

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

Десктопные игры и игры для мобильных устройств

Web игры

Многопользовательские игры

В этой статье дан обзор основным графическим технологиям, используемым при разработке десктопных, клиентских и приложений для мобильных устройств

Графические технологии

Введение

Цель данного туториала — провести Вас через весь путь создания небольшой, но полноценной РПГ игры. Мы будем делать ее с помощью С++ — самого распространенного языка игростроя и Ogre3D — самого распространенного бесплатного графического движка на С++. Основная цель туториала — показать весь путь разработки игры, описать основные этапы.

Что мы будем писать?

В этом туториале мы разберем небольшую, но полноценную РПГ игру.

Собранный вариант вы можете получить здесь.

bld.rar

Посмотрите, поиграйтесь — это то, что вы сможете делать, если запасетесь достаточным для этого туториала терпением;)

Почему мы не используем DirectX/OpenGL с самого начала туториала?

В качестве одной из задач туториала мы сделаем рендерер (вывод графики) независимым от приложения. В дальнейшем, мы покажем, как заменить рендерер Ogre3D на любой другой, в том числе DirectX/OpenGL.

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

Где задавать вопросы?

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

Где получить код проекта?

Код проекта для MSVS 10 доступен по этой ссылке

Код проекта для MSVS 10

Где получить дополнительную информацию?

Вы можете получить дополнительную информацию по предмету у нас в ресурсах

Как закрепить полученные знания?

Небольшие части туториала вы можете практиковать самостоятельно в небольших консольных проектах, однако, как ни странно, самый лучший способ научиться писать игры — это писать игры;) Для этого мы предусмотрели раздел Конкурсы. Здесь Вы можете попробовать свои знания на практике, получите так необходимые мотивацию, сроки и поддержку.

Раздел конкурсов

Предварительная подготовка

Что нужно знать, чтобы читать данный туториал?

Основным требованием является базовые навыки программирования с помощью С++ или любого другого объектно-ориентированного языка. Начать программировать не сложно — сложно научиться программировать хорошо;) Для того, чтобы освежить ваши знания в области С++ мы предлагаем наш цикл статей по С++. Если Вы знаете любой другой ООП язык — прочитайте — это не вызовет у Вас проблем — основные вещи очень схожи.

Этот курс не является полным — при написании данного курса мы пользовались эмпирическим правилом Паретто — 80 % задач можно решить с помощью 20 % C++ (ну или около того). В С++ есть возможности, которыми вы никогда не воспользуетесь;) Море дополнительной и подробной информации вы можете найти в дайджесте.

Если вы обладаете базовыми навыками программирования и названия глав и статей звучат знакомо и понятно — смело пропускайте. Вы учитесь не для того, чтобы учиться, а для того, чтобы научиться — каждому конкретному специалисту нужна конкретная информация, а не весь курс вцелом.

(!Курс в процессе подготовки!)

Помимо С++ нам понадобятся знания приемов объектно-ориентированного программирования, моделирования, проектирования и специфические для gamedev навыки. Эти вещи Вы будете получать по мере необходимости в ходе туториала и в виде ссылок на отдельные статьи. Эти знания применимы при программировании на любых других языках. В Java и С# применяются точно те же навыки и подходы. Это самые ценные знания.

Итак: дизайн -> проектирование -> имплементация (реализация задуманного в коде). В добрый путь!;)

Дизайн и анализ требований

С чего начинается игра, да и вообще, любое большое дело? С идеи. Говорят, что за дюжину идей дают 10 центов;) Это и так и не так. За свою карьеру в индустрии автор сталкивался с неисчислимым количеством индивидуальных разработчиков да и коллективов, которые делали «убийцу N» или следующее поколение N (где N зависит от фантазии). Т.к. технические и финансовые возможности не соответствовали задумке, эти люди оставались ни с чем.

Три основных правила.

Итак — первое правило — идея должна быть реализуема вами здесь и сейчас. Это не значит, что вы всю жизнь будете писать арканоиды, просто первый шаг — это, скорее, арканоид, чем мморпг — Ваши возможности будут прогрессировать.

Идею можно забыть, потерять не до конца продумать и т. п. Второе правило — идея должна быть формализована в виде дизайн документа на момент старта разработки. Профессионалы нередко дорабатывают диздок в ходе разработки, но это профессионалы. Эти люди делают не первый проект и хорошо разбираются в подводных камнях.

Ну и третье правило заключается в том, что с одной стороны вы должны продумать идею достаточно тщательно, а с другой, не запутаться в деталях. Важна золотая середина. Идите от большего к меньшему. Применяйте правило «разделяй и властвуй». Пример — я хочу написать игру про червячков. Червячки стреляют друг в друга из различных пушек. Пушек много: есть автомат, гранатомет, святая граната и т.п.

Причем, Вы не должны углубляться в разделы, до тех пор, пока не охватите проект целиком. Как только вы рассмотрели проект на определенном уровне — переходите поэтапно глубже. Процесс формирования идеи — итеративный — т.е. вы работаете над диздоком проход за проходом, каждый раз уточняя детали и убирая противоречия.

Стори лайн.

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

Жанр.

Жанр мы выбрали один из наиболее старинных и опробованных – roguelike. Roguelike – это поджанр rpg, который вырос из настольных игр. В качестве наиболее общих характеристик можно выделить: перманентная смерть игрока, рандомная генерация карт. В этом жанре Вы видите игровое поле сверху, игрок перемещается по нему пошагово. После хода героя перемещаются/атакуют враги/npc.

Наиболее популярными по версии RogueBasin можно считать NetHack, ToME и Dungeon Crawl.

Помимо прочего этот жанр довольно прост в реализации и крайне интересен. Существуют настоящие шедевры жанра, поддерживаемые и развиваемые open source сообществом. По объему контента (скилы, итемы, объем географии мира и т.п.), замысловатости сторилайна в эти игры бьют любые ААА проекты.

Требования.

В качестве платформы мы выберем Windows, однако, построим приложение таким образом, чтобы обеспечить возможность работы и в Linux с минимальными изменениями в коде. Игра должна быть легковесная, простая. Это однопользовательское, десктопное приложение. Это значит, что пользователи будут играть оффлайн со своих персональных компьютеров.

Язык исполнения — С++ — ввиду его максимальной распространенности. В дальнейших туториалах мы покажем как писать игры на других языках. Знания вам понадобятся те же.

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

Помимо этого в следующем туториале мы продемонстрируем Вам как легко, заменив рендерер на другой, вы сделаете полностью новую игру, даже не притронувшись к игровому коду — это интересно;)

В качестве первичной платформы, на которой мы построим рендерер — мы выберем Ogre3D — это наиболее качественный и поддерживаемый бесплатный графический движек с большим и активным комьюнити.

В дальнейших туториалах мы покажем, как написать свой собственный рендерер с помощью графических api — OpenGL и DirectX.

Дизайн.

До сих пор мы обсуждали игру в общем, пришло время поговорить более детально. Наши основные действующие лица: Warrior, Wizard. Приспешники Wizard’a: Imp, Skeleton, Troll. Итемы: Sword, Shield, Cuirass.

Противники по поведению делятся на два типа — стрелки и бойцы. Механика поведения бойцов — сближаться и атаковать, стрелков — стрелать в зоне видимости. Imp, Troll — бойцы, Wizard, Skeleton — стрелки.

Rpg система нашей игры крайне простая: урон = атака / защита, за каждого монстра игрок получает опыт, как только опыт игрока доходит до определенного состояния его уровень увеличивается. С каждым новым уровнем параметры игрока растут.

Каждый итем обладает двумя параметрами — сколько он добавляет к атаке, и сколько к защите. Для меча добавка к защите = 0, для щита и кирассы — добавка к атаке = 0.

Карта игры состоит из тайлов, размер карты 25 * 25. На карте присутствуют: горы, деревья, кустарники. Проходимыми считаются клетки без гор и деревьев. Для этого мы будем хранить в карте два слоя — первый слой — это земля и горы, второй — растительность. Т.е. фактически у нас будут два массива 25 * 25.

Мы можем перемещаться по карте пошагово. Мы делаем шаг, враги за нами. Атакуем врага, пытаясь переместиться на занятую им клетку.

Условия победы — все враги повержены, поражения — главный герой погиб. Все довольно просто.

Проектирование

Предварительная подготовка.

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

Для того, чтобы проектировать, нужен опыт, знания и язык для формализации проекта на бумаге.

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

Категория ООП на нашем ресурсе

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

Раздел, посвященный UML

Модули игры.

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

Среднестатистическое десктопное игровое приложение содержит:

  • Класс приложения — обычно имеет статический метод, который вызывается непосредственно в main(), создает и запускает объект приложения. У этого класса две главные функции — хэндлинг ввода из среды (Windows/Linux) и хранение всех остальных модулей. Это очень удобно — удаляется приложение — удаляются модули, освобождаются оставшиеся ресурсы. Хэндлинг ввода обычно идет по событийной модели — перехватываются сообщения нажатий кнопок, работы другого ввода (мышек, джойстиков и т.п.). Далее вызов уходит в модуль ввода (если требуется, например, маппинг ввода или буферизация — об этом поговорим позже) или в более простом случае напрямую в механику. В данном туториале мы так и поступим.
  • Модуль графики — этот модуль отвечает за вывод графики на экран. Обычно, внутри этот модуль опирается на графический движек или api. Хотя на самом деле выводом графики может заниматься что угодно — хоть консоль. Если модуль правильно абстрагирован (отделен интерфейсом и т.п.), подмена модуля труда не составит.
  • Модуль физики — в зависимости от конкретного приложения данный модуль может существовать отдельно или же быть частью модуля игровой механики (более простой случай). Модуль физики считает общие для многих игровых объектов правила — например, ньютоновскую динамику, collision detection (обнаружение столкновений, например со стенами уровня), положение на heightmap (карте высот ландшафта), ray tracing (пересечение луча с мешем, например для проверки видимости) и т.п.
  • Модуль звука — воспроизводит и микширует звуки и музыку в приложении — может быть простым, а может и очень сложным, учитывающим физическую составляющую уровня и т.п. Как пример — Half-Life 2.
  • Ну и наконец модуль игровой механики. Этот модуль несет в себе игровые сущности, их менеджеры, привила игры, условия победы/поражения и т.п. — все то, что и является игрой.

В идеале, между модулями должно быть минимум связей. Они должны быть отделены друг от друга сплошной стеной интерфейсов и типов. Любой из модулей должен быть заменяем и т.п. Условно говоря, если вы «выдерете» игровую механику из приложения и запустите ее в другом проекте, скажем, в консоле, все должно работать как ни в чем не бывало. Некоторые вещи не будут работать по определению (как минимум у вас не будет ввода и физики), но это не должно вызывать ошибок и исключений (exception) ни на этапе сборки проекта ни в рантайм.

Взгляд с высоты птичьего полета.

Итак, вернемся от общего к частному — что будет в нашей игре? Принятые нами решения на этапе дизайна вводят нас в определенные рамки — рендерер должен быть независимым. Опять же в нашем отправном случае мы начинаем работы на готовом движке — Ogre3D. Ogre3D предлагает готовый класс приложения с уже существующим вводом — почему бы не воспользоваться оказией?;) Мы унаследуем свой класс приложения от приложения движка и перегрузим нужные нам методы. Как это сдалать и какие именно методы надо перегрузить мы покажем на этапе имплементации.

Наше приложение будет содержать в себе объект рендерера, и игровую механику. Игровая механика содержит ссылку на рендерер для того, чтобы им управлять.

Отношения игровой механики и рендерера представляют из себя ничто иное, как паттерн Observer. Игровая механика «сообщает» рендереру, что ему необходимо что либо обновить и передает интерфейс, через который рендерер может запросить необходимые для обновления данные. Об этом паттерне и его применении в игрострое Вы можете почитать в наших статьях

ООП на сайте

Схема проста и надежна, как автомат Калашникова. Помимо прочего, схема позволяет держать каждый модуль независимым — в дальнейших туториалах мы подменим рендер на другой.

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

Рендерер.

Для того чтобы отделить рендерер от остального приложения мы сделаем две вещи. Первое — мы не дадим рендереру никаких ссылок на игровую механику — рендерер не сможет вызывать ее методы. И второе — вызовы к рендереру будут осуществляться через интерфейс Renderer.

Этот интерфейс будет обладать вызовами, необходимыми игровой механике для отрисовки себя. Игровая механика «требует» от рендерера определенного интерфейса.

Рендереру для обновлений потребуются определенные данные от игровой механики — например, название и позиция спрайта, где какой тайл расположен и т.п. Эти данные будут предоставлены рендереру через два дополнительных интерфейса — Sprite и Field. Sprite представляет любую двухмерную картинку, а Field — игровое поле.

По-сути, нам безразлично, как конкретная реализация рендера (тот класс, который унаследует интерфейс Renderer) справится со своей задачей — все, что нам нужно знать, что как-то справится;)

Мы поговорим об этих интерфейсах и их реализации более подробно на этапе имплементации.

Выделение игровых сущностей.

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

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

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

GameManager — менеджер объектов. Содержит и управляет игровыми объектами. Обрабатывает ввод. Проверяет условия победы/поражения. Управляет рендером.

Warrior — главный герой. Атакует и принимает урон. Меняет позицию. Улучшается поднятыми итемами. Получает опыт и прогрессирует по уровням.

Wizard, Troll, Imp, Skeleton — наши противники. Атакуют и принимают урон. Меняют позицию. Делают ход после ГГ на основе своей модели поведения (стрелок/боец).

Sword, Shield, Cuirass — игровые итемы. Лежат на карте. Содержат параметры, которые при поднятии добавятся ГГ.

GameField — игровое поле. Содержит информацию о тайлах (клетках) и их проходимости.

Построение модели игры.

У нас есть как минимум две группы классов, которые содержат много общего — а это повод задуматься о наследовании. Наследование — очень мощный механизм, а, как известно, ответственность пропорциональна возможностям. Существует много способов унаследовать функционал одного объекта от другого. Об этом Вы можете почитать в соответствующем разделе нашего сайта

Раздел ООП статей.

Мы, как и ранее, выберем самую прямую дорогу — публичное наследование классов. Первая группа, которую мы рассмотри — это группа существ — Critters.

Очевидно, что единственное их различие — то, как они совершают ход. Наносят/получают урон, перемещаются по игровому полю они абсолютно одинаково. Более того, за исключением момента совершения хода и итемов наш ГГ ведет себя точно так же.

Мы выделим общую между этими классами функциональность в класс Critter. Помимо этого, мы реализуем в этом классе интерфейс Sprite, для того, чтобы не повторять работу для всех классов.

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

Помимо этого Противники так же имеют внутреннее сходство — часть из них стрелки, а часть бойцы. Мы реализуем это сходство в виде классов Ranger и Fighter. Стрелки, как и бойцы, различаются только на уровне параметров. Представим вышесказанное в виде схемы.

Вторая группа — итемы. Здесь ситуация проще. У нас просто еcть общий для всех итемов класс, который так же реализует интерфейс Sprite

Состояния игры.

Любой объект всегда находится в определенном состоянии. Это состояние определяется совокупностью его данных. Т.е. например, если у нас есть объект с int внутри, то int == 1 — это одно состояние, а int == 2 — другое. Если в стакане есть молоко — это одно состояние, нет — другое. Если там плавает печенька — третье;)

Игра так же находится в различных состояниях. Т.е. если вы, напиример, находитесь в главном меню, клавиша Esc отправит вас в Windows, а если вы в игре — вызовет главное меню. Объект в различных состояниях реагирует на внешние события по разному (такие, как, например, ввод).

Вы, конечно, можете написать гору вложенных if() else {}, тогда Ваша игра будет в рантайм проверять свое состояние и действовать соответствующе. Приведет это рано или поздно к «макаронному коду»;) Каждое последующее действие будет усложнять ситуацию, появятся баги, методы будут похожи на простыни.

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

Конечные автоматы в ООП

Мы выделили в нашей игре всего три состояния и линейные переходы — это простейший случай. У нас нет сложных меню и прочих нетривиальных вещей. У нас есть

LoadingState — состояние загрузки нашего поля и игровых объектов

GameState — основное состояние, в котором разворачивается наша игра

GameOverState — состояние, в котором мы оказываемся в случае победы/поражения

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

Раздел статей, посвященный UML диаграммам

Попробуем изобразить конечный автомат нашей игры

О том, как реализовать эти состояния мы поговорим чуть позже в разделе Имплементация.

Основной цикл игры.

У нас есть представление о практически всех аспектах нашей игровой механики, кроме самого главного. Как спроектировать и представить основной игровой цикл до того, как начнем писать код? Ведь если мы сразу бросимся программировать, не имея плана — скорее всего это закончится кашей.

Перед тем, как нарисовать картину, художник рисует эскиз. Мы поступим точно так же. Для этого мы воспользуемся еще одним выжным при проектировании средством — мы напишем псевдокод нашего игрового цикла.

Написание псевдокода, как и многое в программировании — процесс итеративный. Представим основные этапы написания псевдокода в нашем случае.

	1) 
		ход игрока
		ход противника
		проверка победы/поражения
 
	2) 
		{
			if ( может пройти )
			делает ход
		}
 
		{
 
			if ( видит игрока )
				атакует
		}
 
		{
 
			if ( игрок мертв )
				поражение
 
			if ( все противники повержены )
				победа
		}
 
	3) 
		{
			if ( может пройти )
				if ( натолкнулся на противника )
					атакует и делает ход
				else
					делает ход
		}
 
		{
 
			if ( видит игрока )
				if ( стрелок )
					пускает снаряд
				else
					приближается и атакует
		}
 
		{
			if ( игрок мертв )
				поражение
 
			if ( все противники повержены )
				победа
		}

И так далее. Последний уровень уточнения псевдокода — это, на самом деле, реальный код приложения;) Не стоит уходить глубоко — достаточно дойти до итерации, на которой вам все более-менее ясно и не вызывает вопросов. Так же псевдокодирование надо применять только там, где это действительно важно. Если Вы что либо понимаете не до конца — это первый признак того, что надо подумать на бумаге — в виде псевдокода.

Соответственно, поначалу Вы будете заниматься этим постоянно, затем у Вас появится опыт и этот процесс будет требоваться гораздо реже.

Итого.

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

Имплементация игровой механики.

Имплементация — это процесс реализации задуманного в коде. В этой главе мы объясним ключевые моменты классов игры. Самый лучший вариант работы с этой главой — читать и просматривать код соответствующих классов в проекте. Если что либо осталось неясным после объяснения, Вы нашли баг, или что-либо в этом духе — обращайтесь на форум.

Глава будет изложена довольно лаконично. Смотрите проект, учитесь работать с кодом. Уметь получать недостающие части мозаики из кода/документации — это must для хорошего программиста. На определенном этапе Вы будете быстро пробегать взглядом пример использования класса из документации, после этого вы будете открывать IDE и изучать класс/фреймворк на примерах.

Второе. Вы наверняка заметили, что в туториале мы регулярно используем анлийские термины и даже словосочетания. Английский — это международный язык науки. Если Вы его не знаете — Ваш кругозор и объем доступной Вам информации съеживается до границ рунета — т.е. почти исчезает;) Самое время найти какие-либо сносные курсы — не будем давать рекламы, но лингво лев очень неплох;) Поверьте, нет ничего страшнее, чем названия в духе class Pulia; void Otvintit(); Помимо этого, стоит сразу приучаться к терминам, которые дают в международной литературе — renderer — это рендерер, а не рисователь..;) Освоить английский на уровне чтения проф.литературы — это полгода, с репетитором — пара месяцев — от вас никто не потребует ничего сверхъестественного — google, словарь и коллеги всегда под рукой.

Третье. Код пишется Вами не для Вас, а для тех кто будет с ним работать — выработайте или позаимствуйте coding standards и придерживайтесь их на протяжении всего проекта. Каша в коде — каша в результате.

И четвертое. Комментирование кода. «Хорошо комментированный» код — это очень плохо;) Хорошо комментированный код — это код, понятный без комментариев. Комментированными должны быть ключевые интерфейсы, классы и методы. Так же откоментированны могут быть нетривиальные алгоритмы. Вы тратите кучу времени на коментирование — это сродни прокрастинации — вместо того, чтобы двигаться дальше вы комментируете и без того понятные участки. Помимо этого код постоянно меняется и редко когда разработчики меняют и оставленные комментарии.

Все эти правилы — как устав — писаны слезами разработчиков;) Не ленитесь применять их с самого начала — со временем вы просто перестанете их замечать. Это непросто, но результат того стоит. Думайте о том, чего вы сможете делать умея, а не о том, как сложно учиться.

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

Прекондишн — это условия, при которых медод выполняется. Самым распространенным прекондишном является проверка переданных в параметры указателей на ноль (с ссылками такого делать не нужно). Помимо этого в прекондишне может быть проверено что угодно — например, что игрок жив перед ходом или что спрайт существует перед удалением.

Более подробно о пре- и пост- условиях вы можете поситать в соответствующих разделах сайта

В нашей игре мы будем использовать самый простой вариант проверки — ассертацию. Если мы хотим проверить какое либо условие в режиме дебага — мы передаем его в конструкцию

assert( boolean condition );

Если условие будет неудовлетворено, например в проверке

assert( pPointer != 0 ), pPointer будет равен нулю — мы получим сообщение об этом. Таким образом мы удостоверимся, что абсолютно все места где это необходимо работают как надо.

GameField.

Этот класс является представлением игрового поля. Реализует интерфейс Field.

class GameField : public Field

Т.е. когда мы хотим, чтобы рендерер произвел какие либо действия над полем мы передаем ему объект этого класса

pRenderer->createField( gameField );

Класс GameField содержит в приватной части информацию о тайлах игрового поля. Для удобства каждый тайл выделен в отдельную приватную структуру TileData. Помимо этого, приватная часть содержит методы для удобного доступа к этим структурам по координатам.

TileData & getTileAt( const Vec2 & tile );
const TileData & getTileAt( const Vec2 & tile ) const;

Мы можете заметить, что метода два — один константный, другой — нет. Это сделано для того, чтобы компилятор сам мог выбрать метод в зависимости от использования.

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

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

Так же в приватной части реализован интерфейс Field. Фактически, он состоит из двух методов

virtual TileResource getLayer1TileResource( const Vec2 & tile ) const = 0;
virtual TileResource getLayer2TileResource( const Vec2 & tile ) const = 0;

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

Публичная часть содержит три ключевых метода, которые нужны игровой механике

void reloadField();
bool isTileOpen( const Vec2 & tile ) const;
bool isPathOpen( const Vec2 & from, const Vec2 & to ) const;

Первый вызывается механикой, когда мы желаем перезагрузить информацию о уровне из хедера Map.h.

Второй запрашивает у карты — открыт ли тайл для прохода по нему.

Ну и третий проверяет открыт дли для прохода путь между точками from и to. Это нужно, например для того, чтобы проверить — может ли стрелок выстрелить в героя. Конкретная реализация этого метода не должна вас беспокоить. Говоря вкратце — он находит все тайлы на пути от from к to и проверяет — проходим ли каждый из них поотдельности. Не стоит лезть в математику — старайтесь уловить картину в общем — детали вы можете разработать и сами.

Те, кому интересен алгоритм Брезенхэма, могут почерпнуть информацию из довольно интересной статьи на Википедии.

Critter.

Как вы помните из раздела проектирования, Critter — это общий для всех существ класс, который несет общую функциональность. Начнем рассмотрение этого класса с его приватной части и конструктора.

В приватной части мы видим все необходимые параметры для нашей механики

int hits;
int attack;
int defense;
int sightRadius;
int experience;

Помимо этого криттер так же является спрайтом

class Critter : public Sprite

Это значит, что мы можем передавать его и его наследников для работы нашему рендеру, например

pRenderer->createSprite( critter );

Так же в приватной части класса содержится информация, относящаяся к спрайту

std::string name;
Vec2 position;
SpriteResource spriteResource;

И реализацию интерфейса Sprite

private:
	virtual const Str & getName() const { return name; }
	virtual SpriteResource getResource() const { return spriteResource; }
	virtual const Vec2 & getTile() const { return position; }

Конструктор просто принимает значения, проверяет их на валидность и заполняет внутренние поля класса.

Публичная часть криттера состоит из виртуального метода

virtual void makeTurn( Warrior & warrior, const GameField & gameField, Renderer *pRenderer ) {}

и методов самого класса.

Метод makeTurn будет перегружен в потомках и будет управлять существом, когда ему будет необходимо сделать свой ход. В качестве параметров он принимает все данные, которые необходимы для совершения хода — Warrior — для преследования и атаки, GameField — для поиска пути и видимости, указатель на рендерер для того, чтобы внести изменения в результате хода.

Оставшиеся методы являются методами игровой механики для криттера — эти методы позволяют наносить ему урон, атаковать, перемещаться и т.п.

void hit( int enemyAttack );
bool isDead() const;
void move( const Vec2 & where );
virtual int getAttack() const;
virtual int getDefense() const;
int getExperience() const;
const Vec2 & getPosition() const;

Реализация имеет мало значения.

Ranger & Fighter.

Ranger и Fighter являются прямыми потомками класса Critter

class Fighter : public Critter
class Ranger : public Critter

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

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

Warrior.

Warrior так же является потомком класса Critter. Метод makeTurn в Warrior не перегружен, т.к. решения о ходе будет принимать сам игрок.

Warrior состоит из конструктора, методов для механики и некторох собственных данных.

Конструктор обычный — просто заполняет Critter и себя.

Методы механики так же вполне говорят за себя

void gainExperience( int exp )
void upgrade( Item item )

Методы

virtual int getAttack() const { return attack + attackBoost; }
virtual int getDefense() const { return defense + defenseBoost; }

немного отличны от аналогичных в Critter. Параметры защиты и атаки героя считаются не только из исходных, но и из того, что прибавляют итемы (boost).

Item.

Класс Item представляет игровые объекты в нашей игре. Так как Item имеет графическое представление на нашем игровом поле, он также реализует интерфейс Sprite.

Класс Item условно можно разделить на три части: различные приватные данные и конструктор, реализация класса Sprite и методы, специфичные для класса.

Первая группа методов класса позволяет получить конкретные данные атаки и защиты для итема

int getAttack() const { return attack; }
int getDefense() const { return defense; }

Вторая группа методов позволяет воину поднять Item и проверить, поднят ли текущий.

void pick() { picked = true; }
bool isPicked() const { return picked; }

Таким образом, игровые итемы в отличие от игровых существ, фактически, различаются только параметрами.

GameManager.

Игровой менеджер — это наиболее большой и сложный класс нашего приложения. В основные задачи игрового менеджера входят управление игровой логикой и хранение игровых объектов.

Игровой менеджер, как мы упомянули на этапе проектирования, является конечным автоматом. Для того чтобы иметь возможности конечного автомата, он является наследником класса FSM. Конкретную реализацию конечного автомата для нашего случая вы можете посмотреть в коде.

Для удобства мы будем рассматривать класс игрового менеджера сверху вниз по хедеру. Первыми идут конструктор и деструктор. Тут все понятно — конструктор получает указатель на рендерер для того, чтобы иметь возможность им управлять.

Следующим идет метод loadAndGo()

void loadAndGo();

Этот метод создает наши игровые состояния и переключает игровой менеджер в первое из состояний LoadingState. Начиная с этого момента, управлением игрового менеджера и вызовом ключевых его методов занимаются три его основных состояния.После метода loadAndGo() идет метод sendEvent

void sendEvent( int evtId );

Этот метод является ключевым способом передачи внешних событий конечному автомату. То есть, если что-то произошло, мы передаем это событие конечному автомату с соответствующим идентификатором, и конечный автомат меняет свое состояние. Так мы можем, например, реагировать на ввод пользователя.Далее идут методы, которые вызываются из состояний и, по сути, являются нашей игровой механикой. Первая группа методов относится к состоянию LoadingState

void reloadField();
void reloadWarrior();
void reloadEntities();

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

bool isVictory();
bool isDefeat();

void playerMove( PlayerMove move ); void playerPickUpItem(); void crittersTurn();

Последние два метода показывают пользователю победу или поражение в случае конца игры.

void showVictory();
void showDefeat();

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

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

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

Задавайте Ваши вопросы на форуме.

Приложение и рендерер, ресурсы приложения.

Следующая часть нашего туториала посвящена приложению, рендереру и ресурсам нашего приложения.

Фактически, это первая глава нашего туториала, которая касается непосредственно вывода графики. Для вывода графики мы выбрали графический движок Ogre3D. На текущий момент это самый распространенный и поддерживаемый бесплатный графический движок для C++. Если вы не работали с этим графическим движком, мы предлагаем вводный цикл статей на тему Ogre3D.

Туториалы Ogre3D.

Если вы знакомы с этим движком, вы можете пропустить этот цикл. Если вы знакомы с любым другим графическим движком, вы можете бегло ознакомиться. Графические движки довольно похожи между собой.

Приложение Ogre3D.

Движок Ogre3D предлагает нам свой класс приложения, имеющий собственный ввод и собственную систему ресурсов. В одном из дальнейших уроков мы подменим приложение и рендерер в Ogre на свои собственные.

Любая игра начинается с приложения. Базовое приложение Ogre3D поставляется в комплекте вместе с SDK. Класс называется BaseApplication и напрямую копируется в проект. Далее вы наследуете от BaseApplication непосредственно ваш класс приложения и перегружаете необходимые вам для работы методы.

Наш класс приложения называется TutorialApplication и наследуется непосредственно от BaseApplication. Класс TutorialApplication является модифицированной версией того, что идет в SDK для работы с примерами Ogre3D.

Функция main() определена в файле TutorialApplication.cpp и, фактически, единственное что делает — это создает приложение и запускает его с помощью метода go().

TutorialApplication app;
app.go();

Для работы туториала мы перегрузили лишь ключевые методы BaseApplication:

-этот метод СreateScene

virtual void createScene();

-метод FrameStarted

virtual bool frameStarted( const Ogre::FrameEvent& evt );

-Callback’и для ввода

virtual bool keyPressed( const OIS::KeyEvent &arg );
virtual bool keyReleased( const OIS::KeyEvent &arg );
virtual bool mouseMoved( const OIS::MouseEvent &arg );
virtual bool mousePressed( const OIS::MouseEvent &arg, OIS::MouseButtonID id );
virtual bool mouseReleased( const OIS::MouseEvent &arg, OIS::MouseButtonID id );

В CreateScene мы создаем наш рендерер и главный менеджер игры, после чего мы вызываем у менеджера игры метод loadAndGo(), который запускает нашу игру.

Метод frameStarted() вызывается перед началом отрисовки каждого фрейма (т.е. картинки) движком фрейма. Фактически, этот метод представляет из себя обычный метод update(), в который передается количество времени, прошедшее с момента предыдущего вызова. В этом методе мы обновляем положение прожектилов на игровом поле.

Каллбэки ввода с помощью клавиатуры — методы keyPress и keyRelease — просто передают события в игровой менеджер, вызывая его соответствующий метод.

Методы ввода мышью перегружены, для того чтобы избежать перехвата управления сценой в базовом приложении. Вы можете закомментировать декларацию этих методов в классе TutorialApplication и посмотреть, что выйдет.

Ресурсы приложения.

Графический движок Ogre3D предоставляет нам собственную ресурсную систему. По умолчанию ресурсная система Ogre3D управляется из файла resources.cfg. Этот файл обычно располагается рядом с экзешниками вашего проекта. Вы можете найти их в папке binrelease или bindebug в папке OGRE_SDK. В этом файле вы можете увидеть пути к ресурсам вашего приложения. По умолчанию в нем довольно большое количество путей, необходимых для работы туториалов Ogre. Мы рекомендуем закомментировать большую часть из них, для того чтобы приложение загружалось быстрее. В релизе нашего проекта, ссылка на который была предоставлена в начале туториала, вы можете посмотреть наш вариант resources.cfg.

Ресурсы к нашей игре располагаются непосредственно в папке media. Фактически, ресурсы состоят из большого набора материалов, описанных в файле roguelike.material.

roguelike.material cостоит из набора однотипных определений ресурсов. Говоря вкратце, мы описываем соответствующий материал со ссылкой на текстуры. В нашем случае – это обрезанные по альфе файлики *.png. Заметьте, что несколько материалов могут ссылаться на одни и те же текстуры. Это сделано для того чтобы при загрузке движка сохранить память.

SpriteRenderer.

В нашем случае рендерер приложения является двухуровневым. Первый уровень инкапсулирует в себе возможности вывода спрайтов и текста, и реализован в виде класса SpriteRenderer. Второй уровень является непосредственно реализацией нашего интерфейса Renderer. Фактически, второй уровень выполняет функции, возложенные на него интерфейсом Renderer с помощью возможностей, которые предлагает ему SpriteRenderer.

Для удобства работы со спрайтами через мануальные Ogre3D на уровне SpriteRenderer мы выделили в специальный класс, который называется OgreSprite. Единственное, что позволяет этот класс — это установить позицию для конкретного спрайта на экране.

Если вам интересно как SpriteRenderer выводит спрайты и текст с помощью механизмов Ogre, вы можете почитать об этом в соответствующих статьях. Спрайты выводятся с помощью мануальных объектов. Текст выводится с помощью оверлеев. Все это вы так же можете почерпнуть в наших статьях.

OgreRenderer.

Класс OgreRenderer непосредственно имплементирует интерфейс Renderer

class OgreRenderer : public Renderer, SpriteRenderer

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

virtual void createField( const Field & field );

Далее идет группа методов, позволяющих нам работать со спрайтами

virtual void createSprite( const Sprite & sprite );
virtual void updateSprite( const Sprite & sprite );
virtual void removeSprite( const Sprite & sprite );

Следующим методом является setSceneCenter()

virtual void setSceneCenter( const Vec2 & tile );

Этот метод позволяет направить камеру на главного героя.

Следующие два метода позволяют размещать и убирать пятна крови на игровом поле.

virtual void removeAllBlood();
virtual void createBlood( const Vec2 & tile );

С помощью следующих методов

void updateProjectiles( float elapsed );
virtual void createProjectile( const Vec2 & from, const Vec2 & to, ProjectileType resource );

мы сможем создавать и обновлять позиции прожектилов на экране.

Наконец, последний метод позволяет нам выводить на экран текст.

virtual void setTextOutput( const Str & text );

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

Не стоит тратить слишком много времени на изучение реализации методов рендерера. Основной посыл пунктов — дать возможность увидеть, что рендерер, скрытый за интерфейсом, может быть подменен любым другим

Заключение.

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

В этом уроке мы не рассматриваем заключительный этап тестирования и балансирования игры — механика игры довольна проста — вы можете самостоятельно поиграться с цифрами в соответствующих хедерах — Stats.h и Map.h.

Пробуйте полученные знания в собственных проектах, задавайте вопросы на форумах.

До встречи в следующих туториалах;)

Заполнен: Туториалы
Присвоен тэг:

avatar

Об Авторе ()

sex, drugs & computer science!

Комментирование закрыто.

Наверх