Игровой конструктор, часть 3. Иерархия классов и объектов

Игровой конструктор, часть 3. Иерархия классов и объектов

Самопал — Игровой конструктор, часть 3. Иерархия классов и объектов
Если вы плохо представляете себе структуру будущего движка, можете воспользоваться очень простой методикой. Напишите на листочке все игровые объекты в любом порядке (предметы, окружение, героев, любые другие объекты). Под каждым напишите список его свойст
Игроманияhttps://www.igromania.ru/
Самопал
Игровой конструктор, часть 3. Иерархия классов и объектов

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

    Ночной кошмар программиста
   
Прежде всего давайте выясним, зачем вообще нужна какая-то иерархия, система классов? Почему бы просто не писать движок, как пишется, и не заморачиваться какими-то иерархическими сложностями? Необходимо это для того, чтобы потом не было предельно сложно. В самой первой статье цикла я
59 Kb
На этой странице справки
GLScene содержатся ссылки
на краткие спецификации всех
его классов.
привел пример с домом и кирпичами. Мы выяснили, что игровые движки лучше разбивать на независимые модули. Можно продолжить эту аналогию. Допустим, вы строите крупнопанельный дом, и вашему начальству пришло в голову панели сделать ну очень крупными: размерами в несколько этажей, да еще и сложной формы. Соседний дом строится по старинке, из кирпичей. Вдруг высшее начальство решило, что дома должны расти не ввысь, а вширь. Какое здание будет проще переделать под новый стандарт? Ясно, что кирпичное, так как там элементы гораздо меньше, и они могут свободно сопрягаться друг с другом.
    Аналогия отнюдь не случайная. Разработка компьютерных игр — процесс нелинейный. Как бы подробно не был написан дизайн-документ, уже в процессе работы над игрой в него придется вносить какие-то изменения, а значит — переписывать уже написанный код или добавлять новое в модули, для этого не предназначенные. Причины могут быть самыми разными: дизайнеру или сценаристу неожиданно пришла в голову гениальная идея, которую надо непременно воплотить (тот факт, что для воплощения этой идеи надо переписать с нуля весь движок, обычно никого не волнует), изменилась конъюнктура рынка или используемые технологии уже устарели (они до релиза еще раз пять успеют устареть, но об этом мало кто задумывается на этом этапе).
    Результат таких преобразований всегда один: непонятный код, состоящий из плохо стыкующихся кусков, странные баги, которые приходится отлавливать неделями, и бессчетные чашки кофе и упаковки димедрола, не способные более охладить кипящий разум всех программистов проекта. Но и это не самое страшное. Самое страшное — потеря времени, а значит — вновь умчавшиеся вперед технологии, вновь поменявшаяся конъюнктура рынка, и работу опять приходится начинать сначала.
    Всего этого можно если не избежать, то по крайней мере скомпенсировать четкой и удобной иерархией классов и объектов игры. Тогда программисты смогут встраивать в игровой код любой степени завершенности новые возможности без опаски, что после этого работа пойдет наперекосяк.

    По кирпичику — да целый дом!
   
Кирпичный дом значительно легче разобрать и переделать, чем какой-нибудь дворец с железобетонными элементами, отлитыми по спецзаказу. Такая гибкость нужна не только в строительстве домов, но и в строительстве трехмерных движков...
52 Kb 43 Kb
    Разработчики игр готовы крушить мониторы и разбивать о головы соратников клавиатуры, когда руководитель проекта сообщает, что они должны переделать большую часть движка, причем ко вчерашнему дню. Перестраивать все здание, даже если оно построено из правильно подогнанных кирпичиков, — занятие не из приятных.

    Первый, второй, десятый...
