构建2D游戏:地精寻宝

思考并回答以下问题:

本章涵盖:

本章将构建一个卷轴动作游戏,图1是该游戏的一个截图。此游戏很大程度上依赖于Unity的2D图形和物理功能,另外还大量使用了UI系统。

图1:最终的游戏效果

游戏设计

游戏玩法很简单。玩家控制一个地精,用一根绳索将其坠到一个井中,井底有宝藏。井壁上有一些陷阱,地精碰到陷阱后就会死亡。

首先创建一个非常粗糙的草图,展示游戏将会是什么样子。使用纸和笔就可以,这里的目标是尽快形成一个大概的想法,知道如何把游戏的各个部分组织起来。图2显示了草图。

图2:游戏的简略概念图

确定了游戏的最终效果后,还需要确定总体架构。首先确定哪些对象是可见的,以及它们之间的关系是什么。同时,我们思考“不可见的”组件如何工作:如何收集输入信息,游戏的内部管理器如何彼此通信。

最后,还要考虑游戏的视觉效果。我们找到了一个艺术家朋友,请他绘制一幅地精下到井中、遭遇陷阱威胁的图片。这让我们意识到主要角色可能是什么样子,并设定了游戏的总体基调:轻松的、卡通风格的、稍微有点暴力的游戏,主角是贪婪的地精。最终概念图如图3所示。

图3:地精角色的概念图

如果不认识艺术家,就自己绘图!也许你觉得自己画得不好,但是只要能表达游戏看起来是什么样子,任何想法都比没有想法要好。

完成了初步设计后,就能够开始确定一些需要实现的东西:地精在游戏中如何移动,如何设置界面,以及游戏对象如何联系起来。

为使地精能够下到井中,可给玩家提供3个按钮:一个增加绳索的长度,一个缩短绳索的长度,还有一个显示游戏的菜单。按下增加绳索长度的按钮,地精将在井中下降。为了在下降过程中避开陷阱,玩家需要左右倾斜设备,使地精左右移动。

游戏玩法主要是2D物理模拟的结果。地精是一个“布娃娃”(ragdoll),即通过关节连接的部位集合,每个部位是一个独立模拟的刚体。这意味着当通过Rope(绳索)对象连接到井的顶部时,地精将正确摆动。

绳索是通过类似的方式创建的:绳索是刚体的集合,各个刚体通过关节彼此连接。刚体链中的第一段连接到井口,并通过一个旋转的关节连接到第二段。第二段连接到第三段,第三段连接到第四段,以此类推,直到最后一段连接到地精的脚踝。要延长绳索,就在绳索顶部添加更多绳段;要缩短绳索,就移除绳段。

游戏玩法的剩余部分通过非常直观的碰撞检测来处理。

  • 如果地精的任何部位触碰到陷阱对象,地精将死亡,然后创建一个新的地精。另外,创建一个鬼魂精灵,沿着井向上移动。
  • 如果触碰到宝藏,就更新地精的精灵,显示它抱着宝藏。
  • 如果触碰到井口(不可见对象),并且地精抱着宝藏,那么玩家获胜。

除了地精、陷阱和宝藏,游戏摄像机的脚本也在运行,使摄像机的位置关联到地精的纵向位置,但也会阻止摄像机显示高于井口或者低于井底的任何东西。

我们构建这个游戏的方式如下。

  • (1) 首先,暂时使用简单的火柴人来创建地精。我们将设置布娃娃,并连接精灵。
  • (2) 接下来,设置绳索。这里涉及第一段大块代码,因为绳索是在运行时生成的,并且需要支持绳索的延长和缩短。
  • (3) 设置好绳索后,将创建输入系统。输入系统将接收关于设备如何倾斜的信息,并将此信息提供给游戏的其他部分(特别是地精)使用。同时,我们将构建游戏的用户界面,并创建延长和缩短绳索的按钮。
  • (4) 创建好绳索、地精和输入系统后,就可以开始实际创建游戏了。我们将实现陷阱和宝藏,并开始玩游戏。
  • (5) 剩下的就是锦上添花了:将地精的精灵替换为更加复杂的精灵,添加粒子效果,以及添加整体音效。

图4:第一个粗糙版本完成后的游戏效果

在完成这个项目的过程中,我们将向游戏对象添加大量组件,并调整属性值。除了我们告诉你需要修改的组件之外,还有大量组件可以调整,因此你可以自由修改任何东西的设置来查看效果,否则就保留默认设置。

创建项目并导入资源

(1) 创建一个新2D项目,命名为GnomesWell。

(2) 创建项目中的文件夹。

  • Scripts
  • Scenes
  • Sounds
  • Sprites
    • 此文件夹将包含所有精灵图像。
  • Gnome
    • 此文件夹将包含地精角色需要的预设,以及其他相关的对象,例如绳索、粒子效果和鬼魂。
  • Level
    • 此文件夹将包含关卡自身的预设,包括背景、井壁、装饰用的对象和陷阱。
  • App Resources
    • 此文件夹将包含应用作为一个整体需要的资源:应用图标及其闪屏。

完成创建文件夹的工作后,Assets文件夹应该如图5所示。

图5:创建文件夹之后的Assets文件夹

(3) 导入原型地精资源。原型地精是地精的粗糙版本。我们首先构建这个版本,后面将把它替换为更加美观的精灵。

在下载的资源中,找到Prototype Gnome文件夹,将其拖放到Unity的Sprites文件夹中,如图6所示。

图6:地精的原型精灵

(4)将场景保存为Main.scene。

现在就可以开始构造地精了。

创建地精

因为地精将由多个独立移动的对象组成,所以我们需要先创建一个对象,作为每个身体部位的容器。还需要给此对象添加Player标签,因为用来检测地精何时触碰到陷阱、宝藏或者关卡出口的碰撞检测系统需要知道该对象是否为特殊的Player对象。构建地精的步骤如下。

