Hello大家好,我是Nowpaper,一爸学游戏,越来越有趣,文章是B站视频的论坛文字版本:
前言
第一次接触到电子游戏中的时间倒退玩法,着实被惊艳的表现震惊到了,那种掌控时间的感觉,让人意犹未尽,可是这类游戏并不很多,其中有个原因是在于,时间倒退的功能,会对游戏机制的设计要求极高,程序处理也较为复杂,理解实现原理,才能更好的开发出来同类型的游戏,本文章将使用CocosCreator3,实现一个时间回溯的效果,如果您觉这个很酷,还请点赞收藏支持
2D和3D的时间倒退技术方案,基本上是一样的,在本文中将使用3D物理,来实现这样的展示,在一个平台上堆一个墙,通过发射的球打散,按住一个键产生时间倒退,松开键时间流逝就会继续
实现回溯效果的原理并不复杂,我们只需要记录,时间点上的物体状态数据,而次数直接影响了内存量级,一般来说回放只记录小范围的时间段,然后在游戏循环逻辑中,倒着播放出来即可,每个记录间隔,而具体多少需要看你的数据设计
我们不需要将每个帧都记录,因为它是在太快了,一般的做法是记录固定时间间隔上状态数据,然后做中间插值,而快速倒放甚至都不需要做中间插值,视频里的实现是没有作插值的快速倒放
准备演示场景
首先用来展示的这个场景的搭建也不复杂,包含了一个平台,一个方块墙,一个球的预制体,素材来源于官方Store
场景准备好之后,我们在世界上创建一个节点,名字叫做RewindSystem,后面在这个节点下面的所有物体,都会按照规则记录数据状态,它之外的都不会被记录,具体的实现我们等会儿再说
添加一个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次的赋值修改,因此看起来比较快
优化修正
线性速度和旋转(角)速度
就目前来看,还有一些瑕疵,第一是回放到一半,恢复正常,物体不会按照之前的物理状态运行,上面的动图中,收回到一半的时候,它会在半空落下或者按照恢复时候的物理行进,我们的期望是,回放前是什么样,回放后恢复的时候还是什么样子,如下动图
为了达到这个效果,这里我们需要记录的数据,除了位置和旋转信息,还需要记录刚体的线性速度,和旋转速度,因此只需要改造一下记录数据结构,在记录上做一些处理,添加线性速度和角速度的记录数据,恢复的时候也要将这些数据还原,仅仅这样还不够最重要的是,最后一次的记录缓存到一个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创建的,它没有之前的数据,因此只需要将队列为空的时候,判断一下是不是小球即可,如果是的话直接销毁
结束
到此已经完成了,这是一个小而有趣的技术点,其实无论对于2D还是3D游戏,要想完美的实现回放,还需要很多工作,回放点记录数值的策略,取决于你的游戏结构设计,可以做比如计数调用、过度插值、动画倒放等优化这个功能,但是考虑的问题也会很多,这里就不展开说了,如果你打算用Creator做游戏,收藏一下绝对不亏,希望文对您有用,我是Nowpaper,一个混迹游戏行业的老爸,我们下次再见
其他
本人喜欢研究各种有趣的玩法,以下是往期制作,可以移步研究
用RenderTexture实现Sprite版小地图和炫酷的传送门