24 Kb
Если дважды кликнуть по
значку GLScene на форме,
можно увидеть такую картину.
Не перепутайте: это вовсе не
дерево доступных классов, а
дерево объектов, уже
находящихся в сцене.
    Давайте решим, как именно будет выглядеть иерархия игровых классов. Во многих играх самый главный и самый верхний класс — TEngine, который описывает графический движок в целом. В нем хранится список всех игровых объектов и вспомогательная игровая информация глобального масштаба (например номер уровня, настройки графики). Он также представляет общеигровые методы, например Загрузить игру, Перейти на уровень, Сохранить игру и многие другие.
    Одновременно может существовать только один объект этого класса, так как и игровой движок у нас один. Есть исключения, например dedicated сетевые серверные движки, но они и строятся по другим принципам. Особого смысла в реализации класса TEngine в нашем случае нет. Вы можете создать его как организующий элемент, чтобы вся система была стройной и однозначной. Но особой смысловой нагрузки он нести не будет. Ведь список объектов и все действия с ними “держит” класс TGLScene. Он в какой-то мере и будет самым верхним классом иерархии. Остальные поля и методы движка реализуйте как обычные переменные и методы главной формы.
    Теперь разберемся с другими классами. Если вы плохо представляете себе структуру будущего движка, можете воспользоваться очень простой методикой. Напишите на листочке все игровые объекты в любом порядке (предметы, окружение, героев, любые другие объекты). Под каждым напишите список его свойств и методов. К примеру, какие поля могут быть у объекта Тролль в простом 3D-action? Жизни, броня (если есть), тип оружия, мана и доступные заклинания. А методы у него могут быть такими: Идти (в качестве параметра “направление”), Идти к (в качестве параметра “конечная точка”, здесь пригодятся алгоритмы поиска путей, о которых мы поговорим в одной из следующих статей цикла), Ударить (тип оружия или магии), Искусственный интеллект, Проверить столкновение с объектом (об этом мы также поговорим в одной из следующих статей ).
    После того как вы распишите таким образом каждый игровой объект, вы обязательно заметите, что у многих объектов есть общие поля и методы. К примеру, поле Жизнь есть у всех живых существ, а поле Мана только у тех, кто обладает магией. А вот метод Проверить столкновение с объектом есть не только у живых существ, но и у многих неживых, но движущихся. К примеру, он будет у стрелы и у камня, если его можно метать. Если у группы классов есть одинаковые свойства и методы, в их основу надо положить отдельный подкласс.
    Для тех, кто еще не очень хорошо ориентируется в наследовании классов, приведу код, который отражает эту иерархию в Дельфи. Естественно, все названия и тонкости реализации гипотетические. В вашем случае они могут быть совсем другими. Однако общий принцип прослеживается.
41 Kb
А вот это уже дерево классов
одной достаточно сложной
игры, написанной на GLScene.
Не удивляйтесь — вы такого
окошка в Delphi не найдете.
Это специальный плагин,
написанный одним из
главных координаторов
разработки GLScene для
удобного ориентирования в
сотнях страниц исходного
кода. А называется сие
чудо Code Warp.
    TMovable=class
    procedure CheckCollision(obj: TMovable); virtual;
    end;
    TLifeObject=class(TMovable)
    procedure Go(direction:TDirection); virtual;
    procedure GoTo(obj:TObject); virtual;
    procedure AI; virtual;
    public
    life:integer;
    end;
    TBattleCharacter=class(TLifeObject)
    procedure Attack(obj: TLifeObject); virtual;
    end;
    TMage=class(TBattleCharacter)
    public
    mana:integer;
    end;
    TTroll=class(TMage)
    procedure CheckCollision(obj: TMovable); override;
    procedure Go(direction:TDirection); override;
    procedure GoTo(obj:TObject); override;
    procedure AI; override;
    procedure Attack(obj: TLifeObject); override;
    end;
    TAmmo= class(TMovable)
    public
    speed:integer;
    attacktype:TAttackType;
    power:integer;
    end;
