网络游戏的状态同步实现

相比于帧同步的复杂度和资源耗费, 状态同步的开发要求相对容易(主要实现起来很简单)

这里数据结构是采用 Protobuf 定义, 客户端和服务端数据交互的时候比较统一

假设目前想要设计个 2DRPG 游戏, 那么通用简单的玩家状态 proto 如下:

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
syntax = "proto3";
option java_package = "me.meteorcat.game.protobuf";
option java_multiple_files = true;


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

// 简略版本的玩家状态数据结构
message PlayerState{
// 基础信息
int64 id = 1; // 玩家 ID
Vector2 position = 2;// 玩家位置
int64 uptime = 3; // 状态更新时间戳, 用于判断状态新旧
float speed = 4; // 移动速度

// 状态信息
bool is_moving = 100; // 是否处于移动状态

// 属性信息
int32 hp = 1000; // 玩家血量
}

// 进入房间|场景初始化信息, 用于同步当前房间|场景的玩家所有相关信息
message PlayerInitialization{
repeated PlayerState players = 1;
}

// 客户端发送的移动指令, 会影响到对应玩家状态
message PlayerMove{
int64 id = 1; // 玩家 ID
Vector2 direction = 2; // 移动的归一化(normalize), 不知道归一化可以查阅资料
}

这里的后续服务端功能都是基于 Pekko Actor 开发, 所以需要有点 Actor 相关的知识点来阅读.

这里数据结构定义完成之后就能很简单看到共享的数据结构体功能:

  • PlayerState: 核心, 这就是需要被广播的玩家状态

  • PlayerInitialization: 进入房间|场景的初始化玩家状态列表

  • PlayerMove: 更新玩家的移动事件, 也就是会影响玩家状态

按照具体的业务流程来说, 这些数据结构就会形成以下执行链路:

  1. 客户端连接服务端加入房间/场景, 写入当前 PlayerState 状态, 初始化加载房间/场景所有玩家状态

  2. 客户端将本地 WASD 移动操作归一化数据提交给服务端

  3. 服务端接收到 PlayerMove 会去验证玩家ID是否和会话记录的玩家ID是否一致, 不一致为非法操作(记录不执行或者关闭连接)

  4. 服务端验证提交的归一化 Vector2 的 向量模(magnitude) 是否大于1(向量模大于1代表异常, 可能是非法操作)

  5. 服务端验证状态成立的时候, 会将成立的状态直接广播给当前房间/场景的玩家相关状态

  6. 客户端接收到状态更新, 渲染 UI 层面的动画和效果

也就是实际上的行为操作都是由 房间/场景管理(SecenManager) 独立的 Actor 来控制和评判, 为什么要这么操作?

主要问题就是在于场景是多人共享状态的, 必须要保证数据和行为的线程安全性; 否则场景内部出现可收集物资的话, 多人交互会出现很明显抢占问题

比如场景之后生成的怪物/资源/物资, 如果不做 Actor 处理就要靠锁来保证避免抢占数据出现的异常

这里采用 pekko-actor + pekko-protobuf-v3 生成的模拟游戏状态同步:

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
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
import me.meteorcat.game.protobuf.PlayerMove;
import me.meteorcat.game.protobuf.PlayerState;
import me.meteorcat.game.protobuf.Vector2;
import org.apache.pekko.actor.AbstractActor;
import org.apache.pekko.actor.ActorRef;
import org.apache.pekko.actor.ActorSystem;
import org.apache.pekko.actor.Props;
import org.apache.pekko.event.Logging;
import org.apache.pekko.event.LoggingAdapter;

import java.io.IOException;
import java.time.Duration;
import java.util.*;
import java.util.random.RandomGenerator;

