前言

项目已经传到 github 上了 https://github.com/zong4/SuperHot,3D 版本的会在后续上传。

3D 的版本已经上传好了,推荐阅读 3D 版本的代码,更加优美和完善。

Demo


回放的话其实不外乎两种方法,分别对应了服务器的两种状态同步方式:帧同步和状态同步。

帧同步的话就是每一帧都记录所有玩家的输入,然后回放时重新执行一遍。

状态同步的话就是每一帧都记录所有玩家的状态,然后回放时直接把状态设置过去。

因此我们也可以同样的通过记录每一帧各角色的信息或者输入来实现回放,但是由于帧同步就意味着要重新模拟一遍世界,所以我这边现用了实现起来简单的状态同步。

ControllerManager

那不管是哪种方法,我们都需要一个 ControllerManager 来管理所有的角色。

我这边由于是状态同步,所以记录信息的任务就统一交给 ControllerManager 来做,在 LateUpdate 中收集所有角色的信息并保存。

其中 Action 中的 Time 是为了在实现 wait 的,从而保证回放时的时间间隔和录制时一致,这里我为了方便才把它放在 Action 结构体中的,按理说应该以帧的单位存储。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
public struct Action
{
public float Time;
public int ID;
public Vector3 Position;
public Vector3 Direction;
public bool Fire;
}

namespace Controllers
{
public class ControllerManager : MonoBehaviour
{
private static ControllerManager _instance;

// Data
private static int _nextId;
public List<Controller> controllers = new();
public List<List<Action>> Actions = new();

// Player
public GameObject player;

private void Awake()
{
if (_instance == null)
{
_instance = this;
DontDestroyOnLoad(gameObject);
}

Instantiate(player);
}

private void LateUpdate()
{
if (ReplayManager.IsReplaying())
return;

// Process actions
var actions = new List<Action>();
foreach (var controller in controllers)
{
actions.Add(new Action
{
Time = Time.time,
ID = controller.ID,
Position = controller.transform.position,
Direction = controller.transform.forward,
Fire = Input.GetButton("Fire1") // Example input check
});
}

Actions.Add(actions);
}

public static ControllerManager Instance
{
get
{
if (_instance == null)
{
_instance = new GameObject("ControllerManager").AddComponent<ControllerManager>();
}

return _instance;
}
}

public void RegisterController(Controller controller)
{
if (controller == null) return;

controller.ID = _nextId++;
controllers.Add(controller);
Debug.Log($"Controller {controller.ID} registered.");
}

public Controller GetController(int id)
{
return controllers[id];
}

public List<Action> GetActions(int time)
{
return Actions[time];
}
}
}

ReplayManager

ReplayManager 主要负责回放的逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
using System.Collections;
using Controllers;
using UnityEngine;

public class ReplayManager : MonoBehaviour
{
// Singleton instance
private static ReplayManager _instance;

// Data
private static bool _isReplaying;

private void Awake()
{
if (_instance == null)
{
_instance = this;
DontDestroyOnLoad(gameObject);
}
}

public static ReplayManager Instance
{
get
{
if (_instance == null)
{
_instance = new GameObject("ReplayManager").AddComponent<ReplayManager>();
}

return _instance;
}
}

public void StartReplay()
{
_isReplaying = true;
TimeScaleManager.ResetTimeScale();

StartCoroutine(ReplayCoroutine());
}

private static IEnumerator ReplayCoroutine()
{
for (var i = 0; i < ControllerManager.Instance.Actions.Count && _isReplaying; i++)
{
var time = ControllerManager.Instance.Actions[i];
foreach (var action in time)
{
var controller = ControllerManager.Instance.GetController(action.ID);

if (controller != null)
{
controller.transform.position = action.Position;
controller.transform.forward = action.Direction;

if (action.Fire)
{
// controller.Fire();
}
}
}

if (i < ControllerManager.Instance.Actions.Count - 1)
{
// Wait for the next action time
yield return new WaitForSeconds(ControllerManager.Instance.Actions[i + 1][0].Time - time[0].Time);
}
else
{
// Last action, wait for a short duration before stopping
yield return new WaitForSeconds(0.1f);
}
}
}

public void StopReplay()
{
_isReplaying = false;
// Additional logic to stop replaying
}

public static bool IsReplaying()
{
return _isReplaying;
}
}

基于缩放的回放

之前的回放有点问题,慢的还是慢,快的还是快,就像录屏一样。

但是我们可以计算一下假设一秒10帧,每一帧可以移动1cm。

  • 那么正常速度下一秒动 10cm,速度是 10cm/s。
  • 将 TimeScale 改成 0.1 后,就意味着每一帧的利用率只有 10%,所以一帧移动 0.1cm,一秒移动 1cm,速度是 1cm/s,正好是 TimeScale 的值。
  • 回放时由于我们记录的是 Time.deltatime,所以等于我们每一帧的时间是 0.6s(1s 里由 100帧),每一帧移动的距离是 0.1cm,所以总共移动了 10cm,速度就是 10cm/s。

但是很奇怪的是,明明速度一样(打印出来也是一样),但是人眼确觉得回放时比正常速度慢,这就涉及到人眼的机制,在这里不具体展开,总之就是人眼对时间的感知是非线性的。

那要怎么解决上面这个问题呢,很简单,就是抽帧,我这边就是根据我设定的 TimeScale 来将每 10次 状态是站立(慢速)的帧合并在一起,从而让肉眼看上去与政策速度别无二致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
private static IEnumerator ReplayCoroutine()
{
for (var i = 0; i < ControllerManager.Instance.playerControllerMessages.Count && _isReplaying; i++)
{
var cnt = 0;
var deltaTime = 0.0f;
while (ControllerManager.Instance.playerControllerMessages[i].state == Controller.ControllerState.Idle &&
i < ControllerManager.Instance.playerControllerMessages.Count - 1 && cnt < 10) //todo
{
cnt++;

i++;
deltaTime += ControllerManager.Instance.playerControllerMessages[i].deltaTime;
}

// Player
{
var playerMessage = ControllerManager.Instance.playerControllerMessages[i];

var playerController = ControllerManager.Instance.playerController;
if (playerController)
{
playerController.transform.position = playerMessage.position;
playerController.transform.forward = playerMessage.direction;

if (playerMessage.state == Controller.ControllerState.Attack)
{
playerController.Attack();
}
}
}

// All other controllers
{
var allControllerMessages = ControllerManager.Instance.allControllersMessages[i];

foreach (var controllerMessage in allControllerMessages)
{
var controller = ControllerManager.Instance.GetController(controllerMessage.id);
if (controller)
{
controller.transform.position = controllerMessage.position;
controller.transform.forward = controllerMessage.direction;

if (controllerMessage.state == Controller.ControllerState.Attack)
{
controller.Attack();
}
}
}
}

yield return new WaitForSeconds(deltaTime);
}
}