【unity demo】使用unity制作射击游戏demo(下)

上篇的链接:使用unity制作射击游戏demo(上)
在上篇中,我们主要讲解了开发环境的配置,场景搭建,预制件的创建,以及基本的玩家角色控制。
在下篇中,我们主要实现显示游戏情况的HUD,并且让敌人能够自主的动起来,最后实现游戏结束和重启的相关功能。

1.更新游戏文档

主要是更新了胜利与失败条件:玩家需要拾取到对应数目的拾取物,且血量必须大于0。

  • 概念 :
    基于第一人称视角,躲避场景中巡逻和警惕的敌人,并能够进行射击的游戏demo。

  • 机制 :

    1)敌人会在场景中沿指定路线巡逻,并存在警惕范围,当主角进入到敌人的警惕范围后,敌人会自动改变巡逻路线,向主角移动
    2)敌人接触到主角后,会减扣主角的生命值
    3)主角能够通过射击抵御接近的敌人,能拾取物品

  • 胜利条件:

    玩家需要拾取到4个(自定数目)拾取物

  • 用户接口 :

    1)通过WSAD控制角色移动,鼠标控制摄像机方向,使用左键射击
    2)角色通过接触,拾取物体
    3)简单HUD(抬头显示,Head Up Display),显示玩家的血量和剩余的子弹数

  • 剧情 :

    demo暂无剧情

  • 表现风格:

    使用unity的默认3d模型进行搭建,不使用自定义shader和贴图作为额外材质

2.实现游戏管理器

可以知道的是,现在我们需要设置两个变量来对胜利条件进行检监测:玩家拾取数和生命值。
显然这两个变量设置为public并不合适,我们希望它们只能通过游戏管理器相关的类进行读取和修改。
这里主要使用了C#提供的get和set属性,通过这两个属性来修改私有变量,并提供给其他类一个读取和修改的接口。

gameManager类的脚本GameBehavior 如下:

public class GameBehavior : MonoBehaviour
{
    
    
    private int _itemsCollected = 0;
    private int _playerHp = 10;
    
    public int Items
    {
    
    
        get {
    
    return _itemsCollected;}

        set {
    
    
            _itemsCollected = value;
            Debug.LogFormat("Items: {0}", _itemsCollected);
        }
    }

    public int HP
    {
    
    
        get {
    
    return _playerHp;}

        set {
    
    
            _playerHp = value;
            Debug.LogFormat("Lives: {0}", _playerHp);
        }
    }
}

在拾取物的脚本中,我们对应更新:
在初始化方法中,获取gameManager,并在触发碰撞的逻辑中,修改gameManager实例的Items。

public class ItemBehavior : MonoBehaviour
{
    
    

    private GameBehavior gameManager;

    void Start()
    {
    
    
        gameManager = GameObject.Find("GameManager").GetComponent<GameBehavior>();
    }
    
    void OnCollisionEnter(Collision collision)
    {
    
    
        if(collision.gameObject.name == "Player")
        {
    
    
            //其他部分不变

            gameManager.Items += 1;
        }
    }
}

实现拾取后提示,拾取物总数增加。
在这里插入图片描述
在这里插入图片描述

2.1. 通过简易GUI实现HUD

我们通过更新GameBehavior 脚本,实现提示玩家拾取道具数目,玩家血量的UI。

所有对于GUI相关方法的调用,都放入到MonoBehaviour提供的内置OnGUI方法中执行,根据内部逻辑情况,每一个游戏帧内,OnGUI会执行一次到多次。

public class GameBehavior : MonoBehaviour
{
    
    
    private int _itemsCollected = 0;
    private int _playerHp = 10;
	
    public int maxItems;
    public string LabelText;
    
    void Start(){
    
    
        LabelText = "Collect all " + maxItems.ToString() + " items and win the game!";
    }

    public int Items
    {
    
    
        get {
    
    return _itemsCollected;}

        set {
    
    
            //其他部分不变

            if(_itemsCollected >= maxItems)
            {
    
    
                LabelText = "You've found all the items!";
            }
            else
            {
    
    
                LabelText = "Item found, only " + (maxItems - _itemsCollected) + " more to go!";
            }
        }
    }

    public int HP
    {
    
    
        //不变
    }