94 Kb
У каждого из трудяг-сеттлеров
десятки разных свойств и
методов.
    TArrow=class(TAmmo)
    procedure CheckCollision(obj: TMovable); override;
    end;
    TSheep=class(TLifeObject)
    procedure CheckCollision(obj: TMovable); override;
    procedure Go(direction:TDirection); override;
    procedure GoTo(obj:TObject); override;
    procedure AI; override;
    end;
    TOrk=class(TBattleCharacter)
    procedure CheckCollision(obj: TMovable); override
    procedure Go(direction:TDirection); override;
    procedure GoTo(obj:TObject); override;
    procedure AI; override;
    procedure
attack(obj: TLifeObject) override;
    end;

28 Kb
Схема простейшей игровой
иерархии. Более сложные
строятся по образу и подобию.
    Золотые правила иерархии
    У каждого конкретного движка своя собственная, совершенно неповторимая система иерархии. Но... Есть несколько общих принципов создания удобной иерархии, которые используются разработчиками во всех качественных движках. При построении собственного игрового “двигателя” обязательно нужно иметь их в виду.
    ПРИНЦИП ПЕРВЫЙ. Если два класса, относящиеся к разным базовым классам, имеют одинаковые по смыслу и реализации свойства или методы, значит, вы что-то не так сделали. Необходимо пересмотреть всю систему и сделать ее не избыточной.
    ПРИНЦИП ВТОРОЙ. Пользуйтесь замечательным правилом Бритвы Оккама: не плодите сущностей сверх необходимости. Например, не стоит объединять всех игровых персонажей с зеленой чешуей в отдельный класс, основным отличием которого является свойство зеленочешуйчатости. Если только это свойство не ключевое для игры.
    ПРИНЦИП ТРЕТИЙ. Представьте себе, что вам нужно добавить в вашу игру: гиппогрифа, чемодан, танк со сменными колесами, всем живым существам признак наркотического отравления, изменить у всех объектов проверку столкновений с боксовой на полигональную. Если все эти вещи вы сможете сделать без изменения структуры иерархии — значит, вы все сделали правильно.

    Полцарства за наследника
   
В коде, который мы только что рассмотрели, все методы, объявленные в родительском классе, переобъявляются в финальных классах. Причем в
50 Kb
Технология наследования
классов и перегрузка
методов — нетривиальная
операция выполняется
одной строчкой.
родительском классе после объявления стоит слово virtual, а в классе потомка — override. Такой прием называется перегрузкой. Давайте разберемся, зачем он нужен.
    Возьмем для примера метод Attack, который есть у тролля и у орка. Если подумать, реализация этого метода должна быть для них разной. Ведь орк атакует оружием, а тролль — не только оружием, но и магией. Допустим, по нашей задумке тролль всегда атакует противника магией на дальних дистанциях, а оружием — на ближних. Орк атакует только оружием на ближних дистанциях. В реализации метода Attack для этих двух персонажей будет общая часть (атака оружием) и части различные. Можно также сказать, что на ближних дистанциях тролль ничем не отличается от орка. Тогда атаку обычным оружием мы реализуем в методе Attack у родительского класса орка — TBattleCharacter, а атаку магией реализуем в родительском классе тролля — TMage. Причем у всех потомков TMage будет возможность драться как магией (так как эта возможность заложена в сам этот класс), так и обычным оружием (так как эта возможность присутствует у одного из предков — TBattleCharacter). Допустим, метод Attack у TBattleCharacter уже реализован. Тогда метод Attack у всех магов, в том числе и у тролля, может выглядеть так:
63 Kb
Класс героев в Warсraft III был
наследован от класса простого
атакующего юнита, так как все
базовые методы и свойства у
них одинаковы.
    procedure TTroll.Attack(obj: TLifeObject);
    begin
    if DistanceTo(obj.AbsolutePosition)>100 then
    AttackAsMage(obj); {атакуем магией}
    else inherited Attack(obj);
    end;
    Предполагается, что AttackAsMage уже реализован. Обратите внимание на ключевое слово inherited перед повторным вызовом метода Attack. Это не рекурсия, как вы могли подумать, то есть метод не вызывает сам себя. Ключевое слово inherited указывает на то, что мы должны вызвать метод предка этого класса. Еще раз посмотрите на объявление метода Attack у класса TBattleCharacter и TTroll. Метод у TBattleCharacter помечен словом virtual, то есть он виртуальный, его можно перегрузить. Метод у TTroll помечен словом override, что и обозначает перегрузку. Итак, все эти три ключевых слова: virtual, override и inherited позволяют организовать иерархическое дерево методов. Таким образом, с помощью иерархии классов мы можем дать объектам не только общие свойства, но и общие участки кода, отвечающие за
61 Kb
Если назначить один танк
ведущим, а остальным танкам
добавить в иерархию свойство
ведущего и пару методов, то
можно управлять всей
колонной, как одним объектом.
одинаковые действия.
    Есть еще одно ключевое слово из этой же категории — abstract. Если его поставить после объявления метода, который вы собираетесь перегружать, для него вообще не нужна реализация. То есть в классе будет только название метода, и больше ничего. Ну а его реализация или каскад реализаций вы сможете написать в классах-потомках.
    У иерархической модели методов есть одно замечательное свойство, которое на первый взгляд неочевидно. На деле же это один из удобнейших приемов в объектно-ориентированном программировании. Рассмотрим все тот же пример с троллем и орком. Допустим, вы пишите искусственный интеллект для группы разных боевых существ. В какой-то момент игрок выделил рамкой свое могучее воинство и послал его в туманную даль бить врагов. В обычном случае вам придется для каждого существа в отряде вызывать метод Attack. Но так как эти существа принадлежат десяткам разных классов, вам придется повторить это действие для каждого типа существ в отдельности! А вот как этого же можно добиться с нашей системой. Предположим, что Creatures — это список всех существ нашего отряда.
    For x:=1 to Creatures.Count do (Creatures[x] as
TBattleCharacter).Attack(obj)
    Всего одна строчка! Игра берет самый первый объект списка Creatures, приводит его к классу TBattleCharacter (мы имеем полное право так сделать, потому что все объекты нашего списка являются потомками TBattleCharacter в том или ином поколении), и у приведенного объекта вызываем метод Attack. Далее игра для каждого типа объектов проходит вверх по дереву его предков, доходит до него самого и вызывает его личный метод Attack. То есть эта строчка вызовет разные методы Attack для тролля и орка. Каждому — свой. Мораль: тщательно планируйте иерархию классов, чтобы однотипный код перекрывался на разных уровнях дерева классов. Тогда несколькими строчками кода вы сможете сделать столько всего, на что при обычном подходе ушла бы не одна страница.

    Перепишем всех!
   
Когда мы посылали все могучее виртуальное воинство на бой одной единственной строчкой, то использовали в ней список Creatures. В нем хранятся все игровые персонажи. Давайте разберемся, что это за звери такие — списки.
    Списки — вещь это крайне удобная. В них можно хранить любые объекты любых классов и оперативно получать к ним доступ, сортировать по какому-то критерию, перебирать...
    За списки вообще в Delphi отвечает класс TList. Есть несколько классов для специфических списков, но нам они пока не интересны. Вот как можно объявить список Creatures:
    Var
    Creatures:TList;
   
Теперь с помощью команды Add можно добавить в список элементы. Например, у нас есть объект Troll класса TTroll. Тогда следующей строчкой мы добавим его в список:
    Creatures.Add(Troll);
   
А вот так мы достанем его из списка, зная его индекс x:
    (Creatures[x] as TTroll)
   
