跳到主要内容

世界同步

世界同步系统负责实时的玩家位置同步、远程玩家渲染、NPC 状态协调以及频道/实例系统。它建立在 Nakama 的实时对局 API 之上,每张地图一场对局。

它有两种传输方式,由 OnlineConfig.WorldSyncMode 选择:

模式位置如何传输谁决定谁能看到谁
Authoritative(默认)每个客户端把自己的位置发给服务器的世界对局(一个 Go 插件),后者每个 tick 向每个接收者发送一个批次,只包含该接收者看得到的内容服务器(Go 对局中的兴趣区域)
Relayed对局把每个位置转发给该地图的每位玩家每个客户端(OnlineConfig.AOIRadius)

Authoritative 是能够扩展到数千名玩家的那个;转发模式从一场对局中约一百名玩家起就会退化。本页其余部分会说明哪些部分是某一模式特有的。

带有世界同步 HUD 的在线会话中的世界

本页描述的一切都能在那一帧中看到:左上角的频道徽章和 Online — Channel 1、兴趣区域(Area-of-Interest) 过滤产生的附近玩家数量,以及底部的延迟/服务器指示器。如果你预期有同伴却显示为零, 首先要检查 AOI。

架构概览​

世界同步栈由以下组件构成:

类文件职责
WorldSyncServiceWorld/WorldSyncService.cs加入地图的对局、广播位置、路由对局状态、客户端 AOI(转发模式)
WorldBatchDecoderWorld/WorldBatchDecoder.cs把服务器的每 tick 批次(权威模式)解码为相同的 AOI 事件
PlayerNetworkEntityWorld/PlayerNetworkEntity.cs远程玩家渲染、插值、名牌、跟随者精灵
PlayerSpawnManagerWorld/PlayerSpawnManager.cs根据 AOI 事件生成/销毁远程玩家的 GameObject,并绘制他们的表情
ChannelManagerWorld/WorldSyncService.cs服务器频道/实例切换和人数查询

此外,NPCSyncService(位于 World/PlayerNetworkEntity.cs)负责跨玩家的 NPC 交互锁定,FollowerUpdateData 定义同步怪物跟随者的线路协议。两种传输方式触发相同的事件,所以绘制玩家的代码并不知道哪一种处于活动状态。

WorldSyncService​

初始化​

WorldSyncService 在 NakamaManager.FinalizeConnection() 期间自动初始化:

await WorldSync.InitializeAsync(userId);

这只是准备服务。对局本身在地图已知时(玩家所在的场景)立即加入,并且每次玩家更换地图时再次加入:

  • 权威模式:客户端用 { "mapId": "<map>" } 调用 world_join RPC。服务器返回该地图权威对局的 matchId(如果尚不存在就创建它,并拒绝它没有普查数据的地图),客户端通过套接字加入它。如果服务器的 Go 插件没有加载,加入会失败并在控制台输出错误:没有静默回退到转发传输。
  • 转发模式:客户端创建或加入名为 world_<mapId> 的转发对局(WorldSyncService.WorldMatchNameFor)。

当玩家更换地图时,它会离开旧对局,忘掉它的邻居(生成管理器会销毁它们),并在新对局中宣告自己的位置和跟随者。

对局状态协议​

所有世界同步通信都使用 Nakama 对局状态消息,操作码如下:

OpCode常量方向用途
1OP_POSITION客户端 → 对局玩家位置更新(x、y、z、方向、地图,外加名称、外观、等级、公会、奔跑/骑行/游泳标志)
2OP_MAP_CHANGE双向玩家切换到另一张地图
3OP_EMOTE双向玩家表情:参见在线表情
4OP_FOLLOWER_UPDATE双向玩家的怪物跟随者发生变化
10WorldBatchDecoder.OP_BATCH对局 → 客户端权威对局的每 tick 批次

传入的对局状态由 HandleMatchState() 路由。批次(op 10)最先被处理,因为它没有发送者:它来自对局本身。所有其他消息都会忽略本地玩家,并按其操作码分派。操作 2、3 和 4 是服务器按原样转发给其他玩家的事件。

权威批次​

在权威模式下,服务器的世界对局以每秒 10 个 tick 运行,并每个 tick 为每个接收者发送一个 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​

每个远程玩家由生成的 GameObject 上的一个 PlayerNetworkEntity MonoBehaviour 表示。它负责:

初始化​

entity.Initialize(onlinePlayerInfo);

根据 OnlinePlayerInfo 设置 userId、displayName、guildTag、level、战斗状态和可观战标志。

插值移动​

当调用 ReceivePositionUpdate(position, direction) 时:

  • 如果距离超过 snapDistance(默认:5 个图块),实体会立即传送(处理地图切换/传送点)。
  • 否则,实体以 interpolationSpeed(默认:10)向目标插值。

精灵动画​

远程玩家使用四方向、四帧的行走循环(共 16 个精灵)。实体移动时,动画以每帧 0.2 秒运行。空闲时,它显示当前方向的第一帧。左右翻转通过 SpriteRenderer.flipX 处理。

跟随者显示​

远程玩家的跟随者通过 SetFollower(FollowerUpdateData) 管理:

  • 非空且 speciesId > 0 的数据会显示跟随者精灵子对象。
  • null 或 speciesId == 0 会调用 ClearFollower(),隐藏精灵。
  • HasActiveFollower 和 CurrentFollowerSpeciesId 属性公开当前状态。

交互​

点击远程玩家会触发 OnClicked。IsInBattle 属性和 SetBattleStatus() 方法跟踪玩家是否处于战斗中(在名牌上显示视觉指示)。

PlayerSpawnManager​

PlayerSpawnManager 是一个 MonoBehaviour,它监听 WorldSyncService 的 AOI 事件,并自动管理远程玩家的 GameObject。

  • OnPlayerEnterAOI:实例化 playerNetworkPrefab(或创建带有 SpriteRenderer + PlayerNetworkEntity 的空 GameObject),调用 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。

每当 NPC 的交互状态发生变化时,就会触发 OnNPCStateChanged。

频道系统​

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/s)。
  2. 死区过滤:与上次同步相差在 0.01 单位以内的位置会被抑制。
  3. AOI 剔除:在权威模式下,服务器只发送接收者看得到的内容(每个 tick 一个批次);在转发模式下,只处理 AOIRadius 个图块以内的玩家。
  4. 最大可见玩家数:MaxVisiblePlayers 限制 PlayerSpawnManager 保持在屏幕上的远程玩家数量(默认:50)。
  5. 跟随者去重:5 秒内相同物种的跟随者广播会被抑制。
  6. 主线程分发:所有 Unity API 调用都通过 NakamaManager.RunOnMainThread() 封送到主线程,防止跨线程问题。

服务器权威的传送验证​

跨地图的传送点和传送由服务器端通过 mce_validate_teleport RPC(RpcIds.ValidateTeleport)验证,由 src/main.ts 中的 rpcMceValidateTeleport 处理。这是一项真实的、被强制执行的检查 —— 不是占位符 —— 服务器绝不会伪造成功。

客户端发送一个 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(伪造的 ID 或缺失 会话会被拒绝)。
  2. 坐标合理性 —— target_x / target_y 必须是有限数,并且在 ±100000 个世界单位之内(这也会拒绝伪造的 Infinity)。
  3. 频率限制 —— 每个用户、由存储支持的上限:每 10 秒 30 次传送 (bucket validate_teleport)。客户端无法禁用它;计时使用服务器 时间,因此窗口无法被伪造。
  4. 场景允许列表 —— target_scene 必须是已知场景:一个小型内置 回退列表(pallet_town、route_1、viridian_city),或可填充的 scene_registry 存储集合中的一条记录(系统用户,公开读取 / 服务器写入)。 未知场景由服务器权威的 dev_mode 控制:在开发节点上是宽松的 (允许并警告),在 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 KB)会在任何其他处理之前被丢弃。

服务器还会把每个被接受的 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 过滤发生在客户端。 每次位置广播都会到达该地图对局中的 每个客户端;每个客户端丢弃超出范围的内容。因此,被修改的客户端可以 看到比预期更远的玩家。权威传输(默认) 没有这个限制:服务器只向每个客户端发送它可以看到的内容。下面的证据 是在转发传输上记录的。
  • 站着不动的玩家只广播一次。 位置在变化时发送,外加定期 刷新,所以一个从不移动的玩家只宣告一次。任何丢弃了那个 第一个数据包的接收方都没有第二次机会 —— 原点错误正是这样存活下来的。