(1) 创建Prototype Gnome对象。打开GameObject菜单并选择Create Empty,创建一个新的空游戏对象。将新对象命名为Prototype Gnome,然后从Inspector顶部的Tag下拉列表中选择Player,将该对象的标签设为Player。重置其Transform。

(2) 添加精灵。找到前面添加的Prototype Gnome文件夹,将每个精灵拖放到场景中,但是不要拖放Prototype Arm Holding with Gold,这个精灵以后才会用到。

你需要单独拖放每个精灵。如果选择所有精灵,试图把它们一次全部拖放到场景中,那么 Unity会认为你在试图拖放一系列图像,从而创建一个动画。

完成拖放操作后,场景中将有6个新精灵:Prototype Arm Holding、Prototype Arm Loose、Prototype Body、Prototype Head、Prototype Leg Dangle和Prototype Leg Rope。

(3) 将精灵设置为Prototype Gnome对象的子对象。在Hierarchy窗格中,选择刚才添加的全部精灵,把它们拖放到空的Prototype Gnome对象上。完成之后,Hierarchy应该如图7所示。

图6:在 Hierarchy 中,地精精灵已被附加为Prototype Gnome对象的子对象

(4) 设置精灵的位置。添加精灵后,需要正确设置它们的位置,把胳膊、腿和头连接到躯干上。在场景视图中,单击工具栏中的Move工具或者按下T键,选择Move工具。

使用Move工具重新排列精灵,使其如图8所示。

图8:原型地精的精灵

另外,让全部精灵使用Player标签,就像其父对象一样。最后,确保每个对象的z位置为0。在每个对象的Transform组件(位于Inspector顶部)的Position字段中,可看到其z位置。

(5) 向躯干部位添加Rigidbody 2D组件。选中全部身体部位精灵,然后在Inspector中单击Add Component 按钮。在搜索框中输入Rigidbody,添加一个Rigidbody 2D组件(如图9所示)。

一定要添加“Rigidbody 2D”组件,而不是“Rigidbody”。Rigidbody组件在3D空间中完成模拟,这并不适合现在这个游戏。

另外,确保仅在精灵上添加Rigidbody 2D。不要在父对象Prototype Gnome上添加刚体。

图 4-10:在精灵上添加 Rigidbody 2D 组件

(6) 在身体部位上添加碰撞器。碰撞器定义了对象的物理形状。因为身体部位在视觉上有不同的形状,所以不同的身体部位需要不同形状的碰撞器。
a. 选择手臂和腿部精灵,添加一个Box Collider 2D组件。
b. 选择头部精灵,添加一个Circle Collider 2D组件。保留其半径不变。
c. 选择Body精灵,添加一个Circle Collider 2D组件。然后,进入该碰撞器的Inspector,将其半径(radius)减小大约一半,以适应Body精灵。

现在就可以把地精及其身体部位链接起来了。身体部位之间的链接将通过Hinge Joint 2D关节完成,该关节允许对象相对于另一对象围绕中心点旋转。腿部、手臂和头部都将链接到躯干。关节的配置步骤如下所示。
(1) 选择除躯干之外的所有精灵。躯干自身不需要任何关节,其他身体部位将通过各自的关节链接到躯干。
(2) 给所有选中的精灵添加Hinge Joint 2D组件。通过单击Inspector底部的Add Component按钮,然后选择Physics 2D → Hinge Joint 2D完成。
(3) 配置关节。在仍然选中精灵的状态下,我们将设置一个所有身体部位都具备的属性:这些部位都将连接到Body精灵。

将Prototype Body从Hierarchy窗格拖放到Connected Rigid Body框中,从而把对象链接到躯干。完成之后,铰链关节的设置应该如图10所示。

图 4-11:铰链关节的初始设置

(4) 对关节添加限制。我们不希望对象能够旋转整圈,而想限制它们的旋转程度。这可以避免一些看上去不符合实际的行为,例如腿部从身体中穿过。
选择手臂和头部,然后选中Use Limits。将Lower Angle设为-15,Upper Angle设为15。

接下来,选择腿部,同样选中Use Limits。将Lower Angle设为-45,Upper Angle设为0。

(5) 更新关节的轴点。我们想让手臂绕肩膀旋转,让腿部绕臀部旋转。但默认情况下,关节将绕对象的中心旋转(如图 4-12 所示),这样看起来会很奇怪。

图 4-12:铰链关节的锚点的起始位置不正确

为了纠正这个问题,需要更新关节的 Anchor(锚点)以及 Connected Anchor(连接处锚点)的位置。拥有关节的身体部位将绕Anchor旋转,而关节连接到的身体部位则绕着Connected Anchor旋转。对于地精的关节,我们想让 Connected Anchor 和 Anchor 在同一个位置。

当选择一个有铰链关节的对象时,Anchor 和 Connected Anchor 都会显示在场景视图中:
Connected Anchor 显示为一个蓝点,Anchor 显示为一个蓝色的圆圈。
选择有铰链关节的每个身体部位,将 Anchor 和 Connected Anchor移动到正确的轴点。
例如,选择右臂,将蓝点拖动到肩膀位置,这就移动了Connected Anchor。
移动Anchor要稍微困难一点,因为默认情况下Anchor位于对象的中心,但是拖动对象的中心会导致 Unity 移动整个对象。要移动Anchor,首先需要手动调整Anchor的位置:修改Inspector中的数字,可改变 Anchor 在场景视图中的位置。当Anchor不在对象的中心位置后,就可以将其拖动到正确的位置,就像拖动Connected Anchor一样(如图 4-13 所示)。

对双臂(连接到肩膀位置)、双腿(连接到臀部位置)和头部(连接到脖子底部)重复这个过程。

