SashkoSoft

godot-walkability

Проверка проходимости построенного уровня в Godot физикой, а не графом. Используй, когда уровень строится из кода (планировка, лабиринт, процедурное здание) и надо убедиться, что игрок действительно доходит до каждого помещения; когда «по логике связано, а пройти нельзя»; когда игрок застревает в дверях, не может подняться по лестнице или проваливается между этажами.

SashkoSoft 0 Updated 3w ago

Resources

1
GitHub

Install

npx skillscat add sashkosoft/godot-skilled/godot-walkability

Install via the SkillsCat registry.

SKILL.md

Проходимость: проверять физикой

Скил собран на задаче, где этаж жилого дома строился из кода по чертежу и
надо было гарантировать, что игрок попадёт в каждую из восьми квартир.

Главное правило

Граф дверей врёт. Обход «комната → дверь → комната» скажет, что всё связано,
и будет неправ: проём может упираться в перпендикулярную стену, в шахту лифта,
может быть уже капсулы игрока, может лежать в углу так, что подойти к нему
некуда. В одном замере граф рапортовал «связано всё», а физически достижимо было
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 м) или свари координаты заранее.

Цикл «собрать → проверить → прорезать»

Автоматическая починка окупается, когда помещений больше двух десятков:

  1. собрать уровень;
  2. прогнать физическую проверку;
  3. для каждого недостижимого помещения прорезать проём в стене, ведущей в уже
    достижимую часть, выбирая место с наибольшим просветом по обе стороны;
  4. пересобрать и повторить.

Две детали, без которых цикл не сходится:

  • эскалация. Если стену уже резали и не помогло, следующий шаг — не вторая
    дверь в ней же, а либо соседняя стена, либо снос участка целиком. Иначе цикл
    бесконечно режет одно и то же место;
  • отсев. После схождения пройди по добавленным проёмам и попробуй убрать
    каждый: если уровень остался проходим — проём был лишним. У нас из 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.