多人联机开发总结
最近有点忙,新老项目的迭代,让我有点马不停蹄。在种种的尝试下,我还是采用了 TCP 去做帧同步,没有用下 UDP 和 KCP。在腾讯云全球加速的加持下,高延迟的玩家并不是很多。这段时间算是对联机帧同步和状态有了一些了解,是时候可以分享一些心得了。
同步策略
现在大多游戏常用的两种同步技术方向分别是: 帧同步和状态同步。经常看到同行在讨论 “该用帧同步还是状态同步”?似乎这已经变成了一个非此即彼、二选一的问题。 但在实际开发中,帧同步和状态同步经常是联动使用的。
帧同步
帧同步对于服务端来说是比较简单的,抛开个别数据存储的的逻辑。大多数只要做一个数据转发而已。客户端告诉服务端,服务端再广播给相关的客户端即可。
状态同步
状态同步往往是客户端把操作告诉服务端,服务端拿到操作进行相关的业务逻辑处理,再把结果服务端再广播给相关的客户端即可。比如射 击类游戏。
同步策略
在实际体验时,用户的网络情况真的不好预测。就像常见的 460 一样。网络延迟一直是联机游戏的痛点。那么如何避免延迟造成的卡顿?我们可以分四个步骤。
先行
比如客户端移动时,直接渲染。无需等待后端的状态响应。立即执行动作等,异步的去发送状态。比如斗地主,象棋等游戏。
预测
先自行计算操作展示给玩家, 等服务端状态返回后再次渲染,但是这样会有个问题。状态回退。
和解
和解就是一个公式:预测帧 = 权威帧 + 预测输入
因为我们用了 tcp 协议,所以我们无需关注丢包的情况。
权威状态: 服务端回来的状态。
伪代码:
// 初始化帧
currentFrame = 0
authoritativeState = { x: 0, y: 0 } // 初始权威状态
predictedState = { x: 0, y: 0 } // 初始预测状态
inputQueue = [] // 存储预测输入
// 客户端发送的输入,例如向右移动
function sendInput(input) {
currentFrame++ // 增加当前帧数
inputQueue.push({ frame: currentFrame, input: input }) // 存储预测输入
applyInput(predictedState, input) // 立即应用输入,更新预测状态
render(predictedState) // 立即渲染预测状态
}
// 服务器返回权威状态
function onServerUpdate(serverFrame, authoritativeInput) {
if (serverFrame > currentFrame) {
console.log("警告:服务器帧超前")
return
}
// 更新本地的权威状态为服务器返回的权威帧
applyInput(authoritativeState, authoritativeInput)
// 清除到此帧之前的预测输入
inputQueue = inputQueue.filter(input => input.frame > serverFrame)
// 从最新的权威状态开始,重播本地预测 输入
replayPredictions()
}
// 应用输入到状态
function applyInput(state, input) {
if (input === '右移') {
state.x += 1
}
}
// 重播预测输入
function replayPredictions() {
predictedState = { ...authoritativeState } // 以权威状态为基础
for (const input of inputQueue) {
applyInput(predictedState, input.input)
}
render(predictedState) // 根据重播后的预测状态进行渲染
}
// 渲染函数(用于更新 UI)
function render(state) {
console.log("渲染位置: ", state)
}
// 测试流程
// 用户发出了两个右移指令
sendInput('右移') // 帧 1
sendInput('右移') // 帧 2
// 服务端返回帧 1 的权威状态
onServerUpdate(1, '右移')
// 用户又发出了两个右移指令
sendInput('右移') // 帧 3
sendInput('右移') // 帧 4
// 服务端返回帧 2 的权威状态
onServerUpdate(2, '右移')
加载游戏
在多人游戏中,玩家的设备性能和 网络状况可能会导致不同步加载。因此,可以设计一个加载上报接口和相应的处理机制,确保游戏能够在合理的时间内启动。
加载上报接口:
• 每个玩家在加载游戏时,客户端会周期性上报加载进度到服务器(例如每隔 1 秒)。
• 当服务器接收到所有玩家的加载进度时,维护每个玩家的进度状态。
处理逻辑:
• 服务器可以设置一个加载超时时间(例如 30 秒)。如果所有玩家在这个时间内都加载完成,则游戏正常开始。
• 如果有玩家未在超时时间内加载完成:
• 可以先让已加载完成的玩家开始游戏(例如广播“游戏开始”消息)。
• 未加载完成的玩家会在后续完成加载时进入游戏,进入后自动同步当前游戏状态(如游戏进程、分数等)。
伪代码:
class GameServer {
playerLoadStatus = new Map(); // 存储每个玩家的加载进度
// 接收加载进度报告
reportLoadProgress(playerId, progress) {
this.playerLoadStatus.set(playerId, progress);
if (this.checkAllPlayersLoaded()) {
this.broadcastStartGame();
}
}
checkAllPlayersLoaded() {
// 检查是否所有玩家都已加载完成
return Array.from(this.playerLoadStatus.values()).every(p => p === 100);
}
broadcastStartGame() {
console.log("游戏开始");
// 广播消息给已加载的玩家
this.broadcastToAllPlayers('gameStart');
}
// 加载超时处理
handleLoadTimeout() {
console.log("部分玩家未加载完成,但游戏开始");
this.broadcastToLoadedPlayers('gameStart');
}
}
断线重连回到对局中
游戏中,玩家可能会掉线。在重连时,可以通过以下流程实现重连到对局:
处理逻辑:
1. 记录游戏状态:在游戏进行中,服务器要维护每个房间的游戏状态,并定期保存玩家的状态(例如位置、分数等)。
2. 玩家掉线:当检测到玩家掉线,服务器不会立刻移除该玩家,而是保留玩家的状态一段时间,等待重连。
3. 重连处理:
• 玩家重连后,服务器首先检查该玩家是否有正在进行的对局。
• 如果有,服务器将玩家重新加入房间并同步游戏状态(例如分数、位置等),以便无缝回到对局中。
• 如果游戏已结束或房间不再存在,服务器可以无感处理,让玩家进入主菜单或等待下一场游戏。
伪代码:
class GameServer {
activeGames = new Map(); // 存储进行中的游戏及其状态
handlePlayerDisconnect(playerId) {
const game = this.findGameByPlayer(playerId);
if (game) {
game.markPlayerAsDisconnected(playerId);
}
}
handlePlayerReconnect(playerId) {
const game = this.findGameByPlayer(playerId);
if (game) {
game.reconnectPlayer(playerId);
this.syncGameStateToPlayer(playerId, game);
} else {
console.log("游戏已结束或不存在");
// 处理未能重连的情况
}
}
findGameByPlayer(playerId) {
// 查找玩家是否有正在进行的游戏
for (const game of this.activeGames.values()) {
if (game.hasPlayer(playerId)) {
return game;
}
}
return null;
}
syncGameStateToPlayer(playerId, game) {
const playerState = game.getPlayerState(playerId);
// 发送游戏状态到客户端
this.sendMsgToPlayer(playerId, 'syncGameState', playerState);
}
}
感悟
联机还是有很多场景和考虑的,不要等策划设计才改代码(SRP),提前设计好代码,可以参考竞品或者主流游戏的设计思路,预留可能出现的交互和设计。😀