什么是帧同步
帧同步同步的是客户端的操作指令。客户端上传操作到服务器,并且服务器并不做过多的处理,然后将当前帧间隔内收集到的操作指令广播给每一个客户端,各个客户端在一致环境下,处理同样的操作输入,则会得到同样的结果,这样服务器的计算压力、传输数据量就相对小很多,能更好的满足游戏对高实时性、高一致性的要求。
帧同步的要点与细节
-
运算结果一致性
帧同步的逻辑运行环境在客户端,这要求在客户端运行时候运算结果要一样,为了达到这种目的,需要做到
- 随机数一致
- 浮点数一致
- 命令顺序一致
- 逻辑一致
- 物理引擎一致(暂无)
- 随机数一致
通过设置同一个随机种子,确保多端结果一致
export class IOMath {
public static _seed: number = 20240118;
/**
* 设置随机数种子
* @param seed
*/
public static setSeed(seed: number) {
this._seed = seed;
}
/**
* 获取随机数
*/
public static random(): number {
const a = 1664525;
const c = 1013904223;
this._seed = (a * this._seed + c) & 0x7fffffff; // 使用位运算确保结果为正整数
return this._seed / 0x7fffffff; // 将结果归一化到 [0, 1) 范围内
}
}
typescript浮点数在传输的时候没法保证一致性,为了保持一致性,在传输时,将浮点数转化为整数
/**
* 获取当前状态
* @param playerId 当前需要获取游戏状态的玩家,这个id用于做增量状态下发。
*/
getState(playerId: number): string {
let state = this.gameState.clone();
state.playerState.forEach(e => {
e.headPos.x = Math.ceil(e.headPos.x * 1000)
e.headPos.y = Math.ceil(e.headPos.y * 1000)
e.dir.x = Math.ceil(e.dir.x * 1000)
e.dir.y = Math.ceil(e.dir.y * 1000)
})
let ioNetS = ModuleService.getModule(IONetModuleS);
if (ioNetS.playerIsFirstSync(playerId)) {
state.playerState.forEach(e => {
if (ioNetS.playerIsFirstSync(e.playerId)) {
e.nodePos = []
} else {
e.nodePos.forEach((v, i) => {
v.x = Math.ceil(v.x * 1000)
v.y = Math.ceil(v.y * 1000)
})
}
})
} else {
state.playerState.forEach(e => {
e.nodePos.forEach((v, i) => {
v.x = Math.ceil(v.x * 1000)
v.y = Math.ceil(v.y * 1000)
})
})
}
return JsonEx.stringify(state)
}
/**
* 回滚状态
* @param jsonStr
*/
backState(jsonStr: string): void {
if (jsonStr == null || jsonStr == "") {
return;
}
let newState = new SnackGameState()
try {
if (JsonEx.parse(jsonStr, newState) != null) {
//console.error("backState KB : " + jsonStr.length / 1024);
}
} catch (e) {
console.error("json err : " + jsonStr);
return;
}
newState.playerState.forEach((value, index) => {
let curState = this.gameState.playerState.find(e => e.playerId == value.playerId)
if (curState == null && value.die == false && value.nodePos.length > 0) {
let newState = SnackPlayerState.copy(value)
newState.nodePos.forEach(e => {
e.x = e.x / 1000;
e.y = e.y / 1000;
})
newState.headPos.x = newState.headPos.x / 1000;
newState.headPos.y = newState.headPos.y / 1000;
newState.dir.x = newState.dir.x / 1000;
newState.dir.y = newState.dir.y / 1000;
this.gameState.playerState.push(newState);
/**
* 回滚状态的时候判断,如果自己是第一次出生。则设置下摄像机位置
*/
if (newState.playerId == mw.Player.localPlayer.playerId) {
mw.Camera.currentCamera.worldTransform.position = new mw.Vector(newState.headPos.x, newState.headPos.y, mw.Camera.currentCamera.worldTransform.position.z)
}
} else {
if (curState == null) return;
curState.headPos.x = value.headPos.x / 1000;
curState.headPos.y = value.headPos.y / 1000;
curState.dir.x = value.dir.x / 1000;
curState.dir.y = value.dir.y / 1000;
curState.moveSpeed = value.moveSpeed;
curState.score = value.score;
curState.die = value.die;
}
});
let newGameState = [];
this.gameState.playerState.forEach(e => {
if (newState.playerState.find(e2 => e2.playerId == e.playerId)) {
newGameState.push(e)
}
})
this.gameState.playerState = newGameState;
this.gameState.spawnCount = newState.spawnCount
}
- 命令顺序一致
这里保持一致性的方式是通过rpc的顺序传输,mw的rpc传输是必定到达且有顺序的传输,传输队列的顺序的一致性是可以保证的
- 逻辑一致
在客户端和服务端分别运行一样的逻辑,保证不同端的内容,保持一致
////////////////// 服务端会运行预测帧逻辑 /////////////////
/**
* 滚动逻辑
* @param frameNumber
*/
private onTick(frameNumber: number, param: InputParamsObj<InputCommanderBase>) {
/**
* 预测客户端逻辑
*/
this.gameLogic.onFrameTick(IONetDef.FrameTickNumber, param, true)
}
////////////////// 客户端也会执行逻辑帧,这里因为拉扯等问题直接返回了 /////////////////
/**
* 滚动逻辑
* @param frameNumber
*/
private onTick(frameNumber: number, param: InputParamsObj<any>, isPreLogic: boolean) {
return;
/**
* 向远端发送param
*/
if (param != undefined && param != null && MapEx.has(this._inputParamsAck, frameNumber)) {
//this.server.net_OnInputField(param);
} else {
let paramIns: InputParamsObj<InputCommanderBase> = new InputParamsObj<InputCommanderBase>();
let params = {};
params["frameCount"] = frameNumber;
MapEx.set(paramIns.pCmds, mw.Player.localPlayer.playerId.toString(), params);
MapEx.set(this._inputParams, frameNumber.toString(), paramIns);
this.server.net_OnInputField(paramIns);
MapEx.set(this._frameDelay, this._clientFrameNumber, Date.now());
param = paramIns;
}
/**
* 预测客户端逻辑
*/
if (isPreLogic) {
this.gameLogic.onFrameTick(IONetDef.FrameTickNumber, param, false)
}
}
////////////////// 客户端得到服务端命令之后回滚帧,并重新执行相关逻辑 /////////////////
public net_OnServerTick(remoteFrameNumber: number, state: string, inputParams: InputParamsObj<InputCommanderBase>, seed: number, isComplate: boolean): void {
this.reviceState += state.toString();
if (isComplate == false) {
return;
}
state = this.reviceState.toString()
this.reviceState = ""
if (this.init == false) return;
/**
* 回滚状态
*/
this.gameLogic.backState(state);
/**
* 设置服务器帧号
*/
this.serverFrameNumber = remoteFrameNumber;
let clientFrameNum = 0;
let preReciveClientFrameNumber = this._reciveClientFrameNumber;
if (this._reciveClientFrameNumber + 1 < this.clientFrameNumber) {
preReciveClientFrameNumber++
}
if (MapEx.has(inputParams.pCmds, mw.Player.localPlayer.playerId.toString())) {
let info = MapEx.get(inputParams.pCmds, mw.Player.localPlayer.playerId.toString());
if (!Number.isNaN(info.frameCount)) {
if (info.frameCount >= this._reciveClientFrameNumber) {
preReciveClientFrameNumber = info.frameCount
}
}
}
this._reciveClientFrameNumber = preReciveClientFrameNumber
/**
* 读取上次投递的帧号,好做回滚预测。
* 如果空帧的情况,就递增一帧
*/
clientFrameNum = this._reciveClientFrameNumber
/**
* 移除上一帧的输入
*/
if (clientFrameNum > 0) {
MapEx.del(this._inputParams, clientFrameNum - 1);
MapEx.del(this._inputParamsAck, clientFrameNum - 1);
}
/**
* 修正当前帧输入
*/
MapEx.set(this._inputParams, clientFrameNum, inputParams);
/**
* 设置当前帧输入参数已下发
*/
MapEx.set(this._inputParamsAck, clientFrameNum, true);
/**
* 设置随机数种子
*/
IOMath.setSeed(seed)
/**
* 获取帧延迟
*/
let frameDelay = MapEx.get(this._frameDelay, clientFrameNum);
MapEx.del(this._frameDelay, clientFrameNum);
let now = Date.now();
if (frameDelay == undefined) {
frameDelay = 0;
} else {
frameDelay = now - frameDelay;
}
/**
* 滚动预测逻辑
*/
Event.dispatchToLocal("updateFrame", clientFrameNum, this.serverFrameNumber, this._clientFrameNumber, frameDelay);
/**
* 补偿逻辑帧
*/
let param = MapEx.get(this._inputParams, clientFrameNum);
this.gameLogic.onFrameTick(IONetDef.FrameTickNumber, param, true);
return;
for (let i = clientFrameNum; i < this._clientFrameNumber; i++) {
let param = MapEx.get(this._inputParams, i);
this.gameLogic.onFrameTick(IONetDef.FrameTickNumber, param, i == clientFrameNum);
}
}
-
显示与逻辑分离
客户端逻辑、渲染分离主要解决以下问题:
1,抗网络抖动导致的渲染抖动(各种表现不平滑),不分离的话网络抖动导致逻辑抖动进而影响渲染
2,新建线程处理逻辑层,避免逻辑、渲染共用线程导致拖累渲染(因为是js没有做独立的逻辑线程)
3,逻辑层独立出来,可做服务端验算(帧同步因为数据都在本地,很容易出现透视等作弊操作,并且难被发现)
/**
* 本地tick
*/
protected onUpdate(dt: number): void {
if (this.init == false) {
return;
}
/**
* 以服务端的帧率进行预测,如果帧率低的情况下 也会追帧。
*/
this.curFrameDt += dt;
while (this.curFrameDt >= IONetDef.FrameTickNumber) {
this.curFrameDt -= IONetDef.FrameTickNumber;
/**
* 服务器必须要先跑一帧 客户端才能开始跑
*/
if (this.serverFrameNumber > 0 &&
(this.clientFrameNumber - this._reciveClientFrameNumber) < IONetDef.maxPreLogicCount) {
//if (this._clientFrameNumber < this._reciveClientFrameNumber + 10) {
/**
* 1秒误差可以进行预测
*/
/**
* 完成预测的同时要向远端发送输入
*/
let params = MapEx.get(this._inputParams, this._clientFrameNumber);
/**
* 预测逻辑
*/
this.onTick(this._clientFrameNumber, params, true);
/**
* 帧号增加
*/
this._clientFrameNumber++;
} else {
/**
* 网络不稳定弹窗,正常发帧包,但是不预测输入
*/
//this.onTick(this._clientFrameNumber, null, false);
}
}
/**
* 更新游戏渲染tick
*/
this.gameLogic.onViewTick(dt);
}
/**
* 服务端Tick
* @param dt
*/
protected onUpdate(dt: number): void {
if (this.init == false) return;
// npc
NpcHelper.npcMap.forEach(npc => {
npc.onTick(dt);
})
/**
* 以服务端的帧率进行预测,如果帧率低的情况下 也会追帧。
*/
this.curFrameDt += dt;
while (this.curFrameDt >= IONetDef.FrameTickNumber) {
this.curFrameDt -= IONetDef.FrameTickNumber;
/**
* 解析指令
*/
let handleCommand: InputParamsObj<InputCommanderBase> = new InputParamsObj<InputCommanderBase>();
for (let key in this.frameInputParams) {
let cmdList = this.frameInputParams[key];
let cmd = cmdList.shift();
if (cmd != null) {
MapEx.set(handleCommand.pCmds, key, cmd)
}
MapEx.del(this.playerFrameIsInputed[key], this._frameNum.toString());
}
const seed = IOMath._seed;
/**
* 预测逻辑
*/
this.onTick(this._frameNum, handleCommand);
const curFrameNum = this._frameNum;
/**
* 广播当前状态和本次输入。
*/
this.allPlayers.forEach(e => {
this.addWaitFrameContent(e.playerId, curFrameNum, this.gameLogic.getState(e.playerId), handleCommand, seed);
//this.getClient(e.playerId).net_OnServerTick(curFrameNum, this.gameLogic.getState(e.playerId), handleCommand, seed);
});
this.allPlayers.forEach(e => {
this.isFirstSync.set(e.playerId, true);
});
this.gameLogic.onFrameEnd()
/**
* cache 历史帧,后面想了下 实际不用记录了,因为有完整的状态回滚
*/
//MapEx.set(this.historyFrameInputCache, this.frameNum, handleCommand);
//MapEx.set(this.historyFrameStateCache, this.frameNum, state);
/**
* 服务器帧号递增
*/
this._frameNum++;
}
this.handleWaitFrameContent();
this.gameLogic.onViewTick(dt);
}