Unity中的时间控制

关卡创建

本文会探讨如何在Unity中使用时间控制进行关卡创建。在探讨之前,可以观看视频了解拥有时间控制玩法的解谜游戏《Lintrix》:

因为与《Lintrix》拥有同样的时间控制玩法的游戏并不多见,因此本文以《Lintrix》为例,说明如何在Unity中实现该功能,以及它可以有哪些应用。
我们将以Unity引擎为例,并使用Unity相关的术语,但其概念也适用于其他引擎。

时间控制对创建关卡的意义

为什么要用时间控制来辅助创建关卡呢?这样设计的主要契机是:第一,《Lintrix》团队有成员在创建动画编辑器方面具有丰富经验;第二,Unity有一个时间轴,可以查看场景在任何时刻的表现。

我们希望状态和属性,比如Unity对象的Transforms,可依赖于时间,并且能够跳转到特定时刻,因为如果这样的话,正常进行游戏的时候,所有对象都将拥有相应的状态。

就游戏本身而言,因为《Lintrix》是确定性的游戏,所以可以很好地利用时间轴来实现时间控制。《Lintrix》场景的许多对象都在游戏进行时移动,并且它们彼此之间的相对位置非常重要,像时间轴这样的工具可以帮助关卡设计者更容易理解该关卡在游戏中的样子,以便在编辑器中进行创建。另外在大多数情况下,我们也很容易编辑某个时刻的关卡,从而让所有的对象在某个具体时间点定位或旋转,并且当时间设置为零或开始移动时,对象会自动重新计算其位置。

我们提供了快捷键以便于对时间进行前进和后退操作。之后,关卡创建不仅是摆放物体后运行,更多的是向前或向后跳跃,以及重新定位对象,所以这些物体在关卡的任意时刻都要保持在其预先设计的位置。

这在《Lintrix》游戏中非常有用:一方面,希望玩家能够连接晶体来消灭所有的敌人;另一方面,又不希望这些连接会覆盖其他晶体或者彼此重叠。

如何避免对象碰撞

实际上,在开发《Lintrix》游戏的过程中,重叠是个大问题,因为当场景中大多数对象移动时,很难防止它们过早碰撞。 大部分时间会出现类似这种糟糕的效果:

为了理解物体如何移动,我们添加了移动轨迹。它让敌人在晶体之间移动而不碰撞的问题更加容易避免。

它也可用于可视化晶体运动。

我们在创建关卡时使用时间控制进行“计时”。 这里的计时表示调整关卡某些部分的运动,使得在关卡运行时物体早点或晚点出现。 有时甚至希望来回移动所有的动作。
例如,在关卡开始时可以预留给玩家一些时间,以便他们能够预估将会发生的景象。 使用时间轴可以轻松地将时间移至负值,并将此新时间设为零表示开始。

因为在编辑器模式下更新时间的脚本被添加到控制时间的对象,如果想要启用或禁用对象上更改时间的效果,只需启用或禁用此脚本,并使它们响应或忽略时间的更改。

实现方法

下面我们一起来看看如何在Unity中实现该功能。

创建接口

首先创建一个非常简单的接口,让所有需要随时间变化的脚本都实现这个接口:

1
2
3
4
public interface ITimeChanging
{
void AddTime(float dt);
}

定义时间操控实体

这个时间操控实体可以为一组实现了ITimeChanging接口的对象调用这些方法。 实体接口如下:

1
2
3
4
5
6
public class TimeManager
{
float Time { get; set; }
IEnumerable<ITimeChanging> TimeDependants { get; set; }
void SetTimeBruteForce(float time);
}

在编辑器模式中,有一个可以让用户直接在运行模式下控制时间的脚本。如下图所示,你可以看到关卡控制器通过它的Update()来增加时间。

示例项目下载链接:

https://github.com/alexander91/timelineExample

为时间控制添加物体响应

我们设置了一个时间轴管理器,允许跳转到不同的时刻。
以及几个带有线性运动(LineMovement)脚本的立方体,LineMovement脚本继承自ITimeChaning,并监听TimeManager中的时间改变(AddTime),可以在监视器面板设置这个立方体以给定速度朝预定方向移动,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class LineMovement : MonoBehaviour, ITimeChanging
{

[SerializeField]
Vector3 direction = Vector3.up;

[SerializeField]
float speed = 0.2f;

public void AddTime(float dt)
{
transform.position += dt * speed * direction.normalized;
}
}

综上所述,编辑器自定义时间控制工具非常适用于确定性的游戏,这将让整个工作流程分外轻松。 另外,如果游戏中的动作很简单,也可以很容易在编辑器中提供各种选项。

时间倒退

