Синхронизация мира
Система World Sync отвечает за синхронизацию позиций игроков в реальном времени, отрисовку удалённых игроков, координацию состояния NPC и систему каналов/экземпляров. Она построена на API матчей реального времени Nakama, с одним матчем на карту.
У неё два транспорта, которые выбираются через OnlineConfig.WorldSyncMode:
| Режим | Как передаются позиции | Кто решает, кто кого видит |
|---|---|---|
Authoritative (по умолчанию) | Каждый клиент отправляет свою позицию в матч мира на сервере (плагин на Go), который отправляет каждому получателю один пакет за тик только с тем, что этот получатель видит | Сервер (область интереса в матче на Go) |
Relayed | Матч ретранслирует каждую позицию каждому игроку карты | Каждый клиент (OnlineConfig.AOIRadius) |
Authoritative масштабируется до тысяч игроков; ретрансляция деградирует примерно со ста игроков в одном матче. В остальной части страницы указано, какие части относятся к одному из режимов.

Всё, что описывает эта страница, видно на этом единственном кадре: значок канала и
Online — Channel 1 вверху слева, число ближайших игроков, которое даёт фильтрация
по области интереса (Area-of-Interest), и индикатор задержки/сервера внизу. Если число равно нулю,
когда вы ожидаете компанию, первым делом проверьте AOI.
Обзор архитектуры
Стек синхронизации мира состоит из следующих компонентов:
| Класс | Файл | Ответственность |
|---|---|---|
WorldSyncService | World/WorldSyncService.cs | Подключение к матчу карты, рассылка позиции, маршрутизация состояния матча, AOI на стороне клиента (режим ретрансляции) |
WorldBatchDecoder | World/WorldBatchDecoder.cs | Декодирует пакет сервера за тик (авторитетный режим) в те же события AOI |
PlayerNetworkEntity | World/PlayerNetworkEntity.cs | Отрисовка удалённого игрока, интерполяция, табличка с именем, спрайт последователя |
PlayerSpawnManager | World/PlayerSpawnManager.cs | Создание/удаление GameObject удалённых игроков по событиям AOI и отрисовка их эмоций |
ChannelManager | World/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 | Константа | Направление | Назначение |
|---|---|---|---|
1 | OP_POSITION | клиент → матч | Обновление позиции игрока (x, y, z, направление, карта, а также имя, внешний вид, уровень, гильдия, флаги бега/велосипеда/плавания) |
2 | OP_MAP_CHANGE | в обе стороны | Игрок перешёл на другую карту |
3 | OP_EMOTE | в обе стороны | Эмоция игрока: см. Онлайн-эмоции |
4 | OP_FOLLOWER_UPDATE | в обе стороны | Изменился монстр-последователь игрока |
10 | WorldBatchDecoder.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 тайлов). Когда приходит обновление позиции удалённого игрока:
- Вычисляется расстояние до локального игрока.
- Если он в радиусе AOI и ещё не отслеживается, вызывается
OnPlayerEnterAOI. - Если он в AOI, вызывается
OnPlayerPositionUpdateс(userId, position, direction). - Если он вне 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 про ловлю монстров.
Конфигурация
| Параметр | По умолчанию | Описание |
|---|---|---|
DefaultChannel | 1 | Канал, к которому присоединяются при подключении |
MaxChannels | 10 | Максимум каналов на мир |
Переключение каналов
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).
Оптимизация трафика
Несколько механизмов минимизируют сетевой трафик:
- Ограничение частоты позиции:
PositionSyncRateограничивает число сообщений в секунду (по умолчанию: 10/с). - Фильтрация по мёртвой зоне: позиции в пределах 0,01 единицы от последней синхронизации подавляются.
- Отсечение по AOI: в авторитетном режиме сервер отправляет только то, что видит получатель (один пакет за тик); в режиме ретрансляции обрабатываются только игроки в пределах
AOIRadiusтайлов. - Максимум видимых игроков:
MaxVisiblePlayersограничивает число удалённых игроков, которыхPlayerSpawnManagerдержит на экране (по умолчанию: 50). - Дедупликация последователей: рассылки последователя того же вида в течение 5 секунд подавляются.
- Передача в основной поток: все вызовы 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;
}
Сервер применяет по порядку:
- Идентичность —
player_idдолжен быть равенctx.userId(поддельный идентификатор или отсутствие сессии отклоняются). - Допустимость координат —
target_x/target_yдолжны быть конечными и находиться в пределах±100000мировых единиц (это также отклоняет поддельнуюInfinity). - Ограничение частоты — ограничение на пользователя на основе хранилища: 30 телепортов / 10 с
(бакет
validate_teleport). Клиент не может его отключить; отсчёт идёт по серверному времени, поэтому окно подделать нельзя. - Белый список сцен —
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
Обновления позиции проверяются на сервере, а не в журнале клиента: клиент не печатает каждое обновление, поэтому поиск там ничего бы не нашёл и ничего не доказал.
На экране


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