Объектная система игры (игровой "движок")



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

Сразу оговорюсь, что намеренно не включаю в этот обзор последние разработки по игровым "движкам" (в т.ч. свои) т.к. рассчитываю... описать их позднее, после того как вы, дорчитатели, оцените и обсудите эту статью.

Любая диалоговая программа, не исключая, конечно же, и игры, строиться на основе цикла (обычно — одного цикла). Краткая схема такого цикла выглядит так:

    Инициализация
    Цикл:
    {
        Ввод
        Обработка
        Вывод
    }


На этапе Инициализации производится подготовка к работе цикла — задаются начальные состояния объектов игры, запускается графическая, звуковая и другие подсистемы программы игры. Кроме того, инициализация может встречатся и внутри цикла (на этапе Обработки), если требуется, например, загрузить новый уровень или восстановить работоспособность графической подсистемы после переключения на другую задачу по Alt+Tab (в Windows).

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

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

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


Таким образом видно, что для игрового "движка" необходимо задать:

а) какие бывают объекты (типы объектов)
б) как они создаются/уничтожаются, обрабатываются, взаимодействуют
в) как они получают ввод от игрока (интерфейс системы ввода)
г) как они отображают себя для игрока (интерейс системы вывода)




Ретроспектива видов игровых "движков":


1. Хранение и обработка отдельных единичных объектов

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

Пример:

    int playerX, playerY, playerHealth, playerScore;
    int monsterX, monsterY, monsterHealth, monsterAnger;

    playerX = playerY = 10;
    playerHealth = 100;
    playerScore = 0;

    monsterX = 190; monsterY = 90;
    monsterHealth = 1000;
    monsterAnger = 10;


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

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

Примерно так:


    struct player_t
    {
        int x,y;
        int health;
        int score;
    };

    struct monster_t
    {
        int x,y;
        int health;
        int anger;
    };
        
    player_t  player;
    monster_t monster;
        
    player.x = player.y = 10;
    player.health = 100;
    player.score = 0;

    monster.x = 190; monster.y = 90;
    monster.health = 2500;
    monster.anger = 100;


2. Множества однотипных объектов

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

Пример:

    const int max_monsters = 10;
    monster_t monsters[ max_monsters ];
    
    for(int j = 0; j < max_monsters; j++)
    {
       monsters[j].x = i * 10;
       monsters[j].y = 90 - i;
       monsters[j].health = 1000 + (random() % 1000);
       monsters[j].anger = 10;
    }


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

Однако проблемы начинаются тогда, когда количество типов объектов становиться большим, а от игры требуется еще большая реалистичность. Код просто напросто превращается в нечто такое:

    //Этап обработки объектов
    ControlPlayer();                       //обрабатываем управление игрока
    ThinkMonsters();                       //монстры "думают"
    MovePlayer();                          //двигает игрока
    MoveMonsters();                        //двигаем монстров
    CheckCollisions_Player_Monsters();     //проверяем, если монстр поймал игрока
    MoveMissles();                         //двигаем ракеты
    CheckCollisions_Player_Missles();      //проверяем, если ракета попала в игрока (ракета взорвется)
    CheckCollisions_Monsters_Missles();    //проверяем, какие ракеты в каких монстров попали (ракеты взрываюся)
    AnimateExplosions();                   //анимируем взрывы, которые оставляют ракеты
    CheckCollisions_Player_Explosions();   //проверяем, если игрока задел взрыв (уменьшаем здоровье и т.д.)
    CheckCollisions_Monsters_Explosions(); //проверяем, каких монстров какие взрывы задели (...)
    ...


И так далее — чем больше типов объектов, тем сложнее код.

3. Единая структура для всех объектов

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

    struct game_object_t
    {
        int kind; //это код типа объекта
        int x,y;
        int health;
        int score;
        int anger;
    };

    enum game_object_kinds
    {
        kind_Player,
        kind_Monster,
        kind_Missle,
        kind_Explosion,
    };


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

Чтобы избежать таких затрат памяти можно объединить поля разных объектов в безымянные union'ы.

    struct game_object_t
    {
        int kind; //это код типа объекта
        int x,y;
        int health;
        union
        {
            int score;
            int anger;
        };
    };


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

Однако где-же здесь упрощение при обработке объектов?

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

Пример:

    void Think( game_object_t *p )
    {
        switch(p->kind)
        {
            case kind_Player:
                ...
                break;
            case kind_Monster:
                ...
                break;
            case kind_Missle:
                ...
                break;
            case kind_Explosion:
                ...
                break;
            default:
                //ошибка - тип объекта неверен
                ...
                break;
        }
    }


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

