FOnline Engine
Current 2026.1.118-dev GitHub
Документация Docs/ru/explanation/maps-and-movement.md

Карты, движение и геометрия

Владельцы геометрии/движения Engine: Source/Common/Geometry.*, LineTracer.*, Movement.*, PathFinding.*, MapLoader.*, map bakers и тесты.

Разделы .fomap, identity/владение размещением, Mapper round-trip, side-specific baking и материализация runtime описаны в формате карт.

Модель владения

Engine владеет типами координат, алгоритмами пути, интерполяцией и загрузкой файлов. Проект владеет картами, блокировками, правилами встреч и игровой навигацией/заданиями.

Типы координат и направлений

Source/Common/Geometry.h экспортирует следующие value-типы runtime/API/скриптов:

  • mpos — позиция гекса или тайла на карте, хранящая mcoord x и mcoord y;
  • msize — размер карты как mcoord width и mcoord height;
  • hdir — дискретное направление гекса или тайла;
  • mdir — угол направления движения, преобразуемый в направление гекса и обратно.

Помощники границ msize:

  • clamp_pos() ограничивает сырые координаты границами карты;
  • from_raw_pos() утверждает, что сырые координаты уже находятся в границах.

Замена типов открытого API карты на ipos/isize требует проверки сгенерированных метаданных и script-layout.

Ширина координат и предел размера карты

mcoord (mcoord_t в нативном коде) по умолчанию равен int16 и становится int32, когда подключающий проект включает CMake-опцию FO_MAP_WIDE_COORDS. Codegen выводит тип поля layout mcoord из этой опции, поэтому нативные структуры, поля AngelScript (int16/int32), поля Managed C# (short/int), сетевые сообщения и запечённые данные свойств следуют за ней. Выведенный layout входит в FO_COMPATIBILITY_VERSION, поэтому клиент и сервер, собранные с разной шириной, отказываются соединяться. Документы базы данных и текстовые карты хранят координаты числами, поэтому смена ширины требует перепекания, но не миграции данных, пока каждое сохранённое значение помещается в целевую ширину.

Широкие координаты добавляют четыре байта к каждой позиции в сетевых сообщениях и запечённых данных свойств. Сетки гексов не растут: их ячейки не хранят позиций.

Конструкторы Managed mpos и msize принимают int при любой ширине и сужают 16-битные поля с проверкой переполнения. Скриптовый код, который создаёт позиции через new mpos(x, y) и читает поля в int, компилируется при обеих ширинах; код, сохраняющий поле в short, собирается только с 16-битными координатами.

FO_MAP_MAX_SIZE (по умолчанию 4000) ограничивает ширину и высоту карты. Значение должно быть не меньше размера карты Mapper по умолчанию 200 и не больше 32767, диапазона координат int16, а с широкими координатами — GameSettings::MAP_SIZE_LIMIT (4194304): клиентский ключ порядка отрисовки спрайтов хранит X ячейки в 22 битах, а экранную строку, не больше удвоенной стороны, — в 24 битах. Конфигурация отклоняет другие значения, а статические проверки повторяют границы. Площадь карты и все индексы массивов, выводимые из координат, считаются в 64 битах и преобразуются в size_t с проверкой переполнения, поэтому на 32-битной платформе координаты, которые size_t не может индексировать, вызывают OverflowException вместо переполнения; статическая проверка удерживает пиксельные позиции в пределах половины диапазона int32 для настроенного размера гекса. Сервер и клиент отклоняют при загрузке карту, чей Size превышает предел, а Mapper ограничивает им изменение размера. Освещение гексов на клиенте хранится в сетке полей с чанками, а поиск пути индексирует свои блоки поиска 16x16 только в окне вокруг старта, которое допускает ограничение длины (MaxLength плюс контур мультигекса и кольцо цели), поэтому ни то ни другое не растёт с площадью карты. Пробник замкнутости, заливающий от цели, отдельно учитывает немногие гексы, которые он посещает за пределами окна. Позиции отрисовки хранятся во float32, поэтому вдали от начала карты (дальше примерно 2^23 пикселей) они теряют субпиксельную точность.

MapView::Resize проверяет, что запрошенная площадь помещается в size_t, до изменения карты и удаления предметов. Статическая двумерная сетка также проверяет новую площадь до переноса ячеек и публикует новые размеры только вместе с заменяющим хранилищем. Отказ из-за переполнения площади сохраняет прежние размеры и ячейки.

Соглашение о смещении внутри гекса

Пиксельные sub-hex offsets отсчитываются от визуального центра hex в пределах ±{MAP_HEX_WIDTH/2, MAP_HEX_HEIGHT/2}. Critter HexOffset, начало/конец MovingContext, MoveToHex, transparent egg и MapView::GetHexAtScreen / Client_Map_GetHexAtScreenPos используют это соглашение; cursor offset не требует преобразования.

