Перейти к содержанию

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


Рекомендуемые сообщения

В этой теме не будет вопроса по программированию, а будет просто статья.

Ссылка на оригинал (статья из RDSN)

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

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

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

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

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


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

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

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

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

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

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

а) какие бывают объекты (типы объектов)

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

в) как они получают ввод от игрока (интерфейс системы ввода)

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


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

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

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

Пример:


    int monsterX, monsterY, monsterHealth, monsterAnger;

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

    monsterX = 190; monsterY = 90;
    monsterHealth = 1000;
    monsterAnger = 10;
    int playerX, playerY, playerHealth, playerScore;

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

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

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


    {
        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;
struct player_t

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

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

Пример:


    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;
    }
    const int max_monsters = 10;

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

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


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

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

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

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


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

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

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

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


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

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

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

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

Пример:


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

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

Пример:


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

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


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

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


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

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

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

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

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

Пример:


    {
        public:
            int x,y;
            int health;
            virtual int GetType() { return -1; }
            virtual void Think() {}
    };
    class 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()  {}
    };
    class GameObject_Player: public GameObject

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


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

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

   ... = p->GetType();

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

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

Пример:


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

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

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


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

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

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

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

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

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

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

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


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

© Германов "EyeGem" Евгений, 2003 г.

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

Ссылка на комментарий
Поделиться на другие сайты

Прочитал я статью полностью и не со всем соглашусь.

1. Здесь нужно участие typedef для enum, чтобы kind объявить не как int, а как описанный тип множества.

 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,
};
 

2. В базовом классе нельзя открывать так доступ через public. Следовало сделать через "protect" 

    

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

3.

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

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

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

Ссылка на комментарий
Поделиться на другие сайты

  • 2 недели спустя...

2MasterGH

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

Если интересно, могу предложить вот такой вариант деления на "движки":

класс Game (просто все целиком завернуто, чтобы можно было начать игру каким-нить StarGame("map_name",...), кроме всего прочего, является диспетчером памяти, то есть при выполнении деструктора очищает все, что мы насоздавали где бы то ни было, через убийство объектов подклассов с соотв. кодом)

--{

--класс Math (мат. модель происходящего, в частности, подкласс Object, в котором есть общие параметры для всех объектов - координаты, коллизионность; от которого потом наследуются Player, Cam и др.)

----{

----класс Phys (коллизии объектов, результат возвращается в Math, а он, в свою очередь, выполняет прорисовку в Graph и звуки в Sound)

----класс Graph (отображение, подгрузка графических объектов, рендеринг и т.д.)

----класс Sound

----}

--класс Network и т.д.

--}

Именно в плане иерархии можно, конечно, поспорить.. Но главное, что это работает :)

  • Плюс 1
Ссылка на комментарий
Поделиться на другие сайты

  • 2 месяца спустя...

Случайно наткнулся на примеры исходников CS, которые пытаются восстановить для модов.

Вот интересный пример weapon.cpp:

/***
*
* Copyright (c) 1996-2002, Valve LLC. All rights reserved.
*
* This product contains software technology licensed from Id
* Software, Inc. ("Id Technology"). Id Technology (c) 1996 Id Software, Inc.
* All Rights Reserved.
*
* Use, distribution, and modification of this source code and/or resulting
* object code is restricted to non-commercial enhancements to products from
* Valve LLC. All other use, distribution, or modification is prohibited
* without written permission from Valve LLC.
*
****/


#include "weapons.h"


void CBasePlayerWeapon::FireRemaining( int &iShotsFired, float &flShootTime, BOOL bIsGlock )
{
m_iClip--;

if ( m_iClip < 0 )
{
m_iClip = 0;

iShotsFired = 0;
shootTime = 0.0;

return;
}

float flSpread;
int iPenetration;
int iBulletType;
int iDamage;
float flRandeModifier;
BOOL bFireParam;

unsigned short usEvent;
BOOL bEventParam1;

UTIL_MakeVectors( pPlayer->pev->v_angle + pPlayer->pev->punchangle );

if ( bIsGlock )
{
flSpread = 0.05;
iPenetration = 1;
iBulletType = 1;
iDamage = 18;
flRandeModifier = 0.90;
bFireParam = TRUE;

usEvent = m_usGlock18;
bEventParam1 = m_iClip ? FALSE : TRUE;
}
else
{
flSpread = m_flBurstSpread;
iPenetration = 2;
iBulletType = 12;
iDamage = 30;
flRandeModifier = 0.96;
bFireParam = FALSE;

usEvent = m_usFamas;
bEventParam1 = FALSE;
}

Vector vecDir = pPlayer->FireBullets3( m_pPlayer->GetGunPosition(), gpGlobals->v_forward,
flSpread, 8192.0, iPenetration, iBulletType, iDamage, flRandeModifier, m_pPlayer->pev, 1, m_pPlayer->random_seed );

PLAYBACK_EVENT_FULL( FEV_NOTHOST, m_pPlayer->edict(), usEvent, 0.0, (float *)&g_vecZero, (float *)&g_vecZero,
vecDir.x, vecDir.y, pPlayer->pev->punchangle.x * 10000000, pPlayer->pev->punchangle.y * 10000000, bEventParam1, FALSE );


m_pPlayer->SetAnimation( PLAYER_ATTACK1 );
m_pPlayer->pev->effects |= EF_MUZZLEFLASH;

iShotsFired++;

if ( iShotsFired != 3 )
flShootTime = gpGlobals->time + 0.1;
else
flShootTime = 0.0;
}

Для любознательных советую скачать файлы из SVN. Обратить внимание на структру игры: игрков, оружия. Как добавляется оружие, как добавляются патроны, как создаются игроки и т.п. Хорошие примеры динамически создаваемых объектов, примеры сложных указателей...

Ссылка на комментарий
Поделиться на другие сайты

×
×
  • Создать...

Важная информация

Находясь на нашем сайте, Вы автоматически соглашаетесь соблюдать наши Условия использования.