图 4-13:左臂的锚点在正确的位置;注意,点的周围有一个环,这说明 Connected Anchor 和 Anchor在同一个位置接下来添加连接到 Rope 对象的关节。这是一个连接到地精右腿的 Spring Joint 2D,允许绕着关节的锚点自由旋转,并限制身体离开绳索一端的距离(下一节将创建绳索)。弹簧关节的工作方式类似于现实世界中的弹簧:有弹性,可被适度拉伸。

在 Unity 中,弹簧关节主要由两个属性控制:距离和频率。距离指的是弹簧的“首选”长度,即压缩或拉伸之后,弹簧应当恢复到的长度。频率指的是弹簧的“刚度”,较低的值意味着弹簧更松。

为设置 Rope 中使用的弹簧关节,执行下面的步骤。

(1) 添加绳索关节。选择 Prototype Leg Rope。这是右上腿部精灵。
(2) 向其添加弹簧关节。添加一个 Spring Joint 2D。将其Anchor(蓝色圆圈)移动到靠近腿的一端。不要移动 Connected Anchor(即移动蓝色圆圈,不移动蓝点)。图 4-14 显示了地精的锚点位置。

图 4-14:添加精灵关节,以将腿部连接到绳索;关节的Anchor靠近脚趾

(3) 配置关节。关闭Auto Configure Distance,将关节的Distance设为0.01,将Frequency 设为5。

(4) 运行游戏。地精将在屏幕中部悬荡。

最后一步是缩小地精,使其以合适的大小显示在其他关卡对象旁边。

(5) 缩放地精。选择父Gnome对象,将其Scale值的x和y值改为0.5。这将使地精缩小一半。

现在就准备好了地精,可以添加绳索了。

绳索

绳索是游戏中第一个需要用到代码的地方,其原理如下。绳索是游戏对象的一个集合,每个游戏对象都具有刚体和弹簧关节。每个弹簧关节链接到下一个Rope对象,该对象又链接到下一个Rope对象,一直链接到绳索顶端。最上面的Rope对象链接到一个位置固定的刚体,从而保持自己固定在一个位置。绳索另一端连接到地精的一个组件:Rope Leg对象。

为创建绳索,首先需要创建一个对象,作为绳索每一段的模板。然后,创建另外一个对象,它将使用此绳段对象和一些代码来生成整条绳索。执行下面的步骤来准备Rope Segment。

  • (1) 创建Rope Segment对象。创建一个新的空游戏对象,命名为Rope Segment。
  • (2) 向该对象添加刚体。添加一个Rigidbody 2D,将其Mass设为0.5,这样绳索将有一些重量感。
  • (3) 添加关节。添加一个Spring Joint 2D组件,将其Damping Ratio设为1,Frequency设为30。

可以自由尝试其他值。我们发现,上面设置的这些值能够让绳索比较有真实感。游戏设计的关键就在于不断调整数字

  • (4) 创建一个使用此对象的预设。打开Assets窗格中的Gnome文件夹,将Rope Segment对象从Hierarchy窗格拖放到Assets窗格。这将在Assets文件夹中创建一个新的预设文件。
  • (5) 删除原来的Rope Prefab对象。现在已经不需要原来的对象了:我们马上编写代码来创建Rope Segment的多个实例,并把它们连接成整条绳索。

现在来创建Rope对象。

  • (1) 创建一个新的空游戏对象,命名为Rope。
  • (2) 改变Rope的图标。因为游戏没有运行时,场景视图中是看不到绳索的,所以我们需要为绳索设置一个图标。选择新创建的Rope对象,然后单击Inspector左上角的立方体图标(如图 15所示)。

图15:为Rope对象选择一个图标

选择红色的圆角矩形,Rope对象将在场景中显示为一个红色的药片形状的对象(如图16所示)。

  • (3) 添加刚体。单击Add Components按钮,向对象添加一个Rigidbody 2D组件。添加此刚体后,在 Inspector 中将Body Type改为Kinematic。这将把对象固定在该位置,从而不会下落——这正是我们想要的效果。
  • (4) 添加一个线渲染器。再次单击Add Component按钮,添加一个LineRenderer。将新线渲染器的Width设为0.075,使其具有好看的、细细的绳索外观。保留线渲染器其余设置的默认值不变。

图16:选择图标后,Rope对象将出现在场景中

现在就设置好了绳索的组件,可以编写脚本来控制它们了。

编写控制Rope的代码

此处看过了,不需要内容

Rope.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
using UnityEngine;
using System.Collections;
using System.Collections.Generic;

