Перейти к основному содержимому

Синхронизация мира

Система World Sync отвечает за синхронизацию позиций игроков в реальном времени, отрисовку удалённых игроков, координацию состояния NPC и систему каналов/экземпляров. Она построена на API матчей реального времени Nakama, с одним матчем на карту.

У неё два транспорта, которые выбираются через OnlineConfig.WorldSyncMode:

РежимКак передаются позицииКто решает, кто кого видит
Authoritative (по умолчанию)Каждый клиент отправляет свою позицию в матч мира на сервере (плагин на Go), который отправляет каждому получателю один пакет за тик только с тем, что этот получатель видитСервер (область интереса в матче на Go)
RelayedМатч ретранслирует каждую позицию каждому игроку картыКаждый клиент (OnlineConfig.AOIRadius)

Authoritative масштабируется до тысяч игроков; ретрансляция деградирует примерно со ста игроков в одном матче. В остальной части страницы указано, какие части относятся к одному из режимов.

Мир в онлайн-сессии с HUD синхронизации мира

Всё, что описывает эта страница, видно на этом единственном кадре: значок канала и Online — Channel 1 вверху слева, число ближайших игроков, которое даёт фильтрация по области интереса (Area-of-Interest), и индикатор задержки/сервера внизу. Если число равно нулю, когда вы ожидаете компанию, первым делом проверьте AOI.

Обзор архитектуры​

Стек синхронизации мира состоит из следующих компонентов:

КлассФайлОтветственность
WorldSyncServiceWorld/WorldSyncService.csПодключение к матчу карты, рассылка позиции, маршрутизация состояния матча, AOI на стороне клиента (режим ретрансляции)
WorldBatchDecoderWorld/WorldBatchDecoder.csДекодирует пакет сервера за тик (авторитетный режим) в те же события AOI
PlayerNetworkEntityWorld/PlayerNetworkEntity.csОтрисовка удалённого игрока, интерполяция, табличка с именем, спрайт последователя
PlayerSpawnManagerWorld/PlayerSpawnManager.csСоздание/удаление GameObject удалённых игроков по событиям AOI и отрисовка их эмоций
ChannelManagerWorld/WorldSyncService.csПереключение серверных каналов/экземпляров и запросы численности

Кроме того, NPCSyncService (в World/PlayerNetworkEntity.cs) отвечает за блокировку взаимодействия с NPC между игроками, а FollowerUpdateData определяет сетевой протокол для синхронизации монстров-последователей. Оба транспорта вызывают одни и те же события, поэтому код, рисующий игроков, не знает, какой из них активен.

WorldSyncService​

Инициализация​

WorldSyncService инициализируется автоматически во время NakamaManager.FinalizeConnection():

await WorldSync.InitializeAsync(userId);

Это лишь подготавливает сервис. Сам матч присоединяется как только становится известна карта (сцена игрока) и снова при каждой смене карты:

  • Авторитетный режим: клиент вызывает RPC world_join с { "mapId": "<map>" }. Сервер возвращает matchId авторитетного матча этой карты (создаёт его, если он ещё не существует, и отказывает для карты, по которой у него нет переписи), а клиент присоединяется к нему через сокет. Если плагин сервера на Go не загружен, присоединение завершается ошибкой в консоли: молчаливого перехода на ретранслируемый транспорт нет.
  • Режим ретрансляции: клиент создаёт ретранслируемый матч с именем world_<mapId> или присоединяется к нему (WorldSyncService.WorldMatchNameFor).

Когда игрок меняет карту, он покидает старый матч, забывает своих соседей (менеджер создания их удаляет) и объявляет свою позицию и последователя в новом.

Протокол состояния матча​

Вся коммуникация синхронизации мира использует сообщения состояния матча Nakama со следующими кодами операций:

OpCodeКонстантаНаправлениеНазначение
1OP_POSITIONклиент → матчОбновление позиции игрока (x, y, z, направление, карта, а также имя, внешний вид, уровень, гильдия, флаги бега/велосипеда/плавания)
2OP_MAP_CHANGEв обе стороныИгрок перешёл на другую карту
3OP_EMOTEв обе стороныЭмоция игрока: см. Онлайн-эмоции
4OP_FOLLOWER_UPDATEв обе стороныИзменился монстр-последователь игрока
10WorldBatchDecoder.OP_BATCHматч → клиентПакет авторитетного матча за тик

