Навигация — навмеш + агенты

Персонажи, которые сами находят дорогу: навигационная сетка описывает, где агент может стоять и ходить (запекается один раз, во время разработки, из статических коллайдеров уровня), а агенты по ней ходят — поиск пути, рулёжка и расталкивание друг друга происходят нативно (Recast/Detour), а игра решает, куда все идут и когда.

Файлы сцен — обычный путь

Уровню, который является файлом сцены, нужен один блок и никакого кода:

TypeScript
export default defineScene({
  env: {
    navmesh: {
      data: asset('../assets/nav/yard.navmesh'),
      agent: { radius: 0.4, height: 1.8, climb: 0.4, slope: 50 },   // размер, под который строится сетка
    },
  },
  nodes: { /* … */ },
})

Затем, раз на каждое изменение планировки:

TypeScript
lecodes navmesh bake            # → assets/nav/yard.navmesh

Что проходимо — выводится, а не объявляется: каждый узел с Shape и Physics { motion: 'static' } — поверхности, с которыми сталкивается CharacterController. Бочки и ящики (dynamic) в сетку не входят; декорация без коллайдера никому не мешает. Переопределения на узел: nav: false (не учитывать это тело), nav: 'unwalkable' (никто не стоит на его отпечатке и не пересекает его), navArea: 'road' (именованная область — см. ниже).

Запекание запускает приложение в desktop-хосте до момента, когда уровень готов (env.navmesh сцены или рукописный NavMesh.load), сбрасывает статические треугольники столкновений и строит сетку в CLI (тайлами, под размер агента из agent). Файл называется по файлу сцены, который объявляет navmesh: (--name переопределяет, --entry выбирает файл, который строит уровень); --obj ещё и записывает полигоны как .obj — посмотреть в любом просмотрщике. lecodes navmesh bake --help перечисляет ручки Recast (--cell, --cell-height, --tile, …).

Запекай перед первым запуском: asset(), чей файл не существует, роняет компиляцию. Запекание пишет заглушку перед компиляцией и заменяет её сеткой; заглушка (запекание, которое ничего не построило) загружается как отсутствие навмеша — NavMesh.load резолвится в null с предупреждением, и агенты стоят на месте.

Агенты

TypeScript
const spawn = new Node()
spawn.position = [4, 0, -6]                      // СНАЧАЛА поставь узел: агент входит в толпу там, где стоит
const op = spawn.aspect(NavAgent, { speed: 3.2 })
scene.add(op)
op.agent.goTo(player.position)                   // false = рядом с этой точкой нет ничего проходимого (старая цель остаётся)
op.agent.on('arrive', () => console.log('there'))
op.agent.on('blocked', () => op.agent.goTo(nav.randomPoint(op.position, 8) ?? op.position))
Опция По умолчанию
radius, height 0.4, 1.8 м — размер агента (избегание, ширина коридора). Из сетки не читается: сам подбери под agent запекания
speed, acceleration 3, 8 м/с, м/с²
turnSpeed 540 град/с, с которой узел поворачивается к своей скорости; 0 = не трогать вращение
avoidance 'medium' избегание агент-агент: 'off' | 'low' | 'medium' | 'high'
separation 2 сила разделения (0 = нет)
drive 'position' 'position': толпа ставит узел (без физики, на любом хосте). 'controller': скорость идёт в CharacterController.move узла — капсула сталкивается со всем, толпа следует за телом
stopDistance 0.3 достиг = в пределах стольких метров вдоль коридора
include / exclude все / нет имена областей, по которым агент может / никогда не может ходить

Состояние: agent.state (idle | moving | arrived | blocked | offmesh), agent.velocity, agent.desiredVelocity, agent.remainingDistance (м вдоль коридора, −1 = нет цели), agent.target, agent.hasPath. Команды: goTo(p), stop() (сбросить цель и плавно остановиться), move(vx, vz) (рулить скоростью — игрок на WASD: вызывай каждый фиксированный шаг; толпа держит узел на сетке и в стороне от других; move(0, 0) останавливает; отменяет goTo), warp(p) (телепорт; цель сохраняется). Каждая опция живая — меняй её на agent в любой момент.

События: arrive и blocked (цель недостижима — агент остановился в ближайшей точке или простоял 3 с по пути: затор, закрывшаяся дверь). Толпа никогда не даёт всем дойти до одной точки, так что arrive срабатывает и после секунды, проведённой в пределах stopDistance + 8 × radius от цели — одинокий агент может сообщить о прибытии, не дойдя до места (измерено: на 1.4 м раньше при 3 м/с). Если важна точная точка, доведи подход сам.

Note

трансформ агента с 'position' принадлежит толпе — запись node.position перезаписывается на следующем шаге (узел прыгает обратно в свой слот). Двигай его через warp(p), а позицию узла задавай до прикрепления аспекта, который входит в толпу в позиции узла.

