网络游戏的帧同步实现

本篇章主要实现多人在线的帧同步流程, 首先是定义客户端和服务端会用到的游戏交互事件 Protobuf:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
syntax = "proto3";

// 初始化最新的序列帧 - 这个事件是服务端和客户端双向共享, 也就是 Request-Response 响应方式
// 其实就是从服务端获取的最新序列帧ID, 然后挂载客户端目前已经执行的序列化帧上等待下个帧运行
// 后续需要初始化玩家坐标信息,场景信息也是通过该初始化事件加载; 比如下面声明初始化坐标位置[其实应该定义个 Vec2(x,y) 坐标结构]
message InitFrame{
int32 frame = 1;
// 后续初始化内容在以下扩充
float x = 2;
float y = 3;
}

// 广播单项 - 玩家引发的发生帧变动
// 核心的 frame 字段是帧序列的编号, 需要确保客户端和服务端对发生帧做同步
// 当网络丢包重传的时候, 就能明确从那一个帧序列开始丢帧从而采用追帧方式让玩家回滚到最新状态
// frame 采用 uint32 最佳, 但无法确认开发语言是否支持 unsigned 特性, 所以采用到达最大值回滚为0重置或者设置int64也可以
// 从性能考虑来说采用 int32 就行, 对于小游戏来说 0 到 2^31-1 足够帧序列做行为序列处理
// 注意: 到达最大帧的时候服务端和客户端都要同步回滚处理才能对齐序列帧编号
message InputFrame{
string sessionId = 1; // 玩家唯一标识
int32 frame = 2; // 发生帧
int32 direction = 3; // 方向
bool jump = 4; // 是否触发跳跃
}

// 广播事件 - 依据客户端提交的 InputFrame.frame, 服务端响应的后续发生帧列表
message Frames{
int32 frame = 1; // 发生帧
repeated InputFrame frames = 2; // 所有玩家输出的帧操作列表
int64 timestamp = 5 ;// 毫秒级别时间戳
}

以上还有事件没有说明: 进入场景(JoinScene), 离开场景(LeaveScene) 等, 可以思考游戏参与多人游戏整体流程需要多少事件

帧同步说明:

  1. 连接服务端的时候, 获取下最新的服务器序列帧挂载到目前客户端最新帧, 其实就是对齐序列帧

  2. 客户端开始启动定时器按照指定帧率运行(后面这个定时器假定为 FrameTimer, 帧更新方法为 FrameTimer.Update )

  3. 在帧更新方法 FrameTimer.Update 之中, 每一帧都要提交给服务端, 并且 frame = frame + 1

  4. 服务端帧接收, 需要注意的是服务端也是启动和客户端一致帧率定时器, 需要 Map<Integer,List> frames 的对象来维护消息列表

客户端的帧提交伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var frame = 0  or ? # 本地同步帧号(与服务端对齐)

# 核心帧循环逻辑, 这里就是客户端在拉取最新帧之后初始化具体每次运行周期(15/30/60fps)的定时器
func Update():
frame += 1

# 收集输入(对应你的移动/跳跃逻辑)
var direction = Input.get_axis("move_left", "move_right")
var jump = Input.is_action_just_pressed("jump")

# 封装成 Protobuf 并且网络推送指令等
var msg = InputFrame{ sessionId: "玩家唯一标识", frame, direction, jump}
WebSocket.send(100,msg)

# 注意: 服务端和客户端必须采用相同的帧率运行, 具体精度为 15/30/60fps(1000÷15ms,1000÷30ms,1000÷60ms)
# 一般来说15帧(0.03秒)适合大部分轻中度类型游戏, 30帧适合塔防MMORPG类型游戏, 60帧则专业的FPS和格斗竞技类游戏
# 也就是后续不再使用 Godot._process 或者 Unity.Update 更新方法处理玩家输入, 而是利用启动的自定义帧率定时器处理逻辑

服务端的帧广播伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private final AtomicInteger frame = new AtomicInteger(0); // 全局帧号, 从0开始且必须原子量保证帧率不会乱序
private final Map<Integer,List<InputFrame>> frames = new ConcurrentHashMap(); // 输入缓存, 按帧号存储玩家输入, 也是必须要保证线程安全性

