Unity官方教程 2D Roguelike(6):音乐音效、U

前言
这一节我们主要完成以下内容:
- 添加UI
- 完成切换关卡功能
- 添加背景音乐和音效
本节你将学会什么?
- 对Unity的UI开发有初步了解
- 了解委托函数
- 如何在游戏里添加声音
- 了解params关键字
一、添加界面UI
2D Roguelike 的UI层很简单,一共就两部分:过关时候的界面和Food数值展示。
1.1 添加过关界面和结束UI
刚进入游戏和移动到EXIT进入下一关的时候,都会出现过关界面,元素是一张黑色的背景图和一个显示第几天的标题文本。

游戏结束的时候其实也是用的这个界面,只是更换了文字的内容。
1.1.1 添加黑色背景LevelImage
打开Unity编辑器,添加Canvas。

Canvas画布是承载所有UI元素的区域,所有的UI元素都必须是Canvas的子对象。可以把Canvas看成是UI层的摄像机。

可以看到,在Hierarchy窗口下出现了Canvas和EventSystem(EventSystem主要是负责加工和处理事件,在这个项目用不上)。
我们先添加黑色背景图,Hierarchy窗口下空白处点击右键——UI——Image,可以看到Image自动成为Canvas的子节点。

切换到Scene窗口,用鼠标滚轮缩小到合适的程度以便我们观看(鼠标右键可以移动)。新增的Image默认是在Canvas中间,颜色是白色。我们需要把它填充到和Canvas一样大,并且变成黑色。

在Hierarchy窗口选中Image,点击右侧Inspector的锚点设置(Rect Transform),按住Alt键(如果是Mac电脑则是option键)选择右下角进行填充。

关于锚点设置的相关知识可以阅读此文 Unity进阶技巧 - RectTransform详解
Color选择黑色。

在Scene和Game窗口都可以看到效果。选中Image再左键单击,命名为LevelImage。

1.1.2 添加LevelText
LevelText的作用是显示第几天和游戏结束信息。添加Text的方法和Image是一样的,Hierarchy窗口下空白处点击右键——UI——Text,命名为LevelText。

在背景板上LevelText需要居中对齐,进行文字颜色、大小、字体、文字对齐、默认文字内容等设置。
选中LevelText,点击右侧Inspector的锚点设置,按住Alt键(如果是Mac电脑则是option键)选择中间进行居中对齐。

然后我们把默认文本内容改成“Day 1”,Font选择PressStart2P-Regular(更适合这个游戏的字体),Font Size字号为32,Alignment文字对齐都是居中,Color字体颜色改成白色。

但是我们把视线放回Scene,却发现文本框消失不见了!

这是因为文字字体超出了文本框的大小。因为文本长度不确定,因此我们不能通过拉伸文本框来解决这个问题。选择文本框水平和垂直方向都允许溢出即可。

当控制LevelImage显示或者隐藏的时候我们期待LevelText也是跟随变化,因此我们需要把LevelText变成LevelImage的子对象。拖动LevelText到LevelImage松开即可。

1.1.3 脚本控制LevelImage和LevelText
UI元素添加好了,我们需要在脚本里添加代码来控制这些控件。打开GameController脚本,往里面添加如下代码:

代码简读:
- 在新的关卡渲染之前需要有几秒钟的时间显示过关界面,告诉玩家这是第几天(也可以理解是第几关)。因此新增float类型成员变量levelStartDelay,代表过关界面展示时长(单位为s)。
- 新增GameObject类型的变量levelImage,代表黑色背景的过关界面。
- 声明Text类型的变量levelText,代表显示当前天数的文本控件内的Text组件。需要在头部引用UnityEngine.UI才可以使用Text类。
- 为了防止玩家在过关界面还没消失的时候进行移动,我们需要声明一个布尔值类型变量doingSetup作为开关。
- 修改Update方法,检测当doingSetup为true时,也就是游戏还在渲染关卡,还在展示过关页面的时候,就return,不让怪物进行移动,从而实现玩家也不能移动的效果。
- InitGame方法,把doingSetup开关开起来,然后对levelImage和levelText进行初始化获取对应的游戏对象和组件。levelText上的文本跟随天数更新变化,因此对text文字内容进行赋值,内容为文本“Day ”加上具体的关卡数字。通过调用levelImage的SetActive方法把过关界面显示出来之后,再使用Invoke方法来调用HideLevelImage方法执行隐藏过关页面操作。
Invoke()可以实现根据时间调用指定的方法,延时加载界面,这样视觉上过渡更加平滑,不突兀。
- 新增HideLevelImage(),调用levelImage的SetActive()隐藏过关页面,关闭doingSetup开关,以便让玩家可以进行移动。
保存代码,回到Unity编辑器,运行游戏试试。