Входящее состояние матча маршрутизируется через HandleMatchState(). Пакет (op 10) разрешается первым, поскольку у него нет отправителя: он приходит от самого матча. Все остальные сообщения игнорируют локального игрока и распределяются по коду операции. Операции 2, 3 и 4 — события, которые сервер пересылает другим игрокам как есть.

Авторитетный пакет​

В авторитетном режиме матч мира на сервере работает на 10 тиках в секунду и отправляет один пакет op 10 на получателя за тик, содержащий только то, что этот получатель может видеть:

  • взаимные друзья в первую очередь, даже если они далеко;
  • затем ближайшие игроки в радиусе области интереса (20 тайлов), вплоть до лимита (по умолчанию 50, MCE_WORLD_MAX_VISIBLE на сервере);
  • с гистерезисом: игрок, уже ставший видимым, остаётся видимым до 4 тайлов за пределами радиуса, чтобы граница не мерцала.

Пакет компактен. Игроки определяются слотом в рамках матча и поколением, которое растёт при повторном использовании слота (чтобы клиент никогда не применял к новому игроку то, что знал о предыдущем):

{ "t": tick, "k": 1 on a keyframe,
"p": [[slot, gen, x, y, dir, flags(, z)]], positions; flags: run=1 bike=2 swim=4
"s": [[slot, gen, userId, name, look, lvl, guild, gm]], identity, only when the recipient does not know it yet
"f": [[slot, gen]] } left the recipient's area (or the match)

Неподвижные игроки не пересылаются повторно, игрок, входящий в область, приходит полностью, даже если он неподвижен, а ключевой кадр каждые 5 секунд пересылает позиции. WorldBatchDecoder превращает пакет в те же события OnPlayerEnterAOI, OnPlayerPositionUpdate и OnPlayerExitAOI, которые вызывает путь ретрансляции.

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

Рассылка позиции​

Позиция локального игрока отправляется через UpdateLocalPosition():

NakamaManager.Instance.WorldSync.UpdateLocalPosition(
position, mapId, direction);

Правила ограничения частоты:

  • Ограничение частоты: задаётся OnlineConfig.PositionSyncRate (по умолчанию: 10 сообщений/секунду).
  • Мёртвая зона: позиции в пределах 0,01 единицы от последней синхронизированной подавляются.

Нагрузка — сериализованный в JSON PositionData с полями x, y, z, dir (направление взгляда) и map, плюс всё, что нужно получателям для отрисовки игрока: name (для таблички с именем), look (имя CharacterType для отрисовки), lvl (уровень сильнейшего монстра команды), тег гильдии, является ли игрок гейм-мастером, а также флаги run, bike и swim.

Рассылка последователя​

Когда монстр-последователь локального игрока меняется, разошлите обновление:

var data = FollowerUpdateData.FromMonster(monster, form, gender);
NakamaManager.Instance.WorldSync.BroadcastFollowerUpdate(data);

// To clear (hide follower for remote players):
NakamaManager.Instance.WorldSync.BroadcastFollowerUpdate(
FollowerUpdateData.CreateClear());

FollowerUpdateData включает: speciesId, formIndex, gender, isShiny, isEgg и nickname. speciesId, равный 0, — сигнал «очистить».

Рассылки последователя дедуплицируются: тот же вид в окне перезарядки 5 секунд подавляется.

Фильтрация по области интереса (AOI)​

Фильтрация по AOI не даёт клиенту обрабатывать обновления игроков, находящихся слишком далеко.

В авторитетном режиме фильтрует сервер (см. пакет выше): клиент получает только тех игроков, которых должен рисовать, а OnlineConfig.AOIRadius не меняет то, что отправляет сервер.

В режиме ретрансляции фильтрует клиент. Радиус настраивается через OnlineConfig.AOIRadius (по умолчанию: 20 тайлов). Когда приходит обновление позиции удалённого игрока:

  1. Вычисляется расстояние до локального игрока.
  2. Если он в радиусе AOI и ещё не отслеживается, вызывается OnPlayerEnterAOI.
  3. Если он в AOI, вызывается OnPlayerPositionUpdate с (userId, position, direction).
  4. Если он вне AOI и ранее отслеживался, вызывается OnPlayerExitAOI, и игрок удаляется из NearbyPlayers.

