Поделиться
Поделиться

Недавно съездил с докладом на Casual Connect. Как обычно бывает после подобных встреч, вернулся с массой впечатлений. Час общения с компетентными людьми даёт больше опыта и знаний, чем пол-года сёрфинга в сети. Поэтому в очередной раз советую всем: посещайте такие мероприятия как можно чаще и не пропустите FlashGamm, который пройдёт 4-5 декабря в Киеве (я там тоже буду).

Но сейчас не об этом. Выкладываю свою презентацию с CasualConnect Kiev 2010, а также код и небольшой пример как с ним работать.

[kml_flashembed movie="http://xitri.com/wp-content/uploads/2010/10/cc2010.swf" height="412" width="550" /]

Цель доклада — немного изменить подход к программированию ИИ персонажа. Иными словами — как задать персонажу сложное поведение во время игры, когда он выполняет большой набор действий и принимает новые решения. В качестве «умного» объекта могут быть не только бегающие в игре объекты. Это также может быть «окружающая среда» (например, уровень игры, который напускает на вас противников, когда вам легко играть или подкидывает аптечку, когда у вас мало жизней).

Традиционно алгоритм ИИ задавался через цепочку if…else…if...
Долгое время я и сам только так и делал. На событие ENTER_FRAME вызывалась громадная функция, которая проверяла что сейчас делает персонаж и принимала какие-то решения. Смена действий обычно требовала установки «флагов» (переменные типа Boolean которые в условии разрешают или запрещают что-то делать). Это нормальный способ и он имеет свои плюсы. Например, запрограммировать что-то простое так гораздо быстрее. Но если кода становится много, а поведение персонажа сложным, то вы рискуете запутаться в длинной цепочке условий. По мере роста сложности игры, может наступить момент, когда уже сложно уследить за логикой приложения (особенно если код написан не идеально, а мы-то знаем, что идеально не пишет никто 🙂 ).

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

В этом случае подход следующий: все поведение персонажа разбивается на задачи (в программном понимании задачи — это обычные функции). Например, дровосек из игры Warcraft может: 1)стоять; 2)идти; 3)рубить дрова; 4)защищаться от противников; 5)умирать. Мы должны обеспечить переключение между ними и создать последовательность задач если нужно. На каждую из этих задач нужно написать функцию, которая будет вызываться каждый кадр, пока задача не будет выполнена. Это очень важный момент — фактически это инструмент для нелинейного развития событий. Мы не можем предсказать заранее, сколько времени нужно дровосеку чтобы дойти до дерева и нарубить дров, так как во время ходьбы может измениться рельеф и придётся искать новый путь, противник может нас отвлечь или сама цель может смещаться в пространстве. Поэтому задача-функция просто будет  вызываться каждый кадр пока не выполнится.

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

  • addTask() — добавить задачу в конец очереди
  • addUrgentTask() —  добавить задачу в начало очереди
  • addInstantTask() — добавить задачу, которая выполнится всего 1 раз в конец очереди
  • addUrgentInstantTask() — добавить одноразовую задачу в начало очереди
  • addPause() — добавить паузу в конец очереди (в кадрах)
  • removeAllTasks() — удалить все задачи из очереди

Синтаксис работы с классом TaskManager показан на слайде 6. Каждой задаче можно передавать необходимое количество параметров. Объекты TaskManager  можно и нужно создавать для каждого отдельного юнита в игре. Тогда вы сможете наделить любого вашего персонажа уникальным поведением.

Принцип работы менеджера задач достаточно прост: вы создаёте объект TaskManager и задаёте ему задачи которые нужно выполнить. Менеджер САМ, внутри себя, запустит обработку события ENTER_FRAME и будет каждый кадр вызывать текущую задачу (первая в очереди) до тех пор, пока задача-функция не вернёт true.  Как только вернула истину, удаляем задачу из очереди и переходим к следующей задаче, и так пока не выполнится вся очередь. Это стековый режим работы.

