减压发泄之妙方:割草——我们用Unity来做做无双类游戏

作者:四五二十
之前有不少童鞋提了一些建议,希望出一些“节奏快的3D游戏教程”。
我首先想到的自然是biubiubiu的FPS。

不过FPS类的样品教程太多了,我想稍微换个口味。
一刀下去一群人升天的无双类割草游戏出现在了我的脑海里。
按惯例,先把效果演示放在这里:

视频展示了游戏的主要功能:
1、敌人AI功能
2、主角的攻击功能
3、技能条的显示
4、新手引导
接下来不废话,挨个实现这些功能。
首先是敌人功能,在这里敌人拥有4种主动状态(红色字体),5种被动状态(黄色字体):
先创建一个敌人类Enemy,暂时空着,再为敌人做了一个动画类EnemyAnima:

先创建一个敌人类Enemy,暂时空着,再为敌人做了一个动画类EnemyAnima:
[HideInInspector]
public AnimatorStateInfo animaState; //动画状态
Animator anim;
[HideInInspector]
public Enemy enemy; //敌人类
void Start()
{
anim = GetComponentInChildren<Animator>();
}
void Update()
{
//获取动画状态
animaState = anim.GetCurrentAnimatorStateInfo(0);
}
然后就是定义一堆播放动画的方法:
public void SwitchAnimaForDis(float dis) //根据距离切换动画状态
{
anim.SetFloat("State", dis);
}
public void PlayAttackAnim(int attNum) //攻击动画,不同参数播放不同攻击动画
{
anim.SetInteger("Attack", attNum);
}
public void PlayHitAnim() //被打动画
{
anim.SetTrigger("Hit");
}
public void PlayHitFlyAnim() //被打飞动画
{
anim.SetTrigger("HitFly");
}
public void PlayFallDownAnim() //倒地动画
{
anim.SetTrigger("FallDown");
Util.Instance.Delay(1, () => anim.SetTrigger("GitUp"));
}
public void PlayGieUpAnim() //起身动画
{
anim.SetTrigger("GitUp");
}
public void PlayDeathAnim() //死亡动画
{
anim.SetTrigger("Death");
}
敌人会根据与玩家距离主动切换动画状态::
待机动画==》移动动画==》戒备动画


在Enemy类中的Update中实时判断与玩家距离:
//判断和玩家距离,//不同距离播放不同动画
float dis = (player.transform.position - transform.position).magnitude;
enemyAnima.SwitchAnimaForDis(dis);
发现玩家后要面向玩家,并移动:
public void LookAtPlayer(Vector3 position) //面向玩家
{
Vector3 pos = new Vector3(position.x, transform.position.y, position.z);
Vector3 dir = pos - transform.position; //玩家方向
transform.rotation = Quaternion.Lerp(transform.rotation, Quaternion.LookRotation(dir), Time.deltaTime * 5);
}
public void Run() //前进
{
transform.Translate(transform.forward * Time.deltaTime * speed, Space.World);
}
进入戒备状态就可以随意攻击了,在攻击方法中可以设置攻击频率,随机采用一种攻击方式:
float attCD = 0;//攻击冷却
void AtWillAttack() //随意攻击
{
attCD += Time.deltaTime;
//每两秒决策一次是否攻击
if (attCD >= 2)
{
attCD = 0;
enemyAnima.PlayAttackAnim(Random.Range(0, 5)); //3/5的攻击概率
Util.Instance.Delay(0.1f, () => enemyAnima.PlayAttackAnim(0));
}
}
使用UI的Slider控件为敌人做血条,并做成预制体,敌人创建时加载,在Canvas下做一个空物体管理加载出的血条:

float maxHp; //满血血量
float hp = 100; //当前血量
Slider hpSlider; //血条
void InitHpSlider() //初始化血条
{
hpSlider = Instantiate(Resources.Load<Slider>("Prefab/HPSlider"), GameObject.Find("EnemyHps").transform);
maxHp = hp;
hpSlider.value = hp / maxHp;
}
根据敌人是否在摄像机视锥范围判断是否隐藏血条:
//进入镜头显示血条,离开则隐藏
bool onCamera; //是否进入摄像机
void OnBecameVisible()
{
onCamera = true;
}
void OnBecameInvisible()
{
onCamera = false;
}
血条跟随敌人移动:
RaycastHit hit;
void HideEnemyHpSlider() //隐藏敌人血条
{
Vector3 pos = transform.position + Vector3.up * 2.5f; //射线点
Physics.Raycast(pos, cameraTH.transform.position - pos, out hit, 100);
//当敌人进入视锥且未被遮挡时显示血条
if (hpSlider != null)
{
if (hit.collider == cameraTH && onCamera)
hpSlider.gameObject.SetActive(true);
else
hpSlider.gameObject.SetActive(false);
hpSlider.transform.position = Camera.main.WorldToScreenPoint(pos); //血条跟随
}
}
敌人被打时会受伤,受伤调用受伤方法,没血就调用死亡方法:
void Damage(float damage) //受伤
{
if (hp > damage)
hp -= damage;
else
{
hp = 0;
Death();
ShowKillCount();
}
if (hpSlider != null)
hpSlider.value = hp / maxHp;
}
void Death() //死亡方法
{
if (!hitFly) //没有被击飞时才播放死亡动画
enemyAnima.PlayDeathAnim();
if (hpSlider != null)
Destroy(hpSlider.gameObject);
}
敌人的主要功能就是以上这些,然后用了一个敌人系统EnemySystem刷新敌人,保证地图上的敌人数量,在敌人脚本中定义一个静态变量记录敌人数量,创建时增加,死亡时减少:
public static int total = 0;
将EnemySystem挂在一个空物体上,把空物体在地图上方,在一定范围内随机刷新敌人:
public class EnemySystem : MonoBehaviour
{
[SerializeField]
private GameObject enemy;
[SerializeField]
private int enemyCount;
void Update()
{
if (Enemy.total < enemyCount)
CreateEnemy();
}
RaycastHit hit;
void CreateEnemy()
{
transform.position = new Vector3(Random.Range(-65, 56), transform.position.y, Random.Range(-65, 71));
if (Physics.Raycast(transform.position, -transform.up, out hit, 100))
{
Debug.DrawRay(transform.position, hit.point - transform.position, Color.red);
if (hit.collider.tag == "Ground")
{
Instantiate(enemy, hit.point, Quaternion.identity);
Enemy.total++;
}
}
}
}
然后我们来看看玩家的功能:

创建一个动画类,挂在动画模型上:
public class PlayerAnima : MonoBehaviour
{
Animator anim;
[HideInInspector]
public AnimatorStateInfo animaState; //动画状态
[SerializeField]
private float attRange; //攻击范围
[SerializeField]
private float angle; //扇形射线角度范围
[SerializeField]
private float attRange1; //攻击范围
[HideInInspector]
public Player player;
[HideInInspector]
public int doubleHit; //连击计数
void Start()
{
anim = GetComponent<Animator>();
}
void Update()
{
//待机动画和跑步动画以外的动画播放完后自动返回待机动画
animaState = anim.GetCurrentAnimatorStateInfo(0);
if (!animaState.IsName("idle") && !animaState.IsName("run") && animaState.normalizedTime > 1.0f)
{
doubleHit = 0;
anim.SetInteger("AttNumber", doubleHit);
player.attackTrail.SetActive(false);
}
//关闭攻击特效
if (!animaState.IsName("attack3"))
player.attack3_1.Stop();
//ShowCheckRange(Color.green, attRange, angle);
//ShowCheckRange(Color.red, attRange1);
}
}
然后定义玩家的动画播放功能,然后在控制类中调用就行了:
public void PlayRunAnima(bool run) //跑动动画
{
anim.SetBool("Run", run);
}
public void PlayHitAnima() //被打动画
{
anim.SetTrigger("Hit");
}
public void PlayDeathAnima() //死亡动画
{
anim.SetTrigger("Death");
}
public void PlayAttackAnima() //连击动画
{
switch (doubleHit)
{
case 0:
anim.SetInteger("AttNumber", ++doubleHit);
break;
case 1:
if (animaState.IsName("attack1") && animaState.normalizedTime > 0.6f && animaState.normalizedTime < 0.9f)
anim.SetInteger("AttNumber", ++doubleHit);
break;
case 2:
if (animaState.IsName("attack2") && animaState.normalizedTime > 0.6f && animaState.normalizedTime < 0.9f)
anim.SetInteger("AttNumber", ++doubleHit);
break;
}
}
public void PlaySkillAnima() //技能动画
{
anim.SetTrigger("Skill");
}
public void PlayBigSkillAnima() //大技能动画
{
anim.SetTrigger("BigSkill");
}
攻击敌人的方法使用了动画帧事件,在攻击动画播放到一定时候发射扇形射线检测一定范围内的敌人,被检测到则会受到攻击。
玩家的状态条使用了三个Slider搭建,在这里分别代表血量和真气以及怒气,真气可以用来释放小技能,通过攻击敌人增加,怒气可以释放大技能,被敌人攻击时增加(读者也可以尝试其他不同的模式):

