TinyReplaySystem回放系统设计和开发

TinyReplaySystem回放系统设计和开发

简单探讨和分析下游戏回放系统的设计和针对特定需求回放功能的TinyReplaySystem设计和具体实现

需求分析

在屏幕舞台中,玩家操控动画角色通过手势缩放,移动,修改角色颜色等属性,用户操控所需要的角色进行PlayAnimation,角色扮演。扮演结束,保存到本地,可以回放用户所扮演的动画。相当于录制屏幕指定区域,存储成视频,加载回放。

  1. 记录用户通过手势控制角色,玩动画的整个过程
  2. 回放用户已经"录制"的动画内容
  3. 保证整个动画过程不会因为"实时录制",加载和写入文件造成卡顿,保证用户玩动画过程的流畅性
  4. 多平台支持(IOS,Android和PC)

开发设计思路分析

通过找资料获得一些实现的具体思路,简单分析下各自的问题和方便性,其实总的思路就是"记录"+"解析",只是不同的方案在不同点记录不同的内容,记录不同的内容,导致回放处理逻辑不同而已(恩,这句算是废话...)

  1. 直接录屏将整个动画保存出mp4格式进行压缩 
    直接使用视频的优点很明显高保真,搜寻了一些使用录制屏幕插件,他们只支持PC端,移动端不支持,移动端如果想要录制屏幕,目前只能是每一帧进行截图保存到本地,录制结束,将这些图片拼接保存成mp4格式,针对不同的平台可能需要编写不同的图片合成方案。也尝试了Unity官方提供的EveryPlay插件,用于快速分享游戏内容到网站,这个插件使用简单,能够实现录制屏幕的功能,但是视频保存不到本地,通过视频方式保存虽然能够高保真,但是文件量和针对不同平台是两个比较大和需要考虑的问题
  2. 状态记录同步 
    记录每一帧所有角色状态,包括位置,旋转等,这种方式代码比较容易实现,文件量会比较大,3分钟视频,一秒钟30帧,需要36030 = 5400帧如果每个角色每一帧都进行记录至少要3M以上,如果角色少做一些简单的优化比如,只有发生修改的时候才记录会明显的降低文件量,但是对于移动端流量还是很有压力。
  3. 命令/状态驱动同步 
    记录初始状态和命令集合,命令集合包括了玩家全部的操作(攻击,逃跑等等),明显的优点就是数据量小,因为只记录了断断续续的玩家操作,针对编码实现起来会比较难,只能顺序播放,为了保证回放正确必须按照命令顺序严格执行,一般针对战斗规则一定的卡牌这种回放方式是最好的选择,一个输入,得到的结果是一定的。如果结果不一致,那么一定是战斗异常。针对这种设计方案再多思考一下

一种战斗回放简单的流程如下:

  • 角色初始化数据+该战斗使用的randomSeed(保证输入和随机数一致)
  • 交给战斗逻辑处理,每一个回合计算出战斗表现,交给表现层进行播放处理
  • 战斗表现的过程就是回放的过程

将战斗逻辑和战斗表现进行拆分,能够方便进行战斗验证和回放,可以将战斗逻辑封装成dll提供给服务器和客户端共同使用,服务器验证方便,指定这场战斗的输入(角色初始状态+当前用户输入比如在第xx回合玩家使用了xx大招,这个算是一个大招序列+战斗随机种子),服务器通过战斗dll逻辑计算和客户端(战斗逻辑dll+战斗)计算的结果进行比对即可判断出当前战斗是否异常

注意点: 这种方式能够让客户端和服务器复用战斗逻辑,能够"确保,输入一致,输出结果必须一致,否则则是战斗异常",因为算法一致,输出一致,那么输出一定一致.

在编写战斗逻辑的时候要做好异常处理,不然战斗服务器很容易异常导致战斗服务器需要重启,因为客户端出现异常影响的是一个玩家,如果服务器异常则影响的是整个网络的玩家,所以编写dll或者公用逻辑的时候一定要做好异常处理

