欢迎光临散文网 会员登陆 & 注册

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

2019-12-19 21:38 作者:皮皮关做游戏  | 我要投稿

作者:四五二十


之前有不少童鞋提了一些建议,希望出一些“节奏快的3D游戏教程”。

我首先想到的自然是biubiubiu的FPS。

几十年来一直处在争议中的“真实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");

}

 

敌人会根据与玩家距离主动切换动画状态::

待机动画==》移动动画==》戒备动画

距离小于50开始跑向玩家
距离小于3.5准备攻击玩家

在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

减压发泄之妙方:割草——我们用Unity来做做无双类游戏的评论 (共 条)

分享到微博请遵守国家法律