GeometryHelper::GetHexPos, MapView::GetHexMapPos и Offset каждого Field возвращают верхний левый origin с шагами MAP_HEX_WIDTH / MAP_HEX_LINE_HEIGHT. HexView::AddSprite размещает critter/item в origin + {MAP_HEX_WIDTH/2, MAP_HEX_HEIGHT/2}; MapView::GetHexScreenPos возвращает этот визуальный центр (ступни critter).

При совмещении center offset с GetHexMapPos учитывайте этот сдвиг на половину hex. GetHexAtScreen вычитает его перед GetHexPosCoord, выбирая ближайший визуальный центр и возвращая center offset в допустимом диапазоне без clamping. UpdateTransparentEgg и critter-вариант SetTransparentEgg прибавляют/вычитают тот же сдвиг. GetHexScreenPos(hex) + offset проецирует позицию напрямую без дополнительной поправки.

Режимы геометрии

hdir компилируется по-разному в зависимости от FO_GEOMETRY:

  • FO_GEOMETRY == 1 — шесть направлений: северо-восток, восток, юго-восток, юго-запад, запад, северо-запад;
  • FO_GEOMETRY == 2 — восемь направлений: шесть перечисленных плюс юг и север.

GameSettings::MAP_DIR_COUNT участвует в нормализации направлений. При изменении геометрии проверяйте compile-time настройки, сгенерированные value-типы, тесты поиска пути и код рендеринга, проецирующий позиции карты.

mdir хранит нормализованные углы, а hdir — дискретные направления карты. При движении или развороте используйте общие помощники преобразования: в квадратной геометрии север имеет угол 0, поэтому самописное разбиение угла обязано корректно обрабатывать переход 360/0.

MapView::RebuildMapOffset сохраняет видимость центра при реверсе или совмещении осей на больших картах. Отрицательный X использует west; чередующиеся шаги положительного Y — south-east/south-west, отрицательного Y — north-east/north-west в обеих геометриях.

Проекция камеры карты

Камера карты использует world frame +X вправо, +Y вверх и +Z на юг карты; одна world unit равна одному pixel hex spacing. Parallel projection наклоняет мир вокруг X на MAP_CAMERA_ANGLE (arcsin(sqrt(3)/4)): northing земли сокращается в sin(angle) раз, а elevation — в cos(angle). Ground point с z = legacy_y / sin(angle) заставляет ProjectWorldToMap точно воспроизводить legacy-позицию GetHexPos при нулевой высоте.

ProjectWorldToMap является эталонной формой без scroll и zoom. Он возвращает map-space pixels в .x/.y и view depth в .z; больший depth ближе к камере. Пара (.y, .z) — ортонормальное вращение world (Z, Y), а не shear.

MakeMapCameraView — GPU-форма со scroll, zoom и вращением yaw_deg вокруг вертикальной оси. При нулевом yaw она воспроизводит (ProjectWorldToMap(world).xy - scroll) * zoom с неизменным depth. Backend ortho компонуется поверх этой matrix, поэтому sprites, 3D models и particles используют одно world-to-clip преобразование. Map sprites также записывают world depth с DepthFunc = LessEqual: CPU painter order продолжает обслуживать blended layers, а общий depth buffer разрешает occlusion между разными типами. Test_Geometry.cpp закрепляет обе формы проекции друг относительно друга и относительно GetHexPos. Он также закрепляет GetHexOffset(from, to) == GetHexPos(to) - GetHexPos(from), поэтому изменение view origin является uniform pixel translation; MapView использует этот инвариант при сдвиге cached light primitives во время scroll.

Обязанности GeometryHelper

GeometryHelper — статический служебный класс. Он отвечает за проекцию координат и математику направлений:

  • преобразование гекса или тайла в экранные координаты карты: GetHexPos(), GetHexPosCoord(), GetHexOffset();
  • канонизацию гекса и смещения: NormalizeHexOffset() переписывает проецируемую точку как ближайший находящийся в границах гекс и малое локальное смещение;
  • помощники осевых координат: GetHexAxialCoord(), GetAxialHexes();
  • расстояния и направления: GetDistance(), GetHexDir(), GetDirAngle() и помощники разности углов;
  • радиусы и проверки линии с окружностью: HexesInRadius(), IntersectCircleLine();
  • шаг движения: MoveHexByDir(), MoveHexByDirUnsafe(), MoveHexAroundAway();
  • обход multihex: ForEachMultihexLines().

Безопасные помощники проверяют границы msize; небезопасные предназначены для внутренних алгоритмов, уже доказавших нахождение в границах.

Поиск пути

Source/Common/PathFinding.h экспортирует основное API поиска пути:

  • FindPathInput
  • FindPathOutput
  • TraceLineInput
  • TraceLineOutput
  • PathFinding::CheckHexWithMultihex()
  • PathFinding::FindPath()
  • PathFinding::EvaluateFreeMovementEndOffset()
  • PathFinding::TraceLine()

FindPathInput намеренно основан на callback. Runtime предоставляет CheckHex(mpos), чтобы владение картой, блокировки, криттеры, gag-предметы и проектная политика блокирования оставались вне универсального алгоритма.