    void OnGUI()
    {
    
    
    	//通过GUI.Box创建GUI方盒,第一个参数为位置和尺寸信息,第二个参数为内容(字符串)
    	//通过Rect方法给出位置和尺寸:左边距,上边距,宽度,高度
        GUI.Box(new Rect(20, 20, 150, 25), "Player Health: " + _playerHp);

        GUI.Box(new Rect(20, 50, 150, 25), "Items collected: " + _itemsCollected);

        GUI.Label(new Rect(Screen.width/2 - 100, Screen.height - 50, 300, 50), LabelText);
    }
}

在这里插入图片描述
除了HUD以外,我们还需要增加一个胜利结算的画面。

public class GameBehavior : MonoBehaviour
{
    
    
    //........
    private bool showwinScreen = false;
    
    //........

    public int Items
    {
    
    
        //.......

        set {
    
    
            _itemsCollected = value;
            Debug.LogFormat("Items: {0}", _itemsCollected);

            if(_itemsCollected >= maxItems)
            {
    
    
                //......
                showwinScreen = true;
            }
            else
            {
    
    
                //........
            }
        }
    }

    public int HP
    {
    
    
        //.......
    }

    void OnGUI()
    {
    
    
        //...........

        if(showwinScreen)
        {
    
    
            GUI.Button(new Rect(Screen.width/2 - 100, Screen.height/2 -50, 200, 100), "YOU WIN!");
        }
    }
}

在这里插入图片描述

2.2. 游戏暂停和重启

现在我们可以看到相对诡异的一幕,在游戏胜利的提示出来后,玩家角色依然能够在场景中任意穿梭和交互。
所以在胜利后,我们需要暂停整个场景的运行。
在这里插入图片描述
更新GameBehavior :

 if(_itemsCollected >= maxItems)
{
    
    
    LabelText = "You've found all the items!";
    showwinScreen = true;
    //通过Time.timescale
    Time.timeScale = 0f;
}

在这里插入图片描述
重置场景则主要通过UnityEngine.SceneManagement提供的LoadScene方法:
更新GameBehavior :

using UnityEngine.SceneManagement;

public class GameBehavior : MonoBehaviour
{
    
    
    //........

    void OnGUI()
    {
    
    
        //...........

        if(showwinScreen)
        {
    
    
            GUI.Button(new Rect(Screen.width/2 - 100, Screen.height/2 -50, 200, 100), "YOU WIN!");
			//只有一个场景,则默认通过LoadScene(0)进行重置
			//LoadScene能接受场景索引作为参数,也能接受场景名作为参数
            SceneManager.LoadScene(0);

            Time.timeScale = 1.0f;
        }
    }
}

在这里插入图片描述

3.为敌人设置AI

当然敌人一直愣在原地也很没有意思,根据GDD,我们需要赋予敌人自主移动的能力。但显然NPC很难像有人操作一样能够灵活地识别和避开场景中存在的障碍物,并找到前往目标位置的路线。

unity内置了一系列工具来帮助NPC单位识别地图:

  1. NavMesh:NavMesh主要从关卡场景烘焙而来,主要提供地图信息,记录了当前场景中全部可通行的区域。
  2. NavMeshAgent:使用NavMesh的对象,挂载了NavMeshAgent的物体,能够读取NavMesh的信息,获得场景的通行数据,这有助于物体获得前往指定位置的路径
  3. NacMeshObstacle: 对于场景中存在的障碍物,我们可以通过NacMeshObstacle进行标注,这样NavMeshAgent在生成路径时就会绕开对应的障碍物。

目前我们将通过NavMesh获得地图信息,并为敌人game object添加NavMeshAgent,使其实现固定路线的巡逻功能。

3.1 设置NavMesh

我们选择场景中的环境物体,将其设置为Navigation Static。

在后续的NavMesh烘焙中,unity将能够自动识别标注为Navigation Static的物体。
在这里插入图片描述
选择window-AI-Navigation进行烘焙设置。
在这里插入图片描述
在navigation页签下,选择bake面板,并点击bake。
在这里插入图片描述
可以看到场景中出现了烘焙好的NavMesh。
在这里插入图片描述

3.2 为NPC挂载NavMeshAgent

更新enemy的预制件,确保NPC物体全部都挂载了navmeshAgent。
在这里插入图片描述
接下来我们创建空物体Patrol Route,在场景的四个角设置四个巡逻点Location01-04,不需要对的特别整齐哈,这样才能体现出自动寻路的意义。

