пятница, 6 января 2012 г.

Готовим стратегию в реальном времени (RTS) в домашних условиях

Эх, давно хотел попробовать написать какую-нибудь игру. К тому же уже был опыт работы с 3D графикой, как с DirectX, так и с OpenGL, но всё некогда, некогда. Тогда я решил воспользоваться одним из готовых 3D движков, и поковырять его. Что из этого вышло...

В общем, на хабре как то проскочила статья о отечественном движке NeoAxis. Давно его скачал, но вот только руки дошли поглядеть, что внутри.
Итак, движок, насколько я помню, основан на Ogre 3D, работа с графикой написана на неуправляемом C++, вокруг которого наворотили обёрток и дали нам. простым смертным, писать под него логику на любом .NET языке. Имеет несколько вариантов лицензий, в том числе и некоммерческую, которые отличаются ценой и возможностями. Также на сайте полно всяких руководств, документаций, туториалов, статей, как на английском, так и на русском языках. Скачать можно и хелпник, и SDK, и демки и даже их исходный код (сам я пробую некоммерческую лицензию). Вот я и начну с исходного кода демо проекта (где уже включена RTS), который буду чуть чуть модифицировать, затачивая под свои нужды. Оговорюсь сразу - я не делаю полноценную стратегию, я просто хочу оценить насколько это затратно и реально ли вообще.

Как известно, RTS состоит из нескольких компонентов:
1. Карта. Для этого разработчики движка заботливо приложили к SDK специальное приложение - редактор карт. Он позволяет работать с объектами карты, с персонажами и даже запускать симуляцию карты (то есть как она будет работать в игре). Также в редактор карт встроен редактор логики - то есть можно скриптовать различные сценарии не отходя от станка, так сказать.
2. Ресурсы.
К ресурсам относятся следующие типы игровых объектов:
  • трехмерные модели,
  • физические модели,
  • материалы,
  • текстуры,
  • интерфейсы,
  • системы частиц,
  • описания шрифтов,
  • звуки,
  • видео
Чтобы там не потеряться, в комплекте идет редактор ресурсов. В общем, я поработал над дизайном главного окна...
3. Конфиг. Тут можно указывать настройки движка - рендера, звука и тд. Я вынес это как отдельный пункт, так как в поставке есть программа-конфигуратор.
4. Дополнительные инструменты
5. Ну, и, собственно, код

Для ясности, приведу весь список инструментов, что идут с SDK
  • ResourceEditorIcon.jpg Редактор ресурсов - Редактор ресурсов предназначен для редактирования ресурсов проекта. Сюда входят, главным образом, трехмерные модели, карты, материалы, текстуры, звуки и прочее.
  • MapEditorIcon.jpg Редактор карт - Редактор карт предназначен для создания и редактирования игровых карт.
  • ConfiguratorIcon.jpg Конфигуратор - Утилита для настройки параметров движка.
  • DeploymentToolIcon.jpg Deployment Tool - Инструмент для подготовки конечного продукта.
  • UtilitiesIcon.jpg Компилятор шейдеров - Компилятор кэша шейдеров.

Теперь к коду.
Сразу в солюшен включены несколько проектов:
  • GameCommon - Различные классы проекта, такие как, описание типов материалов, сетевые сервисы проекта, класс консоли движка, пользовательские гуи классы.
  • GameEntities - Описание игровых классов и всей логики игры.
  • Game - Точка входа приложения. Инициализация движка, классы для реализации структуры проекта, навигации игровых экранов и взаимодействия с пользователем.
  • ChatExample - Пример реализации сетевого чата на базе Windows Forms.
  • DedicatedServer - Приложение для создания выделенного сервера.
  • WinFormsAppExample - Пример интеграции движка в Windows Forms приложение.
  • WPFAppExample - Пример интеграции движка в WPF приложение.
