先对之前的功能做一些优化
在测试中,我发现,当攀爬到顶的动作中,再次检测到墙壁会导致上墙BUG,卡到墙体里,例如以下场景,由于两面墙的墙面距离差距较小,就会导致以上BUG
因此每次攀登动作完成后的再次检测上墙需要进行约束,不能在攀登动作未完成时就上墙,思路比较简单,只需要在墙体检测最开头加入以下代码即可
if (ani.GetCurrentAnimatorStateInfo(1).shortNameHash == GameConst.CLIMBTOUP_STATE) return;
之前忘记做攀爬到地面的检测了,导致如果一直向下攀爬会穿越地面的BUG,这里修复一下,再攀爬脚本里增加一个方法,并在Update
里调用,放在攀爬到墙顶检测方法的后面;
///
/// 攀爬到地面检测
///
public void ClimbDownToLand()
{
if (inputDelta.y > 0) return;//如果是向上移动则退出,向下移动才进入判断
Vector3 origin = roleTransform.position + GameConst.GROUND_CHECK_ORIGION_OFFSET * Vector3.up;
RaycastHit hit;
if (Physics.Raycast(origin, -roleTransform.up, out hit, climbDownToLand, environmentLayerMask))
{
float distance = Vector3.Distance(roleTransform.position,hit.point);
float angle = Vector3.Angle(hit.normal, upAxis);
if (angle > 60) return;
if (distance < 0.2f)
{
ExitClimb();
}
}
}
首先判断是否为向下移动,如果是才进行攀爬到墙底检测,通过向人物的下方进行射线检测,并计算检测点和人物的距离以及检测面向量与世界Y轴的角度,如果这个角度大于60度,说明检测到的不是地面而是弯曲的墙壁,不退出攀爬,如果角度小于60度,那么则判断距离是否足够小以接近地面,如果是,则退出攀爬,注意这里的射线检测方向应当是朝着以人物坐标系的负Y轴方向,而非世界坐标的负Y轴方向
之前的移动还存在bug,例如遇到台阶需要通过跳跃才能越过,而不能直接越过,因此需要加入台阶修复
///
/// 台阶跨越修正
///
private void StepProcess()
{
Vector3 origin = roleTranform.position + GameConst.STEP_CHECK_OFFSET * Vector3.up;
RaycastHit hit;
if(Physics.Raycast(origin, moveDirection, out hit, stepCheckLength, environmentLayerMask))
{
Vector3 upNormal = Quaternion.AngleAxis(90,roleTranform.right)*hit.normal;
Vector3 raydir = Quaternion.AngleAxis(45,roleTranform.right) * roleTranform.forward;
RaycastHit stepHit;
Physics.Raycast(hit.point + upNormal * stepFixedhHeight, raydir, out stepHit, environmentLayerMask);
if (Vector3.Angle(hit.normal, stepHit.normal) < 5f)//如果台阶太高,两次检测的都是一个墙面那么就直接返回
return;
Vector3 targetPos = new Vector3(hit.point.x, stepHit.point.y, hit.point.z);
roleTranform.position = Vector3.Lerp(roleTranform.position, targetPos, stepFixedSpeed);
}
}
从脚底射出一条朝向移动方向的射线,检测台阶,如果检测到台阶,该检测点作为旧检测点,再在检测点往上移动设定的能够跨越的台阶距离作为新的射线起点,在新的起点斜向下再次发出射线,检测台阶,这次检测到的点作为新检测点,如果检测到的新检测点的法线和旧检测点法线夹角几乎相同,说明台阶过高无法跨越;否则,则为可跨越台阶,新建一个坐标点,使用旧检测点的x
,z
,新检测点的y
坐标,即可获得从角色当前位置提高台阶高度的新位置,将角色的位置lerp
到这个位置即可
当玩家进入潜行状态时,敌人无法通过听觉察觉到玩家,逻辑比较简单,动画部分阅者自行完成,这里只展示添加了潜行逻辑的新增部分代码,但按住C键时,进入潜行。
if (!ani.GetBool(GameConst.ISWALK_PARAM)&&!ani.GetBool(GameConst.ISSNEAK_PARAM))
{
rigidbody.velocity = Vector3.ProjectOnPlane(moveDirection,upAxis) * runSpeed * Time.fixedDeltaTime+Vector3.up*rigidbody.velocity.y;
ani.SetFloat(GameConst.SPEED_PARAM, 1f);
IsMove = true;
}
else if(!ani.GetBool(GameConst.ISSNEAK_PARAM))
{
rigidbody.velocity = Vector3.ProjectOnPlane(moveDirection, upAxis) * walkSpeed * Time.fixedDeltaTime + Vector3.up * rigidbody.velocity.y;
ani.SetFloat(GameConst.SPEED_PARAM, 1f);
IsMove = true;
}
else
{
rigidbody.velocity = Vector3.ProjectOnPlane(moveDirection, upAxis) * sneakSpeed * Time.fixedDeltaTime + Vector3.up * rigidbody.velocity.y;
ani.SetFloat(GameConst.SPEED_PARAM, 1f);
IsMove = true;
}
if (Input.GetButton(GameConst.SNEAK_BUTTON))
{
ani.SetBool(GameConst.ISSNEAK_PARAM, true);
}
else
{
ani.SetBool(GameConst.ISSNEAK_PARAM, false);
}
普通属性:基础生命值上限;基础攻击力;基础防御力;基础体力值;
进阶属性:暴击率;暴击伤害;冷却缩减;护盾强效;额外伤害加成;伤害减免;魔法抗性;物理抗性;治疗加成;
韧性值;韧性削减力量;霸体值;
这里主要说明一下韧性与霸体值,角色和敌人都拥有随着时间不断恢复的韧性值,当角色或敌人受到攻击时,韧性值会减少,减少的量遵从以下计算公式:韧性削减值 = 韧性削减力量*(1-霸体值)
霸体值为0-1
,所有单位本身的基础霸体值均为0
,也就是说在受到攻击的时候会减少相当于100%
的韧性削减力量的韧性值,霸体值为1
时,受到攻击时韧性不会削减;
当角色韧性值小于0时,此时再受到攻击,则会造成硬直,在硬直期间播放受伤动画,无法攻击,移动,跳跃,攀爬,如果在墙上则会立刻坠落,以其其他任何动作;
普通属性:基础攻击力;额外生命值上限加成;额外攻击力加成;额外防御力加成;
进阶属性:暴击率;暴击伤害;护盾强效;额外伤害加成;治疗加成;韧性削减力量;
普通属性:固定生命值上限加成;额外生命值上限加成;固定攻击力加成;额外攻击力加成;固定防御力加成;额外防御力加成;固定体力值加成;
进阶属性:暴击率;暴击伤害;冷却缩减;护盾强效;额外伤害加成;伤害减免;魔法抗性;物理抗性;治疗加成;固定韧性值加成;韧性削减力量;霸体值;
生命值 = 角色基础生命值上限*(1+武器额外生命值上限加成+装备额外生命值上限加成)+装备固定生命值上限加成
攻击力 = (角色基础攻击力+武器基础攻击力)*(1+武器额外攻击力加成+装备额外攻击力加成)+装备固定攻击力加成
防御力 = 角色基础防御力*(1+武器额外防御力加成+装备额外防御力加成)+装备固定防御力加成
体力值 = 角色基础体力值+装备固定体力值加成
暴击率 = 角色暴击率+武器暴击率+装备暴击率
暴击伤害 = 角色暴击伤害+武器暴击伤害+装备暴击伤害
冷却缩减 = 角色冷却缩减+装备冷却缩减
护盾强效 = 角色护盾强效+武器护盾强效+装备护盾强效
额外伤害加成 = 角色额外伤害加成+武器额外伤害加成+装备额外伤害加成
伤害减免 = 角色伤害减免+装备伤害减免
魔法抗性 = 角色魔法抗性+装备魔法抗性
物理抗性 = 角色物理抗性+装备物理抗性
治疗加成 = 角色治疗加成+武器治疗加成+装备治疗加成
韧性值 = 角色韧性值 + 装备韧性值
韧性削减力量 = 角色韧性削减力量+武器韧性削减力量+装备韧性削减力量
霸体值 = 角色霸体+装备霸体值
普通属性:额外攻击力加成;额外防御力加成;
进阶属性:暴击率;暴击伤害;冷却缩减;护盾强效;伤害加成;伤害减免;魔法抗性;物理抗性;治疗加成;韧性削减力量;霸体值
普通属性:攻击弱化;防御弱化
进阶属性:护盾弱化;伤害减免弱化;魔法抗性弱化;物理抗性弱化;治疗加成弱化;韧性削减力量弱化;霸体弱化
角色叠加BUFF&DEBUFF后的对应面板属性——A
A = 角色面板攻击力+(角色基础攻击力+武器基础攻击力)*(BUFF攻击力加成-DEBUFF攻击弱化)
A = 角色面板防御力+(角色基础防御力)*(BUFF防御力加成-DEBUFF防御弱化)
A = 角色面板暴击率+BUFF暴击率
A = 角色面板暴击伤害+BUFF暴击伤害
A = 角色面板冷却缩减+BUFF冷却缩减
A = 角色面板护盾强效+BUFF护盾强效-DEBUFF护盾弱化
A = 角色面板伤害加成+BUFF伤害加成
A = 角色面板伤害减免+BUFF伤害减免-DEBUFF伤害减免弱化
A = 角色面板魔法抗性+BUFF魔法抗性-DEBUFF魔法抗性弱化
A = 角色面板物理抗性+BUFF物理抗性-DEBUFF物理抗性弱化
A = 角色面板治疗加成+BUFF治疗加成-DEBUFF治疗弱化
A = 角色面板韧性削减力量+BUFF韧性削减力量-DEBUFF韧性削减力量弱化
A = 角色面板霸体值+BUFF霸体值-DEBUFF霸体弱化
注意:霸体值经过BUFF与DEBUFF的叠加,仍然不会越过0-1
的区间范围,如果叠加数值过大,则固定在1,反之同理;
角色造成的实际伤害根据伤害类型(物理伤害,魔法伤害,真实伤害)
以下属性均为已经叠加BUFF后的面板属性,不再赘述
依据一下公式:
物理伤害:造成的伤害=攻击力*暴击伤害(如果没有暴击则没有这部分乘区)*(1+额外伤害加成)*(1-敌人伤害减免)*(1-敌人物理抗性)*(角色防御力/(敌人防御力+角色防御力))*本次招式倍率
魔法伤害,同理
真实伤害,则无视伤害减免,抗性,防御力这三个乘区,注意真实伤害会忽略护盾值直接对本体造成伤害
角色如果此时拥有护盾值,则优先扣除护盾值,当扣除护盾值时,会根据以下公式扣除:
扣除的护盾值=造成的伤害*(1-护盾强效)
注意:如果护盾强效为负值,则造成的伤害会被放大,护盾强效大于100%,那么造成的伤害会被吸收转化为护盾值
因此在强度控制中,要注意护盾强效的值的控制
如果受到伤害造成护盾值归零,则溢出的伤害会被直接抵消;
如果受到伤害护盾值已经为0,则扣除角色当前生命值,注意,这里不是面板生命值,而是另外一个变量存储当前生命值
并且,如果扣除的是护盾值,则不会造成韧性值削减;
韧性削减公式在上文中已经说过,不在赘述;
这里需要配合动画帧事件,在对应的动画攻击时机添加Hit
事件
通过脚本调用,在动画播放到那一刻时,会调用Hit
方法,思路就是检测武器伤害判定范围内所有敌人碰撞体,并对其造成伤害。
public void Hit(int index)
{
ani.speed = 0;
Collider[] enemies = Physics.OverlapBox(weapon.transform.position,new Vector3(0.21f,0.37f,1.4f),weapon.transform.rotation,enemyLayerMask);
for(int i = 0; i < enemies.Length; i++)
{
float damage;
Enemy enemy = enemies[i].GetComponent();
RoleSynthesizedAttribute enemyAttr = enemy.addBuff_Attribute;
switch (weapon.damageType)
{
case DamageType.物理伤害:
damage = DamageCalculations(enemyAttr) * (1 - enemyAttr.physicalResistance) * commomHit[index];
enemy.Damage(damage, roleAttribute.attackToughness);
break;
case DamageType.魔法伤害:
damage = DamageCalculations(enemyAttr) * (1 - enemyAttr.magicResistance) * commomHit[index];
enemy.Damage(damage, roleAttribute.attackToughness);
break;
case DamageType.真实伤害:
damage = DamageCalculations(enemyAttr, true) * commomHit[index];
enemy.Damage(damage, roleAttribute.attackToughness, true);
break;
default:
damage = 0;
break;
}
//Debug.Log(damage);
}
}
角色状态脚本需要有受伤方法Damage
,敌人也同理
当角色被调用受伤方法时,首先判断当前动画是否在播放有无敌帧设定的动画,如果有,那么直接返回不造成伤害;
再判断当前角色生命值是否已经小于等于0
,如果是,则直接返回,防止角色已经死亡而继续造成伤害;
判断当前护盾值是否大于0并且不为真实伤害,如果是,那么按照之前讲过的逻辑扣除护盾值;
否则,按照之前的讲过的逻辑扣除当前生命值,并扣除韧性值,如果韧性值归零,则播放受击动画;
如果生命值归零则执行死亡方法,代码如下
public void Damage(float damage, float attackToughness,bool real=false)
{
if (ani.GetCurrentAnimatorStateInfo(2).shortNameHash == GameConst.ROLL_STATE) return;//翻滚无敌帧
if (health <= 0) return;
if (shieldValue > 0 && !real)
{
shieldValue -= damage * (1 - addBuff_Attribute.shieldPotent);
if (shieldValue <= 0)
{
shieldValue = 0;
}
}
else
{
health -= damage;
toughness -= attackToughness * Mathf.Clamp((1 - addBuff_Attribute.damValue), 0, 1);
if (toughness <= 0)
{
ani.CrossFade(GameConst.GETHIT_STATE, 0f);
}
}
if (health <= 0)
{
die = StartCoroutine(Death());
}
}
特别说明:敌人和玩家的攻击与受击逻辑是一样的,敌人没有翻滚设定,所以在敌人的受击逻辑里把翻滚无敌帧那行去掉即可
敌人拥有视觉和听觉,当玩家处于潜行状态时,敌人只能通过视觉发现玩家;一旦某个敌人发现玩家,那么一定范围内的所有敌人都会发现玩家;当敌人发现玩家后,如果没有进入可攻击范围,则会逐步靠近玩家,但不会径直走向玩家,会左右或者后退前进移动试探,当玩家进入可攻击范围时,敌人会攻击,如果攻击落空,那么不施展接下来的连招,如果攻击到了,那么施展接下来的连招;如果玩家走出敌人的攻击状态检测范围,那么敌人会进入追击状态,注意,敌人拥有耐心值,如果追击过程中追击超过一定时间,则不再追击返回原位;
另外说一下听觉,如果敌人通过听觉察觉到玩家,不会立刻发现玩家,而是单人前往目标点查看,如果没有玩家则返回,如果视觉看到玩家,则发现玩家;
如果敌人被打出硬直,则无法进行任何动作,这一点和玩家相同;
如果玩家攀爬墙壁,导致敌人无法攻击到,那么则直接放弃追击回到原位
感官脚本有一个变量:findPlayer
用于标记是否发现玩家,当敌人通过视觉发现玩家则将其标记为true
还有一个Vector3
变量:checkPos
,当敌人通过听觉察觉玩家时,会将听到的最后位置赋值给改变量
视听的原理比较简单,这里不做赘述,听觉中如果玩家处于潜行状态则直接范围,这里直接上代码
public void Sighting()
{
Vector3 dir = roleTransform.position - transform.position;
float angle = Vector3.Angle(dir,transform.forward);
if (angle > sightAngle / 2) return;
RaycastHit hit;
if (Physics.Raycast(transform.position + Vector3.up * GameConst.ENEMY_EYE_OFFSET, dir, out hit, sphereCollider.radius))
{
if (hit.collider.CompareTag(GameConst.PLAYER))
{
findPlayer = true;
}
}
}
public void Hearing()
{
if (roleAni.GetCurrentAnimatorStateInfo(1).shortNameHash == GameConst.SNEAK_STATE) return;
float distance = Vector3.Distance(roleTransform.position, transform.position);
if (distance < hearDistance)
{
chechPos = roleTransform.position;
}
}
之前说过,当敌人发现玩家,不会径直走向玩家,而是会不断左右前后拉扯距离,当距离小于攻击范围时,则进行攻击
这里使用协程,每隔一段时间就随机改变拉扯方向,并通过attackDesire
这个值控制敌人的攻击欲望,如果随机到的值小于攻击欲望,返回true
,如果大于攻击欲望,返回false
协程根据返回值,如果为true
则想玩家方向移动,否则随机移动,也就是随机改变拉扯方向
并通过变量canStrafe
来判断当前是否能够进行拉扯
public bool ForwardRate()
{
if (Random.Range(0,100) < attackDesire)//说明当前想攻击就前进
{
return true;
}
else//当前想拉扯,不前进,随机
{
return false;
}
}
IEnumerator Strafe()
{
while (true)
{
if (ForwardRate())
{
randomDelta = new Vector2(0, 1);
}
else
{
randomDelta = new Vector2(Random.Range(-1, 1), Random.Range(-1, 1));
}
ani.SetFloat(GameConst.HOR_PARAM_ENEMY, randomDelta.x);
ani.SetFloat(GameConst.VER_PARAM_ENEMY, randomDelta.y);
yield return new WaitForSeconds(strafeInterval);
}
}
通过canMove
变量表示当前能否移动/追击,逻辑十分简单,直接上代码,通过根运动控制
private void Update()
{
if (!canMove)
{
ani.SetBool(GameConst.ISRUN_PARAM_ENEMY, false);
return;
}
if (nav.isStopped)
{
ani.SetBool(GameConst.ISRUN_PARAM_ENEMY,false);
}
else
{
ani.SetBool(GameConst.ISRUN_PARAM_ENEMY, true);
}
}
通过canAttack变量控制当前能否攻击,通过根运动控制
public void Update()
{
if (!canAttack)
{
ani.SetBool(GameConst.ISATTACK_PARAM_ENEMY,canAttack);
return;
}
else
{
ani.SetBool(GameConst.ISATTACK_PARAM_ENEMY, canAttack);
}
}
首先需要有一个方法检测敌人是否通过听觉察觉到玩家,那么就要检测感官脚本中的checkPos是否发生改变
private bool HearPos()
{
if (preCheckPos != enemySense.chechPos)
{
preCheckPos = enemySense.chechPos;
return true;
}
return false;
}
由于玩家可能会通过攀爬等行为到达敌人无法通过导航到达的位置,因此需要一个变量canReach
来表示本次导航能否到达目的地;
在Update中进行判断
首先使用布尔变量接受听觉检测
hearing = HearPos();
其次判断当前canReach&findPlayer
(findPlayer
在感官脚本中)
如果是,则进入下一步判断,首先将导航启用,设置目的地为角色位置,并判断能否到达目的地,如果不能则执行放弃方法,如果能,那么计算敌人与玩家的距离,并根据距离做出:拉扯/攻击/追击 的不同应对策略。在进行拉扯与攻击时,需要将导航关闭。
如果没有发现玩家,并且canReach
为true
,那么首先判断hearing
,如果听觉检测位置变化,则开启导航,并设置导航目的地为玩家位置,否则检查敌人是否已经到达导航位置,如果是,则判断当前位置是否为原位,如果是,则返回;如果不是,则表示当前为通过听觉到达了玩家最后发出声音的位置,通过计时器延迟几秒,在返回原位
如果canReach
为false
表示无法到达,则即可返回原位,同样判断hearing
另外,如果敌人处于追击状态,则会不断消耗耐心值,归零则返回原位;回到原位时,耐心值才会回升
总之,逻辑比较复杂,一两句话不能说清楚,需要阅者自己加以思考,这里提供参考代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class EnemyAI : MonoBehaviour
{
[Header("攻击距离")]
public float attackDistance;
[Header("耐心值")]
public float patienceValue;
[Header("耐心变化速率")]
public float patienceSpeed;
[Header("旋转速度")]
public float rotateToPlayerSpeed;
[Header("视察停滞时间")]
public float waitTime;
public Vector3 originPos;
private Vector3 preCheckPos;
private NavMeshAgent nav;
private EnemyStrafe enemyStrafe;
private EnemyAttack enemyAttack;
private EnemySense enemySense;
private EnemyMove enemyMove;
private Transform roleTransform;
private SphereCollider sphereCollider;
//private Rigidbody rigidbody;
public bool canReach;
private bool hearing;
public float waitCounter;
private void Awake()
{
sphereCollider = GetComponent();
roleTransform = GameObject.FindGameObjectWithTag(GameConst.PLAYER).transform;
nav = GetComponent();
enemyStrafe = GetComponent();
enemyAttack = GetComponent();
enemySense = GetComponent();
enemyMove = GetComponent();
preCheckPos = enemySense.chechPos;
patienceValue = 100;
//rigidbody = GetComponent();
}
private void Start()
{
originPos = transform.position;
canReach = true;
waitCounter = waitTime;
}
///
/// 通过听觉检测玩家位置
///
///
private bool HearPos()
{
if (preCheckPos != enemySense.chechPos)
{
preCheckPos = enemySense.chechPos;
return true;
}
return false;
}
private void Update()
{
hearing = HearPos();
if (canReach && enemySense.findPlayer)
{
nav.isStopped = false;
nav.SetDestination(roleTransform.position);
if (nav.pathStatus == NavMeshPathStatus.PathPartial)
{
GiveUpChase(false);
return;
}
float distance = Vector3.Distance(transform.position, roleTransform.position);
if (distance < attackDistance)
{
Attack();
} else if (distance < sphereCollider.radius)
{
Strafe();
}
else
{
Chase();
}
}
else if (!enemySense.findPlayer && canReach)
{
if (hearing)
{
CheckPos();
return;
}
if (Mathf.Abs(nav.remainingDistance - nav.stoppingDistance) < 0.1f)
{
if (Vector3.Distance(transform.position, originPos) < 0.1f)
{
if(waitCounter 1f)
{
enemyMove.canMove = true;
}
}
}
///
/// 拉扯
///
public void Strafe()
{
nav.isStopped = true;
patienceValue = 100;
enemyMove.canMove = false;
enemyAttack.canAttack = false;
enemyStrafe.canStrafe = true;
RotateToPlayer();
}
///
/// 攻击
///
public void Attack()
{
nav.isStopped = true;
patienceValue = 100;
enemyMove.canMove = false;
enemyAttack.canAttack = true;
enemyStrafe.canStrafe = false;
RotateToPlayer();
}
///
/// 追赶
///
public void Chase()
{
nav.isStopped = false;
enemyMove.canMove = true;
enemyAttack.canAttack = false;
enemyStrafe.canStrafe = false;
patienceValue -= patienceSpeed * Time.deltaTime;
if (patienceValue <= 0)
{
patienceValue = 0;
GiveUpChase();
}
}
///
/// 放弃追赶
///
///
public void GiveUpChase(bool r = true)
{
if (r)
{
enemySense.findPlayer = false;
ReturnPos();
}
else
{
enemyAttack.canAttack = false;
enemyStrafe.canStrafe = false;
canReach = false;
}
}
///
/// 视察位置
///
public void CheckPos()
{
nav.isStopped = false;
nav.SetDestination(roleTransform.position);
if (nav.pathStatus == NavMeshPathStatus.PathComplete)
{
enemyMove.canMove = true;
canReach = true;
}
}
///
/// 返回原位
///
public void ReturnPos()
{
nav.isStopped = false;
nav.SetDestination(originPos);
enemySense.findPlayer = false;
canReach = true;
}
///
/// 转向玩家
///
public void RotateToPlayer()
{
Vector3 relative = roleTransform.position - transform.position;
Vector3 dir = Vector3.ProjectOnPlane(relative,-Physics.gravity.normalized);
transform.rotation = Quaternion.Lerp(transform.rotation,Quaternion.LookRotation(dir),rotateToPlayerSpeed*Time.deltaTime);
}
}
当前战斗系统仍然比较简单,后续会加入更多复杂的功能