// 连接的绳索
public class Rope : MonoBehaviour
{
// 要使用的Rope Segment预设
public GameObject ropeSegmentPrefab;

// 包含Rope Segment对象的一个List
List<GameObject> ropeSegments = new List<GameObject>();

// 现在是在延长还是缩短绳索?
public bool isIncreasing { get; set; }
public bool isDecreasing { get; set; }

// 绳索末端应该关联到的rigidbody对象
public Rigidbody2D connectedObject;

// 一段绳索的最大长度(如果需要伸长的程度超过此长度,就创建一个新的绳段)
public float maxRopeSegmentLength = 1.0f;

// 松开新绳索的速度
public float ropeSpeed = 4.0f;

// 渲染实际绳索的LineRenderer
LineRenderer lineRenderer;

void Start()
{
// 缓存线渲染器,这样就不需要每一帧都进行查找
lineRenderer = GetComponent<LineRenderer>();

// 重置绳索,做好准备
ResetLength();
}

// 删除所有绳段,然后创建一个新的绳段
public void ResetLength()
{
foreach (GameObject segment in ropeSegments)
{
Destroy (segment);
}

ropeSegments = new List<GameObject>();

isDecreasing = false;
isIncreasing = false;

CreateRopeSegment();

}

// 将新绳段关联到绳索的顶部
void CreateRopeSegment()
{
// 创建新绳段
GameObject segment = (GameObject)Instantiate(ropeSegmentPrefab, this.transform.position, Quaternion.identity);

// 使绳段成为此对象的子对象,并使其保留其世界位置
segment.transform.SetParent(this.transform, true);

// 获取绳段的刚体
Rigidbody2D segmentBody = segment.GetComponent<Rigidbody2D>();

// 获取绳段的距离关节
SpringJoint2D segmentJoint = segment.GetComponent<SpringJoint2D>();

// 如果绳段没有刚体或者弹簧关节,就显示错误,因为二者都是我们需要的
if (segmentBody == null || segmentJoint == null)
{
Debug.LogError("Rope segment body prefab has no " + "Rigidbody2D and/or SpringJoint2D!");

return;
}

// 检查后,将其添加到绳段List的开始位置
ropeSegments.Insert(0, segment);

// 如果是“第一个”绳段,需要将其连接到地精

if (ropeSegments.Count == 1)
{
// 将连接对象上的关节连接到绳段
SpringJoint2D connectedObjectJoint = connectedObject.GetComponent<SpringJoint2D>();

connectedObjectJoint.connectedBody = segmentBody;
connectedObjectJoint.distance = 0.1f;

// 将此关节设置为最大长度
segmentJoint.distance = maxRopeSegmentLength;
}
else
{
// 这是一个额外的绳段。我们需要把顶端绳段连接到此绳段

// 获取第二个绳段
GameObject nextSegment = ropeSegments[1];

// 获取我们需要关联到的绳段
SpringJoint2D nextSegmentJoint = nextSegment.GetComponent<SpringJoint2D>();

// 连接此关节
nextSegmentJoint.connectedBody = segmentBody;

// 使此绳段的开始位置距离前一个绳段0个单位——我们将拉长前一个绳段
segmentJoint.distance = 0.0f;
}

// 将新绳段连接到绳索的锚点(即此对象)
segmentJoint.connectedBody = this.GetComponent<Rigidbody2D>();
}

// 缩短绳索后调用,以移除一个绳段
void RemoveRopeSegment() {

// 如果没有两个以上的绳段,就停止移除操作
if (ropeSegments.Count < 2)
{
return;
}

// 获取顶端绳段及其下方的那个绳段
GameObject topSegment = ropeSegments[0];
GameObject nextSegment = ropeSegments[1];

// 将第二个绳段连接到绳索的锚点
SpringJoint2D nextSegmentJoint = nextSegment.GetComponent<SpringJoint2D>();

nextSegmentJoint.connectedBody = this.GetComponent<Rigidbody2D>();

// 删除并销毁顶端的绳段
ropeSegments.RemoveAt(0);
Destroy (topSegment);
}

// 在每一帧中,根据需要增加或者减小绳索的长度
void Update()
{
// 获取顶端绳段及其关节
GameObject topSegment = ropeSegments[0];
SpringJoint2D topSegmentJoint = topSegment.GetComponent<SpringJoint2D>();

if (isIncreasing)
{
// 我们在增长绳索。如果绳段到达最大长度,就添加新绳段,否则,增加顶端绳段的长度
if (topSegmentJoint.distance >= maxRopeSegmentLength)
{
CreateRopeSegment();
}
else
{
topSegmentJoint.distance += ropeSpeed * Time.deltaTime;
}
}

if (isDecreasing)
{

// 我们在缩短绳索。如果绳段长度接近于0,则删除绳段,否则,减小顶端绳段的长度
if (topSegmentJoint.distance <= 0.005f)
{
RemoveRopeSegment();
}
else
{
topSegmentJoint.distance -= ropeSpeed * Time.deltaTime;
}

}

if (lineRenderer != null)
{
// 线渲染器根据一个点的集合绘制线。
// 必须使这些点与绳段的位置保持同步

// 线渲染器的顶点数 = 绳段数 + 顶端的一个点(代表绳段的锚点) + 底端的第一个点(代表地精)
lineRenderer.positionCount = ropeSegments.Count + 2;

// 顶端的顶点总是位于绳索的位置
lineRenderer.SetPosition(0, this.transform.position);

// 对于每个绳段,使对应的线渲染器顶点位于该绳段的位置
for (int i = 0; i < ropeSegments.Count; i++)
{
lineRenderer.SetPosition(i+1, ropeSegments[i].transform.position);
}

// 最后的顶点位于连接对象的锚点
SpringJoint2D connectedObjectJoint = connectedObject.GetComponent<SpringJoint2D>();
lineRenderer.SetPosition(ropeSegments.Count + 1, connectedObject.transform.TransformPoint(connectedObjectJoint.anchor)
);
}
}
}

创建地精的代码

BodyPart.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
using UnityEngine;
using System.Collections;

[RequireComponent (typeof(SpriteRenderer))]
public class BodyPart : MonoBehaviour
{
// The sprite to use when ApplyDamageSprite is called with
//damage type 'slicing'
public Sprite detachedSprite;

// The sprite to use when ApplyDamageSprite is called with
// damage type 'slicing'
public Sprite burnedSprite;

// Represents the position and rotation that a blood fountain will
// appear at on the main body
public Transform bloodFountainOrigin;

// If true, this object will remove its collision, joints and rigidbody
// when it comes to rest
bool detached = false;

// Decouple this object from the parent, and flag it as needing
// physics removal
public void Detach()
{
detached = true;

this.tag = "Untagged";

transform.SetParent(null, true);
}

// Every frame, if we're detached, remove physics if the rigidbody is
// sleeping. This means this detached body part will never get in the
// way of the gnome.
public void Update()
{

// If we're not detached, do nothing
if (detached == false)
{
return;
}

// Is our rigidbody sleeping?
var rigidbody = GetComponent<Rigidbody2D>();

if (rigidbody.IsSleeping())
{

// If so, destroy all joints..
foreach (Joint2D joint in
GetComponentsInChildren<Joint2D>())
{
Destroy (joint);
}

// ...rigidbodies...
foreach (Rigidbody2D body in
GetComponentsInChildren<Rigidbody2D>())
{
Destroy (body);
}

// ...and the collider.
foreach (Collider2D collider in GetComponentsInChildren<Collider2D>())
{
Destroy (collider);
}

// Finally, remove this script.
Destroy (this);
}
}

// Swaps out the sprite for this part based on what kind of
// damage was received
public void ApplyDamageSprite(Gnome.DamageType damageType)
{
Sprite spriteToUse = null;

switch (damageType)
{

case Gnome.DamageType.Burning:
spriteToUse = burnedSprite;

break;

case Gnome.DamageType.Slicing:
spriteToUse = detachedSprite;

break;
}

if (spriteToUse != null)
{
GetComponent<SpriteRenderer>().sprite = spriteToUse;
}

}

}