Я оставил только первые 3, остальные за ненадобностью удалил. Далее мне надо было найти меню игры (ведь я менял ресурсы менюшек, а значит при запуске приложения те элементы, что я изменил или удалил не найдутся и приложение просто может отвалиться). Сделать это было несложно, класс MainMenuWindow лежит прямо в корне проекта. Заглянув в него, я увидел, как находятся кнопки окон и как вешаются на них обработчики. Мне оставалось только убрать ненужное и добавить то, что меня интересовало.

  1. //button handlers - ненужное просто закаментил
  2. //( (Button)window.Controls[ "Run" ] ).Click += Run_Click;
  3. //( (Button)window.Controls[ "RunVillageDemo" ] ).Click += RunVillageDemo_Click;
  4. //( (Button)window.Controls[ "Multiplayer" ] ).Click += Multiplayer_Click;
  5. //( (Button)window.Controls[ "Maps" ] ).Click += Maps_Click;
  6. //( (Button)window.Controls[ "LoadSave" ] ).Click += LoadSave_Click;
  7. ( (Button)window.Controls[ "Options" ] ).Click += Options_Click;
  8. //( (Button)window.Controls[ "Profiler" ] ).Click += Profiler_Click;
  9. //( (Button)window.Controls[ "GuiTest" ] ).Click += GuiTest_Click;
  10. //( (Button)window.Controls[ "About" ] ).Click += About_Click;
  11. ( (Button)window.Controls[ "Exit" ] ).Click += Exit_Click;
  12.  
  13. // Добавляю обработчик на свою кнопку, которая грузит нужную мне карту
  14. ((Button) window.Controls["Play"]).Click += delegate(Button sender)
  15.                                                 {
  16.                                                     var file = VirtualDirectory.GetFiles(@"Maps\RTSDemo\", "Map.map", SearchOption.AllDirectories)[0];
  17.                                                     GameEngineApp.Instance.SetNeedMapLoad(file);
  18.                                                 };
Отлично. Теперь при запуске игры, она покажет отредактированное окно и кнопка "Играть" запустит нужную мне карту.
Итак, на данный момент у нас есть карта с юнитами, есть главное меню, не хватает только AI. Не, ну, конечно, сейчас враги будут в нас стрелять и наши войска будут стрелять в них и кто то, очевидно, погибнет, но хочется как то этим процессом управлять. Для этого заглянем в проект GameEntities, а именно в класс RTSUnitAI.

  1. /// <summary>
  2. /// Defines the <see cref="RTSUnitAI"/> entity type.
  3. /// </summary>
  4. public class RTSUnitAIType : AIType
  5. {
  6. }
  7.  
  8. public class RTSUnitAI : AI
  9. {
  10.     ................
  11. }
Насколько я понял, первый класс используется в редакторе ресурсов. А вот во втором творится самое чудесное - логика AI. Там довольно много кода, потому я решил написать класс логики персонажей с нуля, конечно, подглядывая в этот класс. Итак, начнем!
Сперва сделаем пустое AI

  1. namespace GameEntities
  2. {
  3.     public class RTSUnitMegaAIType : AIType
  4.     {
  5.     }
  6.  
  7.     public class RTSUnitMegaAI: AI
  8.     {
  9.         RTSUnitMegaAIType _type = null; public new RTSUnitMegaAIType Type { get { return _type; } }
  10.     }
  11. }

После этого, в папке \Data\Types\RTSSpecific\AIs найдем файл DefaultRTSUnitAI.type, вот его содержимое
  1. type DefaultRTSUnitAI
  2. {
  3.     class = RTSUnitAI
  4. }
Заменим на
  1. type DefaultRTSUnitAI
  2. {
  3. class = RTSUnitMegaAI
  4. }

Запустив игру, можно заметить, что юниты теперь вообще ни на что не реагируют. Это понятно, ведь AI у них сейчас пустой! А давайте мотивируем их на что нибудь! :)
Самое главное, это перегрузить метод
  1. protected override void OnTick()