Пример:

    struct game_object_t
    {
        int kind; //это код типа объекта
        int x,y;
        int health;
        union
        {
            int score;
            int anger;
        };
        void (* Think)( game_object_t *self );
    };


Затем в программе, при инициализации объекта надо будет заполнить это поле определенным значением, либо нулем (ноль — отсутствие обрабочика)

    game_object_t objects[ max_game_objects ];
    ...
    void Monster_Think( game_object_t *self )
    {
        //здесь "думает" монстр
    }
    ...
    void Missle_Think( game_object_t *self )
    {
        //здесь "думает" ракета
    }
    ...
       objects[0].Think = Monster_Think;
       objects[1].Think = Missle_Think;
    ...


Теперь можно вызывать обработчики "дум" объектов единообразным образом.

    for(int j = 0; j < num_game_objects; j++)
    {
        void (* proc)() = objects[j].Think;
        if(proc) proc( &objects[j] );
    }


Причем для каждого объекта будет вызван именно тот обработчик, который был задан ему в поле Think.

Может возникнуть вопрос — а зачем после этого нужно поле kind объекта? Очень просто — чтобы можно было идентифицировать объект по номеру его типа. Для чего? Например, для того, чтобы можно было сохранять игровые объекты в файл (ведь в файле нельзя хранить адрес обработчика "думания" т.к. этот адрес может поменяться при новом запуске программы, номер же не изменится пока мы этого не захочем).

4. Класс объекта, наследование и виртуальные методы

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

Пример:

    class GameObject
    {
        public:
            int x,y;
            int health;
            virtual int GetType() { return -1; }
            virtual void Think() {}
    };


В этом примере нет описания собственно типов объектов, а описывается т.н. _базовый класс_, от которого могут _наследоваться_ классы-потомки.

Примеры:

    class GameObject_Player: public GameObject
    {
        private:
            int score;

        public:
            int GetType() { return kind_Player; }
            void Think()  {}
    };

    class GameObject_Monster: public GameObject
    {
        private:
            int anger;

        public:
            int GetType() { return kind_Monster; }
            void Think()  {}
    };


Чтобы любой объект класса-потомка GameObject "подумал" нужно все лишь написать:

    GameObject *p = ...; //здесь подставляется адрес конкретного объекта
                         //в том числе можно подставить адрес объекта,
                         //класс которого - класс-потомок класса GameObject
    p->Think();          //здесь вызывается метод Think того класса, к
                         //которому пренадлежит объект


Чтобы получить код типа объекта можно написать:

    ... = p->GetType();


Таким образом в С++ уже встроены те средства, которые помогают в создании системы игровых объектов.

Как правило в классе-родителе (таком как GameObject) не должно быть никаких полей данных — только объявление общих для всех объектов _виртуальных_ методов.

Пример:

    class GameObject
    {
        public:
            virtual int GetType() { return -1; }

            virtual void Think  ()                               {}
            virtual void Push   ( float force_x, float force_y ) {}
            virtual void Damage ( float damage )                 {}
            ...
    };


Таким образом, если надо толнуть объект, то достаточно лишь вызвать его метод Push(...), независимо от того, к какому классу-потомку пренадлежит этот объект:

    GameObject *p = GetAnyDerivedClassObject();
    p->Push( force_x, force_y );


Если в классе-потомке метод Push(...) не будет описан, то будет вызван метод класса-родителя т.е. в данном случае пустой метод.

5. Вынос физики за рамки объекта

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

Можно реализовать игровой "движок" так, что объекты не будут "знать" своего точного положения и размеров в том "мире", в котором они находяться. Эта информация будет храниться отдельно от объектов самим "миром". "Мир" будет заведовать физикой объектов т.е. находить какие объекты пересекаются и вызывать соответствующие методы объектов. Если объекту потребуется узнать какую-то физическую информацию, создать другой объект или подвинуть себя — он должен будет вызвать соответствующий метод "мира".

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

6. Продолжение банкета

Про следующий шаг улучшения игрового "движка" я, как и обещал, напишу в следующей статье. А пока — комментарии, дополнения, размышления, критика — are welcome.



Объектная система игры (игровой "движок")
(C) Германов "EyeGem" Евгений, 2003 г.
... << RSDN@Home 1.1 beta 1 >>
Автор: EyeGem    Оценить