События​

WorldSync.OnPlayerEnterAOI += (OnlinePlayerInfo info) => { /* spawn */ };
WorldSync.OnPlayerExitAOI += (string userId) => { /* despawn */ };
WorldSync.OnPlayerPositionUpdate += (string userId, Vector3 pos, int dir) => { /* move */ };
WorldSync.OnPlayerMapChanged += (string userId, string mapId) => { /* map transition */ };
WorldSync.OnFollowerUpdate += (string userId, FollowerUpdateData data) => { /* follower change */ };

Словарь NearbyPlayers предоставляет снимок только для чтения:

IReadOnlyDictionary<string, OnlinePlayerInfo> nearby =
NakamaManager.Instance.WorldSync.NearbyPlayers;

PlayerNetworkEntity​

Каждый удалённый игрок представлен MonoBehaviour PlayerNetworkEntity на созданном GameObject. Он обрабатывает:

Инициализация​

entity.Initialize(onlinePlayerInfo);

Задаёт userId, displayName, guildTag, level, состояние сражения и флаг возможности наблюдения из OnlinePlayerInfo.

Интерполированное движение​

Когда вызывается ReceivePositionUpdate(position, direction):

  • Если расстояние превышает snapDistance (по умолчанию: 5 тайлов), сущность мгновенно телепортируется (обрабатывает смены карты и варпы).
  • Иначе сущность интерполируется к цели со скоростью interpolationSpeed (по умолчанию: 10).

Анимация спрайта​

Удалённые игроки используют цикл ходьбы на 4 направления по 4 кадра (всего 16 спрайтов). Анимация идёт со скоростью 0,2 секунды на кадр, пока сущность движется. В покое показывается первый кадр текущего направления. Отражение влево/вправо обрабатывается через SpriteRenderer.flipX.

Отображение последователя​

Последователи удалённых игроков управляются через SetFollower(FollowerUpdateData):

  • Ненулевые данные с speciesId > 0 показывают дочерний спрайт последователя.
  • null или speciesId == 0 вызывает ClearFollower(), скрывая спрайт.
  • Свойства HasActiveFollower и CurrentFollowerSpeciesId предоставляют текущее состояние.

Взаимодействие​

Щелчок по удалённому игроку вызывает OnClicked. Свойство IsInBattle и метод SetBattleStatus() отслеживают, находится ли игрок в сражении (показывает визуальный индикатор на табличке с именем).

PlayerSpawnManager​

PlayerSpawnManager — MonoBehaviour, который слушает события AOI от WorldSyncService и автоматически управляет GameObject удалённых игроков.

  • OnPlayerEnterAOI: создаёт экземпляр playerNetworkPrefab (или пустой GameObject с SpriteRenderer + PlayerNetworkEntity), вызывает Initialize() и регистрирует его.
  • OnPlayerExitAOI: уничтожает соответствующий GameObject и удаляет его из словаря отслеживания.
  • OnPlayerPositionUpdate: вызывает ReceivePositionUpdate() у соответствующей сущности.

Доступ ко всем созданным в данный момент игрокам:

var spawned = spawnManager.GetSpawnedPlayers();

Синхронизация NPC​

NPCSyncService обеспечивает согласованное состояние NPC для всех подключённых игроков.

Блокировка взаимодействия​

Когда игрок разговаривает с NPC в онлайн-мире, NPC «блокируется», чтобы другие игроки не могли взаимодействовать одновременно:

npcSync.RegisterNPC("npc_nurse_joy", position, "PalletTown");

// Player starts talking
bool engaged = npcSync.TryEngageNPC("npc_nurse_joy", userId);

// Player finishes conversation
npcSync.ReleaseNPC("npc_nurse_joy");

NPCNetworkState отслеживает: NpcId, Position, MapId, IsEngaged, EngagedByUserId и DialogState.

OnNPCStateChanged вызывается всякий раз, когда меняется состояние взаимодействия NPC.

