目录
声明
11:Player Attack 实现攻击动画
12:FoundPlayer 找到Player追击
13Enemy Animator设置敌人的动画控制器
14: Patrol Randomly 随机巡逻点
15:Character Stats 人物基本属性和数值
~Scriptable Object进行数值的存储和调用
本教程学习均来自U3D中文课堂麦扣老师
现在点击敌人的时候鼠标会改变了,但是点击这个人物的时侯没有任何变化,原因是敌人挡住了鼠标的射线,射线不能找到敌人的地面,所以角色也不能跑到敌人的面前,接下来我们在代码当中实现这个效果,
在MouseManager当中创建另外一个事件:
public event Action
当我们对敌人点击的时候,那么传递的就是当前敌人这个物体了,更改MouseControl方法:
void MouseControl()//返回鼠标左键点击返回值
{
if(Input.GetMouseButtonDown(0)&&hitInfo.collider != null)
{
if(hitInfo.collider.gameObject.CompareTag("Ground"))
{
OnMouseClicked?.Invoke(hitInfo.point); //当前OnMouseClicked事件如果不为空,将点击到地面上的坐标传回给这个事件(执行所有加入到onMouseClicked的函数方法)
}
}
if(Input.GetMouseButtonDown(0)&&hitInfo.collider != null)
{
if(hitInfo.collider.gameObject.CompareTag("Enemy"))
{
OnEnemyClicked?.Invoke(hitInfo.collider.gameObject); //当前OnEnemyClicked事件如果不为空,将点击到敌人的gameObject传回给这个事件(执行所有加入到OnEnemyClicked的函数方法)
}
}
}
}
回到PlayerController,要在Start里添加另外一个方法,移动到要攻击的目标面前,这次我们先添加事件不写方法,
点击可以直接添加这个方法。
下面我们设定一些变量的参数,方便我们获得这些变量,
将MoveToTarget按照Ctrl+R+R全部重命名
private GameObject attackTarget;
private float lastAttackTime; //攻击冷却计时器
private void EventAttack(GameObject target)
{
if(target != null)
{
attackTarget = target;
}
}
要让角色移动到敌人身边,我们定义一个携程:
IEnumerator MoveToAttackTarget()
{
agent.isStopped = false;
transform.LookAt(attackTarget.transform);//转向我的攻击目标
while(Vector3.Distance(attackTarget.transform.position,transform.position) > 1)
{
agent.destination = attackTarget.transform.position;
yield return null;
}
agent.isStopped = true;
//Attack
if(lastAttackTime < 0)
{
}
}
攻击先做到这里,
private void EventAttack(GameObject target)
{
if(target != null)
{
attackTarget = target;
StartCoroutine(MoveToAttackTarget());//协程:攻击敌人
}
}
IEnumerator MoveToAttackTarget()//协程:攻击敌人
{
agent.isStopped = false;
transform.LookAt(attackTarget.transform);//转向我的攻击目标
//Todo:修改攻击范围参数
while(Vector3.Distance(attackTarget.transform.position,transform.position) > 1)
{
agent.destination = attackTarget.transform.position;
yield return null;
}
agent.isStopped = true;
//Attack
if(lastAttackTime < 0)
{
anim.SetTrigger("Attack");
//重置冷却时间
lastAttackTime = 0.5f;
yield return new WaitForSeconds(0.2f);
anim.ResetTrigger("Attack");
}
}
我们为了可以在攻击过程中能打断攻击动画,需要将点击地面移动的方法中取消协程并且确保可以进行移动:
public void MoveToTarget(Vector3 target) //必须包含参数Vector3,保证函数命名方式定义方式是和onMouseClicked完全一致
{
StopAllCoroutines();//打断攻击
agent.isStopped = false;//可以进行移动
agent.destination = target;
}
使用FreeLook Camera:
添加摄像机:
更改参数和InputAxis:
这样就可以移动的时候改变视角了。
下面更改EnemyController代码让敌人能够发现Player并追击:
更改公有变量ememyStates为 private EnemyStates ememyStates;不需要外部进行选择,只需内部进行更改, 定义一个布尔类型变量来方便后面的确定初始状态public bool isGuard;//判断是否是站桩怪,定义一个变量sightRadius表示可视范围 ,如果发现Player,切换为CHASE,如何在我们的可视范围去寻找我们的Player呢:我们用到一个物理判断:OverlapSphere,就是他周围一个球体范围之内是否有我们想要找的collider
我们用这个案例来推写我们的这个脚本:
[Header("Basic Settings")]
public float sightRadius;//可视范围
void SwitchStates() //切换状态
{
//如果发现Player,切换为CHASE
if(FoundPlayer())
{
ememyStates = EnemyStates.CHASE;
Debug.Log("找到Player");
}
switch(ememyStates)
{
case EnemyStates.GUARD:
break;
case EnemyStates.PATROL:
break;
case EnemyStates.CHASE:
break;
case EnemyStates.DEAD:
break;
}
}
bool FoundPlayer()//在可视范围内寻找Player
{
var colliders = Physics.OverlapSphere(transform.position, sightRadius);
foreach(var target in colliders)
{
if(target.CompareTag("Player"))
{
return true;
}
}
return false;
}
这样敌人就能发现我们的Player了,接下来我就可以做一系列的操作了,切换到CHASE模式之后,我们就可以在CHASE模式的这一部分代码当中去写他的方法了,去追击我们的Player还是进行远程攻击各种各样的判断了
将史莱姆保存为Characters预制体,我们给敌人一个视野的范围,当我们走到敌人视野范围之内,敌人就要追击,这个逻辑需要获得Player的坐标,把Enemy目标设置为我们的Player,那么敌人就能走到我们Player的位置上
来到敌人的代码中, 我们需要获得玩家:private GameObject attackTarget;这就是敌人的攻击目标,在FoundPlayer()方法中,如果找到了Player就把attackTarget赋值为target,如果没有找到,就赋值为null,玩家就脱离视野范围了。
当敌人找到Player就进入追击状态进行追击:
case EnemyStates.CHASE:
//TODO:追Player
//TODO:在攻击范围内则攻击
//TODO:配合动画
if(!FoundPlayer())
{
//TODO:拉托回到上一个状态
}
else
{
agent.destination = attackTarget.transform.position;
}
break;
我们要给它设定一个变量来记录speed,让巡逻怪巡逻速度比较慢,而追击速度比较快
private float speed;//移动速度
Awake中初始化: speed = agent.speed;
CHASE状态中更改Enemy的追击速度: agent.speed = speed;
制作Slime动画控制器:
新建控制器:在BaseLayer中拖入Walk和Idle动画,设定一个bool类型的变量Walk来判断是站桩怪还是巡逻怪
新建一个Layer:Attack Layer:完全覆盖
Attack Layer中创建一个空动画Base State为默认动画,拖入IdleBattle和Run动画,创建bool类型的变量Chase和Follow来确定发现敌人和追击敌人
暂时设定这几个状态,返回代码添加这些变量来配合这些动画:
要控制Animator,要先声明Animator的变量,在Awake当中进行赋值,设定bool值的变量来固定动画的转换:
//bool配合动画
bool isWalk;
bool isChase;
bool isFollow;
动画的判断方法需要在Update当中实时进行同步,
void SwitchAnimation()//切换动画
{
anim.SetBool("Walk",isWalk);
anim.SetBool("Chase",isChase);
anim.SetBool("Follow",isFollow);
}
在Chasea状态中配合动画:
case EnemyStates.CHASE:
//TODO:追Player
//TODO:在攻击范围内则攻击
//TODO:配合动画
isWalk = false;
isChase = true;
agent.speed = speed;
if(!FoundPlayer())
{
//TODO:拉托回到上一个状态
isFollow = false;
}
else
{
isFollow = true;
agent.destination = attackTarget.transform.position;
}
break;
为了让Player脱离敌人的可视范围之后敌人不会有追击延迟,可以在isFollow = false下面加一句
agent.destination = transform.position;
现在就可以简单实现敌人的追击了。
我们要实现敌人的随机移动,所以它可以根据我们给定的一定的范围来进行巡逻,打开敌人的代码,我们要给定一个变量表示巡逻的范围,
[Header("Patrol State")]
public float patrolRange;
那么这个范围和之前可视的范围在我们的unity编辑器当中是无法查看的,这就很影响我们后期来调节我们游戏,我们希望给定的float的范围都可以在窗口当中可以看得见,接下来写一个Gizmos把我们的范围画出来
private void OnDrawGizmosSelected()//在对象被选中时绘制Gizmos
{
Gizmos.color = Color.blue;
Gizmos.DrawWireSphere(transform.position, sightRadius);//画视野范围
}
这样就能画出敌人的视野范围了
接下来希望敌人可以在给定的区域当中选择一个点的坐标,然后一旦当它移动到这个点之后,随机在选择另外一个点的坐标,再让它去移动,明确逻辑之后,来写代码:
既然需要选择一个点,就要把这一个点创建为一个变量 private Vector3 wayPoint;
接下来写一个函数方法来随机获得巡逻范围内的一个点:注意这个点不在空中
void GetNewWayPoint()//随机获得巡逻范围内的一个点
{
float randomX = Random.Range(-patrolRange, patrolRange);
float randomZ = Random.Range(-patrolRange, patrolRange);
Vector3 randomPoint = new Vector3(transform.position.x+randomX, transform.position.y,transform.position.z+randomZ);
//FIXME:可能出现问题
wayPoint = randomPoint;
}
补充巡逻怪的状态:
case EnemyStates.PATROL:
isChase = false;
agent.speed = speed * 0.5f;
if(Vector3.Distance(wayPoint,transform.position) <= agent.stoppingDistance)
{
isWalk = false;
GetNewWayPoint();//随机获得巡逻范围内的一个点
}
else
{
isWalk = true;
agent.destination = wayPoint;
}
break;
Start中确定初始状态:
private void Start()
{
if(isGuard)//判断是否是站桩怪
{
enemyStates = EnemyStates.GUARD;
}
else//巡逻怪
{
enemyStates = EnemyStates.PATROL;
GetNewWayPoint();//得到初始移动的点
}
}
这样就能让巡逻怪正常巡逻了,但是发现巡逻怪巡逻不是按照给定的范围内巡逻,而是在移动的过程中随机选择周围的点进行巡逻,需要修改代码:
需要一开始就拿到敌人的初始坐标: private Vector3 guardPos;//初始坐标
在Awake中: guardPos = transform.position;
修改GetNewWayPoint()方法:
void GetNewWayPoint()//随机获得巡逻范围内的一个点
{
float randomX = Random.Range(-patrolRange, patrolRange);
float randomZ = Random.Range(-patrolRange, patrolRange);
Vector3 randomPoint = new Vector3(guardPos.x+randomX, transform.position.y,guardPos.z+randomZ);
//FIXME:可能出现问题
wayPoint = randomPoint;
}
这样敌人就能在给定的范围内巡逻了,但是还有一个问题:如果把敌人放在头附近,敌人选择的巡逻点在石头里面,没有办法穿过石头的模型,它就会卡在这里,所以我们选择点的时候就要尽量避开不可移动的范围,那么如何判断地面上的某一个点是不可移动的范围呢,这里我们要用到Nav Mesh的一个方法: 在找到的目标点为目标在附近寻找另一个可以移动的最近的点,找到则返回true
更改敌人代码:
void GetNewWayPoint()//随机获得巡逻范围内的一个点
{
float randomX = Random.Range(-patrolRange, patrolRange);
float randomZ = Random.Range(-patrolRange, patrolRange);
Vector3 randomPoint = new Vector3(guardPos.x+randomX, transform.position.y,guardPos.z+randomZ);
NavMeshHit hit;
wayPoint = NavMesh.SamplePosition(randomPoint, out hit, patrolRange, 1) ? hit.position : transform.position;
}
这样就能让敌人进行正常移动巡逻了,
现在希望敌人不是一直走路,而是走到一个点停下来做一段时间的观察,来模拟巡逻怪的状态,
定义2个变量
public float lookAtTime;//观察时间
private float remainLookAtTime;//仍需查看的时间
在Awake中给remainLookAtTime初始化: remainLookAtTime = lookAtTime;
修改巡逻状态代码:
//判断是否到了随机巡逻点
if(Vector3.Distance(wayPoint,transform.position) <= agent.stoppingDistance)
{
isWalk = false;
if(remainLookAtTime > 0)
{
remainLookAtTime -= Time.deltaTime;
}
else
{
GetNewWayPoint();//随机获得巡逻范围内的一个点
}
}
在GetNewWayPoint()方法中在对remainLookAtTimex重新赋值:
remainLookAtTime = lookAtTime;便可实现真实巡逻了
现在开始游戏当玩家接近巡逻的敌人后敌人会进入追击状态,当玩家拉托敌人后敌人不是回去巡逻而是变为站桩状态,下面简单修改一下代码:
case EnemyStates.CHASE:
//TODO:在攻击范围内则攻击
//配合动画
isWalk = false;
isChase = true;
agent.speed = speed;
if(!FoundPlayer())
{
//拉托回到上一个状态
isFollow = false;
if(remainLookAtTime > 0)
{
agent.destination = transform.position;
remainLookAtTime -= Time.deltaTime;
}
else if(isGuard)
{
enemyStates = EnemyStates.GUARD;
}
else
{
enemyStates = EnemyStates.PATROL;
}
}
else
{
isFollow = true;
agent.destination = attackTarget.transform.position;//追Player
}
break;
现在就能拉托敌人让敌人继续回去巡逻了
在Scripts文件夹中创建一个文件夹Character Stats保存人物的状态,在这个文件夹中创建2个子文件夹,一个存储我们的ScriptableObject生成资源文件,一个存储我们的MonoBehaviour挂载我们的人物身上的,在MonoBehaviour文件夹中创建一个脚本CharacterData_SO,方便在其他代码调用它的时候通过它的名就知道这是一个存储数据的文件
打开CharacterData_SO:它是继承于ScriptableObject的,我们会在我们的项目当中创建资源文件,所以一开始我们在上面要写上[CreateAssetMenu()],这个能帮我们在菜单当中去创建一个子集菜单,
[CreateAssetMenu(fileName ="New Data",menuName = "Character Stats/Data")]
表示菜单名称为Character Stats,子集菜单为Data,创建的文件默认名称为New Data。
[CreateAssetMenu(fileName ="New Data",menuName = "Character Stats/Data")]
public class CharacterData_SO : ScriptableObject
{
}
接下来可以填写一些属性:
[CreateAssetMenu(fileName ="New Data",menuName = "Character Stats/Data")]
public class CharacterData_SO : ScriptableObject
{
[Header("Stats Info")]
public int maxHealth;//最大血量
public int currentHealth;//当前血量
public int baseDefence;//基础防御
public int currentDefence;//当前防御
}
攻击力有关的内容会单独创建一个额外的ScriptableObject专门用来存储所有跟攻击有关的,因为攻击包括更具体的一些数值,甚至会根据武器的切换来改变攻击的数值,我们将它们分开来写比较方便。
这时候你就能看见这些数值的变量了:
这样就很方便我们创建这些数值的模板:通过这一个脚本创建多个人物的数值
这些Data并不是MonoBehaviour,无法挂载到我们人物的身上,接下来我们创建一个脚本用来管理我们的Data,并且实现Data的转换、读取和变化
在MonoBehaviour文件夹下创建一个脚本CharacterStats
要读取我们的ScriptableObject,就要一开始创建这个变量:
public CharacterData_SO characterData;
接下来要将数值读到我们的CharacterStats 里面:用到一个新的方法
public class CharacterStats : MonoBehaviour
{
public CharacterData_SO characterData;
#region Read from Data_SO
public int MaxHealth
{
get//读
{
if (characterData != null)
return characterData.maxHealth;
else return 0;
}
set//写
{
characterData.maxHealth = value;//输入进来的值
}
}
public int CurrentHealth
{
get//读
{
if (characterData != null)
return characterData.currentHealth;
else return 0;
}
set//写
{
characterData.currentHealth = value;//输入进来的值
}
}
public int BaseDefence
{
get//读
{
if (characterData != null)
return characterData.baseDefence;
else return 0;
}
set//写
{
characterData.baseDefence = value;//输入进来的值
}
}
public int CurrentDefence
{
get//读
{
if (characterData != null)
return characterData.currentDefence;
else return 0;
}
set//写
{
characterData.currentDefence = value;//输入进来的值
}
}
#endregion
}
现在就可以为Player和Slime添加好函数的方法,并且选择设置好的Data
现在在PlayerController当中创建CharacterStats的变量characterStats
并在Awake中初始化:characterStats = GetComponent
在Start当中更改赋值和编辑 characterStats.MaxHealth = 2;都可以直接读到Data里面的数据,所以如果以后要换数值模板的时候只需要回到Data的数值模板当中进行调整就行了,不需要考虑characterStats代码当中是否有一些其他的数据