/**
* Pekko Actor 状态同步
*/
public class PekkoStatusSync {


/**
* 玩家会话对象, 一般都是网络层请求之后动态构建的单独 Actor 对象
*/
public static class PekkoSessionActor extends AbstractActor {

/**
* 日志句柄
*/
final LoggingAdapter log = Logging.getLogger(getContext().getSystem(), this);


/**
* 玩家 ID, 实际正式环境可能比较复杂, 不是单纯的单个状态属性字段
*/
final long id;

/**
* 角色名
*/
final String name;

/**
* 角色移动速度
*/
final float speed;


/**
* 场景列表
*/
final Map<Integer, ActorRef> scenes;


/**
* 当前的进入场景, 0 代表没有进入任何场景
*/
int scene = 0;


/**
* 玩家进入场景操作
*/
public record Join(int scene, float x, float y) {

}

/**
* 玩家场景移动操作
*/
public record Move(float x, float y) {
}

/**
* 玩家退出场景
*/
public record Leave() {
}


/**
* 私有化构建
*/
private PekkoSessionActor(long id, String name, float speed, Map<Integer, ActorRef> scenes) {
this.name = name;
this.id = id;
this.speed = speed;
this.scenes = scenes;
}

/**
* 静态构建
*/
public static Props props(long id, String name, float speed, Map<Integer, ActorRef> scenes) {
return Props.create(PekkoSessionActor.class, () -> new PekkoSessionActor(id, name, speed, scenes));
}


/**
* 初始化
*/
@Override
public void preStart() {
log.info("客户端玩家已连接: {}(ID={})", name, id);
}

@Override
public Receive createReceive() {
return receiveBuilder()
.match(Join.class, (command) -> {
ActorRef owner = scenes.get(command.scene);
if (Objects.isNull(owner)) return;

// 存在场景就可以开始执行切换到进入到场景过程
this.scene = command.scene;
PlayerState state = PlayerState
.newBuilder()
.setId(id)
.setPosition(Vector2 // 坐标地址
.newBuilder()
.setX(command.x)
.setY(command.y)
.build()
)
.setSpeed(speed)
.setIsMoving(false) // 进入的时候不处于移动状态
.setUptime(System.currentTimeMillis())
.setHp(100) // 默认 100 血量即可
.build();
owner.tell(new PekkoSceneShared.JoinScene(state), getSelf());
log.info("客户端玩家[ID={},NAME={}]已进入: SceneID={}", id, name, this.scene);
})
.match(PekkoSceneShared.JoinScene.class, (command) -> {
log.info("客户端玩家[ID={},NAME={}]接收到玩家加入场景: {}", id, name, command.state);
})
.match(Move.class, (command) -> {
// 没有选中场景或者场景不存在直接跳过指令
if (scene <= 0) return;
ActorRef sceneActorRef = scenes.get(scene);
if (Objects.isNull(sceneActorRef)) return;

// 通知场景管理器移动
log.info("客户端玩家[ID={},NAME={}]移动(x:{},y:{})", id, name, command.x, command.y);
sceneActorRef.tell(new PekkoSceneShared.InputMove(PlayerMove
.newBuilder()
.setId(id)
.setDirection(Vector2
.newBuilder()
.setX(command.x)
.setY(command.y)
.build())
.build()
), getSelf());
})
.match(PekkoSceneShared.BroadcastState.class, (command) -> {
log.info("客户端玩家[ID={},NAME={}]接收到广播信息: {}", id, name, command.state);
})
.match(Leave.class, (ignore) -> {
// 没有选中场景或者场景不存在直接跳过指令
if (scene <= 0) return;
ActorRef sceneActorRef = scenes.get(scene);
if (Objects.isNull(sceneActorRef)) return;


// 执行场景退出
log.info("客户端玩家[ID={},NAME={}]退出场景", id, name);
sceneActorRef.tell(new PekkoSceneShared.LeaveScene(
false,
getSelf(),
PlayerState.newBuilder().setId(id).build() // 随便构建获取 ID 必须为当前玩家 ID 的数据结构
), getSelf());

this.scene = 0; // 回滚成 0 代表无状态场景
})
.match(PekkoSceneShared.LeaveScene.class, (command) -> {
if (command.reconnect) {
log.info("客户端玩家[ID={},NAME={}]重连场景", id, name);
} else {
// 退出 Actor
log.info("客户端玩家[ID={},NAME={}]退出游玩: {}", id, name, command.state);
getContext().stop(getSelf());
}
})
.build();
}
}


/**
* 多人共享的场景(也可以是房间/地区等)
*/
public static class PekkoSceneShared extends AbstractActor {

/**
* 日志句柄
*/
final LoggingAdapter log = Logging.getLogger(getContext().getSystem(), this);

/**
* 场景名称, 一般都是用 ID 做标识映射到策划配置表来加载场景事件
*/
final String name;

/**
* 场景状态列表
*/
final Map<Long, PlayerState> status;

/**
* 关联的远程 Actor 节点类表
*/
final Map<Long, ActorRef> session;


// 一般来说场景之中会有大量触发事件, 通过策划配置的事件表加载 Lua 等动态脚本实现事件调度
// 比如满足指定条件之后自动触发某些游戏事件或者动画场景, 通知客户端UI层来处理
//final List<Integer,Event> events;

/**
* 私有构建
*/
private PekkoSceneShared(String name, Map<Long, PlayerState> status, Map<Long, ActorRef> session) {
this.name = name;
this.status = status;
this.session = session;
}


/**
* 静态构建
*/
public static Props props(String name, Map<Long, PlayerState> status, Map<Long, ActorRef> session) {
return Props.create(PekkoSceneShared.class, () -> new PekkoSceneShared(name, status, session));
}


/**
* Actor 初始化
*/
@Override
public void preStart() {
log.info("[Scene: {}] 场景已经启动", name);
}

/**
* 玩家加入当前场景
*/
public record JoinScene(PlayerState state) {
}

/**
* 玩家离开当前场景
*/
public record LeaveScene(
boolean reconnect,
ActorRef sender,
PlayerState state
) {

}

/**
* 玩家触发输入指令
*/
public record InputMove(PlayerMove move) {
}

/**
* 广播操作指令
* <p>
* 注意: 不要直接在场景 Actor 直接读写 Socket, 场景只负责处理业务而不负责底层读写, 序列化返回应该由玩家的会话 Actor 负责
*/
public record BroadcastState(PlayerState state) {

}


/**
* 消息拦截
*/
@Override
public Receive createReceive() {
return receiveBuilder()
.match(JoinScene.class, this::handleJoinScene)
.match(LeaveScene.class, this::handleLeaveScene)
.match(InputMove.class, this::handleInputMove)
.build();
}


/**
* 加入场景
*/
private void handleJoinScene(JoinScene command) {
final Long id = command.state.getId();
final ActorRef sender = getSender();

// 如果已经存在就直接覆盖即可(可能是掉线重连的情况)
if (status.containsKey(id)) {
log.info("[Scene: {}] 玩家重新连接到场景, ID = {}", name, command.state.getId());

// 通知断开广播
ActorRef actorRef = session.get(id);
if (Objects.nonNull(actorRef)) {
getSelf().tell(new LeaveScene(
true,
actorRef,
status.get(id)
), getSelf());
}

} else {
log.info("[Scene: {}] 玩家加入到场景, ID = {}", name, command.state.getId());

// 通知所有客户端新玩家加入
session.forEach((ignore, actorRef) -> {
actorRef.tell(new JoinScene(command.state), getSelf());
});
}

// 写入当前参与的玩家状态和玩家 Actor 地址
status.put(id, command.state);
session.put(id, sender);
}

/**
* 离开场景
*/
private void handleLeaveScene(LeaveScene command) {
if (command.reconnect) {
// 重连会话
log.info("[Scene: {}] 玩家重连场景, ID = {}", name, command.state.getId());
command.sender.tell(command, getSelf());
} else {
final Long id = command.state.getId();
final ActorRef sender = session.remove(id);
PlayerState state = status.remove(id);
if (Objects.nonNull(state)) {
// 删除场景
log.info("[Scene: {}] 玩家离开场景, ID = {}", name, id);
command = new LeaveScene(false, command.sender, state);
if (Objects.nonNull(sender)) {
sender.tell(command, getSelf());
} else {
command.sender.tell(command, getSelf());
}
}
}
}


/**
* 玩家移动状态变更
*/
private void handleInputMove(InputMove command) {
final Long id = command.move.getId();
Vector2 direction = command.move.getDirection();

// 确认是否存在用户状态
PlayerState state = status.get(id);
if (Objects.isNull(state)) return;


// 服务端并没有对应的向量计算工具, 不过计算方式相对简单自己处理即可

// 求向量模, sqrt 为公式:√(x² + y²), 但是这里 Math.sqrt 返回的是 double, 只能先自己手写计算
float x = direction.getX();
float y = direction.getY();
float magnitude = x * x + y * y;

// 归一化处理
float normalizedX = 0.0f;
float normalizedY = 0.0f;
if (magnitude >= 1e-6f) {
float sqrtMagnitude = (float) Math.sqrt(magnitude);
normalizedX = x / sqrtMagnitude;
normalizedY = y / sqrtMagnitude;
}

log.info("[Scene: {}] 玩家移动归一值: ID={}, NORMALIZED({}) * {}",
name, id, "%f,%f".formatted(normalizedX, normalizedY), state.getSpeed());

// 2. 计算新位置(核心修复:更新玩家坐标)
Vector2 oldPos = state.getPosition();
float newX = oldPos.getX() + normalizedX * state.getSpeed();
float newY = oldPos.getY() + normalizedY * state.getSpeed();


// 生成新的构建器
PlayerState.Builder builder = state.toBuilder();

// 处理移动状态的 position 变动
builder.setPosition(Vector2
.newBuilder()
.setX(newX)
.setY(newY)
.build()
).setUptime(System.currentTimeMillis());


// 更新覆盖配置, 广播给所有客户端会话 Actor 状态变更
PlayerState newState = builder.build();
BroadcastState broadcast = new BroadcastState(newState);
status.put(id, newState);
session.forEach((ignore, actorRef) -> actorRef.tell(broadcast, getSelf()));

log.info("[Scene: {}] 玩家移动后状态更新: ID={}, 新位置=({},{})",
name, id, newX, newY);
}
}


/**
* 服务入口
*/
public static void main(String[] args) throws IOException {
// 构建 Actor 系统
ActorSystem system = ActorSystem.create("pekko-status-sync");


// 挂载场景1 Actor 服务
ActorRef sceneOf1 = system.actorOf(PekkoSceneShared.props(
"主城区",
// Map 实例化交由外部传入, 因为可能某些功能需要用到线程安全的 MAP, 这样符合外部的依赖反转策略
new HashMap<>(),
new HashMap<>()
), "scene-1");

// 挂载场景2 Actor 服务
ActorRef sceneOf2 = system.actorOf(PekkoSceneShared.props(
"下城区",
// Map 实例化交由外部传入, 因为可能某些功能需要用到线程安全的 MAP, 这样符合外部的依赖反转策略
new HashMap<>(),
new HashMap<>()
), "scene-2");

// 这里简单构建成 Map 作为场景管理器
Map<Integer, ActorRef> scenes = Map.of(
1, sceneOf1,
2, sceneOf2
);

float allSpeed = 1.0f;// 默认全体的移动速度


// 假设目前网络层连接成功并且动态生成了会话
// 注意: 一般来说都是有专门管理器来负责动态加载会话节点, 这里是模拟环境所以减少这部分代码编写
long id = 10001;
String name = "MeteorCat";
ActorRef self = system.actorOf(PekkoSessionActor.props(
id, name, allSpeed, scenes
));


// 这里设计机器人用户加入场景
int capacity = 7;
List<ActorRef> robots = new ArrayList<>(capacity);
RandomGenerator randomGenerator = RandomGenerator.getDefault();
for (int i = 0; i < capacity; i++) {
long rid = randomGenerator.nextLong(20000, 99999);
String rname = "robot-%d".formatted(rid);

// 随机让机器人分布到不同场景
ActorRef robotActorRef = system.actorOf(PekkoSessionActor.props(rid, rname, allSpeed, scenes));
int scene = randomGenerator.nextInt(1, scenes.size() + 1);

// 随机生成坐标位置
float x = randomGenerator.nextInt(0, 100);
float y = randomGenerator.nextInt(0, 100);

// 通知加入场景
robotActorRef.tell(new PekkoSessionActor.Join(scene, x, y), ActorRef.noSender());
robots.add(robotActorRef);
}

// 确认 Actor 地址
for (ActorRef address : robots) {
system.log().info("机器人 Actor: {}", address.path().toSerializationFormat());
}


// 这里模拟自己账号进入下城区(id=2)的 { x=4, y=3 } 位置, 这里延迟下 2s 模拟手动登陆
// 注意: 一般传送点的策划表配置, 而不是直接传入坐标, 这里只是简略处理
system.getScheduler().scheduleOnce(Duration.ofSeconds(2), () -> {
self.tell(new PekkoSessionActor.Join(2, 4, 3), ActorRef.noSender());
}, system.getDispatcher());


// 这里假设客户端移动向上移动之后向做左移动, 那么就相当于如下推送(延迟5s之后)
system.getScheduler().scheduleOnce(Duration.ofSeconds(5), () -> {
self.tell(new PekkoSessionActor.Move(1, 0), ActorRef.noSender()); // 必须采用归一化的值
self.tell(new PekkoSessionActor.Move(0, -1), ActorRef.noSender()); // 必须采用归一化的值
}, system.getDispatcher());


// 最后玩家游玩结束就可以退出游戏, 这里延迟10s之后运行
system.getScheduler().scheduleOnce(Duration.ofSeconds(10), () -> {
self.tell(new PekkoSessionActor.Leave(), ActorRef.noSender());
}, system.getDispatcher());


// 退出模拟
System.out.println("Press RETURN to stop...");
int ignore = System.in.read();
system.terminate();
}
}