Система каналов​

ChannelManager предоставляет систему каналов/экземпляров в стиле MMO, подобную популярным MMO про ловлю монстров.

Конфигурация​

ПараметрПо умолчаниюОписание
DefaultChannel1Канал, к которому присоединяются при подключении
MaxChannels10Максимум каналов на мир

Переключение каналов​

bool switched = await NakamaManager.Instance.Channels.SwitchChannelAsync(3);
int current = NakamaManager.Instance.Channels.CurrentChannel;

Значения канала должны быть от 1 до MaxChannels. При успешном переключении вызывается событие OnChannelChanged.

Численность канала​

Dictionary<int, int> populations =
await NakamaManager.Instance.Channels.GetChannelPopulationsAsync();

Событие OnChannelPopulationUpdate предоставляет обновления (channel, playerCount).

Оптимизация трафика​

Несколько механизмов минимизируют сетевой трафик:

  1. Ограничение частоты позиции: PositionSyncRate ограничивает число сообщений в секунду (по умолчанию: 10/с).
  2. Фильтрация по мёртвой зоне: позиции в пределах 0,01 единицы от последней синхронизации подавляются.
  3. Отсечение по AOI: в авторитетном режиме сервер отправляет только то, что видит получатель (один пакет за тик); в режиме ретрансляции обрабатываются только игроки в пределах AOIRadius тайлов.
  4. Максимум видимых игроков: MaxVisiblePlayers ограничивает число удалённых игроков, которых PlayerSpawnManager держит на экране (по умолчанию: 50).
  5. Дедупликация последователей: рассылки последователя того же вида в течение 5 секунд подавляются.
  6. Передача в основной поток: все вызовы API Unity маршалируются в основной поток через NakamaManager.RunOnMainThread(), что предотвращает межпоточные проблемы.

Серверная авторитетная проверка телепорта​

Варпы и телепорты между картами проверяются на стороне сервера через RPC mce_validate_teleport (RpcIds.ValidateTeleport), которую обрабатывает rpcMceValidateTeleport в src/main.ts. Это реальная, принудительная проверка, а не заглушка, и сервер никогда не изображает успех.

Клиент отправляет TeleportValidateRequest:

[Serializable]
public class TeleportValidateRequest
{
public string player_id; // must equal the caller's session user id
public string target_scene; // e.g. "pallet_town"
public float target_x;
public float target_y;
}

Сервер применяет по порядку:

  1. Идентичность — player_id должен быть равен ctx.userId (поддельный идентификатор или отсутствие сессии отклоняются).
  2. Допустимость координат — target_x / target_y должны быть конечными и находиться в пределах ±100000 мировых единиц (это также отклоняет поддельную Infinity).
  3. Ограничение частоты — ограничение на пользователя на основе хранилища: 30 телепортов / 10 с (бакет validate_teleport). Клиент не может его отключить; отсчёт идёт по серверному времени, поэтому окно подделать нельзя.
  4. Белый список сцен — target_scene должна быть ИЗВЕСТНОЙ сценой: небольшой встроенный запасной список (pallet_town, route_1, viridian_city) ИЛИ запись в заполняемой коллекции хранилища scene_registry (системный пользователь, чтение публичное / запись сервером). Неизвестная сцена регулируется серверным авторитетным dev_mode: разрешающе (разрешить и предупредить) на dev-узле, с закрытием при сбое на prod-sim-узле (отклоняется с code: "UNKNOWN_SCENE").

При успехе сервер возвращает { success:true, scene, position_x, position_y }. При любой ошибке он возвращает { success:false, error, code? }, повторяя запрошенные сцену/позицию. Клиент блокирует телепорт при любом success:false; прежнее мягкое разрешение NOT_IMPLEMENTED (OnlineConfig.TeleportSoftAllowWhenUnimplemented) сохранено ТОЛЬКО как запасной вариант для более старого сервера, у которого этого обработчика ещё нет.

Серверная защита движения (анти-телепорт)​