тут варится вся логика нашего персонажа. Но перед этим нужно провести подготовительные работы. Так как я впервые работаю с этим движком, я буду потихоньку копировать логику из AI по умолчанию и, где надо, её затачивать для себя.
Итак, самое первое - это инициализация вооружения персонажа. Оружие используется как минимум для того, чтобы определить, может ли персонаж стрелять, и если может, то из какой точки выпускать анимированную пулю. Далее. нужно определиться с задачей, которую он выполняет - если текущая задача есть, то пусть действует по ней. Если задачи закончились - пусть ищет себе занятие сам.

  1. /// <summary>
  2. /// Самый мозг юнита
  3. /// </summary>
  4. protected override void OnTick()
  5. {
  6.     base.OnTick();
  7.     // обновляем вооружение
  8.     if (_initialWeapons == null)
  9.         UpdateInitialWeapons();
  10.  
  11.     // Выполняем шаг для текущей заачи
  12.     TickTasks();
  13.  
  14.     // Если задачи заканчиваются, ищем новую задачу
  15.     if ((_currentTask.Type == Task.Types.Stop ||
  16.         _currentTask.Type == Task.Types.BreakableMove ||
  17.         _currentTask.Type == Task.Types.BreakableAttack ||
  18.         _currentTask.Type == Task.Types.BreakableRepair
  19.         ) && _tasks.Count == 0)
  20.     {
  21.         _inactiveFindTaskTimer -= TickDelta;
  22.         if (_inactiveFindTaskTimer <= 0)
  23.         {
  24.             _inactiveFindTaskTimer += 1.0f;
  25.             if (!InactiveFindTask())
  26.                 _inactiveFindTaskTimer += .5f;
  27.         }
  28.     }
  29. }

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


  1. /// <summary>
  2. /// Выполнение задачи
  3. /// </summary>
  4. protected virtual void TickTasks()
  5. {
  6.     // Наш юнит
  7.     var controlledObj = ControlledObject;
  8.     if (controlledObj == null)
  9.         return;
  10.  
  11.     switch (_currentTask.Type)
  12.     {
  13.  
  14.         // остановить юнит
  15.         case Task.Types.Stop:
  16.             controlledObj.Stop();
  17.             break;
  18.  
  19.         // движение
  20.         case Task.Types.Move:
  21.         case Task.Types.BreakableMove:
  22.             if (_currentTask.Entity != null)
  23.             {
  24.                 controlledObj.Move(_currentTask.Entity.Position);
  25.             }
  26.             else
  27.             {
  28.                 var pos = _currentTask.Position;
  29.  
  30.                 if ((controlledObj.Position.ToVec2() - pos.ToVec2()).LengthFast() < 1.5f &&
  31.                     Math.Abs(controlledObj.Position.Z - pos.Z) < 3.0f)
  32.                 {
  33.                     DoNextTask();
  34.                 }
  35.                 else
  36.                     controlledObj.Move(pos);
  37.             }
  38.             break;
  39.  
  40.         // Атакуем или чиним
  41.         case Task.Types.Attack:
  42.         case Task.Types.BreakableAttack:
  43.         case Task.Types.Repair:
  44.         case Task.Types.BreakableRepair:
  45.             {
  46.                 // ремонт
  47.                 if ((_currentTask.Type == Task.Types.Repair ||
  48.                     _currentTask.Type == Task.Types.BreakableRepair)
  49.                     && _currentTask.Entity != null)
  50.                 {
  51.                     // проверяем, если жизней у робота достаточно, то прекращаем его чинить
  52.                     if (Math.Abs(_currentTask.Entity.Life - _currentTask.Entity.Type.LifeMax) < 0.000001)
  53.                     {
  54.                         DoNextTask();
  55.                         break;
  56.                     }
  57.                 }
  58.  
  59.                 // Оптимальное расстояние для возможности ремонта
  60.                 var needDistance = controlledObj.Type.OptimalAttackDistanceRange.Maximum;
  61.  
  62.                 // позиция юнита, которого чиним
  63.                 Vec3 targetPos;
  64.                 if (_currentTask.Entity != null)
  65.                     targetPos = _currentTask.Entity.Position;
  66.                 else
  67.                     targetPos = _currentTask.Position;
  68.  
  69.                 // расстояние между нашим юнитом и целью
  70.                 var distance = (controlledObj.Position - targetPos).LengthFast();
  71.  
  72.                 // если это расстояние не равно 0
  73.                 if (Math.Abs(distance - 0) > 0.000001)
  74.                 {
  75.                     var lineVisibility = false;
  76.                     
  77.                     // если луч для лечения может достать до цели
  78.                     if (distance < needDistance)
  79.                     {
  80.                         lineVisibility = true;
  81.  
  82.                         
  83.                         var start = _initialWeapons[0].Position;
  84.                         var ray = new Ray(start, targetPos - start);
  85.  
  86.                         var piercingResult = PhysicsWorld.Instance.RayCastPiercing(
  87.                             ray, (int)ContactGroup.CastOnlyContact);
  88.  
  89.                         foreach (var result in piercingResult)
  90.                         {
  91.                             var obj = MapSystemWorld.GetMapObjectByBody(result.Shape.Body);
  92.  
  93.                             if (obj != null && obj == _currentTask.Entity)
  94.                                 break;
  95.  
  96.                             if (obj != controlledObj)
  97.                             {
  98.                                 lineVisibility = false;
  99.                                 break;
  100.                             }
  101.                         }
  102.                     }
  103.  
  104.  
  105.                     // если цель не в прямой видимости, то нужно повернуться к ней, или двигаться в её сторону
  106.                     if (lineVisibility)
  107.                     {
  108.                         controlledObj.Stop();
  109.  
  110.                         var character = controlledObj as RTSCharacter;
  111.                         if (character != null)
  112.                             character.SetLookDirection(targetPos);
  113.                     }
  114.                     else
  115.                     {
  116.                         controlledObj.Move(targetPos);
  117.                     }
  118.  
  119.                     // тут проверяем, можем ли задействовать оружие. Если можем - то стреляем
  120.                     if (lineVisibility)
  121.                     {
  122.                         foreach (Weapon weapon in _initialWeapons)
  123.                         {
  124.                             var pos = targetPos;
  125.                             var gun = weapon as Gun;
  126.                             if (gun != null && _currentTask.Entity != null)
  127.                                 gun.GetAdvanceAttackTargetPosition(false, _currentTask.Entity, false, out pos);
  128.                             weapon.SetForceFireRotationLookTo(pos);
  129.  
  130.                             if (weapon.Ready)
  131.                             {
  132.                                 var range = weapon.Type.WeaponNormalMode.UseDistanceRange;
  133.                                 if (distance >= range.Minimum && distance <= range.Maximum)
  134.                                     weapon.TryFire(false);
  135.  
  136.                                 range = weapon.Type.WeaponAlternativeMode.UseDistanceRange;
  137.                                 if (distance >= range.Minimum && distance <= range.Maximum)
  138.                                     weapon.TryFire(true);
  139.                             }
  140.                         }
  141.                     }
  142.                 }
  143.  
  144.             }
  145.             break;
  146.     }
  147. }
