[置顶] 【设计模式】从命令模式到录像(replay)系统

时间:2022-09-09 09:24:25

引言
这几天看到《游戏编程模式》中的到命令模式一章,这个模式说起来并不是很熟,想着大概也像观察者模式,单例之类的,被用了很多次却不自知吧,没想到还真的对我有所启发。
命令模式
命令模式,将一个请求封装为一个对象,从而使你可以用不同的请求对客户进行参数化;对请求排队或记录请求日志,以及支持可撤销的操作。
如果一个命令对象可以做(do)一些事情,那么就应该可以很轻松地撤销(undo)它们。撤销这个行为经常在一些策略游戏中见到,在游戏中可以回滚一些让你不满意的步骤。
以移动对象为例,只要将移动命令封装起来,需要移动对象时,调用这个封装起来的命令即可。如果要实现撤销操作,只需记录对象上一次的位置(或是保留对象上一个执行的命令)。
如果需要支持多次撤销,需要维护的不只是上一个命令,而应该维护一个命令列表,其中每个命令都包含了上一个命令。当玩家选择撤销时,逆序遍历命令列表,执行当前命令的undo指令,并移动到下一个命令处即可。
[置顶]        【设计模式】从命令模式到录像(replay)系统

replay系统从使用到设计
当初设计《海战世界》的replay系统时,调研了市面上几款游戏的replay系统,目前市面上很少有游戏会记录每一帧游戏的状态,来保存数据。其中《星际争霸》的rep文件(《星际争霸》的replay系统保存的文件以.rep后缀结尾,下文主要以《星际争霸》的录像文件进行分析)让我的记忆最为深刻,文件大小只有几百KB,着实很好奇是如何做到的。后来经过分析,发现rep的文件中保存了大量的命令,播放rep文件实际上是将游戏客户端当作是一个播放器,而rep作为输入,客户端只需按照rep中的命令进行播放即可。
播放《星际争霸》的rep时常常会遇到一种异常情况,就是播放某些rep文件,起先还正常,突然某一方玩家就开始不停的制造农民,后来发现整个rep中各种单位的表现越来越反常。当时玩游戏的时候,碰到这种情况,往往会换一个rep文件重新播放,后来有了游戏论坛,有人提出问题,有老玩家会指出这是因为游戏版本与rep文件版本不一致导致的。
为什么会播放失败?
单机游戏如《星际争霸》等游戏碰到播放失败的情况,可以通过游戏版本切换工具切换到对应的游戏版本来避免播放失败的问题。而站在网络游戏开发者的角度,为什么游戏版本与rep版本不一致为什么会导致录像播放失败呢?
简述《海战世界》的录像功能
《海战世界》作为一款已经上线的游戏,录像功能还有很多不足的地方,这里抛砖引玉,对于我算是一种总结,对于其他开发者,希望可以帮助设计录像系统时少走些弯路。
首先要将输入数据(主要包含网络数据和玩家操作)两部分抽象出来。设计以时间流为基准的数据结构来保存数据,播放是也是以时间顺序来播放。保存为二进制文件并加密。
网络数据方面,《海战世界》的网络通信分为战斗外的部分和战斗内的部分,实现战斗录像只需要记录战斗内的网络协议即可。具体实现是在网络通信的入口处,注册一个callback,通过服务器的id来区分是否为战斗内的协议数据。
玩家操作方面,由于《海战世界》中玩家的操作比较复杂,除了改变鼠标、键盘的输入,还会改变camera的状态(如进入瞄准模式、进入飞机视角、进入死亡视角等),为了还原玩家对camera状态的改变,保存了很多camera的数据。《海战世界》在战斗录像内有两种camera模式,一种是fix mode(完全还原玩家当时的操作),另一种是free camera(播放录像的玩家可以*转动)。每个玩家在播放录像时,会同时模拟两个camera的状态,根据玩家的切换,同时只有一个camera的状态生效(同时另一个camera在后台持续进行模拟)。
《海战世界》的录像功能包含了快进,暂停,(残废的)快退功能。
《海战世界》录像功能的不足之处:
1.每次快退功能都需要从头开始播放录像,正是因此我觉得这个功能很残废;
2.录像文件的大小过大,对于网络游戏来说,更小的文件意味着可以保存更多的录像文件,而不必频繁的清理磁盘。
3.为了规避游戏版本与录像版本不匹配的问题,检测到游戏版本不一致时不能播放录像。
4.没有实现《Dota2》,《守望先锋》中,其他客户端在线即可近乎实时的观察其他玩家的比赛。

插播完毕,回到客户端版本与rep文件版不一致为什么会失败的问题上来。《海战世界》的网络协议在客户端与服务端是用共用一份枚举文件来实现协议的一一对应。比如说开火的协议ID为5,当在开火协议前新增一个协议时,开火的协议ID就变成了6,这时播放录像文件必然会出错。以此推断《星际争霸》录像播放失败,也极有可能是由于游戏客户端的更新导致了rep文件中保存的命令与更新后的客户端不一致的情况。