运行之后会生成机器人进入指定场景, 同时构建出自身 Actor 模拟出 会话登陆 - 加入场景 - 场景移动 - 广播移动 - 退出游戏 的流程

建议自己动手复制或者编写代码, 确认具体逻辑是否能跑通, 这个 Actor 架构就是很基础的 2D 游戏场景设计和管理.

相比于帧同步, 状态同步仅传输 状态变更, 网络开销更小(只需要广播客户端位移坐标数据)

Actor 类型 核心职责
PekkoSessionActor 玩家会话管理(单个玩家专属):处理玩家的连接/进入场景/移动/退出指令,接收场景广播的状态并响应。
PekkoSceneShared 场景状态管理(多人共享):验证玩家指令合法性、维护所有玩家状态、广播状态变更、处理玩家进出场景。

深入思考

虽然从服务端来看已经构建出简单的 2D 游戏场景的广播服务端架构, 但是客户端应该怎么去使用他呢? 目前仅仅将服务端坐标广播客户端.

客户端需要避免直接把服务端下发的 Vector2 坐标修改成本地坐标, 而是应该采用 线性插值(Lerp) 让对象播放动画特效慢慢移动过去

如果直接客户端把坐标修改成服务端下发的位置, 就会产生出 “瞬移” 效果, 这样的表现看起来及其糟糕