// 核心帧循环逻辑, 这里就是定时器的具体每次运行周期(15/30/60fps), 服务端和客户端必须保持一致
void Update(){
long frameTime = System.currentTimeMillis(); // 记录毫秒帧时间戳
int currentFrame = frame.get(); // 获取当前帧号(递增前的帧号,作为本帧标识)

// 收集本帧所有玩家的输入
List<InputFrame> inputs = frames.remove(currentFrame);
if (inputs == null) {
inputs = new ArrayList<>(); // 若没有输入,用空列表
}

// 生成Protobuf帧数据并广播给所有客户端 - Frames 消息结构
BroadcastToAllClients(new Frames{ frame:currentFrame,frames: inputs,timestamp: frameTime});

// 序列帧递增
frame.incrementAndGet();
}

以上就是服务端的每帧广播和递进处理, 另外还有提交上来的帧也是需要判断下, 假设目前客户端提交序列帧行为:

  • int currentFrame = frame.get(); // 获取当前帧号(递增前的帧号,作为本帧标识)

    • InputFrame.frame == currentFrame : 目前的缓存的序列帧列表, 直接 frames.get(currentFrame).add(InputFrame)
      写入当前帧等待服务端广播移交
    • InputFrame.frame == (currentFrame+1): 下一帧的序列行为, 低延迟网络可能会提交比较快, 所以将其写入到 frames.get( currentFrame+1).add(InputFrame)
    • 其他序列帧直接当作异常, 视为无效输入跳过即可, 这种可能客户端序列帧迭代本身就有问题

注意: 以上的共享对象必须采用原子值线程锁或者 Actor 等来保证线程安全, 否则会出现序列帧异常

还有游戏的 追帧 概念, 用来处理客户端和服务端状态异常需要重新同步序列帧, 接下来就是需要说明的重要概念

追帧处理

对于 追帧 应用主要场景如下:

  • 玩家中途加入游戏场景, 从服务端的 InitFrame 获取最新序列帧为0, 但是设备卡顿导致服务器的实际序列帧误差为1000+

  • 网络卡顿或者切换恢复, 移动端最常见的网络可能自主从优由 4G|5G 切换到 WIFI, 期间交换网络恢复的过程会存在极大误差帧

  • 断线重连的帧数据异常, 这种就是各方面原因导致的, 可能是设备|网络|环境等导致掉线, 而客户端采用掉线重连导致目前序列帧和本地有误差

若不进行追帧, 这些客户端会始终落后于服务端和其他玩家, 导致画面不同步(如其他玩家已移动到新位置, 滞后客户端仍显示在旧位置)

另外追帧过程不需要做渲染(跳过动画播放|粒子特效等UI更新), 只处理状态更新直到追帧完成开始正常序列帧同步

这里也就代表服务端需要把每一个序列帧都要做好保存, 保证后续客户端能够正常取出对应的数据帧

序列帧最好后续异步入库保存, 后续作弊举报或者bug复现的时候可以通过序列帧重放还原现场.

在帧同步游戏中, 需要触发追帧的核心判定条件是客户端当前状态帧号显著落后于服务端当前帧, 具体场景可分为以下几类:

  1. 客户端中途加入游戏(最常见场景)

    • 判定条件: 客户端连接时, 服务端当前帧 > 客户端初始帧号(通常是0)
    • 示例: 服务端已运行到帧1000, 新客户端刚连接(初始帧0), 两者帧差1000帧必须追帧
  2. 网络异常后恢复连接

    • 判定条件: 客户端连接时, 服务端当前帧 > 客户端初始帧号(通常是0)
    • 示例: 客户端因网络卡顿断连5秒(30fps下对应150帧), 重连后帧差150帧需要追帧
  3. 客户端帧数据丢失且无法重传

    • 判定条件: 客户端检测到连续丢失的帧数量 > 阈值(如3帧), 且重传请求未得到响应
    • 示例: 客户端丢失帧501-503, 多次请求重传失败, 此时服务端已推进到帧510, 帧差7帧触发追帧
  4. 客户端状态与服务端校验不一致

    • 判定条件: 服务端定期广播关键状态快照(如每100帧), 客户端对比后发现状态偏差超过阈值(如位置偏差>1米)
    • 示例: 客户端因本地预测错误, 角色位置与服务端快照偏差2米, 判定为需要追帧校正

