首先简单讲一下帧同步的原理。帧同步是同一场战斗中的各个玩家的操作(比如移动摇杆、点击释放技能等)不立即在客服端执行,而是先上报到服务器。服务器收集并合并所有玩家的操作(必要时进行校验等控制),下发给所有客服端,客户端收到之后再执行。
只要各个客户端的代码(版本)一致,并且需要用到的随机数也进行同步,那么所有客服端运行出来的表现结果是一致的。大部分moba类游戏,例如王者荣耀,都是使用帧同步。帧同步适用于对同步要求比较高(格斗竞技类游戏)、一场战斗内玩家不算多(因为要同步所有玩家的操作,moba类游戏一般就10个人)的场景。
我们的手游有组队PVE、排位赛PVP,有强烈的需求使用帧同步。帧同步也是我们这个项目遇到的难点,走了不少弯路。
首先是通讯协议的选择,我们开发了UDP版、TCP版、还用过第三方的“可靠”UDP,最终用了自己UDP版。还有刚开始和状态同步混用,同步了血量等信息,导致不同步难以调试等。
并且帧同步细节也很多,比如怪物AI挂靠、玩家网络不好的情况下追帧处理、断线重连、以及有玩家掉线转AI的处理等。以下都会直接用代码实现讲解。
直接上简化版的帧同步实现代码。
//同步类型枚举
enum FrameSyncDataType{
COMMON_UNKNOW_SYNCTYPE = 0 ;
FRAME_SYNC_CONNECT = 1;//连接
FRAME_SYNC_READY = 2;//预备
FRAME_SYNC_START = 3;//开始
FRAME_SYNC_CHANGE_POS = 4;//同步位置
FRAME_SYNC_PLAY_SKILL = 5;//同步释放技能
FRAME_SYNC_MOVE_START = 6;//同步开始移动操作
FRAME_SYNC_MOVE_SPEED = 7;//同步移动操作速度
FRAME_SYNC_MOVE_END = 8;//同步停止移动操作
FRAME_SYNC_END = 22;//结束
}
message SyncMechaInfo{
optional int32 zoneId = 1 [default = 0];
optional int32 playerId = 2 [default = 0];
}
//通知客户端做数据变更的具体数据信息
message FrameSyncData{
optional SyncMechaInfo syncObj = 4 ;//帧同步数据对象信息
optional FrameSyncDataType frameSyncDataType= 1 ;//帧同步数据类型
optional bytes frameSyncBytes= 2 ; //具体同步对象的pb字节数组
}
//通知客户端做数据变更的具体信息数组
message FrameSyncDataArray{
optional float deltaTimeFloat= 15 ; //距离上一帧的时间差值,以秒为单位,客户端帧间时差以这个为准来运算
optional int64 totalTime = 8 [default = 0]; //战斗持续的总时间,单位毫秒
optional int32 randomSeed = 9 [default = 0];//同步随机数种子
optional SyncMechaInfo syncObj = 5; //客户端上报的时候填这里,可以不填同步数据信息内的,节省网络带宽
optional int32 pkSessionId= 3 [default = 0]; //战斗sessionId
optional int32 frameIndex= 2 [default = 0]; //战斗服务器同步的服务器帧id,客户端上报时则表示是客户端收到过的服务器最近一次帧id
optional int32 clientSeq= 4 [default = 0]; //客户端上报专用的本地帧序号,用于服务器过滤重复帧或旧帧
repeated FrameSyncData syncs= 1 ;//0到多个同步数据信息
repeated StringStringKeyValue playerAI = 13;//key:掉线转AI的玩家playerId@zoneId;value:负责跑该AI的玩家playerId@zoneId
repeated IntStringKeyValue npcAI = 14;//key:需要跑ai的小怪id除以5得到的余数,即01234;value:负责跑这些小怪AI的玩家玩家playerId@zoneId
}
message IntStringKeyValue{
required int32 key = 1 [default = 0]; //键值对的整数Key
required string value = 2 [default = ""]; //键值对的字符串Value
}
message StringStringKeyValue{
required string key = 1 [default = ""]; //键值对的字符串Key
required string value = 2 [default = ""]; //键值对的字符串Value
}
玩家类
public class PkPlayer {
private int zoneId = 0;
private int playerId = 0;
private int lastSyncFrameSeq = 0;// 最近一次同步到的服务器帧序号,帧序号是递增的
private long connectedTime = 0;// 连接到服务器的时间,大于0表示客户端网络联通了
private long readyTime = 0;// 准备就绪的时间,大于0表示客户端准备就绪了
private long changeAiTime = 0;//被转成AI的时间,大于0表示被转成了AI。转成AI之后可能又会转回来变成0
private long offLineTime = 0;// 玩家掉线时间,大于0表示客户端连接掉线了
private long endTime = 0;// 玩家上报的战斗结束时间,大于0表示已经上报结束
private FrameSyncEndData endData;// 玩家上报的战斗结束信息,用于校验战斗结果
private Set<Integer> receivedClientSeqSet = new TreeSet<Integer>();
private int receivedClientSeqMax = 0;
private IoSession ioSession = null;
public IoSession getIoSession() {
return ioSession;
}
public void setIoSession(IoSession ioSession) {
this.ioSession = ioSession;
}
public long getOffLineTime() {
return offLineTime;
}
public void setOffLineTime(long offLineTime) {
this.offLineTime = offLineTime;
}
public int getZoneId() {
return zoneId;
}
public void setZoneId(int zoneId) {
this.zoneId = zoneId;
}
public int getPlayerId() {
return playerId;
}
public void setPlayerId(int playerId) {
this.playerId = playerId;
}
public String getPlayerIdStr() {
return playerId + "@" + zoneId;
}
public int getLastSyncFrameSeq() {
return lastSyncFrameSeq;
}
public void setLastSyncFrameSeq(int lastSyncFrameSeq) {
this.lastSyncFrameSeq = lastSyncFrameSeq;
}
public long getConnectedTime() {
return connectedTime;
}
public void setConnectedTime(long connectedTime) {
this.connectedTime = connectedTime;
}
public long getReadyTime() {
return readyTime;
}
public void setReadyTime(long readyTime) {
this.readyTime = readyTime;
}
public long getEndTime() {
return endTime;
}
public void setEndTime(long endTime) {
this.endTime = endTime;
}
public FrameSyncEndData getEndData() {
return endData;
}
public void setEndData(FrameSyncEndData endData) {
this.endData = endData;
}
public Set<Integer> getReceivedClientSeqSet() {
return receivedClientSeqSet;
}
public boolean isDealedClientSeq(int clientSeq) {
return receivedClientSeqSet.contains(clientSeq);
}
public void addDealedClientSeq(int clientSeq) {
receivedClientSeqSet.add(clientSeq);
if (receivedClientSeqMax < clientSeq) {
receivedClientSeqMax = clientSeq;
}
}
public int getReceivedClientSeqMax() {
return receivedClientSeqMax;
}
public long getChangeAiTime() {
return changeAiTime;
}
public void setChangeAiTime(long changeAiTime) {
this.changeAiTime = changeAiTime;
}
/*
* 给玩家发送数据
*/
public void send(FrameSyncDataArray fsda) {
if (ioSession != null) {
ioSession.write(fsda);
}
}
}
战斗会话类
public class PkSession {
private static LoggerWraper log = LoggerWraper.getLogger("PkSession");
/**
* 战斗的会话id
*/
private int sessionId = 0;
/**
* 战斗状态,0是初始等待状态,1是战斗中,2,是战斗正常结束,3是战斗异常结束
*/
private int pkState = 0;
/**
* 创建时间
*/
private long createTime = System.currentTimeMillis();
/**
* 第一个玩家连上开始等待其他玩家的时间
*/
private long startWaitTime = 0;
/**
* 战斗开始时间
*/
private long startTime = 0;
/**
* 所有玩家信息
*/
List<PkPlayer> pkPlayers = new ArrayList<PkPlayer>();
/**
* 掉线玩家的AI挂靠
*/
private List<StringStringKeyValue> playerAI = new ArrayList<StringStringKeyValue>();
/**
* 小怪的AI挂靠
*/
private List<IntStringKeyValue> npcAI = new ArrayList<IntStringKeyValue>();
/**
* 帧同步数据
*/
private Map<Integer, FrameSyncDataArray> fsdaMap = new ConcurrentHashMap<Integer, FrameSyncDataArray>();
/**
* 帧序号
*/
private AtomicInteger serverFrameSeq = new AtomicInteger();
/**
* 上一帧的运行的时间
*/
private long preFrameTime = 0;
/**
* 结束帧序号
*/
private AtomicInteger endFrameIndex = new AtomicInteger(0);
/**
* 准备帧
*/
FrameSyncDataArray waitFrame = FrameSyncDataArray.newBuilder().setPkSessionId(sessionId).setFrameIndex(0).build();
/**
* 合并的操作队列
*/
private ArrayBlockingQueue<FrameSyncDataArray> cachedOpList = new ArrayBlockingQueue<FrameSyncDataArray>(500);
/**
* 等待第一个人连入的时间(秒)