用 Unity 做个游戏(八) - 客户端逻辑结构和网络同步机制

1,857 阅读7分钟

本文首发自inspoy的杂七杂八 | 菜鸡inspoy的学习记录

前言

距离上一篇又差不多一周多了,果然写代码要比写博客轻松多了orz
经过了漫长的无聊的准备,这次终于开始正式写游戏逻辑相关的内容了,当然,到目前为止的代码可以直接拿来做任意一个游戏,这也算是个好处吧233
断断续续写了一周的代码,到目前为止已经基本实现了:登陆,加入战斗,同步移动。其中同步移动是重点,之前的坑里就是因为这一点导致爆炸,做不下去了233

客户端结构

这次先说客户端,服务端下一篇再继续

场景划分

工程项目里一共有3个场景

  1. SceneTitle
  2. SceneGame
  3. SceneTest
    其中SceneTest不会打包进游戏,只是为了测试某些游戏效果,比如测试特效,编辑UI等
    SceneTitle包含所有正式战斗以外的所有外围系统,现在只做了登陆,之后还会有房间匹配,技能配置,角色成长等等,场景主要内容为各种各样的UI,其他3D GameObject比较少
    SceneGame就是游戏战斗的场景了,主体是3D场景,角色,特效,UI加以辅助

    用户登陆

    先做了个最简单的登陆界面:
    0801

    暂时还没有做登陆验证,现在只要输入一个字符串,这个字符串就作为你的用户ID来用了,而且现在也没有做数据持久化,服务器重启后所有数据就清空了。
    点击登陆按钮,尝试连接服务器,成功后将会自动切换场景到SceneGame
    连接服务器:
    void onLogin(SFEvent e)
    {
     string username = m_view.txtUsername.text;
     SFUserData.instance.uid = username;
     m_infoMsg = "正在连接服务器...";
     SFNetworkManager.instance.init();
     SFNetworkManager.instance.dispatcher.addEventListener(this, SFEvent.EVENT_NETWORK_READY, onConnectResult);
     SFNetworkManager.instance.dispatcher.addEventListener(this, SFEvent.EVENT_NETWORK_INTERRUPTED, result =>
         {
             m_infoMsg = "网络连接中断";
             m_willReset = true;
         });
     SFNetworkManager.instance.dispatcher.addEventListener(this, SFResponseMsgUnitLogin.pName, onLoginResult);
     SFNetworkManager.instance.dispatcher.addEventListener(this, SFResponseMsgNotifyRemoteUsers.pName, onRemoteUsers);
    }
    连接服务器的操作是异步的,当有结果时回调函数void onConnectResult(SFEvent)将会被调用:
    void onConnectResult(SFEvent e)
    {
     var retCode = e.data as SFSimpleEventData;
     if (retCode.intVal == 0)
     {
         m_infoMsg = "连接成功,正在登陆...";
         doLogin();
     }
     else
     {
         m_infoMsg = "无法连接到服务器";
         m_willReset = true;
     }
    }
    如果成功连接到服务器,那么就执行登陆操作
    SFRequestMsgUnitLogin req = new SFRequestMsgUnitLogin();
    req.loginOrOut = 1;
    SFNetworkManager.instance.sendMessage(req);
    登陆结果的回调函数为void onLoginResult(SFEvent),不过在这之前,服务器将会推送给客户端游戏初始化所必要的数据,这些数据由协议SFResponseMsgNotifyRemoteUsers推送
    登陆成功后,切换场景:
    m_view.StartCoroutine(loadSceneGame());
    // ...
    IEnumerator loadSceneGame()
    {
     var op = SceneManager.LoadSceneAsync("SceneGame");
     yield return op;
    }
    切换场景的操作也是异步加载的,如果场景比较大,还可以在update里每帧检查加载进度,做一个loading进度条
    然后场景就切换到了SceneGame

    战斗场景

    战斗场景是有个HUD的,不过涉及一些Unity默认UGUI不支持的控件,这些控件我还没写完,就先没弄UI了。
    场景的层次结构如图:
    0802

    几个主要的GameObjet:
    |名称|说明|
    |--|--|
    |SceneMgr|挂载了两个脚本:SceneManager和BattleController|
    |BattleField|空GO,只是为了统一管理所有游戏对象|
    |Units|所有角色的容器|
    |Balls|所有飞行火球的容器|