在这里插入图片描述
修改EnemyBehavior类,让其在初始化时读取Patrol Route,并获取四个location。

public class EnemyBehavior : MonoBehaviour
{
    
    
	//......
	
    public Transform patrolRoute;
    public List<Transform> locations;
    
    void Start()
    {
    
    
        //...........
        InitializePatrolRoute();
    }

    void InitializePatrolRoute()
    {
    
    
        foreach(Transform child in patrolRoute)
        {
    
    
            locations.Add(child);
        }
    }

    //...........
}

可以看到在完成初始化后,四个位置已被读入到Locations列表中。
在这里插入图片描述
接下来我们通过NavMeshAgent提供的方法,让NPC在场景中自动沿4个既定的location进行移动。

public class EnemyBehavior : MonoBehaviour
{
    
    
    //........
    private UnityEngine.AI.NavMeshAgent agent;
    
    void Start()
    {
    
    
        //.....
        InitializePatrolRoute();

        agent = this.GetComponent<UnityEngine.AI.NavMeshAgent>();
        
    }

    void MoveToNextPatrolLocation()
    {
    
    
        if(locations.Count == 0)
            return;
        
        //设置destination后自动寻路和移动
        agent.destination = locations[locationIndex].position;
		
		//通过取余,避免超出列表范围
        locationIndex = (locationIndex + 1) % locations.Count;
    }

    
    void Update()
    {
    
    
    	//当距离目的地足够近,且unity没有在做路线计算时,切换至下一个目的地
    	//pathPending返回当前的路径计算状态
        if(agent.remainingDistance < 0.2f && !agent.pathPending)
        {
    
    
            MoveToNextPatrolLocation();
            Debug.Log("change destination to next point...");
        }
    }

    //............
}

这里动图压缩把NPC的颜色去掉了,但是已经能看到NPC已经正常的在场景中巡逻了。
在这里插入图片描述

3.2.1 让NPC追击玩家角色

既然有了直接给出目的地以驱动NPC移动的方法,那么让NPC自动追击进入到侦测范围的玩家角色也很容易了。
检查到玩家进入范围后,我们自动修改agent的目的地为玩家的坐标,就能够实现NPC的自动追击。

public class EnemyBehavior : MonoBehaviour
{
    
    
    //...........
    private Transform Player;
    
    void Start()
    {
    
    
        //............
        Player = GameObject.Find("Player").transform;
    }

    //..........

    void OnTriggerEnter(Collider other)
    {
    
    
        if (other.name == "Player")
        {
    
    
            //.......
            agent.destination = Player.position;
        }
    }

    void OnTriggerExit(Collider other)
    {
    
    
        //........
        agent.destination = locations[locationIndex].position;
    }
}

在这里插入图片描述

3.3 实现NPC攻击反馈

NPC对玩家的攻击方式,显然不能一直是老牛推车式的交互,一边挪动一边近身搏击。

还有一个重要原因就是,两个碰撞体持续地贴在一体,非常容易出现物理系统计算错误的情况。

所以一方面,我们需要让NPC能够狠狠地将玩家推开。

另一方面,我们需要对AI与玩家的交互进行计算,实时的扣减玩家角色的生命值,从而达成游戏失败的条件。

public class PlayerBehavior : MonoBehaviour
{
    
    
    //.........
    //PushForce 主要是控制NPC碰撞后推动玩家的力度
    public float PushForce = 10;
    //EnemyTrigger 同理,用于碰撞检测和FixUpdate的联动,实现碰撞
    private bool EnemyTrigger = false;
    private GameBehavior _gameManager;
    
    void Start()
    {
    
    
        //........

        _gameManager = GameObject.Find("GameManager").GetComponent<GameBehavior>();
    }

    
    //..........

    void FixedUpdate()
    {
    
    
        //...........

        if(EnemyTrigger)
        {
    
    
        	//这里既给玩家角色一个水平的朝向力,也给玩家一个向上的力,使得玩家受击后会被击飞
            _rb.AddForce(collisionDir + new Vector3(0f, 0.2f, 0f))* PushForce, ForceMode.Impulse);

            EnemyTrigger = false;
        }