一般为了避免频繁触发追帧都会设定合理的辅助机制帮助确认是否要调用追帧:

  • 帧偏移阈值: 在处于轻微偏差帧(1~2帧)不触发追帧, 而如果偏差比较大(6~9帧)的时候就要开始追帧处理

  • 时间偏差: 在指定时间内(100ms)没有获取到新的序列帧更新时间, 需要主动发起追帧确认是否服务异常

还有一些不需要追帧的的情况, 这种情况就尽可能避免触发追帧:

  1. 帧差在阈值内: 如仅落后1-2帧, 可通过正常接收服务端后续帧自然同步(无需专门追帧)

  2. 客户端超前服务端: 帧同步中客户端不会主动超前(因帧号由服务端广播驱动), 若出现超前直接以服务端帧号为准重置即可, 无需追帧

  3. 单帧数据丢失但可重传: 丢失1-2帧时, 优先请求服务端重传该帧数据, 而非触发完整追帧流程(重传成本更低)

另外追帧其实还有很多隐患需要明确留意一下:

  1. 画面瞬移: 因为追帧是批量把帧按操作顺序直接执行到最新, 所以表现行为看起来就像一直在瞬移变动

  2. 操作延迟: 客户端启动追帧的时候是直接阻止玩家发起的操作去优先处理历史帧, 所以会看到触发游戏行为的时候没反应等到追帧到最新帧

  3. 状态不一致: 这也是很常见的问题, 可能受到硬件浮点数|队列排序|编程语言等差异, 会出现最后追帧和多个玩家展示的画面不一致

  4. 回滚风暴: 状态不一致 引发的后续问题, 因为内部不一致就需要从不一致的帧重新拉取服务器的序列帧, 多次不一致会出现频繁回滚追帧

  5. 数据庞大: 这也是很常见的问题, 比如从0~500的追帧无法确定操作量, 如果序列帧数据太大会导致短时间内网络带宽占用激增从而进一步让网络拥堵

  6. 安全问题: 有些帧同步游戏内部核心游戏逻辑在客户端, 服务端则只验证结果; 在追帧的过程之中可能会被第三方内部手动插入伪造帧从而达到作弊效果

追帧需要额外追加同步的协议:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
syntax = "proto3";


// 输入行为, 其他略
message InputFrame{
string session_id = 1; // 玩家唯一标识
int32 frame = 2; // 发生帧
int32 direction = 3; // 方向
bool jump = 4; // 是否触发跳跃
}

// 注意: 在发起追帧的时候是不能参与帧同步广播的, 也就是无法收到 Frames 消息
// 请求追帧: 客户端→服务端, 包含需要追赶的帧范围
// 比如刚进场景目前客户端 frame = 0, 而服务端目前 frame = 1000, 通过 InitFrame.frame 拉取最新服务帧
// snapshot 则是加速同步些场景初始化状态, 比如道具位置和是否获取|其他所有玩家的血量装备更新等
message CatchFrameRequest{
string session_id = 1; // 玩家唯一标识
int32 start = 2; // 起始帧数ID, 如果是刚进入场景则为0
int32 target = 3; // 目前服务器同步的最新的帧ID
bool snapshot = 4; // 是否需要同步场景某些状态, 比如NPC和场景破坏程度等
}


// 响应追帧: 服务端→客户端, 返回追帧所需的历史帧数据, 可能会分多包传输
message CatchFrameResponse{
string session_id = 1; // 玩家唯一标识
int32 start = 2; // 起始帧数ID, 如果是刚进入场景则为0
int32 offset = 3; // 目前服务器同步的最新的帧ID, 注意如果数据量过大这里可能是偏移值, 需要比较 InitFrame.frame 最新服务帧
int32 frame = 4; // 当前的服务帧
repeated InputFrame frames = 5; // 历史帧数据列表 - 按帧号递增
CatchFrameSnapshot snapshot = 6; // 最近的状态快照 - 仅在 need_snapshot=true 时返回
bool last = 7; // 是否为最新的服务帧数据包, 服务端获取 CatchFrameRequest.start 比较序列帧是否为最新
}