Важные поля FindPathInput:

  • FromHex / ToHex — запрошенные начало и конец маршрута;
  • ToHexOffset — настоящее смещение цели внутри ToHex; непрерывная позиция цели равна центру ToHex плюс ToHexOffset. Используется только при расчёте конечного смещения FreeMovement;
  • MapSize — границы всех проверок;
  • MaxLength — максимальная длина маршрута в шагах, обычно получаемая из настроек движка. Маршрут ровно такой длины допустим; память выделяется блоками 16×16 только для затронутых гексов, а не пропорционально пределу;
  • CritterDetour — добавочная стоимость входа в гекс, занятый живым криттером. MapManager и MapView берут её из Geometry.PathFindCritterDetour (по умолчанию 12);
  • Cut — остановка при достижении заданного расстояния до цели; 0 требует точного достижения;
  • Multihex — радиус multihex-сущности;
  • FreeMovement — включает оптимизацию контрольных шагов через line tracer и непрерывное конечное смещение;
  • CheckTarget — необязательный предикат точной цели для поиска среди нескольких целей. При наличии заменяет проверку единственной пары ToHex / Cut; ближайшая достижимая цель возвращается в NewToHex;
  • CheckHex — callback, возвращающий состояние блокировки или откладывания.
  • EnclosureProbeLimit — бюджет обратного обхода после его превышения прямым фронтом; 0 отключает проверку. MapManager и MapView используют Geometry.PathFindEnclosureProbe (по умолчанию 1024).

FindPathOutput возвращает результат, шаги направлений, контрольные шаги, возможно скорректированный Cut конечный NewToHex и EndHexOffset как конкретный ipos16, равный нулю при выключенном FreeMovement.

MapManager::FindPathToAny() строит индексированный набор целей и передаёт его через CheckTarget, поэтому сервер может найти ближайшую достижимую точную цель одним поиском вместо отдельного полного поиска для каждого кандидата. Скриптовые серверные перегрузки Map.FindPathToAny(...) предоставляют ту же операцию для сырого начального гекса или криттера и возвращают выбранную цель и длину маршрута через выходные аргументы.

Если нужен ответ о достижимости каждой точки, а не один выбранный маршрут, PathFinding::FindReachable() выполняет один обход в ширину от FromHex до обнаружения всех целей либо исчерпания MaxLength шагов. Серверный Map.FindReachableHexes(fromHex, targetHexes, gagCallback) возвращает достижимые цели в порядке входного списка, сохраняя повторы, и отвергает координаты за границами карты. Проверяются препятствия карты и разрешённый callback проход через gag-предметы, но не занятость гекса криттером и не проходимость для конкретного multihex-актёра. Для выбора маршрута с учётом стоимости движения используйте FindPathToAny().

Поиск A* и выбор маршрута

FindPath() использует A*: стоимость пройденного пути плюс оставшееся расстояние в гексах до радиуса Cut цели. Для CheckTarget оценка оставшегося расстояния равна нулю, поэтому поиск равномерно расходится к ближайшей достижимой цели. Шаг стоит единицу, DeferGag добавляет десять, а DeferCritter добавляет CritterDetour (отрицательное значение при расчёте считается нулём). Короткий обход криттера предпочтительнее прохода через него, но стоящего в дверях можно пересечь вместо обхода всего здания. Раньше криттер стоил дороже любого пути без криттеров: окружённая ими цель заставляла поиск исчерпать большую свободную область, прежде чем попробовать пройти через толпу.

Для одной цели ToHex/Cut поиск до первого шага проверяет весь диск целей. Занятый криттером гекс можно пересечь за добавочную стоимость, но на нём не останавливаются, пока хотя бы один целевой гекс имеет состояние Passable или DeferGag. Если все цели заблокированы или заняты и хотя бы одна занята, маршрут может закончиться на занятой цели. Поиск с несколькими целями через CheckTarget не имеет такого диска и принимает выбранную цель как заданную. Правило игрового действия, которому нельзя останавливаться на другом криттере, нужно проверять на границе его собственного запроса движения.

Цель дальше MaxLength даже по прямой даёт TooFar без вызова CheckHex. Поиск не входит и в гекс, для которого длина пройденного пути плюс оставшееся расстояние превышает предел. Исчерпание обрезанной области даёт TooFar, замыкание всей достижимой области — NoWay. Ответ CheckHex для одного гекса кэшируется, но отпечаток multihex проверяется отдельно по стороне входа: в центр можно войти с одной стороны и не суметь с другой.

После нахождения самой дешёвой цели поиск рассматривает все варианты той же стоимости и восстанавливает самый прямой из дешёвых маршрутов. Среди равно дешёвых целей внутри Cut предпочтительна ближайшая к прямой от начала до ToHex. Очередь с корзинами стоимости обеспечивает одинаковый выбор маршрута на разных платформах.

Для одной цели проверка обходит гексы от её радиуса Cut в обратном направлении через всё, кроме Blocked. Если эта широкая область замкнулась до начала пути, результат — NoWay; при исчерпании бюджета прямой поиск продолжается. FindPathToAny() не имеет одной цели для проверки. Ранний успех не оплачивает обратный обход.