Gnome.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
using UnityEngine;
using System.Collections;

public class Gnome : MonoBehaviour
{

// The object that the camera should follow.
public Transform cameraFollowTarget;

public Rigidbody2D ropeBody;

public Sprite armHoldingEmpty;
public Sprite armHoldingTreasure;

public SpriteRenderer holdingArm;

public GameObject deathPrefab;
public GameObject flameDeathPrefab;
public GameObject ghostPrefab;

public float delayBeforeRemoving = 3.0f;
public float delayBeforeReleasingGhost = 0.25f;

public GameObject bloodFountainPrefab;

bool dead = false;

bool _holdingTreasure = false;

public bool holdingTreasure
{
get
{
return _holdingTreasure;
}
set
{
if (dead == true)
{
return;
}

_holdingTreasure = value;

if (holdingArm != null)
{
if (_holdingTreasure)
{
holdingArm.sprite = armHoldingTreasure;
} else
{
holdingArm.sprite = armHoldingEmpty;
}
}

}
}

public enum DamageType
{
Slicing,
Burning
}

public void ShowDamageEffect(DamageType type)
{
switch (type)
{

case DamageType.Burning:
if (flameDeathPrefab != null)
{
Instantiate(
flameDeathPrefab,cameraFollowTarget.position,
cameraFollowTarget.rotation);
}
break;

case DamageType.Slicing:
if (deathPrefab != null)
{
Instantiate(
deathPrefab,
cameraFollowTarget.position,
cameraFollowTarget.rotation
);
}
break;
}
}

public void DestroyGnome(DamageType type)
{
holdingTreasure = false;

dead = true;

// find all child objects, and randomly disconnect their joints
foreach (BodyPart part in GetComponentsInChildren<BodyPart>())
{

switch (type)
{

case DamageType.Burning:
// 1 in 3 chance of burning
bool shouldBurn = Random.Range (0, 2) == 0;
if (shouldBurn)
{
part.ApplyDamageSprite(type);
}
break;

case DamageType.Slicing:
// Slice damage always applies a damage sprite
part.ApplyDamageSprite (type);

break;
}

// 1 in 3 chance of separating from body
bool shouldDetach = Random.Range (0, 2) == 0;

if (shouldDetach)
{
// Make this object remove its rigidbody and
// collider after it comes to rest
part.Detach ();

// If we're separating, and the damage type was
// Slicing, add a blood fountain

if (type == DamageType.Slicing)
{
if (part.bloodFountainOrigin != null &&
bloodFountainPrefab != null)
{
// Attach a blood fountain for
// this detached part
GameObject fountain = Instantiate(
bloodFountainPrefab,
part.bloodFountainOrigin.position,
part.bloodFountainOrigin.rotation
) as GameObject;

fountain.transform.SetParent(
this.cameraFollowTarget,
false
);
}
}

// Disconnect this object
var allJoints = part.GetComponentsInChildren<Joint2D>();
foreach (Joint2D joint in allJoints)
{
Destroy (joint);
}
}
}

// Add a Remove-After-Delay component to this object
var remove = gameObject.AddComponent<RemoveAfterDelay>();
remove.delay = delayBeforeRemoving;

StartCoroutine(ReleaseGhost());
}

IEnumerator ReleaseGhost()
{
// No ghost prefab? Bail out.
if (ghostPrefab == null)
{
yield break;
}

// Wait for delayBeforeReleasingGhost seconds
yield return new WaitForSeconds(delayBeforeReleasingGhost);

// Add the ghost
Instantiate(
ghostPrefab,
transform.position,
Quaternion.identity
);
}


}

设置GameManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
using UnityEngine;
using System.Collections;