Агенты тикают в фиксированной фазе: вся толпа продвигается раз за шаг (NavCrowd — система, которую сетка прикрепляет к своей сцене; ты её никогда не создаёшь; упорядочивай систему относительно неё через static before = [NavCrowd]), затем каждый агент применяет свой слот. Толпа из 50 стоит одного нативного обновления и одного плоского чтения за шаг. Агент, прикреплённый до загрузки какой-либо сетки, ждёт инертно и входит в толпу, когда она появится.

Запросы

TypeScript
const nav = NavMesh.of(scene)!           // или результат NavMesh.load; NavMesh.active = последняя загруженная
const path = nav.findPath(a, b)          // Vec3[] углы; path.partial = b недостижима
const p = nav.nearest(candidate)         // ближайшая проходимая точка (или null)
const r = nav.raycast(a, b)              // { hit, point, normal } — есть стена между a и b?
const spot = nav.randomPoint(center, 8)  // достижимая точка в пределах 8 м (пешком)
const any = nav.randomPoint()            // где угодно на сетке
nav.debug = true                         // рисовать полигоны (синяя поверхность на 3 см выше)

Все синхронные и достаточно дешёвые, чтобы вызывать каждый кадр. Каждый принимает { include?, exclude?, extents? } (findPath ещё и maxCorners, до 256). findPath возвращает пустой массив, когда конечная точка вне сетки (дальше extents, по умолчанию 2 / 4 / 2 м); частичный путь заканчивается в ближайшей точке, до которой смог дойти. raycast из точки вне сетки сообщает попадание в своём начале. randomPoint сидирован: одни и те же вызовы дают одни и те же точки при каждом запуске. nav.info — заголовок запекания (stats, cell, cellHeight, …), nav.agent — размер, под который она построена, nav.areas — id областей.

Области

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

TypeScript
env: { navmesh: { data, areas: { road: 0.8, mud: 4 } } }      // id назначаются в этом порядке
nodes: { swamp: { model: MUD, navArea: 'mud', aspects: [use(Shape), use(Physics, { motion: 'static' })] } }

nav.findPath(a, b, { exclude: ['mud'] })
nav.cost('mud', 10)                                            // на ходу
enemy.aspect(NavAgent, { exclude: ['mud'] })

До 15 областей; неперечисленное тело — обычная земля (стоимость 1). Три вещи, которые надо знать:

  • Область — это своя собственная земля. Покрашенное тело должно быть полом там: лоскут, положенный поверх другого статического пола, сливается с ним и остаётся обычной землёй (измерено: 4-см дорожная плита на полу не дала полигонов road, а полоса сама по себе вышла как mud).
  • Стоимость направляет поиск; exclude — правило. Измерено на 4-м полосе грязи с 36-м обходом: стоимости до 10 всё ещё пересекали её (16 м напрямую), 100 шла в обход.
  • include перечисляет каждую разрешённую область — у обычной земли нет имени, так что список include её исключает (путь, начинающийся на обычной земле, приходит пустым).

Сцены, собранные вручную

TypeScript
NavMesh.exclude(decorativeFence)    // до загрузки: это статическое тело не формирует сетку
NavMesh.unwalkable(pit)             // его отпечаток вырезается
NavMesh.area(swamp, 'mud')          // (файлы сцен: nav: false / 'unwalkable', navArea)

const main = async () => {
  // после того, как статические тела уровня существуют (под `lecodes navmesh bake` запекание срабатывает здесь)
  const nav = await NavMesh.load(scene, { data: asset('./assets/nav/level.navmesh'), agent: { radius: 0.4 }, areas: { mud: 4 } })
  if (!nav) return                  // нет навигации на этом хосте, заглушка или само запекание
  scene.add(new Node().aspect(NavAgent))
}
main()

NavMesh.load резолвится в null на хосте без навигации (предупреждение; агенты остаются инертными), для файла-заглушки и пока CLI запекает. NavMesh.baking равно true, пока CLI запекает — пропускай меню и строй уровень сразу. NavMesh.clear() забывает все переопределения (пересборка уровня). nav.dispose() освобождает сетку (разбор сцены); агенты возвращаются в инертность. Top-level await в приложении не компилируется — см. соглашения.

Навигация работает одинаково на нативных хостах и в headless-рендерере (flow-тест видит те же пути и позиции агентов, что и --sim). В Web Lite навигации нет.

Ограничения (v1)

  • Один навмеш на сцену, построенный под один размер агента. Второй размер = второе запекание.
  • Динамические тела не входят в сетку: агент с 'position' проходит сквозь бочку; дай агенту CharacterController (drive: 'controller'), чтобы сталкиваться. Вырезания препятствий во время выполнения нет.
  • Высоты квантуются до высоты ячейки запекания (--cell-height, 0.1 м): узел с 'position' может сидеть выше пола на эту величину.
  • В SDK нет off-mesh-связей (прыжки, лестницы, двери) и нет оверлея сетки в редакторе сцен.