Saltar al contenido principal

Sincronización del mundo

El sistema World Sync gestiona la sincronización en tiempo real de la posición de los jugadores, el renderizado de los jugadores remotos, la coordinación del estado de los NPC y el sistema de canales/instancias. Está construido sobre la API de partidas en tiempo real de Nakama, con una partida por mapa.

Tiene dos transportes, elegidos por OnlineConfig.WorldSyncMode:

ModoCómo viajan las posicionesQuién decide quién ve a quién
Authoritative (el predeterminado)Cada cliente envía su posición a la partida del mundo del servidor, un plugin de Go, que envía a cada destinatario un lote por tick con solo lo que ese destinatario veEl servidor (área de interés en la partida de Go)
RelayedLa partida retransmite cada posición a todos los jugadores del mapaCada cliente (OnlineConfig.AOIRadius)

Authoritative es el que escala a miles de jugadores; el retransmitido se degrada a partir de unos cien jugadores en una misma partida. El resto de esta página indica qué partes son específicas de cada modo.

El mundo en una sesión en línea, con el HUD de sincronización del mundo

Todo lo que describe esta página es visible en ese único fotograma: la insignia del canal y Online — Channel 1 arriba a la izquierda, el contador de jugadores cercanos que produce el filtrado por área de interés (Area-of-Interest) y el indicador de latencia/servidor abajo. Si el contador marca cero cuando esperas compañía, el AOI es lo primero que hay que revisar.

Descripción general de la arquitectura​

La pila de sincronización del mundo consta de estos componentes:

ClaseArchivoResponsabilidad
WorldSyncServiceWorld/WorldSyncService.csUnirse a la partida del mapa, difusión de posición, encaminamiento del estado de la partida, AOI del lado del cliente (modo retransmitido)
WorldBatchDecoderWorld/WorldBatchDecoder.csDecodifica el lote por tick del servidor (modo autoritativo) en los mismos eventos de AOI
PlayerNetworkEntityWorld/PlayerNetworkEntity.csRenderizado de jugadores remotos, interpolación, cartel con el nombre, sprite del seguidor
PlayerSpawnManagerWorld/PlayerSpawnManager.csGenerar/destruir los GameObjects de jugadores remotos según los eventos de AOI y dibujar sus emotes
ChannelManagerWorld/WorldSyncService.csCambio de canal/instancia del servidor y consultas de población

Además, NPCSyncService (en World/PlayerNetworkEntity.cs) gestiona el bloqueo de interacción con NPC entre jugadores, y FollowerUpdateData define el protocolo de cable para sincronizar los monstruos seguidores. Ambos transportes lanzan los mismos eventos, así que el código que dibuja a los jugadores no sabe cuál está activo.

WorldSyncService​

Inicialización​

WorldSyncService se inicializa automáticamente durante NakamaManager.FinalizeConnection():

await WorldSync.InitializeAsync(userId);

Esto solo prepara el servicio. La propia partida se une en cuanto se conoce el mapa (la escena del jugador) y de nuevo cada vez que el jugador cambia de mapa:

  • Modo autoritativo: el cliente llama a la RPC world_join con { "mapId": "<map>" }. El servidor devuelve el matchId de la partida autoritativa de ese mapa (la crea si todavía no existe y rechaza un mapa del que no tiene censo), y el cliente se une a ella mediante el socket. Si el plugin de Go del servidor no está cargado, la unión falla con un error en la consola: no hay alternativa silenciosa al transporte retransmitido.
  • Modo retransmitido: el cliente crea o se une a la partida retransmitida llamada world_<mapId> (WorldSyncService.WorldMatchNameFor).

Cuando el jugador cambia de mapa, abandona la partida anterior, olvida a sus vecinos (el gestor de generación los destruye) y anuncia su posición y su seguidor en la nueva.

Protocolo de estado de la partida​

Toda la comunicación de sincronización del mundo usa mensajes de estado de partida de Nakama con los siguientes códigos de operación:

OpCodeConstanteDirecciónPropósito
1OP_POSITIONcliente → partidaActualización de la posición del jugador (x, y, z, dirección, mapa, además de nombre, aspecto, nivel, gremio, indicadores de correr/bici/nadar)
2OP_MAP_CHANGEambosEl jugador cambió a otro mapa
3OP_EMOTEambosEmote del jugador: ver Emotes en línea
4OP_FOLLOWER_UPDATEambosCambió el monstruo seguidor del jugador
10WorldBatchDecoder.OP_BATCHpartida → clienteEl lote por tick de la partida autoritativa

El estado de partida entrante se encamina mediante HandleMatchState(). El lote (op 10) se resuelve primero, porque no tiene remitente: viene de la propia partida. Todos los demás mensajes ignoran al jugador local y se despachan según su código de operación. Las operaciones 2, 3 y 4 son eventos que el servidor reenvía tal como llegan a los demás jugadores.