通过这次测试能发现有两个bug:
- 第一天是Day 4,正确应该是Day 1。
- 移动到Exit格子进入下一关时,依然显示是“Day 1”(LevelText的默认文本),并没有一直往上添加。
1.1.4 处理“下一关”
第一个问题,由于之前在GameController脚本声明level的时候,为了方便测试把它赋值为4,现在改成1就可以了。

第二个问题,进入下一关之后程序需要实现两个功能:level+1、调用initGame()渲染生成新关卡。众所周知,GameController在重新加载场景的时候是不摧毁的(单例模式),因此导致了Awake()没有再被调用,所以InitGame()也没再次被调用来生成新关卡地图。那么,需要寻找其他的方式来实现新关卡的渲染。
原教程视频里的方法OnLevelWasLoaded()已经被废弃,现在的版本提供SceneManager.sceneLoaded这个委托函数来做更灵活的调用。
我们在GameController加入以下代码:

- 这段代码的意思是,第一次运行GameContorller脚本的时候,在Start方法内注册委托函数SceneManager.sceneLoaded,当发生场景重新加载的事件后,LevelWasLoaded方法就会收到通知并执行。
被委托的方法需要按照一定的格式,比如对sceneLoaded来说,两个参数类型必须是Scene和LoadSceneMode;不过方法名是可以自定义的。

添加以上代码之后,游戏角色进入下一关的天数显示和关卡渲染都正常了。
1.1.5 添加游戏结束提示
游戏角色死亡的时候需要弹出相应的提醒。我们在处理游戏结束的方法GameOver里添加以下代码:

把文案改成“After N days,you starved.”,我以前写的died,后来发现这货是饿死的。
然后把黑色背景图激活显示即可。

1.2 添加Food值显示和变动UI
在游戏的过程中,玩家需要实时看到Food的当前数值并且获得Food值变化的反馈,以便他采取合适的策略通过关卡。

如上图所示,我们只需要添加一个文本控件在界面底部,在里面用文字显示Food的数量和变化即可。
1.2.1 添加FoodText
回到编辑器,Hierarchy窗口下空白处点击右键——UI——Text,命名为FoodText。

FoodText需要对齐底部中间,并且和前面的LevelText一样进行文字颜色、大小、字体、文字对齐、默认文字内容等设置。
选中FoodText,点击右侧Inspector的锚点设置,按住Alt键(如果是Mac电脑则是option键)选择bottom center 交点进行底部中间对齐。

然后我们把默认文本内容改成“Food:100”,Font选择PressStart2P-Regular,Font Size字号为24,Alignment文字对齐都是居中,Color字体颜色改成白色,文本框水平和垂直方向都允许溢出。

为了更美观,我们把FoodText往上移动少许,不要紧贴底边。要实现这个效果,我们先来看看现在锚点的位置。

可以看见锚点的位置是在底边中间,而文本框FoodText的中心点与这个锚点的垂直距离是15,水平距离为0代表两个点的X坐标一样。
把Anchors的Min和Max的Y坐标都改成0.05(相对Canvas画布高度的比例值)。

可以看到,改动之后,这个散射菊花状的锚点就上移了,FoodText中心点与它的垂直距离从15变成了-13。把-13改成0,FoodText就会跟着上移了。

此外要注意,显示天数或者游戏结束页面的时候,我们不希望玩家还能看到Food的数量,也就是说FoodText层级要比LevelImage和它的子节点LevelText低,这样才能被遮盖。
鼠标拖拽FoodText移动到LevelImage上即可。

UI元素绘制顺序和在Hierarchy(层级视图)中的顺序一致,后面的将在更早的上面绘制。
1.2.2 脚本控制FoodText内容
打开GameController脚本,往里面添加如下代码:

- 因为要使用Text类型,所以在头部引用了UnityEngine.UI。
- 声明Text类型的变量foodText,指的是FoodText这个GameObject上的Text组件,在Start()对该组件的text也就是文本内容进行了赋值。
然后我们在food值产生改变的地方都添加相应的代码来改变文本显示。一共三个地方哦,别漏了。



保存代码,切换到Unity编辑器,把FoodText拖到Player的Player Script的FoodText选项框里。

开测!

完美~除了字体没有做屏幕适配有点遗憾,不过这个项目教程的重点也不是在这。
二、添加背景音乐和音效
游戏中一般会存在两种音乐:时间较长并且循环播放的背景音乐、时间较短的各种音效(战斗、移动、拾取等)。
2.1 实现背景音乐添加和设置
2.1.1 添加背景音乐
首先需要创建一个Game Object,用来管理所有的音频资源。Hierarchy窗口下空白处右键 - > Create Empty,创建空白的游戏对象之后命名为SoundManager并且给它添加一个Audio Source组件来控制背景音乐的播放。

Audio Source是控制一个指定音乐播放的组件,可以通过属性设置来控制音乐的一些效果。
在组件里的AudioClip选取scavengers_music(背景音乐),勾选Play On Awake和Loop。

属性说明:
- Audio Clip:要播放的声音片段。
- Play On Awake:生成游戏对象之后自动播放。
- Loop:是否循环播放。
也就是说,我们选定了背景音乐,让它加载场景之后就自动播放并且不断循环。看起来没问题了,我们运行游戏测试一下。
(gif不能播放音乐,假装你们已经测试了)
这游戏背景音乐很带感,但是我们会发现切换关卡的时候会出现一个很别扭的事情:音乐从头开始播放了。这是因为切换场景的时候SoundManager会自动销毁并且重新生成,音乐就会关闭然后再次播放。和GameController一样,在同一时间只需要一个SoundManager来控制全部音乐的播放(多个的话就会混乱),因此也要设定为单例模式。
2.1.2 SoundManager实现单例模式
实现单例模式的方法我们在第二章生成关卡中的时候已经提过。在Scripts文件夹创建一个C# Script脚本文件,命名为SoundManager,在里面输入以下代码。

切换到Unity编辑器,把SoundManager脚本挂载到SoundManager游戏对象,然后再次运行游戏测试。

(gif不能播放音乐,假装你们再次测试了)
可以听到音乐正常循环播放并且切换关卡的时候也不会中断了。接下来我们继续加油给游戏添加音效吧!

2.2 添加音效
2D Roguelike游戏里有几种音效:角色移动、角色进食、角色死亡游戏结束、怪物攻击、角色劈墙的音效等。长期单调重复的音效会让人疲倦厌烦,因此可以在音效数量和音效音高上做出一些随机和变化,让人产生一种新鲜感。
2.2.1 添加移动、吃、喝、死亡音效
首先我们在SoundManager游戏对象上再添加一个Audio Source组件,用来控制各种音效的播放。Play On Awake和Loop记得取消勾选,音效不需要自动播放也不需要循环。

打开SoundManager脚本,加入以下代码。

代码简读:
- 新增AudioSource类型的变量efxSource,代表刚添加的控制音效播放的第二个Audio Source。
- 为了给音高增加一些随机变化,声明两个float类型的变量lowPitchRange和highPitchRange并进行赋值,代表音高的上下限。这个随机区间不大,分别是初始音高加减5%。这样听起来音效会有轻微的不同没那么单调,但又不至于变化太大而觉得不舒服。
- 新增公共方法PlaySingle(),把传入的音乐片段作为efeSource的clip并且调用Play()进行播放。
- 新增公共方法RandomizeSfx(),它的参数是AudioClip类型的数组,也就是N个音乐片段。通过Random.Range()分别随机一个数组下标和音高,然后再分别赋给efeSource的clip和pitch作为要播放的音乐片段和音高,然后调用Play()进行播放。使用这个方法就可以每次在音乐片段数组里随机选择一个进行播放并且是不同的音高,减轻了同一个音效反复播放的单调感。
方法使用params关键字可以指定数目可变的参数,调用的时候传入指定类型的逗号分隔的参数数组。
方法都有了,在什么情况下进行播放,在哪里进行调用呢?
打开Player脚本,新增7个AudioClip类型的成员变量代表移动、吃东西、喝东西、游戏结束的音效片段,其中游戏结束的音效只播放一次,所以不需要做2个来减轻单调感。