Существует ещё циклический режим работы. Есть 2 способа его включить: создать объект TaskManager с параметром true при старте либо изменить в любое время его свойство cycle, соответственно на true. В этом режиме отработанные задачи не удаляются, а переносятся в конец очереди. Это очень удобно, если противник должен постоянно слоняться по карте или патрулировать местность, или дровосек должен всё время бегать за дровами пока не закончится лес… Тут всё просто, если свойство cycle==true, то задача переносится в конец очереди, иначе — удаляется.

На 9-м слайде характерный пример циклического режима. Зомби выбирает случайную точку на карте и идёт к ней пока не приблизится на расстояние, меньшее чем его скорость (всего одна задача). Потом он ждёт одну секунду — я реализовал это через добавление паузы только чтобы показать как она работает, но лучше для ожидания создать отдельную задачу, которая будет проверять, нет ли рядом чего вкусненького. Попутно отрабатываются мгновенные задачи (instantTask), которые переводят графику в нужное состояние.

Очень часто бывает полезно изменить свойство в нужный момент времени. Я не стал добавлять отдельных методов для этого, так как считаю что способ показанный на 11-м слайде решает эту проблему и позволяет при этом производить необходимые проверки. На 12-м слайде видно как это работает — скорость дровосека меняется когда он несёт груз.

С этим как бы всё ясно, но это ещё не всё. Настоящий потенциал класса TaskManager раскрывается когда мы добавляем задачи на лету, то есть когда события в игре меняются. В любое время мы можем добавить задачу в начало или  в конец (как одноразовую, так и исполняемую каждый кадр). Более того, если у нас циклический режим, мы можем добавлять задачи, которые выполнятся всего один раз (необязательный параметр ignoreCycle:Boolean). Принцип действия на 15-м слайде — как только зомби встречает дровосека, он добавляет себе в начало очереди новую задачу «Атаковать». Теперь эта задача будет выполняться пока дровосек не отойдёт в мир иной и тогда зомби продолжит свой маршрут.

Тут важно понять, кто и как принимает решение выполнять новые действия. Это делают сами задачи! Например, задача_идти_в_случайную_точку кроме того что смещает персонажа, ещё и выполняет проверку на близость дровосека. И как только он оказывается рядом, добавляет атакующее действие. То есть расширяем немного сознание: задачи-функции не просто выполняют какое-то действие, но и проверяют, не нужно ли добавить новых задач в очередь.

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

Ещё раз скажу, что для запуска менеджера ничего делать не нужно. Он сам запускается как только вы добавили ему задачу (но выполняться начнёт только со следующего кадра). Если нужно его приостановить, можно использовать свойство pause (загонять в него true или false).  TaskManager остановится сам когда выполнит все задачи или если вызвать метод removeAllTasks(). Ещё можно вручную перейти к следующей задачи не дожидаясь её выполнения — метод nextTask() — но это так, на всякий случай 🙂

Теперь где и как хранить задачи? У меня есть 2 варианта:

  1.  Мы выстраиваем грамотную цепочку наследования для классов юнитов, например: BaseUnit -> StaticUnit -> DynamicUnit -> Hero или Enemy(дальше идут параллельно). Все задачи-функции тут грамотно распределены внутри этих классов — соответственно идти, стоять — будет в DynamicUnit, а рубить дрова в Hero. Общие для юнитов задачи выносим в общие для них классы.
  2. Хранить функции-задачи во внешних классах как статические, но тогда нужно в качестве параметра передавать и ссылку на сам объект которым можно управлять. Такие классы легко переносить из проекта в проект, но методы должны знать что умеет делать юнит.

Всё.

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

Сам код класса и небольшой пример патрулирования местности в архиве ниже:

TaskManagerExample

Об авторе

Денис Романко (Stormit)Занимаюсь дизайном, программированием, спецэффектами и разработкой игр.

НазадНазад
  • Alex Shiganov

    Можно ли увидеть ссылку на архив, а то там [download=6] ?

    • Stormit

      Поправил ссылку