replay系统原型
最后在命令模式的基础上,利用Unity3d实现了一个简单的replay原型,记录了GameObject的移动。
通过Execute可以播放GameObject的移动过程;
通过Undo可以逆序播放GameObject的移动过程;
一言不和就上代码

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class PlayerController : MonoBehaviour {

//命令的抽象基类
abstract class Command
{
public virtual void Execute()
{
}
public virtual void Undo()
{
}
}

//移动命令单元,其他操作也可以通过继承Command来实现
class MoveUnit : Command
{
private Vector3 _lastDir = Vector3.zero;
private Vector3 _newDir = Vector3.zero;
private CharacterController _controller;
private float _moveTime;

public override void Execute()
{
_controller.Move(_newDir);
}

public override void Undo()
{
_controller.Move(_lastDir);
}

public void InitController(CharacterController cc)
{
_controller = cc;
}

public Vector3 NewDir
{
get { return _newDir; }
set
{
if (_newDir != value)
{
_newDir = value;
}
}
}
public Vector3 LastDir
{
get { return _lastDir;}
set
{
if (_lastDir != value)
{
_lastDir = value;
}
}
}

public float MoveTime
{
get { return _moveTime; }
set
{
if (_moveTime != value)
{
_moveTime = value;
}
}
}

}


private CharacterController _controller;
private Vector3 _moveDelta = Vector3.zero;
private List<MoveUnit> _moveList = new List<MoveUnit>();
private Vector3 _startPos = Vector3.zero;

void Start ()
{
_controller = GetComponent<CharacterController>();
if (_controller == null)
{
Debug.LogWarning("[Start] _controller == null!");
}
_startPos = transform.localPosition;
}

void Update ()
{
if (Input.GetKeyDown(KeyCode.UpArrow) || Input.GetKeyDown(KeyCode.DownArrow) || Input.GetKeyDown(KeyCode.LeftArrow) || Input.GetKeyDown(KeyCode.RightArrow))
{

Vector3 input = new Vector3(Input.GetAxis("Horizontal"), 0f, Input.GetAxis("Vertical"));

MoveUnit moveData = new MoveUnit();
moveData.InitController(_controller);
moveData.NewDir = input;
moveData.LastDir= input * -1; //
moveData.MoveTime = Time.realtimeSinceStartup;
_moveList.Add(moveData);
Debug.Log("_moveList.Add Data, newPos: " + input + ", lastPos: " + input * -1 + ", moveTime: " + Time.realtimeSinceStartup);
_controller.Move(input);
}

}

void OnGUI()
{
if (GUI.Button(new Rect(50f, 50f, 100f, 20f), "Execute"))
{
StartCoroutine(OperateExecute());
}

if (GUI.Button(new Rect(50f, 80f, 100f, 20f), "Undo"))
{
StartCoroutine(OperateUndo());
}

if (GUI.Button(new Rect(50f, 110f, 100f, 20f), "Clear Data"))
{
_moveList.Clear();
}
}

private IEnumerator OperateExecute()
{
int index = 0;
MoveUnit data = null;
float curTime = Time.realtimeSinceStartup;
float startTime = Time.realtimeSinceStartup;

transform.localPosition = _startPos;

while (index < _moveList.Count)
{
data = _moveList[index];
curTime = Time.realtimeSinceStartup;
if (curTime - startTime >= data.MoveTime)
{
Debug.Log("OperateMove MoveTime: " + data.MoveTime + ", curTime: " + curTime + ", frameCount: " + Time.frameCount);
data.Execute();
++index;
}
//解决while循环等待时卡死主线程的问题
yield return null;
}
}

private IEnumerator OperateUndo()
{
int index = _moveList.Count - 1;
MoveUnit data = null;
float lastTime = 0f;

while (index >= 0)
{
data = _moveList[index];
if(index == _moveList.Count - 1)
{
lastTime = 0f;
Debug.Log("OperateUndo MoveTime: " + data.MoveTime + ", lastTime: " + lastTime + ", frameCount: " + Time.frameCount);
data.Undo();
--index;
continue;
}
else
{
lastTime = _moveList[index + 1].MoveTime;
}
// 等待下一条命令与当前命令的执行间隔
yield return new WaitForSeconds(lastTime - data.MoveTime);
data.Undo();
--index;
}
}
}

运行时截图
[置顶]        【设计模式】从命令模式到录像(replay)系统
运行时说明:
在创建的Capsule上添加CharacterController以及上面新创建的PlayerController .cs文件
[置顶]        【设计模式】从命令模式到录像(replay)系统