游戏的线性插值 lerpSpeed 建议设置为 10-20, 主要是因为:

  • 值太小: 修正过程太慢, 玩家会感觉 “被拖着走”

  • 值太大: 修正太快, 接近瞬移, 失去平滑效果

LerpSpeed值 效果描述 适用场景
10 修正较慢,手感偏 “软” 休闲游戏、低延迟场景
15 修正速度适中,平衡手感和平滑度 大部分2DRPG
20 修正较快,接近 “无感修正” 竞技类游戏、高延迟场景

在 Unity 之中就实现类似如下插值:

1
2
3
4
5
transform.position = Vector2.Lerp(
transform.position, // 当前坐标
new Vector2(state.Position.X, state.Position.Y), // 服务器下发坐标
Time.deltaTime * 15f // 移动插值 15f 综合起来还行
);

这部分需要按照个人习惯偏好来调整, 目前视觉效果和操作手感比较好的是采用 预测移动 机制:

  1. 本地玩家输入归一方向

  2. 本地先行移动到指定位置, 同时提交移动指令到服务端

  3. 服务端后续下发移动坐标, 本地先行移动的位置调整成服务端下发位置

  4. 最后采用线性插值的方式将先行到达调整到服务端下发位置

  5. 结果就是本地坐标和服务坐标同步