Ключевое слово as — универсальный преобразователь типов. Однако пользуйтесь им только тогда, когда точно уверены, что преобразуемые типы совместимы. Например, находятся в связи предок-потомок. Если вы попробуете привести один тип к другому, совершенно с ним не совместимому, ничего хорошего не выйдет.
    Зная индекс элемента, можно удалить его из списка:
    Creatures.Delete(x);
   
Ну а что делать, если у вас есть объект и вы хотите получить его индекс в списке? Воспользуйтесь этой командой:
    x:=Creatures.IndexOf(Troll);
   
После выполнения этой строчки в x окажется индекс объекта Troll. Количество элементов в списке можно прочитать из его свойства Count. Как перебирать все объекты списка, вы уже знаете.
    Напоследок — одно важное замечание. В списке не хранятся сами объекты, а только указатели на них. Все манипуляции с элементами (добавление, удаление, сортировка) никак не отражаются на самих объектах. То есть списки — просто удобный инструмент для структурированного хранения информации.

    Иерархия GLScene
   
Принципы, изложенные выше, активно использовали разработчики GLScene. Эта графическая библиотека имеет крайне продуманную и удобную иерархию классов. Причем дерево классов довольно развесисто. Самые длинные цепочки наследования содержат до десяти уровней! А базовых классов, над которыми строятся все остальные, не больше десятка (всего классов больше трехсот!). Давайте рассмотрим несколько наиболее интересных ветвей этого могучего дерева. Это поможет лучше понять принципы наследования. Кроме того, многие родительские классы тех классов, которыми мы часто пользуемся, интересны и сами по себе. С их помощью можно реализовать многие вещи, недоступные на самом верхнем уровне иерархии.
65 Kb
Дриада и здание на заднем
фоне образованы от одного и
того же класса. Положение в
пространстве и углы поворота
в пространстве, линейные
размеры, трехмерная модель,
текстура, параметры освещения.
    Самый главный класс GLScene — сам TGLScene. В нем содержатся все данные о сцене, ее описание (объекты, камеры, источники света и так далее) и вспомогательная информация, необходимая для рендера. Спустимся вниз по дереву иерархии до стандартного класса Delphi. Им будет TComponent, который воплощает основные свойства визуальных компонентов (то есть классов, объектами которых программист может манипулировать иначе, чем через код). Поднимемся на уровень выше — TGLCadenceAbleComponent. Этот класс добавляет возможность работы с TGLCadencer — его предназначение мы подробно разобрали в прошлой статье цикла. Идем еще выше и встречаем TGLUpdateAbleComponent. Он добавляет возможность обновления данных для стандартных компонентов GLScene. Идем еще выше и встречаем сам TGLScene. Эта цепочка оказалась короткой.
    Давайте от TGLUpdateAbleComponent свернем в другую сторону — к классу-потомку TGLBaseSceneObject. Это базовый класс для всех видимых и многих невидимых классов GLScene. Он добавляет к предыдущим классам целую гроздь важнейших свойств и методов: динамическое изменение иерархии, понятие координат, векторы направления, поворота, масштабирования, сортировка и множество геометрических операций, например расстояние до другого объекта, и многое другое. Если вы хотите написать свой собственный компонент для GLScene (а рано или поздно такая потребность у вас обязательно возникнет), начинать надо именно отсюда.