MapManager::FindPath(max_length) при 0 использует Geometry.MaxPathFindLength, ограничивает положительные значения этой настройкой и отвергает отрицательные. Серверный Critter.MoveToHex(hex, cut, endHexOffset, maxPathLength, speed, gagCallback) возвращает HexTooFar сверх предела. Задавайте его, когда обход лишает движение смысла.

Обратный проход должен перечислять GameSettings::MAP_DIR_COUNT через GeometryHelper::MoveHexByDirUnsafe(), а не жёстко задавать шесть соседей гекса. Гексагональная сборка компилирует шесть направлений, квадратная — восемь; общие помощники удерживают расширение поиска и восстановление пути на одинаковых правилах геометрии.

Конечное смещение FreeMovement

При установленном FreeMovement прямые отрезки могут заменять части найденного маршрута, если в каждый гекс можно войти со стороны этого отрезка, даже когда поиск не достигал его. Поиск по-прежнему заканчивает маршрут на целом гексе, но конечная позиция уточняется до точки внутри него вместо привязки к центру NewToHex. PathFinding::EvaluateFreeMovementEndOffset() вычисляет EndHexOffset относительно центра NewToHex так, чтобы непрерывная конечная позиция находилась точно на расстоянии Cut, R = dist(центр NewToHex, центр ToHex), от настоящей позиции цели центр ToHex + ToHexOffset, со стороны NewToHex. Расстояния используют Y-проекцию камеры GeometryHelper::GetYProj(), совпадающую с метрикой расстояний сегментов MovingContext.

Поведение:

  • EndHexOffset — обычный ipos16. При выключенном FreeMovement или досрочном выходе до его прохода сохраняется нулевое значение, то есть движущийся останавливается в центре достигнутого гекса.
  • Центрированная цель, до которой остановились с зазором (ToHexOffset == 0, Cut > 0), даёт EndHexOffset == 0.
  • Cut == 0 для смещённой цели даёт EndHexOffset == ToHexOffset, то есть точную остановку на цели.
  • Модуль смещения ограничен |ToHexOffset| и половиной гекса.
  • При вырожденном направлении остановки внутренний EvaluateFreeMovementEndOffset возвращает optional<ipos16> со значением nullopt, когда настоящая цель практически совпадает с центром достигнутого гекса, например Cut == 0 для центрированной цели или Cut > 0, когда смещение цели точно компенсирует вектор между гексами. Тогда FindPath подставляет FindPathInput::FromHexOffset, собственное текущее смещение движущегося, чтобы он остался в текущей точке, а не привязался к центру. MapView::FindPath и MapManager::FindPath автоматически заполняют FromHexOffset из переданного криттера.

Вызывающий передаёт ToHexOffset и непосредственно использует EndHexOffset: на клиенте через MapView::FindPath и учитывающую Cut перегрузку Critter.MoveToHex(hex, cut, hexOffset, speed), на сервере через MapManager::FindPath и Critter.MoveToHex(hex, cut, endHexOffset, speed). Разрешённое смещение проходит в существующем поле end-offset сообщения SendCritterMove как конкретный ipos16, поэтому клиентское предсказание и серверная авторитетность останавливаются в одной непрерывной точке без изменения протокола.

Модель блокировки

HexBlockResult выражает приоритет поиска пути:

  • Passable — гекс доступен;
  • Blocked — постоянная блокировка;
  • DeferGag — gag-предмет, проход добавляет стоимость десяти шагов;
  • DeferCritter — криттер, проход добавляет стоимость CritterDetour; остановка допустима лишь при отсутствии свободной цели.

Для multihex-сущностей CheckHexWithMultihex() проверяет переднюю дугу по направлению и возвращает худший результат среди проверенных гексов.

Когда игровой код меняет семантику блокировки, обновляйте поставщика callback и тесты; не встраивайте проектные правила в универсальный алгоритм пути.

Серверные Map::IsHexMovable() и IsHexShootable() объединяют две сетки: собственное Field карты, пересчитываемое RecacheHexFlags() по динамическим предметам и ручным блокировкам, и статическое StaticMap::Field того же гекса. Статическая половина читается через Map::GetStaticField() — именно там применяется удаление статических предметов конкретного экземпляра карты.

Флаги блокировки предмета на карте кэшируются по всему зарегистрированному следу: в начальном гексе, клетках MultihexMesh и клетках MultihexLines, развёрнутых от каждого начала. Изменения NoBlock, ShootThru, IsGag или IsTrigger пересчитывают все эти поля. Пересчёт только начала спрайта оставляет устаревшие серверные блокировки после открытия широкой двери, даже когда клиент уже допускает маршрут. Регрессия MultihexItemFlagChanges в MapHexOperations проверяет изменения проходимости и простреливаемости, сохраняя блокировку, которую другой предмет вносит в общую клетку.

