时间倒放的有趣实现,在Creator中作物理回溯,开发《时空幻境》一样的倒退玩法

Hello大家好,我是Nowpaper,一爸学游戏,越来越有趣,文章是B站视频的论坛文字版本:

前言


第一次接触到电子游戏中的时间倒退玩法,着实被惊艳的表现震惊到了,那种掌控时间的感觉,让人意犹未尽,可是这类游戏并不很多,其中有个原因是在于,时间倒退的功能,会对游戏机制的设计要求极高,程序处理也较为复杂,理解实现原理,才能更好的开发出来同类型的游戏,本文章将使用CocosCreator3,实现一个时间回溯的效果,如果您觉这个很酷,还请点赞收藏支持
动画3

2D和3D的时间倒退技术方案,基本上是一样的,在本文中将使用3D物理,来实现这样的展示,在一个平台上堆一个墙,通过发射的球打散,按住一个键产生时间倒退,松开键时间流逝就会继续

实现回溯效果的原理并不复杂,我们只需要记录,时间点上的物体状态数据,而次数直接影响了内存量级,一般来说回放只记录小范围的时间段,然后在游戏循环逻辑中,倒着播放出来即可,每个记录间隔,而具体多少需要看你的数据设计
image10

我们不需要将每个帧都记录,因为它是在太快了,一般的做法是记录固定时间间隔上状态数据,然后做中间插值,而快速倒放甚至都不需要做中间插值,视频里的实现是没有作插值的快速倒放

准备演示场景

首先用来展示的这个场景的搭建也不复杂,包含了一个平台,一个方块墙,一个球的预制体,素材来源于官方Store


动画7

场景准备好之后,我们在世界上创建一个节点,名字叫做RewindSystem,后面在这个节点下面的所有物体,都会按照规则记录数据状态,它之外的都不会被记录,具体的实现我们等会儿再说
image

添加一个Canvas节点,加入一个倒退图标,用来标记是否在倒退状态


现在我们建立一个发射小球的脚本,比如叫SphereShooter的组件,进入代码编辑中,加入Canvas、Camera,以及Prefab的可引用属性,在Start里注册按键事件,通过on方法,监听一个KeyUp的事件,在事件获取中,通过KeyCode判断是否为空格键抬起,触发一个发射方法,为了方便,我们将发射封装成一个shoot方法,传入点击屏幕的坐标,我们期望是从屏幕中心发射出去,因此传入(0,0)点即可,核心代码如下:

private shoot(x:number,y:number){
    const outRay = new geometry.Ray();
    this.camera.screenPointToRay(x,y,outRay);
    let clone = instantiate(this.sphere);
    clone.setPosition(this.camera.node.position);
    this.node.addChild(clone);
    clone.getComponent(RigidBody).applyImpulse(outRay.d.multiplyScalar(40));
}
// 完整代码请移步Store : https://store.cocos.com/app/detail/3407

在这个方法里,通过摄像机screenPointToRay方法,取得一个由摄像机为起点,经过屏幕点击点的射线,这个射线就是小球要发射出去的推力向量,而小球的发射位置,就直接是相机的位置,将它添加到父节点,最后把推力设置给它。
保存一下返回到Creator里面,将刚刚写的组件脚本,添加到RewindSystem节点上,属性中把Camera和小球的Prefab,引用到组件属性上,运行一下看看效果,按空格键的时候小球发射,球体撞击方块堆砌成的墙面,场景环境发生变化,测试场景就准备好了

实现回放

现在实现的目标是,按一个按键执行回放,松开按键停止回放,为了实现这个效果,新建一个回放系统组件,在组件脚本中定义isRecording属性,来判断控制是否在回放,Start中注册KeyDown和KeyUp的事件,通过KeyCode值判断按键R键是否按下,来修改是否回放的成员变量

记录和回放

在整个回放系统中,我们需要记录所有的,可以回放物体的重要信息,因此首先定义一个record类,里面保存坐标数据和旋转数据,用一个静态方法来设置回放数据,定义一个基于此记录类的数组类RecordBuffer,它的作用是不断的Push数据记录,回放的时候利用Pop取出最后一个数据,还原给对应的物体,数据结构代码:

class RecordItem{
    public vec3:Vec3;
    public quat:Quat;
    public linearVelocity:Vec3 = new Vec3();
    public angularVelocity:Vec3 = new Vec3();
    public constructor(rig:RigidBody){
        this.vec3 = rig.node.position.clone();
        this.quat = rig.node.rotation.clone();
        rig.getLinearVelocity(this.linearVelocity);
        rig.getAngularVelocity(this.angularVelocity);
    }
    public static Rewind(rig:RigidBody,item:RecordItem){
        rig.node.setPosition(item.vec3);
        rig.node.setRotation(item.quat);
        rig.setLinearVelocity(item.linearVelocity);
        rig.setAngularVelocity(item.angularVelocity);
    }
}
class RecordBuffer extends Array<RecordItem>{

}
// 完整代码请移步Store : https://store.cocos.com/app/detail/3407