另外一种完全命令驱动的流程如下: 每一个回合记录下当前战斗发生的情况(比如角色A释放了技能S,击中角色B,角色B掉血100),每一个回合都通过"命令阐述"的形式记录下来,客户端有一个命令解析器(战斗表现)通过解析命令移动角色,释放技能造成伤害等等操作

这种方式要比上一种"一个输入一个输出"要占用较多的数据量,这种可以"编辑战斗",可以通过配置,配置出一场战斗,因为,客户端就是命令解析+具体表现

TinyReplaySystem设计

需求简单化:通俗的说就是玩家"通过手势玩出来动画",这里面没有复杂的技能表现和酷炫的战斗特效表现,这里就是"纯粹",纯粹的动画,拿着木偶在舞台上表演,抽象出来也不例外,命令+当前角色状态+时间点 TimePos+EntityState

采用的开发方案

状态/命令存储模式

  1. 每一帧记录所有需要记录角色包括部件的位置,旋转等属性,只有当属性发生变化的时候才进行记录否则不记录,保证文件量最小化,解析命令,TinyReplaySystem直接使用的字符分割解析方法
  2. 引入命令,比如一个角色动画可能特别的复杂各种骨骼动画,这个时候直接记录该角色的"命令",在xxTimePos处于"奔跑"状态,在xxTimePos处于待机状态,直接的记录当前的状态不用记录该角色每个节点相应的旋转和位移,直接的将"大动画,大幅度,大数据量"的内容交给逻辑本身处理

录制和文件写入

最初的存储方案如下:

每个角色,有一个状态list,每一帧,new出来一个新的state添加到状态list中,当录制结束直接的将整个角色list序列化到json文件中

问题

如果保存每个角色完整的状态list,意味着内存使用的内存会一直增加,在录制结束之前必须保存整个录制数据文件(所有角色,所有需要记录的部位,所有部位或者角色的状态list,这些状态可能包括了基本的位置,旋转缩放和需要记录颜色状态对象的颜色值rgba),当录制结束,写入文件也是一个很费时间的过程(json文件,3-4M电脑上差不多2s,真机测试耗费了20s以上IOS也是25s以上......,这么久的写入和加载过程还玩个)

开始优化
  1. 最简单的优化操作,将这个分开处理,也就是一个3M的动画分成3个问价存储就OK,是的,可以优化到5-6s每个文件,是的,还不行,再分,难度和复杂度也就上来了,维护也就难起来了
  2. Json字符串是否可以做到分段写入和分段读取,这样,我可以规定,100个状态过后就需要写入文件,同样读取的时候,100个为一段,这样可以保证录制过程内存不是一直增加同时保证写入和加载的过程不是那么耗费时间,TinyReplaySystem使用的是Json.Net作为插件,经过测试,的确可以完成分段读取,但是分段写入可能就需要自己处理下了 
    在实验过程中找的一些有关大数据json文件处理的方案:http://stackoverflow.com/questions/26601594/what-is-the-correct-way-to-use-json-net-to-parse-stream-of-json-objects 
    http://www.drdobbs.com/windows/parsing-big-records-with-jsonnet/240165316?pgno=2

经过自己的摸索,感觉方向上出了问题,数据量大,应该想办法降低下来,为了错误的方案去找解决方案,这个不应该,的确花了很多的时间去研究Json如何分段读取,方向错了......Json本身数据量就比较大,因为TinyReplaySystem使用了多态,Json.Net为了支持多态,会在序列化的字符串中添加该对象类型所在assembly和Type写入到文件中,又增加了Json文件的量

最终选择方案

使用Json结构记录动画的基本信息,将动画角色和部件属性定义成用特殊分隔符分开的字符串EntityIndex|TimePos|x,y,z;x,y,z;r,g,b|这种格式,不需要的属性直接空就行|x,y,z;;|一行一行写入到文件中,每发生一次属性更改,直接写入到对应的属性文件中,这样能保证不一次性大数据的写入,也能保证不卡顿,同时也不用保存一个状态对象list在内存中,因为每次有新的状态发生,直接的转化成字符串到文件中,省去new对象这个过程