DeferGag включается отдельно для каждого серверного или клиентского поиска. Map::CheckGagItem() и MapView::CheckGagItem() требуют флаг поля MovableWithGag, который устанавливается только когда все блокирующие движение предметы на гексе являются gag-предметами, и предикат вызывающего, принимающий каждый такой предмет. Без предиката MapManager::FindPath() и MapView::FindPath() возвращают для того же гекса Blocked, поэтому обычное движение не меняется. Клиентская скриптовая перегрузка Map.GetPath и серверные перегрузки Map.GetPathLength с gagCallback предоставляют тот же выбор: вызывающий может отличить маршрут, запечатанный стеной, от маршрута через дверь, которую ему разрешено открыть.

Серверная gag-проверка сначала отклоняет MoveBlocked в текущем статическом поле экземпляра карты и лишь затем рассматривает динамические gag-предметы. Разрешающий предикат не делает статическую стену проходимой. Удаление этого статического предмета через overlay экземпляра снимает его вклад в блокировку; оставшийся динамический gag по-прежнему требует разрешающего предиката. MapPathGagChecksRespectStaticBlockers проверяет обычный поиск пути, массовую достижимость и переход после удаления на настоящих запечённых static items.

Удаление статических предметов

Запечённые статические предметы живут в StaticMap (Source/Server/StaticMap.h), который MapManager ключует по ProtoMap и разделяет между всеми живыми экземплярами этой карты. Экземпляр всё равно может убрать отдельные статические предметы, не трогая эти общие данные.

Удаление одностороннее на всё время жизни экземпляра карты. RemovedStaticItemIds только растёт: попытку вынуть id обратно отклоняет setter свойства, который срабатывает до записи значения, поэтому запись не проходит, а сохранённый список и overlay остаются нетронутыми. Не рассчитывайте, что предмет вернётся на уже загруженные у игроков карты: живой клиентский путь умеет только убирать статический предмет, но не строить его заново, а отказ после записи сохранил бы укороченный список и молча отменил удаление при следующем старте сервера. MapManager::RegenerateMap() перегенерирует содержимое карты и оставляет удаления на месте; карте, которой нужен целый статический слой, нужен новый экземпляр.

Механика:

  • Map::RemovedStaticItemIds (Common Mutable PublicSync Persistent) — сохранённый список. Он и есть весь контракт: сохранение, синхронизация с клиентом и видимость из скриптов следуют из свойства.
  • Map выводит из него три кэша в RefreshRemovedStaticItems() → RebuildStaticOverlay(): множество удалённых id, vector уцелевших статических предметов и override StaticMap::Field для каждого гекса, который покрывал удалённый предмет. Все три пусты, пока карта хранит все запечённые предметы, поэтому нетронутая карта читает общую сетку без лишней косвенности.
  • Map::VerifyStaticItemRemovalsOnlyGrow() — страж append-only, вызываемый через setter ServerEngine::OnSetMapRemovedStaticItems. Он ничего не меняет и только бросает исключение, поэтому отклонённая запись оставляет карту ровно такой, какой она была.
  • Map::GetStaticField() возвращает override, когда он есть, иначе непустое поле runtime static items карты, иначе общую ячейку. Через него проходят все статические запросы (GetStaticItem, GetStaticItemOnHex, GetStaticItems, GetStaticItemsOnHex, GetStaticItemsInRadius, GetTriggerStaticItemsOnHex, IsTriggerStaticItemOnHex) и оба запроса блокировки, поэтому удалённый предмет исчезает из движения, стрельбы, триггеров и поиска одинаково.
  • StaticMap::ForEachItemHex() даёт гексы, которые покрывает предмет, а приватный StaticMap::ApplyItemToField() добавляет предмет в поле для загрузчика (AddStaticItem()), для пересборки overlay (BuildFieldWithout(), MergeFieldInto()) и для выгрузки runtime static items (RemoveStaticItems()), поэтому никто из них не может разойтись в том, какие гексы занимает предмет и какую блокировку он даёт.
  • StaticMap::Field::ScrollBlocked существует ради этой пересборки. Проход загрузчика по scroll-блокировке пишет MoveBlocked без владеющего предмета, поэтому override, собранный только из уцелевших предметов, молча открыл бы границу карты; пересборка сначала засевает MoveBlocked из ScrollBlocked.
  • Запечённые статические предметы несут ident_t, записанный их файлом карты и прочитанный вместе с содержимым карты при первом использовании (MapManager::ReadStaticMap()); runtime static items несут этот id плюс idOffset своей загрузки. Именно этот id ищет GetStaticItem() и несёт клиентский ItemHexView; список удалений записывает только запечённые id.

EntityManager::CallInit(Map, bool) строит overlay один раз на карту — этот хук покрывает и только что созданную карту, и восстановленную из базы. Изменения во время работы приходят через post-setter свойства ServerEngine::OnPostSetMapRemovedStaticItems, поэтому скрипт, пишущий свойство напрямую, получает ту же пересборку, что и вызывающий RemoveStaticItem().

