本次大作业中,我选择的主题是制作一款简单的坦克大战小游戏,此处借鉴了学长的博客,使用了KawaiiTank作为坦克的基本模型,利用Unity自带的3D导航技术实现敌人坦克的自动导航。
源代码:UnityGame/TankWar/Assets at master · ShirohaBili/UnityGame (github.com)
3D自动寻路的实现
第一步:建立地图
首先,先导入KawaiiTank包中自带的Test_Field场景,然后在它的基础上,对整张地图进行美化,加入一些后续会实现的道具和地方的坦克,最后实现的地图如下所示
将上述的场景另存为TankWar,保存在Assert文件夹目录下。
第二步:建立NavMesh导航图
先点击页面上方的Window按钮,在下拉框中选择AI一栏,选中AI一栏内的Navigation选项,引出Navigation页面,再点击Terrain,在右侧刚引出的Navigation页面中,如下设置bake选项:
设置完成后点击bake按钮,生成如下的NavMesh导航图:
第三步:编写脚本实现导航
为所有的敌方坦克加入NavMeshAgent插件,然后编写如下的脚本,实现自动巡航:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
namespace ChobiAssets.KTP{
public class PlaceTarget : MonoBehaviour
{
public GameObject target;
NavMeshAgent mr;
Damage_Control_CS DamageScript;
private int destPoint = 0;
private bool found = false;
private static Vector3[] points = { new Vector3(12.7f,-3.52f,140.4f), new Vector3(91.72f, -2.427363f, -3.54f)}; //设置的巡逻点
// Use this for initialization
void Start()
{
mr = GetComponent<NavMeshAgent>(); //获取到自身的NavMeshAgent组件
DamageScript = transform.root.GetComponent<Damage_Control_CS>(); //获取到自身的Damage_Control_CS脚本,用于判断该坦克是否已经爆炸
}
// Update is called once per frame
void Update()
{
if (!DamageScript.getStatus() && (found || Vector3.Distance(transform.position, target.transform.position) <= 30)){ //玩家没有被敌人发现时,巡逻
mr.SetDestination(target.transform.position);
mr.autoBraking = true;
found = true;
}
else if (!DamageScript.getStatus() && Vector3.Distance(transform.position, target.transform.position) > 30){ //玩家被敌人发现时,追捕
if (this.gameObject.tag == "guard") return; //如果该坦克是负责守护物资的,则不巡航。
patrol();
}
}
private void patrol()
{
if(!mr.pathPending && mr.remainingDistance < 10f){
GotoNextPoint();
Debug.Log(mr.pathStatus);
}
mr.SetDestination(points[destPoint]);
}
private void GotoNextPoint()
{
Debug.Log(mr.SetDestination(points[destPoint]));
destPoint = (destPoint + 1) % points.Length; //在两个点之间来回循环
}
}
}
本段代码中需要注意的地方:
实现导航是利用NavMeshAgent组件实现的,所以首先需要获取本游戏对象上的组件。
这里设置了一个简单的“感知系统”,当坦克和玩家距离大于30f时,坦克会根据预先设置好的点位进行巡逻,若是发现了玩家,即距离小于30f时,会对玩家展开追捕。若系统判定本坦克已经爆炸,则会自动停止追捕,停留在原地。
关于代码中出现的Damage_Control_CS中的getStatus方法,这是我自己加的,需要在其中增加一个方法,代码如下:
public bool getStatus(){
return isDead;
}
其实就是简单地返回一个是否死亡的标记符而已。
通过上述的过程,已经可以实现敌方坦克的自动寻路功能了,但现在它还不能射击攻击玩家,因此还需要进一步完善。
敌方坦克的攻击实现
在原本的Fire_Control_CS中,是没有设置敌方坦克的攻击的,所以需要对其进行修改,在其Update函数中,简单增加一些判断:
void Update()
{
if (isLoaded == false)
{
return;
}
if (isSelected)
{
inputScript.Get_Input();
}
counter = counter + Time.deltaTime;
if (gameObject.tag != "Player" && counter > 3.0f && Vector3.Distance(transform.position, target.transform.position) <= 30){
Fire();
counter = 0;
}
}
代码中有一个if条件判定语句,它判断了三个条件:
①:判断本对象是否是玩家控制的对象(为了与玩家区别开来,玩家通过点击鼠标左键来开火,不会自动开火)。
②:当前计数器超过了3.0f(具体的值需要根据ReLoad时间来设置)
③:为了增加命中率,我设置了靠近到一定距离才开枪,否则不开枪。
将上述的脚本修改完成后,由于KawaiiTank中已经将这一脚本设置好了,所以直接运行游戏即可实现敌方坦克开炮的功能。
血条/装填条的实现
总览:
UML设计图如下:
为了让玩家更好地了解当前坦克的状况,我分别设计了一个血条和装填条,血条显示的是当前的坦克的血量,装填条显示的是当前的装弹情况,实现的效果如下所示:
这两个条都不是静止不动的,在受到伤害或者发射导弹后,会发生相对应的变化,接下来作详细的说明:
血条
先使用UGUI设计一个简单的血条,创建一个Canvas对象作为载体,并在其中加入一个Slider,调整合适的参数后设置为Camera_Manager的子对象,使其能够跟随画面移动,一直显示在玩家控制的坦克的正上方。
然后再编写代码,将其和玩家控制的坦克联系到一起:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace ChobiAssets.KTP
{
public class HealthBar : MonoBehaviour
{
public Slider slider;
float factor = 0.1f;
public bool active = true;
public Damage_Control_CS DamageScript;
GameObject gameObject;
float initialDurability;
float currentDurability;
private void Start() {
gameObject = GameObject.Find("SD_Tiger-I_2.0"); //找到玩家的操作的坦克
DamageScript = gameObject.GetComponent<Damage_Control_CS>(); //找到其上的Damage_Control_CS脚本
initialDurability = 300f;
slider.value = DamageScript.getHealth() / initialDurability * 100; //初始化slider.value为100
}
void change() {
if (DamageScript == null) slider.value = Mathf.Lerp(slider.value,0,0.1f); //当玩家被摧毁时,为了避免出现空指针访问,这里直接判断值为0
else slider.value = Mathf.Lerp(slider.value,DamageScript.getHealth() / initialDurability * 100,0.1f); //当玩家没有被摧毁时,根据当前的生命值计算slider的值
}
void Update () {
this.transform.LookAt (Camera.main.transform.position);
change();
Color current = slider.fillRect.transform.GetComponent<Image>().color; //设置不同生命值下的血条颜色
if (slider.value <= 30) { //生命值在30%以下时,血条为红色
slider.fillRect.transform.GetComponent<Image>().color = Color.Lerp(current, Color.red, factor);
}
else if (slider.value <=60){ //生命值在30%-60%之间时,血条为黄色
slider.fillRect.transform.GetComponent<Image>().color = Color.Lerp(current, Color.yellow, factor);
}
else{ //生命值大于60%时,血条为绿色
slider.fillRect.transform.GetComponent<Image>().color = Color.Lerp(current, Color.green, factor);
}
}
}
}
代码大致的功能已经在注释中给出,这里做一点小小的补充:
使用Mathf.Learp()方法的目的是通过使用插值,实现血条平滑减少和增加的功能,不会显得特别的突兀。
DamageScript内的getHealth()方法也是我自己增加的,它实现的内容其实和前文的getStatus差不多,返回玩家坦克当前的生命值。
装填条
在KawaiiTank中,作者设置了一个ReLoad时间,在发射一枚炮弹之后,都会需要有一定的时间来重新装填,装填完成之后才可以发射炮弹,这样的设计更加符合真实的情况,增加了游戏的趣味性。
为了让玩家更好地了解到目前装填的情况,我设计了一个装填条,它位于血条的正下方,外观和血条是一模一样的,在发射后会清零,之后会随着时间缓慢恢复。代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace ChobiAssets.KTP
{
public class ReloadBar : MonoBehaviour
{
public Slider slider;
float factor = 0.1f;
public bool active = true;
public Fire_Control_CS FireScript; //获取发射状态
private void Start() {
slider.value = 100;
}
void change() {
if (!FireScript.Loading() && active){ //发射完之后,清零
while (slider.value != 0f) slider.value = slider.value - Time.deltaTime*1000;
active = false;
}
}
void Update () {
this.transform.LookAt (Camera.main.transform.position);
change();
if (slider.value != 100f) { //根据时间缓慢恢复
slider.value = slider.value + Time.deltaTime*100;
active = true;
}
Color current = slider.fillRect.transform.GetComponent<Image>().color; //和血条一样
if (slider.value <= 30) {
slider.fillRect.transform.GetComponent<Image>().color = Color.Lerp(current, Color.red, factor);
}
else if (slider.value <=60){
slider.fillRect.transform.GetComponent<Image>().color = Color.Lerp(current, Color.yellow, factor);
}
else{
slider.fillRect.transform.GetComponent<Image>().color = Color.Lerp(current, Color.green, factor);
}
}
}
}
从上面的代码可以看出,和血条不一样的地方在于,缓慢恢复的过程没有采用插值法,而是直接利用Time.deltaTime,方便控制时间。
子弹系统设计
为了使游戏更加完整,我设置了子弹系统,初始状态下,玩家具有五发炮弹可以发射,若是子弹耗尽了,则无法发射炮弹,为了实现这一功能,我对Fire_Control_CS进行了更改了配置,加入了curAmmo变量,用于记录目前的子弹数量,并对Fire方法进行了修改,如下所示:
public void Fire()
{
if (gameObject.tag == "Player"){ //只有玩家有这个限制,敌方坦克不受子弹数量限制
if (curAmmo > 0){
curAmmo--;
}
else return;
}
// Call the "Fire_Spawn_CS".
fireSpawnScript.Fire_Linkage();
// Call the "Barrel_Control_CS".
barrelScript.Fire_Linkage();
// Add recoil shock force to the MainBody.
bodyRigidbody.AddForceAtPosition(-thisTransform.forward * recoilForce, thisTransform.position, ForceMode.Impulse);
// Reload.
StartCoroutine("Reload");
}
通过上述的更改,当玩家开火时,会自动减少当前的子弹数量,若子弹数量为0时,则直接返回,此时就无法发射炮弹了。
为了让玩家能够实时了解当前的子弹数量,我为它设计了UI,在屏幕右上角自动显示当前的子弹数量,当子弹为0时,会出现弹药不足的提示,代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace ChobiAssets.KTP
{
public class UserGUI : MonoBehaviour {
private GUIStyle score_style = new GUIStyle();
private GUIStyle text_style = new GUIStyle();
public Fire_Control_CS fireScript;
void Start () {
text_style.normal.textColor = new Color(0, 0, 0, 1);
text_style.fontSize = 16;
score_style.normal.textColor = new Color(1,0.92f,0.016f,1);
score_style.fontSize = 16;
}
private void OnGUI() {
GUI.Label(new Rect(Screen.width - 250, 10, 200, 50), "子弹数量:", text_style);
if (fireScript.getAmmo() !=0) GUI.Label(new Rect(Screen.width - 170, 10, 200, 50), "" + fireScript.getAmmo(), text_style);
else GUI.Label(new Rect(Screen.width - 170, 10, 200, 50), "弹药不足!!" , score_style);
}
}
}
这里沿用了之前作业中实现的代码,因此变量名什么的可能有点奇怪,不用太在意。
物资设计
为了和之前设计的子弹系统和血条形成配套,我这里设计了两种物资,分别是子弹包和医疗包,分布在地图上的两个地方,可供玩家收集。
碰到医疗包时,会自动将其拾取,当玩家血量没满的时候,会为玩家增加30的血量,否则就会变无效,碰到弹药包时,也会自动拾取,为玩家增加5发子弹。
在编写代码之前,我先创建了两个Cube游戏对象,在网络上找了两张贴图,将贴图贴上去,并更改了两个Cube的标签,分别为medkit和ammo,便于后续的使用。最后设计出来的医疗包和弹药包如下图所示:
上面的是弹药包,下面的是医疗包。
完成建模之后,编写代码来判断是否出现碰撞,然后决定是否删除该游戏对象,并奖励玩家对应的物资,UML设计图如下:
详细的代码如下所示:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace ChobiAssets.KTP{
public class Collide_Detect : MonoBehaviour
{
// Start is called before the first frame update
private GameObject obj;
Damage_Control_CS DamageScript;
public Fire_Control_CS fireScript;
void Start()
{
obj = this.gameObject;
}
// Update is called once per frame
void Update()
{
}
void OnCollisionEnter(Collision e) {
if (e.gameObject.tag == "Player" && obj.tag == "medkit"){ //碰到医疗包的时候
DamageScript = e.transform.root.GetComponent<Damage_Control_CS>();
DamageScript.addHealth();
Destroy(this.gameObject);
}
else if (e.gameObject.tag == "Player" && obj.tag =="Ammo"){ //碰到弹药包的时候
fireScript.addAmmo();
Destroy(this.gameObject); //碰到了都需要删除
}
}
}
}
其中加入了一个碰撞体tag的判断,只有玩家碰到的时候会有奖励并且摧毁该物资,若是敌方坦克碰到了,不会有奖励物资也不会消失。
代码中使用了DamageScript.addHealth() 方法,这是我自行添加的,代码如下:
public void addHealth(){
if (currentDurability < 270f) currentDurability = currentDurability + 30; //判断,保证玩家的生命值不超过上限
else currentDurability = 300f;
}
同时也用到了fireScript内的addAmmo() 方法,这也是我添加的,代码如下:
public void addAmmo(){
curAmmo = curAmmo + 5; //添加五发子弹
}
通过上述的代码和脚本,即可以实现碰撞检测,并根据是否是玩家,决定是否添加物资的操作了。
其他
本栏的创建是为了补充说明一些前文没有提到的内容
巡逻点的设置:我设置了两个巡逻点,光是看代码很难理解是什么东西,其中一个点其实就是主道路的尽头,另一个点是放置医疗包的房间门口,敌方坦克会在这两个点之间来回巡逻。
守卫角色的设置:在我的设计中,弹药包是稀缺物品,所以很容易就能获取的话不符合基本的常识,毕竟在很多游戏中,顶级武器都是由大Boss守着的,所以我为弹药创建了一个类似军火库的地形,并且配置了两台坦克作为护卫守着弹药库,它们的tag为guard,在前文findTarget中配置好了,它们不需要巡逻,只需要在原地守着包等着玩家过来。