本地和服务端坐标是允许有偏差值的, 两者偏差值不需要百分之百准确, 稍微偏移 0.1~0.2f 不是什么大问题(也就是上面的10-20)

这样的预测插值处理有以下好处:

  • 操作手感: 本地输入后立即移动, 无网络延迟带来的 “按键没反应” 问题

  • 状态一致: 最终以服务端权威位置为准, 避免客户端位置偏移

  • 视觉体验: 插值修正过程平滑, 玩家感知不到“修正”动作

所以有时候会看到某些网络游戏的敌方明明距离你好像还有一段距离, 但就是会移动去触发敌方的战斗范围(服务端已经锁定触发事件)

另外还有服务端广播的问题, 上面的简单 2DRPG 场景案例没有说明, 注意服务端广播有两种区分:

  • 立即广播: 上面的案例采用的就是这种直接广播方式, 实现起来简单, 但只适用于玩家人少的时候, 玩家数量上来时候会出问题

  • 批量广播: 把周期的需要执行的广播状态保存成有序列表, 挂载场景 Actor 定时器批量将多条状态同步指令合并成一次响应返回给客户端

这里模拟过场景只采用立即广播的测试:

  • 假设一个 PlayerState 序列化后是 32 字节, 场景有 100 个玩家

  • 单个玩家每秒移动 10 次 → 服务端每秒要发送 100 × 10 × 32 = 32000 字节 = 31.25KB

  • 10 个玩家同时移动 → 每秒 312.5KB, 100 个玩家同时移动 → 每秒 3.125MB

  • 这还只是移动状态, 加上血量、技能等状态, 带宽会快速超过服务器承载上限