        //.......
    }

    void OnCollisionEnter(Collision collision)
    {
    
    
        if(collision.gameObject.name == "Enemy")
        {
    
    
            EnemyTrigger = true;

            _gameManager.HP -= 1;

            collisionDir = (this.transform.position - collision.gameObject.transform.position).normalized;
        }
    }
}

在这里插入图片描述
但是我们也发现,明明没有消灭入侵的玩家角色,NPC在做出一次攻击之后居然扭头就走。

好家伙,公然摸鱼是吧!

原来是我们没有在碰撞体的stay状态下持续的修正agent的目的地,导致NPC只执行一次攻击之后就会回到巡逻的逻辑。

我们修改一下。

// in EnemyBehavior
void OnTriggerStay(Collider other)
{
    
    
    if (other.name == "Player")
    {
    
    
        // Debug.Log("Player stay in guard range!");
        agent.destination = Player.position;
    }
}

在这里插入图片描述
这下NPC攻击的欲望高涨起来了!

3.4 实现子弹和NPC的碰撞交互

同样的,参考碰撞处理的逻辑,我们在enemyBehavior中进行NPC和子弹的碰撞处理。

使NPC能够被子弹消灭。

public class EnemyBehavior : MonoBehaviour
{
    
    
    //...
    private int _Lives = 1;

    public int EnemyLives
    {
    
    
        get {
    
    return _Lives;}

        private set
        {
    
    
            _Lives = value;

            if(_Lives <= 0)
            {
    
    
                Destroy(this.gameObject);
            }
        }
    }

    void OnCollisionEnter(Collision collision)
    {
    
    
        if(collision.gameObject.name == "Bullet(Clone)")
        {
    
    
            EnemyLives -= 1;
        }
    }
}

现在敌人会被玩家角色的子弹消灭了。

在这里插入图片描述

3.5 制作失败结算界面

失败结算界面的逻辑,和成功界面的逻辑很类似,这里我们主要是增加了暂停环节,只有获取到玩家按z键后,才会进行场景的重置。

public class GameBehavior : MonoBehaviour
{
    
    
    //........
    private bool showloseScreen = false;
    private bool resetTag = false;
	
	//即便是Time.timeScale = 0的情况下,update也会正常执行
    void Update(){
    
    
    	//在进入了成功/失败结算的前提下,再按下z键,才会触发重置tag的改变
        if(Input.GetKeyDown(KeyCode.Z) && (showwinScreen || showloseScreen))
        {
    
    
            resetTag = true;
        }
    }

    public int HP
    {
    
    
        get {
    
    return _playerHp;}

        set {
    
    
            _playerHp = value;
            Debug.LogFormat("Lives: {0}", _playerHp);

            if(_playerHp <= 0)
            {
    
    
                Debug.Log("no hp left....");
                LabelText = "No ... the game is so damn diffcult";
                showloseScreen = true;

                Time.timeScale = 0f;
            }
        }
    }

    void OnGUI()
    {
    
    
        //.........

        if(showloseScreen)
        {
    
    
            GUI.Button(new Rect(Screen.width/2 - 100, Screen.height/2 -50, 200, 100), "YOU LOSE....");

            LabelText = "Press z to continue ....";

            //SceneManager.LoadScene(0);
            if(resetTag)
            {
    
    
                SceneManager.LoadScene("SampleScene");

                Time.timeScale = 1.0f;
            }
        }
    }
}

在这里插入图片描述

4.一些其他特殊功能

当然现在游戏demo主体的功能基本已经制作好了,接下来我们可以通过个人喜好尽情地自由发挥,添加一些自己喜欢的功能。

4.1 玩家角色倒地表现

那既然玩家角色已经gg了,肯定不能就平平淡淡的弹出一个YOU LOSE,就像动画中的反派最终会被主角狠狠地击飞一样。玩家角色在HP归零时也需要狠狠被击飞出去并且倒地。

首先在击倒玩家角色后,NPC已经没有再进行巡逻和鞭尸攻击的理由,所以NPC需要停止进行移动。

public class EnemyBehavior : MonoBehaviour
{
    
    
    //NPC在击倒玩家的时候肯定是出于OnTriggerStay响应的状态,仅需在该方法内修改,使agent停止行动即可
    void OnTriggerStay(Collider other)
    {
    
    
        //.......
        if (_gameManager.HP == 0)
        {
    
    
            agent.isStopped = true;
        }
    }
}

