塔防游戏

思考并回答以下问题:

本章涵盖:

  • 策划
  • 地图编辑器
    • “格子”数据
    • 在Inspector窗口添加自定义U控件
    • 创建一个自定义窗口
  • 游戏场景
  • 制作UI
  • 创建游戏管理器
  • 摄像机
  • 路点
  • 敌人
  • 敌人生成器
    • 创建敌人生成器
    • 遍历敌人
  • 防守单位
  • 生命条
  • 小结

本章将使用Unity完成一款塔防游戏。我们将使用自定义的编辑器创建场景,创建路点引导敌人行动,对战斗进行配置、动画播放,还涉及摄像机控制和UI界面等。

策划

塔防游戏的基本玩法比较类似,在场景中我方有一个基地,敌人从场景的另一侧出发,沿着相对固定的路线攻打基地。我方可以在地图上布置防守单位,攻击前来进攻的敌人,防止他们闯入基地。

**场景**

塔防游戏的场景有些固定的模式,它由一个二维的单元格组成,每个格子的用途可能都不同:

  • 专用于摆放防守单位的格子。
  • 专用于敌人通过的格子。
  • 既无法摆放防守单位,也不允许敌人通过的格子。
**摄像机**

摄像机始终由上至下俯视游戏场景,按住鼠标左键并移动可以移动摄像机的位置。

**胜负判定**

我方基地有10点生命值,敌人攻入基地一次减少一点生命值,当生命值为0,游戏失败。

敌人以波数的形式向我方基地进攻,每波由若干个敌人组成。在这个实例中,一关有10波,当成功击退敌人10波的进攻则游戏胜利。

**敌人**

敌人有两种:一种在陆地上行走;另一种可以飞行。每打倒一个敌人会奖励一些铜钱,用来购买新的防守单位。

**防守单位**

塔防游戏会有多种类型的防守单位。本游戏有两种类型的防守单位:一种是近战类型;另一种是远程。每造一个防守单位需要消耗相应数量的铜钱。

**UI界面**

游戏中的UI包括防守单位的按钮、敌人的进攻波数、基地的生命值和铜钱数量。

当防守单位攻击敌人时,在敌人的头上需要显式一个生命条表示剩余的生命值。

当游戏失败或胜利后显示一个按钮重新游戏。

地图编辑器

在正式开始制作游戏之前,我们有必要先完成一个塔防游戏的地图编辑器。Unity编辑器的自定义功能非常强大,几乎可以把Unity编辑器扩展成任何界面。在示例中,我们将完成一个“格子”编辑系统,帮助我们输入塔防游戏的地图信息。

“格子”数据

新建工程,在Hierarchy窗口中创建一个空对象,然后创建脚本TileObject.cs指定给这个空对象,这里将空对象命名为Grid Object,这个类主要用于保存场景中的“格子”数据,代码如下所示。

TileObject.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
using 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
[HideInInspector]
public int dataID = 0;
[HideInInspector]
// 是否显示数据信息
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));
}
}
}

在Inspector窗口添加自定义UI控件

Unity提供了API可以扩展Inspector窗口中的UI控件。

(1)以本示例的地图编辑器为例,为了扩展TileObject这个类的Inspector窗口,我们创建了脚本TileEditor.cs,继承自Editor。因为它是一个编辑器脚本,所以必须放到Editor文件夹中,代码如下:

TileEditor.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
using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(TileObject))]
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();
}
}

(2)

(3)

(4)

游戏场景

创建游戏管理器

GameManager.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
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
using UnityEngine;
using UnityEngine.SceneManagement;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.UI; // 注意UI控件命名空间的引用
using UnityEngine.Events; // 注意UI事件命名空间的引用
using UnityEngine.EventSystems; // 注意UI事件命名空间的引用