Что касается структуры Task - её мы тоже определили в коде

  1. public struct Task
  2. {
  3.     [Entity.FieldSerializeAttribute]
  4.     [DefaultValue(RTSUnitAI.Task.Types.None)]
  5.     Types type;
  6.  
  7.     [Entity.FieldSerializeAttribute]
  8.     [DefaultValue(typeof(Vec3), "0 0 0")]
  9.     Vec3 position;
  10.  
  11.     [Entity.FieldSerializeAttribute]
  12.     DynamicType entityType;
  13.  
  14.     [Entity.FieldSerializeAttribute]
  15.     Dynamic entity;
  16.  
  17.     public enum Types
  18.     {
  19.         None,
  20.         Stop,
  21.         BreakableAttack,//for automatic attacks
  22.         Hold,
  23.         Move,
  24.         BreakableMove,//for automatic attacks
  25.         Attack,
  26.         Repair,
  27.         BreakableRepair,//for automatic repair
  28.         BuildBuilding,
  29.         ProductUnit,
  30.         SelfDestroy,//for cancel build building
  31.     }
  32.  
  33.     public Task(Types type)
  34.     {
  35.         this.type = type;
  36.         this.position = new Vec3(float.NaN, float.NaN, float.NaN);
  37.         this.entityType = null;
  38.         this.entity = null;
  39.     }
  40.  
  41.     public Task(Types type, Vec3 position)
  42.     {
  43.         this.type = type;
  44.         this.position = position;
  45.         this.entityType = null;
  46.         this.entity = null;
  47.     }
  48.  
  49.     public Task(Types type, DynamicType entityType)
  50.     {
  51.         this.type = type;
  52.         this.position = new Vec3(float.NaN, float.NaN, float.NaN);
  53.         this.entityType = entityType;
  54.         this.entity = null;
  55.     }
  56.  
  57.     public Task(Types type, Vec3 position, DynamicType entityType)
  58.     {
  59.         this.type = type;
  60.         this.position = position;
  61.         this.entityType = entityType;
  62.         this.entity = null;
  63.     }
  64.  
  65.     public Task(Types type, Dynamic entity)
  66.     {
  67.         this.type = type;
  68.         this.position = new Vec3(float.NaN, float.NaN, float.NaN);
  69.         this.entityType = null;
  70.         this.entity = entity;
  71.     }
  72.  
  73.     public Types Type
  74.     {
  75.         get { return type; }
  76.     }
  77.  
  78.     public Vec3 Position
  79.     {
  80.         get { return position; }
  81.     }
  82.  
  83.     public DynamicType EntityType
  84.     {
  85.         get { return entityType; }
  86.     }
  87.  
  88.     public Dynamic Entity
  89.     {
  90.         get { return entity; }
  91.     }
  92.  
  93.     public override string ToString()
  94.     {
  95.         string s = type.ToString();
  96.         if (!float.IsNaN(position.X))
  97.             s += ", Position: " + position.ToString();
  98.         if (entityType != null)
  99.             s += ", EntityType: " + entityType.Name;
  100.         if (entity != null)
  101.             s += ", Entity: " + entity.ToString();
  102.         return s;
  103.     }
  104. }

