前言
在Unity官方教程 2D Roguelike(1):动画和预制件中,我们准备了所有生成一个随机关卡必备的素材并且做成了预制件:角色、怪物、Floor、Exit、Food、Soda、OuterWall、Wall。那么这一节我们将主要完成一个内容:
- 生成随机关卡
本节你将学会什么?
- 如何动态创建GameObject
- 如何保持Hierarchy干净整洁
- 如何用Serializable序列化一个类
- 如何在随机坐标放置随机物品
- 如何实现单例模式
一、新建脚本BoardManager、GameController
新建一个空白GameObject,重置Transform,命名为GameManager。这是游戏管理者,关卡生成、游戏控制的脚本都需要挂载在它身上。
右键Scripts文件夹,选择Create->C# Script,创建一个新的脚本,命名为BoardManager,所有生成关卡的逻辑代码都会写在里面。
同样的方法再新建一个脚本GameController(官方视频起名是GameManager,和游戏管理者同名,介意混淆所以采取其他名字),相当于游戏的控制中心,包含游戏的主要逻辑代码,比如保存角色生命值、指定生成第几关的关卡等。
二、铺设地砖Floor和外墙OuterWall
第1步:认识关卡布局
双击BoardManager脚本,它会自动启动MonoDevelop程序并且打开这个脚本文件。默认的空白脚本内容见下图。
在开始写代码之前,我们先清楚地认识我们即将要实现的是怎么样的一个关卡,方形?长方形?资源分布在哪里?这些都清楚了,对于接下来的代码实现就很容易理解。
如图所示:
整个关卡:10x10,正方形。
白色区域:8x8,角色和怪物的活动区域,铺设floor,外面被一圈OuterWall包围着
黄色区域:6x6,所有随机物品(Enemy、Food、Soda、Wall)都会在这里随机生成
角色初始位置:默认为(0,0)
Exit(右上角):(7,7)
白色和黄色之间的道路:留一条道路让角色可以通行。
因此我们很清晰明了地知道,接下来我们是需要在(0,0)-(7,7)的区域内铺设Floor,在横坐标为-1或8、竖坐标为-1或8的四条边边铺设OuterWall。哟西!(`・ω・´)开始我们的代码大战吧!
第2步:编辑生成Floor和OuterWall的代码
BoardManager里自带的Start和Update方法在这里用不上,删除他们之后,我们新增了两个方法:SetupScene()和BoardSetup()。
简单说明下:
- 新增四个变量成员,rows和columns是大胡子活动区域的行数和列数,floorTiles是Floor预制件的数组集合,OuterWallTiles是OuterWall预制件的数组集合。
- SetupScene()方法的功能是生成关卡,现在它调用了BoardSetup()方法去铺设Floor和OuterWall。
- BoardSetup()方法,采用for循环遍历从(-1,-1)到(columns,rows)整个坐标系,每次循环的时候从Floor预制件数组内随机获得一个预制件,赋值给局部变量toInstantiate,这时候我们会做一个判断,如果是处于四面墙的坐标下,toInstantiate的值会变成是从OuterWall预制件数组内随机取得的一个预制件。最后用Instantiate()方法在指定的坐标渲染出游戏对象instance。当for循环完全结束的时候,地板和外墙也就生成完全了。
为了可读性和美观,单个方法要控制代码行数。代码太多的时候,建议拆分成不同的方法。
类的变量成员和方法前面有public关键字,代表可被外部实例化访问。
OK,生成Floor和OuterWall的代码写好了,我们需要找个地方去调用执行它。双击GameController脚本,我们将在这里实现对BoardManager的调用。
代码简读:
- 新增变量成员boardManager,是BoardManager类的一个实例。
- 把Start()方法改成Awake(),获取GameController所挂载的GameObject上名字为BoardManager的组件的一个实例,赋值给boardManager,然后调用InitGame()方法初始化关卡。
- InitGame()方法,调用boardManager这个实例的SetupScene()方法来生成关卡。
在对象初始化之后,会立刻调用Awake()方法,然后才会调用Start()方法。一般初始化用Awake()比较安全。
脚本写好了,切回到Unity编辑器,选中BoardManager和GameController脚本,鼠标左键不动拖到Hierarchy窗口下的GameManager游戏对象再松开,右边Inspector窗口会自动加上这两个脚本组件。
点击Inspector窗口右上角的锁来保持窗口固定。
打开Prefabs文件夹,选中所有的Floor预制件,拖到BoardManager脚本组件下的Floor Tiles上,这些预制件就会添加到Floor Tiles这个数组里了。Outer Wall Tiles也是同理,把所有OuterWall预制件都拖过去吧!(・ω<)☆
解除Inspector窗口锁,现在万事俱备只欠东风,验证下辛苦的成果吧。运行游戏看看!
啊咧?!怎么关卡不是在正中央?强迫症看的我好蓝瘦!(▼ヘ▼#)
排除掉坐标错误的可能,我们所看到的所有游戏内容都是摄像机决定的,所以这个位置不是显示在正中央可以通过修改摄像机参数来解决。切到Scene窗口,我们可以看到摄像机的可视区域。
摄像机的中心点是在(0,0),所以未能看到整个关卡。那么我们思考下,把它的中心点和关卡的中间点重叠起来不就可以了?关卡的中心点坐标是(3.5,3.5),选中Main Camera,修改Transfrom的Position的X和Y均为3.5。
再运行游戏看,正的不能再正了。
第3步:整理Hierarchy窗口的GameObject
在运行游戏的时候,可以看到Hierarchy窗口下面生成了很多GameObject,按照坐标去计算的话应该是生成了10X10=100个游戏对象,如果就这样显示的话会显得很冗长繁琐,我们需要创建一个GameObject容器来收纳floor和outerWall这些游戏对象。
回到BoardManager脚本,我们在里面增加如下代码。
代码简读:
- 新建一个私有变量成员boardHolder,是容器GameObject的transform组件。
- 在BoardSetup()方法,动态创建一个名为Board的空白游戏对象,并把它的transform组件赋给boardHolder。在for循环里渲染生成游戏对象instance之后,通过transform.SetParent方法把自己转变成Board的子对象。
new GameObject()和Object.Instantiate()两种方法都可以在脚本里动态创建GameObject,区别是new方法的结果是一个空白GameObject,只包含transform组件;Instantiate方法可以指定要创建的预制件、位置、旋转。
Quaternion.identity代表不旋转。
切回到Unity编辑器,运行游戏,可以看到Hierarchy窗口下出现了一个游戏对象Board,点击前面小三角可以打开生成的Floor和OuterWall对象们。
这样整个窗口就干净多了。以后在遇到同类的游戏对象太多的时候建议使用这个方法来整理哦!
三、放置Exit
Exit固定放置在活动区域的右上角(7,7),也就是(columns-1,rows-1),生成代码很简单。在BoardManager里加这几行代码。
代码简读:
- 新建变量成员exitTile,代表Exit预制件。
- 在SetupScene()方法内,使用Instantiate()方法渲染生成一个GameObject,指定使用exitTile资源,指定位置,不旋转。
回到Unity编辑器,把Exit预制件拖到BoardManager脚本组件的ExitTile选项内就好了。
运行游戏。
四、生成随机物品(Wall、Food、Soda、Enemy)
生成随机物品,除了选的资源是随机的,它生成的位置也是随机的。所以我们不能像之前铺设floor一样的方法去做,而是要先获得黄色区域的坐标集,然后从里面随机取一个坐标,在上面渲染一个随机的预制件。
第1步:初始化随机区域坐标集
我们对关卡的布局了然于胸,所以应该会记得随机物品都是分布在(1,1)——(6,6)这个正方形区域之间。所以我们第一件事就是先添加这个区域的所有坐标。
代码简读:
- 新建一个List类型的私有变量成员gridPositions,集合里面存放的是Vector3类型的数据。
- 添加InitialiseList()方法,调用Clear()清空gridPositions的数据,for循环遍历,通过Add()方法把随机区域的坐标都添加到gridPositions。
第2步:从坐标集获得一个随机坐标
在BoardManager添加一个RandomPosition方法,通过Random.Range()方法获取一个从0到gridPositions数组长度之间的任意数字,并把它作为索引代入到gridPositions获得并返回一个随机坐标。这个坐标的索引需要清除掉,避免重复抽取。
第3步:编写生成随机物品的方法
每个关卡都是随机生成,除了位置不同以外,每种资源的数量也是在一定的范围内变动,并不会固定。根据这个我们创建了LayoutObjectAtRandom()方法用于生成随机物品。
代码简读:
- 根据传入的最小最大值,获取中间的一个随机数objectCount,作为要生成的GameObject的数量。
- 进行for循环,调用RandomPosition()方法获得一个随机坐标,从传入的数组集合tileArray内随机一个要生成的GameObject的预制件,调用Instantiate创建出指定的GameObject。for循环会执行objectCount次。
第4步:生成关卡的Wall、Food、Soda、Enemy
生成随机物品的方法写好了,接下来就是调用这个方法去生成关卡内的Wall、Food、Soda、Enemy了。
代码看起来有点多ヾ(=・ω・=)o,但其实理清了思路也不难。
简单说明下:
- 第一个红方框
- 定义了一个类Count,声明了两个变量成员minimum、maximum,它的构造函数把传入的参数的值赋给了这两个成员。
- [Serializable]可以序列化Count类,使Count类的实例在Inspector面板上显示, 并可以赋予相应的值。
- 要想使用[Serializable],需要在顶部命名空间声明 using System。
- 声明了两个变量成员,wallCount是障碍墙的数量,foodCount是food、soda(它们都算food)的数量,因为Serializable的作用,这4个数值都会在Inspector面板上显示并且可以修改。
- 第二个红方框
- 定义了三个变量成员,wallTiles是Wall预制件的数组集合,foodTiles是Food、Soda预制件的数组集合,enemyTiles是Enemy预制件的数组集合。
- 第三个红方框
先调用InitialiseList()初始化坐标集,然后分别调用LayoutObjectAtRandom()方法渲染生成Wall、Food、Enemy这些游戏对象。要注意的是,因为怪物的数量是根据关卡的等级来决定的,所以SetupScene()需要增加一个level参数,代表关卡的等级或者天数。
Mathf.log(level,2f) 指的是以2为底,level的对数。比如说level是8的话,结果就是3。(2的3次方是8)
说到这里,不知道大家有没有发现一个异常?
Random.Range()方法红色,无法使用。回到Unity编辑器,可以看到控制器也报告了这个错误。
仔细研读控制台的报错内容,我们会发现原来存在两个Random。UnityEngine和System下都有Random,然后我们的命名空间都包括了这两者,所以不指明的话,程序不知道我们的Random是用的哪一个。实际上我们只需要UnityEngine.Random,指定清楚就可以了。
修正了这个错误之后,我们还需要打开GameController脚本,新增变量成员level,在InitGame方法内调用的SetupScene方法加上level参数。
GameController是控制中心,类似关卡等级level、玩家生命等整个游戏生命周期都需要的数据都会保存在这里。
先给level赋值为4,切回到Unity编辑器准备测试效果啦!
选中GameManager,锁定右侧Inspector窗口,打开Prefabs文件夹,分别把Wall、Enemy、Food、Soda预制件们拖到BoardManager组件下的对应选项内。记得Food、Soda预制件是一起拖到Food Tiles选项的!
另外可以看看在最上面有显示WallCount和FoodCount,并且他们的minimux和maximum都可以修改。这就是Serializable的作用,序列化一个类,让它的实例显示属性在Inspector窗口并且可以通过折叠来显示和隐藏它们。
运行游戏。
怪物、食物、障碍都有了,而且每种资源的数量也是正确的。很好,做的不错!✧。٩(ˊᗜˋ)و✧*。
五、游戏管理实现单例模式
GameController是游戏管理器,主要是负责对其他脚本发号施令,比如说玩家来到第三关的时候,它就凶巴巴地对BoardManager说:“你,第三关,造出来!”BoardManager就会屁颠屁颠的去把第三关生成。像GameController这样的管理器,游戏从始至终有且只能有一个,否则会造成调用混乱等问题。这个唯一的实例只需要生成一次,并且直到游戏结束才需要销毁。 这就是单例模式。让我们来看看怎么实现。
第1步:GameController实现单例模式
在GameController脚本内添加以下代码:
代码简读:
- 新增一个静态成员变量instance,值默认是null,可以外部直接调用访问,不需要实例化类之后才可以调用。
- 在Awake方法里,判断当instance的值是null时,把自身这个实例赋值给instance,如果instance的值不为空且不等于自身,就把自身所挂载的GameObject(在这里是GameManager)销毁。这样就保证了从头到尾有且只能同时存在一个GameController的实例。为了进入下一关时不被摧毁,调用了DontDestroyOnLoad()方法实现重新加载场景的时候不会干掉自身挂载的GameObject。
第2步:通过摄像机生成GameManager
回到Unity编辑器,把GameManager往下拖到Prefabs做成预制件,然后删除窗口下的GameManager,在Scripts内创建一个新的脚本Loader,在里面编写如下代码。
代码很简单,声明一个新的成员变量gameManager,是指的GameManager预制件。然后在Awake方法里,判断当前是否存在GameController的实例,如果不存在,则生成gameManager游戏对象。
回到Unity编辑器,把Loader拖到Main Camera,这个组件就添加到了摄像机里了。打开Prefabs文件夹,把GameManager这个预制件拖到Loader组件的Game Manager选项。
运行游戏,可以看到即使之前左边Hierarchy窗口下没有GameManager游戏对象,在运行之后还是会生成关卡所需的GameObject。
梳理流程:运行游戏的时候,Main Camera对象生成,开始调用Loader脚本的Awake()方法,它判断当前没有GameController的实例,就动态创建了GameManager游戏对象。GameManager游戏对象一生成,马上就执行GameController的Awake()方法,判断出当前静态变量成员instance为null,就把自身这个实例赋值给它,然后获取GameManager的组件BoardManager的一个实例,调用它的SetupScene()方法来生成关卡。
(o゜▽゜)o☆ 总算结束了,里程碑高高升起!
接下来该让我们的大胡子开始动起来了!