// 响应内部的其他快照信息, 玩家和场景的同步状态放置内部
message CatchFrameSnapshot{
int32 frame = 1; // 快照对应帧
repeated RoleFrameState roles = 2; // 队友场景内角色状态
}


// 二维坐标
message Vector2{
float x = 1;
float y = 2;
}

// 快照场景的状态
message RoleFrameState{
string session_id = 1; // 角色玩家唯一标识
Vector2 position = 2; // 坐标位置
}


// 请求完成: 客户端→服务端, 已经追帧完成可以参与 Frames 广播消息的接收
message CatchupCompleted{
string session_id = 1; // 玩家唯一标识
int32 frame = 2; // 目前同步的客户端本地帧
bool success = 3; // 是否追帧完成, 如果不成功就继续从 frame 该帧追起
}

// 响应微调: 服务端→客户端, 服务端按照追帧数过程当中动态调整的策略
// 当追帧命令过多, CPU/GPU 负载过高导致帧率暴跌可以动态调整追帧频率
// 设置与有的游戏核心业务是在客户端做碰撞运算体积的时候, 也是需要返回开启本地碰撞计算
// 并且还可以动态纠正客户端异常追帧的问题, 比如提交的 frame 帧明显对不上的时候就需要重新校正
message CatchFrameCommand{
enum CommandType {
CONTINUE = 0; // 继续追帧
ABORT = 1; // 中断追帧, 重新同步(包括检测到作弊就直接退出游戏)
SLOW_DOWN = 2; // 减慢追帧速度(避免客户端过载)
RESET_FRAME = 3; // 重置最新服务帧
}
CommandType type = 1;
string reason = 2; // 命令原因, 用于返回该指令说明
}

这部分消息结构流程如下:

  1. 玩家进入场景, 发送 InitFrame 结构服务端响应相同 InitFrame.frame 获取到当前最新序列帧

  2. InitFrame.frame 对比本地的最新序列帧(默认刚进来的序列帧为0), 这时候就要触发追帧请求

  3. 客户端发送 CatchFrameRequest 消息, 要求服务端返回指定的 CatchFrameResponse 帧消息

  4. CatchFrameResponse 列表包含有当前帧玩家和场景快照等相关信息, 不过一般都是采用批量提取 5~10帧 返回

  5. 客户端不断递归追帧, 直到 CatchFrameResponse.frame 返回的服务帧为 InitFrame.frame 返回序列帧一致

  6. 当序列帧一致的时候, 客户端提交 CatchupCompleted 表示追帧完成, 本地可以创建和服务端相同的定时器获取消息

  7. 如果其中服务端检测到消息异常, 那么服务端会返回 CatchFrameCommand 用来确认重新追帧还是直接把玩家踢下线

需要注意: 如果客户端递归追帧 CatchFrameRequest 频繁没有校正正确情况下, 可以判断异常玩家让其下线重新追帧

这里理解起来整体比较抽象, 不过只需要处理过几次就能大概直到内部总体流程.

追帧缺陷

从上面就能看出利用 追帧 相当于将玩家属性(坐标等)初始化到指定值, 然后通过不断迭代序列帧指令从而实现回滚到最新序列帧的帧号.

不过这里面也带来缺陷: 频繁追帧导致如果帧号偏差过大(0→99999), 导致网络转发和CPU运算处于高负载.

上面采用的多人共享状态场景的 Actor, 当每次有玩家进入场景都要做频繁的网络发包同步;
而如果客户端出现数据帧校正不一致就会触发频繁的序列帧回滚, 如果回滚正常还好说, 要是回滚校正失败会触发频繁再次回滚.

虽然有重试机制限制, 但是如果是多人参与的游戏场景就很复杂了(SLG之类游戏), 频繁校正会使得单一的 Actor 一直处于高负载.