而采用批量广播则会将多条指令合并成一条, 经由序列化压缩一次性返回执行, 比起频繁做数据构建消息结构并响应网络好很多

维度 立即广播 批量广播
网络包数量 每状态变更1个包 每间隔Nms 1个包(合并所有状态)
序列化开销 频繁序列化(每状态1次) 单次序列化(合并所有状态)
带宽占用 高(100玩家≈3MB/s) 低(100玩家≈0.3MB/s,降低90%)
实现复杂度 中(需缓存+定时器+批量消息)
适用场景 低并发/高优先级事件 高并发/移动等高频状态

其实最好的方法是两者混合起来使用: 如果消息实行要求比较高就采用立即广播, 否则采用批量广播形式.

广播类型 事件/状态类型 采用原因 实现方式
立即广播 技能释放、血量变化、道具拾取、玩家进出场景、战斗触发 这类事件对实时性要求高,延迟会导致逻辑不一致(比如“我看到他没血了,但他还在打我”) 保持当前的立即广播逻辑,事件发生后直接遍历所有玩家会话,发送单条状态广播消息
批量广播 玩家移动位置、移动方向、站立/移动状态 移动状态允许短时间延迟,客户端的预测+插值机制可以掩盖50~100ms的延迟 在场景管理器Actor中添加状态缓存(保留每个玩家最新状态)+ 定时(50~100ms)批量广播逻辑,合并多条状态为单次响应发送