之前我们探讨了Unity中时间控制在关卡创建方面的应用,今天我们将以解谜游戏《Lintrix》为例,继续为大家分享在开发游戏的过程中,如何在Unity中实现时间倒退功能,并且对游戏设计与机制进行深入的探讨,希望能对大家有帮助。

时间倒退功能的必要性

在开发并试玩游戏的过程中,我们认识到玩家往往会忘记在关卡中犯过的错误,所以需要在游戏中做大量的记录。事实上,一些游戏关卡的设计目的就是为了从各个方向分散玩家的注意力,让玩家在某个时刻忽视某个方向来的敌人,最终导致游戏失败。当然,游戏中也有很多选择可以用来帮助玩家回想起发生过的事情,例如展示遇到过敌人的轨迹,或者在玩家之前的游戏过程中角色死亡次数比较多的地方设置警示点。

但有人担心这样做会让屏幕变得混乱,或者让玩家容易重复之前的行为而不去思考新的方式,降低了游戏的趣味性。为了解决这个问题,我们发现利用时间轴复用功能进行提示似乎是个不错的选择。因为每次游戏失败之后,在玩家点击“重新开始”按钮后,以相反的方向显示之前的游戏经历,如下图所示:

从失败那一刻开始,呈现最有可能导致失败的片段

但若要有一个完美的解决方案,应该还要知道之前发生了什么事情,记录下来作为回放。

实现方法

事实上,反向回放要做的工作几乎都在上一篇为关卡设置时间轴时就完成了。而下面将要讲述的方法,虽然可能这不是最优的解决方案,但是这种做法十分简单。

虽然用之前已经实现的后退操作就能完成敌人和水晶的运动。但是关卡中的其它事件是由玩家或者玩家动作触发的。对于这种情况,需要为TimeManager添加新的接口:

1
2
3
4
5
6
7
8
9
10
public delegate void ReversingTimeActionDelegate();
public class TimeManager
{
class ReversingActionWithTime
{
public float Time { get; set; }
public ReversingTimeActionDelegate ActionToCarryOut { get; set; }
}
public void RememberAction(ReversingTimeActionDelegate action)
}

我们引入了ReversingActionWithTime这个类来记录动作的时间以及一个函数,它可以执行反向动作,这个逻辑与编程中的命令模式相似,但它是通过与时间绑定而不是点击ctrl+Z或其他类似的组合键来触发执行的。当关卡运行且动作发生时,只需要把RememberAction反向函数添加到这一时刻,如果想让时间倒退就可以调用这个函数。

例如当有敌人碰到屏障且消失时,就添加这个函数。在之后点击重新开始后,就可以回放到这一时间点了。
这个函数会自动用于再次激活敌人的TimeManager调用,使敌人出现在屏幕上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Enemy
{
void onCollisionWithBarrier()
{
timeManager.RememberAction(Activate);
Deactivate();
}

void Activate()
{
// 进行激活相关的操作
}
//.. 剩下的实现
}

在时间倒退模式中,我们并不关心碰撞,因为这些碰撞已经在游戏正常运行的时候发生了。只需要倒退游戏状态以更快的速度跳过一些帧即可,
这里不需要太强大的设备,因为倒退可以按照正常速度的20倍进行,并且关卡倒退完成就相当于重置所有对象的位置。

但是在某些情况下,也需要将一些变量的值存储在函数中。例如下图中黄色的搬运者敌人,一种在被消灭后会产生细小敌人的角色,它的确切运动需要记录下来,是因为生成的小敌人的运动轨迹会有1秒或2秒的随机延迟。

视觉反馈

为了让玩家更好地注意到时间倒退的发生,要添加一些视觉反馈功能。玩家非常喜欢游戏中时间倒退的部分,特别是引入像移动水晶或搬运者这些高端的东西的时候。时间倒退不仅是一个视觉体验,而且还能让玩家再回顾一次如何败给之前的敌人。这可以给玩家在游戏开始前留一些缓冲时间,可以减少一些挫败感。

当玩家成功地通关之后,我们大概都会做相似的事情。比如把界面切换到游戏地图,而不仅仅是打开下一关卡,播放不同类型的动画并且有专门的地方让玩家查看进度。

例如在上图中,我们把红色的敌人高亮了,因为这是玩家需要看到的最重要的东西,同时,这样还会得到一个很好的视觉效果。我们还添加了时间倒退小图标,虽然最开始我们尝试过类似VHS的时间倒退效果,但这不符合我们的视觉风格。这个简单的效果只需使用灰度就能实现,但要让值大于0.7的红色像素保持不变。

另外,如果想要将这个效果应用到相机而非单独的物体中,我们可以将着色器赋给某个材质,并将下面脚本绑定到相机中:

1
2
3
4
5
6
7
8
9
10
11
12
using UnityEngine;
public class BasicPostEffect : MonoBehaviour
{

[SerializeField]
Material mat;

void OnRenderImage(RenderTexture src, RenderTexture dst)
{
Graphics.Blit(src, dst, mat);
}
}

接着,将新材质赋给相机对应的字段。有趣的事情是添加了这个效果之后,玩家最终可以直观的知道发生了什么事情,并且不再点击屏幕创建屏障了。同样有趣的是,有部分玩家观察到有一个倒退回放只会在十次重新开始后出现,但对这个回放丝毫没有印象。

基于上述的讨论,我们还添加了双击屏幕任何地方就能跳过倒退回放的功能,双击屏幕也算是玩家想跳过的时候最常见的反应了。

延伸思考

这篇教程还可以举一反三,尝试一些其它用法。例如在游戏中,玩家失败后就及时提供指示信息,然后倒退一点时间并让玩家再次挑战。在我们看来,这比让整个关卡重新开始,或在玩家要行动的时候停下来提示的做法要好。还可以倒退回玩家连续多次失败的关节点。

总结

希望您能轻松上手这个功能,实现时间倒退的效果。在下一篇文章中,我们会关注利用了时间轴的游戏设计,了解如何解决一些常见的问题并提升游戏质量。

Unity中的单元测试和错误追踪

本文将由Unity大中华区企业支持经理高川,为大家分享Unity自带的单元测试及性能报告工具。

在游戏开发过程中,Bug的出现是无法避免的。解决Bug的重要前提是及时发现Bug,准确定位Bug。Unity提供了两个优秀的工具来帮助大家完成“杀虫”工作。

Unity Editor Test Runner

在开发过程中,一个Bug被发现的越早,其修复的可能性越高,而修复成本则越低。为了尽早发现问题,避免Bug积累重叠,单元测试就显得尤为重要。

单元测试可以针对特定功能模块进行持续的检测,帮助开发人员尽早发现问题,及时修正。在Unity编辑器中集成了单元测试模块。该模块源自著名的开源工程NUnit,与Unity引擎结合后,可以方便的完成日常开发中的单元测试功能。下面来介绍一下如何使用这个功能。

首先要建立测试用例。测试用例的脚本需要放到Editor 目录下:

在测试用例脚本中引入名字空间NUnit.Framework:

测试用例函数需要一个[Test]属性来标识:

测试用例函数中通过Assert类下面的一系列函数,进行断言测试:

一个测试用例中可以有多个断言,只有所有断言都通过检测,才会认为一个测试用例通过了检测。

写好全部测试用例后,打开Unity Editor的Windows->Editor Tests Runner,在Editor
Tests窗口中我们可以看到测试工具按钮和刚刚写的一系列测试用例:

上面一栏中包含按钮:Run All,Run Selected和Run Failed

- Run All:运行全部测试用例

- Run Selected:运行当前选择的测试用例

- Rerun Failed:重新运行全部失败的测试用例

右侧的四个图标则分别表示:

  • 成功的测试用例数

    - 失败的测试用例数
    
    - 被忽略的测试用例数
    
    - 尚未运行过的测试用例数

下方的树状结构表示具体测试用例当前的状态。

同时Editor Tests提供headless的运行模式,可以很好的与CI&CD等自动化流水线配合。

Game Performance Reporting

Unity提供的另外一个工具是Game Performance
Reporting(GPR)性能报告。这是一个用来做运行时错误追踪的系统。目前市面上也有很多运行时错误追踪系统,但很多在和Unity引擎结合使用中效果并不理想。主要表现在,追踪到的代码可读性差,错误追踪不准确,定位错误等等。结果造成了开发者看到很多错误报告上来,然而并不能解决的尴尬局面。做为Unity原生解决方案,Game
Performance Reporting性能报告系统完美解决了这个问题。下面来简单介绍一下这个系统。

首先改系统整合简单。Game Performance
Reporting性能报告继承了Unity一贯的使用简单的风格,在Unity5.4之前的Unity版本(目前仅支持Unity5.x系统),开发者需要去Unity官网下载一个UnityPackage并导入工程。然后在游戏启动时的某个脚本上加上一句代码:

CrashReporting.Init(““);

其中Project ID是在Unity官网上生成的唯一ID。

在Unity5.4版本中,Game Performance
Reporting系统直接被整合到了编辑器中。开发者只需要在Services窗口中将Performance Reporting的开关打开即可:

当游戏在运行时(测试期或者上线后)出现异常的时候,通过登录Unity开发者页面的Unity Online Services 就可以查看到异常的信息。

堆栈部分,在信息后台是可以看到清晰的异常定位的。这样可以快速的帮助开发者定位到问题所在:

同时Game Performance Reporting提供异常的基本数据统计,包括异常出现的设备,异常出现的时间点,影响到的客户数量以及影响到的版本等等:

通过以上两个工具,在开发期和运行时,开发者都可以很好的管理游戏的Bug数量,评估工程的质量,及时发现和修改问题,从而提供更好的游戏体验给广大玩家。

使用Raycast显示射击轨迹

本教程适合Unity新手或对Raycast不甚了解的开发者,主要介绍Raycast的用途并绘制出射击游戏的射线轨迹。

最终效果

本课程包括鼠标控制相机旋转、射击物体、显示射线轨迹及准星三个部分,最终实现效果如下:

在讲解实现步骤之前,先来了解Raycast的概念。Raycast可以简单理解为游戏场景中由某点发出的隐形射线,它能返回所有被射线射中的游戏对象的详细信息及RaycastHit结构体,RaycastHit结构体包括该游戏对象的Transform引用和射线与游戏对象交点的坐标等等。这里需要注意的是,只有带有碰撞体的游戏对象才能被射线检测到。

想了解更多关于Raycast及RaycastHit的信息,请点击[阅读原文]进入Unity官方中文社区。

另外要注意的是, 在FPS游戏中,射击目标通常都是玩家眼睛朝向的位置,也就是相机正前方的中心点
。所以这里瞄准物体并进行碰撞检测的射线并非我们需要绘制的射击轨迹。

** **

实现步骤

1 准备工作

首先点击[阅读原文]进入Unity官方中文社区,下载本课程所需的工程资源并导入Unity项目中。

找到Let’s Try Shooter >
Scenes文件夹下的ShootingWithRaycasts场景并双击打开。其中已经设置好了本课程所需的游戏环境,FPSController来自Unity自带的Standard
Assets资源包,下面新增了Gun游戏对象:

使用鼠标旋转场景中的相机,手臂会随着鼠标进行旋转。

2 添加射击脚本

下面来添加射击脚本。在Scripts目录下新建C#脚本命名为RaycastShoot,将该脚本拖拽至层级视图的Gun游戏对象上,然后双击脚本进行编辑。RaycastShoot脚本的主要作用是发出射线,射击物体并造成伤害,播放射击音效,显示射击轨迹并等待一段时间后消失。脚本代码如下:

注意,射线应该从玩家眼睛所处位置向眼镜前方射出,玩家眼睛即相机所在位置。

上述代码涉及到还未添加的脚本与游戏对象,下面的步骤将一一说明。

3 添加LineRenderer组件

为Gun游戏对象添加LineRenderer组件,用于在运行时的游戏视图中绘制出射击轨迹,只有子弹发出时才会显示轨迹,所以默认是隐藏的,取消勾选组件名左侧的复选框隐藏组件。另外这里并未指定材质,所以绘制出来的射线会是粉红色的,你也可以自行添加材质。

4 创建GunEnd

这里需要一个空的游戏对象作为枪头处的位置标记,在层级视图中选中Gun游戏对象,右键单击在弹出菜单中选择Create
Empty新建游戏对象,重命名为GunEnd。为GunEnd添加标签以便在场景中显示更明显,然后调整GunEnd的坐标至枪头处,这里设置为(0.36,
-0.18, 1):

将创建好的GunEnd游戏对象拖拽至Gun对象的RaycastShoot脚本的GunEnd字段。

5 添加RayViewer脚本

上面提到了,瞄准物体并进行碰撞检测的射线并非我们需要绘制的射击轨迹。需要绘制的射击轨迹已经在RaycastShoot脚本中完成了,接下来添加RayViewer脚本,使用Debug功能在场景中绘制用于瞄准的射线,也就是从相机位置发出的射线。

新建C#脚本重命名为RayViewer,将脚本拖拽至Gun游戏对象上,双击脚本进行编辑。RayViewer脚本代码如下:

到此射线的处理与绘制就差不多了,运行场景会看到场景视图出现了两条射线:

找到层级视图中Environment >
Targets下,有两个TargetBox对象,选中对象会发现上面已经绑定了ShootableBox脚本。ShootableBox脚本的功能非常简单,CurrentHealth表示对象当前血量,初始总血量为3,脚本代码如下:

在上方的RaycastShoot脚本中已经添加了射击处理的逻辑,当射中立方体时,此时运行场景,已经可以进行射击了,但还是比较难进行瞄准。下面来添加准星。

6 添加UI

在层级视图右键单击,弹出菜单中依次选择UI > Image新建Image,点击Color右边的颜色选取按钮,将Image的颜色设置为红色:

然后点击Rect Transform的锚点设置按钮,按下Alt/Option键同时选择中心点,让准星永远出现在屏幕中心位置:

设置好后再次点击运行,大功告成啦!再看看运行效果:

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×