共享 Actor 成本很高, 既要做网络广播转发还要做关键逻辑判断, 序列帧列表有时需要保存入库方便举报还原现场等情况

所以后面这种默认从0开始追帧设计除非是需要强实时性且参与人数较少情况(最好是能动态控制场景的运行时间),
最明显的就是 Moba|RTS|ACT 之类匹配开房间对战且人数组合较少的情况, 直接从0追帧保证游戏玩法公平性和连贯性.

而不适合的就是 MMORPG|FPS|SLG|BigMap 之类游戏场景特别复杂, 参与的玩家数量庞大且序列帧冗长;
持久化地图可能数天甚至数个月的序列帧不可能完成, 客户端追帧在低端设备上容易直接搞崩系统;
FPS 则是有的游戏逻辑是跑在本地客户端, 可以被人为手动去伪造 ‘追帧请求’ 产生数据风暴让服务端系统卡顿从而实现作弊效果.

所以后面的轻量化帧同步方法都是采用 初始化快照状态(snapshoot) + 追帧处理(frame),
也就是进入场景的 InitFrame 消息表不仅带有最新的帧号, 还带有 场景快照结构(role+secne+npc)
标识 所有玩家|场景机关|NPC状态 直接同步到最新状态, 然后直接从当前帧开始 Update 开始跑逻辑更新.

这种方法可以作为简单的动态游戏 Actor 设计, 也就是玩家点击进入游戏扣除体力游玩关卡这种简单游戏类型,
服务端会动态创建 Actor 作为游戏场景直到最后通关成功|失败来做回收 Actor 并落地入库序列帧数据.

实际上最后简单概括就是对于需要持久化的 Actor 并不推荐采用帧同步处理, 而是寻求状态同步的处理方式避免掉这些同步帧流程.

代码说明

注意多人共享的游戏场景, 一般采用单个 Actor 处理共享的场景状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
package io.fortress.quarkus.protobuf;

import io.fortress.quarkus.protobuf.message.Game;
import org.apache.pekko.actor.*;
import org.apache.pekko.event.LoggingAdapter;

import java.time.Duration;
import java.util.*;

