公司的游戏准备上线了,我呢也在准备新项目,这几天看了一下策划文档,写了整体流程和需求接口的代码,终于有一点点时间来写自己的博客了。今天我们就来讲讲帧同步吧。
百度了一下帧同步,百度百科上对帧同步的解释是:在数字时分多路通信系统中,为了能正确分离各路时隙信号,在发送端必须提供每帧的起始标记,在接收端检测并获取这一标志的过程称为帧同步。哈哈哈,一脸懵逼吧,简而言之,就是在游戏中同步的是玩家的操作指令,操作指令包含当前的帧索引。一般的流程是客户端上传操作到服务器, 服务器收到后并不计算游戏行为, 而是转发到所有客户端。这里最重要的概念就是,相同的输入 + 相同的时机 = 相同的输出。
由于每台设备的环境都不一样,为了保证我们的操作和设备环境无关,我们为每个逻辑帧设置为固定时长,那么我们在游戏中的移动,碰撞等算出来的结果,算出来的就都是相同的结果(不要使用Unity的物理引擎和浮点型)。众所周知,由于每台设备的环境都不一样,其渲染帧一般为30帧到60帧不等,而我们的逻辑帧一般设置为10帧到20帧,在考虑到性能和平滑的问题做一个取舍的展示。逻辑帧实际上是有一个个定时下发的网络帧来驱动,而渲染帧则由设备的CPU的Update来驱动。如果逻辑帧突然中断,游戏就会卡在那一帧的状态。在理想的状态下,每一个网络帧都会被及时的接收,而客户端的渲染帧就跟播放电影一样。我们可以想象一下帧动画的播放,如果我们设置每秒10帧,也就是0.1秒播放一张帧动画,那帧同步差不多就是这种效果。但是在网络游戏中,每个客户端的硬件和网络环境不尽相同,这就可能导致客户端收到过去时间里的一堆网络帧,比如我们在玩某些游戏的时候,突然卡了一下,然后角色又快速的跑到别的地方去了,就跟电影的快进一样,还有一种情况就是,角色直接跳到最新的位置,就跟闪现一下,这种情况是经过处理的,直接抛弃了中间的网络帧。为了能让玩家在游戏里面顺畅的玩游戏而不感觉到很生硬,我还是建议采用快进的方式,其实我们不用做任何处理。
看到这里,可能你们会有一个疑问,为了保证一致性我们用逻辑帧来处理数据,那我们游戏的画面不就一卡一卡的吗?
这里就涉及到另一个重要的问题了——数据层和表现层的分离。
逻辑帧处理数据层,渲染帧处理表现层。当我们用逻辑帧来跑游戏的时候,我们会发现游戏一卡一卡的,是因为我们逻辑帧的帧数少,跟不上渲染帧的速度,那为什么我们不把逻辑帧的帧数提上去呢?前面已经讲了,是考虑到性能和平滑展示。如果我们设置的帧数过高,就会影响到游戏性能,反之就会造成数据处理不及时,影响到数据的有效性。为了解决这个一卡一卡的问题,我们可以在渲染层做平滑处理,其实我们还是跑的数据层,只不过你们看到的都是经过处理的表现而已。
叽叽歪歪了一大堆,那怎么实现呢?
1.模拟服务端的网络帧。
一个好的客户端,不能受制于服务端的蹂躏,我们自己在本地模拟网络帧。
// **********************************************************************
// Copyright (C) XM
// Author: 吴肖牧
// Date: 2018-05-25
// Desc: 战斗模拟
// **********************************************************************
using UnityEngine;
using System.Collections;
public class BattleMock : Singleton
{
float FrameRate = 0.1f; //帧速率,0.1秒1帧,即1秒10帧
int FrameCount = 0;//帧数
float NextRunTime = 0;
bool isRunning = false;
///
/// 消息数据
///
UpdateMsg updateMsg;
// Use this for initialization
void Start()
{
NextRunTime = Time.time + FrameRate;
}
// Update is called once per frame
void Update()
{
if (isRunning && BattleManager.Instance)
{
while (Time.time >= NextRunTime)
{
UpdateFrame();
NextRunTime += FrameRate;
}
}
}
///
/// 更新帧数据
///
void UpdateFrame()
{
updateMsg.frameCount += 1;
BattleManager.Instance.UpdateBattle(updateMsg);
int fc = updateMsg.frameCount;
updateMsg = new UpdateMsg();
updateMsg.frameCount = fc;
}
///
/// 开始战斗
///
public void StartBattle()
{
updateMsg = new UpdateMsg();
FrameCount = 0;
NextRunTime = 0;
isRunning = true;
}
///
/// 玩家输入
///
///
public void UserInput(Record record)
{
updateMsg.records.Add(record);
}
}
随便写个数据结构,用于模拟协议内容。
public class UpdateMsg
{
public int frameCount = 0;
public List records;
}
public class Record
{
public int uid;
public float posX;
public float posY;
public RecordType recordsType;
}
public enum RecordType
{
NONE,
MOVE,
}
2.创建一个战斗的管理器。
战斗管理器主要的功能是把消息内容分发给游戏所有的角色,物品,触发器等,然后更新数据。
// **********************************************************************
// Copyright (C) XM
// Author: 吴肖牧
// Date: 2018-05-25
// Desc: 战斗管理
// **********************************************************************
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
public class BattleManager : Singleton
{
int frameCount = 0;
bool isStart = false;
// Use this for initialization
void Start()
{
//创建一个uid是10000的角色
ActorManager.Instance.CreateActor(10000);
}
public void UpdateBattle(UpdateMsg update_msg)
{
if (update_msg.frameCount != (frameCount + 1))
{
//丢帧
Debug.Log("lost frame :" + "server:" + update_msg.frameCount + " client:" + frameCount);
}
else
{
//把服务器发来的帧数赋值给本地的帧数
frameCount = update_msg.frameCount;
if (isStart)
{
foreach (var record in update_msg.records)
{
if (record.recordsType == RecordType.MOVE)
{
ActorManager.Instance._actorDic[record.uid].TransState(ActorStateType.Move);
}
else
{
//TODO
}
}
}
Dispatcher.Instance.SendMessage(BattleMsg.BattleUpdateMsg);
}
}
}
3.角色发送数据。
我们在之前的文章《Unity3D用状态机制作角色控制系统》修改PlayerActor移动方法MoveCallBack,把直接修改角色坐标的方式改为发送消息,然后通过战斗管理器去修改角色的坐标。
// **********************************************************************
// Copyright (C) XM
// Author: 吴肖牧
// Date: 2018-04-13
// Desc:
// **********************************************************************
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerActor: Actor {
///
/// 摇杆
///
private ETCJoystick _joystick;
///
/// 初始化状态机
///
protected override void InitState()
{
_actorStateDic[ActorStateType.Idle] = new IdleState();
_actorStateDic[ActorStateType.Move] = new MoveState();
}
///
/// 初始化当前状态
///
protected override void InitCurState()
{
_curState = _actorStateDic[ActorStateType.Idle];
_curState.Enter(this);
}
void Start()
{
_joystick = GameObject.FindObjectOfType();
if (_joystick != null)
{
_joystick.onMoveStart.AddListener(StartMoveCallBack);
_joystick.onMove.AddListener(MoveCallBack);
_joystick.onMoveEnd.AddListener(EndMoveCallBack);
}
Dispatcher.Instance.AddListener(BattleMsg.BattleUpdateMsg, BattleUpdate);
}
///
/// 战斗更新
///
///
private void BattleUpdate(Message evt)
{
//TODO
}
///
/// 开始移动
///
private void StartMoveCallBack()
{
TransState(ActorStateType.Move);
}
///
/// 正在移动
///
///
private void MoveCallBack(Vector2 vec2)
{
float value = 0.02f * _moveSpeed / Mathf.Sqrt(vec2.normalized.x * vec2.normalized.x + vec2.normalized.y * vec2.normalized.y);//勾股定理得出比例,第一个值是摇杆的比例
//_pos = new Vector3(_pos.x + vec2.x * value, _pos.y + vec2.y * value, 0);
Vector3 pos = new Vector3(_pos.x + vec2.x * value, _pos.y + vec2.y * value, 0);
Record record = new Record();
record.uid = _uid;
record.recordsType = RecordType.MOVE;
record.posX = pos.x;
record.posY = pos.y;
BattleMock.Instance.UserInput(record);
//int angle = (int)(Mathf.Atan2(vec2.normalized.y, vec2.normalized.x) * 180 / 3.14f);
//Debug.Log(angle);
//if (angle > 45 && angle < 135)
//{
// ChangeDir(Direction.Back);
// //Debug.Log("上");
//}
//else if (angle <= 45 && angle >= -45)
//{
// ChangeDir(Direction.Right);
// //Debug.Log("右");
//}
//else if (Mathf.Abs(angle) >= 135)
//{
// ChangeDir(Direction.Left);
// //Debug.Log("左");
//}
//else
//{
// ChangeDir(Direction.Front);
// //Debug.Log("下");
//}
}
///
/// 移动结束
///
private void EndMoveCallBack()
{
TransState(ActorStateType.Idle);
}
void OnDestroy()
{
if (_joystick != null)
{
_joystick.onMoveStart.RemoveListener(StartMoveCallBack);
_joystick.onMove.RemoveListener(MoveCallBack);
_joystick.onMoveEnd.RemoveListener(EndMoveCallBack);
}
Dispatcher.Instance.RemoveListener(BattleMsg.BattleUpdateMsg, BattleUpdate);
}
}
这是一个大致的思路,不是一个完整的帧同步框架,请不要直接用于项目。