关于在Unity中创建“ roguelike”的教程并不多,所以我决定编写它。不是吹牛,而是与那些我已经相当一段时间的人分享知识。注意:我并不是说这是在Unity中创建“ roguelike” 的唯一方法。他只是其中之一。我通过反复试验中学到了可能不是最好和最有效的方法。我将在创建教程的过程中正确学习一些东西。假设您至少了解Unity的基础知识,例如,如何创建预制件或脚本等。不要指望我教您如何创建Sprite工作表,有很多很棒的教程。我将不专注于研究引擎,而是专注于如何实现我们将共同创造的游戏。如果遇到困难,请访问Discord的一个很棒的社区并寻求帮助:Unity开发人员社区Roguelikes所以,让我们开始吧!阶段0-规划
恩,那就对了。首先要制定一个计划。规划您的游戏对我来说很有益,对我而言-规划本教程也将是一件好事,这样一会儿我们就不会分散话题的注意力。就像在流氓地下城中一样,很容易在游戏的功能上感到困惑。我们将写roguelike。我们将主要遵循Cogmind开发商乔希戈明智的建议在这里。单击链接,阅读文章或观看视频,然后再回来。本教程的目的是什么?获得扎实简单的基本roguelike,然后您就可以尝试使用。它应该具有地牢生成,在地图上移动的玩家,可见性迷雾,敌人和物体。只有最必要的。因此,玩家应该能够走下几层楼。比方说,增加五分,提高水平,最后与老板搏斗并击败他。或者死。实际上,仅此而已。遵循Josh Ge的建议,我们将构建游戏功能,以便它们将我们引向目标。因此,我们得到了roguelike框架,可以进一步扩展该框架,添加您自己的芯片,从而创造出独特性。或将所有东西扔进篮子,利用获得的经验并从头开始。无论如何都会很棒。我不会给您任何图形资源。自己绘制它们,或使用免费的tileset,可以在此处,此处或通过Google搜索来下载它们。只是别忘了提及游戏中图形的作者。现在,让我们按照实现的顺序列出所有与我们类似的功能:- 地牢地图生成
- 玩家角色及其动作
- 可见区域
- 敌人
- 寻找方法
- 战斗,健康与死亡
- 玩家等级提升
- 物品(武器和药水)
- 控制台作弊(用于测试)
- 地牢地板
- 保存和加载
- 最终老板
实施所有这些步骤后,我们将具有强大的流氓风格,您将大大提高自己的游戏开发技能。实际上,这是我提高技能的方法:创建代码和实现功能。因此,我相信您也可以处理。阶段1-MapManager类
这是我们将创建的第一个脚本,它将成为我们游戏的支柱。它很简单,但是包含游戏的大部分重要信息。因此,创建一个名为MapManager的.cs脚本并打开它。删除“:MonoBehaviour”,因为它不会继承它,也不会附加到任何GameObject。删除开始()和更新()函数。在MapManager类的末尾,创建一个名为Tile的新公共类。Tile类将包含单个图块的所有信息。到目前为止,我们不需要太多,只需要x和y位置以及位于地图此位置的游戏对象即可。因此,我们有基本的图块信息。让我们从此图块创建一个地图。很简单,我们只需要一个二维的Tile对象数组。听起来很复杂,但是没有什么特别的。只需将Tile [,]变量添加到MapManager类中:瞧!我们有地图!是的,它是空的。但这是一张地图。每当地图上某物移动或更改状态时,此地图上的信息就会更新。也就是说,例如,如果玩家尝试切换到新的图块,则班级将检查地图上的目标图块地址,敌人的存在及其通畅程度。因此,我们不必每次都检查数千次碰撞,也不需要为每个游戏对象使用对撞机,从而简化并简化了游戏工作。产生的代码如下所示:using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MapManager
{
public static Tile[,] map;
}
public class Tile {
public int xPosition;
public int yPosition;
public GameObject baseObject;
}
第一阶段完成了,让我们继续填写卡片。现在我们将开始创建地牢生成器。第2阶段-关于数据结构的几句话
但是,在开始之前,让我分享由于第一部分发表后收到的反馈而产生的提示。在创建数据结构时,您需要从一开始就考虑如何保持游戏状态。否则,以后将更加混乱。 Star Shaped Bagel的开发商Discord st33d的用户(您可以在这里免费玩此游戏)说,起初他创建了游戏,认为它根本不会保存状态。渐渐地,游戏开始变得更大,她的粉丝要求支持已保存的地图。但是由于选择了创建数据结构的方法,因此很难保存数据,因此他无法做到这一点。我们确实从错误中学习。尽管我将“保存/加载”部分放在了本教程的最后,但我从一开始就对它们进行了思考,但还没有对其进行解释。在这一部分中,我将对它们进行一些讨论,但以免使经验不足的开发人员超载。我们将这些内容保存为存储地图的Tile类的变量数组。除了GameObject类的变量(位于Tile类中)之外,我们将保存所有这些数据。为什么?仅仅因为GameObjects无法通过Unity序列化到存储的数据。因此,实际上,我们不需要保存存储在GameObjects内部的数据。所有数据都将存储在诸如Tile之类的类中,然后还会存储在Player,Enemy等类中。然后,我们将具有GameObjects来简化可见性和移动以及在屏幕上绘制精灵之类的事情的计算。因此,在类中将有GameObject变量,但是这些变量的值将不会保存和加载。加载时,我们将强制从保存的数据(位置,子画面等)再次生成GameObject。那我们现在需要做什么?好吧,只需在现有的Tile类中添加两行,并在脚本顶部添加一行。首先,我们添加“使用系统”;脚本标题,然后是[Serializable]在整个类的前面,然后是[NonSerialized]在GameObject变量的前面。像这样:当我们进入有关保存/加载的教程部分时,我将告诉您更多有关此的信息。现在,让我们继续前进。第3阶段-有关数据结构的更多信息
我想在这里分享有关数据结构的另一篇评论。实际上,有很多方法可以在游戏中实现数据。我使用的第一个,将在本教程中实现:所有切片数据都在Tile类中,并且所有它们都存储在一个数组中。这种方法有很多优点:易于阅读,所需的一切都集中在一个地方,数据更易于操作并导出到保存文件。但是从内存的角度来看,它并不是那么有效。您将不得不为游戏中永远不会使用的变量分配大量内存。例如,稍后我们将Enemy GameObject变量放在Tile类中,以便我们可以直接从地图指向站立在此tile上的敌人的GameObject,以简化与战斗有关的所有计算。但这意味着游戏中的每个图块都将为GameObject变量在内存中分配空间,即使在这个方块上没有敌人如果在2500个图块的地图上有10个敌人,那么将有2490个空的,但是分配了GameObject变量-您可以看到浪费了多少内存。一种替代方法是使用结构存储图块的基本数据(例如,位置和类型),并将所有其他数据存储在hashmap-s中,仅在必要时才会生成。这样可以节省大量内存,但是投资回报会稍微复杂一些。实际上,它会比我在本教程中想要的要先进一些,但是如果您愿意,将来我可以写一篇更详细的文章。另外,如果您想阅读有关此主题的讨论,则可以在Reddit上完成。阶段4-地牢生成算法
是的,这是我要讨论的另一部分,我们将不会开始编写任何程序。但这很重要,仔细规划算法将为我们节省大量的工作时间。有几种创建地牢生成器的方法。我们将共同实施的方法并不是最好的,也不是最有效的……这只是一种简单的初始方法。这很简单,但是效果会很好。主要问题将是许多死胡同的走廊。以后,如果您愿意,我可以发布另一本有关更好算法的教程。一般来说,我们使用的算法的工作原理如下:假设我们有一个充满零值的完整地图-一个由石头组成的水平。一开始,我们在中心剪了一个房间。从这个房间我们沿着一个方向突破走廊,然后添加其他走廊和房间,这些走廊和房间总是从现有房间或走廊中随机开始,直到达到一开始就给定的最大走廊/房间数量。或直到算法可以找到新的位置来添加新的房间/走廊,以先到者为准。这样我们就得到了一个地牢。因此,让我们以一种更类似于算法的方式逐步描述它。为了方便起见,我将地图的每个细节(走廊或房间)称为元素,这样我就不必每次都说“房间/走廊”。- 在地图中心剪裁房间
- 随机选择其中一面墙
- 我们穿过这堵墙的走廊
- 随机选择现有元素之一。
- 随机选择此元素的墙之一
- 如果最后选择的项目是房间,则我们生成一条走廊。如果是走廊,则随机选择下一个元素是房间还是另一个走廊
- 检查所选方向上是否有足够的空间来创建所需的项目
- 如果存在,请创建一个元素,否则请返回到步骤4
- 从第4步开始重复
就这样。我们将获得一张简单的地牢地图,其中只有房间和走廊,没有门和特殊元素,但这将是我们的开始。稍后,我们将用箱子,敌人和陷阱将其装满。您甚至可以对其进行自定义:我们将学习如何添加所需的有趣元素。阶段5-剪裁房间
最后进行编码!让我们切开我们的第一个房间。首先,创建一个新脚本并将其命名为DungeonGenerator。它将从Monobehaviour继承,因此您稍后需要将其附加到GameObject。然后,我们将需要在类中声明几个公共变量,以便我们可以从检查器设置地牢的参数。这些变量将是地图的宽度和高度,房间的最小和最大高度和宽度,走廊的最大长度以及应该在地图上的元素数量。接下来,我们需要初始化地牢生成器。我们这样做是为了初始化将由世代填充的变量。目前,这只是一张地图。并且,还要删除Unity为新脚本生成的Start()和Update()函数,我们将不需要它们。在这里,我们初始化了MapManager类的地图变量(我们在上一步中创建),传递了地图的宽度和高度,由上面的变量定义为数组的二维尺寸的参数。因此,我们将获得水平x大小(宽度)和垂直y大小(高度)的地图,并且可以通过输入MapManager.map [x,y]访问地图中的任何单元格。当操纵对象的位置时,这将非常有用。现在,我们将创建一个渲染第一个房间的函数。我们将其称为FirstRoom()。我们将InitializeDungeon()设置为公共功能,因为它将由另一个脚本(我们将很快创建的Game Manager;它将对整个游戏启动过程进行集中管理)启动。我们不需要任何外部脚本即可访问FirstRoom(),因此我们不会将其公开。现在,继续,我们将在MapManager脚本中创建三个新类,以便您可以创建一个房间。这些是Feature,Wall和Position类。 Position类将包含x和y位置,以便我们可以跟踪所有内容。墙壁将具有位置列表,相对于房间中心(北,南,东或西)“看”的方向,长度以及从中创建的新元素的存在。元素将具有其所包含的所有位置,元素的类型(房间或走廊),Wall变量数组以及其宽度和高度的列表。现在让我们进入FirstRoom()函数。让我们回到DungeonGenerator脚本并在InitializeDungeon下面创建一个函数。她将不需要接收任何参数,因此我们将其简化为()。接下来,在函数内部,我们首先需要创建并初始化Room变量及其Position列表。我们这样做:现在让我们设置房间的大小。它将收到脚本开头声明的最小和最大高度和宽度之间的随机值。当它们为空时,因为我们没有在检查器中为它们设置值,但是不用担心,我们会尽快完成。我们像这样设置随机值:接下来,我们需要声明房间的起点将位于何处,即房间0.0的点将在地图网格中位于何处。我们要使其开始于地图的中心(一半宽度和一半高度),但可能不完全位于地图中心。可能需要添加一个小的随机器,使其稍微向左和向下移动。因此,我们将xStartingPoint设置为地图宽度的一半,并将yStartingPoint设置为地图高度的一半,然后采用刚给定的roomWidth和roomHeight,我们从0到此宽度/高度的随机值,然后从初始x和y中减去它。像这样:接下来,在相同的功能中,我们将添加墙壁。我们需要初始化新创建的room变量中的墙数组,然后初始化该数组中的每个wall变量。然后初始化每个位置列表,将墙的长度设置为0,然后输入每个墙“看”的方向。数组初始化之后,我们在for()循环中循环数组的每个元素,初始化每个墙的变量,然后使用开关命名每个墙的方向。它是任意选择的,我们只需要记住它们的含义即可。现在,我们将在放置壁后立即执行两个嵌套的for循环。在外循环中,我们在房间中遍历所有y值,在嵌套循环中遍历所有x值。这样,我们将检查y行中的每个单元x,以便我们可以实现它。然后要做的第一件事是从房间位置中找到地图比例尺上单元格位置的实际值。这很简单:我们有起点x和y。它们在房间网格中的位置为0,0。然后,如果我们需要从任何局部x,y获取x,y的实际值,则我们将局部x和y与初始位置x和y相加。然后我们将这些真实的x,y值保存到Position变量中(来自先前创建的类),然后将它们添加到房间位置的List <>中。下一步是将此信息添加到地图。更改值之前,请记住初始化Tile变量。现在,我们将更改Tile类。让我们转到MapManager脚本,然后在Tile类的定义中添加一行:“ public string type;”。这将允许我们通过声明x,y中的图块是墙,地板或其他东西来添加图块类。接下来,让我们回到完成工作的周期,并添加一个大型的if-else构造,这不仅使我们能够确定每个墙,墙的长度和在该墙中的所有位置,而且还可以在全局地图上指定特定的图块-墙或性别。而且我们已经做了一些事情。如果变量y(外部循环中变量的控制)为0,则图块属于房间中最低的单元格行,即它是南墙。如果x(内部循环变量的控件)为0,则图块属于单元格的最左列,即它是西墙。如果它在最上面,则它属于北墙,而在最右边-东墙。我们从变量roomWidth和roomHeight中减去1,因为这些值是从1开始计算的,而循环的x和y变量是从0开始的,所以我们需要考虑这一差异。所有不符合条件的单元都不是墙,也就是说,它们是地板。太好了,我们几乎已经完成了第一个房间。几乎准备就绪,我们只需要将最后的值放入我们创建的变量Feature中即可。我们退出循环并结束函数,如下所示:精细!我们有一个房间!但是我们如何理解一切正常呢?需要测试。但是如何测试?我们可以为此花费时间并添加资产,但这将浪费时间,并且也使我们无法完成算法。嗯,但这可以使用ASCII来完成!是的,好主意!ASCII是一种绘制地图的简单且低成本的方法,因此可以对其进行测试。另外,如果您愿意,也可以跳过带有精灵和视觉效果的部分,稍后我们将进行研究,并使用ASCII创建整个游戏。因此,让我们看看这是如何完成的。阶段6-绘制第一个房间
实施ASCII卡时首先要考虑的是选择哪种字体。选择ASCII字体时要考虑的主要因素是它是成比例的(可变宽度)还是等宽的(固定宽度)。我们需要等宽字体,以便卡片根据需要显示(请参见下面的示例)。默认情况下,任何新的Unity项目都使用Arial字体,并且它不是等宽的,因此我们需要找到另一个字体。 Windows 10通常具有等距字体的Courier New,Consolas和Lucida Console。从这三个中选择一个,或在所需的位置下载其他任何文件,并将其放置在项目的Assets文件夹内的Fonts文件夹中。让我们为ASCII输出准备场景。对于初学者,请将场景主摄像机的背景色设为黑色。然后,我们将Canvas对象添加到场景中,并将Text对象添加到场景中。将“文本”矩形的变换设置为中间居中并设置为0,0,0。设置Text对象,使其使用您选择的字体和白色,水平和垂直溢出(水平/垂直溢出),选择Overflow,并使垂直和水平对齐居中。然后将Text对象重命名为“ ASCIITest”或类似名称。现在回到代码。在DungeonGenerator脚本中,创建一个名为DrawMap的新函数。我们希望她得到一个参数来告诉要生成的卡-ASCII或精灵,因此创建一个布尔参数并将其称为isASCII。然后,我们将检查渲染的地图是否为ASCII。如果是(现在,我们将仅考虑这种情况),那么我们将在场景中搜索文本对象,将为其提供的名称作为参数传递,并获取其Text组件。但是首先,我们需要告诉Unity我们要使用UI。使用UnityEngine.UI将行添加到脚本头中:精细。现在我们可以获取对象的Text组件。地图将是一条巨大的线,它将以文本形式反映在屏幕上。这就是为什么它如此容易设置的原因。因此,让我们创建一个字符串,并使用值“”对其进行初始化。精细。因此,每次调用DrawMap时,我们都需要告知卡是否为ASCII。如果是这样(并且我们将始终以这种方式使用它,我们将在以后与其他方法一起使用),则该函数将搜索场景层次以搜索名为“ ASCIITest”的游戏对象。如果是的话,它将接收其Text组件并将其保存到screen变量,然后我们可以在其中轻松编写地图。然后,它创建一个字符串,其值最初为空。我们将用标记有符号的地图填充此行。通常,我们会从0开始一直到地图长度的末尾循环遍历地图。但是要填充这一行,我们从文本的第一行开始,即最顶层。因此,在y轴上,我们需要沿相反的方向在循环中移动,即从数组的末端到起点。但是数组的x轴与文本一样从左到右,所以这适合我们。在此循环中,我们检查地图的每个单元以找出其中的内容。到目前为止,我们仅将单元格初始化为一个新的Tile(),我们将其剪切为整个房间,因此其他所有人在尝试访问时都会返回错误。因此,首先我们需要检查此单元格中是否有任何东西,然后通过检查该单元格是否为null来进行此操作。如果不为null,则继续工作,但是如果为null,则内部没有任何内容,因此我们可以在地图上添加空白区域。因此,对于每个非空单元格,我们检查其类型,然后添加相应的符号。我们希望墙壁用符号“#”表示,地板用符号“。”表示。虽然我们只有这两种类型。后来,当我们添加玩家,怪物和陷阱时,一切都会变得更加复杂。另外,我们需要在到达数组行的末尾时执行换行符,以便具有相同x位置的单元格彼此直接位于下方。我们将在循环的每次迭代中检查单元格是否为行中的最后一个单元格,然后添加带有特殊字符“ \ n”的换行符。就这样。然后我们退出循环,以便可以在完成后将这一行添加到场景中的文本对象。恭喜你!您已经完成了创建房间并将其显示在屏幕上的脚本。现在,我们只需要执行这些操作即可。我们不使用DungeonGenerator脚本中的Start(),因为我们希望有一个单独的脚本来控制游戏开始时执行的所有操作,包括生成地图,以及设置玩家,敌人等。因此,该其他脚本将包含Start()函数,并且,如有必要,将调用我们脚本的函数。 DungeonGenerator脚本具有一个Initialize函数,该函数是公共的,而FirstRoom和DrawMap不是公共的。 Initialize只是简单地初始化变量以自定义地牢生成过程,因此我们需要另一个函数来调用生成过程,该函数必须是公共的,以便可以从其他脚本中调用它。现在,她将只调用FirstRoom()函数,然后调用DrawMap()函数,并为其传递一个真值,以便绘制ASCII映射。哦,不是,它甚至更好-让我们创建一个公共变量isASCII,可以将其包含在检查器中,然后只需将此变量作为参数传递给函数即可。精细。因此,现在让我们创建一个GameManager脚本。这将是控制游戏所有高级元素的脚本,例如,创建地图和移动过程。让我们删除其中的Update()函数,添加一个名为DungeonGenerator的DungeonGenerator类型的变量,并在Start()函数中创建此变量的实例。之后,我们只需从dungeonGenerator 依次调用InitializeDungeon()和GenerateDungeon()函数。这很重要-首先您需要初始化变量,然后才开始基于变量进行构建。至此,代码完成。我们需要在层次结构面板中创建一个空的游戏对象,将其重命名为GameManager并将GameManager和DungeonGenerator脚本附加到该对象。然后在检查器中设置地牢生成器的值。您可以为生成器尝试不同的方案,而我决定这样做:现在,只需单击播放并观看魔术!您应该在游戏屏幕上看到类似的内容:恭喜,我们现在有房间!我想让我们把玩家的角色放在那里,让他移动,但是这个职位已经很长了。因此,在下一部分中,我们可以直接进行其余地牢算法的实现,也可以将玩家放置其中并教其如何移动。在原始文章的评论中投票给您最喜欢的东西。MapManager.cs:using System.Collections;
using System;
using System.Collections.Generic;
using UnityEngine;
public class MapManager {
public static Tile[,] map;
}
[Serializable]
public class Tile {
public int xPosition;
public int yPosition;
[NonSerialized]
public GameObject baseObject;
public string type;
}
[Serializable]
public class Position {
public int x;
public int y;
}
[Serializable]
public class Wall {
public List<Position> positions;
public string direction;
public int length;
public bool hasFeature = false;
}
[Serializable]
public class Feature {
public List<Position> positions;
public Wall[] walls;
public string type;
public int width;
public int height;
}
DungeonGenerator.cs:using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class DungeonGenerator : MonoBehaviour
{
public int mapWidth;
public int mapHeight;
public int widthMinRoom;
public int widthMaxRoom;
public int heightMinRoom;
public int heightMaxRoom;
public int maxCorridorLength;
public int maxFeatures;
public bool isASCII;
public void InitializeDungeon() {
MapManager.map = new Tile[mapWidth, mapHeight];
}
public void GenerateDungeon() {
FirstRoom();
DrawMap(isASCII);
}
void FirstRoom() {
Feature room = new Feature();
room.positions = new List<Position>();
int roomWidth = Random.Range(widthMinRoom, widthMaxRoom);
int roomHeight = Random.Range(heightMinRoom, heightMaxRoom);
int xStartingPoint = mapWidth / 2;
int yStartingPoint = mapHeight / 2;
xStartingPoint -= Random.Range(0, roomWidth);
yStartingPoint -= Random.Range(0, roomHeight);
room.walls = new Wall[4];
for (int i = 0; i < room.walls.Length; i++) {
room.walls[i] = new Wall();
room.walls[i].positions = new List<Position>();
room.walls[i].length = 0;
switch (i) {
case 0:
room.walls[i].direction = "South";
break;
case 1:
room.walls[i].direction = "North";
break;
case 2:
room.walls[i].direction = "West";
break;
case 3:
room.walls[i].direction = "East";
break;
}
}
for (int y = 0; y < roomHeight; y++) {
for (int x = 0; x < roomWidth; x++) {
Position position = new Position();
position.x = xStartingPoint + x;
position.y = yStartingPoint + y;
room.positions.Add(position);
MapManager.map[position.x, position.y] = new Tile();
MapManager.map[position.x, position.y].xPosition = position.x;
MapManager.map[position.x, position.y].yPosition = position.y;
if (y == 0) {
room.walls[0].positions.Add(position);
room.walls[0].length++;
MapManager.map[position.x, position.y].type = "Wall";
}
else if (y == (roomHeight - 1)) {
room.walls[1].positions.Add(position);
room.walls[1].length++;
MapManager.map[position.x, position.y].type = "Wall";
}
else if (x == 0) {
room.walls[2].positions.Add(position);
room.walls[2].length++;
MapManager.map[position.x, position.y].type = "Wall";
}
else if (x == (roomWidth - 1)) {
room.walls[3].positions.Add(position);
room.walls[3].length++;
MapManager.map[position.x, position.y].type = "Wall";
}
else {
MapManager.map[position.x, position.y].type = "Floor";
}
}
}
room.width = roomWidth;
room.height = roomHeight;
room.type = "Room";
}
void DrawMap(bool isASCII) {
if (isASCII) {
Text screen = GameObject.Find("ASCIITest").GetComponent<Text>();
string asciiMap = "";
for (int y = (mapHeight - 1); y >= 0; y--) {
for (int x = 0; x < mapWidth; x++) {
if (MapManager.map[x,y] != null) {
switch (MapManager.map[x, y].type) {
case "Wall":
asciiMap += "#";
break;
case "Floor":
asciiMap += ".";
break;
}
} else {
asciiMap += " ";
}
if (x == (mapWidth - 1)) {
asciiMap += "\n";
}
}
}
screen.text = asciiMap;
}
}
}
GameManager.cs:using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GameManager : MonoBehaviour
{
DungeonGenerator dungeonGenerator;
void Start() {
dungeonGenerator = GetComponent<DungeonGenerator>();
dungeonGenerator.InitializeDungeon();
dungeonGenerator.GenerateDungeon();
}
}