Первоначально это руководство было опубликовано в Блоге разработчиков Namespace studio 21 апреля 2016 г.
Проект по поиску пути A* довольно мощный и позволяет использовать несколько сеток из коробки, что весьма удобно, учитывая требования нашей игры. Однако я не нашел много полезных ресурсов для использования графов сетки так, как мы предполагали. Что мы хотели сделать, так это перемещать наш ИИ с графика на график по мере необходимости, без перекрытия графиков. По сути, мы хотели, чтобы наш ИИ шел к месту назначения, даже если ИИ и место назначения — это два разных графа. В итоге мы получили способ сделать именно это, который я задокументировал здесь.
Во-первых, наша цель — заставить ИИ переместиться из начальной позиции в целевую, даже если эти две позиции находятся на разных графиках. Давайте начнем с небольшого домашнего хозяйства. Обязательно импортируйте пакет проекта A* pathfinding в свой проект и создайте новую сцену.
Настройка сцены
Как видите, я разделил уровень на четыре квадранта, причем первый квадрант (серый) расположен в (5,0,5), так что его нижний левый угол находится в начале координат. Это будет важно позже. Кроме того, я разбросал несколько ящиков, чтобы ИИ мог с ними справиться. Я также создал пустой игровой объект и связал с ним все объекты уровня. Это просто для того, чтобы упростить навигацию по сцене, и это совершенно необязательно.
Создание искателя
Далее нужно сделать ИИ для передвижения. Начните с создания объекта-капсулы в сцене, назовите его Искатель и добавьте к нему компонент Pathfinding › Seeker из меню добавления компонентов. Кроме того, создайте файл AstarAI.cs в инспекторе ресурсов. Поскольку мы сосредоточены на том, как перемещаться от сетки к сетке, а не на тонкостях поиска пути, я собираюсь использовать код движения начало работы вместо чего-то необычного. код взят отсюда: Как настроить проект A* Pathfinding в среде 2D Unity
Сейчас мы просто скопируем и вставим этот код в наш файл AstarAI.cs, а затем добавим скрипт в наш объект поиска. После добавления сценария создайте пустой игровой объект под названием цель и назначьте его целевому полю AstarAI.
Создание путей
Теперь, когда у нас есть настройка искателя, давайте создадим несколько путей, по которым он будет следовать! Если мы попытаемся запустить программу сейчас, то получим ошибку «В сцене нет графиков». Чтобы дать ищущему что-то для подражания, мы должны сначала создать пустой игровой объект, а затем добавить к нему скрипт Astar Path. Этот скрипт дает нам набор настроек для создания графиков сетки, и вы уже должны увидеть один из них в сцене с центром на нашем пустом объекте.
Настройте первую диаграмму сетки
Давайте разберемся с графиком. В сценарии Astar Path нажмите «Добавить новый график», а затем «Сетчатый график», затем щелкните имя полученного графика, чтобы изменить его с сетки на «График1». Оставьте ширину, глубину, размер узла и соотношение сторон по умолчанию, и у нас должен быть график, способный идеально покрыть один из наших квадрантов. Итак, первый шаг — убедиться, что центр графика совпадает с центром плоскости, составляющей первый квадрант. В данном случае мы установили центр на (5, 1, 5), чтобы наш скрипт AstarAI удерживал капсулу над землей. Единственная другая вещь, которую мы изменяем, — это настройки столкновения, которые мы изменяем, сняв флажок с логического значения проверки высоты и установив маску для проверки столкновения на все.
Огры похожи на луковицы (использование слоев для правильного сканирования)
Если вы нажмете «Сканировать» в нижней части сценария Astar Path, сценарий сгенерирует для вас новый график сетки, указав недоступные узлы красными кубами и показывая соединения доступных узлов. Сразу же вы заметите, что капсула искателя пометила окружающие узлы как непроходимые. Чтобы исправить это, создайте новый слой под названием «AI» и назначьте ему капсулу самонаведения. Затем вернитесь и снимите флажок со слоя AI в раскрывающемся списке маски столкновений графа сетки. Теперь после сканирования капсула Искателя не должна влиять на график!
Быстрый тест
Если мы все сделали правильно, размещение цели на том же графике сетки, что и ИИ, приведет к тому, что капсула «Искатель» будет двигаться к цели, когда мы запустим программу. (Обратите внимание, что я изменил расстояние до следующей путевой точки AstarAI на 0,5, чтобы он приблизился к цели до завершения пути).
Добавление других графиков
Теперь, когда у нас это работает, давайте настроим другие графики! Эти графики настраиваются так же, как и первый, с изменением только центральной точки. Здесь вы можете увидеть, какие графики я отнес к каким квадрантам. Крайне важно помнить, какие графики относятся к каким квадрантам, хотя вам не обязательно подражать моей схеме. Если вы делаете другую настройку, убедитесь, что вы отслеживаете, какой график относится к какому квадранту.
Попытка перемещения между графиками
Как видите, мы никогда не заставляем нашего искателя двигаться, потому что путь, который мы запрашиваем, возвращается с ошибкой. Прямо сейчас мы ограничены тем, что цель находится в той же сетке, что и наш искатель.
Контрольная точка
Так что это все хорошо, но мы, по сути, только что сделали краткое руководство с частями уровня, к которым мы не можем получить доступ. Разве мы не должны были заставить ИИ перемещаться от сетки к сетке? Чтобы выяснить, как заставить ИИ передавать сетки, давайте поэкспериментируем только с graph1. В частности, я хочу знать, что происходит, когда наша цель или наш Искатель не находятся в сетке.
Эксперимент 1 — Цель вне сетки
Когда мы просим искателя перейти к пункту назначения, находящемуся за пределами графа сетки, кажется, что он находит ближайший узел, который может, и направляется туда, а затем останавливается.
Эксперимент 2 – Искатель вне сети
Когда сам искатель находится вне сетки, мы можем видеть, что он перемещается к ближайшему к нему узлу, а затем продолжает движение к цели.
Метод безумия
Цель этих экспериментов состояла в том, чтобы показать, как мы собираемся заставить передачу от сети к сети работать. По сути, мы хотим, чтобы искатель двигался как можно дальше по сетке, с которой он начинает. Когда он доходит до края своего графика, мы хотим создать новый путь, используя сетку, рядом с которой находится наш искатель. Поскольку искатель будет двигаться к ближайшему возможному узлу на своем графике, даже если цель находится вне сетки (или в другой сетке), нам не нужно выполнять какие-либо специальные расчеты путевых точек. Точно так же, поскольку Seeker будет двигаться к цели, даже если она находится за пределами сетки, нам также не нужны какие-либо специальные расчеты путевых точек для нового пути. Все, что нам нужно выяснить, это то, на какой граф сетки мы должны смотреть. Оказывается, есть еще одна реализация команды seeker.startPath, принимающая четвертый аргумент. Этот аргумент представляет собой битовую маску, которая сообщает поисковику, какие графы следует проверять. Идеально!
Еще немного о битовых масках
Как мы видели ранее, графы-сетки в проекте поиска пути A* недостаточно умны, чтобы соединить точки сами по себе, поэтому мы должны сообщить поисковику, какой именно график мы хотим посмотреть. Мы делаем это с помощью битовых масок. Существует множество руководств по битовым маскам, поэтому я просто дам краткое объяснение, используя только те части, которые нам нужны. Битовые маски представляют собой последовательность битов, используемых для выбора, в данном случае, различных графов. Представьте себе ряд переключателей, где каждый переключатель либо 0 (выключен), либо 1 (включен). Каждому переключателю соответствует определенный граф. Итак, поскольку у нас есть 4 графа, наша битовая маска выглядит так: 0000, где крайний правый 0 — это переключатель для графа 1, второй справа — граф 2 и так далее. Если я хочу сказать «смотрите только на графики 1 и 3 и игнорируйте остальные, то я бы создал битовую маску: 0101. Однако не существует типа битовой маски с единицей. Вместо этого вы заметите, что аргумент битовой маски в функции startPath имеет тип int! Это связано с тем, что С# берет целое число и при необходимости превращает его в двоичную строку. Таким образом, вместо того, чтобы писать 0101, мы просто ввели «5» в параметр, чтобы получить тот же эффект. Теперь это немного беспорядочно, поэтому вместо этого мы будем использовать побитовые операции. Выражение 1 ‹‹ 2 берет число 1 (двоичное число 0001) и сдвигает его влево на 2 пробела, в результате чего получается двоичное число 0100. Другими словами, 1 ‹‹ 2 = 4.
Маска графика
Проект поиска пути хранит массив всех графиков в сцене. Чтобы сообщить проекту поиска пути, что нам нужен первый граф в его массиве, мы должны использовать операцию 1 ‹‹ 0, чтобы получить битовую маску для этого графа. Чтобы найти только второй граф, мы использовали бы операцию 1 ‹‹ 1 и так далее. Итак, если мы отследим, в каком порядке были созданы наши графы, мы можем написать простую функцию для получения битовой маски для графа, учитывая битовое смещение для этого графа.
public int GetGraphBitmask(int bitOffset) {return 1 ‹‹ bitOffset; }
Поиск маски графика на лету
Итак, это отличный способ получить нужную нам битовую маску, но как насчет смещения графа? Что ж, нам нужно где-то его сохранить, чтобы мы могли передать его функции позже. Кроме того, нам нужен способ определить, на каком графике находится наш искатель. Самый простой способ сделать это — сохранить битовое смещение для графа в словаре с позицией этого графа в качестве ключа. Однако нам также нужен способ связать положение ищущего и граф, в котором он находится. Вместо того, чтобы просматривать весь массив графов, находить центр каждого и находить, какой из них ближе всего к ищущему (хотя это сработает, но будет медленно) позволяет вместо этого абстрагировать игровой мир в сетку сверху вниз. Чтобы это работало, у нас есть несколько требований. Каждый граф сетки должен быть одинакового размера и квадратным, чтобы в итоге мы получили идеально квадратную сетку, где каждая ячейка имеет размер графа сетки. Это необходимо, потому что нам нужно иметь возможность легко преобразовывать положение объекта в мировых координатах в координаты ячейки. Если мы можем легко преобразовать положение объектов в координаты ячейки, то мы можем использовать координаты ячейки в качестве ключей для нашего словаря bitOffset и, таким образом, легко выбрать, на какой график искатель должен смотреть в любой заданной позиции. Давайте также для простоты потребуем, чтобы ячейки начинались слева внизу. Для нас это означает, что объект находится в ячейке (0,0), если этот объект имеет x и z от (0,0), вплоть до x и z (10,10). Если объект имеет x и z из (11, 5), то этот объект находится в ячейке (1,0) и так далее. в основном, мы делим мир на 10 x 10 ячеек вдоль плоскости x/z. Чтобы получить положение объекта в координатах ячейки, все, что нам нужно сделать, это взять его положение, разделить на 10, а затем округлить в меньшую сторону (или привести к целому числу).
Менеджер уровней
Это все немного связано только с AstarAI, поэтому мы собираемся создать сценарий менеджера уровней, чтобы содержать логику для поиска координат ячейки объекта, а также словарь, содержащий смещения для графиков, или другими словами, словарь, который сообщает нам, какой граф относится к какой ячейке. Создайте скрипт LevelManager.cs и прикрепите его к новому пустому игровому объекту в сцене. Затем откройте скрипт в monodevelop, чтобы мы могли получить скрипт!
Единственный менеджер уровней
Поскольку менеджер уровней — уникальный объект, мы действительно хотим, чтобы в любой момент времени был только один из них. Для этого добавим следующие строки:
публичный статический экземпляр LevelManager; void Awake() { if (instance == null) { DontDestroyOnLoad(gameObject); экземпляр = это; Debug.Log("Экземпляр" + экземпляр); } else if (instance != this) { Destroy(gameObject); } }
этот код гарантирует, что одновременно существует только один менеджер уровней и что мы можем получить доступ к этому экземпляру из любого места с помощью LevelManager.instance.
Сейчас самое время добавить несколько полей для хранения размера уровня и словаря bitOffsets, поэтому добавьте следующие строки над функцией Awake():
публичный float levelSize; public Dictionary‹ CellCoord, int› graphMaskOffsets = new Dictionary‹CellCoord, int›();
Поиск координат ячеек
Теперь вы можете заметить, что CellCoord не существует. Мне показалось полезным создать новую структуру для хранения координат x и z ячеек. Давайте исправим эти ошибки, создав тип данных CellCoord. Я собираюсь сделать это прямо в файле менеджера уровней, просто потому, что мне не хочется создавать дополнительный файл только для того, чтобы содержать такое маленькое определение структуры. Введите следующее над определением класса LevelManager:
[Сериализуемый] public struct CellCoord { public int x; общественный интервал г; public CellCoord (int x, int z) { this.x = x; это.г = г; } }
Атрибут serializable предназначен для того, чтобы этот тип данных можно было редактировать в редакторе единства.
Поиск координат ячейки (продолжение)
Поскольку теперь у нас есть тип данных для представления координат ячейки, давайте создадим функцию для преобразования Vector3 в объект координат ячейки. Как упоминалось ранее, это просто вопрос деления соответствующей координаты положения на размер нашей ячейки, а затем приведения к целому числу (что отрезает любые надоедливые десятичные знаки в конце). Добавьте следующий код под функцией Awake():
public static CellCoord GetCellCoord (позиция Vector3) { int x = (int) (position.x / instance.levelSize); int z = (int) (position.z / instance.levelSize); вернуть новый CellCoord(x, z); }
Я сделал функцию статической, чтобы мы могли получить к ней доступ из любого места (в частности, из скрипта AstarAI) с помощью LevelManager.GetCellCoord() и без необходимости сохранять ссылку на объект диспетчера уровней.
Получение bitOffset
Следующее, что нам нужно сделать, это вернуть bitOffset, чтобы помочь нам найти конкретный график сетки, когда задана конкретная координата ячейки. Мы могли бы получить доступ к словарю с помощью LevelManager.instance.graphMaskOffsets[cellCoord], но это немного громоздко. Вместо этого давайте создадим еще одну статическую функцию для внутреннего доступа к словарю, чтобы наш AstarAI мог беспрепятственно получить доступ.
public static int GetGraphMaskBitOffset (координата CellCoord) { return instance.graphMaskOffsets [координата]; }
Добавление смещений в редакторе
Последнее, что нам нужно сделать для менеджера уровней, — сделать возможным добавление битовых смещений в словарь из редактора. Обратите внимание, что словари не отображаются в инспекторе, поэтому вместо этого мы создадим массив со всей соответствующей информацией, а затем передадим эту информацию в словарь во время функции пробуждения. Как и прежде, я собираюсь создать новый тип данных, представляющий собой просто комбинацию нашего типа данных CellCoord и целого числа, представляющего индекс графика в массиве графа (т. е. bitOffset). Также, как и раньше, я просто добавлю его в файл менеджера уровней, вместо того, чтобы создавать для него новый файл. Добавьте следующий код над определением класса LevelManager:
[Сериализуемый] public struct GridGraphBitOffset { public CellCoord cellCoord; общедоступный интервал graphBitOffset; public GridGraphBitOffset (CellCoord cellCoord, int bitOffset) { this.cellCoord = cellCoord; this.graphBitOffset = битовое смещение; } }
Затем добавьте следующий код над функцией Awake():
общедоступный GridGraphBitOffset[] gridGraphBitOffsets;
Наконец, добавьте этот цикл for в последнюю часть функции Awake():
foreach (var bitOffset в gridGraphBitOffsets) { graphMaskOffsets.Add (bitOffset.cellCoord, bitOffset.graphBitOffset); }
Добавление данных графика в инспекторе
Теперь мы можем открыть скрипт levelManager в инспекторе и добавить определения наших ячеек. Например, если график2 находится в нижнем правом квадранте, то мы можем создать новый элемент в массиве gridGraphBitOffsets, установить координаты ячейки равными (1,0) и битовое смещение равным 1. Сделайте это для каждого графика и всех наши ячейки будут определены.
Настройка графика из cellCoord
Когда levelManager завершен, мы можем сосредоточиться на AstarAI. По сути, помимо перемещения из точки А в точку Б, единственная другая работа, которую должен выполнять наш ИИ, — это обработка перехода из одной сетки в другую. Для начала, если мы дадим нашему ИИ координату ячейки, нам нужно иметь возможность получить график, связанный с этой ячейкой. Для этого откройте скрипт AstarAI и добавьте следующий код под функцией fixedUpdate:
public int GetGraphMask(CellCoord cellCoord) { return 1 ‹‹ LevelManager.GetGraphMaskBitOffset(cellCoord); }
Вы заметите, что это уравнение из раздела битовой маски. Что делает эта функция, так это возвращает битовую маску, указывающую граф для данной ячейки.
Настройка графика из Vector3
Также будет полезно добавить реализацию функции GetGraphMask, которая находит маску прямо из заданной позиции. Добавьте следующий код ниже предыдущей функции:
public int GetGraphMask (позиция Vector3) { CellCoord cellCoord = LevelManager.GetCellCoord (позиция); вернуть 1 ‹‹ LevelManager.GetGraphMaskBitOffset(cellCoord); }
Получение начального графика
Теперь, когда у нас есть способ получить маску графика, мы можем изменить вызов startPath() в функции Start(), добавив вызов GetGraphMask() в качестве последнего аргумента. после модификации вызов startPath должен выглядеть так:
seeker.StartPath(transform.position, target.position, OnPathComplete, GetGraphMask(transform.position));
Прогресс
Если мы запустим программу сейчас, искатель должен двигаться к цели независимо от того, где эта цель находится, но он остановится, когда достигнет конца графа, с которого он начал. Здесь мы обрабатываем переход.
Получение следующего графика
Нам нужна функция, которая сообщает нам, на какой график мы должны смотреть дальше, учитывая нашу текущую позицию в координатах ячейки и целевую позицию в мировых координатах. Следующая функция выглядит немного сложной, но шаги относительно просты. Чтобы узнать, куда мы должны идти, мы сначала берем положение цели в мировых координатах и вычитаем наше текущее положение в мировых координатах, чтобы получить вектор, указывающий от нас к цели. Затем мы проверяем величины различных компонентов вектора, чтобы увидеть, что больше: x или z (в данном случае ось y нас не волнует). Если ось x больше, мы добавляем смещение к нашей текущей позиции ячейки в направлении знака по оси x. например если ось х имеет большую величину и была равна -3, то мы добавляем -1 к значению х нашей текущей позиции ячейки. Если значение z выше, мы делаем то же самое, но для значения z нашей текущей позиции ячейки. Затем мы возвращаем значение нашей текущей позиции ячейки плюс смещение, чтобы получить координаты ячейки, которую наш ищущий ИИ должен передать следующей. Добавьте следующий код ниже предыдущих функций:
public CellCoord GetNextCellCoord(Vector3 targetPosition) { Vector3 direction = targetPosition — transform.position; CellCoord currentPosition = LevelManager.GetCellCoord(transform.position); Смещение CellCoord = новый CellCoord (0,0); if (Mathf.Abs(direction.x) › Mathf.Abs(direction.z)) { offset.x = (int)(1 * Mathf.Sign(direction.x)); } else { offset.z = (int)(1 * Mathf.Sign(direction.z)); } вернуть новый CellCoord(currentPosition.x + offset.x, currentPosition.z + offset.z); }
Получение нового пути
Конечно, без вызова эта функция бесполезна! Чтобы сказать ИИ, чтобы он продолжал работать, нам нужно сказать ему, чтобы он начал другой путь, со следующим графом, выбираемым после того, как он доберется до края своего текущего графа. Для этого все, что нам нужно сделать, это сравнить позицию ИИ с расстоянием до следующей путевой точки плюс смещение (чтобы он не запутался, когда мы действительно закончим наш путь). Если мы все еще далеки от нашей цели и достигли конца нашего пути, то мы должны продолжать идти! Вам также придется добавить частное логическое значение, чтобы проверить, не пытаемся ли мы уже вычислить путь. Если ваш компьютер медленно находит путь, мы можем случайно запустить другой поиск, который переопределит путь, который вы уже ищете, и наш ИИ никуда не денется. Добавьте следующий код над функцией запуска:
частное логическое вычислениеNewPath = false;
Кроме того, нам придется изменить функцию OnPathComplete, чтобы использовать новое логическое значение. Измените функцию OnPathComplete, чтобы она выглядела следующим образом:
public void OnPathComplete ( Path p ) { Debug.Log («Ура, мы получили обратный путь. Была ошибка?» + p.error ); если (!p.error) { путь = p; //Сброс счетчика путевых точек currentWaypoint = 0; вычисление нового пути = ложь; // ‹‹‹‹‹‹‹‹‹‹‹ важно добавить! } }
Наконец, мы можем обновить способ, которым мы определяем, что достигли конца пути, чтобы разместить несколько сеток.
Добавьте следующий код в вызов fixedUpdate в теле текущей проверки путевой точки. После модификации тело текущей проверки путевой точки должно выглядеть так:
if (currentWaypoint ›= path.vectorPath.Count) { // Магическое число здесь, так что если мы дойдем до конца нашего списка путевых точек, мы не будем пытаться продолжать движение. if (Vector3.Distance(transform.position, target.position) › nextWaypointDistance + 1 && !calculatingNewPath) { seeker.StartPath(transform.position, target.position, OnPathComplete, GetGraphMask(GetNextCellCoord(target.position))); вычисление нового пути = истина; возвращение; } Debug.Log («Достигнут конец пути»); возвращение; }
Собираем все вместе
Теперь, если все работает правильно, мы должны увидеть, как наш искатель перемещается в нашу целевую позицию, независимо от того, на каком графике он находится, когда мы запускаем сцену!
Вы заметите, что искатель всегда перемещается к закрывающему узлу к цели, на которой он находится в данный момент, прежде чем перейти к следующему графику. Это может привести к некоторому странному поведению именно потому, что у нас не настроен GetNextCellCoord для выполнения диагональных смещений. Просто что-то иметь в виду!