Recording过程

void Update()  
{
    if (!this.mIsRecording)
        return;
    if (Time.realtimeSinceStartup - this.mStartRecordTime >= this.mPerRecordInterval)
    {
        this.mCurTimePos++;
        // Debug.Log(" begin next record." + this.mCurTimePos);
        this.mProgressController.InsertEntityNewState(this.mCurTimePos);
        this.mStartRecordTime = Time.realtimeSinceStartup;
        this.mProgressController.mMaxTimePosition = this.mCurTimePos;

        if (this.mCurTimePos >= mMaxTimePosition)
            this.StopRecording();

        TinyReplayManager.GetInstance.RefreshTimePosition(this.mCurTimePos);
    }
}

更新状态写入文件

protected virtual void SaveReplayEntityState(int timePos)  
{
    this.mCacheState.ResetState();
    int saveType = this.GetChangePropertiesType();
    string saveStateData = TinyReplayObjectState.SaveCurStateProperties(this.entityIndex,
        timePos,
        mTrs,
        null,
        saveType);

    if (saveType != 0)
        TinyReplayRecordController.instance.SaveDataToLocalFile(saveStateData);
}

使用Json结构存储动画基本信息

Entity具体信息

使用一行String记录对应Entity的状态具体的结构截图如下:

加载和读取

初始化阶段,读取动画头文件(包括了该动画使用的播放版本,所有的角色对象资源路径,该动画用到的背景音乐,作者名字,创建日期等)在初始化阶段会根据每个角色记录的资源信息,从Resources文件夹中加载模板(有关资源加载部分请往下看),同时禁用相应的控制脚本,因为我们在录制过程中可能需要一些控制文件来处理旋转,移动或者缩放,但是回放的时候,我们不需要这些控制脚本,直接用我们记录的属性文件同步旋转和缩放即可,所以禁用或者删除某些控制脚本(当然选择性禁用,因为有些动画控制脚本,需要根据我们在相应的TimePos记录的命令进行解析来同步当前角色的动画) 代码如下:

private void LoadEntityDataBeforeReplay()  
{
    Debug.Log("@animation entity count is " + this.mProgressController.allEntity.Count);
    List allEntity = this.mProgressController.allEntity;
    StringBuilder sb = null;

    GameObject templateObject = null;
    ReplayParentObject parentScript = null;

    for (int i = 0; i < allEntity.Count; i++)
    {
        TinyReplayEntity replayEntity = allEntity[i];
        sb = new StringBuilder();
        sb.AppendFormat("@entity information: prefab name is {0} entityIndex is {1}.", allEntity[i].mTemplateName, allEntity[i].entityIndex);
        Debug.Log(sb.ToString());

        // add TinyReplayEntity to progressController.
        this.mProgressController.allEntityDic[replayEntity.entityIndex] = replayEntity;

        if (replayEntity.entityIndex % 10000 == 0)
        {
            GameObject loadedPrefab = Resources.Load("AnimationPrefab/" + replayEntity.mTemplateName);

            // load the prefab and disable some script. like MovePosition, ChangeRotation etc.
            // we just set the target state Properties use saved file.
            // and you can disable/enable or just destory these unused control scripts.

            // 删除对象的控制文件,以为在播放阶段,直接通过保存的对象信息更新对象位置缩放等属性信息
            // 如果在文件中存储了一些控制命令,比如播放xx动画,可以解析该命令直接通过脚本控制对象执行动画操作
            // 保证对象控制逻辑和同步逻辑不冲突.
            // 有些情况下比如角色的动作过于复杂,也可以保留动作控制脚本,在Record的时候记录当前动画状态,到达TimePos时间点过后,直接按照当前的
            // 动画状态播放角色动画
            // 必须保证同步文件和控制脚本控制的对象不发生冲突.

            if (loadedPrefab != null)
            {
                templateObject = GameObject.Instantiate(loadedPrefab) as GameObject;

                // Delete some control script attached to the prefab template, such as UIDragObject etc.
                UIDragObject[] disableScript = templateObject.GetComponentsInChildren();
                for (int disIndex = 0; disIndex < disableScript.Length; disIndex++)
                {
                    // or just destory the script you want to disable.
                    // we use UIDragObject script to drag object in recording, when replaying the animation we don't want
                    // the charater can be draged , so we destory or disable the UIDragObject script when replaying.
                    // that's why we add some control logic in these function.
                    // 在录制动画过程中用到了Drag脚本控制,但是在播放的过程不希望当前的角色能够Drag所以直接的删除或者disable相应的控制脚本
                    disableScript[disIndex].enabled = false;
                }

                parentScript = templateObject.GetComponent();
                templateObject.SetActive(true);
                if (parentScript != null)
                    parentScript.InitReplayParentObject(replayEntity.entityIndex);

                // add to animation stage.
                templateObject.transform.parent = AnimationStage.StageRoot.transform;
                templateObject.transform.localScale = Vector3.one;
                templateObject.transform.localPosition = Vector3.zero;
                templateObject.transform.rotation = Quaternion.identity;
                // synchronize first time postiton state.
                replayEntity.PrepareForReplay(templateObject);
            }
            else
                Debug.LogError("load prefab is null.");
        }
        else
        {
            // for the child.
            if (parentScript == null)
                Debug.LogError("@parent script is null.");
            ReplayObject replayObject = parentScript.GetReplayObject(replayEntity.entityIndex);
            if (replayObject == null)
                Debug.LogError("replay object null:" + replayEntity.entityIndex);
            Debug.Log("@ replay object name is " + replayObject.name);
            replayEntity.PrepareForReplay(replayObject.gameObject);
        }
    }
}

播放阶段这个地方应该算是核心部分,TinyReplaySystem采用的方式是,预先读取一定的State字符串解析成具体的State到内存中,使用协同Coroutine加载剩余的状态,这样保证边播放边读取,边解析,边同步

void Update()  
{
    if (!this.mIsReplaying || !this.mIsStartReplay)
        return;

    // Debug information.    
    TinyReplayManager.GetInstance.RefreshTimePosition(this.mCurTimePos);

    if (this.mCurTimePos >= this.mProgressController.mMaxTimePosition)
    {
        this.OnReplayOver();
        return;
    }
    if (Time.realtimeSinceStartup - this.mStartPlayTime >= this.mPerReplayInterval)
    {
        this.mCurTimePos++;
        this.mStartPlayTime = Time.realtimeSinceStartup;
        int entityCount = this.mProgressController.allEntity.Count;
        for (int i = 0; i < entityCount; i++)
            this.mProgressController.allEntity[i].SynchronizeEntity(this.mCurTimePos);
    }
}
播放优化

这个算是基本的优化,用了一个对象deque和activeList用来循环利用new出来的state,每次序列化新的state的时候,先从activeList查看有没有闲着的state对象,如果有直接用,否则再去new新的state.

Record对象如何加载?如何找到对应的GameObject?

