游戏状态增量编码:Old Light 的 world.delta 补丁设计

原文:https://dev.to/arjen_/delta-encoding-multiplayer-game-state-j2a(作者 @arjen_)

Old Light 是一款浏览器策略游戏,一个标签页可以连续打开数天。客户端保存着它被允许看到的星系状态的完整副本,服务器则通过发送补丁来保证这份副本的真实性:每一次变化都以 world.delta 消息到达,客户端将其合并进已有数据。发送变化而非重新发送完整状态,是教科书式的增量编码。但问题是,一份游戏状态补丁到底包含什么,以及为什么对手收到的补丁和你收到的并不相同。

我在网络编程相关文章中介绍了流如何启动(连接时先发一次快照,之后是增量),以及其中时间计算的陷阱。本文讨论的是增量本身。

游戏状态补丁中包含什么

当人们说增量编码时,通常指字节差异:比较一个数据块的两个版本,传输差异部分。这要求发送方知道接收方持有的是哪个版本。一个向数千个 socket 广播的游戏服务器负担不起这种做法;为每个客户端跟踪"最后已知状态"并在每次变化时与其做差异比较,比直接发送更新还要昂贵。

因此,Old Light 的增量消息描述的是玩家和星区的事实,而不是字节差异:

interface WorldDelta {
    added?: { players?: Player[] };
    removed?: { playerIds?: string[] };
    updated?: {
        players?: Player[];
        sectors?: Sector[];
        dirtySectors?: SectorCoord[]; // 这里的地图数据已过期,需要重新拉取
        tradeBoard?: TradeBoardDelta; // 市场板块发生了变动
        deals?: DealsDelta; // 谈判有进展;只有谈判双方会收到这条
    };
    serverNow: number;
}


一条增量消息表示:有玩家加入、某个 id 消失、某个玩家的数据行发生变化,或某个星区的公开地图数据已过期。最后两个字段不携带实际数据。它们只是说某个界面发生了变化,打开该界面的客户端会去重新读取,这样就能让繁忙的交易市场不必向所有未查看它的 socket 推送数据。服务器可以向每个 socket 发送完全相同的消息,而无需知道任何 socket 当前持有什么,客户端也可以将它应用到已有的任意状态上。同时,它还精确告诉渲染器需要重绘什么:players 更新会触及名册和 HUD,dirtySectors 条目会触及指定星区中的六边形。屏幕上其他内容不会重新绘制。

绝对数值,而非增量

增量消息中的每个值都是新的总量,客户端直接赋值,不会与已有数值做任何加减运算。当你的帝国分数变化时,消息中包含的是分数本身,合并操作则是按 id 进行替换:

const incoming = new Map(delta.updated.players.map((p) => [p.id, p]));
this.players = this.players.map((p) => incoming.get(p.id) ?? p);


(这里做了简化;逐字段规则后面会讲。)绝对数值使得补丁具备幂等性。一条消息发送两次,最终到达的是相同的状态。一条消息从未到达,只会让客户端暂时滞后而不会损坏,之后同一实体的下次更新会将其完全修复,因为那次更新同样也是完整真相,而不是某个步骤序列中的一环。增量数值则需要恰好一次的、有序的投递才能保持正确,而对于一个休眠数小时并在另一个网络上重新连接的浏览器标签页来说,指望这种东西并不明智。

同一事件,对不同观察者产生不同的增量

增量编码在这里与信息隐藏发生了冲突。在 Old Light 中,谁拥有哪颗恒星是公开的,但帝国内部的情况不是:建筑构成、收入、舰队编组和金库只有拥有者可见(除非对手侦察到它们)。因此,你帝国的一次变化会产生两个不同的补丁,都源于同一个事件。

公开版本会发送到所有 socket 加入的全银河房间,包括匿名旁观者,并且会先经过一个擦除函数:

function scrubForBroadcast(player) {
    const out = {
        id: player.id,
        name: player.name,
        kind: player.kind,
        spawn: player.spawn,
        score: player.score,
        owned: player.owned.map(publicHex), // 坐标、名称、分数、buildings: []
        // 未列出的字段一律不发送:credits、transits、未读计数……
    };
    // 通过显式决策提升为公开,仅在设置时才携带
    if (player.protectedUntil) out.protectedUntil = player.protectedUntil;
    if (player.banner) out.banner = player.banner;
    return out;
}