/**
* 多人游戏场景, 优先遵循 Actor 模型原则, 所以内部是不需要锁对象和原子量处理
*
*/
public class ProtobufFrameScene extends AbstractActor {


/**
* 对外的场景容器对象, 用于全局容器化
*/
public static class ProtobufFrameSceneWrapper {

final ActorRef actorRef;

public ProtobufFrameSceneWrapper(ActorRef actorRef) {
this.actorRef = actorRef;
}
}

/**
* 坐标地址
*
* @param x
* @param y
*/
public record Vec2(int x, int y) {
}

/**
* 服务器的核心定时延迟
*/
public enum Fps {
Fps15(1000 / 15),
FPS30(1000 / 30),
FPS60(1000 / 60),
;

/**
* 毫秒
*/
final int value;

Fps(int value) {
this.value = value;
}
}

/**
* 帧更新
*/
public interface FixedUpdate {
}

/**
* 日志对象
*/
final LoggingAdapter log = context().system().log();

/**
* Actor 核心定时器
*/
final Scheduler scheduler = context().system().scheduler();

/**
* 帧指令列表, 可以考虑采用更高性能的容器工具, 因为帧同步属于高频会做读写处理的功能
*/
final Map<Integer, List<Game.InputFrame>> frames = new LinkedHashMap<>();

/**
* 加入场景的所有玩家, Key = 玩家唯一标识(可以考虑换成Long之类), Value = Actor 地址
*/
final Map<String, ActorRef> actors = new LinkedHashMap<>();

/**
* 服务端定时器帧率
*/
final Fps fps;


/**
* 帧率定时器
*/
final Cancellable timer;


/**
* 当前的服务帧
*/
int frame = 0;


/**
* 角色坐标列表快照
*/
final Map<ActorRef, Vec2> positions = new HashMap<>();


/**
* 构造方法
*
* @param fps 帧率配置
*/
public ProtobufFrameScene(Fps fps) {
this.fps = fps;
this.timer = scheduler.scheduleAtFixedRate(
Duration.ofMillis(fps.value),
Duration.ofMillis(fps.value),
getSelf(),
new FixedUpdate() {
//ignore
},
context().dispatcher(),
ActorRef.noSender()
);
}


/**
* Actor 关闭回调
*/
@Override
public void postStop() {
if (Objects.nonNull(this.timer)) this.timer.cancel();
}

/**
* 事件绑定
*
* @return Receive
*/
@Override
public Receive createReceive() {
return receiveBuilder()
.match(Game.JoinScene.class, (e) -> !actors.containsKey(e.getSessionId()), (event) -> {
actors.put(event.getSessionId(), getSender());
positions.put(getSender(), new Vec2(1, 1));// 初始化坐标地址

// 广播玩家加入场景
actors.forEach((key, value) -> value.tell(event, getSelf()));
})
.match(Game.LeaveScene.class, (e) -> {
actors.remove(e.getSessionId());

// 广播玩家离开场景
actors.forEach((key, value) -> value.tell(e, getSelf()));
})
.match(Game.InitFrame.class, (e) -> {
ActorRef sender = getSender();
Vec2 vec2 = positions.get(sender);

// 打包返回的初始化事件
var message = Game
.InitFrame
.newBuilder()
.setFrame(frame)
.setX(Objects.isNull(vec2) ? 0 : vec2.x)
.setY(Objects.isNull(vec2) ? 0 : vec2.y)
.build();
getSender().tell(message, getSelf());
})
.match(Game.InputFrame.class, this::onInputFrame)
.match(FixedUpdate.class, this::onFixedUpdate)
.build();
}

/**
* 帧更新方法
*/
private void onFixedUpdate(FixedUpdate ignore) {
long frameTime = System.currentTimeMillis(); // 记录毫秒帧时间戳

// 收集本帧所有玩家的输入
List<Game.InputFrame> inputs = frames.remove(frame);
if (Objects.isNull(inputs)) {
// 若没有输入,用空列表
inputs = Collections.emptyList();
}

// 按照不同业务类型这里采用不同处理方式:
// - 主流帧同步: 服务端不运行客户端业务逻辑, 仅负责输入转发和帧号管理, 依赖客户端逻辑的确定性保证状态一致
// - 增强型帧同步: 服务端选择性运行核心逻辑(如防作弊校验、全局事件), 客户端仍运行完整逻辑, 通过双重计算确保安全性
// 这里更新玩家的坐标信息, 这部分其实更需要抽离单独的同步方法调用, 因为实际上游戏逻辑并不仅仅要处理位置信息, 还有体积碰撞等
for (Game.InputFrame input : inputs) {
ActorRef actor = actors.get(input.getSessionId());
if (Objects.nonNull(actor)) {

// 更新角色坐标, 用来校正玩家是否到达地图最大尺寸
Vec2 pos = positions.get(actor);
Vec2 newPos = new Vec2(pos.x + input.getDirection(), pos.y);
positions.put(actor, newPos);
}
}


// 有参与的 Actor 才需要构建发送, 生成 Protobuf 广播给所有人
if (!actors.isEmpty()) {
var builder = Game
.Frames
.newBuilder()
.setFrame(frame)
.setTimestamp(frameTime);
for (Game.InputFrame input : inputs) {
builder.addFrames(input);
}
var message = builder.build();
for (ActorRef actor : actors.values()) {
actor.tell(message, getSelf());
}
}


// 序列帧递增
frame = frame + 1;

// 值回滚成0
if (frame == Integer.MAX_VALUE) frame = 0;
}

/**
* 追加当前帧数行为
*
* @param f 玩家提交的帧
*/
private void onInputFrame(Game.InputFrame f) {
int value = f.getFrame(); // 获取客户端提交的序列帧
if (value == frame || value == (frame + 1)) {
// 需要判断游戏的逻辑是否成立
var opt = onLogicUpdate(f);
if (opt.isEmpty()) {
log.warning("Validated logical update for frame " + f.getFrame());
} else {
// 允许当前帧或者下一帧的指令通过, 写入目前的等待定期发送的更新方法
frames.computeIfAbsent(value, k -> new ArrayList<>()).add(f);
}
} else {
log.warning("Invalid frame value: {}, session: {}", value, f.getSessionId());
}
}

/**
* 游戏输入逻辑判断
*
* @param f 玩家提交的帧
* @return 逻辑返回的 Optional
*/
private Optional<Game.InputFrame> onLogicUpdate(Game.InputFrame f) {

// 移动方向是否合法(-1, 0, 1)
var direction = f.getDirection();
if (direction < -1 || direction > 1) {
f.toBuilder().setDirection(0).build(); // 修正为静止
}

// 可能需要在这里判断是否允许到达地图最大位置

return Optional.of(f);
}

}