На клиенте есть ровно два пути, и map view не хранит для этого собственного состояния. LoadStaticData() сразу пропускает id из списка удалённых, поэтому предмет не создаётся, не попадает в поле, не рисуется и не индексируется — запись всё равно прочитывается насквозь, потому что запечённые записи переменной длины и у читателя нет индекса для перемотки. ClientEngine::OnSetMapRemovedStaticItems вызывает MapView::ApplyStaticItemRemovals(), который уничтожает ставшие лишними view обычным путём DestroyItems(). Третьего пути нет: ничто на клиенте не восстанавливает статический предмет на загруженной карте.

Содержимое карт во время работы

Экземпляр карты может во время работы принять содержимое других proto-карт и позже вернуть его статическую часть. Проекты так подгружают большую карту частями, собирают карту из повторно используемых частей или ставят заготовку в работающую карту; что, куда и когда загружать, решают скрипты проекта.

  • Запечённое содержимое по требованию. MapManager::LoadFromResources() только проверяет, что у каждого .fomap-bin-server есть proto-карта. MapManager::GetStaticMap() читает запечённое содержимое proto-карты (ReadStaticMap(), в запросившем потоке), когда оно впервые нужно экземпляру карты, LoadRuntimeStaticItems, GenerateContent или Game.GetStaticItemsForProtoMap, и кэширует его. Незавершённое чтение попадает в кэш сразу, поэтому одновременные первые запросы одной карты ждут одного чтения и получают общий результат; неудачное чтение удаляется, и следующий запрос читает файл заново. Game.LoadStaticMap(mapPid, asyncLoad) и Game.LoadStaticMaps(mapPids, asyncLoad) заранее читают одну или несколько карт: в вызывающем потоке, если флаг ложен, иначе в рабочих потоках (у каждого свой контекст синхронизации) и сразу возвращаются; запрос карты, которая читается, ждёт её чтения, а Game.IsStaticMapLoaded(mapPid) сообщает о завершённом. ClearStaticMaps() дожидается фоновых чтений, прежде чем менеджер исчезнет. Game.UnloadStaticMap(mapPid) освобождает его и возвращает, выгружено ли оно: false, пока оно читается или пока существует экземпляр карты этого proto, потому что экземпляр читает содержимое своего proto через простой указатель. Поэтому повреждённый файл карты обнаруживается при первом использовании, а не при старте.
  • Статические предметы времени выполнения. Map.LoadRuntimeStaticItems(mapPid, destHex, idOffset) (MapManager::LoadRuntimeStaticItems()) копирует статические предметы источника в карту как её статические предметы времени выполнения (runtime static items) со сдвигом Hex и MultihexMesh так, что первый гекс источника попадает в destHex; каждая копия должна попасть на карту. Map.LoadRuntimeStaticItems(mapPid, destHex, sourceHex, sourceSize, idOffset) копирует только предметы, чей гекс лежит в этой области источника, которая должна лежать внутри исходной карты, а первый гекс области попадает в destHex; короткая форма — то же для всей исходной карты. Идентификатор каждой копии — её запечённый идентификатор плюс idOffset. Коллизия — ошибка, которая отклоняет всю загрузку до изменения карты: идентификатор копии переполняется, равен нулю, повторяется или уже занят запечёнными статическими предметами карты или её статическими предметами времени выполнения. Выбирайте idOffset для каждого размещения вне диапазонов запечённых идентификаторов и идентификаторов сущностей (Server.EntityStartId), чтобы клиент мог его повторить. Копии сохраняют привязанные статические и триггерные скрипты источника и живут в отдельной разреженной StaticMap размера карты (Server.MapInstanceGridType). Они не сохраняются: у восстановленной карты есть только запечённые статические предметы, и проект загружает своё содержимое заново.
  • Запросы и блокировка. Map::GetStaticField() читает сначала переопределение, затем непустое поле статических предметов времени выполнения, затем запечённое поле. Где запечённые предметы и предметы времени выполнения делят гекс, RefreshStaticFieldOverride() сводит их в переопределение, поэтому все статические запросы, оба запроса блокировки и триггеры видят и те и другие. GetStaticItems() возвращает запечённый список или список времени выполнения напрямую, когда непуст только один из них, иначе — объединённый список без удалённых предметов. HasStaticItem() и GetStaticItem() учитывают статические предметы времени выполнения, а IsRuntimeStaticItem() отличает их. RemoveStaticItem() отклоняет статический предмет времени выполнения, потому что список удалений сохраняет только запечённые идентификаторы.
  • Выгрузка. Map.UnloadRuntimeStaticItems(runtimeStaticItemIds) принимает только идентификаторы статических предметов времени выполнения этой карты. StaticMap::RemoveStaticItems() перестраивает каждое затронутое поле из оставшихся предметов и освобождает поля без предметов и без блока прокрутки.
  • Криттеры и динамические предметы. Map.GenerateContent(mapPid, destHex) создаёт криттеров, предметы карты и вложенные в них предметы источника так же, как GenerateMapContent() наполняет новую карту, с первым гексом источника в destHex, после проверки, что все они попадают на карту. Метод возвращает идентификаторы созданных криттеров и предметов карты; это обычные сущности, которые сохраняются вместе с картой, пока проект их не уничтожит.
  • Клиенты. Движок не пересылает статические предметы времени выполнения. Клиентский скрипт вызывает Map.LoadRuntimeStaticItems(mapPid, destHex, idOffset) или её перегрузку с областью на своём представлении карты, и оно читает клиентскую выпечку той же proto-карты ({map}.fomap-bin-client), для чего эта proto-карта нужна на клиенте; клиент передаёт серверный idOffset, поэтому каждый предмет получает тот идентификатор, который дал сервер, а те же коллизии отклоняют загрузку. Map.UnloadRuntimeStaticItems(runtimeStaticItemIds) уничтожает эти представления и освобождает пустые клиентские блоки полей в области выгруженных статических предметов времени выполнения, кроме блоков в пределах одного блока от текущего вида: на их поля могут ссылаться спрайты карты ради света.