El lote autoritativo​

En modo autoritativo, la partida del mundo del servidor corre a 10 ticks por segundo y envía un lote op 10 por destinatario y tick, que contiene solo lo que ese destinatario puede ver:

  • los amigos mutuos primero, aunque estén lejos;
  • luego los jugadores más cercanos dentro del radio del área de interés (20 tiles), hasta un tope (50 por defecto, MCE_WORLD_MAX_VISIBLE en el servidor);
  • con histéresis: un jugador que ya es visible sigue siéndolo hasta 4 tiles más allá del radio, para que el borde no parpadee.

El lote es compacto. Los jugadores se identifican por un slot propio de la partida y una generación que aumenta cuando se reutiliza un slot (de modo que un cliente nunca aplique a un jugador nuevo lo que sabía del anterior):

{ "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)

Los jugadores inactivos no se reenvían, un jugador que entra en el área llega completo aunque esté inactivo, y un fotograma clave cada 5 segundos reenvía las posiciones. WorldBatchDecoder convierte el lote en los mismos eventos OnPlayerEnterAOI, OnPlayerPositionUpdate y OnPlayerExitAOI que lanza el camino retransmitido.

El servidor valida cada posición que recibe dentro del bucle (ver abajo), así que un cliente no puede colocarse donde quiera.

Difusión de la posición​

La posición del jugador local se envía mediante UpdateLocalPosition():

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

Reglas de regulación:

  • Límite de frecuencia: controlado por OnlineConfig.PositionSyncRate (por defecto: 10 mensajes/segundo).
  • Zona muerta: se suprimen las posiciones a menos de 0,01 unidades de la última posición sincronizada.

La carga útil es un PositionData serializado en JSON con los campos x, y, z, dir (la dirección a la que mira) y map, además de lo que los receptores necesitan para dibujar al jugador: name (para el cartel con el nombre), look (el nombre del CharacterType que hay que dibujar), lvl (el nivel del monstruo más fuerte del equipo), la etiqueta del gremio, si el jugador es un game master, y los indicadores run, bike y swim.

Difusión del seguidor​

Cuando cambia el monstruo seguidor del jugador local, difunde la actualización:

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 incluye: speciesId, formIndex, gender, isShiny, isEgg y nickname. Un speciesId de 0 es la señal de "borrar".

Las difusiones de seguidor se deduplican: se suprime la misma especie dentro de una ventana de enfriamiento de 5 segundos.

Filtrado por área de interés (AOI)​

El filtrado por AOI evita que el cliente procese actualizaciones de jugadores demasiado lejanos.

En modo autoritativo filtra el servidor (ver el lote de arriba): el cliente solo recibe a los jugadores que debe dibujar, y OnlineConfig.AOIRadius no cambia lo que envía el servidor.

En modo retransmitido filtra el cliente. El radio se configura mediante OnlineConfig.AOIRadius (por defecto: 20 tiles). Cuando llega la actualización de posición de un jugador remoto:

  1. Se calcula la distancia al jugador local.
  2. Si está dentro del radio de AOI y aún no se le sigue, se dispara OnPlayerEnterAOI.
  3. Si está dentro del AOI, se dispara OnPlayerPositionUpdate con (userId, position, direction).
  4. Si está fuera del AOI y se le seguía, se dispara OnPlayerExitAOI y el jugador se quita de NearbyPlayers.

Eventos​

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 */ };

El diccionario NearbyPlayers proporciona una instantánea de solo lectura:

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

PlayerNetworkEntity​

Cada jugador remoto está representado por un MonoBehaviour PlayerNetworkEntity en un GameObject generado. Gestiona:

Inicialización​

entity.Initialize(onlinePlayerInfo);

Establece userId, displayName, guildTag, level, el estado de batalla y el indicador de espectable a partir de OnlinePlayerInfo.

Movimiento interpolado​

Cuando se llama a ReceivePositionUpdate(position, direction):

  • Si la distancia supera snapDistance (por defecto: 5 tiles), la entidad se teletransporta al instante (gestiona los cambios de mapa y los warps).
  • En caso contrario, la entidad interpola hacia el destino a interpolationSpeed (por defecto: 10).

Animación del sprite​

Los jugadores remotos usan un ciclo de caminar de 4 direcciones y 4 fotogramas (16 sprites en total). La animación corre a 0,2 segundos por fotograma cuando la entidad se mueve. En reposo, muestra el primer fotograma de la dirección actual. El volteo izquierda/derecha se gestiona con SpriteRenderer.flipX.

Visualización del seguidor​