上面还有实现具体的追帧功能, 仅仅作为简单的帧同步服务 Actor 服务; 理论上服务器在 8核心16线程 + 16G 的环境下作为轻量的百人同时在线服务,
主要的问题怎么尽可能提高 Actor 的网络转发效率, 目前上面还有很多待优化的地方, 而且客户端代码后续看情况补充上(
可能后续没时间).

这部分建议的使用场景是动态创建房间这种情况, 比如常见流程: 选择关卡 → 进入游戏 → 服务端创建 Actor 启动 → 等待游玩结果完成退出 Actor

应付一般的轻度日常游戏关卡就行了, 不过一般会加个 ‘一键扫荡’ 功能; 其实除了玩家不喜欢重复刷刷刷之外, 服务器也不喜欢这种频繁的帧同步操作…

格斗游戏序列帧

如果你游玩过相关格斗游戏(比如街头霸王)就会有相关帧概念, 以 60fps(17ms) 来说明, 具体玩家打出的动作拆解为:

  1. 发生帧, 玩家触发输出提交到服务器并产生攻击窗口判定(启动)

  2. 持续帧, 攻击判定转移到攻击结束的流程(持续)

  3. 恢复帧, 攻击结束到恢复默认状态(硬值)

其中还有状态概念, 而玩家一个动作的 整体帧率 = 启动 + 持续 + 硬直;
假设我们目前服务器 frame=0, 且释放重攻击整体为 32帧(启动10帧 + 持续5帧 + 恢复17帧),
当我们点击重攻击的触发角色攻击, 会发生以下序列帧递增:

  1. frame=0,state=startup: A玩家触发重攻击提交服务端, 广播A玩家出攻击启动UI效果界面

  2. frame=1,state=startup: 目前还是处于启动帧, 跳过玩家其他输入等待启动完成

  3. frame=2,state=startup: 同上

  4. frame=3,state=startup: 同上

  5. …不断递增启动帧的流程, 不接受任何操作序列帧提交

  6. frame=9,state=active: 完成启动帧流程切换到持续帧, A玩家已经展开攻击窗口需要判断窗口当前是否命中, 并且需要播放动画

  7. frame=10,state=active: 目前还是处于持续帧, 持续帧越长代表目前攻击判定越久

  8. frame=11,state=active: 同上

  9. …不断递增持续帧的流程, 不接受任何操作序列帧提交

  10. frame=14,state=recovery: 完成持续帧流程切换到硬直状态, 其实就是展开攻击窗口开始关闭并且等待恢复成默认状态

  11. frame=15,state=recovery: 玩家硬直的窗口, 不接收任何操作

  12. frame=16,state=recovery: 同上

  13. …不断递增硬直帧的流程, 不接受任何操作序列帧提交

  14. frame=31,state=default: 硬直结束, 开始等待玩家指令来触发下次发生的帧事件

这就是基于 60fps 简单帧同步的发生流程, 而且每帧间隔约 17毫秒|17ms, 也就是上面整个动作流程触发时间为 554ms|0.54s;
而上面仅仅是作为简单的动作发生序列帧, 没有包含 绿冲(强制中断当前追加攻击)打空(攻击没命中) 等判断逻辑,
对于 60fps 双人对局的 CPU负载网络转发 是个不小的考验, 更别说还有更多排位赛可能有时候要连续开上百把游戏.