// 管理游戏状态
public class GameManager : Singleton<GameManager>
{
// 地精显示的位置
public GameObject startingPoint;

// 绳索对象,将坠下和拉起地精
public Rope rope;

// The fade-out script (triggered when the game resets)
public Fade fade;

// 跟随脚本,将跟随地精
public CameraFollow cameraFollow;

// “当前的”地精(相对于所有死去的地精)
Gnome currentGnome;

// 需要新地精时将实例化的预设
public GameObject gnomePrefab;

// UI组件,包含restart和resume按钮
public RectTransform mainMenu;

// UI组件,包含up、down和menu按钮
public RectTransform gameplayMenu;

// UI组件,包含“you win!”界面
public RectTransform gameOverMenu;

// 如果为true,将忽略所有伤害(但仍显示伤害效果)
// “get;set;”决定了这是一个属性,将显示在Inspector中对应于Unity事件的方法列表中
public bool gnomeInvincible { get; set; }

// 地精死亡后等待多少秒来创建一个新地精
public float delayAfterDeath = 1.0f;

// 地精死亡时播放的音效
public AudioClip gnomeDiedSound;

// 玩家获胜时播放的音效
public AudioClip gameOverSound;

void Start()
{
// When the game starts, call Reset to set up the gnome.
Reset ();
}

// 重置整个游戏
public void Reset()
{
// 隐藏菜单,显示游戏UI
if (gameOverMenu)
gameOverMenu.gameObject.SetActive(false);

if (mainMenu)
mainMenu.gameObject.SetActive(false);

if (gameplayMenu)
gameplayMenu.gameObject.SetActive(true);

// 找到所有Resettable组件,告诉它们重置
var resetObjects = FindObjectsOfType<Resettable>();

foreach (Resettable r in resetObjects) {
r.Reset();
}

// 创建一个新地精
CreateNewGnome();

// 解除游戏暂停
Time.timeScale = 1.0f;
}

void CreateNewGnome()
{
// Remove the current gnome, if there is one
RemoveGnome();

// Create a new gnome object, and make it be our currentGnome
GameObject newGnome = (GameObject)Instantiate(gnomePrefab, startingPoint.transform.position, Quaternion.identity);
currentGnome = newGnome.GetComponent<Gnome>();

// Make the rope visible
rope.gameObject.SetActive(true);

// Connect the rope's trailing end to whichever rigidbody the
// Gnome object wants (e.g. his foot)
rope.connectedObject = currentGnome.ropeBody;

// Reset the rope's length to the default
rope.ResetLength();

// Tell the cameraFollow to start tracking the new gnome object
cameraFollow.target = currentGnome.cameraFollowTarget;

}

void RemoveGnome()
{
// 如果地精处于无敌状态,那么什么都不做
if (gnomeInvincible)
return;

// 隐藏绳索
rope.gameObject.SetActive(false);

// 停止跟随地精
cameraFollow.target = null;

// 如果有一个当前的地精,使该地精不再代表玩家
if (currentGnome != null)
{
// 此地精不再抱着宝藏
currentGnome.holdingTreasure = false;

// Mark this object as not the player (so that
// colliders won't report when the object
// hits them)
currentGnome.gameObject.tag = "Untagged";

// Find everything that's currently tagged "Player",
// and remove that tag
foreach (Transform child in currentGnome.transform) {
child.gameObject.tag = "Untagged";
}

// 把我们标记为当前没有地精
currentGnome = null;
}
}


// Kills the gnome.
void KillGnome(Gnome.DamageType damageType)
{
// If we have an audio source, play "gnome died" sound
var audio = GetComponent<AudioSource>();
if (audio) {
audio.PlayOneShot(this.gnomeDiedSound);
}

// Show the damage effect
currentGnome.ShowDamageEffect(damageType);

// If we're not invincible, reset the game and make
// the gnome not be the current player.
if (gnomeInvincible == false) {

// Tell the gnome that it died
currentGnome.DestroyGnome(damageType);

// Remove the Gnome
RemoveGnome();

// Reset the game
StartCoroutine(ResetAfterDelay());

}
}

// 地精死亡时调用
IEnumerator ResetAfterDelay()
{
// 等待delayAfterDeath秒,然后调用Reset
yield return new WaitForSeconds(delayAfterDeath);
Reset();
}

// Called when the player touches a trap
public void TrapTouched()
{
KillGnome(Gnome.DamageType.Slicing);
}

// Called when the player touches a fire trap
public void FireTrapTouched()
{
KillGnome(Gnome.DamageType.Burning);
}

// Called when the gnome picks up the treasure.
public void TreasureCollected()
{
// Tell the currentGnome that it should have the treasure.
currentGnome.holdingTreasure = true;
}

// Called when the player touches the exit.
public void ExitReached()
{
// If we have a player, and that player is holding treasure,
// game over!
if (currentGnome != null && currentGnome.holdingTreasure ==
true)
{

// If we have an audio source, play the game over sound
var audio = GetComponent<AudioSource>();
if (audio) {
audio.PlayOneShot(this.gameOverSound);
}

// Pause the game
Time.timeScale = 0.0f;

// Turn off the game over menu, and turn on the game
// over screen!
if (gameOverMenu)
gameOverMenu.gameObject.SetActive(true);

if (gameplayMenu)
gameplayMenu.gameObject.SetActive(false);
}
}


// Called when the Menu button is tapped, and when the Resume game is
// tapped.
public void SetPaused(bool paused)
{
// If we're paused, stop time and enable the menu (and disable
// the game overlay)
if (paused) {
Time.timeScale = 0.0f;
mainMenu.gameObject.SetActive(true);
gameplayMenu.gameObject.SetActive(false);
} else {
// If we're not paused, resume time and disable the
// menu (and enable the game overlay)
Time.timeScale = 1.0f;
mainMenu.gameObject.SetActive(false);
gameplayMenu.gameObject.SetActive(true);
}
}

// Called when the Restart button is tapped.
public void RestartGame()
{
// Immediately remove the gnome (instead of killing it)
Destroy(currentGnome.gameObject);
currentGnome = null;

// Now reset the game to create a new gnome.
Reset();
}
}

背景

(1)更新摄像机的大小。选择Main Camera对象,将摄像机的Ortho Size设为7,这将给玩家足够觉的视野,以便看到整个关卡。
(2)更新摄像机的限高。我们已经修改了摄像机能够看到的范围,还需要调整摄像机的限商。将摄像机的Top Limit改为11.5
我们还需要调整Bottom Limit,但是这里选择的值取决于井有多深。
确定这个值最好的方法是把地精下降到最低的地方。如果在到达井底之前,摄像机就停止移动,则减小Bottom Limit,如果摄像机超出了井底(显示了蓝色的背景),则增大
Bottom Limit. 在停止游戏之前,要记下这个值,因为游戏结束后会重设为原始值。停止游戏后,把刚才记下的数字输入Bottom Limit字段中。

用户界面

现在是时候改进游戏的UI了。前面在设置界面时,使用了Unity提供的标准按钮。虽然它们能够提供必要的功能,但是不太符合这个游戏的观感,因此需要用更好的东西来替换按钮图片。