此时场景中什么都没有,甚至连灯光都没有,因为所有的物体都是动态加载的
切换场景后,BattleController.Start()方法被调用,在这里会加载场景,角色和UI

void Start()
{
    // 加载场景
    SFUtils.log("mapId:{0}", SFBattleData.instance.enterBattle_mapId);
    string mapName = string.Format("map_{0}", SFBattleData.instance.enterBattle_mapId);
    var mapPrefab = Resources.Load("Prefabs/Maps/" + mapName) as GameObject;
    GameObject.Instantiate(mapPrefab, unitContainer.transform.parent);

    // 加载HUD
    SFSceneManager.addView("vwHUD");

    // 加载角色
    m_unitMgr.initUnits();
}

其中,初始化加载角色由挂载在Units上的脚本SFUnitManager负责

public void initUnits()
{
    SFUtils.log("初始化角色...");
    // 自己
    SFUnitConf heroConf = new SFUnitConf();
    heroConf.uid = SFUserData.instance.uid;
    // ...
    m_heroController.setHero(addUnit(heroConf));

    // 其他角色
    var users = SFBattleData.instance.enterBattle_remoteUsers;
    foreach (var item in users)
    {
        SFUnitConf conf = new SFUnitConf();
        conf.uid = item.uid;
        // ...
        addUnit(conf);
    }
    SFUtils.log("初始化角色完成");
}

当然,addUnit(SFUnitConf)方法里就是根据配置信息执行实例化操作

public SFUnitController addUnit(SFUnitConf conf)
{
    if (unitPrefab != null)
    {
        var controllerGO = GameObject.Instantiate(unitPrefab, gameObject.transform.parent);
        var controller = controllerGO.GetComponent<SFUnitController>();
        controller.init(conf);
        m_controllers.Add(conf.uid, controller);
        return controller;
    }
    return null;
}

事实上,这种写法还有很大的优化空间,包括后面添加火球到场景的操作也是,之后考虑换成对象池的方式来缓存不需要的游戏对象而不是直接用GameObject.Destroy()销毁,这样一来的话下次使用的时候就可以直接从缓冲池中取出,省去了实例化操作所需要消耗的时间

游戏对象Units上海挂载了另外一个脚本SFHeroController,这个脚本监听用户的输入,把操作信息上报给服务器

void Update()
{
    float curX = Input.GetAxis("Horizontal");
    float curY = Input.GetAxis("Vertical");
    float curRot = getCurRotation();
    if (curX != m_lastMoveX || curY != m_lastMoveY || curRot != m_lastRotation)
    {
        m_lastMoveX = curX;
        m_lastMoveY = curY;
        m_lastRotation = curRot;
        syncData();
    }
}

// 计算当前鼠标位置所对应的旋转角度
float getCurRotation()
{
    float posX = Input.mousePosition.x;
    float posY = Input.mousePosition.y;
    posX = Mathf.Clamp(posX, 0, m_screenWidth);
    posY = Mathf.Clamp(posY, 0, m_screenHeight);
    posX -= m_screenWidth / 2;
    posY -= m_screenHeight / 2;
    if (Mathf.Abs(posX) < m_screenWidth / 10 &&
        Mathf.Abs(posY) < m_screenHeight / 10)
    {
        return m_lastRotation;
    }
    return Mathf.Atan2(posX, posY) * Mathf.Rad2Deg;
}

// 上报服务器
void syncData()
{
    SFRequestMsgUnitSync req = new SFRequestMsgUnitSync();
    req.moveX = m_lastMoveX;
    req.moveY = m_lastMoveY;
    req.rotation = m_lastRotation;
    SFNetworkManager.instance.sendMessage(req);
}

SFUnitManager监听着协议SFResponseMsgNotifyUnitStatus,这个协议包含了当前场上所有角色的位置以及状态信息。收到这个协议后,onNotifyUnitStatus方法根据里面的信息同步状态信息

