Unity官方教程 2D Roguelike(2):生成关卡

2D Roguelike 最终效果

前言

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,和游戏管理者同名,介意混淆所以采取其他名字),相当于游戏的控制中心,包含游戏的主要逻辑代码,比如保存角色生命值、指定生成第几关的关卡等。

BoardManager和GameController

二、铺设地砖Floor和外墙OuterWall

第1步:认识关卡布局

双击BoardManager脚本,它会自动启动MonoDevelop程序并且打开这个脚本文件。默认的空白脚本内容见下图。

默认的BoardManager

在开始写代码之前,我们先清楚地认识我们即将要实现的是怎么样的一个关卡,方形?长方形?资源分布在哪里?这些都清楚了,对于接下来的代码实现就很容易理解。

关卡布局

如图所示:
整个关卡:10x10,正方形。
白色区域:8x8,角色和怪物的活动区域,铺设floor,外面被一圈OuterWall包围着
黄色区域:6x6,所有随机物品(Enemy、Food、Soda、Wall)都会在这里随机生成
角色初始位置:默认为(0,0)
Exit(右上角):(7,7)
白色和黄色之间的道路:留一条道路让角色可以通行。

因此我们很清晰明了地知道,接下来我们是需要在(0,0)-(7,7)的区域内铺设Floor,在横坐标为-18、竖坐标为-18的四条边边铺设OuterWall。哟西!(`・ω・´)开始我们的代码大战吧!

第2步:编辑生成Floor和OuterWall的代码

BoardManager里自带的Start和Update方法在这里用不上,删除他们之后,我们新增了两个方法:SetupScene()BoardSetup()

BoardManager里生成Floor和OuterWall的代码

简单说明下:

  • 新增四个变量成员,rowscolumns是大胡子活动区域的行数和列数,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的调用。

GameController的代码

代码简读:

  • 新增变量成员boardManager,是BoardManager类的一个实例。
  • 把Start()方法改成Awake(),获取GameController所挂载的GameObject上名字为BoardManager的组件的一个实例,赋值给boardManager,然后调用InitGame()方法初始化关卡。
  • InitGame()方法,调用boardManager这个实例的SetupScene()方法来生成关卡。

在对象初始化之后,会立刻调用Awake()方法,然后才会调用Start()方法。一般初始化用Awake()比较安全。

脚本写好了,切回到Unity编辑器,选中BoardManagerGameController脚本,鼠标左键不动拖到Hierarchy窗口下的GameManager游戏对象再松开,右边Inspector窗口会自动加上这两个脚本组件。

GameManager添加了新的脚本

点击Inspector窗口右上角的锁来保持窗口固定。

Inspector固定窗口的锁

打开Prefabs文件夹,选中所有的Floor预制件,拖到BoardManager脚本组件下的Floor Tiles上,这些预制件就会添加到Floor Tiles这个数组里了。Outer Wall Tiles也是同理,把所有OuterWall预制件都拖过去吧!(・ω<)☆

把预制件拖到BoardManager组件内

解除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脚本,我们在里面增加如下代码。

增加Board游戏对象来收纳floor和outerWall

代码简读:

  • 新建一个私有变量成员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对象们。

Board

这样整个窗口就干净多了。以后在遇到同类的游戏对象太多的时候建议使用这个方法来整理哦!

三、放置Exit

Exit固定放置在活动区域的右上角(7,7),也就是(columns-1,rows-1),生成代码很简单。在BoardManager里加这几行代码。

生成Exit的代码

代码简读:

  • 新建变量成员exitTile,代表Exit预制件。
  • 在SetupScene()方法内,使用Instantiate()方法渲染生成一个GameObject,指定使用exitTile资源,指定位置,不旋转。

回到Unity编辑器,把Exit预制件拖到BoardManager脚本组件的ExitTile选项内就好了。

需要配置上Exit预制件

运行游戏。

关卡出现Exit了

四、生成随机物品(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获得并返回一个随机坐标。这个坐标的索引需要清除掉,避免重复抽取。

RandomPosition方法

第3步:编写生成随机物品的方法

每个关卡都是随机生成,除了位置不同以外,每种资源的数量也是在一定的范围内变动,并不会固定。根据这个我们创建了LayoutObjectAtRandom()方法用于生成随机物品。

LayoutObjectAtRandom方法

代码简读:

  • 根据传入的最小最大值,获取中间的一个随机数objectCount,作为要生成的GameObject的数量。
  • 进行for循环,调用RandomPosition()方法获得一个随机坐标,从传入的数组集合tileArray内随机一个要生成的GameObject的预制件,调用Instantiate创建出指定的GameObject。for循环会执行objectCount次。

第4步:生成关卡的Wall、Food、Soda、Enemy

生成随机物品的方法写好了,接下来就是调用这个方法去生成关卡内的Wall、Food、Soda、Enemy了。

生成随机物品的代码

代码看起来有点多ヾ(=・ω・=)o,但其实理清了思路也不难。
简单说明下:

  1. 第一个红方框
  • 定义了一个类Count,声明了两个变量成员minimum、maximum,它的构造函数把传入的参数的值赋给了这两个成员。
  • [Serializable]可以序列化Count类,使Count类的实例在Inspector面板上显示, 并可以赋予相应的值。
  • 要想使用[Serializable],需要在顶部命名空间声明 using System
  • 声明了两个变量成员,wallCount是障碍墙的数量,foodCount是food、soda(它们都算food)的数量,因为Serializable的作用,这4个数值都会在Inspector面板上显示并且可以修改。
  1. 第二个红方框
  • 定义了三个变量成员,wallTiles是Wall预制件的数组集合,foodTiles是Food、Soda预制件的数组集合,enemyTiles是Enemy预制件的数组集合。
  1. 第三个红方框
    先调用InitialiseList()初始化坐标集,然后分别调用LayoutObjectAtRandom()方法渲染生成Wall、Food、Enemy这些游戏对象。要注意的是,因为怪物的数量是根据关卡的等级来决定的,所以SetupScene()需要增加一个level参数,代表关卡的等级或者天数。

Mathf.log(level,2f) 指的是以2为底,level的对数。比如说level是8的话,结果就是3。(2的3次方是8)

说到这里,不知道大家有没有发现一个异常?

Random.Range方法出错

Random.Range()方法红色,无法使用。回到Unity编辑器,可以看到控制器也报告了这个错误。

控制器报错

仔细研读控制台的报错内容,我们会发现原来存在两个Random。UnityEngine和System下都有Random,然后我们的命名空间都包括了这两者,所以不指明的话,程序不知道我们的Random是用的哪一个。实际上我们只需要UnityEngine.Random,指定清楚就可以了。

指定Random是哪一个

修正了这个错误之后,我们还需要打开GameController脚本,新增变量成员level,在InitGame方法内调用的SetupScene方法加上level参数。

加上level参数

GameController是控制中心,类似关卡等级level、玩家生命等整个游戏生命周期都需要的数据都会保存在这里。
先给level赋值为4,切回到Unity编辑器准备测试效果啦!
选中GameManager,锁定右侧Inspector窗口,打开Prefabs文件夹,分别把Wall、Enemy、Food、Soda预制件们拖到BoardManager组件下的对应选项内。记得Food、Soda预制件是一起拖到Food Tiles选项的!

拖预制件到wall/food/enemy选项

另外可以看看在最上面有显示WallCount和FoodCount,并且他们的minimux和maximum都可以修改。这就是Serializable的作用,序列化一个类,让它的实例显示属性在Inspector窗口并且可以通过折叠来显示和隐藏它们。

Serializable的效果

运行游戏。

生成关卡

怪物、食物、障碍都有了,而且每种资源的数量也是正确的。很好,做的不错!✧。٩(ˊᗜˋ)و✧*。

五、游戏管理实现单例模式

GameController是游戏管理器,主要是负责对其他脚本发号施令,比如说玩家来到第三关的时候,它就凶巴巴地对BoardManager说:“你,第三关,造出来!”BoardManager就会屁颠屁颠的去把第三关生成。像GameController这样的管理器,游戏从始至终有且只能有一个,否则会造成调用混乱等问题。这个唯一的实例只需要生成一次,并且直到游戏结束才需要销毁。 这就是单例模式。让我们来看看怎么实现。

第1步:GameController实现单例模式

在GameController脚本内添加以下代码:

image.png

代码简读:

  • 新增一个静态成员变量instance,值默认是null,可以外部直接调用访问,不需要实例化类之后才可以调用。
  • 在Awake方法里,判断当instance的值是null时,把自身这个实例赋值给instance,如果instance的值不为空且不等于自身,就把自身所挂载的GameObject(在这里是GameManager)销毁。这样就保证了从头到尾有且只能同时存在一个GameController的实例。为了进入下一关时不被摧毁,调用了DontDestroyOnLoad()方法实现重新加载场景的时候不会干掉自身挂载的GameObject。

第2步:通过摄像机生成GameManager

回到Unity编辑器,把GameManager往下拖到Prefabs做成预制件,然后删除窗口下的GameManager,在Scripts内创建一个新的脚本Loader,在里面编写如下代码。

Loader

代码很简单,声明一个新的成员变量gameManager,是指的GameManager预制件。然后在Awake方法里,判断当前是否存在GameController的实例,如果不存在,则生成gameManager游戏对象。

回到Unity编辑器,把Loader拖到Main Camera,这个组件就添加到了摄像机里了。打开Prefabs文件夹,把GameManager这个预制件拖到Loader组件的Game Manager选项。

Loader组件指定GameObject

运行游戏,可以看到即使之前左边Hierarchy窗口下没有GameManager游戏对象,在运行之后还是会生成关卡所需的GameObject。

单例模式下正常生成关卡

梳理流程:运行游戏的时候,Main Camera对象生成,开始调用Loader脚本的Awake()方法,它判断当前没有GameController的实例,就动态创建了GameManager游戏对象。GameManager游戏对象一生成,马上就执行GameController的Awake()方法,判断出当前静态变量成员instance为null,就把自身这个实例赋值给它,然后获取GameManager的组件BoardManager的一个实例,调用它的SetupScene()方法来生成关卡。

(o゜▽゜)o☆ 总算结束了,里程碑高高升起!

我觉得我写的有点啰嗦ಠ╭╮ಠ

接下来该让我们的大胡子开始动起来了!

移动逻辑真的是很费劲啊哭

上一章传送门:动画和预制件
下一章传送门:移动逻辑

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,905评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,140评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,791评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,483评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,476评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,516评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,905评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,560评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,778评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,557评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,635评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,338评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,925评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,898评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,142评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,818评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,347评论 2 342

推荐阅读更多精彩内容