上面我们说到了加载阶段,先读取动画Json文件,先初始化该Animation,比如加载动画主角,动画背景,动画背景音乐等,主要说下如何TinyReplaySystem如何去加载角色

  1. 录制和回放角色"模板"对应问题 
    录制过程需要明确RecordTarget是否是一个父节点(完整的prefab),或者是一个prefab子节点,比如我们的RecordTarget是一个带着帽子的小孩子,帽子也是我们的RecordTarget,小孩子也是一个RecordTarget,只不过小孩子需要记录prefabName,帽子记录一个entityIndex,prefabName在回放初始化的时候加载该prefab,帽子通过entityIndex找到对应的GameObject用来回放过程位置,缩放等的同步工作. 需要录制的对象都会添加一个标示(ReplayObject或者ReplayParentObject)ReplayParentObject是需要设定prefabName的父节点,设置tag为:ReplayParentObject,方便录制前找到所有的Parent对象进行初始化
  2. 模板的概念 
    如果是单机游戏,我们所有的prefab我们都可以放在Resources目录下,但是有些情况下,比如我们需要从服务器下载角色图片,这个时候我们可以直接用一个prefab模板,这个prefab规定了角色的的身体,帽子,鞋子的节点位置,从服务器下载完资源直接替换即可,当回放的时候,加载相应的prefab模板名称,从Resource中Load,替换相应的资源即可,理想状态下我们可以将RecordTarget,创造新的prefab到Resource文件夹中,那么我们回放的时候就不用初始化,加载图片资源了,但是只有在PCEditor下才能创建prefab和移动到resource目录,后续尝试(AssetBundle)这种途径,将对象打包成AB然后加载......(估计不太可行)
  3. AnimationStage 
    当开始回放的时候,所有加载的Entity都会被放到该节点下面,所有需要录制对象的根节点,这个设计主要是为了方便进行层级节点的管理,统一管理RecordTarget和回放

扩展设计

  1. 如果软件发生了更新,那么使用久远版本的生成的动画文件应该怎么播放 
    我们可以在动画Json中写入一个ReplayVersion字段,判断当前的回放版本是多少,可以针对不同的播放版本有一个特殊的处理(派生或者直接针对不同的version编写不同的播放器)
  2. 快进,倒退,播放进度条 
    因为完全是根据time来做的,只要增加每一帧之间的差值时间就可以,倒退功能,直接将TimePos--即可

真机测试

  • RecordTargets:3个动画角色,六个记录对象(三个身体,三个帽子),3分钟(一秒钟30帧,总共5400帧)
  • 操作:移动位置,改变颜色
  • 文件量:动画属性500K+ 动画信息Json(5K)
  • 不同平台数据 Android和IOS:无延迟,因为不存在大数据的写入,在录制过程,保证过程平滑不卡顿

如果工程打开没有问题,可以直接build出Android和IOS版本,直接运行,测试只加入了三个角色,通过手指移动角色,如果在PC的Editor进行测试,可以直接通过编辑器改变旋转和具体的颜色

注意

TinyReplaySystem引入的插件 
1. NGUI3.9.1 
2. Json.Net 
具体的Unity版本和注意事项直接到GitHub查看即可

扩展,更新,开发中

TinyReplaySystem目前完成了基本的框架和核心内容,后续会随着需求的增加进一步的优化,读取,写入效率等等

  1. 针对2D动画自适应问题,目前还没有加入屏幕自适应,现在测试尺寸为宽屏
  2. 目前只有一个移动手势,后续增加更多针对2D动画手势
  3. 增加命令功能,目前只有RecordEntity的属性信息
  4. ......

效果演示

总结

最终的效果算是达到了最终的设计目的:针对简单动画的回放系统设计,当然随着角色的增多,随着需要记录属性的增大,增大的是文件量和解析的速度,录制过程应该不会有卡顿出现,至少不让玩家认为应用或者游戏处于"卡死"状态,后续感觉文件量大,可以再压缩一次,如果感觉解析字符串速度慢也可以引入多线程

希望整个文章能对需要的朋友有一个启发或者帮助,由于内容比较多,后面可能进一步阐述下TinyReplaySystem更进一步详细的设计和开发过程,有问题或者交流的朋友直接留言或者到博客留言......

Talk Is Cheap,Show Me The Code.

为了方便更新,直接放在GitHub上,便于查看和管理,有需要的朋友可以去看下,没用GitHub的朋友,真心建议整一个,下载代码库也挺多了,国内的Coding.Net,国外的GitHub,GitLab(不用钱能创建私有仓库),用代码库方便管理自己的项目也方便分享

GitHub地址:https://github.com/tinyantstudio/TinyReplaySystem

你可能感兴趣的:(Unity,Unity)