public class GameManager : MonoBehaviour {

public static GameManager Instance;

// 显示路点的debug开关
public bool m_debug = true;
// 路点
public List<PathNode> m_PathNodes;

// 敌人列表
public List<Enemy> m_EnemyList = new List<Enemy>();

// 地面的碰撞Layer
public LayerMask m_groundlayer;

// 波数
public int m_wave = 1;
public int m_waveMax = 10;

// 生命
public int m_life = 10;

// 铜钱数量
public int m_point = 30;

// UI文字控件
Text m_txt_wave;
Text m_txt_life;
Text m_txt_point;
// UI重新游戏按钮控件
Button m_but_try;

// 当前是否选中的创建防守单位的按钮
bool m_isSelectedButton =false;

void Awake()
{
Instance = this;
}

// Use this for initialization
void Start () {

// 创建UnityAction,在OnButCreateDefenderDown函数中响应按钮按下事件
UnityAction<BaseEventData> downAction = new UnityAction<BaseEventData>(OnButCreateDefenderDown);
// 创建UnityAction,在OnButCreateDefenderDown函数中响应按钮抬起事件
UnityAction<BaseEventData> upAction = new UnityAction<BaseEventData>(OnButCreateDefenderUp);

// 创建按钮按下事件Entry
EventTrigger.Entry down = new EventTrigger.Entry();
down.eventID = EventTriggerType.PointerDown;
down.callback.AddListener(downAction);

// 创建按钮抬起事件Entry
EventTrigger.Entry up = new EventTrigger.Entry();
up.eventID = EventTriggerType.PointerUp;
up.callback.AddListener(upAction);

// 查找所有子物体,根据名称获取UI控件
foreach (Transform t in this.GetComponentsInChildren<Transform>())
{
if (t.name.CompareTo("wave") == 0) //找到文字控件"波数"
{
m_txt_wave = t.GetComponent<Text>();
SetWave(1);
}
else if (t.name.CompareTo("life") == 0) //找到文字控件"生命"
{
m_txt_life = t.GetComponent<Text>();
m_txt_life.text = string.Format("生命:<color=yellow>{0}</color>", m_life);
}
else if (t.name.CompareTo("point") == 0) //找到文字控件"铜钱"
{
m_txt_point = t.GetComponent<Text>();
m_txt_point.text = string.Format("铜钱:<color=yellow>{0}</color>", m_point);
}
else if (t.name.CompareTo("but_try") == 0) //找到按钮控件"重新游戏"
{
m_but_try = t.GetComponent<Button>();

// 添加按钮单击函数回调,重新游戏按钮
m_but_try.onClick.AddListener( delegate()
{
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
});
// 默认隐藏重新游戏按钮
m_but_try.gameObject.SetActive(false);

}
else if (t.name.Contains("but_player")) //找到按钮控件"创建防守单位"
{
// 给创建防守单位按钮添加EventTrigger,并添加前面定义的按钮事件
EventTrigger trigger = t.gameObject.AddComponent<EventTrigger>();
trigger.triggers = new List<EventTrigger.Entry>();
trigger.triggers.Add(down);
trigger.triggers.Add(up);
}
}

BuildPath();

}

// Update is called once per frame
void Update () {

// 如果选中创建士兵的按钮则取消摄像机操作
if (m_isSelectedButton)
return;

// 鼠标或触屏操作,注意不同平台的Input代码不同
#if (UNITY_IOS || UNITY_ANDROID) && !UNITY_EDITOR
bool press = Input.touches.Length > 0 ? true : false; // 手指是否触屏
float mx = 0;
float my = 0;
if (press)
{
if ( Input.GetTouch(0).phase == TouchPhase.Moved) // 获得手指移动距离
{
mx = Input.GetTouch(0).deltaPosition.x * 0.01f;
my = Input.GetTouch(0).deltaPosition.y * 0.01f;
}
}
#else
bool press = Input.GetMouseButton(0);
// 获得鼠标移动距离
float mx = Input.GetAxis("Mouse X");
float my = Input.GetAxis("Mouse Y");


#endif
// 移动摄像机
GameCamera.Inst.Control(press, mx, my);
}

// 更新文字控件"波数"
public void SetWave(int wave)
{
m_wave= wave;
m_txt_wave.text = string.Format("波数:<color=yellow>{0}/{1}</color>", m_wave, m_waveMax);

}

// 更新文字控件"生命"
public void SetDamage(int damage)
{
m_life -= damage;
if (m_life <= 0) {
m_life = 0;
m_but_try.gameObject.SetActive(true); //显示重新游戏按钮
}
m_txt_life.text = string.Format("生命:<color=yellow>{0}</color>", m_life);

}

// 更新文字控件"铜钱"
public bool SetPoint(int point)
{
if (m_point + point < 0) // ͭ如果铜钱数量不够
return false;
m_point += point;
m_txt_point.text = string.Format("铜钱:<color=yellow>{0}</color>", m_point);

return true;

}

// 按下"创建防守单位按钮"
void OnButCreateDefenderDown(BaseEventData data)
{
m_isSelectedButton = true;
}

// 抬起 "创建防守单位按钮" 创建防守单位
void OnButCreateDefenderUp( BaseEventData data )
{
// 创建射线
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hitinfo;
// 检测是否与地面相碰撞
if (Physics.Raycast(ray, out hitinfo, 1000, m_groundlayer))
{
// 如果选中的是一个可用的格子
if (TileObject.Instance.getDataFromPosition(hitinfo.point.x, hitinfo.point.z) == (int)Defender.TileStatus.GUARD)
{
// 获得碰撞点位置
Vector3 hitpos = new Vector3(hitinfo.point.x, 0, hitinfo.point.z);
// 获得Grid Object坐位位置
Vector3 gridPos = TileObject.Instance.transform.position;
// 获得格子大小
float tilesize = TileObject.Instance.tileSize;
// 计算出所点击格子的中心位置
hitpos.x = gridPos.x + (int)((hitpos.x - gridPos.x) / tilesize) * tilesize + tilesize * 0.5f;
hitpos.z = gridPos.z + (int)((hitpos.z - gridPos.z) / tilesize) * tilesize + tilesize * 0.5f;

// 获得选择的按钮GameObject,将简单通过按钮名字判断选择了哪个按钮
GameObject go = data.selectedObject;

if (go.name.Contains("1")) //如果按钮名字包括“1”
{
if (SetPoint(-15)) // 减15个铜钱,然后创建近战防守单位
Defender.Create<Defender>(hitpos, new Vector3(0, 180, 0));
}
else if (go.name.Contains("2"))// 如果按钮名字包括“2”
{
if (SetPoint(-20)) // 减20个铜钱,然后创建远程防守单位
Defender.Create<Archer>(hitpos, new Vector3(0, 180, 0));
}
}
}
m_isSelectedButton = false;
}

[ContextMenu("BuildPath")]
void BuildPath()
{
m_PathNodes = new List<PathNode>();
// 通过路点的Tag查找所有的路点
GameObject[] objs = GameObject.FindGameObjectsWithTag("pathnode");
for (int i = 0; i < objs.Length; i++)
{
m_PathNodes.Add( objs[i].GetComponent<PathNode>() );
}
}


void OnDrawGizmos()
{
if (!m_debug || m_PathNodes == null)
return;

Gizmos.color = Color.blue; // 将路点连线的颜色设为蓝色
foreach (PathNode node in m_PathNodes) // 遍历路点
{
if (node.m_next != null)
{ // 在路点间画出连接线
Gizmos.DrawLine(node.transform.position, node.m_next.transform.position);
}
}
}
}

敌人生成器

塔防游戏的敌人通常是成批出现,一波接着一波,因为敌人的数量众多,多以需要一个生成器按预先设置的顺序生成不同的敌人。

创建敌人生成器

(1)创建WaveData.cs,它定义了战斗时每波敌人的配置。

1
2
3
4
5
6
7
8
9
10
11
using UnityEngine;
using System.Collections.Generic;

[System.Serializable]
public class WaveData
{
public int wave = 0;
public List<GameObject> enemyPrefab;
public int level = 1; // 敌人的等级
public float interval = 3; // 每3秒创建一个敌人
}

(2)创建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
69
70
71
72
73
using 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");
}
}

(3)创建一个空游戏体作为敌人生成器放置到场景中,为其指定EnemySpawner.cs脚本。在m_startNode中设置起始路点,在Waves中配置敌人的生成,这里配置了10波,如下图所示。

遍历敌人

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void Start () 
{
GameManager.Instance.m_EnemyList.Add(this);
// ...
}

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();
}
}
0%