yukamu 发表于 2021-7-14 13:38

时间回溯——用Unity实现时空幻境(Braid)中的控制时间效果

前言

控制时间相信几乎是每个人都想拥有的能力,也为众多影视、游戏等提供了灵感,荒木老师在jojo的奇妙冒险中几乎把控制时间的能力玩了个遍。而在游戏领域,令笔者印象最深的就是本次的主角——时空幻境(Braid),一款把横板跳跃与时间回溯完美结合的游戏。
注意!由于本教程主要实现时间回溯效果,横板过关类游戏的场景搭建、移动、动画等不在本次内容范围内,有兴趣的同学可以先从https://www.bilibili.com/video/BV1jJ41147WM这里开始学习。本次教程也会以该系列视频的工程为基础,在原项目上进行修改实现时间回溯的功能。
项目来源:https://www.bilibili.com/video/BV1v7411E7qz
基础工程:https://pan.baidu.com/s/1HISQizt0NvCHo8U0KgSCCA 提取码:jlbx
一、时间回溯实现原理

我们知道视频是能够倒放的,那游戏可不可以也像视频那样把每一帧记录下来,需要时再倒着输出实现时间倒流呢?答案当然是可以的,这种方法称为“备忘录模式”。事实上时空幻境的作者也说过该游戏主要是用该方法制作,有兴趣、英语好的同学可以看看作者的解释https://news.ycombinator.com/item?id=9484197。
二、用Unity实现时间回溯

下载好前言中提到的基础工程,打开之后可能会有几个不影响的警告,Clear即可。打开Scene文件夹下的SampleScene场景,运行一下,试试操作人物移动、跳跃,应该不会有什么问题。
主要实现操控角色的时间倒流效果,所以先把怪物(opossum-1)从场景中删除。
1.设置保存数据类型
首先要确定每帧保存什么数据,位置数据、起跳后的速度数据,由于是2d动画所以还需要记录每帧所用的Sprite和脸的朝向。在Scripts文件夹里新建一个c#脚本,取名为ObjectStage。
public class ObjectStage
{
    public Vector3 Position { get; set; }
    public Vector3 Velocity { get; set; }
    public Sprite Sprite { get; set; }
    public bool IsRight { get; set; }
}
2.保存角色状态
接下来就要实现时间倒流的效果了,新建脚本TimeBack挂到Player上。由于读取数据是从后往前读取,所以可以使用stack(栈)一个后进先出的容器来保存数据。同时也需要获取到player上的<SpriteRenderer>,用于获取和修改某一帧角色的动作;<Animator>用于在时间倒流时暂停动画的播放;<CharacterController2D>,原工程的角色控制代码,用于修改角色脸的朝向;<Rigidbody2D>,获取、修改速度和时间倒流时关闭物理引擎。
    void Start()
    {
      TimeBackData = new Stack();
      SpriteRenderer = GetComponent<SpriteRenderer>();
      animator = GetComponent<Animator>();
      cc2D = GetComponent<CharacterController2D>();
      m_Rigidbody2D = GetComponent<Rigidbody2D>();
    }
首先是保存数据,cc2D.m_FacingRight在原工程里受保护的(private)这里我们需要公开(public)。
    void SaveData()
    {
      ObjectStage stage = new ObjectStage();
      stage.Position = transform.position;
      stage.Sprite = SpriteRenderer.sprite;
      stage.IsRight = cc2D.m_FacingRight;
      stage.Velocity = m_Rigidbody2D.velocity;
      TimeBackData.Push(stage);
    }
3.读取和显示状态
接下来是读取数据,读取后的数据就可以删除了,可以用Stack.Pop(),但是最后一个读取的数据,也就是第一个保存的数据不能删,可以用 Stack.Peek()。
    ObjectStage LoadData()
    {
      if (TimeBackData.Count > 1)
      {
            return (ObjectStage)TimeBackData.Pop();
      }
      else
      {
            return (ObjectStage)TimeBackData.Peek();
      }
    }
然后就是把读取的数据反映到Player身上,也就是时间倒流的过程,要注意在这期间角色应该是不受物理引擎的影响,并且不能播放动画,要在代码中关闭。
    void ShowData(ObjectStage stage)
    {
      animator.enabled = false;
      transform.position = stage.Position;
      SpriteRenderer.sprite = stage.Sprite;
      transform.localScale = new Vector3(stage.IsRight ? 1 : -1, 1, 1);
      m_Rigidbody2D.simulated = false;
      m_Rigidbody2D.velocity = stage.Velocity;
    }