批量广播的周期控制在 50~100ms 最好

这样处理能够让服务端更加高效处理共享的数据结构, 首要保证就是 服务器状态必须是唯一权威, 客户端最终的数据都是要以服务端为主

区域广播

上面案例就是针对整个场景广播, 一般都是归类到 场景广播

这里是从 大世界/大地区 架构另外衍生出来的概念; 在大地图同个场景100人的情况, 如果大地图要素交互元素过多, 采用场景广播效率是很差的

包括大部分在地图的时候, 没必要关心同场景所有人的操作指令广播, 而是只需要关注自己 “视窗” 附近的人广播即可

针对这个问题游戏开发中普遍采用 AOI(Area of Interest,兴趣区域) 机制来优化, 核心思想是: 只向玩家广播其视野范围内的实体状态变更

方案 全场景广播 AOI 视野广播
网络开销 与场景玩家总数成正比(100人=100倍开销) 与视野内玩家数成正比(10人=10倍开销)
客户端计算 需处理全场景实体状态 仅处理视野内实体状态
适用场景 房间制小游戏(<20人) 大世界MMO(>100人)
性能瓶颈 玩家数超过50人后带宽飙升 支持千人同屏(依赖网格粒度优化)

假设大世界场景有 1000 玩家, 每个玩家视野内只有 20 玩家:

  • 场景广播: 每个玩家移动需要广播给 999 人 → 1000 玩家 × 10 次 / 秒 × 999 次 = 9990000 次广播 / 秒

  • 区域广播: 每个玩家移动只广播给 20 人 → 1000 玩家 × 10 次 / 秒 × 20 次 = 200000 次广播 / 秒

  • 广播次数降低 98%, 带宽和 CPU 开销呈指数级下降

而针对范围广播目前主要的划分方式有以下几种:

  • 网格划分: 将地图划分为 gridSize × gridSize 的正方形网格(比如 10m×10m), 每个网格对应单个
    SceneActor(场景管理器)

  • 玩家视野: 玩家的兴趣区域为 以自身为中心的 3×3 网格(可配置), 即玩家会感知周围 8 个相邻网格的实体

  • 分区切换: 玩家移动超出当前网格时, 场景 Actor 会将玩家从旧分区移除并加入新分区, 完成后通知新旧分区内的玩家更新视野列表

更进一步可以针对性追加的关键优化点:

  • 大地图场景切分: 大地图将按照网格细分划分成多个场景分区

  • 懒加载分区: 只有玩家进入的网格才会创建,避免空网格占用资源

  • 3×3 视野配置: 可根据游戏类型调整(比如竞技游戏用 2×2,休闲游戏用 5×5)

  • 分区切换优化: 仅计算新旧视野的差异网格, 避免重复加入 / 离开所有网格

  • 批量广播兼容: 可在每个分区场景管理器内加入批量缓存逻辑, 进一步降低同分区内的广播频率

不过必须明确了解你的服务端承载的最大规模, 如果规模本身不大的情况其实使用分区即可

我之前的案例就是将城市的分区为 “上城区” 和 “下城区”, 这也是简单类型的地图分区

而对于 SLG 类型游戏, 一般做好性能优化和服务配置合理, 百人在线同屏对战其实也不是什么大问题:

维度 SLG 同屏对战 MMO 大世界漫游
移动频率 低(部队移动是路径规划+匀速行军,每秒 1-2 次状态更新) 高(玩家自由移动,每秒 10+ 次状态更新)
实体类型 单一(主要是部队、城池,无大量 NPC/道具) 复杂(玩家、NPC、怪物、道具、技能特效等)
交互逻辑 弱实时(对战指令是回合制/半回合制,无需毫秒级同步) 强实时(技能释放、普攻需要毫秒级同步)

