Проверка проходимости построенного уровня в Godot физикой, а не графом. Используй, когда уровень строится из кода (планировка, лабиринт, процедурное здание) и надо убедиться, что игрок действительно доходит до каждого помещения; когда «по логике связано, а пройти нельзя»; когда игрок застревает в дверях, не может подняться по лестнице или проваливается между этажами.
Recursos
1Instal·la
npx skillscat add sashkosoft/godot-skilled/godot-walkability Instal·la des del registre de SkillsCat.
Проходимость: проверять физикой
Скил собран на задаче, где этаж жилого дома строился из кода по чертежу и
надо было гарантировать, что игрок попадёт в каждую из восьми квартир.
Главное правило
Граф дверей врёт. Обход «комната → дверь → комната» скажет, что всё связано,
и будет неправ: проём может упираться в перпендикулярную стену, в шахту лифта,
может быть уже капсулы игрока, может лежать в углу так, что подойти к нему
некуда. В одном замере граф рапортовал «связано всё», а физически достижимо было
1,7 м² из 300 — пятачок перед лестницей.
Проверка проходимости — это всегда шейп-каст капсулой игрока, а не обход по
данным, из которых уровень построен.
Как устроена проверка
# 1. в каждой клетке сетки 10–12 см ищем опору лучом сверху вниз
var ray := PhysicsRayQueryParameters3D.create(
Vector3(x, y + 2.0, z), Vector3(x, y - 0.4, z))
var hit := space.intersect_ray(ray)
# 2. ставим на неё капсулу игрока
q.transform = Transform3D(Basis.IDENTITY,
Vector3(x, hit["position"].y + Player.HEIGHT * 0.5 + 0.03, z))
free[i] = 1 if space.intersect_shape(q, 1).is_empty() else 0
# 3. волна от стартовой точки; между клетками проход есть,
# если перепад опоры не больше высоты шага
if absf(ground[i] - ground[cur]) > CLIMB: continueДальше по каждому помещению считается доля залитых клеток. Ноль — заперто.
Три ошибки в самой проверке
Проверка тоже ошибается, и её ошибки выглядят как ошибки уровня.
Луч начинается слишком высоко. Если пустить его от потолка, над дверным
проёмом он упрётся в перемычку и вернёт опору на высоте 2,8 м. Капсула
поставится под потолок, упрётся в перекрытие — и проём прочитается как глухая
стена. Начинай луч ниже низа перемычек (у нас 2,0 м при потолке 2,84) и
отбрасывай попадания выше пояса.
Капсула ставится на уровень пола. Тогда лестничные марши читаются как
сплошное препятствие: по ним ходят поверху, а не сквозь. Опору надо искать
лучом, а перепад между соседними клетками ограничивать высотой шага (0,45 м) —
тогда лестница становится проходом.
Сетка грубее проёма. Клетка 12 см и дверь 70 см дают 5 клеток, из которых
крайние срезаны радиусом капсулы. Если проверка «мигает» от прогона к прогону —
уменьшай шаг, а не подгоняй пороги.
Капсула игрока: 0,25, а не 0,35
Радиус 0,35 м — это 70 см в поперечнике. Человек столько в плане не занимает,
а вот дверной проём столько и есть. С таким радиусом игрок физически не входит
в дверь 0,8 м, и весь уровень приходится расширять под ошибку в персонаже.
Ставь радиус 0,25 (0,5 м в плане, высота 1,8). Один этот параметр дал
+30 % проходимой площади и убрал две трети «недостающих» дверей.
Если что-то не проходит — сперва проверь габарит игрока, потом уровень.
Совпадающие стены — самая злая ошибка
Если две комнаты граничат по одной координате, но стены строятся независимо
(каждая комната рисует свои четыре стены), на этой координате оказываются
две стены в одном месте. Дверь режется в одной, вторая остаётся глухой.
Внешне не видно ничего: в кадре одна стена с проёмом.
Лечится только общей разбивкой: все рёбра на одной оси собираются в группу,
режутся по всем точкам излома на элементарные участки, и участок строится один
раз. После этого дверь на участке — одна дверь, а не две.
Тот же приём убирает щели: если между комнатами остался зазор в 10–30 см,
сосед не находится выборкой «на 0,22 м в сторону». Ищи соседа с
несколькими радиусами (0,22 → 0,45 → 0,78 м) или свари координаты заранее.
Цикл «собрать → проверить → прорезать»
Автоматическая починка окупается, когда помещений больше двух десятков:
- собрать уровень;
- прогнать физическую проверку;
- для каждого недостижимого помещения прорезать проём в стене, ведущей в уже
достижимую часть, выбирая место с наибольшим просветом по обе стороны; - пересобрать и повторить.
Две детали, без которых цикл не сходится:
- эскалация. Если стену уже резали и не помогло, следующий шаг — не вторая
дверь в ней же, а либо соседняя стена, либо снос участка целиком. Иначе цикл
бесконечно режет одно и то же место; - отсев. После схождения пройди по добавленным проёмам и попробуй убрать
каждый: если уровень остался проходим — проём был лишним. У нас из 89
добавленных лишними оказались 45, и без отсева этаж выглядел дырявым.
Результат цикла — данные, а не код: файл с найденными проёмами, который
игра только читает. Иначе каждый запуск раскладывает двери заново и уровень
меняется между сессиями.
Что печатать в отчёт
Не «проверка пройдена», а числа: сколько клеток свободно, сколько залито,
сколько помещений недостижимо и какие именно. И ASCII-карта заливки при
разборе: # занято, . свободно но не достигнуто, + достигнуто. По карте
за один взгляд видно, где волна остановилась, — это быстрее любого лога.
Общей проходимости мало: проверяй квартиру от её двери
«Из подъезда достижимо всё» и «из квартиры достижимы все её комнаты» — разные
утверждения, и первое прячет ошибки второго. Проверка, которая заливает этаж
одной волной, объявит комнату достижимой, даже если попасть в неё можно только
кругом — через коридор и вторую дверь той же квартиры, или, хуже, через дыру
в стене соседа.
Правильная проверка: волна пускается из мест общего пользования, но ходить
разрешено только по ним и по одной проверяемой квартире — маска по клеткам.
Тогда сразу видно и «входа нет», и «вход есть, но кухня отрезана».
На восьмиквартирном этаже эта проверка нашла то, чего общая не видела:
у одной квартиры не было входа вовсе, у другой от двери не доставались кухня,
обе жилые и лоджия.
Правила, без которых починка ломает планировку
Автоматическая прорезка дверей сделает уровень проходимым любой ценой.
Ограничения нужны заранее, иначе получится дом с проходными квартирами.
- Межквартирную стену не резать никогда. Проверке всё равно, ей нужна
связность, и она с удовольствием соединит жилую комнату одной квартиры
с жилой соседней. Правило должно стоять во всех местах, где режется проём,
включая прорезку парной стены через щель — про неё забыть проще всего,
и дыра появится именно там. - Внутриквартирный проём всегда предпочтительнее нового входа. Иначе
вместо одной входной двери у квартиры окажется пять — проверка просто
прорубает ближайшую стену в коридор каждый раз, когда чего-то не хватает. - Каждой квартире — гарантированный вход, поставленный отдельным правилом
до всякой автоматики: в прихожую, из мест общего пользования, по самой
длинной общей стене. Тогда починке остаётся только внутренняя развязка.
Окно и дверь на одном месте
Если проёмы приходят из двух источников (снято с чертежа + добавлено
автоматом), они рано или поздно совпадут на одной стене. У окна остаётся
подоконник — сплошной блок от пола до 0,85 м. Он наглухо перекрывает дверной
проём, и стена выглядит с дверью, а пройти нельзя. При совпадении дверь
важнее: окно на этом месте убирается.
Там же проверяй и правдоподобие: окно во внутренней стене — это ошибка
чтения, окна бывают только по фасаду. Отбрасывай такие сразу, иначе
единственная стена квартиры в коридор окажется «застеклённой».
Бот, который ходит: навигация в коде
Редактор мы не открываем, поэтому навигационная сетка печётся из кода по уже
построенным коллизиям:
var nav := NavigationMesh.new()
nav.cell_size = 0.08 # мельче дверного проёма, иначе двери «зарастают»
nav.agent_radius = 0.30
nav.agent_max_climb = 0.30
nav.agent_max_slope = 50.0 # марш идёт под 30°
nav.geometry_parsed_geometry_type = NavigationMesh.PARSED_GEOMETRY_STATIC_COLLIDERS
nav.geometry_source_geometry_mode = NavigationMesh.SOURCE_GEOMETRY_ROOT_NODE_CHILDREN
var src := NavigationMeshSourceGeometryData3D.new()
NavigationServer3D.parse_source_geometry_data(nav, src, building)
NavigationServer3D.bake_from_source_geometry_data(nav, src)
region.navigation_mesh = navЛестница попадает в сетку сама, если марш задан наклонной плитой, а не
стопкой ступеней-коробок: по коробкам Recast строит обрыв, по плите — пандус.
Это ещё один довод в пользу «ступени рисуем, ходим по плите».
agent_radius округляется вверх до целого числа клеток. При cell_size 0.08
радиусы 0.26 и 0.30 дают одно и то же (4 клетки = 0.32) — не удивляйся, что
правка «ничего не изменила».
Три ловушки, на которых бот стоит колом
Цель вне сетки. Центр тесного санузла после эрозии на радиус агента лежит
в дыре навигационной сетки. Агент доходит до ближайшей точки и никогда не
рапортует о прибытии — со стороны это выглядит как «застрял в дверях».
Отсеивай цели по габариту помещения (минимальная сторона шире агента), либо
привязывай их к сетке — но не через map_get_closest_point сразу после
запекания: карта синхронизируется позже, и запрос вернёт ноль, хотя агенты
по ней уже ходят.
Цель слишком близко. Если очередная цель в метре от бота, агент
отчитывается о приходе в тот же кадр, бот берёт следующую, и так по кругу:
в логе тысячи «дошёл» в секунду при неподвижном боте. Требуй, чтобы новая
цель была хотя бы в трёх метрах, и не проверяй is_navigation_finished()
первые доли секунды после смены цели.
Плечо о косяк. Путь через дверь формально есть, но бот подходит к ней
под углом, упирается в откос и move_and_slide гасит скорость в ноль.
Лечится не расширением дверей, а качком: если бот не сдвинулся с места
полторы секунды — сместить его на 20 см вбок (в следующий раз в другую
сторону), подтолкнуть вперёд и перестроить путь. Три таких качка снизили долю
застреваний с 30 % до 2 %.
Детектор застревания считай не за кадр: за один кадр смещение всегда
меньше любого разумного порога, и «застрявшими» окажутся все.
Почему бот не ходил между этажами
Три причины, и все три выглядели одинаково — «сетка есть, а пути нет».
Клетка крупнее марша. При cell_size 0.08 и марше 0,95 м после эрозии на
радиус агента от марша оставалась рваная полоса, и этажи в сетке оказывались
несвязанными островами. cell_size 0.05 — и охват вырос с 68 помещений
(один этаж) до 136 (оба). Явные NavigationLink3D по маршам эту дыру, кстати,
не закрыли: связь не спасает, если рвётся сама поверхность.
Второй марш шёл над площадкой. Классическая ошибка раскладки: марш
начинался у дальнего края площадки и уходил обратно, проходя прямо над ней.
Просвет над площадкой — 8 см. Игрок пробегал (у него есть step-up и он шёл
по прямой), бот вставал намертво. Разворачивать марш надо от площадки,
над соседним маршем, а не над ней самой.
Проём в перекрытии не совпадал с верхом марша. Дальше верха — игрок
проваливается в проём; ближе — марш упирается в торец перекрытия ступенькой
в 20 см. У CharacterBody3D нет автоматического шага вверх: игроку step-up
дописан вручную, а бот на такой ступеньке стоит. Край проёма должен совпадать
с точкой, где марш выходит на отметку этажа.
Отдельно: ступени-коробки для навигации не нужны и вредны. В сетку они
попадают, но капсула игрока цепляется за подступёнки и застревает на середине
марша. Ступени — только вид, ходят по наклонной плите; Recast её берёт, если
клетка достаточно мелкая.
Полезная мелочь: на марше боту стоит сбавлять скорость (у нас до 65 %) и
уменьшать path_desired_distance до 0.3 — иначе он срезает угол и сходит
с узкого марша вбок. Это подняло долю дошедших с 40 % до 90 %.
Снаружи: квартал и сеть дорожек
Та же идея — проверять проходимость движением, а не схемой — работает и для
улиц и дворов, без физики: граф проездов и троп, по нему пускаются агенты,
и смотрится, куда они за N секунд не дошли и насколько отходят от оси
дорожки. Главная находка там — дворовая сеть легко распадается на несвязные
части, и половина квартала недостижима, хотя «по логике всё связано»; лечится
тротуарами улиц в графе. Подробно — скил district-layout,references/walk-network.md.