Los seguidores de los jugadores remotos se gestionan mediante SetFollower(FollowerUpdateData):

  • Datos no nulos con speciesId > 0 muestran el hijo del sprite del seguidor.
  • null o speciesId == 0 llama a ClearFollower() y oculta el sprite.
  • Las propiedades HasActiveFollower y CurrentFollowerSpeciesId exponen el estado actual.

Interacción​

Al hacer clic en un jugador remoto se dispara OnClicked. La propiedad IsInBattle y el método SetBattleStatus() registran si el jugador está en una batalla (muestra un indicador visual en el cartel con el nombre).

PlayerSpawnManager​

PlayerSpawnManager es un MonoBehaviour que escucha los eventos de AOI de WorldSyncService y gestiona automáticamente los GameObjects de los jugadores remotos.

  • OnPlayerEnterAOI: instancia el playerNetworkPrefab (o crea un GameObject vacío con SpriteRenderer + PlayerNetworkEntity), llama a Initialize() y lo registra.
  • OnPlayerExitAOI: destruye el GameObject correspondiente y lo quita del diccionario de seguimiento.
  • OnPlayerPositionUpdate: llama a ReceivePositionUpdate() en la entidad correspondiente.

Accede a todos los jugadores generados actualmente:

var spawned = spawnManager.GetSpawnedPlayers();

Sincronización de NPC​

NPCSyncService garantiza que los NPC tengan un estado coherente entre todos los jugadores conectados.

Bloqueo de interacción​

Cuando un jugador habla con un NPC en el mundo en línea, el NPC queda "bloqueado" para que otros jugadores no puedan interactuar a la vez:

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 registra: NpcId, Position, MapId, IsEngaged, EngagedByUserId y DialogState.

OnNPCStateChanged se dispara cada vez que cambia el estado de interacción de un NPC.

Sistema de canales​

ChannelManager ofrece un sistema de canales/instancias al estilo MMO, similar al de los MMO de captura de monstruos más populares.

Configuración​

AjustePor defectoDescripción
DefaultChannel1Canal al que se une al conectar
MaxChannels10Máximo de canales por mundo

Cambiar de canal​

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

Los valores de canal deben estar entre 1 y MaxChannels. El evento OnChannelChanged se dispara al cambiar con éxito.

Población del canal​

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

El evento OnChannelPopulationUpdate ofrece actualizaciones (channel, playerCount).

Optimización del ancho de banda​

Varios mecanismos minimizan el tráfico de red:

  1. Regulación de posición: PositionSyncRate limita los mensajes por segundo (por defecto: 10/s).
  2. Filtrado por zona muerta: se suprimen las posiciones a menos de 0,01 unidades de la última sincronización.
  3. Descarte por AOI: en modo autoritativo el servidor envía solo lo que el destinatario ve (un lote por tick); en modo retransmitido solo se procesan los jugadores dentro de AOIRadius tiles.
  4. Máximo de jugadores visibles: MaxVisiblePlayers limita los jugadores remotos que PlayerSpawnManager mantiene en pantalla (por defecto: 50).
  5. Deduplicación de seguidores: se suprimen las difusiones de seguidor de la misma especie dentro de 5 segundos.
  6. Despacho al hilo principal: todas las llamadas a la API de Unity se trasladan al hilo principal mediante NakamaManager.RunOnMainThread(), evitando problemas entre hilos.

Validación de teletransporte autoritativa en el servidor​

Los warps y teletransportes entre mapas se validan en el servidor mediante la RPC mce_validate_teleport (RpcIds.ValidateTeleport), gestionada por rpcMceValidateTeleport en src/main.ts. Es una comprobación real y aplicada, no un marcador de posición, y el servidor nunca finge el éxito.

El cliente envía un 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;
}

El servidor aplica, en orden:

  1. Identidad: player_id debe ser igual a ctx.userId (un id falsificado o la ausencia de sesión se rechazan).
  2. Sensatez de las coordenadas: target_x / target_y deben ser finitos y estar dentro de ±100000 unidades de mundo (esto también rechaza un Infinity falsificado).
  3. Límite de frecuencia: un tope por usuario, respaldado en almacenamiento, de 30 teletransportes / 10 s (bucket validate_teleport). El cliente no puede desactivarlo; la medición de tiempo usa la hora del servidor, de modo que la ventana no se puede falsificar.
  4. Lista de escenas permitidas: target_scene debe ser una escena CONOCIDA: un pequeño respaldo integrado (pallet_town, route_1, viridian_city) O un registro de la colección de almacenamiento poblable scene_registry (usuario del sistema, lectura pública / escritura del servidor). Una escena desconocida queda sujeta al dev_mode autoritativo del servidor: permisivo (permite y avisa) en el nodo de desarrollo, cerrado ante el fallo en el nodo prod-sim (rechazada con code: "UNKNOWN_SCENE").