42 Kb
Между разными классами могут
существовать очень интересные
связи, незаметные на первый
взгляд. Даже между главным
героем и мини-картой.
    Поднимаемся на уровень вверх и приходим к TGLCustomSceneObject. Этот класс добавляет к предыдущим свойство Material, которое отвечает за свойства материала, текстуры и некоторые параметры освещения. Далее идет TGLImmaterialSceneObject. Он служит буфером между видимыми и невидимыми объектами. Одолеваем очередную ступеньку и встречаем TGLSceneObject. Этот класс олицетворяет собой стандартный видимый объект GLScene, который можно, что называется, “пощупать”. От него уже нельзя образовать нематериальные объекты. Идем еще выше, и перед нами — TGLBaseMesh. Этот класс добавляет к предыдущим списки координат вершин, текстур и нормалей для сложных объектов. Уже чувствуете, куда мы движемся? Нет, ну тогда еще один шажок — и вот она, вершина!
    TGLFreeForm — класс, реализующий весь функционал предыдущих классов, который позволяет вам загружать и использовать в GLScene объекты многих форматов трехмерной графики, например популярного пакета 3D Studio. Именно он поможет нам создать большинство статичных (неанимированных) объектов — дома и деревья, столы и стулья, камни и космические корабли.
    Мы проделали долгий путь, но он окупился сторицей. По пути мы миновали множество развилок. Если бы мы хоть раз свернули, то пришли бы к совсем другому объекту. Как только освоитесь с GLScene, обязательно загляните в его исходники — это настоящий кладезь мудрости и интересных приемов программирования.
    И напоследок — важнейший совет. Когда будете строить свою систему иерархии, все визуальные объекты делайте надстройками над классами GLScene. То есть вы как бы сажаете сверху пирамиды GLScene пирамидку своего трехмерного движка. Так они смогут работать в тесной связке, максимально эффективно. Так вы сможете писать гораздо меньше кода. Например, если вам нужно создать классы для тех же статичных объектов, делайте их общего родителя потомком TGLFreeForm. А если вы хотите создать классы анимированных объектов, обратите внимание на стандартный класс TActor. Я верю: когда код вашего движка и код GLScene сольются в едином экстазе, на свет появится Игра с большой буквы.

    Динамические массивы
   
В Delphi есть и другие способы структурированного хранения информации. Списки — вещь замечательная, но только если вы работаете с классами. Конечно, в список можно добавить любой объект, любую переменную, а точнее — указатель на них. Но вот работать с ними так же просто не выйдет. Вам придется работать с указателями. Ничего сложного в этом нет, но, по статистике, именно в кусках кода, связанных с указателями, начинающие программисты делают максимальное количество ошибок.
    Но есть другой способ хранить информацию в одном месте. Способ этот идеально подходит для структур и переменных одного типа. Это динамические массивы. С обычными массивами вы уже знакомы. Динамические массивы — то же самое по сути, но вот число элементов в них может быть любым. Оно также может меняться во время работы программы. Объявляется динамический массив не просто, а очень просто:
    Var
    A:array of integer;
   
Вот так мы объявили динамический массив A из целых чисел. Изначально его длина равна нулю. Чтобы добавлять в него элементы, вам для начала нужно расширить его до необходимых размеров. Вот как это делается:
    SetLength(A,10);
   
Так мы выделили память под 10 элементов. Работайте с ячейками этого массива точно так же, как и с ячейками обычного, например:
    A[1]:=5;
   
Не забудьте только, что у динамических массивов индекс первого элемента равен нулю, а последний — на единицу меньше длины. Кстати, о длине. Получить ее можно с помощью команды Length. Например, так вы сможете заполнить весь массив одинаковым значением:
    For x:=0 to Length(A)-1 do A[x]:=5;
   
В отличие от списка, в динамическом массиве хранятся не ссылки на значения, а сами значения. И они никак не связаны с тем местом, откуда вы их получили. Например, после выполнения строчки
    A[0]:=B;
   
переменная B и первая ячейка массива A будут иметь одинаковое значение, но жить будут совершенно независимо.
    Вы можете также объявить многомерный динамический массив, например так:
    A:array of array of array of integer;
   
Это трехмерный массив целых чисел. Так его можно проинициализировать:
    SetLength(A,10,20,30);
   
Ну а так получить доступ к конкретной ячейке:
    A[1,2,3]:=5;
   
В целом динамические массивы — крайне удобный контейнер для хранения однотипной информации.

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

    На наших CD/DVD
   
На наших дисках выложена последняя версия движка GLScene.
Комментарии
Загрузка комментариев