Теперь можно поработать и с AI. Например, у нас есть функция, которая определяет приоритет для атаки. Немного её модифицировав, можно повысить приоритет атаки робота-медика. То есть юниты будут атаковать сначала его, а потом военного робота.


  1. /// <summary>
  2. /// Устанавливает приоритет для атаки
  3. /// </summary>
  4. /// <param name="obj"></param>
  5. /// <returns></returns>
  6. protected float GetAttackObjectPriority(Unit obj)
  7. {
  8.     if (ControlledObject == obj)
  9.         return 0;
  10.  
  11.     if (obj.Intellect == null)
  12.         return 0;
  13.  
  14.     //RTSConstructor specific
  15.     if (ControlledObject.Type.Name == "RTSConstructor")
  16.     {
  17.         if (Faction == obj.Intellect.Faction)
  18.         {
  19.             if (obj.Life < obj.Type.LifeMax)
  20.             {
  21.                 Vec3 distance = obj.Position - ControlledObject.Position;
  22.                 float len = distance.LengthFast();
  23.                 return 1.0f / len + 1.0f;
  24.             }
  25.         }
  26.     }
  27.     else
  28.     {
  29.         if (Faction != null && obj.Intellect.Faction != null && Faction != obj.Intellect.Faction)
  30.         {
  31.             var distance = obj.Position - ControlledObject.Position;
  32.             var len = distance.LengthFast();
  33.             var result = 1.0f / len + 1.0f;
  34.             // повышаем приоритет атаки на робота-медика
  35.             if (obj.Type.Name == "RTSConstructor") result = 1.1f/ len + 1.0f;
  36.             return result;
  37.         }
  38.     }
  39.  
  40.     return 0;
  41. }

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



Итак, имея готовый движок и демки, можно легко дорабатывать игру под себя. Ценители графики могут добавлять различные эффекты, реалистичные модели, анимации и много всего; искусственный интеллект тоже поддаётся изменению, так и вообще полной замене, а использование технологии .NET сильно упрощает работу. В заключение также хочется отметить, что трудозатраты на создание игры в данном случае были практически нулевые - 2 часа на изучение и кодинг и час на написание статьи. Это говорит о низком пороге вхождения в технологию. На этом всё. Всем спасибо.

5 комментариев:

  1. Статья понравилась! Я тоже всегда хотел стратегию написать))) Все время слежу за проектом springrts.com - опенсорс стратегия.

    И эта статья мне понравилась тем, что показывает, какие примерно есть тулзы (редакторы карт, материалов...) и из каких компонентов состоит стратегическая игра. Всегда полезно иметь взгляд на всё это свысока.

    ОтветитьУдалить
  2. Выложите, пожалуйста, ссылку на исходник.

    ОтветитьУдалить
    Ответы
    1. Все исходники, что я писал, тут, в статье :) Чтобы хоть что то скомпилировать, Вам нужно скачать движок.

      Удалить
    2. * купить =)

      Удалить