上篇的链接:使用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单位识别地图:
- NavMesh:NavMesh主要从关卡场景烘焙而来,主要提供地图信息,记录了当前场景中全部可通行的区域。
- NavMeshAgent:使用NavMesh的对象,挂载了NavMeshAgent的物体,能够读取NavMesh的信息,获得场景的通行数据,这有助于物体获得前往指定位置的路径
- 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;
}
}
}