然后在不同的事件处分别调用SoundManager的方法播放音效。
【角色移动】:AttempMove()内,当角色移动的时候,使用if语句判断如果Move()返回的是true,代表可以移动,则调用SoundManager的RandomizeSfx()从两个移动音效里选取一个来进行播放。

【角色吃喝】:OnTriggerEnter2D()内,当角色碰撞到Food或者Soda的时候,调用SoundManager的RandomizeSfx()从对应的两个音效里选取一个来进行播放。

【游戏结束】:CheckIfGameOver()内,当food小于等于0,代表角色饿死了游戏要结束,则调用SoundManager的PlaySingle()播放gameOverSound。

保存代码,回到Unity编辑器,选中SoundManager游戏对象,把控制音效的Audio Source拖入到Sound Manager组件的Efx Source选项。

选中Player游戏对象,在Player脚本组件里点击新增7项的最右边小圆点选取对应的音效片段。

运行游戏测试。
(gif不能播放音乐,假装你们测试了)
我们会发现一个问题,游戏角色饿死了弹出了结束面板并且还播放了对应的音效,但是游戏背景音乐没有关闭!这会给玩家一种很诡异的感觉,我们赶紧来修复吧!
打开SoundManager脚本,新增AudioSource类型的成员变量musicSource,代表控制背景音乐播放的Audio Source。

打开Player脚本,在检测游戏结束的方法CheckIfGameOver()里调用SoundManager的musicSource,执行它的Stop方法来关闭音乐播放。

保存代码,回到Unity编辑器,选中SoundManager游戏对象,把控制背景音乐的Audio Source拖入到Sound Manager组件的Music Source选项。

运行游戏,现在就正常了!接下来我们别忘了添加被怪物攻击角色发出的惨叫音效和角色砸墙的音效哦!

2.2.2 添加角色被攻击和劈墙音效
打开Enemy脚本,新增AudioClip类型的成员变量attackSound1和attackSound2代表被打音效,然后在OnCantMove()触发攻击特效之后,调用SoundManager的RandomizeSfx()从attackSound1和attackSound2里选取一个来进行播放。

打开Wall脚本,新增AudioClip类型的成员变量chopSound1和chopSound2代表劈墙音效,然后在DamageWall()扣除墙体生命之后,调用SoundManager的RandomizeSfx()从chopSound1和chopSound2里选取一个来进行播放。

保存代码,回到Unity编辑器,打开Prefabs文件夹,同时选中Enemy1和Enemy2预制件,在右侧Enemy脚本组件里的Attack Sound选择对应的音效。

同时选中Wall1-8预制件,在右侧Wall脚本组件里的Chop Sound选择对应的音效。

运行游戏测试~

三、本地发布游戏
游戏制作完毕,可以发布游戏到本地了。保存场景,通过菜单栏File-->Build Settings打开发布页面。

- Platform选择第一项,PC,Mac&Linux Standalone
- 点击Add Open Scenes按钮,把当前场景添加进去并且勾选
- 点击Build按钮,选择存放路径,就可以完成发布了
找到保存路径下的exe文件双击运行就可以开始玩生存游戏了。
四、完篇
细心的朋友还会发现有一部分内容没提到:手机或者平板触摸控制。主要是以下几个原因:
- 原方法实现出来的效果是,手指像玩切水果游戏那样滑动,游戏角色才能前进。这种方法对这种游戏来说个人觉得不合适。
- 教程是很早以前的了,里面的部分代码在现在的Unity版本失效了。
- 原方法比较粗糙,懒得研究。
【结束感言】
此次教程搬运从第一篇到最后一篇,历时一共1年3个月11天。拖得时间越长,越难开始。对于那些看到一半又陷入漫长等待的童鞋们说声抱歉~
在写文章的过程中,因为需要输出知识,所以要反复的去研究原视频和游戏本身,因此也收获良多,很多本来一知半解的内容也琢磨地更清楚。所以如果有余力的话,建议在学习Unity的过程中把自己掌握的知识内容整理一遍输出成文章,利己利人。
学习之路漫漫。