另外,当地精到达井口时,需要显示一个Game Over画面;当玩家暂停游戏时,显示另外一个画面。

在继续设置之前,确保导入了本节需要用到的精灵。导入精灵的Interface文件夹,将其添加到Sprites文件夹中。

这些精灵被设计成高分辨率的图片,以便用在各种不同的场景中。为了使其在游戏中能用作按钮,将其添加到Canvas中时,Unity需要知道它们应该是什么尺寸。这可以通过调整这些精灵的Pixels Per Unit值做到,当把精灵添加到UI组件或者精灵渲染器时,该值控制着它们的比例。

选择Interface文件夹中的全部图片(但不要选择You-Win)以配置精灵。将Pixels Per Unit值改为2500。

首先处理当前位于窗口右下角的Up和Down按钮,将其更新为更美观的图片。为此,需要移除按钮上的标签,并调整每个按钮的大小和位置,以适应它们的新图片。需要执行的步骤如下所示:

(1)移除Down按钮的标签。找到Down Button对象,移除其Text子对象。

(2)更新精灵。选择Down Button对象,将Source Image属性改为Down精灵(位于Interface文件夹中)。

单击Set Native size按钮,按钮会调整自己的大小。

最后,调整按钮的位置,使其仍然位于画面的右不角。

(3)更新Up按钮。为Up按钮重复相同的过程。移除Text子对象,并将Source Image改为Up精灵。接下来,单击Set Native Size按钮,并更新其位置,使其位于Down按钮的上方。

(4)测试游戏。按钮仍然能够工作,看起来效果也更好了(如图18所示)。

图18:更新后的Up和Down按钮

现在把这些按钮分组到一个容器中。这么做有两个原因:首先,让UI整洁有序是个好主意;其次,把按钮分组到一个对象中,能够同时启用和禁用它们。后面章节中实现Pause菜单时,这会很有用。执行以下步骤来进行设置。

(1)创建按钮的父对象。创建一个新的空游戏对象,命名为Gameplay Menu,设为Canvas的子对象。 (2)设置对象,使其填充整个画面。将Gameplay Menu的锚点设为水平和垂直拉伸。单击左上角附近的Anchors,然后单击弹出菜单右下角的选项(如图7-19所示)。然后,将Left,Top,Right和Bottom设为0,这将使整个对象填充其整个父对象(即Canvas,这样整个对象就将填充整个Canvas)。

图7-19:将对象的锚点设为水平和垂直拉伸

(3)将按钮移动到Gameplay Menu对象中。将Up按钮和Down按钮在Hierarchy中的条目拖放到Gameplay Menu对象中。 接下来将创建You Win画面。该画面向玩家显示一个图片,以及一个让玩家再次玩游戏的按钮。为了准备此画面,执行下面的步骤。
(1)为Game Over画面创建容器对象。创建一个新的空游戏对象,命名为Game Over,设为Canvas的子对象。 按照设置Gameplay Menu对象的步骤,使其水平和垂直拉伸。 (2)添加Game Over图片。通过打开GameObject菜单,选择UI-Image,创建一个新的Image游戏对象,设为刚才创建的Game Over对象的子对象。 将新的Image对象的锚点设为水平和垂直拉伸。将Left和Right边距设为30,将Bottom边距设为60,这将在图片周边添加一些填充,并确保它不会遮盖我们将要添加 的New Game按钮。 Image 的Source Image属性设为You Win精灵,并打开Preserve Aspect选项以防止其被拉伸

无敌模式

电子游戏中出现作弊码,其实源于一个非常实际的需求。构建游戏时,必须击败游戏中包含的各种陷阱和谜题来到达想要测试的特定部分,这个过程可能变得非常枯燥。为了加快开发进度,常见的做法是添加工具来修改游戏的玩法:射击游戏常常包含一些作弊码,让敌人不攻击玩家;策略游戏则可以添加作弊码来禁用战争迷雾。

我们的游戏也不例外:在构建游戏时,不应该每次运行游戏都必须应对每个障碍。为此,我们将添加一个工具来使地精变得无敌。

此功能将实现为一个复选框(有时叫作开关,toggle),显示在画面的左上角。开启该选项后,地精不会死亡。它仍然会受到伤害,显示下一章将添加的粒子效果,这对于测试很有用。

为了保持整洁有序,我们将把此复选框包含在一个容器对象中,就像其他UI组件一样。首先创建这个容器。

(1)创建Debug Menu容器。创建一个新的空游戏对象,命名为Debug Menu,设为Canvas的子对象。将其锚点设为水平和垂直拉伸,并将Left、Right、Top和Bottom边距设为0,使其填满画面。

(2)添加Invincible(无敌模式)开关。打开GameObject菜单并选择UI->Toggle,创建一个新的Toggle对象,命名为Invincible。

将新对象的锚点设为Top Left,并将其移动到画面的左上角。

(3)配置开关。选择Lable对象,这是刚才添加的开关的子对象,并将其Text组件的颜色设为白色。将标签的文本设为Invincible。

将Toggle对象的Is On属性设为关闭。

完成之后,开关应该如图23所示。

图23:显示在画面左上角的Invincible复选框

(4)将开关连接到Game Manager。通过单击+按钮,在Invincible开关的Value Changed事件中添加一个新条目。将Game Manager拖放到显示的框中,并将函数修改为GameManager.gnomeInvincible。现在,当开关的值改变时,gnomeInvincible属性也会改变。

(5)测试游戏。玩游戏,并开启Invincible。现在,即使地精触碰到陷阱也不会死亡。

主菜单

现在,游戏的核心部分已经完成并优化。接下来将构建一些所有游戏都需要的功能,而不是Gnome’s Well特有的功能。也就是说,我们需要一个标题画面,以及从标题画面进入游戏的方法。