在系统中添加它的Map数据结构,Key用来记录UUID,Value则存放RecordBuffer,在Start中,我们启用一个调度,让它在指定的时间间隔中,记录这个节点下的每个子节点数据,在逻辑中需要不停的,将这些记录数据push给Buffer,时间间隔我设置为一秒内记录30次,视频中没有考虑那么多,在update中,对是否回放进行判定,如是回放状态也就是按下了R键,遍历记录的Map数据,通过uuid找到对应的记录缓存,取出最后一个记录点,将数据还原回去

KINEMATIC和DYNAMIC

这里有个重点,倒放时候的游戏时间并没有变化,物理系统仍然运转,此时回放系统在不停的设置位置和旋转,因此动态类型物体的物理,可能会因为不停的赋值,造成大量的不必要运算和错误,为了避免这样的问题,在按键按下的时候,设置所有的物体物理类型为,运动学刚体KINEMATIC,按键抬起的时候设置,所有物体物理类型为,动力学刚体DYNAMIC

// 回放时需要设置
RigidBody.type = ERigidBodyType.KINEMATIC
// 正常时需要设置
RigidBody.type = ERigidBodyType.DYNAMIC

初步成果

将刚刚写的组件脚本,添加到回放控制用的节点上,将自由摄像机控制组件添加给主摄像机,并且添加一下倒放UI的引用,启动一下看看效果,打出几个小球看看效果,然后按R键,怎么样不错吧,回放速度比较的快,是因为我们在update里面执行的修改,之前是1秒30次的记录,而在这里的update通常是在60帧,也就是每秒60次的赋值修改,因此看起来比较快
动画2

优化修正

线性速度和旋转(角)速度

就目前来看,还有一些瑕疵,第一是回放到一半,恢复正常,物体不会按照之前的物理状态运行,上面的动图中,收回到一半的时候,它会在半空落下或者按照恢复时候的物理行进,我们的期望是,回放前是什么样,回放后恢复的时候还是什么样子,如下动图
动画4
为了达到这个效果,这里我们需要记录的数据,除了位置和旋转信息,还需要记录刚体的线性速度,和旋转速度,因此只需要改造一下记录数据结构,在记录上做一些处理,添加线性速度和角速度的记录数据,恢复的时候也要将这些数据还原,仅仅这样还不够最重要的是,最后一次的记录缓存到一个Map中,在按键抬起的时候,将最后一次的记录给物体恢复一下物理数据,完成之后保存返回到Creator运行,现在物理回放前和回放后几乎保持一致了
核心代码:

private lastRecords = new Map<string,RecordItem>();
OnKeyUp(event:EventKeyboard){
    if(event.keyCode == KeyCode.KEY_R){
        this.isRewind = false;
        this.playbackIcon.active = false;
        for(let node of this.node.children){
            this.changeRigidBodyType(node.getComponent(RigidBody),ERigidBodyType.DYNAMIC);
            const item = this.lastRecords.get(node.uuid);
            if(item){
                RecordItem.Rewind(node.getComponent(RigidBody),item);
                this.lastRecords.delete(node.uuid);
            }
        }
    }
}
// 完整代码请移步Store : https://store.cocos.com/app/detail/3407

收回生成额外物体

第二个问题是小球恢复回去之后,不会消失,这是因为小球是通过Prefab创建的,它没有之前的数据,因此只需要将队列为空的时候,判断一下是不是小球即可,如果是的话直接销毁
动画6

结束

到此已经完成了,这是一个小而有趣的技术点,其实无论对于2D还是3D游戏,要想完美的实现回放,还需要很多工作,回放点记录数值的策略,取决于你的游戏结构设计,可以做比如计数调用、过度插值、动画倒放等优化这个功能,但是考虑的问题也会很多,这里就不展开说了,如果你打算用Creator做游戏,收藏一下绝对不亏,希望文对您有用,我是Nowpaper,一个混迹游戏行业的老爸,我们下次再见

其他

本人喜欢研究各种有趣的玩法,以下是往期制作,可以移步研究

摄像机视角的有趣玩法,实现《饥荒》同款视觉表现,一毛一样

Raycast射线实现3D世界交互,如何实现立体界面UI

用RenderTexture实现Sprite版小地图和炫酷的传送门

好玩的编队代码,魔性队伍排列惊喜不断完全停不下来

手撸三个有关Bundle详细教程,大厅+子游戏模式从入门到进阶

Cocos3D《病毒传播模拟器》游戏版本1 开发日志和总结

案例开发 四图猜词 Part1~4 全集教程

18赞

太棒了,Mark

看到神作时空幻境!一爸牛逼~!!!

前排位置Mark

markdown

牛p又高产

高端的效果往往只需要简单的代码实现 :+1:

这帖子不能沉啊

1赞

Mark+1

mark!

学习学习学习!

太太太:ox: :beer: 了。

mark!

实际应用补充视频:
https://www.bilibili.com/video/BV1zF41187qe?p=2
里面使用了官方物理DEMO:
https://github.com/cocos-creator/example-physics
仅需简单改造就可以,还不赶快下单
https://store.cocos.com/app/detail/3407
1块钱买不了吃亏,1块钱买不了上当