void onNotifyUnitStatus(SFEvent e)
{
    var data = e.data as SFResponseMsgNotifyUnitStatus;
    var infos = data.infos;
    foreach (var item in infos)
    {
        foreach (var controller in m_controllers)
        {
            if (controller.Key == item.uid)
            {
                controller.Value.updateStatus(item);
                break;
            }
        }
    }
}

场景中的角色GameObject都挂载了一个SFUnitController脚本,这个脚本来控制各自角色,互不干扰

网络同步机制

之前就是因为网络同步的机制不够合理导致完全改不动了orz
这次一开始就得考虑好了
首先,客户端只负责显示服务端传回的结果,客户端玩家做出的操作也不会影响到场景中角色的状态,而是把操作上传至服务端,服务端根据各个客户端上传的操作信息对整个战场进行模拟,然后定期向所有客户端同步大家的位置以及状态信息。
这样做的好处就是,避免客户端各自模拟导致有可能出现由网络延迟导致的不同步,比如客户端A模拟的结果显示甲打中了乙,而客户端B模拟的结果可能完全相反,这样一来攻击判定以谁的为准就不好说了,所以我要把所有的判定全部交给服务端来处理,这样就保证了结果唯一,所有的客户端看到的结果都一定是相同的。

0803

其实这样做还是存在一定的缺陷,最主要的就是操作延迟,因为客户端按下了按钮,消息传给服务端,服务端计算,推送位置变化,角色移动。所以玩家看到角色开始移动一定会慢半拍,有个解决方案:客户端先根据操作预测接下来的运动,这样就消除了操作延迟,然后下次服务端推送状态的时候,如果服务端推送的实际位置和客户端自己预测的位置相符或者差距很小,那就不管,如果差距过大则再进行纠错,强制把角色移动到实际的位置。

我的做法是:
因为这个游戏移动不是马上就达到最大速度的,一定会有一个加速过程,所以客户端按下按钮后到实际动起来的操作延迟和原本规则上的操作延迟的感觉是差不多的,所以客户端就算不先预测运动,也不会对体验有什么不好的影响。
其次,因为服务端推送的周期(暂定50ms)一定和游戏渲染的帧率(60fps)不一致,再加上网络传输,客户端收到的同步消息一定是不均匀的,如果每次收到消息就直接同步的话会显得角色的运动特别突兀,所以必须对其进行差值,同时客户端也自己根据上一次服务端传回的速度信息进行运动模拟,如果发现误差过大则快速移动到应该在的位置(也通过差值让其平滑移动)。
详见代码

// 服务端同步位置和状态信息
public void updateStatus(SFMsgDataUserSyncInfo info)
{
    m_curPosX = info.posX;
    m_curPosY = info.posY;
    m_curRotation = info.rotation;
    m_curSpeedX = info.speedX;
    m_curSpeedY = info.speedY;
}

// 每帧更新
void Update()
{
    // 本地模拟的运动
    m_curPosX += m_curSpeedX * Time.deltaTime;
    m_curPosY += m_curSpeedY * Time.deltaTime;

    // 位置如果差距不大则不改变,较大差距快速缓动
    float distance = Vector3.Distance(gameObject.transform.position, new Vector3(m_curPosX, 0, m_curPosY));
    if (distance > SFCommonConf.instance.syncPosThrehold)
    {
        Vector3 realPos = gameObject.transform.position;
        Vector3 posDiff = new Vector3(m_curPosX - realPos.x, 0, m_curPosY - realPos.z);
        realPos += posDiff * Time.deltaTime * MOVE_ACC;
        transform.position = realPos;
    }
}

// 相关参数
// 位置修正加速度
const int MOVE_ACC = 20;
// 实际值与参考值的差小于这个阈值就不做位置修正了
float SFCommonConf.instance.syncPosThrehold = 0.1f;

关于服务端,因为服务端几乎承担了所有游戏逻辑的计算,也挺复杂的。。下一篇文章再详细介绍

完整代码

上面贴出的代码片段由于篇幅限制只保留了关键部分,完整的代码可在我的github上找到