Контракт закрепляют MapLoadsAndUnloadsContentOfOtherMaps и ClientMapLoadsAndUnloadsStaticItemsOfOtherMaps.

Трассировка линий

LineTracer считает в координатах float64_t относительно своего стартового гекса; в квадратной геометрии каждая позиция берётся из номера шага, поэтому округление не накапливается вдоль линии. Форма с углом и расстоянием находит конец луча от гекса в начале карты с той же чётностью столбца, что у старта, и затем сдвигает его обратно на целое число клеток. Поэтому округление не зависит ни от того, где лежит линия, ни от её длины, и линия далеко на карте размером с континент проходит те же шаги, что и у начала координат.

TraceLineInput трассирует от StartHex к TargetHex: MaxDist ограничивает длину (0: расстояние между ними), Angle задаёт направление, CheckLastMovable запрашивает последний проходимый гекс, IsHexBlocked останавливает линию, необязательный IsHexMovable отмечает допустимые кандидаты движения. TraceLineOutput сообщает полноту прохода, наличие последнего проходимого гекса, гекс перед блокировкой, заблокированный и последний проходимый гексы. Применения: движение/поиск пути, видимость и стрельба.

Серверные скрипты вызывают native Map::GetMovableHexesInSight как Map.GetMovableHexesInSight(origin, radius) под cover карты. Исходный гекс за границами или отрицательный включительный радиус вызывают исключение. Результат — проходимые гексы по возрастанию x/y с GeometryHelper::GetDistance <= radius; исходному гексу трасса не нужна. Остальные совпадают со скалярным TracePath(..., distance, 0.0f).PreBlock через PathFinding::TraceLine и Map::IsHexShootable. Каждый вызов читает текущие динамические/ручные/статические блокировки экземпляра без кэширования. Видимость, зрение, дружественность, скрытность и приватность остаются у проекта.

Трассировка направления

TraceDirectionInput содержит StartHex, смещение отображения StartHexOffset, Dir, точку линии (RayHex + RayHexOffset), MaxSteps, Multihex, MapSize, CheckHex и Slide. PathFinding::TraceDirection() выбирает между направлениями по обе стороны входного угла по расстоянию центра гекса до линии в проецируемой камерой плоскости MovingContext, без перенацеливания на округлённую цель. Только Passable допускает шаг: gags/криттеры блокируют. TraceDirectionOutput возвращает шаги, control steps и EndHexOffset:

  • Без отклонения один прямой сегмент заканчивается проекцией на линию. Точка линии — начало нового движения либо всего удержания при продолжении; округление между планами не накапливается.
  • Slide заменяет заблокированный шаг свободным соседом с ближайшим углом, строго меньше 90° от ввода, и продолжает параллельно из достигнутого гекса. Шаги идут вперёд без колебаний/возврата. После скольжения используются центры гексов, control step после каждого бокового шага и нулевой EndHexOffset. Ввод перпендикулярно плоской стене останавливает движение. Граница карты останавливает без Slide и направляет вдоль края с ним.

MapView::TraceMoveWay() добавляет блокировку карты; его вызывает только клиентский контроллер прямого управления.

Контексты движения

Source/Common/Movement.h определяет состояние и интерполяцию движения:

  • MovingState — причина завершения или ошибки;
  • MovingMetrics — конечный гекс, полное время и расстояние;
  • MovingProgress — текущий гекс, смещение, направление и флаг завершения;
  • MovingRawProgress — внутренние данные сегмента и прогресса;
  • MovingContext — план движения со счётчиком ссылок и runtime-вычислитель.

MovingContext хранит:

  • размер карты;
  • скорость;
  • шаги направлений;
  • контрольные шаги;
  • начальное время и временное смещение;
  • начальный и конечный гекс и смещения;
  • вычисленные полное время и расстояние;
  • состояние завершения и заблокированный и предшествующий гексы.