Даже обычные рассылки позиций контролируются. Те же проверки выполняются в обоих транспортах: в цикле авторитетного матча и в хуке beforeMatchDataSend (src/world/presence.ts) ретранслируемого матча. Для каждого обновления OP_POSITION сервер сравнивает расстояние по прямой с последней записанной позицией отправителя, используя измеренное сервером прошедшее время.

  • Скачок отбрасывается из ретрансляции, только если его расстояние превышает 30 u/s × elapsed + 4 u — такой, который должен был пройти через mce_validate_teleport. Законный бег/езда на велосипеде (велосипед идёт на <~12 u/s, радиус AOI равен 20 u) никогда не даёт ложных срабатываний.
  • Всегда принимаются (и берутся за новую основу): игроки, замеченные впервые, смены карты (варп между картами — другое координатное пространство), отсутствующие координаты и промежутки > 5 s (не поддаются оценке — это также само исправляет любое ложное срабатывание).
  • При отклонении основа не сдвигается, поэтому телепортированная позиция никогда не распространяется. Проверка отказывает открытым образом (никогда не блокирует ретрансляцию).
  • Слишком большая нагрузка (более 8 КБ) отбрасывается раньше всего остального.

Сервер также отражает каждый принятый OP_POSITION (карта + позиция) в коллекцию world_presence (запись при изменении + обновление каждые 60 с), давая бэкенду авторитетное распределение по картам.

Корректное завершение​

При отключении WorldSyncService.StopSyncAsync() вызывается автоматически:

await WorldSync.StopSyncAsync();

Он покидает матч мира, очищает worldMatchId и опустошает словарь nearbyPlayers.

Проверено на двух настоящих клиентах​

Всё вышеописанное рассказывает, как устроен код. В этом разделе записано, что два собранных игровых клиента действительно сделали 2026-09-01, работая с живым сервером Nakama, — потому что описание устройства и работающая функция — не одно и то же утверждение.

Зачем нужен этот раздел

Эта страница существовала и была точна в отношении кода, в то время как игроки не видели друг друга: удалённый аватар создавался в начале координат мира, а не в позиции владельца. Строка журнала [Spawn] Remote player spawned: JugadorB записывалась и когда всё работало, и когда было сломано, поэтому ничто в журналах не противоречило странице.

Исправление и приведённые ниже свидетельства получены при управлении настоящей сборкой. Всё, что эта страница утверждает как работающее, утверждается потому, что это показал запуск с двумя клиентами.

Что запускалось​

Два отдельных собранных клиента (MCE_Online.exe, по одному на папку — два экземпляра из одной папки не запускаются), у каждого свой журнал, вход в разные аккаунты, вход на одну и ту же карту.

Каждый клиент видит другого в его реальной позиции​

client A log:  [Spawn] Remote player spawned: JugadorB en (-2.00, 13.00, 0.00), sprite=SI, activo=True
client B log: [Spawn] Remote player spawned: NegProg en (-3.00, 7.00, 0.00), sprite=SI, activo=True

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

Движение распространяется​

Пока A шёл, сервер получил от него 32 различные позиции, включая интерполяцию:

"x":-1.0,"y":8.87   "x":-1.0,"y":8.43   "x":-1.0,"y":8.00
"x":-1.0,"y":7.67 "x":-1.0,"y":7.27 "x":-1.0,"y":7.00

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

На экране​

Вид игрока A: удалённый игрок с табличкой имени и последователем

Вид игрока B в тот же момент

Удалённый аватар отрисовывается с табличкой (имя и уровень) и своим монстром-последователем.

Известные ограничения, названные прямо​

  • В режиме ретрансляции фильтрация по AOI выполняется на стороне клиента. Каждая рассылка позиции доходит до каждого клиента в матче карты; каждый клиент отбрасывает то, что вне диапазона. Поэтому изменённый клиент может видеть игроков дальше, чем задумано. У авторитетного транспорта (по умолчанию) такого ограничения нет: сервер отправляет каждому клиенту только то, что тот может видеть. Приведённые ниже свидетельства записаны на ретранслируемом транспорте.
  • Стоящий игрок рассылает позицию один раз. Позиция отправляется при изменении плюс периодическое обновление, поэтому игрок, который никогда не двигается, заявляет о себе один раз. Любой получатель, отбросивший этот первый пакет, не получает второго шанса — именно так и прожил ошибка начала координат.