这个擦除函数是一个白名单。每个公开字段都被显式列出,未列出的字段默认丢弃,因此六个月后添加到玩家类型中的新字段将一直保持私有,直到有人有意将其提升为公开。那两行条件语句也是有意提升的。对手看到新手保护倒计时,是因为否则拒绝攻击会看起来像 Bug;而徽章纯粹是装饰性的。

同一事件的私有版本会填充经济、建造队列、舰队和信用点,只发送到包含你自己连接的房间。服务器还会对两条发送排序:先发送擦除后的全局版本,最后发送你的丰富视图,这样精简版本永远不会覆盖你屏幕上已有的详细版本。从协议的角度看,对手的客户端与一个比你更小的星系保持同步——那个星系只包含你们双方公开共享的事实。

缺失字段保留旧值

同一事件频道上同一玩家的两个版本会引发合并问题。你的客户端和其他人一样坐在银河房间中,因此当服务器广播一份经过清洗(scrub)的关于你的更新时,你的客户端会收到一份没有 credits 字段、owned 列表为空的"你自己"的副本。如果盲目用那份数据替换本地玩家对象,你的帝国就会从你自己屏幕上消失,直到下一次完整视图抵达。

防止这种情况的合并规则是:undefined 表示"未发送",绝不表示"已清空"。

credits: incoming.credits !== undefined ? incoming.credits : local.credits,
owned: incoming.owned.length > 0 ? incoming.owned : local.owned,


被清洗剥离的字段保留本地已有的值;显式值(包括 null)则覆盖。空 owned 规则存在的原因是:公开广播按设计会携带 owned: [](敌对六边形名单走地图数据频道,不走名册),所以空列表绝不能解读为"这个帝国失去了一切"。

captures(占领)还有第二个合并细节。当一颗恒星易主时,增量数据会点名新主人,且该六边形已进入其列表,但前主人根本不在载荷中。客户端会遍历它知道的每个其他玩家,并移除新交付玩家所声明的任何六边形:以坐标为键,最后写入者胜。如果没有这一步,两个帝国都会在渲染中显示为共同拥有同一颗恒星,直到下一次完整快照到来。

下发事实,其余自行推导

玩家看到的很多东西从不上线传输。领土是最明显的例子:每一颗被占领的恒星都会以固定半径向外投射一个帝国的边界,两个帝国重叠时,更早的主张获胜。服务器本可以在每次变化时下发最终形状——一个大帝国就有数百个六边形。但增量数据只携带被占领的恒星,客户端根据它们重新计算投影。客户端和服务器对同一组输入运行同一条规则,所以不可能不一致。补丁包始终保持几百字节,无论它隐含的边界有多大。

dirtySectors 更进一步,它下发的是"失效通知"而不是数据。客户端早期版本在每次增量后重新拉取整个可见地图区域,扩展后意味着每次经济周期任何地方的变化都会触发整个视口大小的拉取。现在,改变恒星归属的增量数据会点名变脏的地图扇区,客户端只重新拉取这些扇区,走的是任何客户端都能发出的同一公开请求。不改变几何结构的增量(大多数增量都是如此)则完全不触发拉取。

当客户端漏掉一条增量

没有增量日志,也没有序号。如果客户端断开连接,它不会请求错过的增量;重连时服务器发送一份新的完整快照,客户端丢弃所有增量状态,因为每次错过的变化都已烘进新快照中。休眠恒星缓存刻意在这次重置中存活下来。它保存着快照从不携带的未占领恒星,如果丢弃它们,在第一次区域拉取返回之前,地图大部分区域都会变成空白,看起来就像 bug。

我本可以保留日志并重放。快照代码反正必须存在,因为每次新连接都需要一份,而且它已经按观察者做了清洗。保留事件日志则需要把同样的按观察者过滤逻辑,对每个重连客户端在未知长度的时间间隔上回溯应用。


这个游戏叫 Old Light,一款运行在浏览器标签页中的跨银河策略游戏。

原文:https://dev.to/arjen_/delta-encoding-multiplayer-game-state-j2a(作者 @arjen_)

发布评论
全部评论(0)