Основные операции:

  • вычисление: EvaluateMetrics(), EvaluateProjectedHex(), EvaluateNearestPathHex(), EvaluatePathHexes(), EvaluateProgress();
  • продвижение времени: UpdateCurrentTime(), UpdateCurrentTimeToNextHex();
  • изменение runtime-состояния: ChangeSpeed(), Complete(), SetBlockHexes();
  • проверка согласованности: ValidateRuntimeState().

Таким образом, движение — переиспользуемый зависящий от времени план, а не просто список позиций. Клиентское предсказание, серверная коррекция и видимые скриптам данные должны сохранять это различие.

_offsetTime сообщает наблюдателю, насколько уже продвинулся план: сервер включает его в сообщение клиенту, но исходный клиентский запрос движения такого поля не содержит. Поэтому сервер запускает план игрока позже клиента на время передачи запроса. При обычном завершении клиент сообщает о прибытии, а сервер согласует позицию вдоль того же пути до обработки следующего упорядоченного запроса; см. сетевой протокол.

Серверная и клиентская обработка сохраняет активный MovingContext независимо от CritterCondition. Игровые скрипты владеют разрешениями движения по состоянию и могут представлять падение в knockout, скольжение мёртвого тела или движение пользовательского состояния, используя те же путь, смещение и завершение. Присоединённые криттеры всё равно останавливаются, поскольку attachment является отношением транспорта и владения, а не состоянием криттера.

Разреженное хранение полей

DynamicTwoDimensionalGrid хранит ячейки, созданные записью. Resize проверяет новые границы, обходит только сохранённые ячейки и удаляет вышедшие за новый размер. Значения внутри пересечения сохраняются; увеличение не создаёт отсутствующие ячейки и не возвращает ранее удалённые. Объём работы зависит от числа сохранённых ячеек, а не площади старой или новой карты. Тесты TwoDimensionalGrid проверяют сохранение и удаление при уменьшении и увеличении независимо от геометрии карты.

Загрузка карт

Source/Common/MapLoader.h экспортирует MapLoader::Load():

static void Load(
    string_view name,
    string_view file_name,
    const string& buf,
    const EngineMetadata& meta,
    HashResolver& hash_resolver,
    const CrLoadFunc& cr_load,
    const ItemLoadFunc& item_load);

Загрузчик разбирает буфер карты, использует file_name в диагностике источника и вызывает предоставленные движком или runtime callback для криттеров и предметов:

  • CrLoadFunc(ident_t id, ptr<const ProtoCritter> proto, ptr<const map<string_view, string_view>> kv);
  • ItemLoadFunc(ident_t id, ptr<const ProtoItem> proto, ptr<const map<string_view, string_view>> kv).

Парсинг файла остаётся универсальным, а сервер и инструменты решают, как загруженные криттеры и предметы превращаются в живые сущности или объекты редактора.

MapLoader::EnumerateMaps(file_name, buf) возвращает имена карт, объявленных исходным файлом, не материализуя их сущности. При изменении парсера сохраняйте тестовое покрытие диагностики имени источника и перечисления нескольких карт.

Производство ресурса карты соседствует с baking. О MapBaker см. Baking Pipeline.

Тесты для проверки

Подходящие тесты:

  • Source/Tests/Test_Geometry.cpp
  • Source/Tests/Test_PathFinding.cpp
  • Source/Tests/Test_Movement.cpp
  • Source/Tests/Test_MapLoader.cpp
  • Source/Tests/Test_MapBaker.cpp
  • Source/Tests/Test_ServerMapOperations.cpp
  • Source/Tests/Test_AngelScriptServerMapOperations.cpp

Маршрутизация изменений

  • Типы координат и значений: Source/Common/Geometry.* и документация сгенерированных метаданных.
  • Трассировка линий: Source/Common/LineTracer.* и Source/Common/PathFinding.*.
  • Поиск и блокирование пути: Source/Common/PathFinding.* плюс предоставленные вызывающим callback блокировок.
  • Интерполяция и состояние движения: Source/Common/Movement.*.
  • Клиентское представление 2D-ходьбы и бега поверх интерполяции: Sprite Root Motion.
  • Парсинг файла карты: Source/Common/MapLoader.*.
  • Baking ресурса карты: Source/Tools/MapBaker.* и Baking Pipeline.
  • Runtime-поведение сущности карты: серверная среда выполнения, клиентская среда выполнения и модель сущностей.

Контрольный список проверки

  1. Запустите относящиеся к алгоритму тесты движения, пути и загрузчика карт.
  2. При изменении блокировок проверьте прямое движение и multihex.
  3. При изменении направлений или проекции проверьте предположения FO_GEOMETRY.
  4. При изменении парсера проверьте загрузку настоящих baked-ресурсов карт подключающего проекта.
  5. Если состояние движения реплицируется, проверьте сеть и поведение клиента и сервера; при изменении потока пакетов или свойств обновите Networking.
  6. Если меняется видимое скриптам API движения, обновите GeneratedApiAndMetadata.md, а при изменении сигнатур или nullability также Nullability.md.
Введите запрос.