4.调用代码实现时间回溯
方法写好了,接下来就是调用了,我们知道update在一秒内执行的次数是不固定的,所以我们保存数据和读取数据只能放在FixedUpdate里。并且只有在按下时间倒流的按键时才能读取数据,其他时间保存数据,按键抬起的时候要把之前关闭的物理引擎和动画开启。
ObjectStage LoadStageData = new ObjectStage();
private void FixedUpdate()
    {
      if (CheckKey)
      {
            LoadStageData = LoadData();
            if (LoadStageData != null)
            {
                ShowData(LoadStageData);
            }
      }
      else
      {
            SaveData();
      }
    }
(注意,按键检测仍然要放在UpDate里。)
    private void Update()
    {
      CheckKey = Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift);
      CheckKeyUp = Input.GetKeyUp(KeyCode.LeftShift) || Input.GetKeyUp(KeyCode.RightShift);
      if (CheckKeyUp)
      {
            cc2D.m_FacingRight = LoadStageData.IsRight;
            animator.enabled = true;
            m_Rigidbody2D.simulated = true;
      }
    }
运行游戏操作一会,再按下Shift看看你的角色是不是已经是一个无敌的存在,毕竟一个可以无限时间倒流的人是不可能会输的吧。(某平凡的上班族点了个赞!)
如果追求细节的话能发现,时间回溯到在空中时结束回溯,角色会垂直落下,这时候只需要把CharacterController2D脚本上的canAirControl勾选为false即可继续跳跃。但这样修改也有个问题,角色不能在空中移动了,为了模拟Braid原版游戏的手感,我们可以尝试修改基础工程的move方法。
首先把canAirControl勾选为false。
      if (m_Grounded || canAirControl)
      {
            // 输入变量move决定横向速度
            m_Rigidbody2D.velocity = new Vector2(move, m_Rigidbody2D.velocity.y);
      }
      else if (!m_Grounded)
      {
            if (move > 0 && m_FacingRight)
            {
                m_Rigidbody2D.velocity = new Vector2(Mathf.Max(move, m_Rigidbody2D.velocity.x), m_Rigidbody2D.velocity.y);
            }
            else if (move < 0 && !m_FacingRight)
            {
                m_Rigidbody2D.velocity = new Vector2(Mathf.Min(move, m_Rigidbody2D.velocity.x), m_Rigidbody2D.velocity.y);
            }//如果在空中有相反方向的操作则修改水平速度
      }
修改后的手感就和Braid里面非常相似了。
另外,如果想在时间回溯时音频也跟着倒放,可以修改AudioSource组件的Pitch参数为-1。
修改后的工程:https://pan.baidu.com/s/1tmPDt9Ebq814cbasffOhlA
提取码: m4pg


对线下游戏开发学习感兴趣的盆友,欢迎访问:http://levelpp.com/
同时,也欢迎加入游戏开发群搅基:610475807

franciscochonge 发表于 2021-7-14 13:46

记操作是不是好一点?

mastertravels77 发表于 2021-7-14 13:48

基于物理的移动、碰撞而影响的速度,记操作是不可靠的

Ylisar 发表于 2021-7-14 13:55

反向操作逻辑上挺困难的,跳下悬崖很easy,从悬崖底下回来就不好搞了。所以记录每一帧数据的方法,简单易行

LiteralliJeff 发表于 2021-7-14 14:05

非常有趣!

RhinoFreak 发表于 2021-7-14 14:13

请问防止内存溢出的机制如何设计比较合理呢?毕竟是一直收集着角色的信息,这个信息收集的变量会一直增大。

这里我的想法是,收集信息的Stack有两个,Stack_1和Stack_2,长度都设定好且相等,当Stack_1装满后使用Stack_2装,当Stack_2也装满之后,直接将Stack_1清空,再继续往后装数据,依次交替清除使用。这种设计思路应该可行
[小建议]

量子计算9 发表于 2021-7-14 14:13

强无敌

c0d3n4m 发表于 2021-7-14 14:18

请问如果人物不是用帧动画实现的,而是用的骨骼动画,如何实现回溯呢?

acecase 发表于 2021-7-14 14:26

我之前做法是记录所有相关无法平滑插值的关键桢的左右值和时间偏移量,比如起跳,撞墙,空间转换……

super1 发表于 2021-7-14 14:29

有没有方法做持久化,感觉这样内存顶不住
页: [1] 2
查看完整版本: 时间回溯——用Unity实现时空幻境(Braid)中的控制时间效果