1. 准备场景
下载并导入官方Space Shooter教程的范例素材包,我们仅使用其中的模型、材质、贴图、Prefab(里面有粒子特效)。
首先设置Player。
将Models
文件夹中的vehicle_playerShip
放入场景,断开Prefab联接,改名为Player
。然后为Player添加一个Mesh Collider
,并指定Modes
文件夹中的vehicle_playerShip_collider
为其Mesh
。勾选上Convex
选项。
这是因为飞船模型比较复杂,不适合直接拿来当做Collider,所以专门制作一个简化版的Collider模型。而使用
Mesh Collider
必须要勾选上Convex
选项,否则就不会计算碰撞效果。
把Prefabs
> VFX
> Engines
文件夹中的engines_player
拖动到场景中的Player内部,这是预制的引擎火焰粒子特效。
然后设置摄影机。
这个范例中使用的是正交摄影机(设置Projection
为Orthographic
),不使用天空球,背景设置为纯黑(设置Clear Flags
为Solid Color
,然后指定为黑色)。将摄影机调整为垂直向下,根据Player在摄像机中的大小调整合适的Size
值。
正交摄影机的视角范围是固定的,不根据其高度变化而变化,只能通过
Size
来调节。
接下来设置背景。
新建一个Quad物体,重命名为BG
,将星空贴图赋给BG
,Unity会自动创建一个材质球。因为星空背景不需要受到光照作用,所以我们修改这个材质球的Shader为Unlit/Texture
。放大BG
到合适大小,降低其Y轴位移避免挡住Player。
最后设置灯光。
原教程中使用了三点光照法来设置光照,我个人觉得没什么必要,效果有限。
大家可以自行设置场景光照效果,需要注意的几点是:
- 这个场景不需要全局光照,也不需要环境光,这两块都要去Lighting面板中去取消(菜单
Window
>Lighting
>Settings
); - Player上的两个材质的高光范围都很大,且Player物体在摄影机中又挺小的,所以在平行光照射下很容易被 “洗白”,大家可以适当增大
Smoothness
值减少高光范围; - 灯光可以无需投射阴影;
- 建议使用Forward渲染路径来渲染这个场景,系统资源占用会比较少。
原版教程中是设置成了600×960分辨率的纵版游戏画面,我懒得新建项目,所以就直接做成4:3的横版画面了。
2. 添加Player运动控制
给Player
添加上Rigidbody
组件,我们还是使用Set Velocity
的方式来控制其运动。因为是在太空中运动,所以无需考虑重力,不勾选Use Gravity
。
为Player
添加Fsm,在State 1
中依次添加Get Axis Vector
、Vector3 Normalize
、Vector3 Multiply
、Set Velocity
四个行为。并按下图设置好相关的变量。
最基本的运动控制,只需要
Get Axis Vector
和Set Velocity
两个行为就可以完成了,但其实是不准确的,因为角色沿着斜角运动速度会比较快(比如向左上方运动时,左和上的向量长度都是1,但综合起来的左上方向量长度大概是1.2)。所以这次我添加Vector3 Normalize
来将input axis
标准化到向量长度始终为1,然后再用speed
变量将其放大为速度向量值。
飞船的倾斜控制
飞机在左右摆动时机身会有一定的倾斜效果,下面我们来制作这个效果。
在如下图所示的位置依次添加Get Vector3 XYZ
、Float Multiply
、Set Rotation
三个行为。(被折叠起来的Actions代表之前就已经有了的行为)
-
Get Vector3 XYZ
将最原始的input axis
的X值单独储存为input x
变量,我们需要这个数值来控制Player在Z轴上的自身旋转; -
Float Multiply
将input x
值放大(原始的input x
最大才为1,作为旋转量肯定是不够的),并指定一个max tilt angle
变量作为Multiply By
参数的值(初始设置为30); -
Set Rotation
设置Player沿自身Z轴旋转的程度(Space
参数设置为Self
),这时候的input x
值已经被放大了30倍了。
要注意,
Set Rotation
行为和Rotate
行为是不一样的,Set Rotation
是设置目标的旋转属性,而Rotate
主要用来让目标持续旋转。
在Variables面板中,记得将speed
变量和max tilt angle
变量都公开到Inspector中,并设置好其数值。
新手常常会犯的错误是新建了变量却忘记赋值。比如这里的如果我们在
Float Multiply
行为中新建speed
变量时忘记将其初始值设置成5的话,speed
变量的初始值就默认为0,然后被其“放大”后的速度向量也就变成了0,调试的时候飞船根本就不会动。
经过调试发现,max tilt angle
数值为正的时候,Player倾斜的角度和我们预想的正好是反的,方便起见,我就直接将max tilt angle
值设置成负数了,否则就需要添加一个Action专门把这个值变成负数。
限制Player移动范围
我们不希望Player移动到星空背景以外的区域去,所以需要限制Player的移动范围。解决办法是直接限制其X方向和Z方向的位置,也就是使用Clamp。
在如下图所示的位置依次添加Get Position
、Float Clamp
、Float Clamp
、Set Position
四个Action:
-
Get Position
行为获得Player的当前位置,并不储存为Vector3类型的变量,而是分别将X轴位移和Z轴位移储存为player position x
和player position z
两个Float类型的变量; - 两个
Float Clamp
行为分别限制player position x
和player position z
的数值,使其不超过设定好的最大最小值,这个最大最小值可以手动从场景中获得; -
Set Position
行为将被限制(Clamp)以后的player position x
和player position z
设置给Player的位移参数,Y方向参数始终保持为0。
每次用PlayMaker做数学计算我都非常非常的蛋疼,明明一句话就能说明白的事情,却非要折腾出一堆Action来,也许这就是“可视化”的代价吧。
这样设置的原理是,虽然Velocity会驱使物体改变其自身位置,但我们强行用直接设置物体位置参数的方式把错误的位置给纠正了过来,于是物体不可以跑出限制区域。
如果不愿意这么麻烦,最简单的办法是在场景中设置4面空气墙(有碰撞,无渲染)。
3. 让Player发射子弹
创建子弹prefab
在场景中新建一个Quad,命名为VFX
,另外新建一个空物体,命名为Bolt
,两个物体都重置位置属性,然后将VFX
放在Bolt
里面。
删掉VFX
的Collider,将Textures
文件夹中的fx_lazer_orange_dff
赋给VFX
,Unity会自动新建材质球,修改材质球的Shader为Mobile/Particles/Additive
。
Mobile类的Shader系统资源占用最少,而Additive类型的Shader,其颜色会与下层颜色相加,适合于用来制作亮光物体,比如这里的激光子弹。
在Bolt
上添加Rigidbody
,取消Use Gravity
选项。
添加Capsule Collider
,勾选Is Trigger
选项,并修改其形状以适合VFX
的子弹形状(设置Radius
为0.03,Height
为0.5,Direction
为Z-Axis
)
结果如图:
给Bolt
添加Fsm,使用Set Velocity
设置其出生就沿着自身(Self
)坐标系的Z轴方向以speed
= 10的速度飞行。
Bolt
的Fsm和Player
的Fsm中我都设置了speed
变量,但这两个speed
变量之间是没有任何关系的,因为它们属于不同的Fsm。
最后,将Bolt
做成prefab。
按下“开火”键连续发射子弹
首先在Player
内部创建一个空物体,命名为Shot Spawn
,放置到合适位置,用来在此位置发射子弹。
空物体在场景中是看不到的,可以改变其默认显示图标,使其在场景视图中可见。
虽然目前用来控制Player运动的Fsm非常简单,但我依然不希望在这个Fsm中进行关于射击的交互制作,所以我添加一个新的Fsm给Player。
希望我懒得给Fsm以及State命名的坏习惯不会影响到大家。:)
发射子弹的制作原理并不困难,因为之前在Bolt
上已经制作了“出生即飞行”的行为,所以这里只需要在“枪口位置”生成子弹就可以了。但因为是纵版射击游戏,如果开火一次只能发射一颗子弹的话就太累了,而且还需要控制两颗子弹之间的发射间隔,不能让手速高的玩家人为制造弹幕。所以这个“开火”的基本逻辑我设计为:按下“开火”键即开始发射子弹,每隔0.5秒发射一颗,松开“开火”键停止发射子弹,但再次“开火”依然需要比之前最后一枚子弹滞后同样的时间间隔。
这里需要3个不同的状态来达到此目的:State 1
检测“开火”键是否被按下;State 2
中发射1枚子弹,发射完毕等待0.5秒;State 3
中暂停0.5秒,暂停完毕后根据“开火”键是否还被按下来选择是继续发射子弹(返回State 2)还是等待下一次开火(返回State 1)。
State 1:使用Get Button Down
行为来检测Fire1这个Button是否被按下了,如果是,则触发Firing
事件。
默认的Input设置中,
Fire1
等同于鼠标左键,以及左Ctrl
键。
State 2:使用Create Object
行为在Shot Spawn
所在位置创建一个Bolt
预设物体的实例,使用Wait
行为等待fire duration
变量中所设置的时间(公开到Inspector,预设值0.5),等待结束触发FINISHED
事件。
State 3:使用Get Button
行为检查Fire1
按钮是否处于被按下状态,将结果储存在Is Firing
变量(Bool类型)中,然后使用Bool Test
行为检查Is Firing
变量的值,如果为True
则触发Keep Firing
事件,如果为False
则触发Stop Firing
事件。
一些必要的解释:
Wait
行为放在State 2
中而不是放在State 3
中是迫使每次发射完一颗子弹都必须休息0.5s才能发射下一颗。如果放在State 3
中与“开火”按键判定在一起,则会由于Bool Test
达成条件而导致未能休息够0.5秒就跳回State 1
,这样玩家可以通过快速点击“开火”键而达到无视发射间隔的目的,所以必须等到发射间隔之后再行判断;State 3
中使用Get Button
而不使用Get Button Down
,是因为我们需要在“按下”和“没按下”两种状态中分别触发两个事件,而Get Button Down
仅能在“按下”时触发一个事件。当然我们也可以把Get Button Down
当做Get Button
来使用,但Get Button Down
是一个默认每帧执行的行为,而Get Button
可以只执行一次,这里仅需要判定一次,所以使用Get Button
更合适;- 在设计PlayMaker行为逻辑的时候,“每帧执行(
Every Frame
)”的行为和“单次执行”的行为是混在一起使用的,这是与编写脚本非常非常不同的一个特征。所以我们要时刻注意哪些行为是必须要每帧执行的,哪些行为是可以单次执行的,同时尽量设计逻辑减少“每帧执行”行为的使用以提高执行效率。
让子弹自动销毁
每次发射的子弹都会保留在场景中不断前进,这对于系统资源来说是不必要的浪费。所以我们希望子弹能够自动销毁。我们可以选择多种方案自动销毁子弹,比如让其在一定时间之后销毁,或者如同本范例中所选择的让其超出某个边界之后消失。
首先创建一个边界物体,比如一个大Cube。
这个Cube表示游戏场景的边界,但我们不需要它实际可见。所以取消掉Mesh Renderer
组件(或者干脆删除这个组件),并勾选上其Box Collider
组件中的Is Trigger
选项(不勾的话,这个Cube内部的非Trigger碰撞体就可能会与其发生非常诡异的碰撞)。
最终这个Cube在场景中显示如下图:
给Cube
添加Fsm。State 1
中检测是否有Trigger离开,如果有,则跳转到State 2
中删除该Trigger。
State 1
:添加Trigger Event
行为,设置只要有Trigger物体离开该Cube的碰撞体,就触发Trigger Leaving
事件,并将离开的Trigger物体储存在leaving trigger
变量中。
State 2
:添加Destroy Object
行为,删除leaving trigger
变量中所储存的游戏物体。
注意,是使用
Trigger Event
还是使用Collision Event
取决于需要监测的对象的碰撞体是否被设置成了Trigger,而不取决于本体的碰撞体是否被设置成了Trigger。也就是说,不论这里的Cube是不是一个Trigger,它都可以使用Trigger Event
来监测是否有其他Trigger进入或者离开,也都可以用Collision Event
来监测是否有其他Collider进入或者离开。按我们现在的设置,Cube
是可以监测Bolt
的,因为Bolt
是一个Trigger,但Cube
并不能监测Player
,因为我们的Player
并没有勾上Is Trigger
选项,依然是普通Collider,如果需要Cube
同样监测Player
,则必须使用Collision Event
行为。
4. 陨石
制作陨石物体
新建一个空物体,重命名为“Asteroid”,将Models
文件夹中的prop_asteroid_01
拖到Asteroid
中,重置它们的位移属性。
为Asteroid
添加一个Capsule Collider
,设置其大小,使其正好包裹住陨石物体。然后再为Asteroid
添加一个Rigidbody
,取消Use Gravity
选项。
通常,我们都会将交互设置做在一个空物体节点上,然后其内部再放置视觉物体,这是一个基本的操作规范。
预设的Asteroid在材质上也有同样的问题,让我们修改Smoothness
为0.8。
制作陨石自旋
原教程中的陨石自旋使用的是随机指定一个旋转向量,放大后设置其为陨石的Angular Velocity的方式,在PlayMaker中完全重现这个方法是可行的,就是稍显麻烦了一点。
给Asteroid
添加一个Fsm。
在State 1
中依次添加如下行为,并设置相应的变量:
- 我们先使用一个
Set Random Rotation
行为给陨石添加一个随机的旋转; - 然后使用
Transform Direction
行为获得这个陨石正方向(Z轴正方向)所指方向在世界坐标中的向量值,并储存为rotate vector
变量; - 然后使用
Set Set Random Rotation
行为再次随机旋转陨石,让其初始旋转方向与自旋方向并不完全一致; - 接下来使用
Vector3 Multiply
行为放大rotate vector
变量,将其倍乘上rotate speed
变量中所设定的数值(公开到Inspector中,预设为5); - 再然后添加一个
Add Torque
行为给陨石施加一个旋转动力,设置Vector
参数为rotate vector
变量(旋转动力向量),设置Space
参数为World
,设置Force Mode
参数为Velocity Change
; - 最后使用一个
Add Force
行为,让陨石出生即受到一个沿着Z轴方向,大小为speed
变量(Float类型,公开到Inspector中),且设置Force Mode
参数为Velocity Change
,使其成为一个改变目标速度的推力。
一些解释:
- 也许会有更好的办法获得一个随机的扭力矢量,我这里用的办法并不是特别简便;
Add Torque
和Add Force
很类似,但一个是给物体添加“扭力”,一个是给物体添加“推力”,将其模式设置成Velocity Change
就基本上等同于Set Angular Velocity(PlayMaker中并没有这个Action)和Set Velocity
了;- 最后一个
Add Force
完全可以用Set Velocity
来替代,更为直观。我这里使用Add Force
完全是为了跟Add Torque
作对比。
在Inspector中设置两个公开了的变量初始值,rotate speed
= 5;speed
= -1(因为陨石应该往Z轴负方向运动)。
子弹与陨石的交互
简化起见,我们设置陨石会被子弹一击击毁。
为陨石添加一个新Fsm来设置其与子弹的交互。我选择在陨石而不在子弹上添加这个Fsm是因为一般来讲,场景中的子弹当然多过场景中的陨石,交互放在陨石上运行效率会高一点点。
基本的交互逻辑是:陨石检测到有子弹物体进入其碰撞体,就自动销毁。
State 1
:添加Trigger Event
行为(因为子弹是Trigger嘛),设置为On Trigger Enter
(当有Trigger进入陨石碰撞体时),触发Hit a Trigger
事件,并储存Trigger物体到trigger
变量。
State 2
:添加Destroy Self
行为,删除自身。为了检查交互逻辑是否正确,我还在Destroy Self
之前添加了一个Get Name
行为,以获得trigger
变量中所储存物体的名称,并紧接一个Debug Log
行为,让这个名称在系统控制台(Console面板)中被打印出来。
运行场景,一开始陨石就消失了,明显有问题。而且Console面板中并没有任何信息被输出。
PlayMaker有时候使用Debug Log
行为输出信息给Unity控制台会出现一些奇怪的问题,尤其是Info
级别的信息,不知道什么时候就不输出了。所以我将State 2
中的Actions修改一下以便来找出问题所在(Debug):
- 修改
Debug Log
的Log Level
为Warning
,这样就保证一定会输出给Unity控制台; - 取消
Destroy Self
行为(别删除,以后还要用的),让陨石不会消失,以便在运行状态中选择陨石查看其状态。
再次运行,发现陨石是检测到了Cube
这个Trigger,于是跳转到State 2
了,Console里也正确显示了警告信息。
Cube
是我们的边界物体,不可能被删除掉,所以需要在Trigger Event
中排除掉对Cube
的检测。这里可以利用Trigger Event
的Collider Tag
参数。
Collider Tag
参数可以指定该行为仅针对具有特定标签(Tag)的碰撞体执行,默认设置为Untagged
的意思是对所有没有打标签的碰撞体都执行操作,Untagged
代表“没有任何标签”**。
我们可以给Cube
物体添加一个叫做“Boundary”的标签,系统默认并没有一个叫做“Boundary”的标签,于是我们可以新建一个:
顺便我们还可以把Player
场景物体和Bolt
预设物体都分别打上Player
标签和Bolt
标签。
但现在还是有点小麻烦,我们希望Trigger Event
排除掉有Boundary
标签的物体,但Trigger Event
只能设置为“仅针对某一个标签有效”,所以我们不得不使用多个Trigger Event
来分别处理不同的标签。
这里有一个小错误,其实第一个
Trigger Event
并不会起作用,因为Player
物体本身并不是一个Trigger,虽然给了标签,但并不会被检测到,也不会被陨石所“消灭”。这个错误不是很致命,就暂时先留在这里好了。
运行场景,问题解决!但新问题出现了,我们的子弹穿过了陨石继续前进。这在一定程度上是合理的,激光子弹能量足够强的话是可以打穿物体的,但我们并不希望这样,我们还是希望“一发子弹只消灭一个敌人”,所以在State 2
中再添加一个Destroy Object
行为,让它也销毁掉子弹物体(变量trigger
)就好了。
如果勾选
Detach Children
选项,Destroy Object
行为将仅销毁顶端节点(父节点),而保留所有子物体,大家别把Detach看成Destroy了。
科普一下Unity中对于“销毁物体(Destroy Object)”的设定:当Unity执行Destroy命令销毁某个游戏物体时,它并非立即从场景中删除该物体,而是将该物体标记为“需要被删除”,直到系统刷新到下一帧画面的时候,才将所有标记为“需要被删除”的游戏物体一次性删掉。
下面我们将Asteroid
做成Prefab,再复制多个,放在场景顶端。运行场景,所有的陨石都会一边自旋一边匀速下落,且能够被子弹摧毁。
别忘了可以在Inspector中设置陨石的自旋速度以及下落速度。
添加爆炸效果
在Unity中可以制作物体爆裂开的效果,但在我们这个例子中就没有这个必要了,我们直接使用一个爆炸粒子特效来代替就好了。
我们下载的教程工程文件包中就提供了陨石的爆炸粒子特效,我们只需要在陨石爆炸时,在陨石所在位置创建这个粒子特效就可以了。
由于我们已经将Asteroid
做成了prefab,所以在选择场景中的Asteroid
实例时,PlayMaker会询问我们是在实例上做修改(Edit Instance
按钮)还是在预设物体(Edit Prefab
按钮)上做修改,我们选择Edit Prefab
:
在FSM 2
的State 2
中插入一个Create Object
行为,选择explosion_asteroid
预设物体作为其Game Object
参数的取值。
注意,导入教程工程文件包以后,会有很多预设,大家不要选错了,尤其是不要去选那个
done_explosion_asteroid
预设物体。
运行场景,发现爆炸效果虽然出现了,但位置好像有所偏差,不是在所击中的陨石中心,而是在坐标原点位置,这是因为我们忘记指定新建物体的位置了。
在Create Object
行为之前添加一个Get Position
行为,获取自身位置为变量position
,然后将position
赋给Get Position
行为的Position
参数。
问题解决!
仔细观察运行场景,爆炸结束后,爆炸粒子物体的实例依然保留在场景中(虽然已经不可见了),这是对于系统资源的浪费,要坚决予以制止!
为了不破坏教程工程文件包中资源的纯洁性(完全没必要),我把explosion_asteroid
预设物体复制了一份,重新命名为my_explosion_asteroid
,重新将其指定给Asteroid
预设物体的FSM 2
的State 2
的Create Object
行为的Game Object
参数。
然后在my_explosion_asteroid
预设物体上添加Fsm,使其出生后等待2秒(使用Wait
行为),然后跳转到State 2
中销毁自身(使用Destroy Self
行为)。
选择2秒是因为这个粒子特效的
Duration
值就是2秒钟,方便起见,我就直接输入这个数值了,当然最标准的做法还是新建一个变量(lifespan
)来储存这个数值并公开到Inspector比较好。
陨石与飞船的碰撞
方便起见,还是把
Player
的碰撞体也设置成Trigger吧。
陨石与飞船的碰撞和陨石与子弹的碰撞不同之处在于,撞上Player
之后需要生成两个爆炸效果,一个是陨石的,一个是飞船的。
修改Asteroid
预设物体上的FSM 2
,让两个Trigger Event
分别触发Hit Bolt
事件和Hit Player
事件,并分别设置事件跳转到State 2
和State 3
中。
可以直接在Events面板中将原来的
Hit a Trigger
事件改名为Hit Bolt
,然后再新建一个Hit Player
事件。
State 3
是陨石碰撞到Player
之后所处于的状态,其中大部分Actions都和State 2
中的一样,可以直接从State 2
中复制过来,需要做的修改是添加一个Get Position
行为以获得Player
物体(保存在trigger
变量中)的位置并储存于target position
变量中,以及添加一个Create Object
行为在Player
所处位置(target position
变量值)创建一个飞船爆炸粒子效果预设(explosion_player
预设)。
这里我暂时取消了删除
Player
的行为,用来查看新爆炸位置是否正确。
运行场景,没有什么问题。现在可以激活删除Player
的行为,并删除场景中的陨石物体了,真正的游戏进程中的陨石物体会使用PlayMaker来自动生成。
5. 游戏控制(Game Controller)
下面我们来制作控制游戏的交互逻辑。
基本版游戏流程是:游戏开始后等待1秒钟,然后每隔0.5秒随机在屏幕上方生成一个陨石,10个陨石一波,每波陨石之间休息3秒。
在场景中新建一个空物体,命名为Game Controller
,重置位置。
在Game Controller
上建立Fsm,我们先制作“不断在屏幕上方生成陨石”的交互。
State 1
:
- 使用
Random Float
获得一个随机的spawn position x
变量,其最大最小值分别指定使用变量min spawn x
和max spawn x
。这两个变量都公开到Inspection,设置其初始值为-5.3和5.3(这个数值根据手动测量得到); - 使用
Set Vector3 XYZ
获得一个位置变量spawn position
(选择Set Vector3 XYZ
是因为其允许用户分别设置X、Y、Z值),然后设置X取值为变量spawn position x
,设置Y取值为0,设置Z取值为变量spawn position z
(公开到Inspector,设置初始值为9); - 在最后添加一个
Create Object
行为,指定Game Object
参数为变量rock object
(变量rock object
公开到Inspector,设置初始值为我们之前制作的Asteroid
预设物体),创建位置(Position
)为变量spawn position
。
State 2
:
- 添加一个
Wait
行为,设置其等待时间为新建变量spawn duration
(公开到Inspection,初始值为0.5),等待结束触发FINISHED
事件,并勾选Real Time
选项。
看起来我们使用了很多很多变量,这是为了能够在Inspector中对这些数值进行调整,而无需进到Fsm中去寻找特定状态的特定行为。在我们这个非常简单的例子里,这么做似乎挺繁琐的,但如果日后需要对这个项目进行修改或改进,就会感受到尽可能变量化的优势所在了。
接下来制作“游戏开始时,等待1秒钟”的交互行为。
在Game Controller的Fsm中添加一个State 3
并设置为Start State,在State 3
中使用Wait
行为让其等待1秒钟(新建变量start wait time
,公开到Inspector,初始值1),等待结束触发FINISHED
事件跳转到State 1。
到目前为止,公开到Inspector的变量及其取值如下图所示:
运行场景,我们可以得到如下图所示密集的陨石下落效果。
继续对Fsm进行改造,让每生成10个陨石,就休息3秒钟。
在State 3
中添加一个Set Int Value
行为,将新建变量rock number
的取值设置为0,这个rock number
变量就是我们的“计数器”。
在State 1
的最后,添加一个Int Add
行为,让rock number
变量的数值+1。这个“计数器+1”的操作最好放在生成陨石的Action后面,避免逻辑混乱。
在State 2
中,插入一个Int Compare
行为比较当前的rock number
变量的取值有没有达到变量max wave spawn number
中的数值(公开到Inspector,初始值10),也就是有没有生成完所有这一波的陨石,如果达到了(Equal
),就触发Wave Finished
事件跳转到State 4
,如果还没达到,继续执行后面的Wait
行为,等待0.5秒之后返回State 1
生成下一颗陨石。
在State 4
中,我们重置计数器,用Set Int Value
行为将变量rock number
的取值归零,然后添加一个Wait
行为让其等待3秒钟(变量wave duration
,公开到Inspector,初始值3),等待结束触发FINISHED
事件跳转回State 1
。
这个Fsm中用到的所有变量如下图:
这些变量是公开到Inspector以方便我们修改的:
运行游戏,可以清楚的分出不同的波次效果了。不过,这个游戏既没有计分,也没有难度递增,很快就会兴趣缺缺了。
音效、 计分系统、游戏进程控制
添加音效
选择Asteroid
预设物体,在FSM 2
中为“被子弹击中”状态和“和玩家相撞”状态添加音效。
State 2
代表被子弹击中,我们添加一个Play Sound
行为,并在Audio Clip
中指定explosion_asteroid
素材。
State 3
代表与玩家相撞,我们添加两个Play Sound
行为,分别在Audio Clip
中指定explosion_asteroid
素材和explosion_player
素材。
对于飞船武器发射的声音,我们采取另外一种方法。
选择Player
,添加一个Audio Source
组件。
在Audio Source
组件的AudioClip
参数中指定weapon_player
素材为其音频来源,取消Play On Awake
选项的勾选避免游戏开始即播放该声音。
在Player
的FSM 2
中的State 2
中,插入Audio Play
行为,无需做任何设置。
或者也可以不设置Audio Source
组件的AudioClip
参数,改为在Audio Play
行为中,将On Shot Clip
指定为weapon_player
素材。效果一样,但系统资源会占用得多一点。
Play Sound
行为和Audio Play
行为不一样,前者无需对象上指定音源,即时调用游戏素材来播放,而后者则主要是用来控制游戏物体上所指定的音源的播放,如果我取消Audio Source
组件,即使Audio Play
行为中另外指定了音源素材,也不会发出声音。
计分系统
原教程是Unity3D4.x时代的,UI部分基本已被新UI系统所取代,所以我们采用新UI系统来进行这一部分的重制。
在场景中添加一个UI Text物体(菜单GameObject
> UI
> Text
),修改名称为Score Text
。
按下图修改Score Text
的参数,让其显示在画面左上方。
然后在Game Controller
中新建Fsm(FSM 2
),State 1
中用Set Int Value
行为初始化两个变量:current score
和added score
。
注意,关于游戏的控制都会尽量放在
Game Controller
中,这也是方便日后的管理。在本范例中,Game Controller
的FSM
专职控制生成陨石,FSM 2
专职控制得分系统以及游戏进程。
在State 2
中,使用Convert Int To String
行为将current score
转换成格式为“Score: 0”这样的字符串,储存于score text
变量中。然后使用U Gui Text Set Text
行为设置Score Text
物体的Text
为变量score text
。
注意,这里的Actions我都没有设置成
Every Frame
的,而是仅执行一次。虽然在我们的概念中“显示得分”不是应该实时跟踪分数值么?但实际上只有“得分”的那一瞬间才需要更新我们的UI显示,并不是必须每帧执行。
在Events面板中新建一个Score Add
事件,并设置State 2
通过Score Add
事件的触发跳转到State 3
。
注意,因为
State 2
中并没有任何Action会触发Score Add
事件,所以这个Fsm单靠自己是永远不可能跳转到State 3
的。
在State 3
中,我们添加一个Int Add
行为,为current score
变量的值增加add score
变量中储存的数值。
注意,现在这个
add score
我一开始初始化为0了,也没有准备将它公开到Inspection。这是因为在我的设计中,这个“增加的分数”是由Player所击中的物体来决定的,物体有多少分,这里就增加多少分。
在这个关于“得分”的Fsm中,有两件事情是不由Fsm自身来控制的,一是Score Add
事件的触发,二是实际增加的分数值。这两件事情都需要由被击毁的陨石来告知Game Controller
:“你得分啦!”、“你得到了10分!”
要做到这一点,我们还需要将Score Add
事件设置成Global全局模式,也就是在Events面板中,勾上Score Add
前面的选框。
要从外部触发一个Event,我们需要将这个Event设置成Global全局模式。而要从外部修改一个变量,我们只需要使用专门的Action来操作就好了,并不需要将这个变量设置成全局模式。
选择Asteroid
预设物体,修改其FSM 2
,让它在被击毁时能够发送消息给Game Controller
。
首先要让这个Asteroid
找到Game Controller
。由于这是prefab,不能用直接指定场景中游戏物体的方式来指定Game Controller
物体,只能用Find Game Object
行为让其寻找游戏场景中的Game Controller
。
在FSM 2
中新建一个状态State 4
,添加Find Game Object
行为。设置Object Name
参数为Game Object
,然后设置Store
参数为变量game controller
(也就是把找到的游戏物体储存在这个变量中)。
我们设定陨石被子弹击中才会得分,陨石与飞船撞击不会得分。所以在State 2
(也就是被击中事件发生以后的状态)中,添加Set Fsm Int
行为和Send Event
行为。
-
Set Fsm Int
行为能够设置任意Fsm中的Int类型变量。设置Game Object
参数为Specify Game Object
,然后指定变量game controller
,再手动填写Fsm Name
为“FSM 2”,手动填写Variable Name
为“added score”,并设置Set Value
参数为新建变量score
(公开到Inspector),也就是将目标Fsm中的added score
变量值设置为本Fsm中变量score
的值; -
Send Event
行为能够发送一个全局事件到任意Fsm中,使目标Fsm触发该事件从而改变状态。由于Game Object
上不止一个Fsm,所以需要设置Event Target
为Game Object FSM
(这样才会提供参数给我们指定Fsm名称),然后指定GameObject
参数为变量game controller
,手动填写Fsm Name
为“FSM 2”,选择Score Add
事件为Sent Event
。
最后设置Asteroid
的score
变量值为5。
运行游戏,果然有趣多了。
重置游戏
我们设计当
Player
被击毁时,Game Controller
停止生成陨石,然后显示提示字幕,告诉玩家按下R
键重新开始游戏。
在场景中选择Score Text
,Ctrl
+ D
复制一个新的出来,修改名称为Restart Text
。
按下图修改Restart Text
物体,主要是显示文字、位置。设置好以后设置其“休眠”(取消Activate
勾选)。
在Asteroid
预制物体的FSM 2
中的State 3
(也就是玩家被击中之后的状态)中,添加Sent Event
,按下图设置(或者从State 2
中直接复制粘贴过来以后修改也可以):修改FSM Name
为“FSM”,设置Send Event
为一个新的全局事件(Global Event):Player Destroyed
。
意思是:告诉Game Controller
上的FSM
:“玩家死翘翘了!”
然后选择Game Controller
,进入FSM
,新建一个State 5
,为其添加一个Global Transition
,并设置Player Destroyed
事件为其触发条件。
在State 5
中,添加Activate Game Object
行为让其激活Restart Text
游戏物体,然后在添加一个Get Key Down
行为让其检测R
键是否被按下,如果按下则触发Game Restart
事件,跳转到State 6
。
在State 6中,添加一个Restart Level
行为。
大功告成!!!
从逻辑结构上来说,“玩家被击毁”这件事情并不应该由击毁玩家的那颗陨石来告诉系统,而是应该由玩家本身来告诉系统。在这里例子中能够让陨石来告诉系统,纯粹是因为我们设置了“一击即毁”的规则。
正确的逻辑应该是:陨石和玩家碰撞,陨石自身击毁,给予玩家一定伤害;玩家自身判断伤害累积超过生命值,被击毁,告知系统。
有兴趣的同学可以自行改造一下。