前面我们为了避免玩家在正常移动过程中倒地,对rigidBody的旋转轴进行了限制,所以在需要玩家倒地的时候,我们需要解除对所有旋转轴的限制。在多维度力的推动下,玩家角色就很容易在生命值归零后倒地了。

public class PlayerBehavior : MonoBehaviour
{
    
    
    private bool EnemyTrigger = false;
    private bool fallTrigger = false;
    private float fallTime;

    private bool fallCountTimeTrigger = false;
    private Vector3 collisionDir;
    
    private CameraBehavior _mainCamera;

    void FixedUpdate()
    {
    
    
    	//针对物理系统的修改,只能放入FixedUpdate中,通过fallTrigger进行碰撞响应方法OnCollisionEnter和FixedUpdate的同步
    	//开启角色的倒地处理
        if (fallTrigger)
        {
    
    
            _rb.freezeRotation = false;
        }
		
		//避免角色倒地后持续乱滚,3秒后锁死速度
        if(Time.time - fallTime > 3 && fallCountTimeTrigger)
        {
    
    
            _rb.velocity = new Vector3(0, 0, 0);
        }

        //NPC碰撞玩家角色的物理系统处理逻辑
        if(EnemyTrigger)
        {
    
    
            _rb.AddForce((collisionDir + new Vector3(0f, 0.2f, 0f))* PushForce  + Vector3.up, ForceMode.Impulse);
			
			//关闭NPC和角色的物理计算
            EnemyTrigger = false;

            if(_gameManager.HP == 0)
            {
    
    
            	//启动死亡动画倒计时
                fallCountTimeTrigger = true;
                fallTime = Time.time;
                
                //使用死亡视角相机
                _mainCamera.useDeadCamera = true;
            }
        }

       //.......
    }

    void OnCollisionEnter(Collision collision)
    {
    
    
        if(collision.gameObject.name == "Enemy")
        {
    
    
            if(_gameManager.HP == 0)
            {
    
    
                fallTrigger = true;
            }

            collisionDir = (this.transform.position - collision.gameObject.transform.position).normalized;
        }
    }
}

接下来NPC在击倒玩家角色后,玩家角色就会以一个漂亮的托马斯回旋飞出去,然后倒在地上了。
在这里插入图片描述

4.2 设置玩家角色死亡视角

另外,玩家倒地后,主摄像机的跟随逻辑也要改变。不然随着玩家角色在空中回旋,镜头会出现剧烈的抖动。

所以玩家角色gg后,我们需要切换到死亡摄像机位置,好好地记录玩家角色旋转倒地的的瞬间。

在玩家倒地静止后,再进入到失败结算画面。

public class CameraBehavior : MonoBehaviour
{
    
    
    public Vector3 camOffset = new Vector3(0f, 1.0f, 1.0f);
    private Vector3 deadViewOffset = new Vector3(0f, 10f, 10f);
    
    private Transform target;
    private GameBehavior _gameManager;
    private bool deadCameraTag = false;
    private bool deadSetTag = true;
    
    void Start()
    {
    
    
        target = GameObject.Find("Player").transform;

        _gameManager = GameObject.Find("GameManager").GetComponent<GameBehavior>();
    }

    // 注意lateupdate也是monoBehavior提供的默认方法,其更新频率与帧率一致,但更新顺序在update之后
    void LateUpdate()
    {
    
    
        if (!deadCameraTag)
        {
    
    
            this.transform.position = target.TransformPoint(camOffset);
        }
        else if(deadSetTag)
        {
    
    
        	//注意死亡摄像机的位置只设置一次,以玩家HP归零的位置为基准进行一次调整
            //Debug.Log("change camera to dead view");
            this.transform.position = target.TransformPoint(deadViewOffset);
            deadSetTag = false;
        }

        // this.transform.LookAt(target);
        if(_gameManager.HP == 0)
        {
    
    
            this.transform.LookAt(target.position);
        }
        else
        {
    
    
            this.transform.LookAt(target.position + target.transform.forward * 10);
        }
    }

    public bool useDeadCamera{
    
    
        get{
    
    return deadCameraTag;}

        set{
    
    
            deadCameraTag = value;
        }
    }
}

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/misaka12807/article/details/131935577
今日推荐