(一)利用泛型单例模式创建 GameManager
创建泛型单例Singleton<T>,便于其他Manager脚本可以继承该类来简化代码。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Singleton<T> : MonoBehaviour where T : Singleton<T>
{
private static T instance;
public static T Instance
{
get { return instance; }
}
protected virtual void Awake()
{
if (instance != null)
Destroy(gameObject);
else
instance = (T)this;
}
public static bool IsInitialized
{
get { return instance != null; }
}
protected virtual void OnDestroy()
{
if (instance == this)
{
instance = null;
}
}
}
在MouseManager类继承此类时,可以重写Awake方法,在其中增DontDestroyOnLoad(this);函数来防止切换场景时销毁脚本。
让GameManager继承泛型单例,并在PlayerController的Start方法中将玩家数值注册到GameManager中。
(二)Observer Pattern 接口实现观察者模式的订阅和广播
观察者模式: 定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象。这个主题对象在状态发生变化时,会通知所有观察者对象,使他们能够自动更新自己。
创建工具接口IEndGameObserver.让EnemyController类实现这一接口来让所有敌人订阅游戏结束的消息,也就是观察玩家生命值归零。。在GameManager中创建IEndGameObserver的列表来记录所有实现这一接口的类,这样就在游戏系统中记录了所有会在游戏结束时做出相同反应的敌人对象,在EnemyController中使用OnEnable和OnDisable两个方法来在敌人创建和销毁完成时(OnDistory时在销毁时执行,详见unity生命周期)加入或注销IEndGameObserver的列表。
如果提示报错NullReferenceException: Object reference not set to an instance of an object
EnemyController.OnEnable (),那么说明EnemyController在GameManager之前执行,因此产生了空指针报错。由于OnEnable在生命周期中是在Awake后执行,但仍然排在了GameManager脚本挂载之前,所以可以通过改变挂载顺序或将敌人观察者注册行为放在Start方法中来临时解决。
修改代码执行顺序的方式也可以从编辑器来解决,“方法是在 Project Settings 中 找到 Script Execute Order 手动添加你的 Manager 类代码,并将它的数值设置为一个负数,例如-20。只要在 Default Time 之上则会优先其他脚本执行”。
当玩家死亡时触发通知来执行各个观察者的行为,因此可以在GameManager中使用Update监测,或在PlayerController中通知,这里采用后者:
private void Update()
{
isDead = characterStats.CurrentHealth == 0;//检查是否死亡
if (isDead)
GameManager.Instance.NotifyObservers();
SwitchAnimation();
lastAttackTime -= Time.deltaTime;//冷却时间减少
}
设置好订阅与通知后,需要实现敌人的EndNotify()方法的具体内容,如控制动画播放胜利动画,通告敌人玩家已经死亡等。另外胜利动画可以单独在Animator中占据一个Layer,防止其他Layer将其覆盖。
(三)创建更多相似敌人
从设置好的slime创建更多相同敌人,通过将数值复制,可以让每个敌人对象都拥有独立的数据。将CharacterStats脚本中的数据复制一份副本作为实际使用的数据即可,这样也可以同时解决每次测试都要重新输入生命值数据的问题:
public CharacterData_SO tempData;
public CharacterData_SO characterData;//ScriptableObject的目标数据对象
private void Awake()
{
if(tempData != null)
{
characterData=Instantiate(tempData);//获得模板数据的copy
}
}
接下来可以简略制作另一个和slime相似的敌人-turtle,导入预制体和相应的组件,组件可以通过[Requirement]字段来自动添加。Animator则可以在已有的动画控制器上修改,或采用创建Animator Override Controller来有选择地进行动画的覆盖,这种方法适用于除动画选择外其他参数都相同的控制器。想要让turtle的攻击能够正常生效还需要再动画里添加动画事件和对应的攻击方法。
(四)创建新的敌人
创建新的石头人和兽人敌人。下面先对兽人士兵的脚本和动画进行设计。
由于兽人士兵的动画逻辑于Slime略有不同,因此需要在原有Animator基础上进行修改。将所有动画进行替换,由于想要把暴击动画改为技能动画,所以需要修改参数和转换条件。对于脚本,新创建单独的Grunt脚本来控制兽人,且让其继承EnemyController来复用代码。导入脚本并创建对应数值,添加动画event。在Grunt中实现击飞的技能,并通过动画事件来触发方法:
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class Grunt : EnemyController
{
[Header("skill")]//兽人在玩家距离过近时触发技能击飞
public float kickForce=10;//击飞的力
public void KickOff()//Grunt的技能函数
{
if(attackTarget!=null)
{
transform.LookAt(attackTarget.transform.position);
Vector3 direction = attackTarget.transform.position - transform.position;//玩家被击飞的方向
direction.Normalize();//量化
attackTarget.GetComponent<NavMeshAgent>().isStopped = true;//目标被击飞之前先被打断动作
attackTarget.GetComponent<NavMeshAgent>().velocity= direction*kickForce;
attackTarget.GetComponent<Animator>().SetTrigger("dizzy");
}
}
}
完成后,由于给兽人士兵的技能是击飞,所以可以给被击飞的玩家启动眩晕动画,而要想实现在被攻击时无法移动,则可以采用在Animator中添加代码的方式。比如在GetHit动画中添加行为StopAgent:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class StopAgent : StateMachineBehaviour
{
// OnStateEnter is called when a transition starts and the state machine starts to evaluate this state
//animator:所挂载的物体
//实现被攻击时无法移动
override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
animator.GetComponent<NavMeshAgent>().isStopped = true;
}
// OnStateUpdate is called on each Update frame between OnStateEnter and OnStateExit callbacks
//由于PlayController中每次点击鼠标移动都会让agent.isStooped变回false,所以需要实时保持为true
override public void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
animator.GetComponent<NavMeshAgent>().isStopped = true;
}
// OnStateExit is called when a transition ends and the state machine finishes evaluating this state
override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
animator.GetComponent<NavMeshAgent>().isStopped =false;
}
}
同时敌人死亡时的状态中原本用于停止功能的agent=false也可以取消,改为将Agent范围缩小,这样既能避免死亡敌人阻挡移动,也可以防止由于关闭agent导致StopAgent脚本中对NavMeshAgent的获取报错。
(五)使用扩展方法实现在敌人身后不会被攻击
一般来说,当玩家绕到敌人背后时,攻击方法应该不会造成生命值减少,应当判断敌人正面扇区内存在玩家才会产生伤害。
采用扩展方法可以在无法编辑的类中扩展一个所需要的方法,来解决想要在类中实现功能却无法编辑此类的问题。创建类ExtensionMethod,扩展方法类不继承其他类,且为static,使用Vector3.Dot来计算两个规格化向量点积,从而获得角度:
public static class ExtensionMethod
{
private const float dotThreshold = 0.5f;//静态类中不允许变量,需要常量
public static bool IsFacingTarget(this Transform transform,Transform target)//this修饰的参数为被扩展的类
{
var vectorToTarget=target.position-transform.position;
vectorToTarget.Normalize();
float dot=Vector3.Dot(vectorToTarget, transform.forward);
return dot >= dotThreshold;
}
}
使用时,在EnemyController类的Hit方法中增加判断条件,当transform.IsFacingTarget(attackTarget.transform)为true时才使攻击生效。(可以在添加一个判断距离小于攻击范围的条件来进一步限制敌人攻击判定?)