Si tiene éxito, el servidor devuelve { success:true, scene, position_x, position_y }. En cualquier fallo devuelve { success:false, error, code? } con la escena/posición solicitadas repetidas. El cliente bloquea el teletransporte ante cualquier success:false; el permiso blando heredado NOT_IMPLEMENTED (OnlineConfig.TeleportSoftAllowWhenUnimplemented) se conserva SOLO como respaldo para un servidor más antiguo que precede a este gestor.

Guarda de movimiento del servidor (anti-teletransporte)​

Incluso las difusiones de posición normales están vigiladas. Las mismas comprobaciones se ejecutan en ambos transportes: en el bucle de la partida autoritativa y en el gancho beforeMatchDataSend (src/world/presence.ts) de la partida retransmitida. Para cada actualización OP_POSITION, el servidor compara la distancia en línea recta con la última posición registrada del remitente usando el tiempo transcurrido medido por el servidor.

  • Un salto solo se descarta de la retransmisión cuando su distancia supera 30 u/s × elapsed + 4 u: uno que debería haber pasado por mce_validate_teleport. Correr/ir en bici de forma legítima (la bici va a <~12 u/s, el radio de AOI es de 20 u) nunca da un falso positivo.
  • Siempre se aceptan (y se vuelven a tomar como referencia): los jugadores vistos por primera vez, los cambios de mapa (un warp entre mapas es un espacio de coordenadas distinto), las coordenadas ausentes y los intervalos > 5 s (no se pueden juzgar; esto también corrige por sí solo cualquier falso positivo).
  • Al rechazar, la referencia no avanza, así que una posición teletransportada nunca se propaga. La validación falla abierta (nunca bloquea la retransmisión).
  • Una carga útil de tamaño excesivo (más de 8 KB) se descarta antes que cualquier otra cosa.

El servidor también refleja cada OP_POSITION aceptado (mapa + posición) en la colección world_presence (escritura al cambiar + actualización cada 60 s), lo que le da al backend una distribución de mapas autoritativa.

Cierre ordenado​

Al desconectar, WorldSyncService.StopSyncAsync() se llama automáticamente:

await WorldSync.StopSyncAsync();

Esto abandona la partida del mundo, borra worldMatchId y vacía el diccionario nearbyPlayers.

Verificado con dos clientes reales​

Todo lo anterior describe cómo está conectado el código. Esta sección registra lo que dos clientes de juego compilados hicieron realmente el 2026-09-01, ejecutándose contra un servidor Nakama en vivo, porque una descripción de la conexión y una función que funciona no son la misma afirmación.

Por qué existe esta sección

Esta página existía, y era exacta sobre el código, mientras los jugadores no podían verse entre sí: el avatar remoto se instanciaba en el origen del mundo en lugar de en la posición de su dueño. La línea de registro [Spawn] Remote player spawned: JugadorB se escribía tanto cuando funcionaba como cuando estaba roto, así que nada en los registros contradecía la página.

La corrección y la evidencia que siguen provienen de manejar la compilación real. Todo lo que esta página afirma que funciona se afirma porque una ejecución con dos clientes lo mostró.

Qué se manejó​

Dos clientes compilados separados (MCE_Online.exe, uno por carpeta: dos instancias sobre la misma carpeta no logran arrancar), cada uno con su propio registro, con la sesión iniciada en cuentas distintas y entrando en el mismo mapa.

Cada cliente ve al otro en su posición real​

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

Las dos coordenadas difieren, que es el control que importa: si el receptor repitiera su propia posición en lugar de la del par, ambas líneas dirían lo mismo.

El movimiento se propaga​

Mientras A caminaba, el servidor recibió de él 32 posiciones distintas, interpolación incluida:

"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

Las actualizaciones de posición se verifican en el servidor, no en el registro del cliente: el cliente no imprime cada actualización, así que buscarlas ahí no encontraría nada y no demostraría nada.

En pantalla​

La vista del jugador A: el jugador remoto con su cartel de nombre y su seguidor

La vista del jugador B del mismo momento

El avatar remoto se renderiza con su cartel (nombre y nivel) y su monstruo seguidor.

Límites conocidos, dichos con claridad​

  • En modo retransmitido, el filtrado por AOI es del lado del cliente. Cada difusión de posición llega a cada cliente de la partida del mapa; cada cliente descarta lo que está fuera de rango. Un cliente modificado puede por tanto ver jugadores más lejanos de lo previsto. El transporte autoritativo (el predeterminado) no tiene este límite: el servidor envía a cada cliente solo lo que puede ver. La evidencia de abajo se registró en el transporte retransmitido.
  • Un jugador que está quieto difunde una sola vez. La posición se envía al cambiar más una actualización periódica, así que un jugador que nunca se mueve anuncia una sola vez. Cualquier receptor que descarte ese primer paquete no tiene una segunda oportunidad, que es exactamente como sobrevivió el error del origen.