世界同步
世界同步系统负责实时的玩家位置同步、远程玩家渲染、NPC 状态协调以及频道/实例系统。它建立在 Nakama 的实时对局 API 之上,每张地图一场对局。
它有两种传输方式,由 OnlineConfig.WorldSyncMode 选择:
| 模式 | 位置如何传输 | 谁决定谁能看到谁 |
|---|---|---|
Authoritative(默认) | 每个客户端把自己的位置发给服务器的世界对局(一个 Go 插件),后者每个 tick 向每个接收者发送一个批次,只包含该接收者看得到的内容 | 服务器(Go 对局中的兴趣区域) |
Relayed | 对局把每个位置转发给该地图的每位玩家 | 每个客户端(OnlineConfig.AOIRadius) |
Authoritative 是能够扩展到数千名玩家的那个;转发模式从一场对局中约一百名玩家起就会退化。本页其余部分会说明哪些部分是某一模式特有的。

本页描述的一切都能在那一帧中看到:左上角的频道徽章和
Online — Channel 1、兴趣区域(Area-of-Interest)
过滤产生的附近玩家数量,以及底部的延迟/服务器指示器。如果你预期有同伴却显示为零,
首先要检查 AOI。
架构概览
世界同步栈由以下组件构成:
| 类 | 文件 | 职责 |
|---|---|---|
WorldSyncService | World/WorldSyncService.cs | 加入地图的对局、广播位置、路由对局状态、客户端 AOI(转发模式) |
WorldBatchDecoder | World/WorldBatchDecoder.cs | 把服务器的每 tick 批次(权威模式)解码为相同的 AOI 事件 |
PlayerNetworkEntity | World/PlayerNetworkEntity.cs | 远程玩家渲染、插值、名牌、跟随者精灵 |
PlayerSpawnManager | World/PlayerSpawnManager.cs | 根据 AOI 事件生成/销毁远程玩家的 GameObject,并绘制他们的表情 |
ChannelManager | World/WorldSyncService.cs | 服务器频道/实例切换和人数查询 |
此外,NPCSyncService(位于 World/PlayerNetworkEntity.cs)负责跨玩家的 NPC 交互锁定,FollowerUpdateData 定义同步怪物跟随者的线路协议。两种传输方式触发相同的事件,所以绘制玩家的代码并不知道哪一种处于活动状态。
WorldSyncService
初始化
WorldSyncService 在 NakamaManager.FinalizeConnection() 期间自动初始化:
await WorldSync.InitializeAsync(userId);
这只是准备服务。对局本身在地图已知时(玩家所在的场景)立即加入,并且每次 玩家更换地图时再次加入:
- 权威模式:客户端用
{ "mapId": "<map>" }调用world_joinRPC。服务器返回该地图权威对局的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 | 对局 → 客户端 | 权威对局的每 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 个图块)。当远程玩家的位置更新到达时:
- 计算与本地玩家的距离。
- 如果在 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
每个远程玩家由生成的 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。
配置
| 设置 | 默认值 | 说明 |
|---|---|---|
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/s)。 - 死区过滤:与上次同步相差在 0.01 单位以内的位置会被抑制。
- AOI 剔除:在权威模式下,服务器只发送接收者看得到的内容(每个 tick 一个批次);在转发模式下,只处理
AOIRadius个图块以内的 玩家。 - 最大可见玩家数:
MaxVisiblePlayers限制PlayerSpawnManager保持在屏幕上的远程玩家数量(默认:50)。 - 跟随者去重:5 秒内相同物种的跟随者广播会被抑制。
- 主线程分发:所有 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;
}
服务器按顺序应用:
- 身份 ——
player_id必须等于ctx.userId(伪造的 ID 或缺失 会话会被拒绝)。 - 坐标合理性 ——
target_x/target_y必须是有限数,并且在±100000个世界单位之内(这也会拒绝伪造的Infinity)。 - 频率限制 —— 每个用户、由存储支持的上限:每 10 秒 30 次传送
(bucket
validate_teleport)。客户端无法禁用它;计时使用服务器 时间,因此窗口无法被伪造。 - 场景允许列表 ——
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,每个文件夹一个 —— 同一文件夹上的两个实例
无法启动),各自有自己的日志,登录不同的账户,
进入同一张地图。