思考并回答以下问题:
本章涵盖:
- 策划
- 地图编辑器
- 游戏场景
- 制作UI
- 创建游戏管理器
- 摄像机
- 路点
- 敌人
- 敌人生成器
- 防守单位
- 生命条
- 小结
本章将使用自定义的编辑器创建场景,创建路点引导敌人行动,对战斗进行配置、动画播放,还涉及摄像机控制和UI界面等。
策划
塔防游戏的基本玩法比较类似,在场景中我方有一个基地,敌人从场景的另一侧出发,沿着相对固定的路线攻打基地。我方可以在地图上布置防守单位,攻击前来进攻的敌人,防止他们闯入基地。
本章也将制作一款塔防游戏,其具备塔防游戏的最基本要素。
场景
塔防游戏的场景有些固定的模式,它由一个二维的单元格组成,每个格子的用途可能都不同:
- 专用于摆放防守单位的格子。
- 专用于敌人通过的格子。
- 既无法摆放防守单位,也不允许敌人通过的格子。
摄像机
摄像机始终由上至下俯视游戏场景,按住鼠标左键并移动可以移动摄像机的位置。
胜负判定
我方基地有10点生命值,敌人攻入基地一次减少一点生命值,当生命值为0,游戏失败。
敌人以波数的形式向我方基地进攻,每波由若干个敌人组成。在这个实例中,一关有10波,当成功击退敌人10波的进攻则游戏胜利。
敌人
敌人有两种:一种在陆地上行走;另一种可以飞行。每打倒一个敌人会奖励一些铜钱,用来购买新的防守单位。
防守单位
塔防游戏会有多种类型的防守单位:一种是近战类型;另一种是远程。每造一个防守单位需要消耗相应数量的铜钱。
UI界面
游戏中的UI包括防守单位的按钮、敌人的进攻波数、基地的生命值和铜钱数量。
当防守单位攻击敌人时,在敌人的头上需要显示一个生命条表示剩余的生命值。
当游戏失败或胜利后显示一个按钮重新游戏。
地图编辑器
在开始正式制作游戏之前,我们有必要先完成一个塔防游戏的地图编辑器。Unity编辑器的自定义功能非常强大,几乎可以把Unity编辑器扩展成任何界面。在示例中,我们将完成一个“格子”编辑系统,帮助我们输入塔防游戏的地图信息。
“格子”数据
新建工程,在Hierarchy窗口中单击鼠标右键,选择【Create Empty】创建一个空物体,然后创建脚本TileObject.cs指定给空物体,这里将空物体命名为Grid Object,这个类主要用于保存场景中的“格子”数据,代码如下所示。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
89using UnityEngine;
public class TileObject : MonoBehaviour
{
public static TileObject Instance = null;
// tile 碰撞层
public LayerMask tileLayer;
// tile 大小
public float tileSize = 1;
// x 轴方向tile数量
public int xTileCount = 2;
// z 轴方向tile数量
public int zTileCount = 2;
// 格子的数值,0表示锁定,无法摆放任何物体。1表示敌人通道,2表示可摆放防守单位
public int[] data;
// 当前数据 id
[ ]
public int dataID = 0;
// 是否显示数据信息
[ ]
public bool debug = false;
void Awake()
{
Instance = this;
}
// 初始化地图数据
public void Reset()
{
data = new int[xTileCount * zTileCount];
}
// 获得相应tile的数值
public int getDataFromPosition(float pox, float poz)
{
int index = (int)((pox - transform.position.x)/ tileSize) * zTileCount + (int)((poz - transform.position.z)/ tileSize);
if (index < 0 || index >= data.Length) return 0;
return data[index];
}
// 设置相应tile的数值
public void setDataFromPosition( float pox, float poz, int number )
{
int index = (int)((pox - transform.position.x) / tileSize) * zTileCount + (int)((poz - transform.position.z) / tileSize);
if (index < 0 || index >= data.Length) return;
data[index] = number;
}
// 在编辑模式显示帮助信息
void OnDrawGizmos()
{
if (!debug)
return;
if (data==null)
{
Debug.Log("Please reset data first");
return;
}
Vector3 pos = transform.position;
for (int i = 0; i < xTileCount; i++) // 画Z方向轴辅助线
{
Gizmos.color = new Color(0, 0, 1, 1);
Gizmos.DrawLine(pos + new Vector3(tileSize * i, pos.y, 0),
transform.TransformPoint(tileSize * i, pos.y, tileSize * zTileCount));
for (int k = 0; k < zTileCount; k++) // 高亮显示当前数值的格子
{
if ( (i * zTileCount + k) < data.Length && data[i * zTileCount + k] == dataID)
{
Gizmos.color = new Color(1, 0, 0, 0.3f);
Gizmos.DrawCube(new Vector3(pos.x + i * tileSize + tileSize * 0.5f,
pos.y, pos.z + k * tileSize + tileSize * 0.5f), new Vector3(tileSize, 0.2f, tileSize));
}
}
}
for (int k = 0; k < zTileCount; k++) // 画X方向轴辅助线
{
Gizmos.color = new Color(0, 0, 1, 1);
Gizmos.DrawLine(pos + new Vector3(0, pos.y, tileSize * k),
this.transform.TransformPoint(tileSize * xTileCount, pos.y, tileSize * k));
}
}
}
因为Unity目前不支持二维数组的序列化,所以本示例使用了一维数组data保存地图x、y的信息。GetDataFromPosition函数通过输入的坐标位置获取data数组的下标,其中一步计算是由输入的坐标减去当前物体transform的坐标值,这里要注意浮点数精度问题,比如有时1.54-0.54会得到0.9999999的结果(实际应当是1),0.9999999在转为整数后就会变为零,为了避免这个问题,最好将Grid Object的transform坐标值设为整数。
在Inspector窗口添加自定义UI控件
在Unity的编辑器中,当选中一个游戏体后,我们即可在【Inspector】窗口中设置它的详细属性。默认【Inspector】窗口中的选项都是预定的,Unity提供了API可以扩展【Inspector】窗口中的UI控件。
步骤01
以本示例的地图编辑器为例,为了扩展TileObject这个类的【Inspector】窗口,我们创建了脚本TileEditor.cs,继承自Editor。因为它是一个编辑器脚本,所以必须放到Editor文件夹中,代码如下: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
73using UnityEngine;
using UnityEditor;
[ ]
public class TileEditor : Editor
{
// 是否处于编辑模式
protected bool editMode = false;
// 受编辑器影响的tile角本
protected TileObject tileObject;
void OnEnable()
{
// 获得tile角本
tileObject = (TileObject)target;
}
// 更改场景中的操作
public void OnSceneGUI()
{
if (editMode) // 如果在编辑模式
{
// 取消编辑器的选择功能
HandleUtility.AddDefaultControl(GUIUtility.GetControlID(FocusType.Passive));
// 在编辑器中显示数据(画出辅助线)
tileObject.debug = true;
// 获取Input事件
Event e = Event.current;
// 如果是鼠标左键
if ( e.button == 0 && (e.type == EventType.MouseDown || e.type == EventType.MouseDrag) && !e.alt)
{
// 获取由鼠标位置产生的射线
Ray ray = HandleUtility.GUIPointToWorldRay(e.mousePosition);
// 计算碰撞
RaycastHit hitinfo;
if (Physics.Raycast(ray, out hitinfo, 2000, tileObject.tileLayer))
{
//float tx = hitinfo.point.x - tileObject.transform.position.x;
//float tz = hitinfo.point.z - tileObject.transform.position.z;
tileObject.setDataFromPosition(hitinfo.point.x, hitinfo.point.z, tileObject.dataID);
}
}
}
HandleUtility.Repaint();
}
// 自定义Inspector窗口的UI
public override void OnInspectorGUI()
{
GUILayout.Label("Tile Editor"); // 显示编辑器名称
editMode = EditorGUILayout.Toggle("Edit", editMode); // 是否启用编辑模式
tileObject.debug = EditorGUILayout.Toggle("Debug", tileObject.debug); // 是否显示帮助信息
//tileObject.dataID = EditorGUILayout.IntSlider("Data ID", tileObject.dataID, 0, 9); // 编辑id滑块
string[] editDataStr = { "Dead", "Road", "Guard" };
tileObject.dataID = GUILayout.Toolbar(tileObject.dataID, editDataStr);
//Debug.Log(tileObject.dataID);
EditorGUILayout.Separator(); // 分隔符
if (GUILayout.Button("Reset" )) // 重置按钮
{
tileObject.Reset(); // 初始化
}
DrawDefaultInspector();
}
}
步骤02
添加一个碰撞Layer,这里设为tile,然后将Tile Layer设为tile,调整Tile Count的值即可改变地图大小;单击Reset按钮初始化数据,默认所有格子的值为0,如图4-1所示。
图4-1 地图格子
步骤03
在Hierarchy窗口中单击鼠标右键,选择【3D Object】→【Plane】,创建一个平面并置于Grid Object层级下,将它的Layer设为tile,并取消选中【Mesh Renderer】复选框,我们主要使用它作为地面的碰撞层,如图4-2所示。
图4-2 创建碰撞物体
步骤04
最后,选中【Edit】复选框,单击Dead(值0)、Road(值1)或Guard(值2)按钮就可以随意绘制地图数据了,如图4-3所示。
图4-3 选中【Edit】复选框
创建一个自定义窗口
除了自定义Inspector窗口,我们还可以创建一个独立的窗口编辑游戏中的设置,在Editor文件夹中创建自定义窗口脚本,示例代码如下:
1 | using UnityEngine; |
在场景中选择Grid Object物体(TileObject实例),然后在菜单栏中选择【Tools】→【Tile Window】,即可打开自定义的窗口,如图4-4所示,这里只是演示了如何显示一些基本的UI。
图4-4 自定义窗口界面
游戏场景
如图4-5所示,本示例的场景地面由Sprite拼凑而成,注意Sprite的x轴被旋转了90°,Sprite刚好与3D视图中的z轴平行。使用Sprite制作的地面不能接收光照和投影,读者可以按自己的兴趣随意搭建游戏场景。
图4-5 由Sprite和3D模型组成的示例游戏场景
本示例场景使用的部分美术资源来自Asset Store的免费资源Backyard – Free,如图4-6所示,注意将Pixels Per Unit的大小设置与图片原始像素大小一致,即可使每个Sprite的大小与Unity单元格的大小一致。
图4-6 美术资源
制作UI
首先创建塔防游戏的UI界面。
步骤01
在本书资源文件目录rawdata/td/GUI/中存放了所有的UI图片,导入图片后,注意将Texture Type设为Sprite类型,如图4-7所示。Unity的UI系统只能使用Sprite类型的图片。
图4-7 创建Sprite
步骤02
创建几个UI文字控件。在Hierarchy窗口中单击鼠标右键,选择【UI】→【Text】创建文字,在创建文字物体的同时,还会自动创建Canvas和EventSystem物体。Canvas会自动作为文字控件的父物体,所有的UI控件都需要放到Canvas层级下。EventSystem物体上有很多UI事件脚本,用来管理和响应UI事件,如图4-8所示。
图4-8 创建基础的UI控件
步骤03
设置文字的位置。在编辑器的上方单击UI编辑按钮,然后选择文字控件即可改变文字的位置和尺寸,如图4-9所示。
图4-9 移动控件
步骤04
在不同的分辨率下对齐UI的位置一直是件很麻烦的事情,不过使用Unity的新UI系统,一切将变得非常简单。选择前面创建的文本UI控件,在【Inspector】窗口的Rect Transform中可以快速设置控件的对齐方式,如左对齐、右对齐等,如图4-10所示。
图4-10 对齐控件
步骤05
除了对齐,我们还需要根据不同的屏幕分辨率对UI控件进行缩放。将Canvas物体的Canvas Scaler设为Scale With Screen Size模式,UI控件将以设置的分辨率为基础,在不同的分辨率下进行缩放适配,如图4-11所示。
图4-11 适配分辨率
步骤06
这里一共需要创建三个不同的文字控件,分别用来显示敌人进攻的波数、铜钱和生命值。我们在【Inspector】窗口可以设置文字的内容、字体、大小、颜色等,使用Unity的Rich Text功能,在文本中添加color标记,可以使同一个文字有不同的色彩,如图4-12所示。
图4-12 设置文本
步骤07
现在很多游戏都给文字配上了描边,我们也加一个吧。选择文字,在菜单栏中选择【Component】→【UI】→【Effects】→【Outline】,如图4-13所示。
图4-13 描边字
步骤08
创建按钮,包括创建防守单位的按钮和重新游戏的按钮。在Hierearchy窗口中单击鼠标右键,选择【UI】→【Button】即可创建一个新的按钮控件,默认按钮下面还有一个文字控件用来显示按钮的名称。在【Inspector】窗口中找到Image组件下的Source Image设置按钮的图片,选择下面的Set Native Size可以使按钮的大小与图片的尺寸快速适配,如图4-14所示。
图4-14 创建按钮
最后的UI效果如图4-15所示。注意UI控件的名字,我们在后面需要通过名字来查找UI控件。UI控件的层级关系也比较重要,因为控件均位于Canvas或其他层级之下,所以它们的位置只是相对于父物体的相对位置。如果通过脚本去修改控件的位置,通常是修改transform.localPosition,而不是transform.position。
图4-15 创建的UI
完成UI设置后,可以将UI保存成Prefab。
创建游戏管理器
在前一节,我们创建了UI,但是没有功能,接下来我们将创建一个游戏管理器,它主要用来管理UI,处理鼠标输入和逻辑事件等。
创建脚本GameManager.cs,这里将其添加到Canvas物体上,代码如下所示:
1 | using UnityEngine; |
在Start函数中,我们先定义了按钮的事件,然后通过查找所有子物体的名称找到相应的UI控件进行初始化处理,对Text文字控件,赋予初始的波数、生命和铜钱数值。
因为在游戏开始时我们不希望看到重新游戏按钮,所以调用gameObject.SetActive(false)方法将该按钮隐藏,当游戏结束时再显示该按钮重新游戏。
对于创建防守单位的按钮,我们分别定义了按下和抬起两个事件,当按下按钮的时候,获得要创建的对象,抬起按钮时在选定位置创建防守单位,不过当前这些按钮事件的回调函数是空的,还没做什么事情。
摄像机
因为游戏的场景可能会比较大,所以需要移动摄像机才能观察到场景的各个部分。接下来我们将为摄像机添加脚本,在移动鼠标的时候可以移动摄像机。
步骤01
在为摄像机创建脚本前,首先创建一个空游戏体作为摄像机观察的目标点,并为其创建脚本CameraPoint.cs,它只有很少的代码。注意,CameraPoint.tif是一张图片,必须保存在工程中的Gizmos文件夹内。
1 | using UnityEngine; |
步骤02
创建脚本GameCamera.cs,并将其指定给场景中的摄像机。
1 | using UnityEngine; |
在这个脚本的Start函数中,我们首先获得了前面创建的CameraPoint,它将作为摄像机目标点的参考。
在Follow函数中,摄像机会按预设的旋转和距离始终跟随CameraPoint目标点。
LateUpdate函数和Update函数的作用一样,不同的是它始终会在执行完Update后执行,我们在这个函数中调用Follow函数,确保在所有的操作完成后再移动摄像机。
Control函数的作用是移动CameraPoint目标点,因为摄像机的角度和位置始终跟随这个目标点,所以也会随着目标点的移动而移动。
步骤03
打开GameManager.cs脚本,在Update函数中添加代码如下:
1 | void Update () |
这段代码的作用是获取鼠标操作的各种信息并传递给摄像机,现在运行游戏,已经可以移动摄像机了。
路点
在前一章中,我们使用Unity提供的寻路功能实现敌人的行动,但在塔防游戏中,敌人通常不需要智能寻路,而是按照一条预设的路线行动。下面我们将为敌人创建一条前进路线,这条路线是预设的,敌人将从游戏场景的左侧沿着通道一直走到右侧。
步骤01
敌人的前进路线是由若干个路点组成,首先添加路点的Tag,这里名为pathnode,为路点创建脚本PathNode.cs:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22using UnityEngine;
public class PathNode : MonoBehaviour
{
public PathNode m_parent; // 前一个节点
public PathNode m_next; // 下一个节点
// 设置下一个节点
public void SetNext(PathNode node)
{
if (m_next != null)
m_next.m_parent = null;
m_next = node;
node.m_parent = this;
}
// 在编辑器中显示的图标
void OnDrawGizmos()
{
Gizmos.DrawIcon(this.transform.position, "Node.tif");
}
}
在游戏中,敌人将从一个路点到达另一个路点,即到达当前路点的子路点。在PathNode脚本中,主要是通过SetNext函数设置它的子路点。
接下来,我们将创建路点并为每个路点设置子路点,为了设置方便,添加一个菜单功能,加速设置路点的操作。
步骤02
在Project窗口中的Assets目录下创建一个名为Editor的文件夹,名称是特定的,不能改变,所有需要在编辑状态下执行的脚本都应当被存放到这里。在Editor文件夹内创建脚本PathTool.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
47using UnityEngine;
using UnityEditor;
public class PathTool : ScriptableObject
{
static PathNode m_parent=null;
[ ]
static void CreatePathNode()
{
// 创建一个新的路点
GameObject go = new GameObject();
go.AddComponent<PathNode>();
go.name = "pathnode";
// 设置tag
go.tag = "pathnode";
// 使该路点处于选择状态
Selection.activeTransform = go.transform;
}
[ ]
static void SetParent()
{
if (!Selection.activeGameObject || Selection.GetTransforms(SelectionMode.Unfiltered).Length>1)
return;
if (Selection.activeGameObject.tag.CompareTo("pathnode") == 0)
{
m_parent = Selection.activeGameObject.GetComponent<PathNode>();
Debug.Log( "Set "+m_parent.name+" as parent.");
}
}
[ ]
static void SetNextChild()
{
if (!Selection.activeGameObject || m_parent==null || Selection.GetTransforms(SelectionMode.Unfiltered).Length>1)
return;
if (Selection.activeGameObject.tag.CompareTo("pathnode") == 0)
{
m_parent.SetNext(Selection.activeGameObject.GetComponent<PathNode>());
m_parent = null;
Debug.Log("Set " + Selection.activeGameObject.name + " as child.");
}
}
}
这里的代码只有在编辑状态才能被执行,注意所有在这里使用的属性和函数均为static类型。
Selection是在编辑模式下的一个静态类,通过它可以获取到当前选择的物体。
[MenuItem(“PathTool/Set Parent %q”)]属性在菜单中添加名为PathTool的自定义菜单,并包括子菜单Set Parent,快捷键为Ctrl+Q。菜单Set Parent执行的功能即是SetParent函数的功能,将当前选中的节点作为父路点。
SetNextChild函数将当前选中的路点作为父路点的子路点。
步骤03
在菜单栏中选择【PathTool】→【Creat PathNode】创建路点。
步骤04
复制若干个路点沿着道路摆放。按快捷键Ctrl+Q将其设为父路点,然后选择下一个路点,按Ctrl+W设为子路点,再按Ctrl+Q将它设为父路点,再选择子路点,反复这个操作,直到将所有路点设置完毕,效果如图4-16所示。注意,最后一个路点没有子路点。
图4-16 设置路点
虽然设置好了路点,但还是无法在场景中清楚地观察路点之间的联系,还需要在GameManager.cs中添加代码,使路点之间产生一条连线。
步骤05
打开脚本GameManager.cs,添加两个属性:m_debug是一个开关,控制是否显示路点之间的连线;m_PathNodes是一个ArrayList,它用来保存所有的路点。1
2public bool m_debug = true; // 显示路点的debug开关
public List<PathNode> m_PathNodes; // 路点
步骤06
继续在GameManager.cs中添加函数BuildPath,并在Start函数中调用它,它的作用是将所有场景中的路点装入m_PathNodes。
步骤07
继续在GameManager.cs中添加函数OnDrawGizmos,它的作用是当m_debug属性为真时,显示路点之间的连线。
步骤08
选择UI Root上的Game Manager脚本组件,设置m_debug属性为真,单击右上方的齿轮按钮,在弹出子菜单中选择【BuildPath】,这是我们自定义的菜单,如图4-17所示。
步骤09
选择【BuildPath】后,将在场景中看到路点之间的连线,如图4-18所示。
图4-17 自定义的BuildPath选项
图4-18 路点之间的连线
敌人
敌人一共有两种:一种在陆地上前进;另一种则会飞行。我们先创建前一种,然后继承它的大部分属性和函数,略加修改完成另一种。
步骤01
创建敌人的脚本Enemy.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
98using UnityEngine;
using System.Collections;
public class Enemy : MonoBehaviour
{
public PathNode m_currentNode; // 敌人的当前路点
public int m_life = 15; // 敌人的生命
public int m_maxlife = 15; // 敌人的最大生命
public float m_speed = 2; // 敌人的移动速度
public System.Action<Enemy> onDeath; // 敌人的死亡事件
Transform m_lifebarObj; // 敌人的UI生命条GameObject
UnityEngine.UI.Slider m_lifebar; //控制生命条显示的Slider
// Use this for initialization
void Start () {
GameManager.Instance.m_EnemyList.Add(this);
// 读取生命条prefab
GameObject prefab = (GameObject)Resources.Load("Canvas3D");
// 创建生命条
m_lifebarObj = ((GameObject)Instantiate(prefab, Vector3.zero, Camera.main.transform.rotation, this.transform )).transform;
m_lifebarObj.localPosition = new Vector3(0, 2.0f, 0);
m_lifebarObj.localScale = new Vector3(0.02f, 0.02f, 0.02f);
m_lifebar = m_lifebarObj.GetComponentInChildren<UnityEngine.UI.Slider>();
// 更新生命条位置和角度
StartCoroutine(UpdateLifebar());
}
// Update is called once per frame
void Update ()
{
RotateTo();
MoveTo();
}
// 转向目标
public void RotateTo()
{
var position = m_currentNode.transform.position - transform.position;
position.y = 0; // 保证仅旋转Y轴
var targetRotation = Quaternion.LookRotation(position); // 获得目标旋转角度
float next = Mathf.MoveTowardsAngle(transform.eulerAngles.y, targetRotation.eulerAngles.y, //获得中间的旋转角度
120 * Time.deltaTime);
this.transform.eulerAngles = new Vector3(0, next, 0);
}
// 向目标移动
public void MoveTo()
{
Vector3 pos1 = this.transform.position;
Vector3 pos2 = m_currentNode.transform.position;
float dist = Vector2.Distance(new Vector2(pos1.x,pos1.z),new Vector2(pos2.x,pos2.z));
if (dist < 1.0f)
{
if (m_currentNode.m_next == null) // 没有路点,说明已经到达我方基地
{
GameManager.Instance.SetDamage(1); // 扣除一点伤害值
DestroyMe(); // 销毁自身
}
else
m_currentNode = m_currentNode.m_next; // 更新到下一个路点
}
this.transform.Translate(new Vector3(0, 0, m_speed * Time.deltaTime));
//m_bar.SetPosition(this.transform.position, 4.0f);
}
public void DestroyMe()
{
GameManager.Instance.m_EnemyList.Remove(this);
onDeath(this); // 发布消息
Destroy(this.gameObject); // 注意在实际项目中一般不要直接调用Destroy
}
public void SetDamage(int damage)
{
m_life -= damage;
if (m_life <= 0)
{
m_life = 0;
// 每消灭一个敌人增加一些铜钱
GameManager.Instance.SetPoint(5);
DestroyMe();
}
}
IEnumerator UpdateLifebar()
{
// 更新生命条的值
m_lifebar.value = (float)m_life / (float)m_maxlife;
// 更新角度,如终面向摄像机
m_lifebarObj.transform.eulerAngles = Camera.main.transform.eulerAngles;
yield return 0; // 没有任何等待
StartCoroutine(UpdateLifebar()); // 循环执行
}
}
在这个脚本中,定义了敌人的一些基本属性,如生命值、移动速度、类型等,它有一个路点属性作为出发点。
RotateTo函数使敌人始终转向目标路点,MoveTo函数则使其沿着当前方向前进,当距离目标路点较近时,将该路点作为当前路点,再向下一个路点前进。注意,这里计算敌人与子路点的距离时没有计算y轴。当敌人走到最后的路点,即是到达我方基地,销毁自身,并使基地减少一点生命值。
步骤02
导入本书资源目录rawdata/td/Rawdata下的资源,找到boar\@skin.FBX模型文件,拖入场景中。这是个野猪模型,它将作为陆地上的敌人。将Enemy.cs脚本指定给它,并设置起始路点,如图4-19所示。
图4-19 敌人组件
运行游戏,敌人会从起始点出发,沿着路点,一路前进到达我方基地,然后消失,我方基地将损失一点生命值。
这时我们会发现,野猪模型没有任何动画,看上去很生硬,需要为其添加动画效果。
步骤03
带有动画的模型,被导入到Unity时会被自动设为Generic。在Project窗口中单击鼠标右键选择【Create】→【Animator Controller】,为野猪模型创建一个动画控制器,如图4-20所示。前面放入场景中的野猪模型默认会带有一个Animator组件,将动画控制器指定给该组件。
图4-20 创建动画控制器
步骤04
双击动画控制器打开Animator窗口,在Project窗口中选择boar@run,在Inspector窗口中设置它的Loop Time使其循环播放,然后将动画拖入Animator窗口中,如图4-21所示。因为当前只有一个动画,所以它将作为默认动画自动播放。
图4-21 指定动画
再次运行游戏,即可看到模型在前进中播放了跑动的动画,将野猪模型保存为Prefab。
步骤05
接下来创建另一个飞行敌人的脚本AirEnemy.cs,它继承了Enemy脚本的大部分功能,只添加一个Fly函数,作用是当高度小于2时向上飞行。
步骤06
在资源文件中找到bird\@skin.FBX模型,拖入场景中进行设置,步骤与野猪模型的设置一样,最后保存为Prefab。运行游戏,效果如图4-22所示。
图4-22 沿着路点前进的敌人
敌人生成器
塔防游戏的敌人通常是成批出现,一波接着一波,因为敌人的数量众多,所以需要一个生成器按预先设置的顺序生成不同的敌人。
创建敌人生成器
步骤01
创建WaveData.cs,它定义了战斗时每波敌人的配置。
1 | using UnityEngine; |
步骤02
创建EnemySpawner.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
69using UnityEngine;
using System.Collections;
using System.Collections.Generic;
public class EnemySpawner : MonoBehaviour
{
public PathNode m_startNode; // 起始节点
private int m_liveEnemy = 0; // 存活的敌人数量
public List<WaveData> waves; // 战斗波数配置数组
int enemyIndex = 0; //生成敌人数组的下标
int waveIndex = 0; //战斗波数数组的下标
// Use this for initialization
void Start () {
StartCoroutine(SpawnEnemies()); // 开始生成敌人
}
IEnumerator SpawnEnemies()
{
yield return new WaitForEndOfFrame();
GameManager.Instance.SetWave((waveIndex + 1));
WaveData wave = waves[waveIndex];
yield return new WaitForSeconds(wave.interval);
while (enemyIndex < wave.enemyPrefab.Count)
{
Vector3 dir = m_startNode.transform.position - this.transform.position; // 初始方向
GameObject enmeyObj = (GameObject)Instantiate(wave.enemyPrefab[enemyIndex], transform.position, Quaternion.LookRotation(dir)); // 创建敌人
Enemy enemy = enmeyObj.GetComponent<Enemy>(); // 获得敌人的脚本
enemy.m_currentNode = m_startNode; // 设置敌人的第一个路点
// 设置敌人数值,这里只是简单示范
// 数值配置适合放到一个专用的数据库(SQLite数据库或JSON、XML格式的配置)中读取
enemy.m_life = wave.level * 3;
enemy.m_maxlife = enemy.m_life;
m_liveEnemy++; // 增加敌人数量
enemy.onDeath= new System.Action<Enemy>((Enemy e) =>{ m_liveEnemy--; });// 当敌人死掉时减少敌人数量
enemyIndex++; // 更新敌人数组下标
yield return new WaitForSeconds(wave.interval); // 生成敌人时间间隔
}
// 创建完全部敌人,等待敌人全部被消灭
while(m_liveEnemy>0)
{
yield return 0;
}
enemyIndex = 0; // 重置敌人数组下标
waveIndex++; // 更新战斗波数
if (waveIndex< waves.Count) // 如果不是最后一波
{
StartCoroutine(SpawnEnemies());
}
else
{
// 通知胜利
}
}
// 在编辑器中显示一个图标
void OnDrawGizmos()
{
Gizmos.DrawIcon(transform.position, "spawner.tif");
}
}
步骤03
创建一个空游戏体作为敌人生成器放置到场景中,为其指定EnemySpawner.cs脚本。在m_startNode中设置起始路点,在Waves中配置敌人的生成,这里配置了10波,如图4-23所示。
图4-23 设置敌人prefab和起始路点
运行游戏,敌人会按照配置逐个生成。
遍历敌人
现在,游戏中有很多敌人,为了能方便地遍历游戏中的所有敌人,查看它们的情况,我们可以准备一个容器,将所有生成的敌人都装进去。
步骤01
打开GameManager.cs脚本,添加一个List用来存放所有的敌人。1
public List<Enemy> m_EnemyList = new List<Enemy>();
步骤02
打开Enemy.cs脚本,在Start和DestroyMe函数中分别更新List中的敌人。添加一个SetDamage函数更新敌人生命值,当生命值为0时销毁自身并增加些铜钱。
1 |
现在所有的敌人都被保存到List中,当我们创建出防守单位后,他们可以通过遍历List中的敌人查找并攻击敌人,通过SetDamage函数更新敌人的生命值。
防守单位
本游戏中有两种防守单位:一种是近战类型;另一种是远程类型。我们先创建近战类型的防守单位,然后通过它派生出远程类型的防守单位。
步骤01
使用资源文件目录rawdata/td/Rawdata/players中提供的模型和动画资源创建防守单位的Prefab,将Prefab放到Resources文件夹内。这里主要是设置动画控制器并添加动画,如图4-24所示,包括idle和attack动画。
图4-24 创建防守单位Prefab
步骤02
创建脚本Defender.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
146using UnityEngine;
using System.Collections;
public class Defender : MonoBehaviour
{
// 格子的状态
public enum TileStatus
{
DEAD = 0, // 不能在上面做任何事
ROAD =1, // 专用于敌人行走
GUARD =2, // 专用于创建防守单位的格子
}
// 攻击范围
public float m_attackArea = 2.0f;
// 攻击力
public int m_power = 1;
// 攻击时间间隔
public float m_attackInterval = 2.0f;
// 目标敌人
protected Enemy m_targetEnemy;
// 是否已经面向敌人
protected bool m_isFaceEnemy;
// 模型Prefab
protected GameObject m_model;
// 动画播放器
protected Animator m_ani;
// 静态函数 创建防守单位实例
public static T Create<T>( Vector3 pos, Vector3 angle ) where T : Defender
{
GameObject go = new GameObject("defender");
go.transform.position = pos;
go.transform.eulerAngles = angle;
T d = go.AddComponent<T>();
d.Init();
// 将自己所占格子的信息设为占用
TileObject.Instance.setDataFromPosition(d.transform.position.x, d.transform.position.z, (int)TileStatus.DEAD);
return d;
}
// 初始化数值
protected virtual void Init()
{
// 这里只是简单示范,在实际项目中,数值通常会从数据库或配置文件中读取
m_attackArea = 2.0f;
m_power = 2;
m_attackInterval = 2.0f;
// 创建模型,这里的资源名称是写死的,实际的项目通常会从配置中读取
CreateModel("swordman");
StartCoroutine(Attack()); // 执行攻击逻辑
}
// 创建模型
protected virtual void CreateModel(string myname)
{
GameObject model = Resources.Load<GameObject>(myname);
m_model = (GameObject)Instantiate(model, this.transform.position, this.transform.rotation, this.transform);
m_ani = m_model.GetComponent<Animator>();
}
// Update is called once per frame
void Update ()
{
FindEnemy();
RotateTo();
Attack();
}
public void RotateTo()
{
if (m_targetEnemy == null)
return;
var targetdir = m_targetEnemy.transform.position - transform.position;
targetdir.y = 0; // 保证仅旋转Y轴
// 获取旋转方向
Vector3 rot_delta = Vector3.RotateTowards(this.transform.forward, targetdir, 20.0f * Time.deltaTime, 0.0F);
Quaternion targetrotation = Quaternion.LookRotation(rot_delta);
// 计算当前方向与目标之间的角度
float angle = Vector3.Angle(targetdir, transform.forward);
// 如果已经面向敌人
if (angle < 1.0f)
{
m_isFaceEnemy = true;
}
else
m_isFaceEnemy = false;
transform.rotation = targetrotation;
}
// 查找目标敌人
void FindEnemy()
{
if (m_targetEnemy != null)
return;
m_targetEnemy = null;
int minlife = 0; // 最低的生命值
foreach (Enemy enemy in GameManager.Instance.m_EnemyList) // 遍历敌人
{
if (enemy.m_life == 0)
continue;
Vector3 pos1 = this.transform.position; pos1.y = 0;
Vector3 pos2 = enemy.transform.position; pos2.y = 0;
// 计算与敌人的距离
float dist = Vector3.Distance(pos1, pos2);
// 如果距离超过攻击范围
if (dist > m_attackArea)
continue;
// 查找生命值最低的敌人
if (minlife == 0 || minlife > enemy.m_life)
{
m_targetEnemy = enemy;
minlife = enemy.m_life;
}
}
}
// 攻击逻辑
protected virtual IEnumerator Attack()
{
while (m_targetEnemy == null || !m_isFaceEnemy) // 如果没有目标一直等待
yield return 0;
m_ani.CrossFade("attack", 0.1f); // 播放攻击动画
while (!m_ani.GetCurrentAnimatorStateInfo(0).IsName("attack")) // 等待进入攻击动画
yield return 0;
float ani_lenght = m_ani.GetCurrentAnimatorStateInfo(0).length; // 获得攻击动画时间长度
yield return new WaitForSeconds(ani_lenght * 0.5f); // 等待完成攻击动作
if (m_targetEnemy != null)
m_targetEnemy.SetDamage(m_power); // 攻击
yield return new WaitForSeconds(ani_lenght * 0.5f); // 等待播放剩余的攻击动画
m_ani.CrossFade("idle", 0.1f); // 播放待机动画
yield return new WaitForSeconds(m_attackInterval); // 间隔一定时间
StartCoroutine(Attack()); // 下一轮攻击
}
}
① Create函数是一个静态函数,我们可以使用它直接在代码中创建防守单位的游戏体。
② TileStatus是一个枚举,用来表示场景中格子的状态,在Create函数中创建防守单位时,我们会更新格子的状态,使原本空闲的格子变为占有状态,这样便不能在这个格子中创建新的防守单位。
③ Init函数是一个初始化函数,初始化了一些数值。注意,这是一个虚函数,当我们派生出其他类后,可以重载Init函数,赋予不一样的数值。在实际的项目中,我们也可以将所有的防守单位数值记录到配置文件中,创建不同的角色,读入不同的数值即可。
④ CreateModel函数创建了所有的模型和动画。我们使用Resources.Load函数读入了模型和动画资源。注意,在实际项目中不建议在Update中直接调用Resources.Load。
⑤ FindEnemy函数会遍历所有的敌人,找出处于攻击范围内的敌人,并选择生命值最低的。
⑥ Attack函数是一个协程函数,实现了攻击的逻辑。
步骤03
创建远程防守单位。我们需要它的Prefab添加一个空物体作为“攻击点”,也就是发射弓箭的位置,如图4-25所示,这里“攻击点”的命名为atkpoint。
图4-25 设置攻击点
步骤04
使用资源文件目录rawdata/td/Rawdata/players,创建一个新的脚本Archer.cs,主要是在攻击时创建了弓箭模型。
Archer类继承自Defender类,重载了Init、Attack函数,赋予不同的数值并做出不同的攻击行为。因为是该类表现为远程攻击,所以在攻击的时候创建了一个弓箭实例,弓箭的脚本我们将在下一步创建。
步骤05
导入本书资源文件目录rawdata/td/Fx_effect.unitypackage,它包括弓箭的Prefab,创建一个新的脚本Projectile.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
49using UnityEngine;
public class Projectile : MonoBehaviour
{
// 当打击到目标时执行的动作
System.Action<Enemy> onAttack;
// 目标对象
Transform m_target;
// 目标对象模型的边框
Bounds m_targetCenter;
// 静态函数 创建弓箭
public static void Create(Transform target, Vector3 spawnPos, System.Action<Enemy> onAttack)
{
// 读取弓箭模型
GameObject prefab = Resources.Load<GameObject>("arrow");
GameObject go = (GameObject)Instantiate(prefab, spawnPos, Quaternion.LookRotation(target.position - spawnPos));
// 添加弓箭角本组件
Projectile arrowmodel = go.AddComponent<Projectile>();
// 设置弓箭的目标
arrowmodel.m_target = target;
// 获得目标模型的边框
arrowmodel.m_targetCenter = target.GetComponentInChildren<SkinnedMeshRenderer>().bounds;
// 取得Action
arrowmodel.onAttack = onAttack;
// 3秒之后自动销毁
Destroy(go, 3.0f);
}
void Update()
{
// 瞄准目标中心位置
if (m_target != null)
this.transform.LookAt(m_targetCenter.center);
// 向目标前进
this.transform.Translate(new Vector3(0, 0, 10 * Time.deltaTime));
if (m_target != null)
{
// 简单通过距离检测是否打击到目标
if (Vector3.Distance(this.transform.position, m_targetCenter.center) < 0.5f)
{
// 通知弓箭发射者
onAttack(m_target.GetComponent<Enemy>());
// 销毁
Destroy(this.gameObject);
}
}
}
}
弓箭类的功能很简单,创建一个弓箭模型,然后朝目标点前进,当距离目标小于0.5个单位时,触发Action通知弓箭发射者已经打击到目标,然后销毁自己。再次提醒,在实际项目中不要在Update中直接调用Resources.Load、Instantiate和Destroy。
最后,我们需要修改GameManager.cs脚本,在按钮事件中添加创建防守单位的代码。
步骤06
打开GameManager.cs脚本,修改OnButCreateDefenderUp函数,在抬起鼠标时用射线测试是否与格子地面碰撞,如果符合条件,则创建防守单位,代码如下:1
2
运行游戏,单击右侧的按钮,然后拖动鼠标到场景中并释放,即可创建一个相应的防守单位,如图4-26所示。
图4-26 防守单位
生命条
敌人在受到攻击的时候,我们并不知道它受到了多少伤害,为了能够显示它的剩余生命值,我们需要为它制作一个生命条,显示在敌人身体的上方。因为这是一款3D游戏,所以我们将使用3D UI功能创建这个生命条。
步骤01
在Hierarchy窗口中单击鼠标右键,选择【UI】→【Canvas】,在场景中创建一个新的Canvas,并命名为Canvas3D,将【Render Mode】设为【World Space】,使这个UI成为一个3D UI,如图4-27所示。
图4-27 设置为3D UI
步骤02
在Hierarchy窗口中单击鼠标右键,选择【UI】→【Slider】创建一个滑动条控件,在Source Image中指定生命条的背景,因为生命条并不需要滑块,所以将Handle Slider Area隐藏或删除,如图4-28所示。
图4-28 生命条背景图
步骤03
默认Silder层级下的Fill即是生命条的前景,在Source Image中设置前景图片,【Image Type】选择【Filled】,【Fill Method】选择【Horizontal】,如图4-29所示。
图4-29 生命条设置
将UI保存为Prefab并放置在Resource目录下,命名为Canvas3D,然后删除场景中的3D UI。
步骤04
打开Enemy.cs脚本,添加创建、更新生命条的代码如下:1
2
运行游戏,在敌人的上方会出现一个生命条,当敌人受到攻击且生命值下降时,生命条状态会改变,如图4-30所示。如果需要精确地放置生命条在角色头上的位置,可以在创建3D模型时专门创建一个节点用来参考生命条的位置。
图4-30 生命条
这个塔防游戏到这里就结束了,它还比较简单,但已具备了塔防游戏的基本要素,如果添加更多的细节和更好的画面,相信它可以变成一款不错的游戏。
本章的最终示例工程保存在资源文件目录c04_TD中。
小结
本章完成了一个塔防游戏的实例,我们使用数组定义场景中的单元格数据并制作了一个地图编辑器,创建敌人行动的路点,还涉及动画的播放、出生点的创建等。