简单探讨和分析下游戏回放系统的设计和针对特定需求回放功能的TinyReplaySystem设计和具体实现
在屏幕舞台中,玩家操控动画角色通过手势缩放,移动,修改角色颜色等属性,用户操控所需要的角色进行PlayAnimation,角色扮演。扮演结束,保存到本地,可以回放用户所扮演的动画。相当于录制屏幕指定区域,存储成视频,加载回放。
通过找资料获得一些实现的具体思路,简单分析下各自的问题和方便性,其实总的思路就是"记录"+"解析",只是不同的方案在不同点记录不同的内容,记录不同的内容,导致回放处理逻辑不同而已(恩,这句算是废话...)
一种战斗回放简单的流程如下:
将战斗逻辑和战斗表现进行拆分,能够方便进行战斗验证和回放,可以将战斗逻辑封装成dll提供给服务器和客户端共同使用,服务器验证方便,指定这场战斗的输入(角色初始状态+当前用户输入比如在第xx回合玩家使用了xx大招,这个算是一个大招序列+战斗随机种子),服务器通过战斗dll逻辑计算和客户端(战斗逻辑dll+战斗)计算的结果进行比对即可判断出当前战斗是否异常
注意点: 这种方式能够让客户端和服务器复用战斗逻辑,能够"确保,输入一致,输出结果必须一致,否则则是战斗异常",因为算法一致,输出一致,那么输出一定一致.
在编写战斗逻辑的时候要做好异常处理,不然战斗服务器很容易异常导致战斗服务器需要重启,因为客户端出现异常影响的是一个玩家,如果服务器异常则影响的是整个网络的玩家,所以编写dll或者公用逻辑的时候一定要做好异常处理
另外一种完全命令驱动的流程如下: 每一个回合记录下当前战斗发生的情况(比如角色A释放了技能S,击中角色B,角色B掉血100),每一个回合都通过"命令阐述"的形式记录下来,客户端有一个命令解析器(战斗表现)通过解析命令移动角色,释放技能造成伤害等等操作
这种方式要比上一种"一个输入一个输出"要占用较多的数据量,这种可以"编辑战斗",可以通过配置,配置出一场战斗,因为,客户端就是命令解析+具体表现
需求简单化:通俗的说就是玩家"通过手势玩出来动画",这里面没有复杂的技能表现和酷炫的战斗特效表现,这里就是"纯粹",纯粹的动画,拿着木偶在舞台上表演,抽象出来也不例外,命令+当前角色状态+时间点 TimePos+EntityState
状态/命令存储模式
最初的存储方案如下:
每个角色,有一个状态list,每一帧,new出来一个新的state添加到状态list中,当录制结束直接的将整个角色list序列化到json文件中
如果保存每个角色完整的状态list,意味着内存使用的内存会一直增加,在录制结束之前必须保存整个录制数据文件(所有角色,所有需要记录的部位,所有部位或者角色的状态list,这些状态可能包括了基本的位置,旋转缩放和需要记录颜色状态对象的颜色值rgba),当录制结束,写入文件也是一个很费时间的过程(json文件,3-4M电脑上差不多2s,真机测试耗费了20s以上IOS也是25s以上......,这么久的写入和加载过程还玩个啥)
经过自己的摸索,感觉方向上出了问题,数据量大,应该想办法降低下来,为了错误的方案去找解决方案,这个不应该,的确花了很多的时间去研究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.
上面我们说到了加载阶段,先读取动画Json文件,先初始化该Animation,比如加载动画主角,动画背景,动画背景音乐等,主要说下如何TinyReplaySystem如何去加载角色
如果工程打开没有问题,可以直接build出Android和IOS版本,直接运行,测试只加入了三个角色,通过手指移动角色,如果在PC的Editor进行测试,可以直接通过编辑器改变旋转和具体的颜色
TinyReplaySystem引入的插件
1. NGUI3.9.1
2. Json.Net
具体的Unity版本和注意事项直接到GitHub查看即可
TinyReplaySystem目前完成了基本的框架和核心内容,后续会随着需求的增加进一步的优化,读取,写入效率等等
最终的效果算是达到了最终的设计目的:针对简单动画的回放系统设计,当然随着角色的增多,随着需要记录属性的增大,增大的是文件量和解析的速度,录制过程应该不会有卡顿出现,至少不让玩家认为应用或者游戏处于"卡死"状态,后续感觉文件量大,可以再压缩一次,如果感觉解析字符串速度慢也可以引入多线程
希望整个文章能对需要的朋友有一个启发或者帮助,由于内容比较多,后面可能进一步阐述下TinyReplaySystem更进一步详细的设计和开发过程,有问题或者交流的朋友直接留言或者到博客留言......
Talk Is Cheap,Show Me The Code.
为了方便更新,直接放在GitHub上,便于查看和管理,有需要的朋友可以去看下,没用GitHub的朋友,真心建议整一个,下载代码库也挺多了,国内的Coding.Net,国外的GitHub,GitLab(不用钱能创建私有仓库),用代码库方便管理自己的项目也方便分享
GitHub地址:https://github.com/tinyantstudio/TinyReplaySystem