Навигация — навмеш + агенты
Персонажи, которые сами находят дорогу: навигационная сетка описывает, где агент может стоять и ходить (запекается один раз, во время разработки, из статических коллайдеров уровня), а агенты по ней ходят — поиск пути, рулёжка и расталкивание друг друга происходят нативно (Recast/Detour), а игра решает, куда все идут и когда.
Файлы сцен — обычный путь
Уровню, который является файлом сцены, нужен один блок и никакого кода:
export default defineScene({
env: {
navmesh: {
data: asset('../assets/nav/yard.navmesh'),
agent: { radius: 0.4, height: 1.8, climb: 0.4, slope: 50 }, // размер, под который строится сетка
},
},
nodes: { /* … */ },
})Затем, раз на каждое изменение планировки:
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 с предупреждением,
и агенты стоят на месте.
Агенты
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 м/с). Если
важна точная точка, доведи подход сам.
трансформ агента с 'position' принадлежит толпе — запись node.position перезаписывается на
следующем шаге (узел прыгает обратно в свой слот). Двигай его через warp(p), а позицию узла задавай
до прикрепления аспекта, который входит в толпу в позиции узла.
Агенты тикают в фиксированной фазе: вся толпа продвигается раз за шаг (NavCrowd — система,
которую сетка прикрепляет к своей сцене; ты её никогда не создаёшь; упорядочивай систему относительно
неё через static before = [NavCrowd]), затем каждый агент применяет свой слот. Толпа из 50 стоит
одного нативного обновления и одного плоского чтения за шаг. Агент, прикреплённый до загрузки
какой-либо сетки, ждёт инертно и входит в толпу, когда она появится.
Запросы
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 областей.
Области
Именованные области назначают цену или запрещают части сетки: объяви их со стоимостью, покрась статические тела в них и дай запросам / агентам ссылаться на них по имени.
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её исключает (путь, начинающийся на обычной земле, приходит пустым).
Сцены, собранные вручную
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-связей (прыжки, лестницы, двери) и нет оверлея сетки в редакторе сцен.