创建一个玩家数据类对数据进行管理:
public class PlayerData
{
private static PlayerData _Instanc;
public static PlayerData Instanc //单例
{
get
{
if (_Instanc == null)
_Instanc = new PlayerData();
return _Instanc;
}
}
public float maxHp = 100; //最大血量
public float hp = 100; //当前血量
public void SubHp(float _hp, Slider slider) //减血
{
if (hp > _hp)
hp -= _hp;
else
hp = 0;
slider.value = hp / maxHp; //显示到界面
}
public float maxGas = 100; //最大真气
public float gas = 0; //当前真气
public void AddGas(float _gas, Slider slider) //加真气
{
if (gas + _gas < maxGas)
gas += _gas;
else
gas = maxGas;
slider.value = gas / maxGas;
}
public void SubGas(float _gas, Slider slider) //减真气
{
gas -= _gas;
slider.value = gas / maxGas;
}
public float maxAnger = 100; //最大怒气
public float anger = 0; //当前怒气
public void AddAnger(float _anger, Slider slider) //加怒气
{
if (anger + _anger < maxAnger)
anger += _anger;
else
anger = maxAnger;
slider.value = anger / maxAnger;
}
public void SubAnger(Slider slider) //减怒气
{
anger = 0;
slider.value = anger / maxAnger;
}
}
我们采用角色控制器来控制玩家。创建玩家类,先定义一些要用到的属性,并对状态进行初始化:
public class Player : MonoBehaviour
{
[SerializeField]
private Slider hpSlider, gasSlider, angerSlider; //状态条
[SerializeField]
private Animator hpAnima, angerAnima; //状态条动画
[HideInInspector]
public PlayerAnima playerAnima;
[SerializeField]
private float speed;
[SerializeField]
private Transform cameraTh; //摄像机
[SerializeField]
private Transform muzzle; //枪口
[SerializeField]
private GameObject bullet; //子弹
[SerializeField]
private float gasCost; //技能消耗
[SerializeField]
private float commonAttack; //普通攻击力
[SerializeField]
private float heavyAttack; //重击攻击力
[SerializeField]
private float skillAttack; //技能攻击力
[SerializeField]
private float bigSkillAttack; //大技能攻击力
public GameObject runTrail; //移动轨迹
public GameObject attackTrail; //刀光轨迹
public ParticleSystem attack3_1, attack3_2; //攻击特效
public ParticleSystem bigSkill1, bigSkill2, bigSkill3; //大技能特效
[SerializeField]
private GameObject fireImage;
CharacterController cc;
[HideInInspector]
public bool superArmor; //霸体状态
bool skill = true; //是否释放小技能
void Start()
{
playerAnima = GetComponentInChildren<PlayerAnima>();
playerAnima.player = this;
cc = GetComponent<CharacterController>();
InitState();
}
void InitState() //初始化状态界面
{
hpSlider.value = PlayerData.Instanc.hp / PlayerData.Instanc.maxHp;
gasSlider.value = PlayerData.Instanc.gas / PlayerData.Instanc.maxGas;
angerSlider.value = PlayerData.Instanc.anger / PlayerData.Instanc.maxAnger;
}
}
定义移动方法,用一个摄像机跟随玩家移动旋转,移动的前方始终为摄像机指向方向:

float v = 0;
void Run(float x, float z) //移动
{
cc.Move(Physics.gravity * Time.deltaTime);
//播放跑步动画
playerAnima.PlayRunAnima(x != 0 || z != 0);
//摄像机指向方向,进行归一化消除加速度
Vector3 dir = cameraTh.forward * z + cameraTh.right * x;
//重力
// dir.y -= Time.deltaTime;
//只有在跑步动画时才能移动,显示移动轨迹
if (playerAnima.animaState.IsName("run"))
{
runTrail.SetActive(true);
cc.Move(dir.normalized * speed * Time.deltaTime);
//使用transform进行移动时使用
//前进方向为摄像机指向方向(摄像机本地方向转世界)
//Vector3 runDir = transform.InverseTransformDirection(dir);
//transform.Translate(runDir * Time.deltaTime * speed);
}
else
runTrail.SetActive(false);
//面向移动方向
if (dir != Vector3.zero && !superArmor)
transform.rotation = Quaternion.Lerp(transform.rotation, Quaternion.LookRotation(dir), Time.deltaTime * 10);
}
当我们在攻击敌人时,使用射线检测敌人,可以自行调整检测范围:

为了体现出爽快感,攻击效果需要有普通攻击和击飞两种。
这两种都需要射线检测,所以定义一个射线检测方法,具体的效果可以用委托定义:
//扇形射线检测敌人
public void _RayCheckEnemy(Action<Collider> action, float _attRange, float _angle = 360)
{
//根据半径(攻击长度)获取周长,如果发射角度<360则获取弧长
float length = _attRange * 2 * Mathf.PI / (360 / _angle);
//长度除以检测物体的碰撞器直径得到所需射线数(这里物体宽度为1,所以不用再除)
int rayCount = (int)length;
float space = _angle / rayCount; //间隔角度
List<Collider> enemys = new List<Collider>();
//从右往左逆时针发射射线(扇形射线增加一根射线)
for (int i = 0; i < rayCount + Convert.ToInt32(_angle != 360); i++)
{
Vector3 dir = Quaternion.AngleAxis(_angle / 2 - space * i, Vector3.up) * transform.forward;
RaycastHit[] hit = Physics.RaycastAll(transform.position + Vector3.up, dir, _attRange, LayerMask.GetMask("Enemy"));
foreach (var item in hit)
{
if (!enemys.Contains(item.collider))
{
enemys.Add(item.collider);
action(item.collider); //具体攻击效果
}
}
}
}
定义具体攻击效果和击飞效果:
public void AttackEnemy(Collider item) //攻击敌人(回调函数)
{
//大技能造成更多伤害
float damage = superArmor ? bigSkillAttack : commonAttack;
item.GetComponent<Enemy>().Hit(damage);
PlayerData.Instanc.AddGas(damage / 50, gasSlider);//增加真气
}
public void HitFlyEnemy(Collider item) //击飞敌人(回调函数)
{
//大技能的击飞力度不同于普通击飞,伤害也不同
float force = superArmor ? 15 : 10;
float damage = superArmor ? bigSkillAttack : heavyAttack;
item.GetComponent<Enemy>().HitFly(transform, force, damage);
PlayerData.Instanc.AddGas(damage / 100, gasSlider);
}
在玩家释放小技能时,会发出一个能量球,能量球落地后也可以通过射线检测周围敌人,然后击飞:
//加载特效,2s后销毁
GameObject effect = Resources.Load<GameObject>("Prefab/Effect/Boom");
effect = Instantiate(effect, transform.position, effect.transform.rotation);
Destroy(effect, 2);
Collider[] collids = Physics.OverlapSphere(transform.position, 3, LayerMask.GetMask("Enemy"));
for (int i = 0; i < collids.Length; i++)
{
collids[i].GetComponent<Enemy>().HitFly(transform, 5, attack);
}
而大技能功能则可以自行定义表现方式。

结语
其实从上面的内容可以看出来:基于现有通用引擎的功能,可以很容易搭建出这类3D动作游戏的基本功能框架。当然,一些进阶的系统(如防反等)需要额外单独设计和实现,不过本文对于“想要快速打起来”这样一个目的来说,大概是已经足够了。
感兴趣的读者可以在此基础上进一步演绎和发挥。
以下是工程链接,欢迎查阅:https://github.com/wushupei/GeCao

有意向参与线下游戏开发学习的童鞋,欢迎访问:http://www.levelpp.com/
皮皮关的游戏开发QQ群也欢迎各位强势插入:869551769