这部分将作为一个单独的场景实现,以保持游戏的独立性。因为菜单是比完整的游戏更简单的场景,所以菜单的加载会比整个游戏更快,让玩家能够更快地看到一些东西。另外,菜单将在后台开始加载游戏。当玩家触摸New Game按钮时,游戏将完成加载并切换场景。二者结合起来产生的效果是,游戏看起来启动更快。设置步骤如下。

(1)创建一个新场景。将新场景命名为Menu。

(2)添加背景图片。打开GameObject菜单,选择UI->Image。将图片的Source Image设为Main Menu Background精灵。将图片的锚点设为垂直拉伸,并水平居中。将x位置设为0,Top边距设为0,Bottom边距设为0,宽度设为800。

打开图片的Preserve Aspect,以防止其拉伸。

Inspector现在看起来应该如图9所示,背景图片应该如图10所示。

图9:主菜单背景图片的Inspector

图10:背景图片

(3)添加New Game按钮。打开GameObject菜单,选择UI->Button。将此对象命名为New Game。将按钮的锚点设为bottom-center。接下来,将x位置设为0,y位置设为40,宽度设为160,高度设为30。将按钮Lable的文本设为New Game。完成之后,按钮应该如图11所示。

图11:添加按钮后的菜单

加载场景

当玩家触摸New Game按钮时,我们想显示一个叠加画面,告诉玩家游戏正在加载。执行下面的步骤进行设置。

(1)创建叠加画面对象。创建一个新的空游戏对象,命名为Loading Overlay,设为Canvas对象的子对象。

使叠加画面的锚点垂直和水平拉伸,并将Top、Bottom、Left和Right边距设为0。这将使其填充整个屏幕。

(2)添加一个Image组件。在仍然选中Loading Overlay对象的情况下,单击Add Component按钮,为其添加一个Image组件。画面将填充为白色。

将Color属性改为黑色,并设置透明度为50%。现在叠加画面将成为半透明的黑色画面。

(3)添加一个标签。添加一个Text对象,设为Loading Overlay的子对象。将标签的锚点设为水平和垂直居中。将Left、Top、Right和Bottom位置设为0。

接下来,增加Text组件的字号,并使文字垂直和水平居中。将颜色设为白色,将文字设为“Loading…”。

设置好叠加画面后,接下来添加代码。这些代码负责实际加载整个游戏,以及在玩家触模New Game按钮时切换场景。为方便起见,我们将把这段代码添加到Main Camera中,不过如果你愿意,也可以添加到一个新的空游戏对象上。设置步骤如下。

在Main Camera中添加MainMenu代码。选择Main Menu,添加一个新的C#脚本,命名为MainMenu。

在MainMenu.cs中添加下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
using UnityEngine;
using System.Collections;
using UnityEngine.SceneManagement;

// 管理主菜单
public class MainMenu : MonoBehaviour
{
// 包含游戏自身的画面的名称
public string sceneToLoad;

// 包含“Loading...”文本的UI组件
public RectTransform loadingOverlay;

// 代表后台加载场景的操作,用于控制场景何时切换
AsyncOperation sceneLoadingOperation;

// 启动时,加载游戏
public void Start()
{
// 确保loading叠加画面不可见
loadingOverlay.gameObject.SetActive(false);

// 开始在后台加载场景......
sceneLoadingOperation = SceneManager.LoadSceneAsync(sceneToLoad);

// ......但是在准备好之前,还不会实际切换到新场景
sceneLoadingOperation.allowSceneActivation = false;
}

// 当触摸New Game按钮时调用
public void LoadScene()
{
// 使loading叠加画面可见
loadingOverlay.gameObject.SetActive(true);

// 告诉场景加载操作,在完成加载后切换场景
sceneLoadingOperation.allowSceneActivation = true;
}
}

Main Menu的脚本负责两个工作:在后台加载游戏场景,以及响应玩家触摸New Game按钮的操作。在Start方法中,让SceneManager在后台开始加载场景。结果作为一个Asyncoperation对象(即sceneLoadingOperation)返回,我们可以使用此对象控制如何加载。这里我们告诉sceneLoadingOperation,加载完成时不要激活新场景。这么做意味着,当加载完成时,加载操作将会等待,直到用户准备好进入下一个菜单。

切换到下一个菜单的操作是在LoadScene方法中完成的,当用户触摸New Game按钮时就会调用此方法。首先,将会显示刚才设置的loading(加载)叠加画面。然后,告诉场景加载操作,在加载完成后可以激活场景。这么做意味着,如果场景已经完成加载,它会立即显示;如果场景还没有完成加载,它会在加载完成后立即显示。

以这种方式设置主菜单,意味着整个游戏看起来加载得更快。因为主菜单需要加载的资源比主体游戏更少,所以会更快显示出来。当主菜单显示时,用户还要花些时间来点击New Game按钮。游戏将在此时加载新场景。不过,因为用户不必盯着一个“请等待”画面,所以相比直接启动游戏而言,这种方式感觉加载得更快。

执行以下步骤。

(1)配置Main Menu组件。将Scene to Load变量设为Main(即游戏主场景的名称)。将Loading Overlay变量设为刚才创建的Loading Overlay。

(2)使按钮加载场景。选择New Game按钮,使其运行Main Camera的MainMenu.LoadScene函数。

最后,需要建立构建的场景列表。Application.LoadLevel及其相关的函数只能加载构建的场景列表中包含的场景,也就是说需要确保其中包含Main和Menu场景。设置步骤如下。

(1)打开Build Settings窗口。打开File菜单,然后选择File->Build Settings。

(2)将Main和Menu场景添加到Scenes In Build列表中。将Main和Menu场景文件从Assets文件夹拖放到Scenes In Build列表中。确保Menu是列表中的第一项,因为这是游戏启动时应该显示的场景。

(3)测试游戏。运行游戏,单击New Game按钮,你将进入游戏。

0%