这方面设计比起帧同步来说简单不少, 并且只需要客户端相互优化下视觉效果让其看起来更灵动些即可.

寻路与遇敌触发

网络游戏除了以键盘|手柄对玩家状态进行移动之外, 还有拖动大地图选中位置计算路线之后缓慢移动, 上面的场景管理器只讲的第一种.

目前主流的的处理方式就是采用 A*寻路算法 来做底层寻路技术, 完整链路如下:

  1. 客户端操作: 玩家拖动地图选中目标点, 发送 MotionPath 给服务端请求路线

  2. 服务端寻路: 场景 Actor 接收请求, 用 A* 算法 计算从玩家当前位置到目标点的可行路径(避开障碍物)

  3. 路径下发: 服务端将路径点列表返回给客户端, 同时在服务端记录该玩家的移动路径

  4. 自动移动: 服务端按照路径点逐段移动玩家, 同时批量广播位置状态

  5. 遇敌检测: 移动过程中, 服务端实时检测玩家与视野内敌人的距离, 满足条件则触发战斗

在服务端下发路径之后, 自身也需要创建个移动定时器(100ms执行一次tick), 在服务端模拟客户端移动并且计算是否触发遇敌范围等.

请注意, 下发的路线其实是数组列表, 用来多个点形成一条完整的路线(起点→终点), Protobuf 定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
syntax = "proto3";

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

// 寻路下发的移动路径
message MotionPath{
bool succeed = 1; // 判断是否可以成功到达目的地, 有的时候需要场景触发事件才能开启路线
repeated Vector2 points = 2; // 路径点列表(起点→终点)
int32 status = 3; // 失败原因, 采用错误代号标识配置, 方便客户端加载支持 i18n 多语言展示
}

大部分情况 MotionPath 下发结果有以下情况(其实只需要关于 succeed 和 status 就知道是否正常):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static class ERRORS {

final MotionPath ERROR_PATH = new MotionPath(
false, // 无法到达的目的地
Collections.emptyList(),
ERRORS.NOT_TARGET // 自定义异常标识值
);

final MotionPath SUCCESS_PATH = new MotionPath(
true, // 可以执行的路线运动
List.of(/* Vec2{....} */), // 路线点列表
0 // 默认0代表没有异常
);
}

succeed 属性大部分情况都会被 status 覆盖, 实际上该属性可以考虑直接去除, 而只使用 status 来判断状态

具体的寻路运行流程如下:

  1. 服务端下发路线, 客户端和服务端必须采取相同的移动速度同时执行前往首个坐标点的状态更新

  2. 服务端的定时器每 Tick 都需要执行 速度 × 时间 获取移动距离, 不断更新玩家坐标到服务端的 PlayerState

  3. 更新服务端 State 的同时采用批量下发形式, 将位置不断下发给客户端, 让客户端做插值平滑同步到服务端的坐标

  4. 在遍历所有路线点更新 State 之后需要做敌人仇恨值或者战斗范围检测, 如果进入战斗阶段就要修改切换 Moving 状态让定时器不继续走移动逻辑

  5. 触发战斗额外下发通知, 告诉客户端切换战斗场景处理, 这个时候服务端定时器处于空转状态, 必须要战斗结束切换可移动才能继续移动

  6. 直到最后 paths 列表的路线点全部到达完毕就代表执行完成

这部分需要很清晰了解 A* 算法和实现方式, 否则会出现路线完全不准的情况

在这个过程当中, 无论客户端怎么虚构数据来伪造已经到达都没用, 因为服务端已经运行并得出 权威结果, 客户端仅仅作为位置校正而已

游戏开发大部分情况下主要问题还是美术和客户端实现上, 服务端基本都是作为协调者负责处理脏活累活, 决定游戏是否成功还是看美术和客户端